commit 68684df47195ec60197fea4b5f02669696d6721b Author: Ryan Wilson Date: Tue Nov 18 22:21:00 2025 -0800 Initial commit: Spicy CDK automation framework Jenkins shared library and CDK constructs for AWS infrastructure. Co-Authored-By: Claude Opus 4.5 diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..62144da --- /dev/null +++ b/.cursorrules @@ -0,0 +1,108 @@ +# Spicy Automation + +## Project Overview + +AWS CDK infrastructure and Jenkins shared library for deploying VPCs, ECS clusters, and ECS services on AWS. Provides type-safe TypeScript CDK constructs and Jenkins pipeline functions for automated infrastructure deployment with blue/green deployments, mixed capacity strategies (EC2 + Fargate), and persistent ALB patterns. + +## Architecture Principles + +### Language & Stack + +- **TypeScript** for CDK constructs (AWS CDK v2) +- **Groovy** for Jenkins shared library pipelines +- **AWS CDK** for infrastructure as code +- **Jest** for testing +- **pnpm** for package management + +### Code Organization + +```text +spicy-automation/ +├── bin/spicy-cdk.ts # CDK app entry point +├── lib/ +│ ├── constructs/ # Reusable CDK constructs +│ │ ├── spicy-vpc.ts # VPC construct +│ │ ├── spicy-ecs-cluster.ts # ECS cluster construct +│ │ ├── spicy-ecs-service.ts # ECS service construct +│ │ └── spicy-alb.ts # Persistent ALB construct +│ └── stacks/ # CDK stacks (fromContext factories) +│ ├── spicy-vpc-stack.ts +│ ├── spicy-ecs-cluster-stack.ts +│ ├── spicy-ecs-service-stack.ts +│ └── spicy-alb-stack.ts +├── vars/ # Jenkins shared library +│ ├── spicyVPC.groovy # VPC pipeline +│ ├── spicyECSCluster.groovy # ECS cluster pipeline +│ ├── spicyECSService.groovy # ECS service pipeline +│ ├── spicyRollback.groovy # Blue/green rollback +│ ├── cdkUtils.groovy # CDK command utilities +│ ├── dockerUtils.groovy # Docker/Nexus utilities +│ ├── gitUtils.groovy # Git utilities +│ └── accounts.groovy # Account configurations +├── docs/ # Documentation +├── test/ # Jest tests +└── Dockerfile # Jenkins agent image +``` + +**Directory Rules:** + +- `lib/constructs/` - Reusable CDK constructs (no stack logic) +- `lib/stacks/` - Stack factories that read from CDK context +- `vars/` - Jenkins shared library functions (Groovy) +- `docs/` - User-facing documentation (keep minimal, avoid duplication) +- `test/` - Unit tests for constructs + +## Coding Standards + +### TypeScript Conventions + +- Use interfaces for all public APIs +- Document exported functions and types with JSDoc +- Prefer readonly properties where possible +- Use strict TypeScript settings +- One construct per file, named exports only +- Use CDK best practices (overrideLogicalId for stability) + +### Error Handling + +- Validate required parameters early in constructors +- Throw descriptive errors with usage examples +- Use CDK's built-in validation where possible +- Don't swallow errors silently + +### Concurrency + +- CDK constructs are synchronous (no async/await needed) +- Jenkins pipelines handle concurrency at the stage level +- No shared mutable state in constructs + +## Documentation + +- Keep README.md updated +- Code comments for complex logic + +## Minimal Viable Product (MVP) + +1. **Keep it minimal**: Code should be the smallest implementation that works correctly +2. **Avoid over-engineering**: Don't add features "just in case" - add them when needed +3. **Simple solutions first**: Prefer simple, straightforward code over complex abstractions +4. **One thing at a time**: Each function/type should do one thing well + +## When Making Changes + +1. **Write tests first**: Define all expectations and error cases in tests BEFORE any implementation +2. **Mock third-party dependencies**: Use mocks for external libraries, don't test their internals +3. **Implement minimal code**: Write only what's needed to pass the tests +4. **Follow patterns**: Use existing code patterns +5. **Keep it simple**: Prefer simple, readable code over clever solutions + +## Questions to Ask + +If unsure about implementation: + +1. Does it follow existing patterns? +2. Is it testable? +3. Does it maintain thread safety? +4. Is it documented? +5. Does it validate inputs? +6. Does it handle errors properly? diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64d87ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out +OLD_STUFF/ +OLDER_STUFF/ diff --git a/docs/ACCOUNTS.md b/docs/ACCOUNTS.md new file mode 100644 index 0000000..13fc196 --- /dev/null +++ b/docs/ACCOUNTS.md @@ -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. diff --git a/docs/CDK_SYNTH_EXAMPLES.md b/docs/CDK_SYNTH_EXAMPLES.md new file mode 100644 index 0000000..60773e4 --- /dev/null +++ b/docs/CDK_SYNTH_EXAMPLES.md @@ -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 +``` diff --git a/docs/COST_OPTIMIZATION_TESTING.md b/docs/COST_OPTIMIZATION_TESTING.md new file mode 100644 index 0000000..f6de13a --- /dev/null +++ b/docs/COST_OPTIMIZATION_TESTING.md @@ -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)** diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..48eea65 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -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. diff --git a/docs/ECS_CLUSTER.md b/docs/ECS_CLUSTER.md new file mode 100644 index 0000000..adf9845 --- /dev/null +++ b/docs/ECS_CLUSTER.md @@ -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 diff --git a/docs/ECS_SERVICE.md b/docs/ECS_SERVICE.md new file mode 100644 index 0000000..26a2047 --- /dev/null +++ b/docs/ECS_SERVICE.md @@ -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 \ + --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 +``` + +### 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 +``` + +### 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 diff --git a/docs/JENKINSFILE_EXAMPLE_NPM.md b/docs/JENKINSFILE_EXAMPLE_NPM.md new file mode 100644 index 0000000..4ad1e76 --- /dev/null +++ b/docs/JENKINSFILE_EXAMPLE_NPM.md @@ -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 + diff --git a/docs/JENKINS_UTILITIES.md b/docs/JENKINS_UTILITIES.md new file mode 100644 index 0000000..e4eb760 --- /dev/null +++ b/docs/JENKINS_UTILITIES.md @@ -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) diff --git a/docs/PERSISTENT_ALB.md b/docs/PERSISTENT_ALB.md new file mode 100644 index 0000000..2c600d7 --- /dev/null +++ b/docs/PERSISTENT_ALB.md @@ -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 diff --git a/docs/VPC.md b/docs/VPC.md new file mode 100644 index 0000000..fab123c --- /dev/null +++ b/docs/VPC.md @@ -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 +``` diff --git a/resources/.dockerignore b/resources/.dockerignore new file mode 100644 index 0000000..1cc8577 --- /dev/null +++ b/resources/.dockerignore @@ -0,0 +1,30 @@ +# Dependencies +node_modules/ + +# Build output +cdk.out/ +*.js +*.d.ts +!jest.config.js + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Test +coverage/ + +# Git +.git/ +.gitignore + +# Misc +*.log +*.tmp + diff --git a/resources/.npmignore b/resources/.npmignore new file mode 100644 index 0000000..c1d6d45 --- /dev/null +++ b/resources/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/resources/Dockerfile b/resources/Dockerfile new file mode 100644 index 0000000..6b9f498 --- /dev/null +++ b/resources/Dockerfile @@ -0,0 +1,42 @@ +# Spicy CDK Docker Image for Jenkins Runners +# This image contains everything needed to deploy CDK stacks from Jenkins pipelines + +FROM node:24-alpine + +# Update package index and upgrade all system packages to patch vulnerabilities +RUN apk update && apk upgrade + +# Install AWS CLI and other dependencies +RUN apk add \ + python3 \ + py3-pip \ + git \ + bash \ + curl \ + jq \ + && pip3 install --break-system-packages awscli + +# Install pnpm globally +RUN corepack enable && corepack prepare pnpm@latest --activate + +# Install AWS CDK CLI globally +RUN npm install -g aws-cdk + +# Set working directory +WORKDIR /app + +# Copy package files first for better layer caching +COPY package.json pnpm-lock.yaml ./ + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Copy the rest of the application +COPY . . + +# Verify CDK is working +RUN cdk --version + +# Default command shows help +CMD ["cdk", "--help"] + diff --git a/resources/README.md b/resources/README.md new file mode 100644 index 0000000..b80471b --- /dev/null +++ b/resources/README.md @@ -0,0 +1,279 @@ +# Spicy Automation + +AWS CDK infrastructure and Jenkins shared library for Spicy automation. This replaces the legacy Ansible/CloudFormation approach with type-safe TypeScript and integrated Jenkins pipelines. + +## Quick Start + +### Minimal VPC Deployment + +Create a `Jenkinsfile` in your VPC repository: + +```groovy +@Library(["spicy-automation@main"]) _ + +spicyVPC( + jenkinsAwsCredentialsId: "aws-credentials", + region: "ca-central-1", + stackName: "my-vpc", + ownerTag: "MyTeam", + productTag: "myproduct", + componentTag: "vpc", +) +``` + +### Minimal ECS Cluster Deployment - Using CloudFormation Imports + +**Minimal props:** Only `vpcStackName` and `numberOfAzs` required. VPC info auto-imports from VPC stack exports. + +```groovy +@Library(["spicy-automation@main"]) _ + +spicyECSCluster( + jenkinsAwsCredentialsId: "aws-credentials", + region: "ca-central-1", + stackName: "my-ecs-cluster", + vpcStackName: "my-vpc", // Auto-imports VPC ID, CIDR, subnets, and AZs + numberOfAzs: 3, + ownerTag: "MyTeam", + productTag: "myproduct", + componentTag: "ecs-cluster", + environment: "dev" +) +``` + +**Explicit IDs (backward compatible):** + +```groovy +spicyECSCluster( + jenkinsAwsCredentialsId: "aws-credentials", + region: "ca-central-1", + stackName: "my-ecs-cluster", + vpcId: "vpc-12345678", + vpcCidrBlock: "10.0.0.0/16", + availabilityZones: "ca-central-1a,ca-central-1b,ca-central-1c", + privateSubnetIds: "subnet-aaa,subnet-bbb,subnet-ccc", + ownerTag: "MyTeam", + productTag: "myproduct", + componentTag: "ecs-cluster", + environment: "dev" +) +``` + +### ECS Service with Mixed Capacity (EC2 + Fargate Burst) - Minimal Props + +**Minimal props:** Only `clusterName` required. VPC info auto-imports from CloudFormation exports. + +```groovy +@Library(["spicy-automation@main"]) _ + +spicyECSService( + jenkinsAwsCredentialsId: "aws-credentials", + region: "ca-central-1", + stackName: "my-api-dev", + serviceName: "my-api", + clusterName: "my-ecs-cluster", // VPC stack name and VPC ID auto-import from cluster stack exports + image: "nexus.kodeniks.com/docker-hosted/my-api:latest", + containerPort: 3000, + capacityProviderStrategy: [ + [capacityProvider: "my-ecs-cluster-ec2", base: 2, weight: 3], + [capacityProvider: "FARGATE_SPOT", weight: 1], + ], + ownerTag: "MyTeam", + productTag: "myproduct", + componentTag: "api", + environment: "dev" +) +``` + +**Explicit IDs (backward compatible):** + +```groovy +spicyECSService( + // ... other params ... + clusterName: "my-ecs-cluster", + vpcId: "vpc-12345678", + vpcCidrBlock: "10.0.0.0/16", + availabilityZones: "ca-central-1a,ca-central-1b,ca-central-1c", + privateSubnetIds: "subnet-aaa,subnet-bbb,subnet-ccc", +) +``` + +### Blue/Green Deployment with Instant Rollback + +```groovy +@Library(["spicy-automation@main"]) _ + +spicyECSService( + jenkinsAwsCredentialsId: "aws-credentials", + region: "ca-central-1", + stackName: "my-api-prod", + serviceName: "my-api", + clusterName: "my-ecs-cluster-prod", + vpcId: "vpc-12345678", + vpcCidrBlock: "10.0.0.0/16", + availabilityZones: "ca-central-1a,ca-central-1b", + privateSubnetIds: "subnet-aaa,subnet-bbb", + image: "nexus.kodeniks.com/docker-hosted/my-api:latest", + containerPort: 3000, + + // Enable Blue/Green + blueGreen: true, + activeHostname: "api.example.com", + inactiveHostname: "inactive-api.example.com", + + // ALB routing - listener ARN auto-imports from ${clusterName}-internet-facing-https-listener + priority: 100, + useExternalALB: true, + // externalListenerArn auto-imports when useExternalALB=true + + // Test before swap + blueGreenTest: { args, buildInfo -> + sh "curl -f https://${buildInfo.inactiveHostname}/health" + }, + + // Test after swap + smokeTest: { args, buildInfo -> + sh "curl -f https://${buildInfo.activeHostname}/health" + }, + + ownerTag: "Platform", + productTag: "myproduct", + componentTag: "api", + environment: "prod" +) +``` + +### Instant Rollback (30 seconds) + +```groovy +@Library(["spicy-automation@main"]) _ + +spicyRollback( + jenkinsAwsCredentialsId: "aws-credentials", + region: "ca-central-1", + stackName: "my-api-prod", + serviceName: "my-api", + // ... same params as deploy ... +) +``` + +## Documentation + +| Document | Description | +| ------------------------------------------------------ | ---------------------------------------------- | +| [VPC Guide](docs/VPC.md) | Complete VPC deployment guide with all options | +| [ECS Cluster Guide](docs/ECS_CLUSTER.md) | ECS cluster with Spot, Capacity Providers | +| [ECS Service Guide](docs/ECS_SERVICE.md) | ECS service with mixed EC2/Fargate strategy | +| [Persistent ALB](docs/PERSISTENT_ALB.md) | Persistent ALB pattern (1:1 with service) | +| [Cost Optimization](docs/COST_OPTIMIZATION_TESTING.md) | Cost optimization for testing environments | +| [CDK Synth Examples](docs/CDK_SYNTH_EXAMPLES.md) | Complete CDK synth command examples | +| [Jenkins Utilities](docs/JENKINS_UTILITIES.md) | All available Jenkins utility functions | +| [Accounts Configuration](docs/ACCOUNTS.md) | Multi-account setup guide | +| [Local Development](docs/DEVELOPMENT.md) | Running CDK locally | +| [NPM Example](docs/JENKINSFILE_EXAMPLE_NPM.md) | Example Jenkinsfile for NPM publishing | + +## Features + +### Infrastructure (CDK) + +- **SpicyVpc**: Multi-AZ VPC with public/private subnets, NAT gateways, NACLs, VPC endpoints +- **SpicyEcsCluster**: ECS cluster with EC2 Capacity Providers, Mixed Instances Policy for Spot, ALBs +- **SpicyEcsService**: ECS service with mixed capacity strategy, auto-scaling, circuit breaker + +### Blue/Green Deployments + +- **Zero-downtime releases** with hostname-based routing +- **Instant rollback** (~30 seconds) via hostname swap +- **Test before swap** with `blueGreenTest` hook +- **Rollback window** - old version kept for 2+ hours + +### Pipeline Hooks + +- **buildCommand**: Custom build logic +- **onPostBuild**: Unit tests, linting, coverage +- **onPreDeploy**: Setup, test preparation +- **blueGreenTest**: Integration tests on inactive stack +- **onPostDeploy**: Cleanup, notifications +- **smokeTest**: Post-deployment verification + +### Jenkins Pipelines + +- **spicyVPC**: Deploy VPCs +- **spicyECSCluster**: Deploy ECS clusters with Spot support +- **spicyECSService**: Deploy ECS services with EC2 + Fargate burst, blue/green +- **spicyRollback**: Instant rollback for blue/green deployments +- **buildAndPushDockerImage**: Build and push Docker images to Nexus + +## Jenkins Setup + +### 1. Configure Global Library + +In Jenkins → **Manage Jenkins** → **Configure System** → **Global Pipeline Libraries**: + +| Setting | Value | +| ------------------ | ------------------------------------------------ | +| Name | `spicy-automation` | +| Default version | `main` | +| Retrieval method | Modern SCM → Git | +| Project Repository | `git@git.kodeniks.com:CORP/spicy-automation.git` | +| Library Path | `jenkins/` | + +### 2. Configure Credentials + +| Credential ID | Type | Description | +| --------------------------- | ----------------- | --------------------------------- | +| `aws-credentials` | Username/Password | AWS Access Key ID / Secret | +| `kodeniks-gitea-token` | Secret text | Gitea personal access token | +| `kodeniks-nexus-repository` | Username/Password | Nexus Docker registry credentials | + +## Project Structure + +``` +spicy-automation/ +├── bin/spicy-cdk.ts # CDK app entry point +├── lib/ +│ ├── constructs/ # Reusable CDK constructs +│ │ ├── spicy-vpc.ts # VPC construct +│ │ ├── spicy-ecs-cluster.ts # ECS cluster construct +│ │ └── spicy-ecs-service.ts # ECS service construct +│ └── stacks/ # CDK stacks +│ ├── spicy-vpc-stack.ts +│ ├── spicy-ecs-cluster-stack.ts +│ └── spicy-ecs-service-stack.ts +├── vars/ # Jenkins shared library +│ ├── spicyVPC.groovy # VPC pipeline +│ ├── spicyECSCluster.groovy # ECS cluster pipeline +│ ├── spicyECSService.groovy # ECS service pipeline +│ ├── buildAndPushDockerImage.groovy # Docker build pipeline +│ ├── cdkUtils.groovy # CDK commands +│ ├── dockerUtils.groovy # Docker/Nexus utilities +│ ├── gitUtils.groovy # Git utilities +│ ├── giteaUtils.groovy # Commit status updates +│ └── accounts.groovy # Account configurations +├── docs/ # Documentation +├── test/ # Jest tests +└── Dockerfile # Jenkins agent image +``` + +## Useful Commands + +```bash +# No build step required - runs directly with ts-node! +npx cdk synth -c stackType=vpc -c stackName=my-vpc ... + +# Or use pnpm scripts +pnpm run test # Run Jest tests +pnpm run build # Compile TypeScript (optional) +``` + +## Migration from spicy-automation-legacy + +| Feature | spicy-automation-legacy | spicy-automation | +| --------------- | ----------------------- | ---------------- | +| Infrastructure | CloudFormation YAML | TypeScript CDK | +| Provisioning | Ansible | CDK CLI | +| Testing | None | Jest | +| Type Safety | None | Full TypeScript | +| Docker Registry | AWS ECR | Nexus | + +CloudFormation outputs are identical, so existing stacks referencing VPC exports continue to work. diff --git a/resources/bin/spicy-cdk.ts b/resources/bin/spicy-cdk.ts new file mode 100644 index 0000000..a534b62 --- /dev/null +++ b/resources/bin/spicy-cdk.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +import * as cdk from 'aws-cdk-lib/core'; +import { SpicyAlbStack, SpicyEcsClusterStack, SpicyEcsServiceStack, SpicyVpcStack } from '../lib/stacks'; + +const app = new cdk.App(); + +// Get the stack type from context (default to vpc for backwards compatibility) +const stackType = app.node.tryGetContext('stackType'); +const stackName = app.node.tryGetContext('stackName'); + +// Environment configuration from context or environment variables +const env: cdk.Environment = { + account: app.node.tryGetContext('account') ?? process.env.CDK_DEFAULT_ACCOUNT, + region: app.node.tryGetContext('region') ?? process.env.CDK_DEFAULT_REGION, +}; + +// Create stacks based on stackType +// If no stackType is provided, create an empty stack to satisfy CDK's requirement (e.g., for bootstrap) +if (stackType) { + switch (stackType) { + case 'vpc': + // Create VPC stack from context + // Usage: npx cdk deploy -c stackType=vpc -c stackName=spicy-vpc -c ownerTag=SpicyTeam ... + SpicyVpcStack.fromContext(app, stackName ?? 'SpicyVpcStack', { env }); + break; + + case 'ecs-cluster': + // Create ECS Cluster stack from context + // Usage: npx cdk deploy -c stackType=ecs-cluster -c stackName=spicy-ecs -c vpcId=vpc-xxx ... + SpicyEcsClusterStack.fromContext(app, stackName ?? 'SpicyEcsClusterStack', { env }); + break; + + case 'ecs-service': + // Create ECS Service stack from context + // Usage: npx cdk deploy -c stackType=ecs-service -c stackName=my-service -c clusterName=spicy-ecs ... + SpicyEcsServiceStack.fromContext(app, stackName ?? 'SpicyEcsServiceStack', { env }); + break; + + case 'alb': + // Create persistent ALB stack from context + // Usage: npx cdk deploy -c stackType=alb -c stackName=my-alb -c vpcId=vpc-xxx ... + SpicyAlbStack.fromContext(app, stackName ?? 'SpicyAlbStack', { env }); + break; + + default: + console.error(`Unknown stack type: ${stackType}`); + console.error('Valid stack types: vpc, ecs-cluster, ecs-service, alb'); + process.exit(1); + } +} else { + // Create an empty placeholder stack when no stackType is provided + // This allows bootstrap and other CDK commands to work without requiring a stack type + // This stack is never deployed - it's only used to satisfy CDK's requirement for at least one stack + new cdk.Stack(app, 'PlaceholderStack', { + stackName: 'spicy-cdk-placeholder', + env, + }); +} + +app.synth(); diff --git a/resources/cdk.context.json b/resources/cdk.context.json new file mode 100644 index 0000000..c3f254d --- /dev/null +++ b/resources/cdk.context.json @@ -0,0 +1,6 @@ +{ + "cli-telemetry": false, + "acknowledged-issue-numbers": [ + 34892 + ] +} diff --git a/resources/cdk.json b/resources/cdk.json new file mode 100644 index 0000000..f3ef157 --- /dev/null +++ b/resources/cdk.json @@ -0,0 +1,98 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/spicy-cdk.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/core:bootstrapQualifier": "hnb659fds", + "@aws-cdk/core:disableBootstrapVersionCheck": true, + "@aws-cdk/aws-signer:signingProfileNamePassedToCfn": true, + "@aws-cdk/aws-ecs-patterns:secGroupsDisablesImplicitOpenListener": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/core:explicitStackTags": true, + "@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false, + "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true, + "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false, + "@aws-cdk/aws-s3:setUniqueReplicationRoleName": true, + "@aws-cdk/aws-events:requireEventBusPolicySid": true, + "@aws-cdk/core:aspectPrioritiesMutating": true, + "@aws-cdk/aws-dynamodb:retainTableReplica": true, + "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true, + "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true, + "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true, + "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true, + "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true + } +} diff --git a/resources/jest.config.js b/resources/jest.config.js new file mode 100644 index 0000000..08263b8 --- /dev/null +++ b/resources/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/resources/lib/constructs/index.ts b/resources/lib/constructs/index.ts new file mode 100644 index 0000000..f585f83 --- /dev/null +++ b/resources/lib/constructs/index.ts @@ -0,0 +1,5 @@ +// Export all constructs +export * from './spicy-vpc'; +export * from './spicy-ecs-cluster'; +export * from './spicy-ecs-service'; +export * from './spicy-alb'; diff --git a/resources/lib/constructs/spicy-alb.ts b/resources/lib/constructs/spicy-alb.ts new file mode 100644 index 0000000..d0b8ab6 --- /dev/null +++ b/resources/lib/constructs/spicy-alb.ts @@ -0,0 +1,304 @@ +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +import * as route53 from 'aws-cdk-lib/aws-route53'; +import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +/** + * Tags for the ALB + */ +export interface SpicyAlbTags { + owner: string; + product: string; + component: string; + environment: string; + build?: string; +} + +/** + * Blue/Green DNS configuration for persistent ALB + */ +export interface PersistentAlbBlueGreenDnsConfig { + /** Active hostname (e.g., api.example.com) */ + activeHostname: string; + + /** Inactive hostname (e.g., inactive-api.example.com) */ + inactiveHostname: string; + + /** Route53 hosted zone ID */ + hostedZoneId: string; +} + +/** + * Properties for the SpicyAlb construct + */ +export interface SpicyAlbProps { + /** The VPC to deploy the ALB into */ + readonly vpc: ec2.IVpc; + + /** VPC CIDR block (required for internal ALB security group rules) */ + readonly vpcCidrBlock?: string; + + /** ALB scheme: "internet-facing" or "internal" */ + readonly scheme: 'internet-facing' | 'internal'; + + /** Subnets for the ALB (either subnet objects or subnet IDs) */ + readonly subnets: ec2.ISubnet[] | string[]; + + /** Availability zones (required if subnets are provided as IDs) */ + readonly availabilityZones?: string[]; + + /** SSL certificate ARN for HTTPS listener */ + readonly certificateArn?: string; + + /** Idle timeout in seconds */ + readonly idleTimeout?: number; + + /** Additional security groups for the ALB */ + readonly additionalSecurityGroups?: ec2.ISecurityGroup[]; + + /** S3 bucket for ALB access logs */ + readonly logsBucket?: s3.IBucket; + + /** S3 prefix for ALB access logs */ + readonly logsPrefix?: string; + + /** Enable HTTP→HTTPS redirect */ + readonly redirectHttpToHttps?: boolean; + + /** Blue/Green DNS configuration */ + readonly blueGreenDns?: PersistentAlbBlueGreenDnsConfig; + + /** Required tags */ + readonly tags: SpicyAlbTags; +} + +/** + * SpicyAlb - A persistent ALB construct for blue/green deployments. + * + * This ALB is designed to be deployed in a separate, long-lived stack + * (like the old bg-common pattern) so the ALB DNS name stays constant + * even when service stacks are deleted and recreated. + */ +export class SpicyAlb extends Construct { + /** The Application Load Balancer */ + public readonly loadBalancer: elbv2.ApplicationLoadBalancer; + + /** HTTP listener */ + public readonly httpListener: elbv2.ApplicationListener; + + /** HTTPS listener (if certificate provided) */ + public readonly httpsListener?: elbv2.ApplicationListener; + + /** ALB security group */ + public readonly securityGroup: ec2.SecurityGroup; + + /** ALB scheme */ + private readonly scheme: 'internet-facing' | 'internal'; + + constructor(scope: Construct, id: string, props: SpicyAlbProps) { + super(scope, id); + + const stack = cdk.Stack.of(this); + + // Resolve subnets - handle both subnet objects and subnet IDs + let subnets: ec2.ISubnet[]; + if (props.subnets.length > 0 && typeof props.subnets[0] === 'string') { + // Subnet IDs provided - create subnet references + const subnetIds = props.subnets as string[]; + const availabilityZones = props.availabilityZones ?? props.vpc.availabilityZones; + subnets = subnetIds.map((subnetId, index) => + ec2.Subnet.fromSubnetAttributes(this, `ALBSubnet${index}`, { + subnetId, + availabilityZone: availabilityZones[index % availabilityZones.length], + }) + ); + } else { + // Subnet objects provided + subnets = props.subnets as ec2.ISubnet[]; + } + + // ALB Security Group + this.securityGroup = new ec2.SecurityGroup(this, 'ALBSecurityGroup', { + vpc: props.vpc, + description: `Security group for persistent ALB ${stack.stackName}`, + allowAllOutbound: true, + }); + (this.securityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('ALBSecurityGroup'); + + // Add ingress rules based on scheme + if (props.scheme === 'internet-facing') { + this.securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP from internet'); + this.securityGroup.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS from internet'); + } else { + // Internal ALB - allow from VPC + const vpcCidrBlock = props.vpcCidrBlock ?? props.vpc.vpcCidrBlock ?? '10.0.0.0/16'; + this.securityGroup.addIngressRule(ec2.Peer.ipv4(vpcCidrBlock), ec2.Port.tcp(80), 'Allow HTTP from VPC'); + this.securityGroup.addIngressRule(ec2.Peer.ipv4(vpcCidrBlock), ec2.Port.tcp(443), 'Allow HTTPS from VPC'); + } + + // Add additional security groups if provided + const securityGroups = [this.securityGroup, ...(props.additionalSecurityGroups ?? [])]; + + // Store scheme + this.scheme = props.scheme; + + // Create ALB + this.loadBalancer = new elbv2.ApplicationLoadBalancer(this, 'LoadBalancer', { + vpc: props.vpc, + internetFacing: props.scheme === 'internet-facing', + securityGroup: this.securityGroup, + vpcSubnets: { subnets }, + idleTimeout: cdk.Duration.seconds(props.idleTimeout ?? 60), + }); + (this.loadBalancer.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('LoadBalancer'); + + // Enable access logs if bucket provided + if (props.logsBucket) { + const prefix = props.logsPrefix ?? `${stack.stackName}`; + this.loadBalancer.logAccessLogs(props.logsBucket, prefix); + } + + // HTTP Listener + this.httpListener = this.loadBalancer.addListener('HTTPListener', { + port: 80, + protocol: elbv2.ApplicationProtocol.HTTP, + }); + + // HTTPS Listener (if certificate provided) + if (props.certificateArn) { + this.httpsListener = this.loadBalancer.addListener('HTTPSListener', { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + certificates: [elbv2.ListenerCertificate.fromArn(props.certificateArn)], + defaultAction: elbv2.ListenerAction.fixedResponse(404, { + contentType: 'text/plain', + messageBody: 'No target groups configured', + }), + }); + + // HTTP→HTTPS redirect (if enabled, default true) + if (props.redirectHttpToHttps !== false) { + this.httpListener.addAction('RedirectToHTTPS', { + action: elbv2.ListenerAction.redirect({ + protocol: 'HTTPS', + port: '443', + permanent: true, + }), + }); + } + } + + // Blue/Green DNS configuration + if (props.blueGreenDns) { + this.configureBlueGreenDns(props.blueGreenDns); + } + + // Apply tags + this.applyTags(props.tags); + + // Add outputs + this.addOutputs(); + } + + /** + * Configure Blue/Green DNS (active and inactive hostnames) + */ + private configureBlueGreenDns(dns: PersistentAlbBlueGreenDnsConfig): void { + // Extract domain from hostname (e.g., "api.example.com" -> "example.com") + const domainParts = dns.activeHostname.split('.'); + const zoneName = domainParts.slice(-2).join('.'); + const activeRecordName = domainParts.slice(0, -2).join('.') || '@'; + const inactiveRecordName = dns.inactiveHostname.split('.').slice(0, -2).join('.') || '@'; + + const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', { + hostedZoneId: dns.hostedZoneId, + zoneName: zoneName, + }); + + // Active hostname DNS record + new route53.ARecord(this, 'ActiveDnsRecord', { + zone: hostedZone, + recordName: activeRecordName === '@' ? undefined : activeRecordName, + target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(this.loadBalancer)), + }); + + // Inactive hostname DNS record + new route53.ARecord(this, 'InactiveDnsRecord', { + zone: hostedZone, + recordName: inactiveRecordName === '@' ? undefined : inactiveRecordName, + target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(this.loadBalancer)), + }); + } + + /** + * Apply tags to all resources + */ + private applyTags(tags: SpicyAlbTags): void { + const stack = cdk.Stack.of(this); + + cdk.Tags.of(this).add('Name', stack.stackName); + cdk.Tags.of(this).add('Owner', tags.owner); + cdk.Tags.of(this).add('Product', tags.product); + cdk.Tags.of(this).add('Component', tags.component); + cdk.Tags.of(this).add('Environment', tags.environment); + if (tags.build) { + cdk.Tags.of(this).add('Build', tags.build); + } + } + + /** + * Add CloudFormation outputs + */ + private addOutputs(): void { + const stack = cdk.Stack.of(this); + const scheme = this.scheme; + const prefix = scheme === 'internet-facing' ? 'internet-facing' : 'internal'; + + const albDnsOutput = new cdk.CfnOutput(this, 'LoadBalancerDNSOutput', { + value: this.loadBalancer.loadBalancerDnsName, + description: `The DNS name of the ${scheme} load balancer`, + exportName: `${stack.stackName}-${prefix}-url`, + }); + albDnsOutput.overrideLogicalId('LoadBalancerDNS'); + + const albArnOutput = new cdk.CfnOutput(this, 'LoadBalancerArnOutput', { + value: this.loadBalancer.loadBalancerArn, + description: `The ARN of the ${scheme} load balancer`, + exportName: `${stack.stackName}-${prefix}-arn`, + }); + albArnOutput.overrideLogicalId('LoadBalancerArn'); + + const albHostedZoneOutput = new cdk.CfnOutput(this, 'LoadBalancerHostedZoneIdOutput', { + value: this.loadBalancer.loadBalancerCanonicalHostedZoneId, + description: `The hosted zone ID of the ${scheme} load balancer`, + exportName: `${stack.stackName}-${prefix}-hosted-zone-id`, + }); + albHostedZoneOutput.overrideLogicalId('LoadBalancerHostedZoneId'); + + const httpListenerOutput = new cdk.CfnOutput(this, 'HTTPListenerArnOutput', { + value: this.httpListener.listenerArn, + description: `The ARN of the ${scheme} HTTP listener`, + exportName: `${stack.stackName}-${prefix}-http-listener`, + }); + httpListenerOutput.overrideLogicalId('HTTPListenerArn'); + + if (this.httpsListener) { + const httpsListenerOutput = new cdk.CfnOutput(this, 'HTTPSListenerArnOutput', { + value: this.httpsListener.listenerArn, + description: `The ARN of the ${scheme} HTTPS listener`, + exportName: `${stack.stackName}-${prefix}-https-listener`, + }); + httpsListenerOutput.overrideLogicalId('HTTPSListenerArn'); + } + + const securityGroupOutput = new cdk.CfnOutput(this, 'SecurityGroupIdOutput', { + value: this.securityGroup.securityGroupId, + description: `The security group ID of the ${scheme} load balancer`, + exportName: `${stack.stackName}-${prefix}-security-group`, + }); + securityGroupOutput.overrideLogicalId('SecurityGroupId'); + } +} diff --git a/resources/lib/constructs/spicy-ecs-cluster.ts b/resources/lib/constructs/spicy-ecs-cluster.ts new file mode 100644 index 0000000..199f9ec --- /dev/null +++ b/resources/lib/constructs/spicy-ecs-cluster.ts @@ -0,0 +1,970 @@ +import * as autoscaling from 'aws-cdk-lib/aws-autoscaling'; +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as ecs from 'aws-cdk-lib/aws-ecs'; +import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as sns from 'aws-cdk-lib/aws-sns'; +import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions'; +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +/** + * Configuration for VPC tagging + */ +export interface SpicyEcsClusterTags { + owner: string; + product: string; + component: string; + environment: string; + build?: string; +} + +/** + * Scaling configuration for the ECS cluster + */ +export interface ClusterScalingConfig { + /** Minimum number of instances */ + minCapacity?: number; + /** Maximum number of instances */ + maxCapacity?: number; + /** Target capacity utilization percentage for managed scaling (0-100) */ + targetCapacityPercent?: number; + /** Cooldown period in seconds after scale up */ + scaleUpCooldown?: number; + /** Cooldown period in seconds after scale down */ + scaleDownCooldown?: number; +} + +/** + * Spot instance configuration + */ +export interface SpotConfig { + /** Enable Spot instances */ + enabled: boolean; + /** Percentage of On-Demand instances to maintain (0-100) */ + onDemandPercentage?: number; + /** Spot allocation strategy */ + spotAllocationStrategy?: 'lowest-price' | 'capacity-optimized' | 'capacity-optimized-prioritized'; + /** Maximum Spot price (leave undefined for on-demand price) */ + maxSpotPrice?: string; +} + +/** + * Load balancer configuration + */ +export interface LoadBalancerConfig { + /** Create an internet-facing load balancer */ + createExternal?: boolean; + /** Create an internal load balancer */ + createInternal?: boolean; + /** SSL certificate ARN for HTTPS listeners */ + certificateArn?: string; + /** Idle timeout in seconds */ + idleTimeout?: number; + /** Enable access logging */ + enableAccessLogs?: boolean; +} + +/** + * Properties for the SpicyEcsCluster construct + */ +export interface SpicyEcsClusterProps { + /** + * The VPC to deploy the cluster into + */ + readonly vpc: ec2.IVpc; + + /** + * VPC CIDR block (required for imported VPCs) + * @default Uses vpc.vpcCidrBlock if available + */ + readonly vpcCidrBlock?: string; + + /** + * Subnets for EC2 instances (private subnets recommended) + */ + readonly instanceSubnets?: ec2.SubnetSelection; + + /** + * Subnets for external load balancer (public subnets) + */ + readonly externalSubnets?: ec2.SubnetSelection; + + /** + * Subnets for internal load balancer (private subnets) + */ + readonly internalSubnets?: ec2.SubnetSelection; + + /** + * EC2 instance type for the cluster + * @default m5a.large + */ + readonly instanceType?: ec2.InstanceType; + + /** + * Additional instance types for mixed instances policy + * Used with Spot instances for better availability + */ + readonly additionalInstanceTypes?: ec2.InstanceType[]; + + /** + * EC2 Key Pair name for SSH access + */ + readonly keyName?: string; + + /** + * EBS volume size in GB + * @default 100 + */ + readonly ebsVolumeSize?: number; + + /** + * Enable Container Insights + * @default true + */ + readonly containerInsights?: boolean; + + /** + * Scaling configuration + */ + readonly scaling?: ClusterScalingConfig; + + /** + * Spot instance configuration + */ + readonly spot?: SpotConfig; + + /** + * Load balancer configuration + */ + readonly loadBalancer?: LoadBalancerConfig; + + /** + * Additional security groups for EC2 instances + */ + readonly additionalSecurityGroups?: ec2.ISecurityGroup[]; + + /** + * Enable Fargate capacity providers (adds both FARGATE and FARGATE_SPOT) + * @default false + */ + readonly enableFargate?: boolean; + + /** + * Timeout in seconds for draining tasks before termination + * @default 900 + */ + readonly drainingTimeout?: number; + + /** + * Maximum instance lifetime in seconds (for instance refresh) + * @default 604800 (7 days) + */ + readonly maxInstanceLifetime?: number; + + /** + * ELB Account ID for access logs (region-specific) + * See: https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/enable-access-logs.html + */ + readonly elbAccountId?: string; + + /** + * Custom S3 bucket name for ALB logs + */ + readonly logsBucketName?: string; + + /** + * VPC stack name (for exporting to other stacks) + * Used to export the VPC stack name so other stacks can import VPC details + */ + readonly vpcStackName?: string; + + /** + * Required tags for the cluster and resources + */ + readonly tags: SpicyEcsClusterTags; +} + +/** + * ELB Account IDs by region for access logging + */ +const ELB_ACCOUNT_IDS: Record = { + 'us-east-1': '127311923021', + 'us-east-2': '033677994240', + 'us-west-1': '027434742980', + 'us-west-2': '797873946194', + 'ca-central-1': '985666609251', + 'eu-west-1': '156460612806', + 'eu-west-2': '652711504416', + 'eu-west-3': '009996457667', + 'eu-central-1': '054676820928', + 'ap-northeast-1': '582318560864', + 'ap-northeast-2': '600734575887', + 'ap-southeast-1': '114774131450', + 'ap-southeast-2': '783225319266', + 'ap-south-1': '718504428378', + 'sa-east-1': '507241528517', +}; + +/** + * SpicyEcsCluster - A production-ready ECS cluster with: + * - EC2 Capacity Provider with managed scaling + * - Optional Fargate capacity providers + * - Mixed instances policy for Spot support + * - Instance draining on termination + * - Optional internal/external ALBs + * - Container Insights + * - Launch Templates with IMDSv2 and gp3 volumes + */ +export class SpicyEcsCluster extends Construct { + /** The ECS cluster */ + public readonly cluster: ecs.Cluster; + + /** The Auto Scaling Group */ + public readonly autoScalingGroup: autoscaling.AutoScalingGroup; + + /** EC2 Capacity Provider */ + public readonly ec2CapacityProvider: ecs.AsgCapacityProvider; + + /** External (internet-facing) load balancer */ + public readonly externalLoadBalancer?: elbv2.ApplicationLoadBalancer; + + /** Internal load balancer */ + public readonly internalLoadBalancer?: elbv2.ApplicationLoadBalancer; + + /** External HTTPS listener */ + public readonly externalHttpsListener?: elbv2.ApplicationListener; + + /** External HTTP listener */ + public readonly externalHttpListener?: elbv2.ApplicationListener; + + /** Internal HTTPS listener */ + public readonly internalHttpsListener?: elbv2.ApplicationListener; + + /** Internal HTTP listener */ + public readonly internalHttpListener?: elbv2.ApplicationListener; + + /** ECS Host security group */ + public readonly ecsHostSecurityGroup: ec2.SecurityGroup; + + /** Load balancer security group */ + public readonly loadBalancerSecurityGroup?: ec2.SecurityGroup; + + /** S3 bucket for ALB access logs */ + public readonly logsBucket?: s3.Bucket; + + constructor(scope: Construct, id: string, props: SpicyEcsClusterProps) { + super(scope, id); + + const stack = cdk.Stack.of(this); + const region = stack.region; + + // Default values + const instanceType = props.instanceType ?? ec2.InstanceType.of(ec2.InstanceClass.M5A, ec2.InstanceSize.LARGE); + const ebsVolumeSize = props.ebsVolumeSize ?? 100; + const containerInsights = props.containerInsights ?? true; + const drainingTimeout = props.drainingTimeout ?? 900; + const maxInstanceLifetime = props.maxInstanceLifetime ?? 604800; + + const scaling = { + minCapacity: props.scaling?.minCapacity ?? 2, + maxCapacity: props.scaling?.maxCapacity ?? 4, + targetCapacityPercent: props.scaling?.targetCapacityPercent ?? 100, + scaleUpCooldown: props.scaling?.scaleUpCooldown ?? 60, + scaleDownCooldown: props.scaling?.scaleDownCooldown ?? 300, + }; + + const spot = { + enabled: props.spot?.enabled ?? false, + onDemandPercentage: props.spot?.onDemandPercentage ?? 100, + spotAllocationStrategy: props.spot?.spotAllocationStrategy ?? 'capacity-optimized', + }; + + const loadBalancer = { + createExternal: props.loadBalancer?.createExternal ?? false, + createInternal: props.loadBalancer?.createInternal ?? false, + certificateArn: props.loadBalancer?.certificateArn, + idleTimeout: props.loadBalancer?.idleTimeout ?? 60, + enableAccessLogs: props.loadBalancer?.enableAccessLogs ?? true, + }; + + // Create ECS Cluster + this.cluster = new ecs.Cluster(this, 'Cluster', { + vpc: props.vpc, + clusterName: stack.stackName, + enableFargateCapacityProviders: false, + }); + + // Enable Container Insights if requested + if (containerInsights) { + const cfnCluster = this.cluster.node.defaultChild as ecs.CfnCluster; + cfnCluster.configuration = { + executeCommandConfiguration: { + logging: 'DEFAULT', + }, + }; + cfnCluster.addPropertyOverride('ClusterSettings', [ + { + Name: 'containerInsights', + Value: 'enabled', + }, + ]); + } + (this.cluster.node.defaultChild as ecs.CfnCluster).overrideLogicalId('Cluster'); + + // Security Groups + this.ecsHostSecurityGroup = new ec2.SecurityGroup(this, 'ECSHostSecurityGroup', { + vpc: props.vpc, + description: 'Security group for ECS container instances', + allowAllOutbound: true, + }); + (this.ecsHostSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('ECSHostSecurityGroup'); + + // Allow internal VPC traffic + const vpcCidrBlock = props.vpcCidrBlock ?? props.vpc.vpcCidrBlock; + if (vpcCidrBlock) { + this.ecsHostSecurityGroup.addIngressRule( + ec2.Peer.ipv4(vpcCidrBlock), + ec2.Port.allTraffic(), + 'Allow internal VPC traffic' + ); + } + + // IAM Role for EC2 instances + const instanceRole = new iam.Role(this, 'InstanceRole', { + assumedBy: new iam.ServicePrincipal('ec2.amazonaws.com'), + managedPolicies: [ + iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonEC2ContainerServiceforEC2Role'), + iam.ManagedPolicy.fromAwsManagedPolicyName('AmazonSSMManagedInstanceCore'), + ], + }); + (instanceRole.node.defaultChild as iam.CfnRole).overrideLogicalId('InstanceRole'); + + // Additional permissions for ECS instances + instanceRole.addToPolicy( + new iam.PolicyStatement({ + actions: [ + 'logs:CreateLogGroup', + 'logs:CreateLogStream', + 'logs:PutLogEvents', + 'logs:DescribeLogGroups', + 'logs:DescribeLogStreams', + ], + resources: ['*'], + }) + ); + + instanceRole.addToPolicy( + new iam.PolicyStatement({ + actions: ['ec2:DescribeVolumes', 'ec2:CreateTags'], + resources: ['*'], + }) + ); + + instanceRole.addToPolicy( + new iam.PolicyStatement({ + actions: ['ecs:UpdateContainerInstancesState'], + resources: ['*'], + }) + ); + + // User data script + const userData = ec2.UserData.forLinux(); + userData.addCommands( + '#!/bin/bash', + 'set -e', + '', + '# Install SSM agent and other utilities', + 'yum install -y amazon-ssm-agent', + 'systemctl enable amazon-ssm-agent', + 'systemctl start amazon-ssm-agent', + '', + `# Configure ECS agent`, + `echo "ECS_CLUSTER=${stack.stackName}" >> /etc/ecs/ecs.config`, + 'echo "ECS_ENABLE_SPOT_INSTANCE_DRAINING=true" >> /etc/ecs/ecs.config', + 'echo "ECS_ENABLE_CONTAINER_METADATA=true" >> /etc/ecs/ecs.config', + 'echo \'ECS_AVAILABLE_LOGGING_DRIVERS=["json-file","awslogs","splunk"]\' >> /etc/ecs/ecs.config', + '', + '# Signal success', + `/opt/aws/bin/cfn-signal -e $? --stack ${stack.stackName} --resource AutoScalingGroup --region ${region}` + ); + + // Create Auto Scaling Group with Launch Template + this.autoScalingGroup = new autoscaling.AutoScalingGroup(this, 'AutoScalingGroup', { + vpc: props.vpc, + vpcSubnets: props.instanceSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + instanceType: instanceType, + machineImage: ecs.EcsOptimizedImage.amazonLinux2(), + role: instanceRole, + minCapacity: scaling.minCapacity, + maxCapacity: scaling.maxCapacity, + maxInstanceLifetime: cdk.Duration.seconds(maxInstanceLifetime), + healthChecks: autoscaling.HealthChecks.ec2({ + gracePeriod: cdk.Duration.minutes(5), + }), + updatePolicy: autoscaling.UpdatePolicy.rollingUpdate({ + maxBatchSize: 1, + minInstancesInService: 1, + pauseTime: cdk.Duration.minutes(5), + }), + signals: autoscaling.Signals.waitForMinCapacity({ + timeout: cdk.Duration.minutes(15), + }), + userData: userData, + blockDevices: [ + { + deviceName: '/dev/xvda', + volume: autoscaling.BlockDeviceVolume.ebs(ebsVolumeSize, { + volumeType: autoscaling.EbsDeviceVolumeType.GP3, + encrypted: true, + }), + }, + ], + keyPair: props.keyName ? ec2.KeyPair.fromKeyPairName(this, 'KeyPair', props.keyName) : undefined, + securityGroup: this.ecsHostSecurityGroup, + }); + (this.autoScalingGroup.node.defaultChild as autoscaling.CfnAutoScalingGroup).overrideLogicalId('AutoScalingGroup'); + + // Add additional security groups + if (props.additionalSecurityGroups) { + for (const sg of props.additionalSecurityGroups) { + this.autoScalingGroup.addSecurityGroup(sg); + } + } + + // Configure Mixed Instances Policy for Spot support + if (spot.enabled) { + const cfnAsg = this.autoScalingGroup.node.defaultChild as autoscaling.CfnAutoScalingGroup; + + // Get the launch template from the ASG + const launchTemplate = this.autoScalingGroup.node.findChild('LaunchTemplate') as ec2.LaunchTemplate; + const cfnLaunchTemplate = launchTemplate.node.defaultChild as ec2.CfnLaunchTemplate; + + // Build instance type overrides + const instanceTypes = [instanceType, ...(props.additionalInstanceTypes ?? [])]; + const overrides = instanceTypes.map((it) => ({ + instanceType: it.toString(), + })); + + cfnAsg.mixedInstancesPolicy = { + launchTemplate: { + launchTemplateSpecification: { + launchTemplateId: cfnLaunchTemplate.ref, + version: cfnLaunchTemplate.attrLatestVersionNumber, + }, + overrides: overrides, + }, + instancesDistribution: { + onDemandBaseCapacity: 0, + onDemandPercentageAboveBaseCapacity: spot.onDemandPercentage, + spotAllocationStrategy: spot.spotAllocationStrategy, + spotMaxPrice: props.spot?.maxSpotPrice, + }, + }; + + // Remove the direct launch template reference since we're using mixed instances + cfnAsg.launchTemplate = undefined; + cfnAsg.launchConfigurationName = undefined; + } + + // Enable IMDSv2 + const asgLaunchTemplate = this.autoScalingGroup.node.tryFindChild('LaunchTemplate'); + if (asgLaunchTemplate) { + const cfnLt = asgLaunchTemplate.node.defaultChild as ec2.CfnLaunchTemplate; + cfnLt.addPropertyOverride('LaunchTemplateData.MetadataOptions', { + HttpTokens: 'required', + HttpPutResponseHopLimit: 2, + HttpEndpoint: 'enabled', + }); + } + + // Create EC2 Capacity Provider with managed scaling + this.ec2CapacityProvider = new ecs.AsgCapacityProvider(this, 'EC2CapacityProvider', { + autoScalingGroup: this.autoScalingGroup, + enableManagedScaling: true, + enableManagedTerminationProtection: true, + targetCapacityPercent: scaling.targetCapacityPercent, + capacityProviderName: `${stack.stackName}-ec2`, + }); + // Override capacity provider logical ID (the child is named 'EC2CapacityProvider') + const cfnCapacityProvider = this.ec2CapacityProvider.node.tryFindChild( + 'EC2CapacityProvider' + ) as ecs.CfnCapacityProvider; + if (cfnCapacityProvider) { + cfnCapacityProvider.overrideLogicalId('EC2CapacityProvider'); + } + + // Override Launch Template and Instance Profile logical IDs + if (asgLaunchTemplate) { + const cfnLaunchTemplate = asgLaunchTemplate.node.defaultChild as ec2.CfnLaunchTemplate; + cfnLaunchTemplate.overrideLogicalId('LaunchTemplate'); + } + const instanceProfile = this.autoScalingGroup.node.tryFindChild('InstanceProfile'); + if (instanceProfile) { + (instanceProfile as iam.CfnInstanceProfile).overrideLogicalId('InstanceProfile'); + } + + // Add EC2 capacity provider to cluster + this.cluster.addAsgCapacityProvider(this.ec2CapacityProvider); + + // Add Fargate capacity providers if enabled + if (props.enableFargate) { + this.cluster.enableFargateCapacityProviders(); + } + + // Instance draining Lambda + this.createDrainingLambda(props, drainingTimeout); + + // Load Balancers + if (loadBalancer.createExternal || loadBalancer.createInternal) { + this.loadBalancerSecurityGroup = new ec2.SecurityGroup(this, 'LoadBalancerSecurityGroup', { + vpc: props.vpc, + description: 'Security group for ALBs', + allowAllOutbound: true, + }); + (this.loadBalancerSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId( + 'LoadBalancerSecurityGroup' + ); + + // Allow ALB to communicate with ECS hosts + this.ecsHostSecurityGroup.addIngressRule( + this.loadBalancerSecurityGroup, + ec2.Port.allTraffic(), + 'Allow traffic from ALB' + ); + + // Create logs bucket if access logs enabled + if (loadBalancer.enableAccessLogs) { + const elbAccountId = props.elbAccountId ?? ELB_ACCOUNT_IDS[region] ?? ELB_ACCOUNT_IDS['ca-central-1']; + + this.logsBucket = new s3.Bucket(this, 'LogsBucket', { + bucketName: props.logsBucketName ?? `${stack.stackName}-${region}-alb-logs`, + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + lifecycleRules: [ + { + expiration: cdk.Duration.days(365), + }, + ], + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + (this.logsBucket.node.defaultChild as s3.CfnBucket).overrideLogicalId('LogsBucket'); + + this.logsBucket.addToResourcePolicy( + new iam.PolicyStatement({ + actions: ['s3:PutObject'], + resources: [`${this.logsBucket.bucketArn}/*`], + principals: [new iam.AccountPrincipal(elbAccountId)], + }) + ); + } + + if (loadBalancer.createExternal) { + this.createExternalLoadBalancer(props, loadBalancer); + } + + if (loadBalancer.createInternal) { + this.createInternalLoadBalancer(props, loadBalancer); + } + } + + // Apply tags + this.applyTags(props.tags); + + // Add outputs + this.addOutputs(props); + } + + /** + * Create instance draining Lambda function + */ + private createDrainingLambda(props: SpicyEcsClusterProps, drainingTimeout: number): void { + const stack = cdk.Stack.of(this); + + // SNS Topic for ASG lifecycle events + const lifecycleTopic = new sns.Topic(this, 'LifecycleTopic', { + displayName: `${stack.stackName}-lifecycle`, + }); + (lifecycleTopic.node.defaultChild as sns.CfnTopic).overrideLogicalId('LifecycleTopic'); + + // Lambda execution role + const lambdaRole = new iam.Role(this, 'DrainingLambdaRole', { + assumedBy: new iam.ServicePrincipal('lambda.amazonaws.com'), + managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')], + }); + (lambdaRole.node.defaultChild as iam.CfnRole).overrideLogicalId('DrainingLambdaRole'); + + lambdaRole.addToPolicy( + new iam.PolicyStatement({ + actions: [ + 'autoscaling:CompleteLifecycleAction', + 'autoscaling:RecordLifecycleActionHeartbeat', + 'ecs:ListContainerInstances', + 'ecs:DescribeContainerInstances', + 'ecs:UpdateContainerInstancesState', + 'sns:Publish', + ], + resources: ['*'], + }) + ); + + // Draining Lambda function + const drainingLambda = new lambda.Function(this, 'DrainingLambda', { + runtime: lambda.Runtime.PYTHON_3_11, + handler: 'index.lambda_handler', + role: lambdaRole, + timeout: cdk.Duration.seconds(60), + memorySize: 128, + description: 'Gracefully drain ECS tasks before instance termination', + logGroup: new logs.LogGroup(this, 'DrainingLambdaLogGroup', { + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }), + code: lambda.Code.fromInline(` +import datetime +import json +import time +import boto3 + +CLUSTER = '${stack.stackName}' +TIMEOUT = ${drainingTimeout} +REGION = '${stack.region}' + +def aws(svc): + return boto3.client(svc, region_name=REGION) + +ASG = aws('autoscaling') +ECS = aws('ecs') +SNS = aws('sns') + +def lookup_instance(msg): + res = ECS.list_container_instances(cluster=CLUSTER, filter='ec2InstanceId == %s' % (msg['EC2InstanceId'])) + if not res['containerInstanceArns']: + return None, None, 0 + res = ECS.describe_container_instances(cluster=CLUSTER, containerInstances=res['containerInstanceArns']) + ret = (res['containerInstances'][0]['containerInstanceArn'], res['containerInstances'][0]['status'], res['containerInstances'][0]['runningTasksCount']) + print('Found: %s %s' % (str(ret), msg)) + return ret + +def can_terminate(msg): + (arn, status, count) = lookup_instance(msg) + if arn is None: + print('Cannot lookup: %s' % (msg)) + return True + if status != 'DRAINING': + print('Draining: %s' % (msg)) + ECS.update_container_instances_state(cluster=CLUSTER, containerInstances=[arn], status='DRAINING') + return False + if count == 0: + print('Finished draining: %s' % (msg)) + return True + now = datetime.datetime.now().timestamp() + if msg['instance_timeout'] < now: + print('Timed out: %s' % (msg)) + return True + return False + +def lambda_handler(event, context): + msg = json.loads(event['Records'][0]['Sns']['Message']) + if 'instance_timeout' not in msg: + msg['instance_timeout'] = (datetime.datetime.now() + datetime.timedelta(seconds=TIMEOUT)).timestamp() + if 'LifecycleTransition' not in msg.keys() or msg['LifecycleTransition'].find('autoscaling:EC2_INSTANCE_TERMINATING') == -1: + print('Unknown transition: %s' % (msg)) + return + if can_terminate(msg): + print('ASG complete: %s' % (msg)) + ASG.complete_lifecycle_action(LifecycleHookName=msg['LifecycleHookName'], AutoScalingGroupName=msg['AutoScalingGroupName'], LifecycleActionResult='CONTINUE', InstanceId=msg['EC2InstanceId']) + return + print('Tasks are still running: %s' % (msg)) + time.sleep(20) + ASG.record_lifecycle_action_heartbeat(LifecycleHookName=msg['LifecycleHookName'], AutoScalingGroupName=msg['AutoScalingGroupName'], LifecycleActionToken=msg['LifecycleActionToken'], InstanceId=msg['EC2InstanceId']) + SNS.publish(TopicArn=event['Records'][0]['Sns']['TopicArn'], Message=json.dumps(msg), Subject='Retry') +`), + }); + (drainingLambda.node.defaultChild as lambda.CfnFunction).overrideLogicalId('DrainingLambda'); + + // Subscribe Lambda to SNS topic + lifecycleTopic.addSubscription(new subscriptions.LambdaSubscription(drainingLambda)); + + // Lifecycle hook role + const lifecycleRole = new iam.Role(this, 'LifecycleRole', { + assumedBy: new iam.ServicePrincipal('autoscaling.amazonaws.com'), + managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AutoScalingNotificationAccessRole')], + }); + (lifecycleRole.node.defaultChild as iam.CfnRole).overrideLogicalId('LifecycleRole'); + + // Add lifecycle hook + this.autoScalingGroup.addLifecycleHook('TerminationHook', { + lifecycleTransition: autoscaling.LifecycleTransition.INSTANCE_TERMINATING, + notificationTarget: new (class implements autoscaling.ILifecycleHookTarget { + bind(_scope: Construct, _options: autoscaling.BindHookTargetOptions): autoscaling.LifecycleHookTargetConfig { + return { + notificationTargetArn: lifecycleTopic.topicArn, + createdRole: lifecycleRole, + }; + } + })(), + defaultResult: autoscaling.DefaultResult.ABANDON, + heartbeatTimeout: cdk.Duration.seconds(120), + }); + } + + /** + * Create external (internet-facing) load balancer + */ + private createExternalLoadBalancer( + props: SpicyEcsClusterProps, + config: { certificateArn?: string; idleTimeout: number; enableAccessLogs: boolean } + ): void { + this.loadBalancerSecurityGroup!.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(80), 'Allow HTTP'); + this.loadBalancerSecurityGroup!.addIngressRule(ec2.Peer.anyIpv4(), ec2.Port.tcp(443), 'Allow HTTPS'); + + (this as { externalLoadBalancer: elbv2.ApplicationLoadBalancer }).externalLoadBalancer = + new elbv2.ApplicationLoadBalancer(this, 'ExternalLoadBalancer', { + vpc: props.vpc, + internetFacing: true, + securityGroup: this.loadBalancerSecurityGroup, + vpcSubnets: props.externalSubnets ?? { subnetType: ec2.SubnetType.PUBLIC }, + idleTimeout: cdk.Duration.seconds(config.idleTimeout), + }); + (this.externalLoadBalancer!.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('ExternalLoadBalancer'); + + if (config.enableAccessLogs && this.logsBucket) { + this.externalLoadBalancer!.logAccessLogs(this.logsBucket, 'external'); + } + + // HTTP Listener + (this as { externalHttpListener: elbv2.ApplicationListener }).externalHttpListener = + this.externalLoadBalancer!.addListener('ExternalHTTP', { + port: 80, + protocol: elbv2.ApplicationProtocol.HTTP, + defaultAction: elbv2.ListenerAction.fixedResponse(404, { + contentType: 'text/plain', + messageBody: 'Not Found', + }), + }); + + // HTTPS Listener (if certificate provided) + if (config.certificateArn) { + (this as { externalHttpsListener: elbv2.ApplicationListener }).externalHttpsListener = + this.externalLoadBalancer!.addListener('ExternalHTTPS', { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + certificates: [elbv2.ListenerCertificate.fromArn(config.certificateArn)], + defaultAction: elbv2.ListenerAction.fixedResponse(404, { + contentType: 'text/plain', + messageBody: 'Not Found', + }), + }); + } + } + + /** + * Create internal load balancer + */ + private createInternalLoadBalancer( + props: SpicyEcsClusterProps, + config: { certificateArn?: string; idleTimeout: number; enableAccessLogs: boolean } + ): void { + (this as { internalLoadBalancer: elbv2.ApplicationLoadBalancer }).internalLoadBalancer = + new elbv2.ApplicationLoadBalancer(this, 'InternalLoadBalancer', { + vpc: props.vpc, + internetFacing: false, + securityGroup: this.loadBalancerSecurityGroup, + vpcSubnets: props.internalSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + idleTimeout: cdk.Duration.seconds(config.idleTimeout), + }); + (this.internalLoadBalancer!.node.defaultChild as elbv2.CfnLoadBalancer).overrideLogicalId('InternalLoadBalancer'); + + if (config.enableAccessLogs && this.logsBucket) { + this.internalLoadBalancer!.logAccessLogs(this.logsBucket, 'internal'); + } + + // HTTP Listener + (this as { internalHttpListener: elbv2.ApplicationListener }).internalHttpListener = + this.internalLoadBalancer!.addListener('InternalHTTP', { + port: 80, + protocol: elbv2.ApplicationProtocol.HTTP, + defaultAction: elbv2.ListenerAction.fixedResponse(404, { + contentType: 'text/plain', + messageBody: 'Not Found', + }), + }); + + // HTTPS Listener (if certificate provided) + if (config.certificateArn) { + (this as { internalHttpsListener: elbv2.ApplicationListener }).internalHttpsListener = + this.internalLoadBalancer!.addListener('InternalHTTPS', { + port: 443, + protocol: elbv2.ApplicationProtocol.HTTPS, + certificates: [elbv2.ListenerCertificate.fromArn(config.certificateArn)], + defaultAction: elbv2.ListenerAction.fixedResponse(404, { + contentType: 'text/plain', + messageBody: 'Not Found', + }), + }); + } + } + + /** + * Apply tags to all resources + */ + private applyTags(tags: SpicyEcsClusterTags): void { + const stack = cdk.Stack.of(this); + + cdk.Tags.of(this).add('Name', stack.stackName); + cdk.Tags.of(this).add('Owner', tags.owner); + cdk.Tags.of(this).add('Product', tags.product); + cdk.Tags.of(this).add('Component', tags.component); + cdk.Tags.of(this).add('Environment', tags.environment); + if (tags.build) { + cdk.Tags.of(this).add('Build', tags.build); + } + } + + /** + * Add CloudFormation outputs + */ + private addOutputs(props: SpicyEcsClusterProps): void { + const stack = cdk.Stack.of(this); + + const clusterNameOutput = new cdk.CfnOutput(this, 'ClusterNameOutput', { + value: this.cluster.clusterName, + description: 'ECS Cluster Name', + exportName: `${stack.stackName}-cluster-name`, + }); + clusterNameOutput.overrideLogicalId('ClusterName'); + + const clusterArnOutput = new cdk.CfnOutput(this, 'ClusterArnOutput', { + value: this.cluster.clusterArn, + description: 'ECS Cluster ARN', + exportName: `${stack.stackName}-cluster-arn`, + }); + clusterArnOutput.overrideLogicalId('ClusterArn'); + + const vpcOutput = new cdk.CfnOutput(this, 'VPCOutput', { + value: props.vpc.vpcId, + description: 'VPC ID', + exportName: `${stack.stackName}-VPC`, + }); + vpcOutput.overrideLogicalId('VPC'); + + // Export VPC stack name if provided (for other stacks to import VPC details) + if (props.vpcStackName) { + const vpcStackNameOutput = new cdk.CfnOutput(this, 'VPCStackNameOutput', { + value: props.vpcStackName, + description: 'VPC Stack Name', + exportName: `${stack.stackName}-VPCStackName`, + }); + vpcStackNameOutput.overrideLogicalId('VPCStackName'); + } + + const sgOutput = new cdk.CfnOutput(this, 'ECSHostSecurityGroupIdOutput', { + value: this.ecsHostSecurityGroup.securityGroupId, + description: 'ECS Host Security Group', + exportName: `${stack.stackName}-ecs-host-security-group`, + }); + sgOutput.overrideLogicalId('ECSHostSecurityGroupId'); + + const asgOutput = new cdk.CfnOutput(this, 'AutoScalingGroupNameOutput', { + value: this.autoScalingGroup.autoScalingGroupName, + description: 'Auto Scaling Group Name', + exportName: `${stack.stackName}-auto-scaling-group`, + }); + asgOutput.overrideLogicalId('AutoScalingGroupName'); + + if (this.externalLoadBalancer) { + const extDnsOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerDNSOutput', { + value: this.externalLoadBalancer.loadBalancerDnsName, + description: 'External Load Balancer DNS', + exportName: `${stack.stackName}-internet-facing-url`, + }); + extDnsOutput.overrideLogicalId('ExternalLoadBalancerDNS'); + + const extArnOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerArnOutput', { + value: this.externalLoadBalancer.loadBalancerArn, + description: 'External Load Balancer ARN', + exportName: `${stack.stackName}-internet-facing-arn`, + }); + extArnOutput.overrideLogicalId('ExternalLoadBalancerArn'); + + const extHzOutput = new cdk.CfnOutput(this, 'ExternalLoadBalancerHostedZoneIdOutput', { + value: this.externalLoadBalancer.loadBalancerCanonicalHostedZoneId, + description: 'External Load Balancer Hosted Zone ID', + exportName: `${stack.stackName}-internet-facing-hosted-zone-id`, + }); + extHzOutput.overrideLogicalId('ExternalLoadBalancerHostedZoneId'); + + if (this.externalHttpListener) { + const extHttpOutput = new cdk.CfnOutput(this, 'ExternalHTTPListenerArnOutput', { + value: this.externalHttpListener.listenerArn, + description: 'External HTTP Listener ARN', + exportName: `${stack.stackName}-internet-facing-http-listener`, + }); + extHttpOutput.overrideLogicalId('ExternalHTTPListenerArn'); + } + + if (this.externalHttpsListener) { + const extHttpsOutput = new cdk.CfnOutput(this, 'ExternalHTTPSListenerArnOutput', { + value: this.externalHttpsListener.listenerArn, + description: 'External HTTPS Listener ARN', + exportName: `${stack.stackName}-internet-facing-https-listener`, + }); + extHttpsOutput.overrideLogicalId('ExternalHTTPSListenerArn'); + } + } + + if (this.internalLoadBalancer) { + const intDnsOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerDNSOutput', { + value: this.internalLoadBalancer.loadBalancerDnsName, + description: 'Internal Load Balancer DNS', + exportName: `${stack.stackName}-internal-url`, + }); + intDnsOutput.overrideLogicalId('InternalLoadBalancerDNS'); + + const intArnOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerArnOutput', { + value: this.internalLoadBalancer.loadBalancerArn, + description: 'Internal Load Balancer ARN', + exportName: `${stack.stackName}-internal-arn`, + }); + intArnOutput.overrideLogicalId('InternalLoadBalancerArn'); + + const intHzOutput = new cdk.CfnOutput(this, 'InternalLoadBalancerHostedZoneIdOutput', { + value: this.internalLoadBalancer.loadBalancerCanonicalHostedZoneId, + description: 'Internal Load Balancer Hosted Zone ID', + exportName: `${stack.stackName}-internal-hosted-zone-id`, + }); + intHzOutput.overrideLogicalId('InternalLoadBalancerHostedZoneId'); + + if (this.internalHttpListener) { + const intHttpOutput = new cdk.CfnOutput(this, 'InternalHTTPListenerArnOutput', { + value: this.internalHttpListener.listenerArn, + description: 'Internal HTTP Listener ARN', + exportName: `${stack.stackName}-internal-http-listener`, + }); + intHttpOutput.overrideLogicalId('InternalHTTPListenerArn'); + } + + if (this.internalHttpsListener) { + const intHttpsOutput = new cdk.CfnOutput(this, 'InternalHTTPSListenerArnOutput', { + value: this.internalHttpsListener.listenerArn, + description: 'Internal HTTPS Listener ARN', + exportName: `${stack.stackName}-internal-https-listener`, + }); + intHttpsOutput.overrideLogicalId('InternalHTTPSListenerArn'); + } + } + + if (this.logsBucket) { + const logsOutput = new cdk.CfnOutput(this, 'LogsBucketNameOutput', { + value: this.logsBucket.bucketName, + description: 'ALB Logs S3 Bucket', + exportName: `${stack.stackName}-logs-s3-bucket`, + }); + logsOutput.overrideLogicalId('LogsBucketName'); + } + } +} diff --git a/resources/lib/constructs/spicy-ecs-service.ts b/resources/lib/constructs/spicy-ecs-service.ts new file mode 100644 index 0000000..1a0fcc4 --- /dev/null +++ b/resources/lib/constructs/spicy-ecs-service.ts @@ -0,0 +1,1025 @@ +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as ecs from 'aws-cdk-lib/aws-ecs'; +import * as elbv2 from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as logs from 'aws-cdk-lib/aws-logs'; +import * as route53 from 'aws-cdk-lib/aws-route53'; +import * as route53Targets from 'aws-cdk-lib/aws-route53-targets'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +/** + * Container definition for the service + */ +export interface ContainerConfig { + /** Docker image URI (e.g., nexus.kodeniks.com/docker-hosted/my-app:latest) */ + image: string; + + /** Container port */ + port: number; + + /** CPU units (256 = 0.25 vCPU) */ + cpu?: number; + + /** Memory in MiB */ + memory?: number; + + /** Memory reservation (soft limit) in MiB */ + memoryReservation?: number; + + /** Environment variables */ + environment?: Record; + + /** Secrets from Secrets Manager (key = env var name, value = secret ARN or ARN:key) */ + secrets?: Record; + + /** Health check command */ + healthCheck?: { + command: string[]; + interval?: number; + timeout?: number; + retries?: number; + startPeriod?: number; + }; + + /** Container command override */ + command?: string[]; + + /** Entry point override */ + entryPoint?: string[]; +} + +/** + * Capacity provider strategy for mixed EC2/Fargate deployments + */ +export interface CapacityProviderStrategyItem { + /** Capacity provider name (e.g., 'FARGATE', 'FARGATE_SPOT', or EC2 provider name) */ + capacityProvider: string; + + /** Base count - minimum tasks on this provider */ + base?: number; + + /** Weight for distributing tasks above base */ + weight: number; +} + +/** + * Auto-scaling configuration + */ +export interface ServiceScalingConfig { + /** Minimum number of tasks */ + minCapacity: number; + + /** Maximum number of tasks */ + maxCapacity: number; + + /** Target CPU utilization percentage (0-100) */ + targetCpuUtilization?: number; + + /** Target memory utilization percentage (0-100) */ + targetMemoryUtilization?: number; + + /** Target requests per task per minute */ + targetRequestsPerTarget?: number; + + /** Scale-in cooldown in seconds */ + scaleInCooldown?: number; + + /** Scale-out cooldown in seconds */ + scaleOutCooldown?: number; +} + +/** + * Health check configuration for ALB target group + */ +export interface HealthCheckConfig { + /** Health check path */ + path: string; + + /** Health check interval in seconds */ + interval?: number; + + /** Health check timeout in seconds */ + timeout?: number; + + /** Healthy threshold count */ + healthyThresholdCount?: number; + + /** Unhealthy threshold count */ + unhealthyThresholdCount?: number; + + /** HTTP codes to consider healthy */ + healthyHttpCodes?: string; +} + +/** + * Blue/Green DNS configuration + * + * Note: Both DNS records are created and point to the same ALB. + * The isActive flag is informational only - routing is controlled by ALB listener rule priorities. + */ +export interface BlueGreenDnsConfig { + /** Active hostname (e.g., api.example.com) */ + activeHostname: string; + + /** Inactive hostname (e.g., inactive-api.example.com) */ + inactiveHostname: string; + + /** Route53 hosted zone ID */ + hostedZoneId: string; + + /** + * Whether this is the active service (informational only) + * + * Note: This flag doesn't affect DNS record creation - both records are always created. + * Actual routing is controlled 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 + */ + isActive?: boolean; +} + +/** + * Load balancer routing configuration + */ +export interface LoadBalancerRoutingConfig { + /** + * Reference the bg-common ALB (from persistent ALB stack) + * + * **Recommended approach**: Deploy ALB separately using SpicyAlbStack (or spicyALB pipeline function), then reference it here. + * This allows the ALB to persist across service stack deletions, keeping DNS stable. + * + * Can be provided as: + * - ALB ARN string (will be imported) + * - ALB object (from SpicyAlb construct) + * + * If not provided, uses cluster ALB (if externalListenerArn/internalListenerArn are set). + */ + existingALB?: elbv2.IApplicationLoadBalancer | string; + + /** Use cluster's external (internet-facing) ALB */ + external?: boolean; + + /** Use cluster's internal ALB */ + internal?: boolean; + + /** Host header for routing (e.g., api.example.com) */ + hostHeader?: string; + + /** Path patterns for routing (e.g., /api/*) */ + pathPatterns?: string[]; + + /** Listener rule priority (lower = higher priority) */ + priority?: number; + + /** Enable session stickiness (default: true for individual ALB, false for cluster ALB) */ + stickiness?: boolean; + + /** Stickiness duration in seconds */ + stickinessDuration?: number; + + /** Deregistration delay in seconds */ + deregistrationDelay?: number; + + /** Blue/Green DNS configuration (for individual ALB) */ + blueGreenDns?: BlueGreenDnsConfig; + + /** + * HTTPS listener ARN for bg-common ALB (required when using existingALB with certificate) + * Only one listener type is needed since bg-common ALB is either external OR internal (not both) + */ + httpsListenerArn?: string; + + /** + * HTTP listener ARN for bg-common ALB (required when using existingALB without certificate) + * Only one listener type is needed since bg-common ALB is either external OR internal (not both) + */ + httpListenerArn?: string; +} + +/** + * Route53 DNS configuration + */ +export interface DnsConfig { + /** Hosted zone ID */ + hostedZoneId: string; + + /** Hosted zone name (e.g., example.com) */ + zoneName: string; + + /** Record name (e.g., api for api.example.com) */ + recordName: string; +} + +/** + * Deployment configuration + */ +export interface DeploymentConfig { + /** Minimum healthy percent during deployment */ + minHealthyPercent?: number; + + /** Maximum percent during deployment */ + maxPercent?: number; + + /** Enable circuit breaker with rollback */ + circuitBreaker?: boolean; + + /** Enable ECS Exec for debugging */ + enableExecuteCommand?: boolean; +} + +/** + * Tags for the service + */ +export interface SpicyEcsServiceTags { + owner: string; + product: string; + component: string; + environment: string; + build?: string; +} + +/** + * Properties for SpicyEcsService construct + */ +export interface SpicyEcsServiceProps { + /** ECS Cluster to deploy to */ + readonly cluster: ecs.ICluster; + + /** VPC for networking */ + readonly vpc: ec2.IVpc; + + /** Service name */ + readonly serviceName: string; + + /** Container configuration */ + readonly container: ContainerConfig; + + /** + * Capacity provider strategy for mixed deployments + * If not specified, uses cluster default + */ + readonly capacityProviderStrategy?: CapacityProviderStrategyItem[]; + + /** + * Desired number of tasks + * @default 2 + */ + readonly desiredCount?: number; + + /** Auto-scaling configuration */ + readonly scaling?: ServiceScalingConfig; + + /** Health check configuration */ + readonly healthCheck?: HealthCheckConfig; + + /** Load balancer routing */ + readonly routing?: LoadBalancerRoutingConfig; + + /** External ALB listener ARN (from cluster) */ + readonly externalListenerArn?: string; + + /** Internal ALB listener ARN (from cluster) */ + readonly internalListenerArn?: string; + + /** External ALB (for target group VPC) */ + readonly externalLoadBalancer?: elbv2.IApplicationLoadBalancer; + + /** Internal ALB (for target group VPC) */ + readonly internalLoadBalancer?: elbv2.IApplicationLoadBalancer; + + /** Route53 DNS configuration */ + readonly dns?: DnsConfig; + + /** Deployment configuration */ + readonly deployment?: DeploymentConfig; + + /** Subnets for tasks (for awsvpc network mode) */ + readonly taskSubnets?: ec2.SubnetSelection; + + /** Security groups for tasks */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** Additional IAM policies for task role */ + readonly taskRolePolicies?: iam.PolicyStatement[]; + + /** Required tags */ + readonly tags: SpicyEcsServiceTags; +} + +/** + * SpicyEcsService - ECS Service with: + * - Mixed capacity provider strategy (EC2 + Fargate burst) + * - Auto-scaling on CPU/Memory/Requests + * - ALB integration with host/path routing + * - Optional Route53 DNS + * - Deployment circuit breaker + * - ECS Exec support + */ +export class SpicyEcsService extends Construct { + /** The ECS service */ + public readonly service: ecs.BaseService; + + /** The task definition */ + public readonly taskDefinition: ecs.TaskDefinition; + + /** The target group */ + public targetGroup?: elbv2.ApplicationTargetGroup; + + /** ALB reference (bg-common ALB when using existingALB) */ + public alb?: elbv2.IApplicationLoadBalancer; + + /** ALB HTTP listener (when using bg-common ALB) */ + public httpListener?: elbv2.ApplicationListener; + + /** ALB HTTPS listener (when using bg-common ALB) */ + public httpsListener?: elbv2.ApplicationListener; + + /** CloudWatch log group */ + public readonly logGroup: logs.LogGroup; + + /** Task role */ + public readonly taskRole: iam.Role; + + /** Task execution role */ + public readonly executionRole: iam.Role; + + constructor(scope: Construct, id: string, props: SpicyEcsServiceProps) { + super(scope, id); + + const stack = cdk.Stack.of(this); + + // Defaults + const desiredCount = props.desiredCount ?? 2; + const container = props.container; + const cpu = container.cpu ?? 256; + const memory = container.memory ?? 512; + const memoryReservation = container.memoryReservation ?? memory; + + const deployment = { + minHealthyPercent: props.deployment?.minHealthyPercent ?? 50, + maxPercent: props.deployment?.maxPercent ?? 200, + circuitBreaker: props.deployment?.circuitBreaker ?? true, + enableExecuteCommand: props.deployment?.enableExecuteCommand ?? true, + }; + + const healthCheck = { + path: props.healthCheck?.path ?? '/health', + interval: props.healthCheck?.interval ?? 15, + timeout: props.healthCheck?.timeout ?? 14, + healthyThresholdCount: props.healthCheck?.healthyThresholdCount ?? 2, + unhealthyThresholdCount: props.healthCheck?.unhealthyThresholdCount ?? 7, + healthyHttpCodes: props.healthCheck?.healthyHttpCodes ?? '200-299', + }; + + // Determine if we're using Fargate (for network mode) + const usesFargate = Boolean( + props.capacityProviderStrategy?.some( + (s) => s.capacityProvider === 'FARGATE' || s.capacityProvider === 'FARGATE_SPOT' + ) + ); + + // CloudWatch Log Group + this.logGroup = new logs.LogGroup(this, 'LogGroup', { + logGroupName: `/ecs/${props.serviceName}`, + retention: logs.RetentionDays.ONE_MONTH, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + (this.logGroup.node.defaultChild as logs.CfnLogGroup).overrideLogicalId('LogGroup'); + + // Task Execution Role (for pulling images, logging) + this.executionRole = new iam.Role(this, 'ExecutionRole', { + assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), + managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AmazonECSTaskExecutionRolePolicy')], + }); + (this.executionRole.node.defaultChild as iam.CfnRole).overrideLogicalId('ExecutionRole'); + + // Allow pulling from Nexus (private registry) + this.executionRole.addToPolicy( + new iam.PolicyStatement({ + actions: ['ecr:GetAuthorizationToken'], + resources: ['*'], + }) + ); + + // Task Role (for application permissions) + this.taskRole = new iam.Role(this, 'TaskRole', { + assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'), + }); + (this.taskRole.node.defaultChild as iam.CfnRole).overrideLogicalId('TaskRole'); + + // Add custom policies + if (props.taskRolePolicies) { + for (const policy of props.taskRolePolicies) { + this.taskRole.addToPolicy(policy); + } + } + + // ECS Exec permissions + if (deployment.enableExecuteCommand) { + this.taskRole.addToPolicy( + new iam.PolicyStatement({ + actions: [ + 'ssmmessages:CreateControlChannel', + 'ssmmessages:CreateDataChannel', + 'ssmmessages:OpenControlChannel', + 'ssmmessages:OpenDataChannel', + ], + resources: ['*'], + }) + ); + } + + // Task Definition + // Use EC2 compatibility for mixed strategies, Fargate-only if no EC2 + const compatibility = usesFargate ? ecs.Compatibility.EC2_AND_FARGATE : ecs.Compatibility.EC2; + + this.taskDefinition = new ecs.TaskDefinition(this, 'TaskDefinition', { + compatibility, + networkMode: usesFargate ? ecs.NetworkMode.AWS_VPC : ecs.NetworkMode.BRIDGE, + cpu: usesFargate ? String(cpu) : undefined, + memoryMiB: usesFargate ? String(memory) : undefined, + executionRole: this.executionRole, + taskRole: this.taskRole, + family: props.serviceName, + }); + (this.taskDefinition.node.defaultChild as ecs.CfnTaskDefinition).overrideLogicalId('TaskDefinition'); + + // Build secrets + const secrets: Record = {}; + if (container.secrets) { + for (const [envName, secretArn] of Object.entries(container.secrets)) { + // Handle ARN:key format + const [arn, key] = + secretArn.includes(':') && !secretArn.startsWith('arn:') ? [secretArn, undefined] : secretArn.split('::'); + + const secret = secretsmanager.Secret.fromSecretCompleteArn(this, `Secret-${envName}`, arn); + secrets[envName] = key ? ecs.Secret.fromSecretsManager(secret, key) : ecs.Secret.fromSecretsManager(secret); + + // Grant read access + secret.grantRead(this.executionRole); + } + } + + // Container Definition + const containerDef = this.taskDefinition.addContainer('Container', { + containerName: props.serviceName, + image: ecs.ContainerImage.fromRegistry(container.image), + cpu: usesFargate ? undefined : cpu, + memoryLimitMiB: usesFargate ? undefined : memory, + memoryReservationMiB: usesFargate ? undefined : memoryReservation, + environment: container.environment, + secrets, + logging: ecs.LogDriver.awsLogs({ + logGroup: this.logGroup, + streamPrefix: props.serviceName, + }), + command: container.command, + entryPoint: container.entryPoint, + healthCheck: container.healthCheck + ? { + command: container.healthCheck.command, + interval: cdk.Duration.seconds(container.healthCheck.interval ?? 30), + timeout: cdk.Duration.seconds(container.healthCheck.timeout ?? 5), + retries: container.healthCheck.retries ?? 3, + startPeriod: cdk.Duration.seconds(container.healthCheck.startPeriod ?? 60), + } + : undefined, + essential: true, + }); + + // Port mapping + containerDef.addPortMappings({ + containerPort: container.port, + protocol: ecs.Protocol.TCP, + }); + + // Security Group for tasks (awsvpc mode) + let taskSecurityGroup: ec2.SecurityGroup | undefined; + if (usesFargate) { + taskSecurityGroup = new ec2.SecurityGroup(this, 'TaskSecurityGroup', { + vpc: props.vpc, + description: `Security group for ${props.serviceName} tasks`, + allowAllOutbound: true, + }); + (taskSecurityGroup.node.defaultChild as ec2.CfnSecurityGroup).overrideLogicalId('TaskSecurityGroup'); + } + + // Determine if we're using bg-common ALB or cluster ALB + // bg-common stack is always created, so it's either: + // 1. Use bg-common ALB (via existingALB) + // 2. Use cluster ALB + const existingALB = props.routing?.existingALB; + + // Use bg-common ALB if provided (persistent ALB from separate stack) + if (existingALB) { + // Validate required listener ARN for bg-common ALB + // bg-common ALB has only one scheme (either external OR internal, not both) + // But it can have both HTTP and HTTPS listeners (like the old template) + const httpsListenerArn = props.routing?.httpsListenerArn; + const httpListenerArn = props.routing?.httpListenerArn; + + if (!httpsListenerArn && !httpListenerArn) { + throw new Error( + 'When using bg-common ALB (existingALB), either httpsListenerArn or httpListenerArn must be provided in routing config' + ); + } + + // Import ALB if ARN string provided, otherwise use object directly + const alb = + typeof existingALB === 'string' + ? elbv2.ApplicationLoadBalancer.fromApplicationLoadBalancerAttributes(this, 'ExistingALB', { + loadBalancerArn: existingALB, + loadBalancerDnsName: '', // Not needed for routing + securityGroupId: 'sg-placeholder', // Placeholder - not used for routing + }) + : existingALB; + this.useExistingALB(props, alb, healthCheck, usesFargate, container.port, taskSecurityGroup); + } else if (props.routing && (props.externalListenerArn || props.internalListenerArn)) { + // Use cluster ALB + // Validate that only one listener is used (external OR internal, not both) + if (props.externalListenerArn && props.internalListenerArn) { + throw new Error( + 'Cannot use both externalListenerArn and internalListenerArn. Choose either external OR internal, not both' + ); + } + if (props.routing.external && props.routing.internal) { + throw new Error( + 'Cannot use both routing.external and routing.internal. Choose either external OR internal, not both' + ); + } + // Validate that routing config matches listener ARNs + if (props.routing.external && !props.externalListenerArn) { + throw new Error('routing.external=true requires externalListenerArn to be provided'); + } + if (props.routing.internal && !props.internalListenerArn) { + throw new Error('routing.internal=true requires internalListenerArn to be provided'); + } + + this.createClusterALBRouting(props, healthCheck, usesFargate, container.port, taskSecurityGroup); + } else if (props.routing) { + // Routing config provided but no ALB/listener ARNs - this is an error + throw new Error( + 'Routing configuration provided but no ALB found. Either provide existingALB (for bg-common ALB) or externalListenerArn/internalListenerArn (for cluster ALB)' + ); + } + // If no routing config, service runs without ALB (valid for workers, pub/sub, etc.) + + // Allow traffic from ALB to tasks + if (taskSecurityGroup) { + if (existingALB) { + // Allow from bg-common ALB + taskSecurityGroup.addIngressRule( + ec2.Peer.ipv4(props.vpc.vpcCidrBlock), + ec2.Port.tcp(container.port), + 'Allow traffic from bg-common ALB' + ); + } else if (props.externalLoadBalancer || props.internalLoadBalancer) { + // Allow from cluster ALB + taskSecurityGroup.addIngressRule( + ec2.Peer.ipv4(props.vpc.vpcCidrBlock), + ec2.Port.tcp(container.port), + 'Allow traffic from VPC (ALB)' + ); + } + } + + // Create Service + const serviceProps: ecs.Ec2ServiceProps | ecs.FargateServiceProps = { + cluster: props.cluster, + taskDefinition: this.taskDefinition, + desiredCount, + serviceName: props.serviceName, + minHealthyPercent: deployment.minHealthyPercent, + maxHealthyPercent: deployment.maxPercent, + circuitBreaker: deployment.circuitBreaker ? { rollback: true } : undefined, + enableExecuteCommand: deployment.enableExecuteCommand, + capacityProviderStrategies: props.capacityProviderStrategy?.map((s) => ({ + capacityProvider: s.capacityProvider, + base: s.base, + weight: s.weight, + })), + }; + + // Create appropriate service type + if (usesFargate) { + this.service = new ecs.FargateService(this, 'Service', { + ...serviceProps, + assignPublicIp: false, + vpcSubnets: props.taskSubnets ?? { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + securityGroups: taskSecurityGroup ? [taskSecurityGroup, ...(props.securityGroups ?? [])] : props.securityGroups, + platformVersion: ecs.FargatePlatformVersion.LATEST, + } as ecs.FargateServiceProps); + } else { + this.service = new ecs.Ec2Service(this, 'Service', { + ...serviceProps, + placementStrategies: [ + ecs.PlacementStrategy.spreadAcrossInstances(), + ecs.PlacementStrategy.spreadAcross(ecs.BuiltInAttributes.AVAILABILITY_ZONE), + ], + } as ecs.Ec2ServiceProps); + } + (this.service.node.defaultChild as ecs.CfnService).overrideLogicalId('Service'); + + // Attach to target group + if (this.targetGroup) { + this.service.attachToApplicationTargetGroup(this.targetGroup); + } + + // Auto-scaling + if (props.scaling) { + this.configureAutoScaling(props.scaling); + } + + // Route53 DNS (for cluster ALB mode only - bg-common ALB handles DNS in useExistingALB) + // Note: For bg-common ALB with blue/green, DNS is configured in useExistingALB() via blueGreenDns + if (props.dns && !existingALB && (props.externalLoadBalancer || props.internalLoadBalancer)) { + this.configureDns(props.dns, props.externalLoadBalancer ?? props.internalLoadBalancer!); + } + + // Apply tags + this.applyTags(props.tags); + + // Outputs + this.addOutputs(props); + } + + /** + * Use existing ALB (from persistent ALB stack) + */ + private useExistingALB( + props: SpicyEcsServiceProps, + existingALB: elbv2.IApplicationLoadBalancer, + healthCheck: { + path: string; + interval: number; + timeout: number; + healthyThresholdCount: number; + unhealthyThresholdCount: number; + healthyHttpCodes: string; + }, + usesFargate: boolean, + containerPort: number, + taskSecurityGroup: ec2.SecurityGroup | undefined + ): void { + const routing = props.routing!; + + // Store reference to existing ALB + this.alb = existingALB; + + // Create Target Group + // Default stickiness to true for individual ALB (matches legacy) + const stickinessEnabled = routing.stickiness ?? true; + this.targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', { + vpc: props.vpc, + port: containerPort, + protocol: elbv2.ApplicationProtocol.HTTP, + targetType: usesFargate ? elbv2.TargetType.IP : elbv2.TargetType.INSTANCE, + healthCheck: { + path: healthCheck.path, + interval: cdk.Duration.seconds(healthCheck.interval), + timeout: cdk.Duration.seconds(healthCheck.timeout), + healthyThresholdCount: healthCheck.healthyThresholdCount, + unhealthyThresholdCount: healthCheck.unhealthyThresholdCount, + healthyHttpCodes: healthCheck.healthyHttpCodes, + }, + deregistrationDelay: cdk.Duration.seconds(routing.deregistrationDelay ?? 60), + stickinessCookieDuration: stickinessEnabled + ? cdk.Duration.seconds(routing.stickinessDuration ?? 86400) + : undefined, + }); + (this.targetGroup.node.defaultChild as elbv2.CfnTargetGroup).overrideLogicalId('TargetGroup'); + + // Get listeners from existing ALB + // bg-common ALB can have both HTTP and HTTPS listeners (like the old template) + const httpsListenerArn = routing.httpsListenerArn; + const httpListenerArn = routing.httpListenerArn; + + if (!httpsListenerArn && !httpListenerArn) { + throw new Error( + 'When using existingALB, either httpsListenerArn or httpListenerArn must be provided in routing config' + ); + } + + // Build conditions for listener rule + const conditions: elbv2.ListenerCondition[] = []; + if (routing.hostHeader) { + conditions.push(elbv2.ListenerCondition.hostHeaders([routing.hostHeader])); + } + if (routing.pathPatterns && routing.pathPatterns.length > 0) { + conditions.push(elbv2.ListenerCondition.pathPatterns(routing.pathPatterns)); + } + if (conditions.length === 0) { + conditions.push(elbv2.ListenerCondition.pathPatterns(['/*'])); + } + + // Create listener references and add rules (like the old template - rules on both HTTP and HTTPS) + const placeholderSG = ec2.SecurityGroup.fromSecurityGroupId(this, 'ExistingListenerSG', 'sg-placeholder', { + allowAllOutbound: true, + }); + + // HTTPS listener rule (if HTTPS listener provided) + if (httpsListenerArn) { + const httpsListener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, 'ExistingHTTPSListener', { + listenerArn: httpsListenerArn, + securityGroup: placeholderSG, + }); + + new elbv2.ApplicationListenerRule(this, 'HTTPSListenerRule', { + listener: httpsListener, + priority: routing.priority ?? 100, + conditions, + targetGroups: [this.targetGroup], + }); + } + + // HTTP listener rule (if HTTP listener provided) + if (httpListenerArn) { + const httpListener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, 'ExistingHTTPListener', { + listenerArn: httpListenerArn, + securityGroup: placeholderSG, + }); + + new elbv2.ApplicationListenerRule(this, 'HTTPListenerRule', { + listener: httpListener, + priority: routing.priority ?? 100, + conditions, + targetGroups: [this.targetGroup], + }); + } + + // Configure Blue/Green DNS if provided (for bg-common ALB) + // Note: Both active and inactive DNS records are created and point to the same ALB. + // Routing is controlled by ALB listener rule priorities, not DNS changes. + // The isActive flag in BlueGreenDnsConfig is informational - both records are always created. + // Actual routing is determined by which service stack has the lower priority listener rule. + // + // Testing: To verify blue/green DNS: + // 1. Check both DNS records exist: `dig api.example.com` and `dig inactive-api.example.com` + // 2. Both should resolve to the same ALB DNS name + // 3. Verify listener rules: Active service should have priority 100, inactive should have priority 200 + // 4. Test routing: curl -H "Host: api.example.com" should hit active service + // 5. Test routing: curl -H "Host: inactive-api.example.com" should hit inactive service + if (routing.blueGreenDns) { + this.configureBlueGreenDns(routing.blueGreenDns, existingALB); + } + } + + /** + * Create cluster ALB routing (existing behavior) + */ + private createClusterALBRouting( + props: SpicyEcsServiceProps, + healthCheck: { + path: string; + interval: number; + timeout: number; + healthyThresholdCount: number; + unhealthyThresholdCount: number; + healthyHttpCodes: string; + }, + usesFargate: boolean, + containerPort: number, + taskSecurityGroup: ec2.SecurityGroup | undefined + ): void { + const routing = props.routing!; + + // Create Target Group + // Default stickiness to false for cluster ALB (existing behavior) + const stickinessEnabled = routing.stickiness ?? false; + this.targetGroup = new elbv2.ApplicationTargetGroup(this, 'TargetGroup', { + vpc: props.vpc, + port: containerPort, + protocol: elbv2.ApplicationProtocol.HTTP, + targetType: usesFargate ? elbv2.TargetType.IP : elbv2.TargetType.INSTANCE, + healthCheck: { + path: healthCheck.path, + interval: cdk.Duration.seconds(healthCheck.interval), + timeout: cdk.Duration.seconds(healthCheck.timeout), + healthyThresholdCount: healthCheck.healthyThresholdCount, + unhealthyThresholdCount: healthCheck.unhealthyThresholdCount, + healthyHttpCodes: healthCheck.healthyHttpCodes, + }, + deregistrationDelay: cdk.Duration.seconds(routing.deregistrationDelay ?? 60), + stickinessCookieDuration: stickinessEnabled + ? cdk.Duration.seconds(routing.stickinessDuration ?? 86400) + : undefined, + }); + (this.targetGroup.node.defaultChild as elbv2.CfnTargetGroup).overrideLogicalId('TargetGroup'); + + // Add listener rules + if (props.externalListenerArn && routing.external) { + this.addListenerRule('External', props.externalListenerArn, routing); + } + + if (props.internalListenerArn && routing.internal) { + this.addListenerRule('Internal', props.internalListenerArn, routing); + } + } + + /** + * Configure Blue/Green DNS (active and inactive hostnames) + * + * IMPORTANT: Both DNS records are created and point to the SAME ALB. + * Traffic routing is controlled by ALB listener rule priorities, NOT by DNS changes. + * + * How it works: + * 1. Both active and inactive hostnames resolve to the same ALB DNS name + * 2. ALB listener rules use hostname conditions to route traffic + * 3. The active service has a lower priority rule (e.g., 100) for the active hostname + * 4. The inactive service has a higher priority rule (e.g., 200) for the inactive hostname + * 5. When swapping, only the listener rule priorities are updated (via CDK deployment) + * 6. DNS records never change - they always point to the same ALB + * + * The isActive flag in BlueGreenDnsConfig is informational only - both records are always created. + * The actual routing is determined by which service stack has the lower priority listener rule. + */ + private configureBlueGreenDns(dns: BlueGreenDnsConfig, loadBalancer: elbv2.IApplicationLoadBalancer): void { + // Extract domain from hostname (e.g., "api.example.com" -> "example.com") + const domainParts = dns.activeHostname.split('.'); + const zoneName = domainParts.slice(-2).join('.'); + const activeRecordName = domainParts.slice(0, -2).join('.') || '@'; + const inactiveRecordName = dns.inactiveHostname.split('.').slice(0, -2).join('.') || '@'; + + const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', { + hostedZoneId: dns.hostedZoneId, + zoneName: zoneName, + }); + + // Active hostname DNS record - always points to the ALB + // Note: This record is created by both blue and green stacks, but routing is via listener priorities + new route53.ARecord(this, 'ActiveDnsRecord', { + zone: hostedZone, + recordName: activeRecordName === '@' ? undefined : activeRecordName, + target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)), + }); + + // Inactive hostname DNS record - always points to the ALB + // Note: This record is created by both blue and green stacks, but routing is via listener priorities + new route53.ARecord(this, 'InactiveDnsRecord', { + zone: hostedZone, + recordName: inactiveRecordName === '@' ? undefined : inactiveRecordName, + target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)), + }); + } + + /** + * Add listener rule for ALB routing + */ + private addListenerRule(name: string, listenerArn: string, routing: LoadBalancerRoutingConfig): void { + const conditions: elbv2.ListenerCondition[] = []; + + if (routing.hostHeader) { + conditions.push(elbv2.ListenerCondition.hostHeaders([routing.hostHeader])); + } + + if (routing.pathPatterns && routing.pathPatterns.length > 0) { + conditions.push(elbv2.ListenerCondition.pathPatterns(routing.pathPatterns)); + } + + // If no conditions, use a catch-all path + if (conditions.length === 0) { + conditions.push(elbv2.ListenerCondition.pathPatterns(['/*'])); + } + + const placeholderSG = ec2.SecurityGroup.fromSecurityGroupId(this, `${name}ListenerSG`, 'sg-placeholder', { + allowAllOutbound: true, + }); + const listener = elbv2.ApplicationListener.fromApplicationListenerAttributes(this, `${name}Listener`, { + listenerArn, + securityGroup: placeholderSG, + }); + + new elbv2.ApplicationListenerRule(this, `${name}ListenerRule`, { + listener, + priority: routing.priority ?? 100, + conditions, + targetGroups: [this.targetGroup!], + }); + + // HTTP redirect rule (if external) + if (name === 'External' && routing.hostHeader) { + // Note: HTTP redirect should be handled at the ALB level, not per-service + // The cluster ALB already has a default HTTP→HTTPS redirect + } + } + + /** + * Configure auto-scaling + */ + private configureAutoScaling(scaling: ServiceScalingConfig): void { + const scalableTarget = this.service.autoScaleTaskCount({ + minCapacity: scaling.minCapacity, + maxCapacity: scaling.maxCapacity, + }); + + const cooldown = { + scaleInCooldown: cdk.Duration.seconds(scaling.scaleInCooldown ?? 300), + scaleOutCooldown: cdk.Duration.seconds(scaling.scaleOutCooldown ?? 60), + }; + + // CPU utilization scaling + if (scaling.targetCpuUtilization) { + scalableTarget.scaleOnCpuUtilization('CpuScaling', { + targetUtilizationPercent: scaling.targetCpuUtilization, + ...cooldown, + }); + } + + // Memory utilization scaling + if (scaling.targetMemoryUtilization) { + scalableTarget.scaleOnMemoryUtilization('MemoryScaling', { + targetUtilizationPercent: scaling.targetMemoryUtilization, + ...cooldown, + }); + } + + // Request count scaling (requires target group) + if (scaling.targetRequestsPerTarget && this.targetGroup) { + scalableTarget.scaleOnRequestCount('RequestScaling', { + requestsPerTarget: scaling.targetRequestsPerTarget, + targetGroup: this.targetGroup, + ...cooldown, + }); + } + } + + /** + * Configure Route53 DNS + */ + private configureDns(dns: DnsConfig, loadBalancer: elbv2.IApplicationLoadBalancer): void { + const hostedZone = route53.HostedZone.fromHostedZoneAttributes(this, 'HostedZone', { + hostedZoneId: dns.hostedZoneId, + zoneName: dns.zoneName, + }); + + new route53.ARecord(this, 'DnsRecord', { + zone: hostedZone, + recordName: dns.recordName, + target: route53.RecordTarget.fromAlias(new route53Targets.LoadBalancerTarget(loadBalancer)), + }); + } + + /** + * Apply tags + */ + private applyTags(tags: SpicyEcsServiceTags): void { + const stack = cdk.Stack.of(this); + + cdk.Tags.of(this).add('Name', stack.stackName); + cdk.Tags.of(this).add('Owner', tags.owner); + cdk.Tags.of(this).add('Product', tags.product); + cdk.Tags.of(this).add('Component', tags.component); + cdk.Tags.of(this).add('Environment', tags.environment); + if (tags.build) { + cdk.Tags.of(this).add('Build', tags.build); + } + } + + /** + * Add CloudFormation outputs + */ + private addOutputs(props: SpicyEcsServiceProps): void { + const stack = cdk.Stack.of(this); + + const serviceNameOutput = new cdk.CfnOutput(this, 'ServiceNameOutput', { + value: props.serviceName, + description: 'ECS Service Name', + exportName: `${stack.stackName}-service-name`, + }); + serviceNameOutput.overrideLogicalId('ServiceName'); + + const serviceArnOutput = new cdk.CfnOutput(this, 'ServiceArnOutput', { + value: this.service.serviceArn, + description: 'ECS Service ARN', + exportName: `${stack.stackName}-service-arn`, + }); + serviceArnOutput.overrideLogicalId('ServiceArn'); + + const taskDefOutput = new cdk.CfnOutput(this, 'TaskDefinitionArnOutput', { + value: this.taskDefinition.taskDefinitionArn, + description: 'Task Definition ARN', + exportName: `${stack.stackName}-task-definition-arn`, + }); + taskDefOutput.overrideLogicalId('TaskDefinitionArn'); + + const logGroupOutput = new cdk.CfnOutput(this, 'LogGroupNameOutput', { + value: this.logGroup.logGroupName, + description: 'CloudWatch Log Group', + exportName: `${stack.stackName}-log-group`, + }); + logGroupOutput.overrideLogicalId('LogGroupName'); + + if (this.targetGroup) { + const targetGroupOutput = new cdk.CfnOutput(this, 'TargetGroupArnOutput', { + value: this.targetGroup.targetGroupArn, + description: 'Target Group ARN', + exportName: `${stack.stackName}-target-group-arn`, + }); + targetGroupOutput.overrideLogicalId('TargetGroupArn'); + } + + // Note: When using existing ALB (bg-common pattern), outputs come from ALB stack, not service stack + } +} diff --git a/resources/lib/constructs/spicy-vpc.ts b/resources/lib/constructs/spicy-vpc.ts new file mode 100644 index 0000000..4dc2360 --- /dev/null +++ b/resources/lib/constructs/spicy-vpc.ts @@ -0,0 +1,677 @@ +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +/** + * Subnet CIDR configuration for each availability zone + */ +export interface SubnetCidrConfig { + /** CIDR for the public subnet */ + publicCidr: string; + /** CIDR for the primary private subnet */ + private1Cidr: string; + /** CIDR for the secondary private subnet (with NACL) */ + private2Cidr?: string; +} + +/** + * Configuration for VPC tagging + */ +export interface SpicyVpcTags { + owner: string; + product: string; + component: string; + build?: string; +} + +/** + * Properties for the SpicyVpc construct + */ +export interface SpicyVpcProps { + /** + * The CIDR block for the VPC + * @default "172.1.0.0/16" + */ + readonly vpcCidr?: string; + + /** + * The tenancy of instances launched into the VPC + * @default "default" + */ + readonly vpcTenancy?: 'default' | 'dedicated'; + + /** + * List of availability zones to use + * If not provided, will use the first N AZs in the region based on numberOfAzs + */ + readonly availabilityZones?: string[]; + + /** + * Number of availability zones to use (2, 3, or 4) + * @default 4 + */ + readonly numberOfAzs?: 2 | 3 | 4; + + /** + * Whether to create private subnets + * @default true + */ + readonly createPrivateSubnets?: boolean; + + /** + * Whether to create additional private subnets with dedicated NACLs + * Only applies if createPrivateSubnets is true + * @default true + */ + readonly createAdditionalPrivateSubnets?: boolean; + + /** + * Subnet CIDR configurations per AZ + * Keys should be 'a', 'b', 'c', 'd' for each AZ + */ + readonly subnetCidrs?: { + a?: SubnetCidrConfig; + b?: SubnetCidrConfig; + c?: SubnetCidrConfig; + d?: SubnetCidrConfig; + }; + + /** + * Tag to add to public subnets (Key=Value format) + * @default "Network=Public" + */ + readonly publicSubnetTag?: string; + + /** + * Tag to add to primary private subnets (Key=Value format) + * @default "Network=Private" + */ + readonly privateSubnetATag?: string; + + /** + * Tag to add to secondary private subnets with NACLs (Key=Value format) + * @default "Network=Private" + */ + readonly privateSubnetBTag?: string; + + /** + * Required tags for the VPC and resources + */ + readonly tags: SpicyVpcTags; + + /** + * Whether to create VPC endpoints for AWS services + * @default true + */ + readonly createVpcEndpoints?: boolean; +} + +/** + * Default CIDR configurations matching the original CloudFormation template + */ +const DEFAULT_SUBNET_CIDRS: Required = { + a: { + publicCidr: '172.1.128.0/20', + private1Cidr: '172.1.0.0/19', + private2Cidr: '172.1.192.0/21', + }, + b: { + publicCidr: '172.1.144.0/20', + private1Cidr: '172.1.32.0/19', + private2Cidr: '172.1.200.0/21', + }, + c: { + publicCidr: '172.1.160.0/20', + private1Cidr: '172.1.64.0/19', + private2Cidr: '172.1.208.0/21', + }, + d: { + publicCidr: '172.1.176.0/20', + private1Cidr: '172.1.96.0/19', + private2Cidr: '172.1.216.0/21', + }, +}; + +/** + * SpicyVpc - A multi-AZ VPC construct with public and private subnets, + * NAT gateways, and optional VPC endpoints. + * + * This construct mirrors the functionality of the original CloudFormation + * template with explicit logical IDs for idempotent deployments. + */ +export class SpicyVpc extends Construct { + /** The VPC created by this construct */ + public readonly vpc: ec2.IVpc; + + /** Public subnets indexed by AZ letter (a, b, c, d) */ + public readonly publicSubnets: Map = new Map(); + + /** Primary private subnets indexed by AZ letter */ + public readonly privateSubnets1: Map = new Map(); + + /** Secondary private subnets (with NACLs) indexed by AZ letter */ + public readonly privateSubnets2: Map = new Map(); + + /** NAT Gateway EIPs indexed by AZ letter */ + public readonly natEips: Map = new Map(); + + /** S3 Gateway Endpoint */ + public readonly s3Endpoint?: ec2.GatewayVpcEndpoint; + + constructor(scope: Construct, id: string, props: SpicyVpcProps) { + super(scope, id); + + const vpcCidr = props.vpcCidr ?? '172.1.0.0/16'; + const numberOfAzs = props.numberOfAzs ?? 4; + const createPrivateSubnets = props.createPrivateSubnets ?? true; + const createAdditionalPrivateSubnets = props.createAdditionalPrivateSubnets ?? true; + const createVpcEndpoints = props.createVpcEndpoints ?? true; + + // Merge provided CIDRs with defaults + const subnetCidrs = { + ...DEFAULT_SUBNET_CIDRS, + ...props.subnetCidrs, + }; + + // Parse subnet tags + const publicSubnetTagParsed = this.parseTag(props.publicSubnetTag ?? 'Network=Public'); + const privateSubnetATagParsed = this.parseTag(props.privateSubnetATag ?? 'Network=Private'); + const privateSubnetBTagParsed = this.parseTag(props.privateSubnetBTag ?? 'Network=Private'); + + // Create the VPC using L1 constructs with explicit logical IDs + const cfnVpc = new ec2.CfnVPC(this, 'VPC', { + cidrBlock: vpcCidr, + enableDnsHostnames: true, + enableDnsSupport: true, + instanceTenancy: props.vpcTenancy ?? 'default', + tags: [ + { key: 'Name', value: cdk.Stack.of(this).stackName }, + { key: 'Owner', value: props.tags.owner }, + { key: 'Product', value: props.tags.product }, + { key: 'Component', value: props.tags.component }, + ...(props.tags.build ? [{ key: 'Build', value: props.tags.build }] : []), + ], + }); + ((cfnVpc.node.defaultChild as cdk.CfnResource) ?? cfnVpc).overrideLogicalId('VPC'); + + // Create Internet Gateway + const igw = new ec2.CfnInternetGateway(this, 'InternetGateway', { + tags: [{ key: 'Name', value: cdk.Stack.of(this).stackName }], + }); + ((igw.node.defaultChild as cdk.CfnResource) ?? igw).overrideLogicalId('InternetGateway'); + + const vpcGatewayAttachment = new ec2.CfnVPCGatewayAttachment(this, 'VPCGatewayAttachment', { + vpcId: cfnVpc.ref, + internetGatewayId: igw.ref, + }); + ((vpcGatewayAttachment.node.defaultChild as cdk.CfnResource) ?? vpcGatewayAttachment).overrideLogicalId( + 'VPCGatewayAttachment' + ); + + // DHCP Options + const dhcpOptions = new ec2.CfnDHCPOptions(this, 'DHCPOptions', { + domainName: + cdk.Stack.of(this).region === 'ca-central-1' ? 'ec2.internal' : `${cdk.Stack.of(this).region}.compute.internal`, + domainNameServers: ['AmazonProvidedDNS'], + }); + ((dhcpOptions.node.defaultChild as cdk.CfnResource) ?? dhcpOptions).overrideLogicalId('DHCPOptions'); + + const dhcpAssociation = new ec2.CfnVPCDHCPOptionsAssociation(this, 'VPCDHCPOptionsAssociation', { + vpcId: cfnVpc.ref, + dhcpOptionsId: dhcpOptions.ref, + }); + ((dhcpAssociation.node.defaultChild as cdk.CfnResource) ?? dhcpAssociation).overrideLogicalId( + 'VPCDHCPOptionsAssociation' + ); + + // Public Route Table (shared across all public subnets) + const publicRouteTable = new ec2.CfnRouteTable(this, 'PublicSubnetRouteTable', { + vpcId: cfnVpc.ref, + tags: [ + { key: 'Name', value: 'Public Subnets' }, + { key: 'Network', value: 'Public' }, + ], + }); + ((publicRouteTable.node.defaultChild as cdk.CfnResource) ?? publicRouteTable).overrideLogicalId( + 'PublicSubnetRouteTable' + ); + + const publicRoute = new ec2.CfnRoute(this, 'PublicSubnetRoute', { + routeTableId: publicRouteTable.ref, + destinationCidrBlock: '0.0.0.0/0', + gatewayId: igw.ref, + }); + ((publicRoute.node.defaultChild as cdk.CfnResource) ?? publicRoute).overrideLogicalId('PublicSubnetRoute'); + + // Get AZ letters based on numberOfAzs + const azLetters = ['a', 'b', 'c', 'd'].slice(0, numberOfAzs); + + // Determine availability zones + const availabilityZones = + props.availabilityZones ?? azLetters.map((letter) => `${cdk.Stack.of(this).region}${letter}`); + + // Track route tables for VPC endpoints + const privateRouteTables: ec2.CfnRouteTable[] = []; + // Track private subnet 1 route tables for VPC import + const privateSubnet1RouteTables: ec2.CfnRouteTable[] = []; + + // Create subnets for each AZ + azLetters.forEach((azLetter, index) => { + const azName = availabilityZones[index]; + const azUpper = azLetter.toUpperCase(); + const cidrs = subnetCidrs[azLetter as keyof typeof subnetCidrs]!; + + // Public Subnet + const publicSubnet = new ec2.CfnSubnet(this, `PublicSubnet${azUpper}`, { + vpcId: cfnVpc.ref, + cidrBlock: cidrs.publicCidr, + availabilityZone: azName, + mapPublicIpOnLaunch: true, + tags: [ + { key: 'Name', value: `Public Subnet ${azUpper}` }, + ...(publicSubnetTagParsed ? [{ key: publicSubnetTagParsed.key, value: publicSubnetTagParsed.value }] : []), + ], + }); + ((publicSubnet.node.defaultChild as cdk.CfnResource) ?? publicSubnet).overrideLogicalId(`PublicSubnet${azUpper}`); + + const publicSubnetRtAssoc = new ec2.CfnSubnetRouteTableAssociation( + this, + `PublicSubnet${azUpper}RouteTableAssociation`, + { + subnetId: publicSubnet.ref, + routeTableId: publicRouteTable.ref, + } + ); + ((publicSubnetRtAssoc.node.defaultChild as cdk.CfnResource) ?? publicSubnetRtAssoc).overrideLogicalId( + `PublicSubnet${azUpper}RouteTableAssociation` + ); + + // Store reference + this.publicSubnets.set( + azLetter, + ec2.Subnet.fromSubnetAttributes(this, `PublicSubnet${azUpper}Import`, { + subnetId: publicSubnet.ref, + availabilityZone: azName, + routeTableId: publicRouteTable.ref, + }) + ); + + if (createPrivateSubnets) { + // NAT Gateway EIP + const natEip = new ec2.CfnEIP(this, `NATZone${azUpper}EIP`, { + domain: 'vpc', + }); + natEip.addDependency(igw); + ((natEip.node.defaultChild as cdk.CfnResource) ?? natEip).overrideLogicalId(`NATZone${azUpper}EIP`); + this.natEips.set(azLetter, natEip); + + // NAT Gateway + const natGateway = new ec2.CfnNatGateway(this, `NATGatewayZone${azUpper}`, { + allocationId: natEip.attrAllocationId, + subnetId: publicSubnet.ref, + }); + natGateway.addDependency(igw); + ((natGateway.node.defaultChild as cdk.CfnResource) ?? natGateway).overrideLogicalId(`NATGatewayZone${azUpper}`); + + // Private Subnet 1 Route Table + const privateRouteTable1 = new ec2.CfnRouteTable(this, `PrivateSubnet${azUpper}1RouteTable`, { + vpcId: cfnVpc.ref, + tags: [ + { key: 'Name', value: `Private Subnet ${azUpper}1` }, + { key: 'Network', value: 'Private' }, + ], + }); + ((privateRouteTable1.node.defaultChild as cdk.CfnResource) ?? privateRouteTable1).overrideLogicalId( + `PrivateSubnet${azUpper}1RouteTable` + ); + privateRouteTables.push(privateRouteTable1); + privateSubnet1RouteTables.push(privateRouteTable1); + + const privateRoute1 = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}1Route`, { + routeTableId: privateRouteTable1.ref, + destinationCidrBlock: '0.0.0.0/0', + natGatewayId: natGateway.ref, + }); + ((privateRoute1.node.defaultChild as cdk.CfnResource) ?? privateRoute1).overrideLogicalId( + `PrivateSubnet${azUpper}1Route` + ); + + // Private Subnet 1 + const privateSubnet1 = new ec2.CfnSubnet(this, `PrivateSubnet${azUpper}1`, { + vpcId: cfnVpc.ref, + cidrBlock: cidrs.private1Cidr, + availabilityZone: azName, + tags: [ + { key: 'Name', value: `Private Subnet ${azUpper}1` }, + ...(privateSubnetATagParsed + ? [ + { + key: privateSubnetATagParsed.key, + value: privateSubnetATagParsed.value, + }, + ] + : []), + ], + }); + ((privateSubnet1.node.defaultChild as cdk.CfnResource) ?? privateSubnet1).overrideLogicalId( + `PrivateSubnet${azUpper}1` + ); + + const privateSubnet1RtAssoc = new ec2.CfnSubnetRouteTableAssociation( + this, + `PrivateSubnet${azUpper}1RouteTableAssociation`, + { + subnetId: privateSubnet1.ref, + routeTableId: privateRouteTable1.ref, + } + ); + ((privateSubnet1RtAssoc.node.defaultChild as cdk.CfnResource) ?? privateSubnet1RtAssoc).overrideLogicalId( + `PrivateSubnet${azUpper}1RouteTableAssociation` + ); + + this.privateSubnets1.set( + azLetter, + ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}1Import`, { + subnetId: privateSubnet1.ref, + availabilityZone: azName, + routeTableId: privateRouteTable1.ref, + }) + ); + + // Private Subnet 2 (with NACL) - only if enabled + if (createAdditionalPrivateSubnets && cidrs.private2Cidr) { + // Private Subnet 2 Route Table + const privateRouteTable2 = new ec2.CfnRouteTable(this, `PrivateSubnet${azUpper}2RouteTable`, { + vpcId: cfnVpc.ref, + tags: [ + { key: 'Name', value: `Private Subnet ${azUpper}2` }, + { key: 'Network', value: 'Private' }, + ], + }); + ((privateRouteTable2.node.defaultChild as cdk.CfnResource) ?? privateRouteTable2).overrideLogicalId( + `PrivateSubnet${azUpper}2RouteTable` + ); + privateRouteTables.push(privateRouteTable2); + + const privateRoute2 = new ec2.CfnRoute(this, `PrivateSubnet${azUpper}2Route`, { + routeTableId: privateRouteTable2.ref, + destinationCidrBlock: '0.0.0.0/0', + natGatewayId: natGateway.ref, + }); + ((privateRoute2.node.defaultChild as cdk.CfnResource) ?? privateRoute2).overrideLogicalId( + `PrivateSubnet${azUpper}2Route` + ); + + // Private Subnet 2 + const privateSubnet2 = new ec2.CfnSubnet(this, `PrivateSubnet${azUpper}2`, { + vpcId: cfnVpc.ref, + cidrBlock: cidrs.private2Cidr, + availabilityZone: azName, + tags: [ + { key: 'Name', value: `Private Subnet ${azUpper}2` }, + ...(privateSubnetBTagParsed + ? [ + { + key: privateSubnetBTagParsed.key, + value: privateSubnetBTagParsed.value, + }, + ] + : []), + ], + }); + ((privateSubnet2.node.defaultChild as cdk.CfnResource) ?? privateSubnet2).overrideLogicalId( + `PrivateSubnet${azUpper}2` + ); + + const privateSubnet2RtAssoc = new ec2.CfnSubnetRouteTableAssociation( + this, + `PrivateSubnet${azUpper}2RouteTableAssociation`, + { + subnetId: privateSubnet2.ref, + routeTableId: privateRouteTable2.ref, + } + ); + ((privateSubnet2RtAssoc.node.defaultChild as cdk.CfnResource) ?? privateSubnet2RtAssoc).overrideLogicalId( + `PrivateSubnet${azUpper}2RouteTableAssociation` + ); + + // Network ACL for Private Subnet 2 + const nacl = new ec2.CfnNetworkAcl(this, `PrivateSubnet${azUpper}2NetworkACL`, { + vpcId: cfnVpc.ref, + tags: [ + { key: 'Name', value: `NACL Protected subnet ${azUpper}` }, + { key: 'Network', value: 'NACL Protected' }, + ], + }); + ((nacl.node.defaultChild as cdk.CfnResource) ?? nacl).overrideLogicalId(`PrivateSubnet${azUpper}2NetworkACL`); + + // Allow all inbound + const naclInbound = new ec2.CfnNetworkAclEntry(this, `PrivateSubnet${azUpper}2NetworkACLEntryInbound`, { + networkAclId: nacl.ref, + ruleNumber: 100, + protocol: -1, + ruleAction: 'allow', + egress: false, + cidrBlock: '0.0.0.0/0', + }); + ((naclInbound.node.defaultChild as cdk.CfnResource) ?? naclInbound).overrideLogicalId( + `PrivateSubnet${azUpper}2NetworkACLEntryInbound` + ); + + // Allow all outbound + const naclOutbound = new ec2.CfnNetworkAclEntry(this, `PrivateSubnet${azUpper}2NetworkACLEntryOutbound`, { + networkAclId: nacl.ref, + ruleNumber: 100, + protocol: -1, + ruleAction: 'allow', + egress: true, + cidrBlock: '0.0.0.0/0', + }); + ((naclOutbound.node.defaultChild as cdk.CfnResource) ?? naclOutbound).overrideLogicalId( + `PrivateSubnet${azUpper}2NetworkACLEntryOutbound` + ); + + const naclAssociation = new ec2.CfnSubnetNetworkAclAssociation( + this, + `PrivateSubnet${azUpper}2NetworkACLAssociation`, + { + subnetId: privateSubnet2.ref, + networkAclId: nacl.ref, + } + ); + ((naclAssociation.node.defaultChild as cdk.CfnResource) ?? naclAssociation).overrideLogicalId( + `PrivateSubnet${azUpper}2NetworkACLAssociation` + ); + + this.privateSubnets2.set( + azLetter, + ec2.Subnet.fromSubnetAttributes(this, `PrivateSubnet${azUpper}2Import`, { + subnetId: privateSubnet2.ref, + availabilityZone: azName, + routeTableId: privateRouteTable2.ref, + }) + ); + } + } + }); + + // Create VPC from the L1 construct for use with L2 constructs + // Build route table IDs arrays matching the subnet order + const publicSubnetRouteTableIds = Array.from(this.publicSubnets.values()).map(() => publicRouteTable.ref); + const privateSubnetRouteTableIds = privateSubnet1RouteTables.map((rt) => rt.ref); + + this.vpc = ec2.Vpc.fromVpcAttributes(this, 'VpcImport', { + vpcId: cfnVpc.ref, + availabilityZones: availabilityZones, + publicSubnetIds: Array.from(this.publicSubnets.values()).map((s) => s.subnetId), + privateSubnetIds: Array.from(this.privateSubnets1.values()).map((s) => s.subnetId), + publicSubnetRouteTableIds: publicSubnetRouteTableIds, + privateSubnetRouteTableIds: privateSubnetRouteTableIds, + }); + + // S3 VPC Endpoint (Gateway type - free) + if (createPrivateSubnets && createVpcEndpoints) { + const s3Endpoint = new ec2.CfnVPCEndpoint(this, 'S3VPCEndpoint', { + vpcId: cfnVpc.ref, + serviceName: `com.amazonaws.${cdk.Stack.of(this).region}.s3`, + routeTableIds: privateRouteTables.map((rt) => rt.ref), + policyDocument: { + Version: '2012-10-17', + Statement: [ + { + Action: '*', + Effect: 'Allow', + Resource: '*', + Principal: '*', + }, + ], + }, + }); + ((s3Endpoint.node.defaultChild as cdk.CfnResource) ?? s3Endpoint).overrideLogicalId('S3VPCEndpoint'); + } + + // Add CloudFormation Outputs matching the original template + this.addOutputs( + cfnVpc, + azLetters, + publicRouteTable, + privateRouteTables, + createPrivateSubnets, + createAdditionalPrivateSubnets + ); + } + + /** + * Parse a tag string in "Key=Value" format + */ + private parseTag(tagString: string): { key: string; value: string } | null { + if (!tagString || !tagString.includes('=')) { + return null; + } + const [key, ...valueParts] = tagString.split('='); + return { key, value: valueParts.join('=') }; + } + + /** + * Add CloudFormation outputs for cross-stack references + * Output logical IDs match the original CloudFormation template + */ + private addOutputs( + cfnVpc: ec2.CfnVPC, + azLetters: string[], + publicRouteTable: ec2.CfnRouteTable, + privateRouteTables: ec2.CfnRouteTable[], + createPrivateSubnets: boolean, + createAdditionalPrivateSubnets: boolean + ): void { + const stack = cdk.Stack.of(this); + + // VPC outputs + const vpcIdOutput = new cdk.CfnOutput(this, 'VPCID', { + value: cfnVpc.ref, + description: 'VPC ID', + exportName: `${stack.stackName}-VPCID`, + }); + vpcIdOutput.overrideLogicalId('VPCID'); + + const vpcCidrOutput = new cdk.CfnOutput(this, 'VPCCIDR', { + value: cfnVpc.cidrBlock!, + description: 'VPC CIDR', + exportName: `${stack.stackName}-VPCCIDR`, + }); + vpcCidrOutput.overrideLogicalId('VPCCIDR'); + + // Number of AZs output (for importing by other stacks) + const numberOfAzsOutput = new cdk.CfnOutput(this, 'NumberOfAZs', { + value: azLetters.length.toString(), + description: 'Number of Availability Zones', + exportName: `${stack.stackName}-NumberOfAZs`, + }); + numberOfAzsOutput.overrideLogicalId('NumberOfAZs'); + + // Public Route Table output + const publicRtOutput = new cdk.CfnOutput(this, 'PublicSubnetRouteTableOutput', { + value: publicRouteTable.ref, + description: 'Public Subnet Route Table', + exportName: `${stack.stackName}-PublicSubnetRouteTable`, + }); + publicRtOutput.overrideLogicalId('PublicSubnetRouteTableOutput'); + + // Public subnet outputs + azLetters.forEach((azLetter) => { + const azUpper = azLetter.toUpperCase(); + const subnet = this.publicSubnets.get(azLetter); + if (subnet) { + const output = new cdk.CfnOutput(this, `PublicSubnet${azUpper}ID`, { + value: subnet.subnetId, + description: `Public Subnet ${azUpper} ID`, + exportName: `${stack.stackName}-PublicSubnet${azUpper}ID`, + }); + output.overrideLogicalId(`PublicSubnet${azUpper}ID`); + } + }); + + // Private subnet outputs + if (createPrivateSubnets) { + azLetters.forEach((azLetter, index) => { + const azUpper = azLetter.toUpperCase(); + + // Private Subnet 1 + const subnet1 = this.privateSubnets1.get(azLetter); + if (subnet1) { + const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}1ID`, { + value: subnet1.subnetId, + description: `Private Subnet 1 ID in Availability Zone ${azUpper}`, + exportName: `${stack.stackName}-PrivateSubnet${azUpper}1ID`, + }); + output.overrideLogicalId(`PrivateSubnet${azUpper}1ID`); + } + + // NAT EIP Output + const natEip = this.natEips.get(azLetter); + if (natEip) { + const output = new cdk.CfnOutput(this, `NATZone${azUpper}EIPOutput`, { + value: natEip.ref, + description: `NAT ${azUpper} IP address`, + exportName: `${stack.stackName}-NATZone${azUpper}EIP`, + }); + output.overrideLogicalId(`NATZone${azUpper}EIPOutput`); + } + + // Private Route Table 1 Output + const rtIndex = index * (createAdditionalPrivateSubnets ? 2 : 1); + if (privateRouteTables[rtIndex]) { + const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}1RouteTableOutput`, { + value: privateRouteTables[rtIndex].ref, + description: `Private Subnet ${azUpper}1 Route Table`, + exportName: `${stack.stackName}-PrivateSubnet${azUpper}1RouteTable`, + }); + output.overrideLogicalId(`PrivateSubnet${azUpper}1RouteTableOutput`); + } + + // Private Subnet 2 + if (createAdditionalPrivateSubnets) { + const subnet2 = this.privateSubnets2.get(azLetter); + if (subnet2) { + const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}2ID`, { + value: subnet2.subnetId, + description: `Private Subnet 2 ID in Availability Zone ${azUpper}`, + exportName: `${stack.stackName}-PrivateSubnet${azUpper}2ID`, + }); + output.overrideLogicalId(`PrivateSubnet${azUpper}2ID`); + } + + // Private Route Table 2 Output + if (privateRouteTables[rtIndex + 1]) { + const output = new cdk.CfnOutput(this, `PrivateSubnet${azUpper}2RouteTableOutput`, { + value: privateRouteTables[rtIndex + 1].ref, + description: `Private Subnet ${azUpper}2 Route Table`, + exportName: `${stack.stackName}-PrivateSubnet${azUpper}2RouteTable`, + }); + output.overrideLogicalId(`PrivateSubnet${azUpper}2RouteTableOutput`); + } + } + }); + } + } +} diff --git a/resources/lib/index.ts b/resources/lib/index.ts new file mode 100644 index 0000000..fc0ec8a --- /dev/null +++ b/resources/lib/index.ts @@ -0,0 +1,5 @@ +// Main entry point for spicy-automation library +// Export all constructs and stacks for use as a library + +export * from './constructs'; +export * from './stacks'; diff --git a/resources/lib/stacks/index.ts b/resources/lib/stacks/index.ts new file mode 100644 index 0000000..b9121e0 --- /dev/null +++ b/resources/lib/stacks/index.ts @@ -0,0 +1,6 @@ +// Export all stacks +export * from './spicy-vpc-stack'; +export * from './spicy-ecs-cluster-stack'; +export * from './spicy-ecs-service-stack'; +export * from './spicy-alb-stack'; + diff --git a/resources/lib/stacks/spicy-alb-stack.ts b/resources/lib/stacks/spicy-alb-stack.ts new file mode 100644 index 0000000..9b80aa4 --- /dev/null +++ b/resources/lib/stacks/spicy-alb-stack.ts @@ -0,0 +1,210 @@ +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; +import { PersistentAlbBlueGreenDnsConfig, SpicyAlb, SpicyAlbTags } from '../constructs/spicy-alb'; + +/** + * Props for SpicyAlbStack + */ +export interface SpicyAlbStackProps extends Omit { + /** VPC ID */ + readonly vpcId: string; + + /** VPC CIDR block */ + readonly vpcCidrBlock?: string; + + /** Number of availability zones (2-4) */ + readonly numberOfAzs: number; + + /** Availability zones */ + readonly availabilityZones: string[]; + + /** Subnet IDs for the ALB */ + readonly subnetIds: string[]; + + /** ALB scheme: "internet-facing" or "internal" */ + readonly scheme: 'internet-facing' | 'internal'; + + /** SSL certificate ARN for HTTPS listener */ + readonly certificateArn?: string; + + /** Idle timeout in seconds */ + readonly idleTimeout?: number; + + /** S3 bucket name for ALB access logs */ + readonly logsBucketName?: string; + + /** S3 prefix for ALB access logs */ + readonly logsPrefix?: string; + + /** Enable HTTP→HTTPS redirect */ + readonly redirectHttpToHttps?: boolean; + + /** Blue/Green DNS configuration */ + readonly blueGreenDns?: PersistentAlbBlueGreenDnsConfig; + + /** Required tags */ + readonly tags: SpicyAlbTags; +} + +/** + * Stack for deploying a persistent ALB (bg-common pattern) + * + * This stack should be deployed once and kept running. The ALB DNS name + * will remain constant even when service stacks are deleted and recreated. + */ +export class SpicyAlbStack extends cdk.Stack { + public readonly alb: SpicyAlb; + + constructor(scope: Construct, id: string, props: SpicyAlbStackProps) { + const { tags, ...stackProps } = props; + super(scope, id, stackProps); + + // Import VPC (using fromVpcAttributes to avoid requiring AWS credentials for synth) + // For ALB, we need to provide subnet IDs - use the subnets we're deploying the ALB to + // For internal ALB, these are typically private subnets; for internet-facing, they're public + const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', { + vpcId: props.vpcId, + availabilityZones: props.availabilityZones, + // Provide subnet IDs based on scheme (internal = private, internet-facing = public) + // We provide them in both arrays to satisfy CDK's requirements, but only the relevant ones are used + ...(props.scheme === 'internal' ? { privateSubnetIds: props.subnetIds } : { publicSubnetIds: props.subnetIds }), + }); + + // Create subnet references + const subnets = props.subnetIds.map((subnetId, index) => + ec2.Subnet.fromSubnetAttributes(this, `Subnet${index}`, { + subnetId, + availabilityZone: props.availabilityZones[index % props.availabilityZones.length], + }) + ); + + // Create logs bucket if specified + let logsBucket: s3.IBucket | undefined; + if (props.logsBucketName) { + logsBucket = s3.Bucket.fromBucketName(this, 'LogsBucket', props.logsBucketName); + } + + // Create ALB construct + this.alb = new SpicyAlb(this, 'ALB', { + vpc, + vpcCidrBlock: props.vpcCidrBlock, + scheme: props.scheme, + subnets, + availabilityZones: props.availabilityZones, + certificateArn: props.certificateArn, + idleTimeout: props.idleTimeout, + logsBucket, + logsPrefix: props.logsPrefix, + redirectHttpToHttps: props.redirectHttpToHttps, + blueGreenDns: props.blueGreenDns, + tags: props.tags, + }); + } + + /** + * Create stack from CDK context + * Only requires clusterStackName - VPC stack name imported from cluster export, then all VPC details imported from VPC stack + */ + static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyAlbStack { + const app = scope.node.root as cdk.App; + if (!app) { + throw new Error('SpicyAlbStack.fromContext must be called from within a CDK App'); + } + + // Required: clusterStackName (VPC stack name imported from cluster export, then VPC details imported from VPC stack) + const clusterStackName = app.node.tryGetContext('clusterStackName') || app.node.tryGetContext('clusterName'); + if (!clusterStackName) { + throw new Error( + 'clusterStackName is required. Provide clusterStackName to import VPC stack name from cluster export, then import VPC details from VPC stack.' + ); + } + + // Import VPC stack name from cluster stack (cluster exports: ${clusterStackName}-VPCStackName) + const vpcStackName = cdk.Fn.importValue(`${clusterStackName}-VPCStackName`).toString(); + + // Import VPC ID from cluster stack (cluster exports: ${clusterStackName}-VPC) + const vpcId = cdk.Fn.importValue(`${clusterStackName}-VPC`); + + // Import all VPC details from VPC stack exports + const vpcCidrBlock = cdk.Fn.importValue(`${vpcStackName}-VPCCIDR`).toString(); + const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs'); + const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN; + if (!numberOfAzs || Number.isNaN(numberOfAzs)) { + throw new Error('numberOfAzs is required in context (2-4) to import VPC subnets for ALB.'); + } + const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(numberOfAzs, 1), 4)); + + // Import subnets based on scheme (public for internet-facing, private for internal) + const scheme = app.node.tryGetContext('scheme') ?? 'internet-facing'; + const subnetIds = azs.map((az) => { + if (scheme === 'internet-facing') { + return cdk.Fn.importValue(`${vpcStackName}-PublicSubnet${az}ID`).toString(); + } else { + return cdk.Fn.importValue(`${vpcStackName}-PrivateSubnet${az}1ID`).toString(); + } + }); + + // Derive availability zones from region + const region = cdk.Stack.of(scope).region; + const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`); + const certificateArn = app.node.tryGetContext('certificateArn'); + const idleTimeout = app.node.tryGetContext('idleTimeout') + ? parseInt(app.node.tryGetContext('idleTimeout')) + : undefined; + const logsBucketName = app.node.tryGetContext('logsBucketName'); + const logsPrefix = app.node.tryGetContext('logsPrefix'); + const redirectHttpToHttps = app.node.tryGetContext('redirectHttpToHttps') !== 'false'; + + // Blue/Green DNS + const activeHostname = app.node.tryGetContext('activeHostname'); + const inactiveHostname = app.node.tryGetContext('inactiveHostname'); + const bgHostedZoneId = app.node.tryGetContext('bgHostedZoneId'); + + let blueGreenDns: PersistentAlbBlueGreenDnsConfig | undefined; + if (activeHostname && inactiveHostname && bgHostedZoneId) { + blueGreenDns = { + activeHostname, + inactiveHostname, + hostedZoneId: bgHostedZoneId, + }; + } + + // Tags + const ownerTag = app.node.tryGetContext('ownerTag'); + const productTag = app.node.tryGetContext('productTag'); + const componentTag = app.node.tryGetContext('componentTag'); + const environmentTag = app.node.tryGetContext('environmentTag') ?? app.node.tryGetContext('environment'); + const buildTag = app.node.tryGetContext('buildTag') ?? app.node.tryGetContext('build'); + + if (!ownerTag || !productTag || !componentTag) { + throw new Error('ownerTag, productTag, and componentTag context are required'); + } + + const tags: SpicyAlbTags = { + owner: ownerTag, + product: productTag, + component: componentTag, + environment: environmentTag ?? 'dev', + build: buildTag, + }; + + return new SpicyAlbStack(scope, id, { + ...stackProps, + vpcId: vpcId.toString(), + vpcCidrBlock, + numberOfAzs, + availabilityZones, + subnetIds, + scheme: scheme as 'internet-facing' | 'internal', + certificateArn, + idleTimeout, + logsBucketName, + logsPrefix, + redirectHttpToHttps, + blueGreenDns, + tags: tags, + }); + } +} diff --git a/resources/lib/stacks/spicy-ecs-cluster-stack.ts b/resources/lib/stacks/spicy-ecs-cluster-stack.ts new file mode 100644 index 0000000..7154e95 --- /dev/null +++ b/resources/lib/stacks/spicy-ecs-cluster-stack.ts @@ -0,0 +1,332 @@ +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; +import { + ClusterScalingConfig, + LoadBalancerConfig, + SpicyEcsCluster, + SpicyEcsClusterTags, + SpotConfig, +} from '../constructs/spicy-ecs-cluster'; + +/** + * Props for SpicyEcsClusterStack + */ +export interface SpicyEcsClusterStackProps extends cdk.StackProps { + /** + * VPC stack name to import VPC details from (required) + * All VPC details (VPC ID, CIDR, subnets, AZs) will be imported from VPC stack exports + */ + readonly vpcStackName: string; + + /** + * Number of availability zones (2-4) used by the VPC + * Must match the VPC stack export to correctly import subnet IDs. + */ + readonly numberOfAzs: number; + + /** + * EC2 instance type + * @default m5a.large + */ + readonly instanceType?: string; + + /** + * Additional instance types for mixed instances policy + */ + readonly additionalInstanceTypes?: string[]; + + /** + * EC2 Key Pair name + */ + readonly keyName?: string; + + /** + * EBS volume size in GB + * @default 100 + */ + readonly ebsVolumeSize?: number; + + /** + * Enable Container Insights + * @default true + */ + readonly containerInsights?: boolean; + + /** + * Minimum cluster size + * @default 2 + */ + readonly minClusterSize?: number; + + /** + * Maximum cluster size + * @default 4 + */ + readonly maxClusterSize?: number; + + /** + * Target capacity utilization percentage + * @default 100 + */ + readonly targetCapacityPercent?: number; + + /** + * Enable Spot instances + * @default false + */ + readonly spotEnabled?: boolean; + + /** + * On-Demand percentage when Spot is enabled + * @default 100 + */ + readonly onDemandPercentage?: number; + + /** + * Spot allocation strategy + * @default capacity-optimized + */ + readonly spotAllocationStrategy?: 'lowest-price' | 'capacity-optimized' | 'capacity-optimized-prioritized'; + + /** + * Create external (internet-facing) load balancer + * @default false + */ + readonly createExternalLoadBalancer?: boolean; + + /** + * Create internal load balancer + * @default false + */ + readonly createInternalLoadBalancer?: boolean; + + /** + * SSL certificate ARN for HTTPS + */ + readonly certificateArn?: string; + + /** + * Enable Fargate capacity providers (adds both FARGATE and FARGATE_SPOT) + * @default false + */ + readonly enableFargate?: boolean; + + /** + * Draining timeout in seconds + * @default 900 + */ + readonly drainingTimeout?: number; + + /** + * Max instance lifetime in seconds + * @default 604800 (7 days) + */ + readonly maxInstanceLifetime?: number; + + /** + * Required cluster tags + */ + readonly clusterTags: SpicyEcsClusterTags; +} + +/** + * Stack for deploying a SpicyEcsCluster + */ +export class SpicyEcsClusterStack extends cdk.Stack { + public readonly cluster: SpicyEcsCluster; + + constructor(scope: Construct, id: string, props: SpicyEcsClusterStackProps) { + super(scope, id, props); + + // Validate required inputs + if (!props.vpcStackName) { + throw new Error('vpcStackName is required for ECS Cluster stack.'); + } + + // Import all VPC details from VPC stack exports + // VPC stack exports: ${vpcStackName}-VPCID, ${vpcStackName}-VPCCIDR, ${vpcStackName}-NumberOfAZs, etc. + const vpcId = cdk.Fn.importValue(`${props.vpcStackName}-VPCID`); + const vpcCidrBlock = cdk.Fn.importValue(`${props.vpcStackName}-VPCCIDR`).toString(); + if (!props.numberOfAzs || Number.isNaN(props.numberOfAzs)) { + throw new Error('numberOfAzs is required and must be a number (2-4) to import subnets.'); + } + const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(props.numberOfAzs, 1), 4)); + + // Import private subnet IDs (always needed for ECS instances) + const privateSubnetIds = azs.map((az) => + cdk.Fn.importValue(`${props.vpcStackName}-PrivateSubnet${az}1ID`).toString() + ); + + // Import public subnet IDs if external load balancer is requested + let publicSubnetIds: string[] | undefined; + if (props.createExternalLoadBalancer) { + publicSubnetIds = azs.map((az) => cdk.Fn.importValue(`${props.vpcStackName}-PublicSubnet${az}ID`).toString()); + } + + // Derive availability zones from region + const region = cdk.Stack.of(this).region; + const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`); + + // Build VPC attributes for importing + const vpcAttributes: ec2.VpcAttributes = { + vpcId: vpcId.toString(), + availabilityZones, + privateSubnetIds, + ...(publicSubnetIds && publicSubnetIds.length > 0 ? { publicSubnetIds } : {}), + }; + + const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', vpcAttributes); + + // Parse instance types + const instanceType = props.instanceType + ? new ec2.InstanceType(props.instanceType) + : ec2.InstanceType.of(ec2.InstanceClass.M5A, ec2.InstanceSize.LARGE); + + const additionalInstanceTypes = props.additionalInstanceTypes?.map((t) => new ec2.InstanceType(t)); + + // Build scaling config + const scaling: ClusterScalingConfig = { + minCapacity: props.minClusterSize ?? 2, + maxCapacity: props.maxClusterSize ?? 4, + targetCapacityPercent: props.targetCapacityPercent ?? 100, + }; + + // Build spot config + const spot: SpotConfig = { + enabled: props.spotEnabled ?? false, + onDemandPercentage: props.onDemandPercentage ?? 100, + spotAllocationStrategy: props.spotAllocationStrategy ?? 'capacity-optimized', + }; + + // Build load balancer config + const loadBalancer: LoadBalancerConfig = { + createExternal: props.createExternalLoadBalancer ?? false, + createInternal: props.createInternalLoadBalancer ?? false, + certificateArn: props.certificateArn, + }; + + // Create the cluster + this.cluster = new SpicyEcsCluster(this, 'EcsCluster', { + vpc, + vpcCidrBlock, + vpcStackName: props.vpcStackName, + instanceType, + additionalInstanceTypes, + keyName: props.keyName, + ebsVolumeSize: props.ebsVolumeSize, + containerInsights: props.containerInsights, + scaling, + spot, + loadBalancer, + enableFargate: props.enableFargate, + drainingTimeout: props.drainingTimeout, + maxInstanceLifetime: props.maxInstanceLifetime, + tags: props.clusterTags, + instanceSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + externalSubnets: publicSubnetIds ? { subnetType: ec2.SubnetType.PUBLIC } : undefined, + internalSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS }, + }); + } + + /** + * Create stack from CDK context + * Only requires vpcStackName - all VPC details are imported from VPC stack exports + */ + static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyEcsClusterStack { + const app = scope.node.root as cdk.App; + + // Required: VPC stack name (all VPC details imported from exports) + const vpcStackName = app.node.tryGetContext('vpcStackName'); + if (!vpcStackName) { + throw new Error( + 'vpcStackName is required. Provide vpcStackName to import all VPC details from VPC stack exports.' + ); + } + const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs'); + const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN; + if (!numberOfAzs || Number.isNaN(numberOfAzs)) { + throw new Error('numberOfAzs is required in context (2-4) to import subnets from the VPC stack.'); + } + + // Tags + const tags: SpicyEcsClusterTags = { + owner: app.node.tryGetContext('ownerTag') ?? 'Unknown', + product: app.node.tryGetContext('productTag') ?? 'Unknown', + component: app.node.tryGetContext('componentTag') ?? 'ecs-cluster', + environment: app.node.tryGetContext('environment') ?? 'dev', + build: app.node.tryGetContext('build'), + }; + + // Optional context values + const instanceType = app.node.tryGetContext('instanceType'); + const additionalInstanceTypes = app.node.tryGetContext('additionalInstanceTypes')?.split(','); + const keyName = app.node.tryGetContext('keyName'); + const ebsVolumeSize = app.node.tryGetContext('ebsVolumeSize') + ? parseInt(app.node.tryGetContext('ebsVolumeSize')) + : undefined; + const containerInsights = app.node.tryGetContext('containerInsights') !== 'false'; + + // Scaling + const minClusterSize = app.node.tryGetContext('minClusterSize') + ? parseInt(app.node.tryGetContext('minClusterSize')) + : undefined; + const maxClusterSize = app.node.tryGetContext('maxClusterSize') + ? parseInt(app.node.tryGetContext('maxClusterSize')) + : undefined; + const targetCapacityPercent = app.node.tryGetContext('targetCapacityPercent') + ? parseInt(app.node.tryGetContext('targetCapacityPercent')) + : undefined; + + // Spot + const spotEnabled = app.node.tryGetContext('spotEnabled') === 'true'; + const onDemandPercentage = app.node.tryGetContext('onDemandPercentage') + ? parseInt(app.node.tryGetContext('onDemandPercentage')) + : undefined; + const spotAllocationStrategy = app.node.tryGetContext('spotAllocationStrategy') as + | 'lowest-price' + | 'capacity-optimized' + | 'capacity-optimized-prioritized' + | undefined; + + // Load balancers + const createExternalLoadBalancer = app.node.tryGetContext('createExternalLoadBalancer') === 'true'; + const createInternalLoadBalancer = app.node.tryGetContext('createInternalLoadBalancer') === 'true'; + const certificateArn = app.node.tryGetContext('certificateArn'); + + // Fargate + const enableFargate = app.node.tryGetContext('enableFargate') === 'true'; + + // Timeouts + const drainingTimeout = app.node.tryGetContext('drainingTimeout') + ? parseInt(app.node.tryGetContext('drainingTimeout')) + : undefined; + const maxInstanceLifetime = app.node.tryGetContext('maxInstanceLifetime') + ? parseInt(app.node.tryGetContext('maxInstanceLifetime')) + : undefined; + + return new SpicyEcsClusterStack(scope, id, { + ...stackProps, + vpcStackName, + instanceType, + additionalInstanceTypes, + keyName, + ebsVolumeSize, + containerInsights, + minClusterSize, + maxClusterSize, + targetCapacityPercent, + spotEnabled, + onDemandPercentage, + spotAllocationStrategy, + createExternalLoadBalancer, + createInternalLoadBalancer, + certificateArn, + enableFargate, + drainingTimeout, + maxInstanceLifetime, + numberOfAzs, + clusterTags: tags, + }); + } +} diff --git a/resources/lib/stacks/spicy-ecs-service-stack.ts b/resources/lib/stacks/spicy-ecs-service-stack.ts new file mode 100644 index 0000000..becb5ac --- /dev/null +++ b/resources/lib/stacks/spicy-ecs-service-stack.ts @@ -0,0 +1,467 @@ +import * as ec2 from 'aws-cdk-lib/aws-ec2'; +import * as ecs from 'aws-cdk-lib/aws-ecs'; +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; +import { + CapacityProviderStrategyItem, + ContainerConfig, + DeploymentConfig, + DnsConfig, + HealthCheckConfig, + LoadBalancerRoutingConfig, + ServiceScalingConfig, + SpicyEcsService, + SpicyEcsServiceTags, +} from '../constructs/spicy-ecs-service'; + +/** + * Props for SpicyEcsServiceStack + */ +export interface SpicyEcsServiceStackProps extends cdk.StackProps { + /** ECS Cluster name to import */ + readonly clusterName: string; + + /** VPC ID */ + readonly vpcId: string; + + /** VPC CIDR block */ + readonly vpcCidrBlock?: string; + + /** Number of availability zones (2-4) */ + readonly numberOfAzs: number; + + /** Availability zones */ + readonly availabilityZones: string[]; + + /** Private subnet IDs */ + readonly privateSubnetIds: string[]; + + /** Service name */ + readonly serviceName: string; + + /** Docker image URI */ + readonly image: string; + + /** Container port */ + readonly containerPort: number; + + /** CPU units */ + readonly cpu?: number; + + /** Memory in MiB */ + readonly memory?: number; + + /** Environment variables (JSON string) */ + readonly environment?: string; + + /** Secrets (JSON string: { "ENV_VAR": "secret-arn" }) */ + readonly secrets?: string; + + /** Desired task count */ + readonly desiredCount?: number; + + /** Capacity provider strategy (JSON string) */ + readonly capacityProviderStrategy?: string; + + /** Scaling configuration */ + readonly scaling?: ServiceScalingConfig; + + /** Health check path */ + readonly healthCheckPath?: string; + + /** Routing configuration */ + readonly routing?: LoadBalancerRoutingConfig; + + /** External ALB listener ARN (for cluster ALB mode) */ + readonly externalListenerArn?: string; + + /** Internal ALB listener ARN (for cluster ALB mode) */ + readonly internalListenerArn?: string; + + /** Public subnet IDs (for individual ALB) */ + readonly publicSubnetIds?: string[]; + + /** Cluster logs bucket name (for individual ALB access logs) */ + readonly clusterLogsBucketName?: string; + + /** DNS configuration */ + readonly dns?: DnsConfig; + + /** Deployment configuration */ + readonly deployment?: DeploymentConfig; + + /** Service tags */ + readonly serviceTags: SpicyEcsServiceTags; +} + +/** + * Stack for deploying an ECS Service + */ +export class SpicyEcsServiceStack extends cdk.Stack { + public readonly service: SpicyEcsService; + + constructor(scope: Construct, id: string, props: SpicyEcsServiceStackProps) { + super(scope, id, props); + + // Import VPC + const vpc = ec2.Vpc.fromVpcAttributes(this, 'ImportedVpc', { + vpcId: props.vpcId, + availabilityZones: props.availabilityZones, + privateSubnetIds: props.privateSubnetIds, + ...(props.publicSubnetIds && { publicSubnetIds: props.publicSubnetIds }), + }); + + // Import Cluster + const cluster = ecs.Cluster.fromClusterAttributes(this, 'ImportedCluster', { + clusterName: props.clusterName, + vpc, + securityGroups: [], + }); + + // Parse JSON configs + const environment = props.environment ? JSON.parse(props.environment) : undefined; + const secrets = props.secrets ? JSON.parse(props.secrets) : undefined; + const capacityProviderStrategy: CapacityProviderStrategyItem[] | undefined = props.capacityProviderStrategy + ? JSON.parse(props.capacityProviderStrategy) + : undefined; + + // Build container config + const container: ContainerConfig = { + image: props.image, + port: props.containerPort, + cpu: props.cpu, + memory: props.memory, + environment, + secrets, + }; + + // Build health check config + const healthCheck: HealthCheckConfig | undefined = props.healthCheckPath + ? { path: props.healthCheckPath } + : undefined; + + // Create the service + this.service = new SpicyEcsService(this, 'EcsService', { + cluster, + vpc, + serviceName: props.serviceName, + container, + capacityProviderStrategy, + desiredCount: props.desiredCount, + scaling: props.scaling, + healthCheck, + routing: props.routing, + externalListenerArn: props.externalListenerArn, + internalListenerArn: props.internalListenerArn, + dns: props.dns, + deployment: props.deployment, + tags: props.serviceTags, + }); + } + + /** + * Create stack from CDK context + * Only requires clusterStackName - VPC stack name imported from cluster export, then all VPC details imported from VPC stack + */ + static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyEcsServiceStack { + const app = scope.node.root as cdk.App; + + // Required: clusterStackName (VPC stack name imported from cluster export, then VPC details imported from VPC stack) + const clusterStackName = app.node.tryGetContext('clusterStackName') || app.node.tryGetContext('clusterName'); + if (!clusterStackName) { + throw new Error( + 'clusterStackName is required. Provide clusterStackName to import VPC stack name from cluster export, then import VPC details from VPC stack.' + ); + } + + // Required service config + const serviceName = app.node.tryGetContext('serviceName'); + const image = app.node.tryGetContext('image'); + const containerPort = parseInt(app.node.tryGetContext('containerPort') ?? '3000'); + + // Import VPC stack name from cluster stack (cluster exports: ${clusterStackName}-VPCStackName) + const vpcStackName = cdk.Fn.importValue(`${clusterStackName}-VPCStackName`).toString(); + + // Import VPC ID from cluster stack (cluster exports: ${clusterStackName}-VPC) + const vpcId = cdk.Fn.importValue(`${clusterStackName}-VPC`); + + // Import all VPC details from VPC stack exports + const vpcCidrBlock = cdk.Fn.importValue(`${vpcStackName}-VPCCIDR`).toString(); + const numberOfAzsRaw = app.node.tryGetContext('numberOfAzs'); + const numberOfAzs = numberOfAzsRaw ? parseInt(numberOfAzsRaw, 10) : NaN; + if (!numberOfAzs || Number.isNaN(numberOfAzs)) { + throw new Error('numberOfAzs is required in context (2-4) to import VPC subnets.'); + } + const azs = ['A', 'B', 'C', 'D'].slice(0, Math.min(Math.max(numberOfAzs, 1), 4)); + + // Import private subnet IDs from VPC stack + const privateSubnetIds = azs.map((az) => cdk.Fn.importValue(`${vpcStackName}-PrivateSubnet${az}1ID`).toString()); + + // Derive availability zones from region + const region = cdk.Stack.of(scope).region; + const availabilityZones = azs.map((az) => `${region}${az.toLowerCase()}`); + + // ALB stack name is derived from service stackName: ${baseStackName}-alb + // Remove -blue/-green suffix and add -alb + const serviceStackName = app.node.tryGetContext('stackName') || id; + const baseStackName = serviceStackName.replace(/-blue$|-green$/, ''); + const albStackName = `${baseStackName}-alb`; + + // Tags + const serviceTags: SpicyEcsServiceTags = { + owner: app.node.tryGetContext('ownerTag') ?? 'Unknown', + product: app.node.tryGetContext('productTag') ?? 'Unknown', + component: app.node.tryGetContext('componentTag') ?? serviceName, + environment: app.node.tryGetContext('environment') ?? 'dev', + build: app.node.tryGetContext('build'), + }; + + // Optional container config + const cpu = app.node.tryGetContext('cpu') ? parseInt(app.node.tryGetContext('cpu')) : undefined; + const memory = app.node.tryGetContext('memory') ? parseInt(app.node.tryGetContext('memory')) : undefined; + const environment = app.node.tryGetContext('environment_vars'); // JSON string + const secrets = app.node.tryGetContext('secrets'); // JSON string + const desiredCount = app.node.tryGetContext('desiredCount') + ? parseInt(app.node.tryGetContext('desiredCount')) + : undefined; + + // Capacity provider strategy (JSON string) + const capacityProviderStrategy = app.node.tryGetContext('capacityProviderStrategy'); + + // Scaling + const minCapacity = app.node.tryGetContext('minCapacity') + ? parseInt(app.node.tryGetContext('minCapacity')) + : undefined; + const maxCapacity = app.node.tryGetContext('maxCapacity') + ? parseInt(app.node.tryGetContext('maxCapacity')) + : undefined; + const targetCpuUtilization = app.node.tryGetContext('targetCpuUtilization') + ? parseInt(app.node.tryGetContext('targetCpuUtilization')) + : undefined; + const targetMemoryUtilization = app.node.tryGetContext('targetMemoryUtilization') + ? parseInt(app.node.tryGetContext('targetMemoryUtilization')) + : undefined; + const targetRequestsPerTarget = app.node.tryGetContext('targetRequestsPerTarget') + ? parseInt(app.node.tryGetContext('targetRequestsPerTarget')) + : undefined; + + const scaling: ServiceScalingConfig | undefined = + minCapacity && maxCapacity + ? { + minCapacity, + maxCapacity, + targetCpuUtilization, + targetMemoryUtilization, + targetRequestsPerTarget, + } + : undefined; + + // Health check + const healthCheckPath = app.node.tryGetContext('healthCheckPath'); + + // Routing - ALB details (resolved by pipeline from cluster or ALB stack) + // Pipeline sets: albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn in context + // If not provided in context, fall back to importing from exports (backward compatibility) + const useClusterAlb = app.node.tryGetContext('useClusterAlb') !== 'false'; // default true + + let existingAlbArn: string | undefined = app.node.tryGetContext('albLoadBalancerArn'); + let existingAlbHttpsListenerArn: string | undefined = app.node.tryGetContext('albHttpsListenerArn'); + let existingAlbHttpListenerArn: string | undefined = app.node.tryGetContext('albHttpListenerArn'); + + // If not provided in context, import from exports (backward compatibility) + if (!existingAlbArn && !useClusterAlb) { + const albScheme = app.node.tryGetContext('albScheme') || 'internet-facing'; + const prefix = albScheme === 'internet-facing' ? 'internet-facing' : 'internal'; + existingAlbArn = cdk.Fn.importValue(`${albStackName}-${prefix}-arn`).toString(); + existingAlbHttpsListenerArn = cdk.Fn.importValue(`${albStackName}-${prefix}-https-listener`).toString(); + existingAlbHttpListenerArn = cdk.Fn.importValue(`${albStackName}-${prefix}-http-listener`).toString(); + } + + // ALB listeners (for cluster ALB mode) - import from cluster stack (backward compatibility) + // Cluster exports: ${clusterStackName}-internet-facing-https-listener, ${clusterStackName}-internal-https-listener + let externalListenerArn = app.node.tryGetContext('externalListenerArn'); + let internalListenerArn = app.node.tryGetContext('internalListenerArn'); + + // Auto-import listener ARNs from cluster stack if clusterStackName provided and listeners not explicitly set + // This matches the old pattern: Fn::ImportValue: !Sub "${ClusterName}-internet-facing-https-listener" + // Only used if albLoadBalancerArn is not provided (backward compatibility) + if (!existingAlbArn && clusterStackName && !externalListenerArn && !internalListenerArn) { + const useExternal = app.node.tryGetContext('useExternalALB') === 'true'; + const useInternal = app.node.tryGetContext('useInternalALB') === 'true'; + const albScheme = + app.node.tryGetContext('albScheme') || + (useExternal ? 'internet-facing' : useInternal ? 'internal' : 'internet-facing'); + + // Try HTTPS listener first (most common), fall back to HTTP if needed + if (albScheme === 'internet-facing' || useExternal) { + externalListenerArn = cdk.Fn.importValue(`${clusterStackName}-internet-facing-https-listener`).toString(); + } else if (albScheme === 'internal' || useInternal) { + internalListenerArn = cdk.Fn.importValue(`${clusterStackName}-internal-https-listener`).toString(); + } + } + const hostHeader = app.node.tryGetContext('hostHeader'); + const pathPatterns = app.node.tryGetContext('pathPatterns')?.split(','); + const priority = app.node.tryGetContext('priority') ? parseInt(app.node.tryGetContext('priority')) : undefined; + const useExternal = app.node.tryGetContext('useExternalALB') === 'true'; + const useInternal = app.node.tryGetContext('useInternalALB') === 'true'; + + let routing: LoadBalancerRoutingConfig | undefined; + + // Use unified ALB parameters if provided (from pipeline resolveAlbDetails) + // Otherwise fall back to cluster ALB mode (backward compatibility) + const useBgCommonAlb = existingAlbArn != null; + + if (useBgCommonAlb) { + // ALB details are provided by pipeline (resolved from cluster or ALB stack) + // Pipeline sets: albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn + if (!hostHeader && (!pathPatterns || pathPatterns.length === 0)) { + throw new Error('When using ALB, either hostHeader or pathPatterns must be provided for routing'); + } + // Use unified ALB parameters (from cluster or ALB stack, resolved by pipeline) + routing = { + existingALB: existingAlbArn!, // ALB ARN (from cluster or ALB stack) + httpsListenerArn: existingAlbHttpsListenerArn, // HTTPS listener ARN (if certificate provided) + httpListenerArn: existingAlbHttpListenerArn, // HTTP listener ARN (if no certificate or for redirect) + hostHeader, + pathPatterns, + priority, + stickiness: app.node.tryGetContext('stickiness') !== 'false', + stickinessDuration: app.node.tryGetContext('stickinessDuration') + ? parseInt(app.node.tryGetContext('stickinessDuration')) + : undefined, + deregistrationDelay: app.node.tryGetContext('deregistrationDelay') + ? parseInt(app.node.tryGetContext('deregistrationDelay')) + : undefined, + }; + + // Blue/Green DNS configuration (for bg-common ALB) + // Note: Both DNS records are created and point to the same ALB. + // Routing is controlled by listener rule priorities, not DNS changes. + // The isActive flag is informational - both records are always created. + const activeHostname = app.node.tryGetContext('activeHostname'); + const inactiveHostname = app.node.tryGetContext('inactiveHostname'); + const bgHostedZoneId = app.node.tryGetContext('bgHostedZoneId') || app.node.tryGetContext('hostedZoneId'); + const isActive = app.node.tryGetContext('isActive') !== 'false'; // default true + + if (activeHostname && inactiveHostname && bgHostedZoneId) { + routing.blueGreenDns = { + activeHostname, + inactiveHostname, + hostedZoneId: bgHostedZoneId, + isActive, // Informational only - both DNS records are always created + }; + } + } else if (hostHeader || pathPatterns) { + // Cluster ALB mode (existing behavior) + // Validate required parameters for cluster ALB + // Note: externalListenerArn/internalListenerArn were declared above and may have been imported if clusterStackName was provided + + if (!externalListenerArn && !internalListenerArn) { + throw new Error('When using cluster ALB, either externalListenerArn or internalListenerArn must be provided'); + } + if (externalListenerArn && internalListenerArn) { + throw new Error( + 'Cannot use both externalListenerArn and internalListenerArn. Choose either external OR internal, not both' + ); + } + if (useExternal && useInternal) { + throw new Error( + 'Cannot use both useExternalALB and useInternalALB. Choose either external OR internal, not both' + ); + } + if (useExternal && !externalListenerArn) { + throw new Error('useExternalALB=true requires externalListenerArn to be provided'); + } + if (useInternal && !internalListenerArn) { + throw new Error('useInternalALB=true requires internalListenerArn to be provided'); + } + // Auto-detect if not explicitly set + const detectedExternal = externalListenerArn ? true : false; + const detectedInternal = internalListenerArn ? true : false; + + // Use explicit flags if set, otherwise auto-detect from listener ARNs + routing = { + external: useExternal || (detectedExternal && !useInternal), + internal: useInternal || (detectedInternal && !useExternal), + hostHeader, + pathPatterns, + priority: priority ?? 100, + stickiness: app.node.tryGetContext('stickiness') === 'true', + stickinessDuration: app.node.tryGetContext('stickinessDuration') + ? parseInt(app.node.tryGetContext('stickinessDuration')) + : undefined, + deregistrationDelay: app.node.tryGetContext('deregistrationDelay') + ? parseInt(app.node.tryGetContext('deregistrationDelay')) + : undefined, + }; + } else { + // No routing configured - this is valid for services without ALB (e.g., workers, pub/sub) + // No validation needed - service can run without ALB + } + + // DNS (for cluster ALB mode) + const hostedZoneId = app.node.tryGetContext('hostedZoneId'); + const zoneName = app.node.tryGetContext('zoneName'); + const recordName = app.node.tryGetContext('recordName'); + + const dns: DnsConfig | undefined = + hostedZoneId && zoneName && recordName + ? { + hostedZoneId, + zoneName, + recordName, + } + : undefined; + + // Deployment + const circuitBreaker = app.node.tryGetContext('circuitBreaker') !== 'false'; + const enableExecuteCommand = app.node.tryGetContext('enableExecuteCommand') !== 'false'; + + const deployment: DeploymentConfig = { + circuitBreaker, + enableExecuteCommand, + }; + + // Public subnet IDs (for individual ALB) + const publicSubnetIds = app.node.tryGetContext('publicSubnetIds')?.split(','); + + // Auto-import logs bucket from cluster stack if not explicitly provided + // This matches the old pattern: Fn::ImportValue: !Sub "${ClusterName}-logs-s3-bucket" + // Cluster exports: ${clusterStackName}-logs-s3-bucket + let clusterLogsBucketName = app.node.tryGetContext('clusterLogsBucketName'); + if (!clusterLogsBucketName && clusterStackName) { + clusterLogsBucketName = cdk.Fn.importValue(`${clusterStackName}-logs-s3-bucket`).toString(); + } + // Note: clusterLogsBucketName is optional (only needed for bg-common ALB access logs) + // We don't fail if it's missing - it's only used if provided + + return new SpicyEcsServiceStack(scope, id, { + ...stackProps, + clusterName: clusterStackName, + vpcId: vpcId.toString(), + vpcCidrBlock, + numberOfAzs, + availabilityZones, + privateSubnetIds, + serviceName, + image, + containerPort, + cpu, + memory, + environment, + secrets, + desiredCount, + capacityProviderStrategy, + scaling, + healthCheckPath, + routing, + externalListenerArn, + internalListenerArn, + publicSubnetIds, + clusterLogsBucketName, + dns, + deployment, + serviceTags, + }); + } +} diff --git a/resources/lib/stacks/spicy-vpc-stack.ts b/resources/lib/stacks/spicy-vpc-stack.ts new file mode 100644 index 0000000..85f2cb5 --- /dev/null +++ b/resources/lib/stacks/spicy-vpc-stack.ts @@ -0,0 +1,164 @@ +import * as cdk from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; +import { SpicyVpc, SpicyVpcProps, SubnetCidrConfig } from '../constructs/spicy-vpc'; + +/** + * Props for SpicyVpcStack + * Extends SpicyVpcProps but makes tags optional since we can derive from context + */ +export interface SpicyVpcStackProps extends cdk.StackProps { + /** + * VPC configuration - all SpicyVpcProps except tags (derived from stack props) + */ + readonly vpcConfig?: Omit; + + /** + * Owner tag + */ + readonly ownerTag: string; + + /** + * Product tag + */ + readonly productTag: string; + + /** + * Component tag + */ + readonly componentTag: string; + + /** + * Build identifier (e.g., git SHA) + */ + readonly buildTag?: string; +} + +/** + * SpicyVpcStack - A CloudFormation stack that creates a Spicy VPC + * + * This stack can be deployed via CDK CLI or from Jenkins pipelines. + * Configuration can be provided via: + * 1. Direct props + * 2. CDK context (cdk deploy -c key=value) + * 3. Environment variables + */ +export class SpicyVpcStack extends cdk.Stack { + /** The VPC construct */ + public readonly spicyVpc: SpicyVpc; + + constructor(scope: Construct, id: string, props: SpicyVpcStackProps) { + super(scope, id, props); + + // Create the VPC + this.spicyVpc = new SpicyVpc(this, 'SpicyVpc', { + ...props.vpcConfig, + tags: { + owner: props.ownerTag, + product: props.productTag, + component: props.componentTag, + build: props.buildTag, + }, + }); + } + + /** + * Create a SpicyVpcStack from CDK context values + * This is useful when deploying from Jenkins where context is passed via -c flags + */ + static fromContext(scope: Construct, id: string, stackProps?: cdk.StackProps): SpicyVpcStack { + const app = scope.node.root as cdk.App; + + // Required context values + const ownerTag = app.node.tryGetContext('ownerTag') ?? 'SpicyTeam'; + const productTag = app.node.tryGetContext('productTag') ?? 'spicy'; + const componentTag = app.node.tryGetContext('componentTag') ?? 'spicy-VPC'; + const buildTag = app.node.tryGetContext('build'); + + // Optional VPC configuration from context + const vpcCidr = app.node.tryGetContext('vpcCidr') as string | undefined; + const vpcTenancy = app.node.tryGetContext('vpcTenancy') as 'default' | 'dedicated' | undefined; + const numberOfAzs = app.node.tryGetContext('numberOfAzs') as string | undefined; + const availabilityZones = app.node.tryGetContext('availabilityZones') as string | undefined; + const createPrivateSubnets = app.node.tryGetContext('createPrivateSubnets') as string | undefined; + const createAdditionalPrivateSubnets = app.node.tryGetContext('createAdditionalPrivateSubnets') as + | string + | undefined; + const publicSubnetTag = app.node.tryGetContext('publicSubnetTag') as string | undefined; + const privateSubnetATag = app.node.tryGetContext('privateSubnetATag') as string | undefined; + const privateSubnetBTag = app.node.tryGetContext('privateSubnetBTag') as string | undefined; + + // Parse subnet CIDRs from context + const subnetCidrs = SpicyVpcStack.parseSubnetCidrsFromContext(app); + + // Build VPC config using a mutable object then cast + const vpcConfigBuilder: { + vpcCidr?: string; + vpcTenancy?: 'default' | 'dedicated'; + numberOfAzs?: 2 | 3 | 4; + availabilityZones?: string[]; + createPrivateSubnets?: boolean; + createAdditionalPrivateSubnets?: boolean; + publicSubnetTag?: string; + privateSubnetATag?: string; + privateSubnetBTag?: string; + subnetCidrs?: SpicyVpcProps['subnetCidrs']; + } = {}; + + if (vpcCidr) vpcConfigBuilder.vpcCidr = vpcCidr; + if (vpcTenancy) vpcConfigBuilder.vpcTenancy = vpcTenancy; + if (numberOfAzs) vpcConfigBuilder.numberOfAzs = parseInt(numberOfAzs, 10) as 2 | 3 | 4; + if (availabilityZones) { + vpcConfigBuilder.availabilityZones = availabilityZones.split(','); + } + if (createPrivateSubnets !== undefined) { + vpcConfigBuilder.createPrivateSubnets = createPrivateSubnets === 'true'; + } + if (createAdditionalPrivateSubnets !== undefined) { + vpcConfigBuilder.createAdditionalPrivateSubnets = createAdditionalPrivateSubnets === 'true'; + } + if (publicSubnetTag) vpcConfigBuilder.publicSubnetTag = publicSubnetTag; + if (privateSubnetATag) vpcConfigBuilder.privateSubnetATag = privateSubnetATag; + if (privateSubnetBTag) vpcConfigBuilder.privateSubnetBTag = privateSubnetBTag; + if (subnetCidrs && Object.keys(subnetCidrs).length > 0) { + vpcConfigBuilder.subnetCidrs = subnetCidrs; + } + + return new SpicyVpcStack(scope, id, { + ...stackProps, + ownerTag, + productTag, + componentTag, + buildTag, + vpcConfig: vpcConfigBuilder, + }); + } + + /** + * Parse subnet CIDR configurations from CDK context + */ + private static parseSubnetCidrsFromContext(app: cdk.App): SpicyVpcProps['subnetCidrs'] { + const cidrs: SpicyVpcProps['subnetCidrs'] = {}; + + const azLetters = ['a', 'b', 'c', 'd'] as const; + + for (const az of azLetters) { + const azUpper = az.toUpperCase(); + const publicCidr = app.node.tryGetContext(`publicSubnet${azUpper}Cidr`); + const private1Cidr = app.node.tryGetContext(`privateSubnet${azUpper}1Cidr`); + const private2Cidr = app.node.tryGetContext(`privateSubnet${azUpper}2Cidr`); + + if (publicCidr || private1Cidr || private2Cidr) { + const config: SubnetCidrConfig = { + publicCidr: publicCidr ?? '', + private1Cidr: private1Cidr ?? '', + }; + if (private2Cidr) { + config.private2Cidr = private2Cidr; + } + cidrs[az] = config; + } + } + + return cidrs; + } +} diff --git a/resources/package.json b/resources/package.json new file mode 100644 index 0000000..7680158 --- /dev/null +++ b/resources/package.json @@ -0,0 +1,29 @@ +{ + "name": "spicy-automation", + "version": "0.1.0", + "bin": { + "spicy-automation": "bin/spicy-cdk.ts" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk", + "synth": "cdk synth", + "deploy": "cdk deploy", + "diff": "cdk diff" + }, + "devDependencies": { + "@types/jest": "^30.0.0", + "@types/node": "^24.10.1", + "aws-cdk": "2.1033.0", + "jest": "^30.2.0", + "ts-jest": "^29.4.5", + "ts-node": "^10.9.2", + "typescript": "~5.9.3" + }, + "dependencies": { + "aws-cdk-lib": "2.230.0", + "constructs": "^10.4.3" + } +} diff --git a/resources/pnpm-lock.yaml b/resources/pnpm-lock.yaml new file mode 100644 index 0000000..40ab192 --- /dev/null +++ b/resources/pnpm-lock.yaml @@ -0,0 +1,3104 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + aws-cdk-lib: + specifier: 2.230.0 + version: 2.230.0(constructs@10.4.3) + constructs: + specifier: ^10.4.3 + version: 10.4.3 + devDependencies: + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + aws-cdk: + specifier: 2.1033.0 + version: 2.1033.0 + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.5 + version: 29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + +packages: + + '@aws-cdk/asset-awscli-v1@2.2.242': + resolution: {integrity: sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==} + + '@aws-cdk/asset-node-proxy-agent-v6@2.1.0': + resolution: {integrity: sha512-7bY3J8GCVxLupn/kNmpPc5VJz8grx+4RKfnnJiO1LG+uxkZfANZG3RMHhE+qQxxwkyQ9/MfPtTpf748UhR425A==} + + '@aws-cdk/cloud-assembly-schema@48.20.0': + resolution: {integrity: sha512-+eeiav9LY4wbF/EFuCt/vfvi/Zoxo8bf94PW5clbMraChEliq83w4TbRVy0jB9jE0v1ooFTtIjSQkowSPkfISg==} + engines: {node: '>= 18.0.0'} + bundledDependencies: + - jsonschema + - semver + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.27.1': + resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.27.1': + resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.27.1': + resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/core@1.7.1': + resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + + '@jest/console@30.2.0': + resolution: {integrity: sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.2.0': + resolution: {integrity: sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.0.1': + resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.2.0': + resolution: {integrity: sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.2.0': + resolution: {integrity: sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.2.0': + resolution: {integrity: sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.2.0': + resolution: {integrity: sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.1.0': + resolution: {integrity: sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.2.0': + resolution: {integrity: sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.0.1': + resolution: {integrity: sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.2.0': + resolution: {integrity: sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@30.0.5': + resolution: {integrity: sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.2.0': + resolution: {integrity: sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.0.1': + resolution: {integrity: sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.2.0': + resolution: {integrity: sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.2.0': + resolution: {integrity: sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.2.0': + resolution: {integrity: sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.2.0': + resolution: {integrity: sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.2.9': + resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + + '@sinclair/typebox@0.34.41': + resolution: {integrity: sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@13.0.5': + resolution: {integrity: sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==} + + '@tsconfig/node10@1.0.12': + resolution: {integrity: sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==} + + '@tsconfig/node12@1.0.11': + resolution: {integrity: sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==} + + '@tsconfig/node14@1.0.3': + resolution: {integrity: sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==} + + '@tsconfig/node16@1.0.4': + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + + '@types/jest@30.0.0': + resolution: {integrity: sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==} + + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} + + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@4.1.3: + resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + aws-cdk-lib@2.230.0: + resolution: {integrity: sha512-hA1cWZAHpvrwJE55N77Km/aPUgN9NfbGm98vi91OeLKFgMVhdCXM57lJUAYTq0e8JHWFRWmsVlm9R8pq5jTJeA==} + engines: {node: '>= 18.0.0'} + peerDependencies: + constructs: ^10.0.0 + bundledDependencies: + - '@balena/dockerignore' + - case + - fs-extra + - ignore + - jsonschema + - minimatch + - punycode + - semver + - table + - yaml + - mime-types + + aws-cdk@2.1033.0: + resolution: {integrity: sha512-Pit2k7cVAwxoYI7RMVsOyltuy7/HGENLupJ4KAm/d8mGzOfX+SLOo9YQsx5CKY9J6ErCZ1ViLerklTfjytvQww==} + engines: {node: '>= 18.0.0'} + hasBin: true + + babel-jest@30.2.0: + resolution: {integrity: sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@7.0.1: + resolution: {integrity: sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==} + engines: {node: '>=12'} + + babel-plugin-jest-hoist@30.2.0: + resolution: {integrity: sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.2.0: + resolution: {integrity: sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + baseline-browser-mapping@2.8.31: + resolution: {integrity: sha512-a28v2eWrrRWPpJSzxc+mKwm0ZtVx/G8SepdQZDArnXYU/XS+IF6mp8aB/4E+hH1tyGCoDo3KlUCdlSxGDsRkAw==} + hasBin: true + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.0: + resolution: {integrity: sha512-tbydkR/CxfMwelN0vwdP/pLkDwyAASZ+VfWm4EOwlB6SWhx1sYnWLqo8N5j0rAzPfzfRaxt0mM/4wPU/Su84RQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bs-logger@0.2.6: + resolution: {integrity: sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==} + engines: {node: '>= 6'} + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001757: + resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + ci-info@4.3.1: + resolution: {integrity: sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==} + engines: {node: '>=8'} + + cjs-module-lexer@2.1.1: + resolution: {integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + constructs@10.4.3: + resolution: {integrity: sha512-3+ZB67qWGM1vEstNpj6pGaLNN1qz4gxC1CBhEUhZDZk0PqzQWY65IzC1Doq17MGPa9xa2wJ1G/DJ3swU8kWAHQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + create-require@1.1.1: + resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.0: + resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + + diff@4.0.2: + resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==} + engines: {node: '>=0.3.1'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + electron-to-chromium@1.5.262: + resolution: {integrity: sha512-NlAsMteRHek05jRUxUR0a5jpjYq9ykk6+kO0yRaMi5moe7u0fVIOeQ3Y30A8dIiWFBNUoQGi1ljb1i5VtS9WQQ==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + + expect@30.2.0: + resolution: {integrity: sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@30.2.0: + resolution: {integrity: sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.2.0: + resolution: {integrity: sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.2.0: + resolution: {integrity: sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.2.0: + resolution: {integrity: sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.2.0: + resolution: {integrity: sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.2.0: + resolution: {integrity: sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.2.0: + resolution: {integrity: sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.2.0: + resolution: {integrity: sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-haste-map@30.2.0: + resolution: {integrity: sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.2.0: + resolution: {integrity: sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.2.0: + resolution: {integrity: sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.2.0: + resolution: {integrity: sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.2.0: + resolution: {integrity: sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-pnp-resolver@1.2.3: + resolution: {integrity: sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==} + engines: {node: '>=6'} + peerDependencies: + jest-resolve: '*' + peerDependenciesMeta: + jest-resolve: + optional: true + + jest-regex-util@30.0.1: + resolution: {integrity: sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.2.0: + resolution: {integrity: sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.2.0: + resolution: {integrity: sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.2.0: + resolution: {integrity: sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.2.0: + resolution: {integrity: sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.2.0: + resolution: {integrity: sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.2.0: + resolution: {integrity: sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.2.0: + resolution: {integrity: sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.2.0: + resolution: {integrity: sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-worker@30.2.0: + resolution: {integrity: sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.2.0: + resolution: {integrity: sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash.memoize@4.1.2: + resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + make-error@1.3.6: + resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==} + + makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pretty-format@30.2.0: + resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-support@0.5.13: + resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + synckit@0.11.11: + resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==} + engines: {node: ^14.18.0 || >=16.0.0} + + test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + + tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-jest@29.4.5: + resolution: {integrity: sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==} + engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@babel/core': '>=7.0.0-beta.0 <8' + '@jest/transform': ^29.0.0 || ^30.0.0 + '@jest/types': ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + esbuild: '*' + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: '>=4.3 <6' + peerDependenciesMeta: + '@babel/core': + optional: true + '@jest/transform': + optional: true + '@jest/types': + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + + ts-node@10.9.2: + resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} + hasBin: true + peerDependencies: + '@swc/core': '>=1.2.50' + '@swc/wasm': '>=1.2.50' + '@types/node': '*' + typescript: '>=2.7' + peerDependenciesMeta: + '@swc/core': + optional: true + '@swc/wasm': + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.1.4: + resolution: {integrity: sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + v8-compile-cache-lib@3.0.1: + resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + + walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yn@3.1.1: + resolution: {integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==} + engines: {node: '>=6'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@aws-cdk/asset-awscli-v1@2.2.242': {} + + '@aws-cdk/asset-node-proxy-agent-v6@2.1.0': {} + + '@aws-cdk/cloud-assembly-schema@48.20.0': {} + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.0 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@bcoe/v8-coverage@0.2.3': {} + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/core@1.7.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.3': {} + + '@jest/console@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + chalk: 4.1.2 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + + '@jest/core@30.2.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))': + dependencies: + '@jest/console': 30.2.0 + '@jest/pattern': 30.0.1 + '@jest/reporters': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + '@jest/diff-sequences@30.0.1': {} + + '@jest/environment@30.2.0': + dependencies: + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + jest-mock: 30.2.0 + + '@jest/expect-utils@30.2.0': + dependencies: + '@jest/get-type': 30.1.0 + + '@jest/expect@30.2.0': + dependencies: + expect: 30.2.0 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@30.2.0': + dependencies: + '@jest/types': 30.2.0 + '@sinonjs/fake-timers': 13.0.5 + '@types/node': 24.10.1 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + '@jest/get-type@30.1.0': {} + + '@jest/globals@30.2.0': + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/types': 30.2.0 + jest-mock: 30.2.0 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.0.1': + dependencies: + '@types/node': 24.10.1 + jest-regex-util: 30.0.1 + + '@jest/reporters@30.2.0': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 24.10.1 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 10.5.0 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + jest-worker: 30.2.0 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@30.0.5': + dependencies: + '@sinclair/typebox': 0.34.41 + + '@jest/snapshot-utils@30.2.0': + dependencies: + '@jest/types': 30.2.0 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.0.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.2.0': + dependencies: + '@jest/console': 30.2.0 + '@jest/types': 30.2.0 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.2.0': + dependencies: + '@jest/test-result': 30.2.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + slash: 3.0.0 + + '@jest/transform@30.2.0': + dependencies: + '@babel/core': 7.28.5 + '@jest/types': 30.2.0 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 7.0.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + micromatch: 4.0.8 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@30.2.0': + dependencies: + '@jest/pattern': 30.0.1 + '@jest/schemas': 30.0.5 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 24.10.1 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.7.1 + '@emnapi/runtime': 1.7.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.2.9': {} + + '@sinclair/typebox@0.34.41': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@13.0.5': + dependencies: + '@sinonjs/commons': 3.0.1 + + '@tsconfig/node10@1.0.12': {} + + '@tsconfig/node12@1.0.11': {} + + '@tsconfig/node14@1.0.3': {} + + '@tsconfig/node16@1.0.4': {} + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + + '@types/jest@30.0.0': + dependencies: + expect: 30.2.0 + pretty-format: 30.2.0 + + '@types/node@24.10.1': + dependencies: + undici-types: 7.16.0 + + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + + '@ungap/structured-clone@1.3.0': {} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-walk@8.3.4: + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@4.1.3: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + aws-cdk-lib@2.230.0(constructs@10.4.3): + dependencies: + '@aws-cdk/asset-awscli-v1': 2.2.242 + '@aws-cdk/asset-node-proxy-agent-v6': 2.1.0 + '@aws-cdk/cloud-assembly-schema': 48.20.0 + constructs: 10.4.3 + + aws-cdk@2.1033.0: + optionalDependencies: + fsevents: 2.3.2 + + babel-jest@30.2.0(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + '@jest/transform': 30.2.0 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 7.0.1 + babel-preset-jest: 30.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@7.0.1: + dependencies: + '@babel/helper-plugin-utils': 7.27.1 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 6.0.3 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.2.0: + dependencies: + '@types/babel__core': 7.20.5 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.28.5) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.28.5) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.28.5) + '@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.28.5) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.28.5) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.28.5) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.28.5) + + babel-preset-jest@30.2.0(@babel/core@7.28.5): + dependencies: + '@babel/core': 7.28.5 + babel-plugin-jest-hoist: 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + + balanced-match@1.0.2: {} + + baseline-browser-mapping@2.8.31: {} + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.0: + dependencies: + baseline-browser-mapping: 2.8.31 + caniuse-lite: 1.0.30001757 + electron-to-chromium: 1.5.262 + node-releases: 2.0.27 + update-browserslist-db: 1.1.4(browserslist@4.28.0) + + bs-logger@0.2.6: + dependencies: + fast-json-stable-stringify: 2.1.0 + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + buffer-from@1.1.2: {} + + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001757: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + ci-info@4.3.1: {} + + cjs-module-lexer@2.1.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + constructs@10.4.3: {} + + convert-source-map@2.0.0: {} + + create-require@1.1.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.0: {} + + deepmerge@4.3.1: {} + + detect-newline@3.1.0: {} + + diff@4.0.2: {} + + eastasianwidth@0.2.0: {} + + electron-to-chromium@1.5.262: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + esprima@4.0.1: {} + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + + expect@30.2.0: + dependencies: + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-util: 30.2.0 + + fast-json-stable-stringify@2.1.0: {} + + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fs.realpath@1.0.0: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-package-type@0.1.0: {} + + get-stream@6.0.1: {} + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + graceful-fs@4.2.11: {} + + handlebars@4.7.8: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + + has-flag@4.0.0: {} + + html-escaper@2.0.2: {} + + human-signals@2.1.0: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + + imurmurhash@0.1.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + is-arrayish@0.2.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-number@7.0.0: {} + + is-stream@2.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.28.5 + '@babel/parser': 7.28.5 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.2 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@30.2.0: + dependencies: + execa: 5.1.1 + jest-util: 30.2.0 + p-limit: 3.1.0 + + jest-circus@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/expect': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.0 + is-generator-fn: 2.1.0 + jest-each: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + p-limit: 3.1.0 + pretty-format: 30.2.0 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@babel/core': 7.28.5 + '@jest/get-type': 30.1.0 + '@jest/pattern': 30.0.1 + '@jest/test-sequencer': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 24.10.1 + ts-node: 10.9.2(@types/node@24.10.1)(typescript@5.9.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.2.0: + dependencies: + '@jest/diff-sequences': 30.0.1 + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + pretty-format: 30.2.0 + + jest-docblock@30.2.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + chalk: 4.1.2 + jest-util: 30.2.0 + pretty-format: 30.2.0 + + jest-environment-node@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + jest-mock: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + + jest-haste-map@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 30.0.1 + jest-util: 30.2.0 + jest-worker: 30.2.0 + micromatch: 4.0.8 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + + jest-leak-detector@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + pretty-format: 30.2.0 + + jest-matcher-utils@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + chalk: 4.1.2 + jest-diff: 30.2.0 + pretty-format: 30.2.0 + + jest-message-util@30.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + '@jest/types': 30.2.0 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + jest-util: 30.2.0 + + jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): + optionalDependencies: + jest-resolve: 30.2.0 + + jest-regex-util@30.0.1: {} + + jest-resolve-dependencies@30.2.0: + dependencies: + jest-regex-util: 30.0.1 + jest-snapshot: 30.2.0 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.2.0: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-pnp-resolver: 1.2.3(jest-resolve@30.2.0) + jest-util: 30.2.0 + jest-validate: 30.2.0 + slash: 3.0.0 + unrs-resolver: 1.11.1 + + jest-runner@30.2.0: + dependencies: + '@jest/console': 30.2.0 + '@jest/environment': 30.2.0 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-haste-map: 30.2.0 + jest-leak-detector: 30.2.0 + jest-message-util: 30.2.0 + jest-resolve: 30.2.0 + jest-runtime: 30.2.0 + jest-util: 30.2.0 + jest-watcher: 30.2.0 + jest-worker: 30.2.0 + p-limit: 3.1.0 + source-map-support: 0.5.13 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.2.0: + dependencies: + '@jest/environment': 30.2.0 + '@jest/fake-timers': 30.2.0 + '@jest/globals': 30.2.0 + '@jest/source-map': 30.0.1 + '@jest/test-result': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + chalk: 4.1.2 + cjs-module-lexer: 2.1.1 + collect-v8-coverage: 1.0.3 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-mock: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.2.0: + dependencies: + '@babel/core': 7.28.5 + '@babel/generator': 7.28.5 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5) + '@babel/types': 7.28.5 + '@jest/expect-utils': 30.2.0 + '@jest/get-type': 30.1.0 + '@jest/snapshot-utils': 30.2.0 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + expect: 30.2.0 + graceful-fs: 4.2.11 + jest-diff: 30.2.0 + jest-matcher-utils: 30.2.0 + jest-message-util: 30.2.0 + jest-util: 30.2.0 + pretty-format: 30.2.0 + semver: 7.7.3 + synckit: 0.11.11 + transitivePeerDependencies: + - supports-color + + jest-util@30.2.0: + dependencies: + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + chalk: 4.1.2 + ci-info: 4.3.1 + graceful-fs: 4.2.11 + picomatch: 4.0.3 + + jest-validate@30.2.0: + dependencies: + '@jest/get-type': 30.1.0 + '@jest/types': 30.2.0 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.2.0 + + jest-watcher@30.2.0: + dependencies: + '@jest/test-result': 30.2.0 + '@jest/types': 30.2.0 + '@types/node': 24.10.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.2.0 + string-length: 4.0.2 + + jest-worker@30.2.0: + dependencies: + '@types/node': 24.10.1 + '@ungap/structured-clone': 1.3.0 + jest-util: 30.2.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)): + dependencies: + '@jest/core': 30.2.0(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + '@jest/types': 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + js-tokens@4.0.0: {} + + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + + leven@3.1.0: {} + + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash.memoize@4.1.2: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.3 + + make-error@1.3.6: {} + + makeerror@1.0.12: + dependencies: + tmpl: 1.0.5 + + merge-stream@2.0.0: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-fn@2.1.0: {} + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + minipass@7.1.2: {} + + ms@2.1.3: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + neo-async@2.6.2: {} + + node-int64@0.4.0: {} + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + + pretty-format@30.2.0: + dependencies: + '@jest/schemas': 30.0.5 + ansi-styles: 5.2.0 + react-is: 18.3.1 + + pure-rand@7.0.1: {} + + react-is@18.3.1: {} + + require-directory@2.1.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + + semver@6.3.1: {} + + semver@7.7.3: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-support@0.5.13: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + synckit@0.11.11: + dependencies: + '@pkgr/core': 0.2.9 + + test-exclude@6.0.0: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + + tmpl@1.0.5: {} + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-jest@29.4.5(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(jest-util@30.2.0)(jest@30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.2.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.28.5 + '@jest/transform': 30.2.0 + '@jest/types': 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + jest-util: 30.2.0 + + ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + '@tsconfig/node10': 1.0.12 + '@tsconfig/node12': 1.0.11 + '@tsconfig/node14': 1.0.3 + '@tsconfig/node16': 1.0.4 + '@types/node': 24.10.1 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.9.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + + tslib@2.8.1: + optional: true + + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + type-fest@4.41.0: {} + + typescript@5.9.3: {} + + uglify-js@3.19.3: + optional: true + + undici-types@7.16.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.1.4(browserslist@4.28.0): + dependencies: + browserslist: 4.28.0 + escalade: 3.2.0 + picocolors: 1.1.1 + + v8-compile-cache-lib@3.0.1: {} + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.0 + + walker@1.0.8: + dependencies: + makeerror: 1.0.12 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wordwrap@1.0.0: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + + wrappy@1.0.2: {} + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yn@3.1.1: {} + + yocto-queue@0.1.0: {} diff --git a/resources/tsconfig.json b/resources/tsconfig.json new file mode 100644 index 0000000..4b4cfbd --- /dev/null +++ b/resources/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["es2022"], + "declaration": true, + "strict": true, + "outDir": "./dist", + "rootDir": ".", + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "typeRoots": ["./node_modules/@types"] + }, + "include": ["bin/**/*", "lib/**/*", "test/**/*", "resources/bin/spicy-cdk.ts", "resources/index.ts"], + "exclude": ["node_modules", "cdk.out", "dist"] +} diff --git a/vars/accounts.groovy b/vars/accounts.groovy new file mode 100644 index 0000000..5071f1b --- /dev/null +++ b/vars/accounts.groovy @@ -0,0 +1,100 @@ +/** + * Account configuration for Spicy CDK pipelines + * + * This file defines AWS account configurations that can be referenced + * in Jenkinsfiles. Customize this for your organization. + * + * Usage: + * def myAccount = accounts.get().SPICY_CA_CENTRAL_1_DEV + */ + +def get() { + return getAccountsWithEnvironments() +} + +def getAccountsWithEnvironments() { + return getAccounts() + getDevelopment() + getSandbox() + getStaging() + getProduction() +} + +/** + * Base account configurations (without environment-specific settings) + */ +def getAccounts() { + def accounts = [:] + + // Example: Spicy AWS Account in ca-central-1 + accounts.put("SPICY_CA_CENTRAL_1", [ + accountId: env.AWS_ACCOUNT_ID ?: "123456789012", + region: "ca-central-1", + jenkinsAwsCredentialsId: "aws-credentials" + ]) + + return accounts +} + +/** + * Development environment configuration + */ +def getDevelopment() { + def accounts = [:] + def base = getAccounts().SPICY_CA_CENTRAL_1 + + accounts.put("SPICY_CA_CENTRAL_1_DEV", base + [ + environmentName: "development", + vpcStackName: "vpc-dev", + ecsClusterName: "cluster-dev", + ]) + + return accounts +} + +/** + * Sandbox environment configuration + */ +def getSandbox() { + def accounts = [:] + def base = getAccounts().SPICY_CA_CENTRAL_1 + + accounts.put("SPICY_CA_CENTRAL_1_SANDBOX", base + [ + environmentName: "sandbox", + vpcStackName: "vpc-sandbox", + ecsClusterName: "cluster-sandbox", + ]) + + return accounts +} + +/** + * Staging environment configuration + */ +def getStaging() { + def accounts = [:] + def base = getAccounts().SPICY_CA_CENTRAL_1 + + accounts.put("SPICY_CA_CENTRAL_1_STAGING", base + [ + environmentName: "staging", + vpcStackName: "vpc-staging", + ecsClusterName: "cluster-staging", + ]) + + return accounts +} + +/** + * Production environment configuration + */ +def getProduction() { + def accounts = [:] + def base = getAccounts().SPICY_CA_CENTRAL_1 + + accounts.put("SPICY_CA_CENTRAL_1_PROD", base + [ + environmentName: "production", + vpcStackName: "vpc-prod", + ecsClusterName: "cluster-prod", + ]) + + return accounts +} + +return this + diff --git a/vars/awsUtils.groovy b/vars/awsUtils.groovy new file mode 100644 index 0000000..29ea454 --- /dev/null +++ b/vars/awsUtils.groovy @@ -0,0 +1,96 @@ +/** + * AWS utilities for Spicy CDK pipelines + */ + +def runCLI(Map args) { + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: args.account.jenkinsAwsCredentialsId, + accessKeyVariable: 'AWS_ACCESS_KEY_ID', + secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) { + return sh( + script: """#!/bin/bash +x + set -e + export AWS_DEFAULT_REGION=${args.account.region} + set -x + ${args.command} + set +x + """, + returnStdout: true + ).trim() + } +} + +/** + * Get the current AWS account ID + */ +def getAccountId(Map args) { + return runCLI( + account: args.account, + command: "aws sts get-caller-identity --query Account --output text" + ) +} + +/** + * Check if a CloudFormation stack exists + */ +def stackExists(Map args) { + try { + runCLI( + account: args.account, + command: "aws cloudformation describe-stacks --stack-name ${args.stackName}" + ) + return true + } catch (err) { + return false + } +} + +/** + * Get CloudFormation stack outputs as a map + */ +def getStackOutputs(Map args) { + def outputs = [:] + try { + def result = runCLI( + account: args.account, + command: "aws cloudformation describe-stacks --stack-name ${args.stackName} --query 'Stacks[0].Outputs' --output json" + ) + def parsed = readJSON text: result + parsed.each { output -> + outputs[output.OutputKey] = output.OutputValue + } + } catch (err) { + echo "Could not get stack outputs: ${err}" + } + return outputs +} + +/** + * Get CloudFormation export value by export name + */ +def getCloudFormationExport(Map account, String exportName) { + try { + def result = runCLI( + account: account, + command: "aws cloudformation list-exports --query \"Exports[?Name=='${exportName}'].Value\" --output text" + ) + return result ?: null + } catch (err) { + echo "Could not get CloudFormation export ${exportName}: ${err.message}" + return null + } +} + +/** + * Build account configuration from pipeline arguments + */ +def buildAccountConfig(Map args) { + return [ + region: args.region, + jenkinsAwsCredentialsId: args.jenkinsAwsCredentialsId, + accountId: args.accountId ?: '' + ] +} + +return this + diff --git a/vars/buildAndPushDockerImage.groovy b/vars/buildAndPushDockerImage.groovy new file mode 100644 index 0000000..f179749 --- /dev/null +++ b/vars/buildAndPushDockerImage.groovy @@ -0,0 +1,150 @@ +/** + * Build and Push Docker Image Pipeline + * + * A simple pipeline for building and pushing Docker images to Nexus. + * + * Usage in Jenkinsfile: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * buildAndPushDockerImage( + * imageName: 'my-service', + * ) + * ``` + * + * With all options: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * buildAndPushDockerImage( + * imageName: 'my-service', + * dockerfile: 'Dockerfile', + * context: '.', + * buildArgs: [NODE_ENV: 'production'], + * registry: 'nexus.kodeniks.com', + * repository: 'docker-hosted', + * credentialsId: 'kodeniks-nexus-repository', + * tagLatest: true, + * compareWithLatest: true, + * pruneAfter: false, // Default false to preserve build cache + * pruneCache: false, // Whether to prune build cache when pruning + * ) + * ``` + */ + +def call(Map args = [:]) { + def config = [ + imageName: args.imageName ?: args.name ?: env.JOB_NAME?.tokenize('/')?.last() ?: 'application', + dockerfile: args.dockerfile ?: 'Dockerfile', + context: args.context ?: '.', + buildArgs: args.buildArgs ?: [:], + registry: args.registry ?: 'nexus.kodeniks.com', + repository: args.repository ?: 'docker-hosted', + credentialsId: args.credentialsId ?: 'kodeniks-nexus-repository', + imageTag: args.imageTag ?: null, // null means use git SHA + tagLatest: args.tagLatest != null ? args.tagLatest : true, + compareWithLatest: args.compareWithLatest != null ? args.compareWithLatest : true, + pruneAfter: args.pruneAfter != null ? args.pruneAfter : false, // Default to false to preserve build cache + pruneCache: args.pruneCache != null ? args.pruneCache : false, // Whether to prune build cache when pruning + agentLabel: args.agentLabel ?: 'docker', + ] + + pipeline { + agent { + label config.agentLabel + } + + stages { + stage('Checkout') { + steps { + checkout scm + script { + giteaUtils.setSuccess("checkout") + } + } + } + + stage('Build Docker Image') { + steps { + script { + // Use custom tag if provided, otherwise dockerUtils will use git SHA + def buildArgs = [ + imageName: config.imageName, + dockerfile: config.dockerfile, + context: config.context, + buildArgs: config.buildArgs, + registry: config.registry, + repository: config.repository, + credentialsId: config.credentialsId, + ] + + if (config.imageTag) { + buildArgs.imageTag = config.imageTag + } + + dockerUtils.buildImage(buildArgs) + dockerUtils.tagImage(buildArgs + [tagLatest: config.tagLatest]) + giteaUtils.setSuccess("build") + } + } + } + + stage('Push Docker Image') { + steps { + script { + def pushArgs = [ + imageName: config.imageName, + registry: config.registry, + repository: config.repository, + credentialsId: config.credentialsId, + tagLatest: config.tagLatest, + compareWithLatest: config.compareWithLatest, + ] + + if (config.imageTag) { + pushArgs.imageTag = config.imageTag + } + + def pushed = dockerUtils.pushImage(pushArgs) + + if (pushed) { + echo "Successfully pushed: ${dockerUtils.getImageRefSHA(pushArgs)}" + if (config.tagLatest) { + echo "Also pushed: ${dockerUtils.getImageRefLatest(pushArgs)}" + } + } else { + echo "Image unchanged - push skipped" + } + + giteaUtils.setSuccess("push") + } + } + } + } + + post { + always { + script { + if (config.pruneAfter) { + dockerUtils.prune([pruneCache: config.pruneCache]) + } + } + } + success { + script { + giteaUtils.setSuccess("pipeline") + echo "Docker image build and push completed successfully!" + } + } + failure { + script { + giteaUtils.setFailed("pipeline") + echo "Build failed!" + } + } + } + } +} + +return this + diff --git a/vars/cdkUtils.groovy b/vars/cdkUtils.groovy new file mode 100644 index 0000000..9aa3f5d --- /dev/null +++ b/vars/cdkUtils.groovy @@ -0,0 +1,197 @@ +/** + * CDK Utilities for Spicy CDK pipelines + * + * Provides functions to run AWS CDK commands from Jenkins pipelines. + * CDK runs directly on the Jenkins worker (no Docker needed for CDK itself). + */ + +/** + * Run a CDK command + * + * @param args.account Account configuration with region and jenkinsAwsCredentialsId + * @param args.command The CDK command to run (e.g., "deploy", "diff", "synth") + * @param args.stackName Name of the stack to deploy + * @param args.context Map of context values to pass to CDK (-c key=value) + * @param args.workDir Working directory (default: current directory) + * @param args.requireApproval CDK approval level (default: "never" for CI/CD) + */ +def runCdk(Map args) { + def account = args.account + def command = args.command ?: "deploy" + def stackName = args.stackName + def context = args.context ?: [:] + def workDir = args.workDir ?: "cdk-source" + def requireApproval = args.requireApproval ?: "never" + + // Build context arguments + def contextArgs = context.collect { k, v -> + if (v != null && v != '') { + "-c ${k}='${v}'" + } + }.findAll { it != null }.join(' ') + + // Build the full CDK command + def cdkCommand = "npx cdk ${command}" + + if (stackName) { + cdkCommand += " ${stackName}" + } + + if (contextArgs) { + cdkCommand += " ${contextArgs}" + } + + if (command == "deploy") { + cdkCommand += " --require-approval ${requireApproval}" + } + + if (command == "destroy") { + cdkCommand += " --force" + } + + // Run with AWS credentials + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: account.jenkinsAwsCredentialsId]]) { + dir(workDir) { + sh """ + export AWS_DEFAULT_REGION=${account.region} + export CDK_DEFAULT_ACCOUNT=${account.accountId ?: ''} + export CDK_DEFAULT_REGION=${account.region} + ${cdkCommand} + """ + } + } +} + +/** + * Deploy a CDK stack + * + * @param args.account Account configuration + * @param args.stackName Name of the stack + * @param args.stackType Type of stack (vpc, ecs-cluster, ecs-service) + * @param args.context Additional context values + */ +def deploy(Map args) { + def context = args.context ?: [:] + + // Add stackType and stackName to context + context.stackType = args.stackType ?: "vpc" + context.stackName = args.stackName + context.region = args.account.region + + runCdk( + account: args.account, + command: "deploy", + stackName: args.stackName, + context: context, + workDir: args.workDir ?: "cdk-source" + ) +} + +/** + * Show diff for a CDK stack + */ +def diff(Map args) { + def context = args.context ?: [:] + context.stackType = args.stackType ?: "vpc" + context.stackName = args.stackName + context.region = args.account.region + + runCdk( + account: args.account, + command: "diff", + stackName: args.stackName, + context: context, + workDir: args.workDir ?: "cdk-source" + ) +} + +/** + * Synthesize CloudFormation template for a CDK stack + */ +def synth(Map args) { + def context = args.context ?: [:] + context.stackType = args.stackType ?: "vpc" + context.stackName = args.stackName + context.region = args.account.region + + runCdk( + account: args.account, + command: "synth", + stackName: args.stackName, + context: context, + workDir: args.workDir ?: "cdk-source" + ) +} + +/** + * Destroy a CDK stack + */ +def destroy(Map args) { + def context = args.context ?: [:] + context.stackType = args.stackType ?: "vpc" + context.stackName = args.stackName + context.region = args.account.region + + runCdk( + account: args.account, + command: "destroy", + stackName: args.stackName, + context: context, + workDir: args.workDir ?: "cdk-source" + ) +} + +/** + * Install CDK workspace files from library resources. + * Assumes CDK/TypeScript toolchain is available globally on the agent. + */ +def install(Map args = [:]) { + def workDir = args.workDir ?: "cdk-source" + dir(workDir) { + // Ensure expected directory layout exists before copying files from resources + sh "mkdir -pv bin lib lib/constructs lib/stacks" + + // Copy CDK project files from shared library resources into the workspace + [ + [source: "package.json", destination: "package.json"], + [source: "pnpm-lock.yaml", destination: "pnpm-lock.yaml"], + [source: "tsconfig.json", destination: "tsconfig.json"], + [source: "cdk.json", destination: "cdk.json"], + [source: "cdk.context.json", destination: "cdk.context.json"], + [source: "bin/spicy-cdk.ts", destination: "bin/spicy-cdk.ts"], + [source: "lib/index.ts", destination: "lib/index.ts"], + [source: "lib/constructs/index.ts", destination: "lib/constructs/index.ts"], + [source: "lib/constructs/spicy-alb.ts", destination: "lib/constructs/spicy-alb.ts"], + [source: "lib/constructs/spicy-ecs-cluster.ts", destination: "lib/constructs/spicy-ecs-cluster.ts"], + [source: "lib/constructs/spicy-ecs-service.ts", destination: "lib/constructs/spicy-ecs-service.ts"], + [source: "lib/constructs/spicy-vpc.ts", destination: "lib/constructs/spicy-vpc.ts"], + [source: "lib/stacks/index.ts", destination: "lib/stacks/index.ts"], + [source: "lib/stacks/spicy-alb-stack.ts", destination: "lib/stacks/spicy-alb-stack.ts"], + [source: "lib/stacks/spicy-ecs-cluster-stack.ts", destination: "lib/stacks/spicy-ecs-cluster-stack.ts"], + [source: "lib/stacks/spicy-ecs-service-stack.ts", destination: "lib/stacks/spicy-ecs-service-stack.ts"], + [source: "lib/stacks/spicy-vpc-stack.ts", destination: "lib/stacks/spicy-vpc-stack.ts"], + ].each { entry -> + resources.copyResourceFile( + source: entry.source, + destination: entry.destination, + overwrite: true + ) + } + + sh "pnpm install --frozen-lockfile" + } +} + +/** + * Run CDK tests + */ +def test(Map args = [:]) { + def workDir = args.workDir ?: "." + dir(workDir) { + sh "pnpm run test" + } +} + +return this + diff --git a/vars/dockerUtils.groovy b/vars/dockerUtils.groovy new file mode 100644 index 0000000..fd382b4 --- /dev/null +++ b/vars/dockerUtils.groovy @@ -0,0 +1,219 @@ +/** + * Docker utilities for Spicy CDK pipelines + * Pushes images to Nexus repository (Sonatype) + */ + +// Default Nexus configuration +def getDefaultConfig() { + return [ + registry: 'nexus.kodeniks.com', + repository: 'docker-hosted', + credentialsId: 'kodeniks-nexus-repository' + ] +} + +def getImageRef(Map args, String tag) { + def config = getDefaultConfig() + args + return "${config.registry}/${config.repository}/${args.imageName}:${tag}" +} + +def getImageTag(Map args) { + return args.imageTag ?: gitUtils.getShortSHA() +} + +def getImageRefSHA(Map args) { + return getImageRef(args, getImageTag(args)) +} + +def getImageRefLatest(Map args) { + return getImageRef(args, 'latest') +} + +def copyFromImage(Map args) { + sh( + script: """#!/bin/bash +x + set -e + IMG_ID=\$(dd if=/dev/urandom bs=1k count=1 2> /dev/null | LC_CTYPE=C tr -cd "a-z0-9" | cut -c 1-22) + docker create --name \${IMG_ID} ${args.imageID} + docker cp \${IMG_ID}:${args.dockerPath} ${args.jenkinsPath} + docker rm \${IMG_ID} + """ + ) +} + +def getContainerName(Map args) { + return "${args.imageName}-${gitUtils.getShortSHA()}" +} + +/** + * Build a Docker image + * @param args.imageName - Name of the image + * @param args.dockerfile - Path to Dockerfile (default: 'Dockerfile') + * @param args.context - Build context (default: '.') + * @param args.buildArgs - Map of build arguments (optional) + */ +def buildImage(Map args) { + def containerName = getContainerName(args) + def dockerfile = args.dockerfile ?: 'Dockerfile' + def context = args.context ?: '.' + + def buildArgsString = '' + if (args.buildArgs) { + buildArgsString = args.buildArgs.collect { k, v -> "--build-arg ${k}=${v}" }.join(' ') + } + + sh("docker build -t ${containerName} -f ${dockerfile} ${buildArgsString} ${context}") + return containerName +} + +/** + * Tag image for Nexus registry + * @param args.imageName - Name of the image + * @param args.tagLatest - Whether to also tag as 'latest' (default: true on main branch) + */ +def tagImage(Map args) { + def containerName = getContainerName(args) + def tagLatest = args.tagLatest != null ? args.tagLatest : gitUtils.isMain() + + def shaImageRef = getImageRefSHA(args) + sh("docker tag ${containerName} ${shaImageRef}") + if (tagLatest) { + // Tag latest from the SHA-tagged image to ensure they point to the same image + sh("docker tag ${shaImageRef} ${getImageRefLatest(args)}") + } +} + +/** + * Push image to Nexus registry + * Optionally compares with remote 'latest' to skip push if unchanged + * @param args.imageName - Name of the image + * @param args.credentialsId - Jenkins credentials ID for Nexus (default: 'kodeniks-nexus-repository') + * @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com') + * @param args.repository - Nexus repository name (default: 'docker-hosted') + * @param args.tagLatest - Whether to also push 'latest' tag (default: true on main branch) + * @param args.compareWithLatest - Skip push if image matches remote 'latest' (default: true) + */ +def pushImage(Map args) { + def config = getDefaultConfig() + args + def tagLatest = args.tagLatest != null ? args.tagLatest : gitUtils.isMain() + def compareWithLatest = args.compareWithLatest != null ? args.compareWithLatest : true + + def localImageId = sh( + script: "docker inspect -f '{{.Id}}' ${getImageRefSHA(args)}", + returnStdout: true + ).trim() + echo "Local image ID: ${localImageId}" + + def remoteImageId = "" + if (compareWithLatest && tagLatest) { + withCredentials([usernamePassword( + credentialsId: config.credentialsId, + usernameVariable: 'NEXUS_USER', + passwordVariable: 'NEXUS_PASS' + )]) { + try { + sh "echo \$NEXUS_PASS | docker login ${config.registry} -u \$NEXUS_USER --password-stdin" + sh "docker pull ${getImageRefLatest(args)}" + remoteImageId = sh( + script: "docker inspect -f '{{.Id}}' ${getImageRefLatest(args)}", + returnStdout: true + ).trim() + echo "Remote (latest) image ID: ${remoteImageId}" + } catch (Exception e) { + echo "Could not pull remote 'latest' image (might be the first build): ${e.getMessage()}" + } + } + } + + if (remoteImageId && localImageId == remoteImageId) { + echo "No changes detected (new image is identical to remote 'latest'). Skipping push." + return false + } + + // Re-tag latest from SHA image right before pushing + // This ensures latest points to the new image even if we pulled the old remote latest for comparison + if (tagLatest) { + sh("docker tag ${getImageRefSHA(args)} ${getImageRefLatest(args)}") + } + + withCredentials([usernamePassword( + credentialsId: config.credentialsId, + usernameVariable: 'NEXUS_USER', + passwordVariable: 'NEXUS_PASS' + )]) { + sh "echo \$NEXUS_PASS | docker login ${config.registry} -u \$NEXUS_USER --password-stdin" + sh "docker push ${getImageRefSHA(args)}" + if (tagLatest) { + sh "docker push ${getImageRefLatest(args)}" + } + } + + return true +} + +/** + * Build and push a Docker image to Nexus + * @param args.imageName - Name of the image (required) + * @param args.dockerfile - Path to Dockerfile (default: 'Dockerfile') + * @param args.context - Build context (default: '.') + * @param args.buildArgs - Map of build arguments (optional) + * @param args.credentialsId - Jenkins credentials ID for Nexus (default: 'kodeniks-nexus-repository') + * @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com') + * @param args.repository - Nexus repository name (default: 'docker-hosted') + * @param args.tagLatest - Whether to also tag/push 'latest' (default: true on main branch) + * @param args.compareWithLatest - Skip push if image matches remote 'latest' (default: true) + * @return The full image reference (registry/repo/name:tag) + */ +def buildAndPush(Map args) { + buildImage(args) + tagImage(args) + pushImage(args) + + return getImageRefSHA(args) +} + +/** + * Prune dangling Docker images + * @param args.pruneCache - Whether to also prune build cache (default: false) + */ +def prune(Map args = [:]) { + def pruneCache = args.pruneCache ?: false + echo "Cleaning up dangling Docker images..." + if (pruneCache) { + sh 'docker system prune -f' + } else { + // Only prune dangling images, not build cache + sh 'docker image prune -f' + } +} + +/** + * Login to Nexus registry + */ +def login(Map args = [:]) { + def config = getDefaultConfig() + args + + withCredentials([usernamePassword( + credentialsId: config.credentialsId, + usernameVariable: 'NEXUS_USER', + passwordVariable: 'NEXUS_PASS' + )]) { + sh "echo \$NEXUS_PASS | docker login ${config.registry} -u \$NEXUS_USER --password-stdin" + } +} + +/** + * Pull an image from Nexus + */ +def pullImage(Map args) { + def config = getDefaultConfig() + args + def tag = args.tag ?: 'latest' + def imageRef = getImageRef(args, tag) + + login(config) + sh "docker pull ${imageRef}" + + return imageRef +} + +return this diff --git a/vars/gitUtils.groovy b/vars/gitUtils.groovy new file mode 100644 index 0000000..dc24671 --- /dev/null +++ b/vars/gitUtils.groovy @@ -0,0 +1,35 @@ +/** + * Git utilities for Spicy CDK pipelines + */ + +def getRemoteURL() { + return sh( + script: 'git config --get remote.origin.url', + returnStdout: true + ).trim() +} + +def getSHA() { + return sh( + script: 'git rev-parse HEAD', + returnStdout: true + ).trim() +} + +def getShortSHA() { + return sh( + script: 'git rev-parse --short HEAD', + returnStdout: true + ).trim() +} + +def isMain() { + return "${BRANCH_NAME}" == "main" || "${BRANCH_NAME}" == "master" +} + +def getBranchName() { + return env.CHANGE_BRANCH ?: env.BRANCH_NAME +} + +return this + diff --git a/vars/giteaUtils.groovy b/vars/giteaUtils.groovy new file mode 100644 index 0000000..88ebae1 --- /dev/null +++ b/vars/giteaUtils.groovy @@ -0,0 +1,72 @@ +/** + * Gitea commit status utilities for Spicy Automation pipelines + * + * Uses the same credential as the Gitea plugin (Gitea Personal Access Token type). + */ + +def updateStatus(Map args) { + try { + // For Gitea, we use the HTTP API to update commit status + // This requires a Gitea API token stored in Jenkins credentials + def giteaUrl = args.giteaUrl ?: env.GITEA_URL ?: 'https://git.kodeniks.com' + def credentialsId = args.credentialsId ?: 'kodeniks-gitea-token' + + // Gitea Personal Access Token credentials store token in the password field + withCredentials([usernamePassword(credentialsId: credentialsId, usernameVariable: 'GITEA_USER', passwordVariable: 'GITEA_TOKEN')]) { + def repoPath = gitUtils.getRemoteURL() + .replaceAll('.*[:/]([^/]+/[^/]+)\\.git$', '$1') + + def sha = gitUtils.getSHA() + def apiUrl = "${giteaUrl}/api/v1/repos/${repoPath}/statuses/${sha}" + + def payload = [ + state: args.state, + target_url: env.BUILD_URL ?: '', + description: args.message ?: '', + context: "ci/jenkins/${args.context}" + ] + + def payloadJson = groovy.json.JsonOutput.toJson(payload) + + // Use single quotes for shell, double quotes for curl args + // $GITEA_TOKEN is a shell variable set by withCredentials + sh "curl -s -X POST '${apiUrl}' -H 'Authorization: token '\"\$GITEA_TOKEN\"'' -H 'Content-Type: application/json' -d '${payloadJson}'" + } + } catch (err) { + print "Error updating commit status, proceeding without updating: ${err.getClass().getSimpleName()}" + } +} + +def setSuccess(context) { + updateStatus( + context: context, + state: 'success', + message: 'Completed' + ) +} + +def setPending(context) { + updateStatus( + context: context, + state: 'pending', + message: 'Pending' + ) +} + +def setFailed(context) { + updateStatus( + context: context, + state: 'failure', + message: 'Failed' + ) +} + +def setError(context) { + updateStatus( + context: context, + state: 'error', + message: 'Error' + ) +} + +return this diff --git a/vars/manualApproval.groovy b/vars/manualApproval.groovy new file mode 100644 index 0000000..2cecbee --- /dev/null +++ b/vars/manualApproval.groovy @@ -0,0 +1,61 @@ +/** + * Manual approval gate for Spicy CDK pipelines + * + * Usage: + * manualApproval( + * time: 4, + * timeUnit: "HOURS", + * message: "Deploy to Production" + * ) { + * // deployment code + * } + */ + +def call(Map args, Closure body) { + def userInput = null + def aborted = false + def allowConcurrentBuildsDuringInput = args.containsKey('allowConcurrentBuildsDuringInput') ? args.allowConcurrentBuildsDuringInput : true + + stage("${args.message} - Approval") { + if (allowConcurrentBuildsDuringInput) { + spicyUtils.setPipelineProperties(args.pipelineProperties, [disableConcurrentBuilds()]) + } + + try { + timeout(time: args.time, unit: args.timeUnit) { + input( + message: "${args.message}?", + parameters: (args.parameters ?: []) + ) + } + } catch (err) { + try { + def user = err.getCauses()[0].getUser() + if (user.toString() == 'SYSTEM') { + aborted = !args.runCommandsOnTimeout + } else { + aborted = true + } + } catch (ugh) { + aborted = true + } + } + + spicyUtils.setPipelineProperties(args.pipelineProperties, null) + + if (aborted) { + return -1 + } + + catchError { + if (args.passUserInputToBody) { + body(userInput) + } else { + body() + } + } + } +} + +return this + diff --git a/vars/npmUtils.groovy b/vars/npmUtils.groovy new file mode 100644 index 0000000..9c1cf9c --- /dev/null +++ b/vars/npmUtils.groovy @@ -0,0 +1,349 @@ +/** + * NPM utilities for Spicy CDK pipelines + * Publishes packages to Nexus NPM repository + * + * TODO: Future enhancement - Add support for running builds in Docker containers + * with configurable Node.js and pnpm versions via pipeline parameters: + * - nodeVersion: '20', '22', '24', etc. + * - pnpmVersion: '8', '9', '10', etc. + * - useContainer: true/false to opt-in to containerized builds + */ + +// Default Nexus configuration +def getDefaultConfig() { + return [ + registry: 'nexus.kodeniks.com', + repository: 'npm-hosted', + credentialsId: 'kodeniks-nexus-repository' + ] +} + +/** + * Retry an operation with exponential backoff + * @param maxRetries - Maximum number of retry attempts (default: 3) + * @param operation - Closure to execute + * @return Result of the operation + */ +def retryWithBackoff(int maxRetries = 3, Closure operation) { + def lastError + for (int i = 0; i < maxRetries; i++) { + try { + return operation() + } catch (Exception e) { + lastError = e + if (i < maxRetries - 1) { + def delay = (i + 1) * 2 // 2s, 4s, 6s + echo "Attempt ${i + 1}/${maxRetries} failed: ${e.message}" + echo "Retrying in ${delay} seconds..." + sleep(delay) + } + } + } + throw new Exception("Operation failed after ${maxRetries} attempts: ${lastError.message}", lastError) +} + +/** + * Read a field from package.json using Node.js + * @param field - Field name to read (e.g., 'name', 'version') + * @return Field value as string + */ +def readPackageJsonField(String field) { + if (!fileExists('package.json')) { + error("package.json not found") + } + return sh( + script: "node -pe \"require('./package.json').${field}\"", + returnStdout: true + ).trim() +} + +/** + * Validate package.json exists and has required fields + * @throws error if validation fails + */ +def validatePackageJson() { + if (!fileExists('package.json')) { + error("package.json not found in workspace") + } + + try { + // Read fields using Node.js + def pkgName = readPackageJsonField('name') + def pkgVersion = readPackageJsonField('version') + + if (!pkgName || pkgName.isEmpty() || pkgName == 'undefined') { + error("package.json missing required 'name' field") + } + + if (!pkgVersion || pkgVersion.isEmpty() || pkgVersion == 'undefined') { + error("package.json missing required 'version' field") + } + + // Validate version format (semver: x.y.z or x.y.z-prerelease) + if (!pkgVersion.matches(/^\d+\.\d+\.\d+(-.*)?$/)) { + error("Invalid version format in package.json: ${pkgVersion}. Expected semver format (e.g., 1.0.0)") + } + + echo "✓ package.json validation passed: ${pkgName}@${pkgVersion}" + return [name: pkgName, version: pkgVersion] + } catch (Exception e) { + error("Failed to parse package.json: ${e.message}") + } +} + +/** + * Verify that version was correctly updated in package.json + * @param expectedVersion - The version that should be in package.json + * @throws error if version doesn't match + */ +def verifyVersionUpdate(String expectedVersion) { + def actualVersion = npmPackageVersion() + + if (actualVersion != expectedVersion) { + error("Version mismatch: expected ${expectedVersion}, but package.json has ${actualVersion}") + } + + // Also verify by reading version directly from package.json using Node.js + try { + def actualVersionFromFile = readPackageJsonField('version') + if (actualVersionFromFile != expectedVersion) { + error("Version in package.json (${actualVersionFromFile}) doesn't match expected (${expectedVersion})") + } + } catch (Exception e) { + echo "WARNING: Could not verify version via package.json read: ${e.message}" + } + + echo "✓ Version verification passed: ${expectedVersion}" +} + +/** + * Get the full registry URL for npm + * @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com') + * @param args.repository - Nexus repository name (default: 'npm-hosted') + */ +def getRegistryUrl(Map args = [:]) { + def config = getDefaultConfig() + args + return "https://${config.registry}/repository/${config.repository}/" +} + +/** + * Extract scope from package name (e.g., '@kodeniks/package' -> '@kodeniks') + * @param packageName - Package name (may include scope) + */ +def extractScope(String packageName) { + if (packageName && packageName.startsWith('@')) { + def parts = packageName.split('/') + return parts.length > 0 ? parts[0] : null + } + return null +} + +/** + * Get package name from package.json + * @return Package name + */ +def npmPackageName() { + if (!fileExists('package.json')) { + error("package.json not found") + } + return sh( + script: 'node -pe "require(\'./package.json\').name"', + returnStdout: true + ).trim() +} + +/** + * Get package version from package.json + * @return Package version + */ +def npmPackageVersion() { + if (!fileExists('package.json')) { + error("package.json not found") + } + return sh( + script: 'node -pe "require(\'./package.json\').version"', + returnStdout: true + ).trim() +} + +/** + * Ensure version is ready for publishing + * Checks if current version is published on Nexus, auto-bumps patch if needed + * Includes retry logic for network operations + * @param args.packageName - Package name (required) + * @param args.registry - Nexus registry URL (default: 'nexus.kodeniks.com') + * @param args.repository - Nexus repository name (default: 'npm-hosted') + * @param args.credentialsId - Jenkins credentials ID for Nexus (default: 'kodeniks-nexus-repository') + * @param args.maxRetries - Maximum retry attempts for network calls (default: 3) + * @return The version to publish + */ +def ensureVersion(Map args = [:]) { + def config = getDefaultConfig() + args + def packageName = args.packageName + def maxRetries = args.maxRetries ?: 3 + + if (!packageName) { + error("packageName is required for ensureVersion") + } + + // Validate package.json first + validatePackageJson() + + def registryUrl = getRegistryUrl(config) + def currentVersion = npmPackageVersion() + + echo "==========================================" + echo "Version Check for: ${packageName}" + echo "Current version: ${currentVersion}" + echo "Registry: ${registryUrl}" + echo "==========================================" + + def versionOutput + withCredentials([usernamePassword( + credentialsId: config.credentialsId, + usernameVariable: 'NEXUS_USER', + passwordVariable: 'NEXUS_PASS' + )]) { + // Build package path for API URL (scoped: @scope/package -> @scope%2Fpackage) + def packagePath = packageName.startsWith('@') + ? packageName.replace('/', '%2F') + : packageName + def apiUrl = "${registryUrl}${packagePath}" + + echo "Fetching from: ${apiUrl}" + + // Run version check with retry logic using curl + jq + versionOutput = retryWithBackoff(maxRetries) { + def output = sh( + script: """ + # Get current version from package.json + CURRENT_VERSION=\$(jq -r '.version' package.json) + + # Fetch package metadata from Nexus + RESPONSE=\$(curl -s -w "\\n%{http_code}" \\ + -H "Accept: application/json" \\ + -u "\${NEXUS_USER}:\${NEXUS_PASS}" \\ + "${apiUrl}") + + HTTP_CODE=\$(echo "\$RESPONSE" | tail -n1) + BODY=\$(echo "\$RESPONSE" | sed '\$d') + + if [ "\$HTTP_CODE" = "200" ]; then + # Extract published version (prefer dist-tags.latest, fallback to highest version) + PUBLISHED_VERSION=\$(echo "\$BODY" | jq -r 'if ."dist-tags".latest then ."dist-tags".latest else (.versions | keys | sort_by(. | split(".") | map(tonumber)) | reverse | .[0]) end') + + if [ "\$PUBLISHED_VERSION" = "null" ] || [ -z "\$PUBLISHED_VERSION" ]; then + PUBLISHED_VERSION="" + fi + + echo "Published version found: \${PUBLISHED_VERSION}, current: \${CURRENT_VERSION}" >&2 + + if [ -n "\$PUBLISHED_VERSION" ]; then + # Compare versions using semver + # If current == published, or current < published, we need to bump from published + # If current > published, we can use current as-is + + # Check if versions are equal (exact string match) + if [ "\$CURRENT_VERSION" = "\$PUBLISHED_VERSION" ]; then + # Equal - bump from published + BASE_VERSION="\${PUBLISHED_VERSION}" + NEW_VERSION=\$(semver -i patch "\$BASE_VERSION") + else + # Compare versions using semver-aware sorting + # sort -V handles version sorting correctly (1.0.1 < 1.0.11 < 1.1.0) + # Get both versions, sort them, highest comes last + SORTED=\$(printf "%s\\n%s" "\$CURRENT_VERSION" "\$PUBLISHED_VERSION" | sort -V) + HIGHEST=\$(echo "\$SORTED" | tail -n1) + LOWEST=\$(echo "\$SORTED" | head -n1) + + # Verify sort worked correctly (should have exactly 2 versions) + if [ "\$(echo "\$SORTED" | wc -l)" != "2" ]; then + echo "ERROR: Version sorting failed" >&2 + exit 1 + fi + + if [ "\$HIGHEST" = "\$CURRENT_VERSION" ] && [ "\$LOWEST" = "\$PUBLISHED_VERSION" ]; then + # Current > Published - no bump needed, use current + BASE_VERSION="\${CURRENT_VERSION}" + NEW_VERSION="\${CURRENT_VERSION}" + elif [ "\$HIGHEST" = "\$PUBLISHED_VERSION" ] && [ "\$LOWEST" = "\$CURRENT_VERSION" ]; then + # Published > Current - bump from published + BASE_VERSION="\${PUBLISHED_VERSION}" + NEW_VERSION=\$(semver -i patch "\$BASE_VERSION") + else + # Should not happen, but fallback to bumping from published + echo "WARNING: Unexpected version comparison result" >&2 + BASE_VERSION="\${PUBLISHED_VERSION}" + NEW_VERSION=\$(semver -i patch "\$BASE_VERSION") + fi + fi + + # Update package.json if version changed + if [ "\$NEW_VERSION" != "\$CURRENT_VERSION" ]; then + jq --arg v "\$NEW_VERSION" '.version = \$v' package.json > package.json.tmp && mv package.json.tmp package.json + echo "Bumped version from \${CURRENT_VERSION} to \${NEW_VERSION}" >&2 + fi + + echo "\$NEW_VERSION" + + # Update package.json + jq --arg v "\$NEW_VERSION" '.version = \$v' package.json > package.json.tmp && mv package.json.tmp package.json + + echo "Bumped version from \${BASE_VERSION} to \${NEW_VERSION}" >&2 + echo "\$NEW_VERSION" + else + # No published version, use current + echo "\$CURRENT_VERSION" + fi + elif [ "\$HTTP_CODE" = "404" ]; then + echo "Package not found (404), using current version" >&2 + echo "\$CURRENT_VERSION" + else + echo "Error: HTTP \$HTTP_CODE" >&2 + echo "\$BODY" >&2 + exit 1 + fi + """, + returnStdout: true + ).trim() + + if (!output || output.isEmpty()) { + throw new Exception("Version check returned empty output") + } + + return output + } + + // Verify the version was actually updated in package.json + def actualVersion = npmPackageVersion() + if (versionOutput != actualVersion) { + echo "WARNING: Version mismatch detected!" + echo " Script returned: ${versionOutput}" + echo " package.json has: ${actualVersion}" + echo " Attempting to use package.json version..." + + // If script said to bump but package.json wasn't updated, try to sync + if (versionOutput != currentVersion && actualVersion == currentVersion) { + echo "Version bump didn't persist. This may indicate a file system issue." + error("Version update failed: script returned ${versionOutput} but package.json still has ${actualVersion}") + } + + versionOutput = actualVersion + } + } + + if (!versionOutput || versionOutput.isEmpty()) { + error("Failed to get version. The version check returned empty.") + } + + // Final verification + verifyVersionUpdate(versionOutput) + + echo "==========================================" + echo "✓ Version to publish: ${versionOutput}" + echo "==========================================" + + return versionOutput +} + +return this diff --git a/vars/publishNpmPackage.groovy b/vars/publishNpmPackage.groovy new file mode 100644 index 0000000..8b0c6e9 --- /dev/null +++ b/vars/publishNpmPackage.groovy @@ -0,0 +1,517 @@ +/** + * Publish NPM Package Pipeline + * + * A resilient pipeline for building and publishing npm packages to Nexus. + * + * TODO: Future enhancement - Add support for running builds in Docker containers + * with configurable Node.js and pnpm versions via pipeline parameters: + * - nodeVersion: '20', '22', '24', etc. (default: use agent's Node version) + * - pnpmVersion: '8', '9', '10', etc. (default: use agent's pnpm version) + * - useContainer: true/false to opt-in to containerized builds + * This would allow projects to specify exact Node/pnpm versions for reproducible builds. + * + * Usage in Jenkinsfile: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * publishNpmPackage() + * ``` + * + * With scoped package: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * publishNpmPackage( + * packageName: '@kodeniks/my-package', + * ) + * ``` + * + * With all options: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * publishNpmPackage( + * packageName: '@kodeniks/my-package', // Scoped packages supported + * registry: 'nexus.kodeniks.com', + * repository: 'npm-hosted', + * credentialsId: 'kodeniks-nexus-repository', + * gitCredentialsId: 'kodeniks-gitea-token', + * agentLabel: 'docker', // Agent must have Node.js installed + * ) + * ``` + */ + +def call(Map args = [:]) { + def config = [ + packageName: args.packageName ?: args.name, + registry: args.registry ?: 'nexus.kodeniks.com', + repository: args.repository ?: 'npm-hosted', + credentialsId: args.credentialsId ?: 'kodeniks-nexus-repository', + gitCredentialsId: args.gitCredentialsId ?: 'kodeniks-gitea-token', + agentLabel: args.agentLabel ?: 'docker', + gitPushRetries: args.gitPushRetries ?: 3, + ] + + // Store original git remote URL for cleanup + def originalGitRemote = null + + pipeline { + agent { + label config.agentLabel + } + + stages { + stage('Checkout') { + steps { + checkout scm + script { + // Validate package name + if (!config.packageName) { + // Try to read from package.json + try { + npmUtils.validatePackageJson() + config.packageName = npmUtils.npmPackageName() + echo "Auto-detected package name: ${config.packageName}" + } catch (Exception e) { + error("Package name not found. Please provide packageName in the arguments or ensure package.json exists with a 'name' field.") + } + } + + echo "==========================================" + echo "Package: ${config.packageName}" + echo "==========================================" + + // Store original git remote for cleanup + originalGitRemote = sh( + script: 'git config --get remote.origin.url 2>/dev/null || echo ""', + returnStdout: true + ).trim() + env.GIT_ORIGINAL_REMOTE_URL = originalGitRemote + + giteaUtils.setSuccess("checkout") + } + } + } + + stage('Get Version') { + steps { + script { + echo "==========================================" + echo "Stage: Get Version" + echo "==========================================" + + // Check if this build was triggered by a tag push + // This prevents infinite loops where tag push → new build → version bump → tag push + def isTagBuild = false + def currentTag = null + + // Check if HEAD is already a tag + try { + currentTag = sh( + script: 'git describe --exact-match --tags HEAD 2>/dev/null || echo ""', + returnStdout: true + ).trim() + if (currentTag && !currentTag.isEmpty()) { + isTagBuild = true + echo "Detected tag build: HEAD is tagged as ${currentTag}" + } + } catch (Exception e) { + // Not a tag, continue + } + + // Also check if BRANCH_NAME indicates a tag (Jenkins sets this for tag builds) + if (!isTagBuild && env.BRANCH_NAME && env.BRANCH_NAME.startsWith('tags/')) { + isTagBuild = true + echo "Detected tag build: BRANCH_NAME is ${env.BRANCH_NAME}" + } + + // Check if commit message indicates this is a version bump commit + if (!isTagBuild) { + try { + def commitMsg = sh( + script: 'git log -1 --pretty=%B', + returnStdout: true + ).trim() + if (commitMsg.contains('chore: bump version') || commitMsg.contains('bump version')) { + isTagBuild = true + echo "Detected tag build: commit message indicates version bump: ${commitMsg}" + } + } catch (Exception e) { + // Continue if we can't check commit message + } + } + + if (isTagBuild) { + echo "==========================================" + echo "⚠️ Skipping publish: Build triggered by tag push" + echo "This prevents infinite version bump loops" + echo "==========================================" + env.SKIP_PUBLISH = 'true' + // Still set a version for consistency, but we won't publish + def currentVersion = npmUtils.npmPackageVersion() + env.PACKAGE_VERSION = currentVersion + echo "Current package version: ${currentVersion}" + giteaUtils.setSuccess("version") + return + } + + // Validate package.json before version check + npmUtils.validatePackageJson() + + // Get version using npmUtils - checks Nexus and auto-bumps if needed + def versionOutput = npmUtils.ensureVersion([ + packageName: config.packageName, + registry: config.registry, + repository: config.repository, + credentialsId: config.credentialsId + ]) + + env.PACKAGE_VERSION = versionOutput + echo "✓ Version determined: ${versionOutput}" + giteaUtils.setSuccess("version") + } + } + } + + stage('Install Dependencies') { + when { + expression { env.SKIP_PUBLISH != 'true' } + } + steps { + script { + echo "==========================================" + echo "Stage: Install Dependencies" + echo "==========================================" + + def lockFile = null + def installCommand = null + + if(fileExists("package-lock.json")) { + lockFile = "package-lock.json" + installCommand = "npm install" + } else if(fileExists("pnpm-lock.yaml")) { + lockFile = "pnpm-lock.yaml" + installCommand = "pnpm install" + } else if(fileExists("yarn.lock")) { + lockFile = "yarn.lock" + installCommand = "yarn install" + } else { + error("No lock file found. Please ensure you have a package-lock.json, pnpm-lock.yaml, or yarn.lock file in your repository.") + } + + echo "Using lock file: ${lockFile}" + echo "Running: ${installCommand}" + sh(installCommand) + echo "✓ Dependencies installed" + } + } + } + + stage('Build Package') { + when { + expression { env.SKIP_PUBLISH != 'true' } + } + steps { + script { + echo "==========================================" + echo "Stage: Build Package" + echo "==========================================" + + def buildCommand = null + + if(fileExists("package-lock.json")) { + buildCommand = "npm run build" + } else if(fileExists("pnpm-lock.yaml")) { + buildCommand = "pnpm run build" + } else if(fileExists("yarn.lock")) { + buildCommand = "yarn run build" + } else { + error("No lock file found. Please ensure you have a package-lock.json, pnpm-lock.yaml, or yarn.lock file in your repository.") + } + + echo "Running: ${buildCommand}" + sh(buildCommand) + echo "✓ Build completed" + giteaUtils.setSuccess("build") + } + } + } + + stage('Publish Package') { + when { + expression { env.SKIP_PUBLISH != 'true' } + } + steps { + script { + echo "==========================================" + echo "Stage: Publish Package" + echo "==========================================" + + // Get registry URL + def registryUrl = npmUtils.getRegistryUrl([ + registry: config.registry, + repository: config.repository + ]) + + // Extract scope if package is scoped + def scope = npmUtils.extractScope(config.packageName) + + withCredentials([usernamePassword( + credentialsId: config.credentialsId, + usernameVariable: 'NEXUS_USER', + passwordVariable: 'NEXUS_PASS' + )]) { + // Configure npm registry and authentication + sh(""" + npm config set registry ${registryUrl} + npm config set _auth \$(echo -n "\${NEXUS_USER}:\${NEXUS_PASS}" | base64) + npm config fix + """) + + // For scoped packages, configure scope-specific registry + if (scope) { + sh("npm config set ${scope}:registry ${registryUrl}") + echo "Configured registry for scope: ${scope}" + } + + // Publish to Nexus with retry logic + def published = false + def lastError = null + for (int i = 0; i < 3; i++) { + try { + sh("npm publish") + published = true + break + } catch (Exception e) { + lastError = e + if (i < 2) { + echo "Publish attempt ${i + 1} failed, retrying in 2 seconds..." + sleep(2) + } + } + } + + if (!published) { + error("Failed to publish after 3 attempts: ${lastError.message}") + } + } + + echo "✓ Successfully published: ${config.packageName}@${env.PACKAGE_VERSION}" + giteaUtils.setSuccess("publish") + } + } + } + + stage('Commit and Push Changes') { + when { + expression { env.SKIP_PUBLISH != 'true' } + } + steps { + script { + echo "==========================================" + echo "Stage: Commit and Push Changes" + echo "==========================================" + + // Check git state + def gitDir = sh( + script: 'git rev-parse --git-dir 2>/dev/null || echo ""', + returnStdout: true + ).trim() + + if (!gitDir) { + echo "WARNING: Not in a git repository, skipping commit/push" + return + } + + withCredentials([usernamePassword( + credentialsId: config.gitCredentialsId, + usernameVariable: 'GITEA_USER', + passwordVariable: 'GITEA_TOKEN' + )]) { + // Get current remote URL + def remoteUrl = originalGitRemote ?: sh( + script: 'git config --get remote.origin.url', + returnStdout: true + ).trim() + + // Configure git user + sh(""" + git config user.name "Jenkins" + git config user.email "oss@kodeniks.com" + """) + + // Update remote URL to include credentials for authentication + sh(""" + REMOTE_URL='${remoteUrl}' + REPO_PATH=\$(echo "\$REMOTE_URL" | sed 's|.*git\\.kodeniks\\.com/||' | sed 's|\\.git\$||') + AUTH_URL="https://\${GITEA_USER}:\${GITEA_TOKEN}@git.kodeniks.com/\$REPO_PATH.git" + git remote set-url origin "\$AUTH_URL" + """) + + // Determine which lock files to add + def filesToAdd = ["package.json"] + if(fileExists("package-lock.json")) { + filesToAdd.add("package-lock.json") + } else if(fileExists("pnpm-lock.yaml")) { + filesToAdd.add("pnpm-lock.yaml") + } else if(fileExists("yarn.lock")) { + filesToAdd.add("yarn.lock") + } + + // Add files to staging + echo "Adding files to git: ${filesToAdd.join(', ')}" + sh("git add ${filesToAdd.join(' ')} 2>/dev/null || true") + + // Check if there are staged changes + // Use a simpler approach: check if git diff --staged has any output + def stagedDiff = sh( + script: 'git diff --staged --name-only', + returnStdout: true + ).trim() + + def hasChanges = !stagedDiff.isEmpty() + echo "Staged changes check: ${hasChanges ? 'Changes found' : 'No changes'}" + if (hasChanges) { + echo "Files with changes: ${stagedDiff}" + } + + if (hasChanges) { + echo "Committing changes..." + sh("git commit -m 'chore: bump version to ${env.PACKAGE_VERSION}'") + echo "✓ Committed version bump to ${env.PACKAGE_VERSION}" + } else { + echo "No changes to commit (version may have been committed already or package.json unchanged)" + // Verify what's actually in package.json vs what we expect + try { + def actualVersion = npmUtils.npmPackageVersion() + echo "Current package.json version: ${actualVersion}, expected: ${env.PACKAGE_VERSION}" + } catch (Exception e) { + echo "Could not read package.json version: ${e.message}" + } + } + + // Get branch name with fallbacks + def branchName = gitUtils.getBranchName() + if (!branchName || branchName.isEmpty() || branchName == 'HEAD') { + // Try multiple fallback strategies + branchName = sh( + script: ''' + git rev-parse --abbrev-ref HEAD 2>/dev/null || \ + git rev-parse --abbrev-ref --symbolic-full-name @{u} 2>/dev/null | sed "s|.*/||" || \ + echo "master" + ''', + returnStdout: true + ).trim() + } + + echo "Using branch: ${branchName}" + + // Create tag + sh(""" + git tag -f v${env.PACKAGE_VERSION} 2>/dev/null || git tag v${env.PACKAGE_VERSION} + """) + + // Push with retry logic + def pushSuccess = false + def pushError = null + + for (int i = 0; i < config.gitPushRetries; i++) { + try { + // Verify remote is accessible + sh("git ls-remote origin > /dev/null 2>&1 || true") + + // Push branch + sh("git push origin HEAD:refs/heads/${branchName}") + + // Push tag + sh("git push origin v${env.PACKAGE_VERSION}") + + // Verify push succeeded by checking if tag exists on remote + // grep -q returns exit code 0 if found, 1 if not found + def tagCheck = sh( + script: "git ls-remote --tags origin v${env.PACKAGE_VERSION} | grep -q v${env.PACKAGE_VERSION}", + returnStatus: true + ) + + if (tagCheck != 0) { + throw new Exception("Tag verification failed: tag v${env.PACKAGE_VERSION} not found on remote") + } + + echo "✓ Tag v${env.PACKAGE_VERSION} verified on remote" + + pushSuccess = true + break + } catch (Exception e) { + pushError = e + if (i < config.gitPushRetries - 1) { + echo "Push attempt ${i + 1} failed: ${e.message}" + echo "Retrying in 2 seconds..." + sleep(2) + } + } + } + + if (!pushSuccess) { + error("Failed to push to git after ${config.gitPushRetries} attempts: ${pushError.message}") + } + + // Restore original remote URL + if (originalGitRemote) { + sh("git remote set-url origin '${originalGitRemote}' || true") + } + } + + echo "✓ Committed and pushed version ${env.PACKAGE_VERSION} with tag v${env.PACKAGE_VERSION}" + giteaUtils.setSuccess("commit-push") + } + } + } + } + + post { + always { + script { + // Cleanup npm config + try { + sh("npm config delete registry || true") + sh("npm config delete _auth || true") + // Clean up scoped registry configs + def scope = npmUtils.extractScope(config.packageName) + if (scope) { + sh("npm config delete ${scope}:registry || true") + } + } catch (Exception e) { + echo "Warning: Failed to clean up npm config: ${e.message}" + } + + // Restore git remote if changed + try { + if (env.GIT_ORIGINAL_REMOTE_URL) { + sh("git remote set-url origin '${env.GIT_ORIGINAL_REMOTE_URL}' || true") + } + } catch (Exception e) { + echo "Warning: Failed to restore git remote: ${e.message}" + } + } + } + success { + script { + giteaUtils.setSuccess("pipeline") + echo "==========================================" + echo "✓ NPM package publish completed successfully!" + echo "==========================================" + } + } + failure { + script { + giteaUtils.setFailed("pipeline") + echo "==========================================" + echo "✗ Publish failed!" + echo "==========================================" + } + } + } + } +} + +return this diff --git a/vars/resources.groovy b/vars/resources.groovy new file mode 100644 index 0000000..188c823 --- /dev/null +++ b/vars/resources.groovy @@ -0,0 +1,49 @@ +def getInputStream(resourcePath) { + def input = libraryResource(resourcePath) + return new ByteArrayInputStream(input.getBytes()) +} + +def getProperties(resourcePath) { + def stream = getInputStream(resourcePath) + def properties = new Properties() + properties.load(stream) + return properties +} + + +/** + * Generates a path to a temporary file location, ending with {@code path} parameter. + * + * @param path path suffix + * @return path to file inside a temp directory + */ +@NonCPS +String createTempLocation(String path) { + String tmpDir = pwd tmp: true + return tmpDir + File.separator + new File(path).getName() +} + +/** + * Returns the path to a temp location of a script from the global library (resources/ subdirectory) + * + * @param source path within the resources/ subdirectory of this repo + * @param destination destination path (optional) + * @param overwrite file (optional) + * @return path to local file + */ +String copyResourceFile(Map args) { + def destination = args.destination ?: createTempLocation(args.source) + def testDestination = new File(destination) + try { + if(!testDestination.exists() || args.overwrite == true) { + writeFile file: destination, text: libraryResource(args.source) + echo "copyResourceFile: copied ${args.source} to ${destination}" + } else { + echo "copyResourceFile: ${destination} already exists... to replace use copyResourceFile(overwrite: true)" + } + } catch(err) { + echo("Unable to locate file: ${args.source}") + echo err.toString() + } + return destination +} diff --git a/vars/spicyDefaults.groovy b/vars/spicyDefaults.groovy new file mode 100644 index 0000000..e02986c --- /dev/null +++ b/vars/spicyDefaults.groovy @@ -0,0 +1,18 @@ +/** + * Default pipeline properties for Spicy CDK pipelines + */ + +def call(Map args) { + return args + [ + /* + * Properties for the Jenkins Pipeline. Does not allow concurrent builds by default. + * If you'd like to schedule a job using a cron syntax, set pipelineProperties to: + * + * [disableConcurrentBuilds(), pipelineTriggers([cron('H 13 * * *')])] + */ + pipelineProperties: [disableConcurrentBuilds()].plus(args.pipelineProperties ?: []), + ] +} + +return this + diff --git a/vars/spicyECSCluster.groovy b/vars/spicyECSCluster.groovy new file mode 100644 index 0000000..f9c4054 --- /dev/null +++ b/vars/spicyECSCluster.groovy @@ -0,0 +1,199 @@ +/** + * Spicy ECS Cluster Pipeline + * + * Deploys an ECS cluster using AWS CDK with: + * - EC2 Capacity Provider with managed scaling + * - Optional Fargate capacity providers (FARGATE + FARGATE_SPOT) + * - Mixed instances policy for Spot support + * - Instance draining on termination + * - Optional internal/external ALBs + * + * Usage in Jenkinsfile: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * spicyECSCluster( + * jenkinsAwsCredentialsId: "aws-credentials", + * region: "ca-central-1", + * stackName: "spicy-ecs-cluster", + * vpcStackName: "production-vpc", + * ownerTag: "SpicyTeam", + * productTag: "spicy", + * componentTag: "ecs-cluster", + * environment: "dev", + * instanceType: "m5a.large", + * minClusterSize: 2, + * maxClusterSize: 4, + * spotEnabled: true, + * onDemandPercentage: 20, + * createExternalLoadBalancer: true, + * createInternalLoadBalancer: true, + * certificateArn: "arn:aws:acm:ca-central-1:123456789:certificate/xxx", + * ) + * ``` + */ + +def call(Map args) { + args = spicyDefaults(args) + + timeout(time: 1, unit: "DAYS") { + timestamps { + node("docker") { + properties(args.pipelineProperties) + ansiColor("xterm") { + + stage("Checkout") { + checkout scm + giteaUtils.setSuccess("checkout") + } + + stage("Setup") { + cdkUtils.install() + giteaUtils.setSuccess("setup") + } + + if (args.onPreDeploy) { + spicyUtils.stageWithFailure("PreDeploy") { + args.onPreDeploy.call(args, [:]) + } + } + + stage("Deploy ECS Cluster") { + if (gitUtils.isMain()) { + try { + def context = buildEcsClusterContext(args) + def account = awsUtils.buildAccountConfig(args) + + // Show diff first + if (args.showDiff != false) { + echo "Showing CDK diff..." + cdkUtils.diff( + account: account, + stackName: args.stackName, + stackType: "ecs-cluster", + context: context + ) + } + + // Manual approval for production + if (args.environment == 'prod' || args.environment == 'production') { + manualApproval( + message: "Deploy ECS cluster ${args.stackName} to production?", + submitter: args.approvers ?: '' + ) + } + + // Deploy the stack + echo "Deploying ECS cluster stack: ${args.stackName}" + cdkUtils.deploy( + account: account, + stackName: args.stackName, + stackType: "ecs-cluster", + context: context + ) + + giteaUtils.setSuccess("deploy") + + if (args.onPostDeploy) { + spicyUtils.stageWithFailure("PostDeploy") { + args.onPostDeploy.call(args, [ + stackName: args.stackName + ]) + } + } + + } catch (err) { + giteaUtils.setFailed("deploy") + throw err + } + } else { + echo "Skipping deployment - not on main branch" + + // Show diff on non-main branches for PR review + if (args.showDiffOnPR != false) { + def context = buildEcsClusterContext(args) + def account = awsUtils.buildAccountConfig(args) + + echo "Showing CDK diff for PR review..." + try { + cdkUtils.diff( + account: account, + stackName: args.stackName, + stackType: "ecs-cluster", + context: context + ) + } catch (err) { + echo "Diff failed (stack may not exist yet): ${err.message}" + } + } + } + } + + giteaUtils.setSuccess("pipeline") + } + } + } + } +} + + +/** + * Build CDK context map from pipeline arguments + */ +def buildEcsClusterContext(Map args) { + def context = [:] + + // Required: VPC stack name (all VPC details imported from VPC stack exports) + context.vpcStackName = args.vpcStackName + if (!args.numberOfAzs) { + error("numberOfAzs is required for ECS cluster; pass the same value used for the VPC stack (2-4).") + } + context.numberOfAzs = args.numberOfAzs.toString() + + // Required tags + context.ownerTag = args.ownerTag + context.productTag = args.productTag + context.componentTag = args.componentTag ?: 'ecs-cluster' + context.environment = args.environment ?: 'dev' + context.build = args.build ?: gitUtils.getShortSHA() + + // Instance configuration + if (args.instanceType) context.instanceType = args.instanceType + if (args.additionalInstanceTypes) context.additionalInstanceTypes = args.additionalInstanceTypes + if (args.keyName) context.keyName = args.keyName + if (args.ebsVolumeSize) context.ebsVolumeSize = args.ebsVolumeSize.toString() + + // Container Insights + if (args.containerInsights != null) context.containerInsights = args.containerInsights.toString() + + // Scaling configuration + if (args.minClusterSize) context.minClusterSize = args.minClusterSize.toString() + if (args.maxClusterSize) context.maxClusterSize = args.maxClusterSize.toString() + if (args.targetCapacityPercent) context.targetCapacityPercent = args.targetCapacityPercent.toString() + + // Spot configuration + if (args.spotEnabled != null) context.spotEnabled = args.spotEnabled.toString() + if (args.onDemandPercentage != null) context.onDemandPercentage = args.onDemandPercentage.toString() + if (args.spotAllocationStrategy) context.spotAllocationStrategy = args.spotAllocationStrategy + + // Load balancer configuration + if (args.createExternalLoadBalancer != null) { + context.createExternalLoadBalancer = args.createExternalLoadBalancer.toString() + } + if (args.createInternalLoadBalancer != null) { + context.createInternalLoadBalancer = args.createInternalLoadBalancer.toString() + } + if (args.certificateArn) context.certificateArn = args.certificateArn + + // Fargate configuration (enables both FARGATE and FARGATE_SPOT capacity providers) + if (args.enableFargate != null) context.enableFargate = args.enableFargate.toString() + + // Timeouts + if (args.drainingTimeout) context.drainingTimeout = args.drainingTimeout.toString() + if (args.maxInstanceLifetime) context.maxInstanceLifetime = args.maxInstanceLifetime.toString() + + return context +} + +return this + diff --git a/vars/spicyECSService.groovy b/vars/spicyECSService.groovy new file mode 100644 index 0000000..e8ca83b --- /dev/null +++ b/vars/spicyECSService.groovy @@ -0,0 +1,892 @@ +/** + * Spicy ECS Service Pipeline + * + * Deploys an ECS service using AWS CDK with: + * - Mixed capacity provider strategy (EC2 + Fargate burst) + * - Auto-scaling on CPU/Memory/Requests + * - ALB integration with host/path routing + * - Blue/Green deployments with hostname swaps + * - Optional Route53 DNS records (hostnames are optional - services may not need DNS) + * - Deployment circuit breaker with rollback + * - ECS Exec support for debugging + * - Pipeline hooks for custom behavior + * + * Hostname Support: + * - hostName: Simple hostname (e.g., "api.example.com") - auto-generates active/inactive hostnames + * - activeHostname/inactiveHostname: Explicit hostnames for blue/green + * - Hostnames are OPTIONAL - services without hostnames (pub/sub, workers) don't need DNS + * - For Cloudflare -> AWS DNS delegation: Point *.production.mydomain.com NS records to AWS Route53 + * + * Pipeline Hooks (executed in order): + * - buildCommand: Custom build command (replaces default docker build) + * - onPostBuild: After build completes (linting, unit tests) + * - onPreDeploy: Before deployment (setup, integration test prep) + * - blueGreenTest: After inactive stack is up (integration tests against inactive) + * - onPostDeploy: After deployment succeeds (cleanup, notifications) + * - smokeTest: After blue/green swap or rolling deploy (smoke tests) + * + * Usage in Jenkinsfile: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * spicyECSService( + * jenkinsAwsCredentialsId: "aws-credentials", + * region: "ca-central-1", + * stackName: "my-service-dev", + * serviceName: "my-service", + * + * // Cluster info (VPC details are imported from cluster/VPC stack exports) + * clusterName: "my-ecs-cluster-dev", + * + * // Container config + * image: "nexus.kodeniks.com/docker-hosted/my-app:latest", + * containerPort: 3000, + * cpu: 256, + * memory: 512, + * environment: [NODE_ENV: "production"], + * + * // Blue/Green deployment (works with both individual ALB and cluster ALB) + * blueGreen: true, + * // Option 1: Simple hostName (auto-generates active/inactive hostnames) + * hostName: "api.example.com", // Creates: api.example.com and inactive-api.example.com + * bgHostedZoneId: "Z1234567890", // Required for DNS records + * // Option 2: Explicit hostnames + * // activeHostname: "api.example.com", + * // inactiveHostname: "inactive-api.example.com", + * blueGreenTest: { args, buildInfo -> + * sh "curl -f https://${buildInfo.inactiveHostname}/health" + * }, + * + * // Capacity strategy (EC2 base + Fargate burst) + * capacityProviderStrategy: [ + * [capacityProvider: "my-ecs-cluster-dev-ec2", base: 2, weight: 3], + * [capacityProvider: "FARGATE_SPOT", weight: 1], + * ], + * + * // Scaling + * desiredCount: 2, + * minCapacity: 2, + * maxCapacity: 10, + * targetCpuUtilization: 70, + * + * // Routing - ALB configuration + * // Pipeline resolves ALB details from cluster or ALB stack based on useClusterAlb + * // Option 1: Use cluster ALB (useClusterAlb=true, default) + * useClusterAlb: true, // Lookup ALB from cluster stack exports + * albScheme: "internet-facing", // or "internal" - determines which cluster ALB to use + * healthCheckPath: "/health", + * hostHeader: "api.example.com", // or pathPatterns: ["/api/*"] + * priority: 100, + * + * // Option 2: Use dedicated ALB stack (useClusterAlb=false) + * // useClusterAlb: false, // Deploy dedicated ALB stack for this service + * // albScheme: "internet-facing", // or "internal" (subnets imported from VPC stack based on scheme) + * // certificateArn: "arn:aws:acm:ca-central-1:123456789:certificate/xxx", + * // clusterLogsBucketName: "my-cluster-logs-bucket", + * // albIdleTimeout: 60, + * // redirectHttpToHttps: true, + * // healthCheckPath: "/health", + * // hostHeader: "api.example.com", // or pathPatterns: ["/api/*"] + * // priority: 100, + * + * // Tags + * ownerTag: "MyTeam", + * productTag: "my-product", + * componentTag: "api", + * environment: "dev", + * + * // Hooks + * onPostBuild: { args, buildInfo -> + * junit 'coverage/junit.xml' + * }, + * smokeTest: { args, buildInfo -> + * sh "curl -f https://${buildInfo.activeHostname}/health" + * }, + * ) + * ``` + */ + +def call(Map args) { + args = spicyDefaults(args) + + // Build info passed to hooks + def buildInfo = [:] + + timeout(time: 1, unit: "DAYS") { + timestamps { + node("docker") { + properties(args.pipelineProperties) + ansiColor("xterm") { + + stage("Checkout") { + checkout scm + buildInfo.commitSha = gitUtils.getShortSHA() + buildInfo.branch = env.BRANCH_NAME ?: 'main' + giteaUtils.setSuccess("checkout") + } + + // Build Docker image if Dockerfile exists + if (fileExists('Dockerfile') && args.buildImage != false) { + spicyUtils.stageWithFailure("Build Image") { + def imageTag = args.imageTag ?: buildInfo.commitSha + def imageName = args.imageName ?: args.serviceName + + // Use custom build command if provided + if (args.buildCommand) { + args.buildCommand.call(args, buildInfo) + } else { + def builtImage = dockerUtils.buildAndPush( + imageName: imageName, + imageTag: imageTag, + dockerfile: args.dockerfile ?: 'Dockerfile', + context: args.dockerContext ?: '.', + buildArgs: args.dockerBuildArgs ?: [:], + ) + args.image = builtImage + } + + buildInfo.image = args.image + buildInfo.imageTag = imageTag + giteaUtils.setSuccess("build-image") + } + + // onPostBuild hook + if (args.onPostBuild) { + spicyUtils.stageWithFailure("Post Build") { + args.onPostBuild.call(args, buildInfo) + giteaUtils.setSuccess("post-build") + } + } + } + + stage("Setup CDK") { + cdkUtils.install() + giteaUtils.setSuccess("setup") + } + + // onPreDeploy hook + if (args.onPreDeploy) { + spicyUtils.stageWithFailure("Pre Deploy") { + args.onPreDeploy.call(args, buildInfo) + giteaUtils.setSuccess("pre-deploy") + } + } + + stage("Deploy Service") { + if (gitUtils.isMain()) { + try { + // Determine deployment strategy + if (args.blueGreen) { + deployBlueGreen(args, buildInfo) + } else { + deployRolling(args, buildInfo) + } + } catch (err) { + giteaUtils.setFailed("deploy") + throw err + } + } else { + echo "Skipping deployment - not on main branch" + + // Show diff on non-main branches for PR review + if (args.showDiffOnPR != false) { + showDiffForPR(args, buildInfo) + } + } + } + + giteaUtils.setSuccess("pipeline") + } + } + } + } +} + +/** + * Rolling deployment (single service, in-place update) + */ +def deployRolling(Map args, Map buildInfo) { + // Resolve ALB details (from cluster or ALB stack) + resolveAlbDetails(args) + + def context = buildServiceContext(args) + def account = awsUtils.buildAccountConfig(args) + + // Show diff first + if (args.showDiff != false) { + echo "Showing CDK diff..." + cdkUtils.diff( + account: account, + stackName: args.stackName, + stackType: "ecs-service", + context: context + ) + } + + // Manual approval for production + if (args.environment == 'prod' || args.environment == 'production') { + manualApproval( + message: "Deploy service ${args.serviceName} to production?", + submitter: args.approvers ?: '' + ) + } + + // Deploy the stack + echo "Deploying ECS service stack: ${args.stackName}" + cdkUtils.deploy( + account: account, + stackName: args.stackName, + stackType: "ecs-service", + context: context + ) + + // Stream logs during deployment stabilization + if (args.streamLogs != false) { + streamDeploymentLogs(args, buildInfo) + } + + giteaUtils.setSuccess("deploy") + + // onPostDeploy hook + if (args.onPostDeploy) { + spicyUtils.stageWithFailure("Post Deploy") { + // Support hostName parameter + def hostname = args.activeHostname ?: args.hostName ?: args.hostHeader + args.onPostDeploy.call(args, [ + stackName: args.stackName, + serviceName: args.serviceName, + activeHostname: hostname + ]) + } + } + + // Smoke test + if (args.smokeTest) { + spicyUtils.stageWithFailure("Smoke Test") { + // Support hostName parameter + buildInfo.activeHostname = args.activeHostname ?: args.hostName ?: args.hostHeader + buildInfo.healthCheckPath = args.healthCheckPath ?: '/health' + args.smokeTest.call(args, buildInfo) + giteaUtils.setSuccess("smoke-test") + } + } +} + +/** + * Blue/Green deployment with hostname swapping + */ +def deployBlueGreen(Map args, Map buildInfo) { + def account = awsUtils.buildAccountConfig(args) + + // Resolve ALB details (from cluster or ALB stack) + resolveAlbDetails(args) + + // Determine current active color + def currentActive = getActiveColor(args) + def targetColor = currentActive == 'blue' ? 'green' : 'blue' + + echo "Current active: ${currentActive}, deploying to: ${targetColor}" + + buildInfo.currentActive = currentActive + buildInfo.targetColor = targetColor + + // Support simple hostName parameter (like old HostName) - auto-generates active/inactive hostnames + // If hostName is provided, use it to generate activeHostname and inactiveHostname + if (args.hostName && !args.activeHostname && !args.inactiveHostname) { + buildInfo.activeHostname = args.hostName + buildInfo.inactiveHostname = "inactive-${args.hostName}" + } else { + // Use explicit hostnames if provided, otherwise fall back to hostHeader + buildInfo.activeHostname = args.activeHostname ?: args.hostName ?: args.hostHeader + buildInfo.inactiveHostname = args.inactiveHostname ?: (args.hostName ? "inactive-${args.hostName}" : "inactive-${args.hostHeader}") + } + + // Build context for target (inactive) service + def targetStackName = "${args.stackName}-${targetColor}" + def context = buildServiceContext(args) + context.serviceColor = targetColor + + // Set blue/green DNS if bgHostedZoneId is provided (hostnames are optional) + // ALB details are resolved by resolveAlbDetails() (internal-only, not input parameters) + if (args.bgHostedZoneId) { + context.activeHostname = buildInfo.activeHostname + context.inactiveHostname = buildInfo.inactiveHostname + context.isActive = 'false' // This is the inactive service + context.bgHostedZoneId = args.bgHostedZoneId + } else { + // No DNS - use hostHeader and priority for routing + context.hostHeader = buildInfo.inactiveHostname + // Use higher priority for inactive (lower number = higher priority, so inactive gets higher number) + def basePriority = args.priority ?: 100 + context.priority = (targetColor == 'blue' ? basePriority : basePriority + 100).toString() + } + + // Show diff + if (args.showDiff != false) { + echo "Showing CDK diff for ${targetColor} service..." + try { + cdkUtils.diff( + account: account, + stackName: targetStackName, + stackType: "ecs-service", + context: context + ) + } catch (err) { + echo "Diff failed (stack may not exist yet): ${err.message}" + } + } + + // Manual approval for production + if (args.environment == 'prod' || args.environment == 'production') { + manualApproval( + message: "Deploy ${args.serviceName} (${targetColor}) to production?", + submitter: args.approvers ?: '' + ) + } + + // Deploy to inactive stack + echo "Deploying to ${targetColor} service: ${targetStackName}" + cdkUtils.deploy( + account: account, + stackName: targetStackName, + stackType: "ecs-service", + context: context + ) + + // Stream logs during deployment + if (args.streamLogs != false) { + streamDeploymentLogs(args, buildInfo) + } + + giteaUtils.setSuccess("deploy-${targetColor}") + + // blueGreenTest hook - test against inactive hostname + if (args.blueGreenTest) { + spicyUtils.stageWithFailure("Blue/Green Test") { + echo "Testing inactive service at: ${buildInfo.inactiveHostname}" + args.blueGreenTest.call(args, buildInfo) + giteaUtils.setSuccess("blue-green-test") + } + } + + // Swap hostnames + stage("Swap Hostnames") { + if (args.environment == 'prod' || args.environment == 'production') { + manualApproval( + message: "Swap traffic to ${targetColor}? This will make ${targetColor} active.", + submitter: args.approvers ?: '' + ) + } + + swapHostnames(args, buildInfo, account) + giteaUtils.setSuccess("swap") + } + + // onPostDeploy hook + if (args.onPostDeploy) { + spicyUtils.stageWithFailure("Post Deploy") { + args.onPostDeploy.call(args, buildInfo) + } + } + + // Smoke test against new active + if (args.smokeTest) { + spicyUtils.stageWithFailure("Smoke Test") { + echo "Running smoke test against: ${buildInfo.activeHostname}" + args.smokeTest.call(args, buildInfo) + giteaUtils.setSuccess("smoke-test") + } + } + + // Schedule cleanup of old stack (keep for rollback window) + def rollbackWindow = args.rollbackWindowHours ?: 2 + echo "Old ${currentActive} service will be retained for ${rollbackWindow} hours for rollback" + saveActiveColor(args, targetColor) +} + +/** + * Swap hostnames between blue and green services + */ +def swapHostnames(Map args, Map buildInfo, Map account) { + def activeColor = buildInfo.targetColor + def inactiveColor = buildInfo.currentActive + + def activeStackName = "${args.stackName}-${activeColor}" + def inactiveStackName = "${args.stackName}-${inactiveColor}" + + echo "Swapping hostnames: ${activeColor} -> active, ${inactiveColor} -> inactive" + + // Update active service to use active hostname with lower priority + def activeContext = buildServiceContext(args) + activeContext.serviceColor = activeColor + + // Set blue/green DNS if bgHostedZoneId is provided + // ALB details are resolved by resolveAlbDetails() (internal-only, not input parameters) + if (args.bgHostedZoneId) { + activeContext.activeHostname = buildInfo.activeHostname + activeContext.inactiveHostname = buildInfo.inactiveHostname + activeContext.isActive = 'true' // This is the active service + activeContext.bgHostedZoneId = args.bgHostedZoneId + } else { + // No DNS - use hostHeader for routing + activeContext.hostHeader = buildInfo.activeHostname + activeContext.priority = (args.priority ?: 100).toString() + } + + cdkUtils.deploy( + account: account, + stackName: activeStackName, + stackType: "ecs-service", + context: activeContext + ) + + // Update inactive service to use inactive hostname with higher priority number + if (stackExists(inactiveStackName, account)) { + def inactiveContext = buildServiceContext(args) + inactiveContext.serviceColor = inactiveColor + + // Set blue/green DNS if bgHostedZoneId is provided + // ALB details are resolved by resolveAlbDetails() (internal-only, not input parameters) + if (args.bgHostedZoneId) { + inactiveContext.activeHostname = buildInfo.activeHostname + inactiveContext.inactiveHostname = buildInfo.inactiveHostname + inactiveContext.isActive = 'false' // This is the inactive service + inactiveContext.bgHostedZoneId = args.bgHostedZoneId + } else { + // No DNS - use hostHeader for routing + inactiveContext.hostHeader = buildInfo.inactiveHostname + inactiveContext.priority = ((args.priority ?: 100) + 100).toString() + } + + cdkUtils.deploy( + account: account, + stackName: inactiveStackName, + stackType: "ecs-service", + context: inactiveContext + ) + } + + echo "Hostname swap complete! ${activeColor} is now active." +} + +/** + * Get the currently active color from SSM Parameter Store + */ +def getActiveColor(Map args) { + def paramName = "/spicy/${args.serviceName}/active-color" + + try { + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: args.jenkinsAwsCredentialsId, + accessKeyVariable: 'AWS_ACCESS_KEY_ID', + secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) { + def result = sh( + script: "aws ssm get-parameter --name '${paramName}' --region ${args.region} --query 'Parameter.Value' --output text 2>/dev/null || echo 'blue'", + returnStdout: true + ).trim() + return result ?: 'blue' + } + } catch (err) { + echo "Could not get active color, defaulting to blue: ${err.message}" + return 'blue' + } +} + +/** + * Save the active color to SSM Parameter Store + */ +def saveActiveColor(Map args, String color) { + def paramName = "/spicy/${args.serviceName}/active-color" + + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: args.jenkinsAwsCredentialsId, + accessKeyVariable: 'AWS_ACCESS_KEY_ID', + secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) { + sh """ + aws ssm put-parameter \ + --name '${paramName}' \ + --value '${color}' \ + --type String \ + --overwrite \ + --region ${args.region} + """ + } +} + +/** + * Check if a CloudFormation stack exists + */ +def stackExists(String stackName, Map account) { + try { + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: account.jenkinsAwsCredentialsId, + accessKeyVariable: 'AWS_ACCESS_KEY_ID', + secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) { + def result = sh( + script: "aws cloudformation describe-stacks --stack-name '${stackName}' --region ${account.region} 2>/dev/null", + returnStatus: true + ) + return result == 0 + } + } catch (err) { + return false + } +} + +/** + * Stream ECS deployment logs to Jenkins console + */ +def streamDeploymentLogs(Map args, Map buildInfo) { + try { + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: args.jenkinsAwsCredentialsId, + accessKeyVariable: 'AWS_ACCESS_KEY_ID', + secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) { + + echo "Streaming ECS service events..." + def logGroupName = "/ecs/${args.serviceName}" + + // Stream logs for 60 seconds or until deployment stabilizes + sh """ + timeout 60 aws logs tail '${logGroupName}' \ + --region ${args.region} \ + --follow \ + --since 5m 2>/dev/null || true + """ + + // Show recent ECS service events + echo "Recent ECS service events:" + sh """ + aws ecs describe-services \ + --cluster ${args.clusterName} \ + --services ${args.serviceName} \ + --region ${args.region} \ + --query 'services[0].events[0:5]' \ + --output table 2>/dev/null || true + """ + } + } catch (err) { + echo "Could not stream logs: ${err.message}" + } +} + +/** + * Show CDK diff for PR review + */ +def showDiffForPR(Map args, Map buildInfo) { + // Resolve ALB details for accurate diff (may fail if stacks don't exist, but that's ok for PR review) + try { + resolveAlbDetails(args) + } catch (err) { + echo "Could not resolve ALB details for diff (stacks may not exist): ${err.message}" + // Continue without ALB details - diff will show what it can + } + + def context = buildServiceContext(args) + def account = awsUtils.buildAccountConfig(args) + + echo "Showing CDK diff for PR review..." + try { + cdkUtils.diff( + account: account, + stackName: args.stackName, + stackType: "ecs-service", + context: context + ) + } catch (err) { + echo "Diff failed (stack may not exist yet): ${err.message}" + } +} + + + +/** + * Resolve ALB details from either cluster ALB or deployed ALB stack + * Always resolves and sets albLoadBalancerArn, albHttpsListenerArn, albHttpListenerArn in args + * These are internal-only values, never input parameters + */ +def resolveAlbDetails(Map args) { + def account = awsUtils.buildAccountConfig(args) + def clusterStackName = args.clusterStackName ?: args.clusterName + + if (!clusterStackName) { + error("clusterStackName or clusterName is required to resolve ALB details") + } + + // Default useClusterAlb to true if not specified + def useClusterAlb = args.useClusterAlb != false + + // Always resolve ALB details (overwrite any existing values - these are not input parameters) + + if (useClusterAlb) { + // Get ALB details from cluster stack exports + def albScheme = args.albScheme ?: 'internet-facing' + def prefix = albScheme == 'internet-facing' ? 'internet-facing' : 'internal' + + echo "Resolving ALB details from cluster stack: ${clusterStackName} (${prefix})" + + def albArn = awsUtils.getCloudFormationExport(account, "${clusterStackName}-${prefix}-arn") + def httpsListenerArn = awsUtils.getCloudFormationExport(account, "${clusterStackName}-${prefix}-https-listener") + def httpListenerArn = awsUtils.getCloudFormationExport(account, "${clusterStackName}-${prefix}-http-listener") + + if (!albArn) { + error("Cluster stack ${clusterStackName} does not have ${prefix} ALB. Export ${clusterStackName}-${prefix}-arn not found.") + } + if (!httpsListenerArn && !httpListenerArn) { + error("Cluster stack ${clusterStackName} does not have ${prefix} ALB listeners. Exports ${clusterStackName}-${prefix}-https-listener and ${clusterStackName}-${prefix}-http-listener not found.") + } + + args.albLoadBalancerArn = albArn + args.albHttpsListenerArn = httpsListenerArn + args.albHttpListenerArn = httpListenerArn + + echo "Resolved cluster ALB: ${albArn}" + } else { + // Deploy ALB stack and get ALB details from ALB stack exports + deployAlbStack(args) + + def baseStackName = args.stackName.replaceAll(/-blue$|-green$/, '') + def albStackName = "${baseStackName}-alb" + def albScheme = args.albScheme ?: 'internet-facing' + def prefix = albScheme == 'internet-facing' ? 'internet-facing' : 'internal' + + echo "Resolving ALB details from ALB stack: ${albStackName} (${prefix})" + + def albArn = awsUtils.getCloudFormationExport(account, "${albStackName}-${prefix}-arn") + def httpsListenerArn = awsUtils.getCloudFormationExport(account, "${albStackName}-${prefix}-https-listener") + def httpListenerArn = awsUtils.getCloudFormationExport(account, "${albStackName}-${prefix}-http-listener") + + if (!albArn) { + error("ALB stack ${albStackName} does not have ${prefix} ALB. Export ${albStackName}-${prefix}-arn not found.") + } + if (!httpsListenerArn && !httpListenerArn) { + error("ALB stack ${albStackName} does not have ${prefix} ALB listeners. Exports ${albStackName}-${prefix}-https-listener and ${albStackName}-${prefix}-http-listener not found.") + } + + args.albLoadBalancerArn = albArn + args.albHttpsListenerArn = httpsListenerArn + args.albHttpListenerArn = httpListenerArn + + echo "Resolved ALB stack ALB: ${albArn}" + } +} + +/** + * Build CDK context map from pipeline arguments + */ +def buildServiceContext(Map args) { + def context = [:] + + // Required: Cluster stack name (all VPC details imported from VPC stack via cluster exports) + context.clusterStackName = args.clusterStackName ?: args.clusterName + if (!context.clusterStackName) { + error("clusterStackName or clusterName is required") + } + if (!args.numberOfAzs) { + error("numberOfAzs is required (2-4) and must match the VPC used by the cluster.") + } + context.numberOfAzs = args.numberOfAzs.toString() + + // Required: Service config + context.serviceName = args.serviceName + context.image = args.image + context.containerPort = (args.containerPort ?: 3000).toString() + + // Tags + context.ownerTag = args.ownerTag + context.productTag = args.productTag + context.componentTag = args.componentTag ?: args.serviceName + context.environment = args.environment ?: 'dev' + context.build = args.build ?: gitUtils.getShortSHA() + + // Container config + if (args.cpu) context.cpu = args.cpu.toString() + if (args.memory) context.memory = args.memory.toString() + if (args.desiredCount) context.desiredCount = args.desiredCount.toString() + + // Environment variables (as JSON) + if (args.environment_vars) { + context.environment_vars = groovy.json.JsonOutput.toJson(args.environment_vars) + } + + // Secrets (as JSON) + if (args.secrets) { + context.secrets = groovy.json.JsonOutput.toJson(args.secrets) + } + + // Capacity provider strategy (as JSON) + if (args.capacityProviderStrategy) { + context.capacityProviderStrategy = groovy.json.JsonOutput.toJson(args.capacityProviderStrategy) + } + + // Scaling + if (args.minCapacity) context.minCapacity = args.minCapacity.toString() + if (args.maxCapacity) context.maxCapacity = args.maxCapacity.toString() + if (args.targetCpuUtilization) context.targetCpuUtilization = args.targetCpuUtilization.toString() + if (args.targetMemoryUtilization) context.targetMemoryUtilization = args.targetMemoryUtilization.toString() + if (args.targetRequestsPerTarget) context.targetRequestsPerTarget = args.targetRequestsPerTarget.toString() + + // Health check + if (args.healthCheckPath) context.healthCheckPath = args.healthCheckPath + + // Routing - ALB details (always resolved by resolveAlbDetails() before calling buildServiceContext) + // These are internal-only values, never input parameters + context.albLoadBalancerArn = args.albLoadBalancerArn + context.albHttpsListenerArn = args.albHttpsListenerArn + context.albHttpListenerArn = args.albHttpListenerArn + if (args.albScheme) context.albScheme = args.albScheme // "internet-facing" or "internal" + if (args.useClusterAlb != null) context.useClusterAlb = args.useClusterAlb.toString() + + // Blue/Green DNS (for bg-common ALB) - hostnames are optional + // Support simple hostName parameter (like old HostName) - auto-generates active/inactive + if (args.hostName && !args.activeHostname && !args.inactiveHostname) { + context.activeHostname = args.hostName + context.inactiveHostname = "inactive-${args.hostName}" + } else { + if (args.activeHostname) context.activeHostname = args.activeHostname + if (args.inactiveHostname) context.inactiveHostname = args.inactiveHostname + } + if (args.bgHostedZoneId) context.bgHostedZoneId = args.bgHostedZoneId + if (args.isActive != null) context.isActive = args.isActive.toString() + + // Routing - Cluster ALB (existing behavior) + if (args.hostHeader) context.hostHeader = args.hostHeader + if (args.pathPatterns) context.pathPatterns = args.pathPatterns + if (args.priority) context.priority = args.priority.toString() + if (args.useExternalALB != null) context.useExternalALB = args.useExternalALB.toString() + if (args.useInternalALB != null) context.useInternalALB = args.useInternalALB.toString() + if (args.stickiness != null) context.stickiness = args.stickiness.toString() + if (args.stickinessDuration) context.stickinessDuration = args.stickinessDuration.toString() + if (args.deregistrationDelay) context.deregistrationDelay = args.deregistrationDelay.toString() + + // ALB listeners (for cluster ALB mode) + if (args.externalListenerArn) context.externalListenerArn = args.externalListenerArn + if (args.internalListenerArn) context.internalListenerArn = args.internalListenerArn + + // DNS + if (args.hostedZoneId) context.hostedZoneId = args.hostedZoneId + if (args.zoneName) context.zoneName = args.zoneName + if (args.recordName) context.recordName = args.recordName + + // Deployment + if (args.circuitBreaker != null) context.circuitBreaker = args.circuitBreaker.toString() + if (args.enableExecuteCommand != null) context.enableExecuteCommand = args.enableExecuteCommand.toString() + + return context +} + +/** + * Deploy ALB stack (1:1 with service stack) + * ALB stack name is {serviceStackName}-alb (shared between blue/green) + * The ALB stack name is derived from the service stackName base (without -blue/-green suffix) + */ +def deployAlbStack(Map args) { + // ALB stack name is based on base service name (without color suffix) + // This ensures blue and green services share the same ALB + // Example: stackName="my-service-dev-blue" -> albStackName="my-service-dev-alb" + def baseStackName = args.stackName.replaceAll(/-blue$|-green$/, '') + def albStackName = "${baseStackName}-alb" + def account = awsUtils.buildAccountConfig(args) + + // Service stack will derive albStackName from stackName and import ALB details from exports + // No need to store it in args + + // Check if ALB stack already exists + def albExists = awsUtils.stackExists( + account: account, + stackName: albStackName + ) + + if (!albExists) { + echo "ALB stack does not exist, creating: ${albStackName}" + + // Build ALB context + def albContext = buildAlbContext(args) + + // Show diff first + if (args.showDiff != false) { + echo "Showing ALB stack diff..." + cdkUtils.diff( + account: account, + stackName: albStackName, + stackType: "alb", + context: albContext + ) + } + + // Deploy ALB stack + echo "Deploying ALB stack: ${albStackName}" + cdkUtils.deploy( + account: account, + stackName: albStackName, + stackType: "alb", + context: albContext + ) + } else { + echo "ALB stack already exists: ${albStackName}" + } + + // Get ALB outputs for service stack (always fetch, even if stack existed) + def albOutputs = awsUtils.getStackOutputs( + account: account, + stackName: albStackName + ) + + if (!albOutputs || albOutputs.isEmpty()) { + error("Failed to get ALB stack outputs from ${albStackName}") + } + + // Service stack will import ALB details from ALB stack exports + // No need to store outputs in args - service stack derives albStackName and imports directly + echo "ALB stack ready: ${albStackName}" + echo "ALB DNS: ${albOutputs.LoadBalancerDNS ?: 'N/A'}" +} + +/** + * Build ALB context from pipeline arguments + */ +def buildAlbContext(Map args) { + def context = [:] + + // Required: Cluster stack name (all VPC details imported from VPC stack via cluster exports) + context.clusterStackName = args.clusterStackName ?: args.clusterName + if (!context.clusterStackName) { + error("clusterStackName or clusterName is required for ALB stack") + } + if (!args.numberOfAzs) { + error("numberOfAzs is required (2-4) for ALB stack and must match the VPC/cluster.") + } + context.numberOfAzs = args.numberOfAzs.toString() + + // ALB scheme + context.scheme = args.albScheme ?: "internal" + + // Optional + if (args.certificateArn) context.certificateArn = args.certificateArn + if (args.albIdleTimeout) context.idleTimeout = args.albIdleTimeout.toString() + if (args.clusterLogsBucketName) context.logsBucketName = args.clusterLogsBucketName + if (args.albLogsPrefix) context.logsPrefix = args.albLogsPrefix + if (args.redirectHttpToHttps != null) context.redirectHttpToHttps = args.redirectHttpToHttps.toString() + + // Blue/Green DNS (if provided) - hostnames are optional + // Support simple hostName parameter (like old HostName) - auto-generates active/inactive + if (args.hostName && !args.activeHostname && !args.inactiveHostname) { + context.activeHostname = args.hostName + context.inactiveHostname = "inactive-${args.hostName}" + } else { + if (args.activeHostname) context.activeHostname = args.activeHostname + if (args.inactiveHostname) context.inactiveHostname = args.inactiveHostname + } + if (args.bgHostedZoneId) context.bgHostedZoneId = args.bgHostedZoneId + + // Tags + context.ownerTag = args.ownerTag + context.productTag = args.productTag + context.componentTag = args.componentTag + context.environmentTag = args.environment ?: "dev" + if (args.buildTag) context.buildTag = args.buildTag + + return context +} + +return this diff --git a/vars/spicyRollback.groovy b/vars/spicyRollback.groovy new file mode 100644 index 0000000..6c755ad --- /dev/null +++ b/vars/spicyRollback.groovy @@ -0,0 +1,306 @@ +/** + * Spicy Rollback Pipeline + * + * Quickly rollback a blue/green deployment by swapping hostnames. + * No new deployment needed - just swaps ALB routing rules. + * + * Usage in Jenkinsfile: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * spicyRollback( + * jenkinsAwsCredentialsId: "aws-credentials", + * region: "ca-central-1", + * stackName: "my-service-dev", + * serviceName: "my-service", + * clusterName: "my-ecs-cluster-dev", + * vpcId: "vpc-12345678", + * vpcCidrBlock: "10.0.0.0/16", + * availabilityZones: "ca-central-1a,ca-central-1b,ca-central-1c", + * privateSubnetIds: "subnet-aaa,subnet-bbb,subnet-ccc", + * activeHostname: "api.example.com", + * inactiveHostname: "inactive-api.example.com", + * priority: 100, + * useExternalALB: true, + * externalListenerArn: "arn:aws:elasticloadbalancing:...", + * ownerTag: "MyTeam", + * productTag: "my-product", + * componentTag: "api", + * environment: "dev", + * ) + * ``` + */ + +def call(Map args) { + args = spicyDefaults(args) + + timeout(time: 30, unit: "MINUTES") { + timestamps { + node("docker") { + properties(args.pipelineProperties) + ansiColor("xterm") { + + stage("Checkout") { + checkout scm + giteaUtils.setSuccess("checkout") + } + + stage("Setup CDK") { + cdkUtils.install() + } + + def account = [ + region: args.region, + jenkinsAwsCredentialsId: args.jenkinsAwsCredentialsId, + accountId: args.accountId ?: '' + ] + + stage("Determine Current State") { + def currentActive = getActiveColor(args) + def targetColor = currentActive == 'blue' ? 'green' : 'blue' + + echo """ +╔════════════════════════════════════════════════════════════════╗ +║ ROLLBACK CONFIRMATION ║ +╠════════════════════════════════════════════════════════════════╣ +║ Service: ${args.serviceName.padRight(42)}║ +║ Current Active: ${currentActive.toUpperCase().padRight(42)}║ +║ Rolling Back To: ${targetColor.toUpperCase().padRight(41)}║ +║ ║ +║ Active Hostname: ${(args.activeHostname ?: args.hostHeader).padRight(38)}║ +║ Inactive Hostname: ${(args.inactiveHostname ?: 'inactive-' + args.hostHeader).padRight(38)}║ +╚════════════════════════════════════════════════════════════════╝ +""" + + env.CURRENT_ACTIVE = currentActive + env.TARGET_COLOR = targetColor + } + + stage("Confirm Rollback") { + manualApproval( + message: "Rollback ${args.serviceName} from ${env.CURRENT_ACTIVE} to ${env.TARGET_COLOR}?", + submitter: args.approvers ?: '' + ) + } + + stage("Execute Rollback") { + def buildInfo = [ + currentActive: env.CURRENT_ACTIVE, + targetColor: env.TARGET_COLOR, + activeHostname: args.activeHostname ?: args.hostHeader, + inactiveHostname: args.inactiveHostname ?: "inactive-${args.hostHeader}" + ] + + echo "Executing rollback: ${env.CURRENT_ACTIVE} -> ${env.TARGET_COLOR}" + + // Swap hostnames (same logic as deployment, but reversed) + swapHostnames(args, buildInfo, account) + + // Update SSM parameter + saveActiveColor(args, env.TARGET_COLOR) + + echo """ +╔════════════════════════════════════════════════════════════════╗ +║ ROLLBACK COMPLETE ║ +╠════════════════════════════════════════════════════════════════╣ +║ ${env.TARGET_COLOR.toUpperCase()} is now ACTIVE ║ +║ ${env.CURRENT_ACTIVE.toUpperCase()} is now INACTIVE ║ +╚════════════════════════════════════════════════════════════════╝ +""" + + giteaUtils.setSuccess("rollback") + } + + // Smoke test after rollback + if (args.smokeTest) { + spicyUtils.stageWithFailure("Smoke Test") { + def buildInfo = [ + activeHostname: args.activeHostname ?: args.hostHeader, + healthCheckPath: args.healthCheckPath ?: '/health' + ] + args.smokeTest.call(args, buildInfo) + giteaUtils.setSuccess("smoke-test") + } + } + + giteaUtils.setSuccess("pipeline") + } + } + } + } +} + +/** + * Swap hostnames between blue and green services + */ +def swapHostnames(Map args, Map buildInfo, Map account) { + def activeColor = buildInfo.targetColor + def inactiveColor = buildInfo.currentActive + + def activeStackName = "${args.stackName}-${activeColor}" + def inactiveStackName = "${args.stackName}-${inactiveColor}" + + echo "Swapping hostnames: ${activeColor} -> active, ${inactiveColor} -> inactive" + + // Update new active service to use active hostname with lower priority + def activeContext = buildServiceContext(args) + activeContext.serviceColor = activeColor + activeContext.hostHeader = buildInfo.activeHostname + activeContext.isActive = 'true' + activeContext.priority = (args.priority ?: 100).toString() + + cdkUtils.deploy( + account: account, + stackName: activeStackName, + stackType: "ecs-service", + context: activeContext + ) + + // Update old active service to use inactive hostname with higher priority number + if (stackExists(inactiveStackName, account)) { + def inactiveContext = buildServiceContext(args) + inactiveContext.serviceColor = inactiveColor + inactiveContext.hostHeader = buildInfo.inactiveHostname + inactiveContext.isActive = 'false' + inactiveContext.priority = ((args.priority ?: 100) + 100).toString() + + cdkUtils.deploy( + account: account, + stackName: inactiveStackName, + stackType: "ecs-service", + context: inactiveContext + ) + } + + echo "Hostname swap complete! ${activeColor} is now active." +} + +/** + * Get the currently active color from SSM Parameter Store + */ +def getActiveColor(Map args) { + def paramName = "/spicy/${args.serviceName}/active-color" + + try { + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: args.jenkinsAwsCredentialsId, + accessKeyVariable: 'AWS_ACCESS_KEY_ID', + secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) { + def result = sh( + script: "aws ssm get-parameter --name '${paramName}' --region ${args.region} --query 'Parameter.Value' --output text 2>/dev/null || echo 'blue'", + returnStdout: true + ).trim() + return result ?: 'blue' + } + } catch (err) { + echo "Could not get active color, defaulting to blue: ${err.message}" + return 'blue' + } +} + +/** + * Save the active color to SSM Parameter Store + */ +def saveActiveColor(Map args, String color) { + def paramName = "/spicy/${args.serviceName}/active-color" + + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: args.jenkinsAwsCredentialsId, + accessKeyVariable: 'AWS_ACCESS_KEY_ID', + secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) { + sh """ + aws ssm put-parameter \ + --name '${paramName}' \ + --value '${color}' \ + --type String \ + --overwrite \ + --region ${args.region} + """ + } +} + +/** + * Check if a CloudFormation stack exists + */ +def stackExists(String stackName, Map account) { + try { + withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', + credentialsId: account.jenkinsAwsCredentialsId, + accessKeyVariable: 'AWS_ACCESS_KEY_ID', + secretKeyVariable: 'AWS_SECRET_ACCESS_KEY']]) { + def result = sh( + script: "aws cloudformation describe-stacks --stack-name '${stackName}' --region ${account.region} 2>/dev/null", + returnStatus: true + ) + return result == 0 + } + } catch (err) { + return false + } +} + +/** + * Build CDK context map from pipeline arguments + */ +def buildServiceContext(Map args) { + def context = [:] + + // Required: Cluster and VPC + context.clusterName = args.clusterName + context.vpcId = args.vpcId + if (args.vpcCidrBlock) context.vpcCidrBlock = args.vpcCidrBlock + context.availabilityZones = args.availabilityZones + context.privateSubnetIds = args.privateSubnetIds + + // Required: Service config + context.serviceName = args.serviceName + context.image = args.image ?: 'placeholder:latest' // Image doesn't change during rollback + context.containerPort = (args.containerPort ?: 3000).toString() + + // Tags + context.ownerTag = args.ownerTag + context.productTag = args.productTag + context.componentTag = args.componentTag ?: args.serviceName + context.environment = args.environment ?: 'dev' + + // Container config + if (args.cpu) context.cpu = args.cpu.toString() + if (args.memory) context.memory = args.memory.toString() + if (args.desiredCount) context.desiredCount = args.desiredCount.toString() + + // Capacity provider strategy (as JSON) + if (args.capacityProviderStrategy) { + context.capacityProviderStrategy = groovy.json.JsonOutput.toJson(args.capacityProviderStrategy) + } + + // Scaling + if (args.minCapacity) context.minCapacity = args.minCapacity.toString() + if (args.maxCapacity) context.maxCapacity = args.maxCapacity.toString() + + // Health check + if (args.healthCheckPath) context.healthCheckPath = args.healthCheckPath + + // Routing + if (args.pathPatterns) context.pathPatterns = args.pathPatterns + if (args.useExternalALB != null) context.useExternalALB = args.useExternalALB.toString() + if (args.useInternalALB != null) context.useInternalALB = args.useInternalALB.toString() + if (args.stickiness != null) context.stickiness = args.stickiness.toString() + + // ALB listeners + if (args.externalListenerArn) context.externalListenerArn = args.externalListenerArn + if (args.internalListenerArn) context.internalListenerArn = args.internalListenerArn + + // DNS + if (args.hostedZoneId) context.hostedZoneId = args.hostedZoneId + if (args.zoneName) context.zoneName = args.zoneName + if (args.recordName) context.recordName = args.recordName + + // Deployment + if (args.circuitBreaker != null) context.circuitBreaker = args.circuitBreaker.toString() + if (args.enableExecuteCommand != null) context.enableExecuteCommand = args.enableExecuteCommand.toString() + + return context +} + +return this + diff --git a/vars/spicyUtils.groovy b/vars/spicyUtils.groovy new file mode 100644 index 0000000..22bf780 --- /dev/null +++ b/vars/spicyUtils.groovy @@ -0,0 +1,45 @@ +/** + * Utility functions for Spicy CDK pipelines + */ + +def setPipelineProperties(baseProperties, propertiesToRemove) { + def props = [] + if (baseProperties) { + props.addAll(baseProperties) + } + if (propertiesToRemove) { + props.removeAll(propertiesToRemove) + } + echo("Setting pipeline properties to ${props}") + properties(props) +} + +def stageWithFailure(stageName, Map args = [:], Closure body) { + customStage([ + stageName: stageName, + failOnError: true + ] + args, body) +} + +def stageWithWarning(stageName, Map args = [:], Closure body) { + customStage([ + stageName: stageName, + failOnError: false + ] + args, body) +} + +def customStage(Map args, Closure body) { + stage(args.stageName) { + try { + body() + } catch (err) { + if (args.failOnError) { + throw err + } + println(err) + } + } +} + +return this + diff --git a/vars/spicyVPC.groovy b/vars/spicyVPC.groovy new file mode 100644 index 0000000..00b5908 --- /dev/null +++ b/vars/spicyVPC.groovy @@ -0,0 +1,189 @@ +/** + * Spicy VPC Pipeline + * + * Deploys a VPC using AWS CDK. + * + * Usage in Jenkinsfile: + * ```groovy + * @Library(["spicy-automation@main"]) _ + * + * spicyVPC( + * jenkinsAwsCredentialsId: "aws-credentials", + * region: "ca-central-1", + * stackName: "spicy-vpc", + * ownerTag: "SpicyTeam", + * productTag: "spicy", + * componentTag: "spicy-VPC", + * availabilityZones: "ca-central-1a,ca-central-1b,ca-central-1c,ca-central-1d", + * createPrivateSubnets: true, + * createAdditionalPrivateSubnets: true, + * ) + * ``` + */ + +def call(Map args) { + args = spicyDefaults(args) + + timeout(time: 1, unit: "DAYS") { + timestamps { + node("docker") { + properties(args.pipelineProperties) + ansiColor("xterm") { + + stage("Checkout") { + checkout scm + giteaUtils.setSuccess("checkout") + } + + stage("Setup") { + cdkUtils.install() + giteaUtils.setSuccess("setup") + } + + if (args.onPreDeploy) { + spicyUtils.stageWithFailure("PreDeploy") { + args.onPreDeploy.call(args, [:]) + } + } + + stage("Deploy VPC") { + if (gitUtils.isMain()) { + try { + // Build context from args + def context = buildVpcContext(args) + + // Create account object for cdkUtils + def account = awsUtils.buildAccountConfig(args) + + // Show diff first (optional, for visibility) + if (args.showDiff != false) { + echo "Showing CDK diff..." + cdkUtils.diff( + account: account, + stackName: args.stackName, + stackType: "vpc", + context: context + ) + } + + // Deploy the stack + echo "Deploying VPC stack: ${args.stackName}" + cdkUtils.deploy( + account: account, + stackName: args.stackName, + stackType: "vpc", + context: context + ) + + giteaUtils.setSuccess("deploy") + + if (args.onPostDeploy) { + spicyUtils.stageWithFailure("PostDeploy") { + args.onPostDeploy.call(args, [ + stackName: args.stackName + ]) + } + } + + } catch (err) { + giteaUtils.setFailed("deploy") + throw err + } + } else { + echo "Skipping deployment - not on main branch" + + // Still show diff on non-main branches for PR review + if (args.showDiffOnPR != false) { + def context = buildVpcContext(args) + def account = [ + region: args.region, + jenkinsAwsCredentialsId: args.jenkinsAwsCredentialsId, + accountId: args.accountId ?: '' + ] + + echo "Showing CDK diff for PR review..." + try { + cdkUtils.diff( + account: account, + stackName: args.stackName, + stackType: "vpc", + context: context + ) + } catch (err) { + echo "Diff failed (stack may not exist yet): ${err.message}" + } + } + } + } + + giteaUtils.setSuccess("pipeline") + } + } + } + } +} + +/** + * Build CDK context map from pipeline arguments + */ +def buildVpcContext(Map args) { + def context = [:] + + // Required tags + context.ownerTag = args.ownerTag + context.productTag = args.productTag + context.componentTag = args.componentTag + + // Optional build tag + context.build = args.build ?: gitUtils.getShortSHA() + + // VPC configuration + if (args.vpcCidr) context.vpcCidr = args.vpcCidr + if (args.vpcTenancy) context.vpcTenancy = args.vpcTenancy + + // Availability zones + if (args.availabilityZones) { + context.availabilityZones = args.availabilityZones + // Calculate numberOfAzs from the AZ list if not explicitly provided + if (!args.numberOfAzs) { + context.numberOfAzs = args.availabilityZones.split(",").length.toString() + } + } + if (args.numberOfAzs) context.numberOfAzs = args.numberOfAzs.toString() + + // Subnet creation flags + if (args.createPrivateSubnets != null) { + context.createPrivateSubnets = args.createPrivateSubnets.toString() + } + if (args.createAdditionalPrivateSubnets != null) { + context.createAdditionalPrivateSubnets = args.createAdditionalPrivateSubnets.toString() + } + + // Subnet tags + if (args.publicSubnetTag) context.publicSubnetTag = args.publicSubnetTag + if (args.privateSubnetATag) context.privateSubnetATag = args.privateSubnetATag + if (args.privateSubnetBTag) context.privateSubnetBTag = args.privateSubnetBTag + + // Public subnet CIDRs + if (args.publicSubnetACidr) context.publicSubnetACidr = args.publicSubnetACidr + if (args.publicSubnetBCidr) context.publicSubnetBCidr = args.publicSubnetBCidr + if (args.publicSubnetCCidr) context.publicSubnetCCidr = args.publicSubnetCCidr + if (args.publicSubnetDCidr) context.publicSubnetDCidr = args.publicSubnetDCidr + + // Private subnet 1 CIDRs + if (args.privateSubnetA1Cidr) context.privateSubnetA1Cidr = args.privateSubnetA1Cidr + if (args.privateSubnetB1Cidr) context.privateSubnetB1Cidr = args.privateSubnetB1Cidr + if (args.privateSubnetC1Cidr) context.privateSubnetC1Cidr = args.privateSubnetC1Cidr + if (args.privateSubnetD1Cidr) context.privateSubnetD1Cidr = args.privateSubnetD1Cidr + + // Private subnet 2 CIDRs (with NACLs) + if (args.privateSubnetA2Cidr) context.privateSubnetA2Cidr = args.privateSubnetA2Cidr + if (args.privateSubnetB2Cidr) context.privateSubnetB2Cidr = args.privateSubnetB2Cidr + if (args.privateSubnetC2Cidr) context.privateSubnetC2Cidr = args.privateSubnetC2Cidr + if (args.privateSubnetD2Cidr) context.privateSubnetD2Cidr = args.privateSubnetD2Cidr + + return context +} + +return this +