mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60c81e20d0 |
@@ -0,0 +1,235 @@
|
||||
# AWS deployment - Stirling-PDF clustered
|
||||
|
||||
Three paths, ordered by ease-of-use:
|
||||
|
||||
| Path | Best for | Time-to-live | Monthly cost (us-east-1) | Scales? |
|
||||
|---|---|---|---|---|
|
||||
| **1. CloudFormation one-click** | Enterprise self-host, no Kubernetes | ~15 min | ~$120-160 | Yes (ECS autoscaling) |
|
||||
| **2. Terraform module** | Your SaaS, env-promotable, GitOps | ~20 min first time | ~$120-160 | Yes (ECS autoscaling) |
|
||||
| **3. EC2 + Docker Compose** | ≤25 concurrent users, single VM, dead simple | ~5 min | ~$25-40 | No (one VM) |
|
||||
|
||||
Already have a Kubernetes cluster (EKS)? Use the existing
|
||||
[`deploy/helm/stirling-pdf/`](../helm/stirling-pdf/) chart instead of any of
|
||||
these.
|
||||
|
||||
## What each deploys
|
||||
|
||||
All three deliver the same logical topology: **N Stirling app instances behind
|
||||
a load balancer, sharing a Valkey backplane and a Postgres database**, with
|
||||
`/internal/*` blocked at the LB.
|
||||
|
||||
```
|
||||
Internet
|
||||
│
|
||||
▼
|
||||
┌────────────────┐
|
||||
│ ALB / nginx │ ←── blocks /internal/*
|
||||
└────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐
|
||||
│ app-1 app-2 │ ←── CLUSTER_ENABLED=true
|
||||
└────────────────┘ CLUSTER_BACKPLANE=valkey
|
||||
│ │
|
||||
▼ ▼
|
||||
Valkey Postgres
|
||||
(ElastiCache) (RDS)
|
||||
```
|
||||
|
||||
### Sticky sessions are required
|
||||
|
||||
Every LB config in this repo (`ip_hash` in nginx, `lb_cookie` on the ALB,
|
||||
`affinity: cookie` on the k8s ingress) is pinning sessions deliberately, not as
|
||||
an optimisation. Result PDFs are written to the local disk of whichever node
|
||||
ran the job; without affinity a download has a ~50% chance of landing on a
|
||||
non-owner node and getting a 410 Gone. Cookie / IP affinity pins a returning
|
||||
client back to the owner pod. If you fork an LB config, keep the stickiness.
|
||||
|
||||
**Heads-up on `ip_hash` (nginx Docker Compose path only):** `ip_hash` collapses
|
||||
every client sharing a source IP onto the same backend. Behind a corporate VPN,
|
||||
CGNAT, or a single egress NAT this means all of those users hammer one app
|
||||
container while the others sit idle. The Compose path is fine for small
|
||||
deployments (the doc above caps it at 25 concurrent users on one VM), but for
|
||||
diverse client populations move to cookie-based affinity (nginx-plus or the
|
||||
openresty sticky-cookie module) or use one of the managed LB paths above
|
||||
- the ALB / k8s Ingress configs in this directory already use cookie stickiness
|
||||
for exactly this reason.
|
||||
|
||||
The only thing that changes between paths is **who manages Valkey and Postgres**:
|
||||
|
||||
| Path | App tasks | Valkey | Postgres | LB |
|
||||
|---|---|---|---|---|
|
||||
| CloudFormation | ECS Fargate | ElastiCache for Valkey | RDS PostgreSQL | ALB |
|
||||
| Terraform | ECS Fargate | ElastiCache for Valkey | RDS PostgreSQL | ALB |
|
||||
| EC2 Compose | Docker on one EC2 | Docker container | Docker container | nginx (Docker) |
|
||||
|
||||
## Path 1 - CloudFormation (recommended for enterprises)
|
||||
|
||||
The template no longer accepts plaintext passwords as parameters - that pattern leaked
|
||||
secrets into the rendered stack template, CloudTrail events, and the template S3 bucket
|
||||
even with `NoEcho`. Operators pre-create the password secrets and pass their ARNs:
|
||||
|
||||
```bash
|
||||
DB_SECRET_ARN=$(aws secretsmanager create-secret \
|
||||
--name stirling/db-password \
|
||||
--secret-string "$(openssl rand -base64 24)" \
|
||||
--query ARN --output text)
|
||||
|
||||
VALKEY_SECRET_ARN=$(aws secretsmanager create-secret \
|
||||
--name stirling/valkey-auth \
|
||||
--secret-string "$(openssl rand -hex 32)" \
|
||||
--query ARN --output text)
|
||||
|
||||
aws cloudformation deploy \
|
||||
--stack-name stirling-pdf \
|
||||
--template-file cloudformation/stirling-pdf-aws.yaml \
|
||||
--capabilities CAPABILITY_NAMED_IAM \
|
||||
--parameter-overrides \
|
||||
DbSecretArn=$DB_SECRET_ARN \
|
||||
ValkeyAuthSecretArn=$VALKEY_SECRET_ARN
|
||||
```
|
||||
|
||||
The X-Engine-Auth token is generated *inside* Secrets Manager by CloudFormation - the
|
||||
operator never sees or supplies it. After deploy it can be retrieved with
|
||||
`aws secretsmanager get-secret-value --secret-id <stack-name>-engine-secret`.
|
||||
|
||||
Or click-deploy via the AWS console: **Console → CloudFormation → Create stack
|
||||
→ Upload `cloudformation/stirling-pdf-aws.yaml` → fill the 2 ARN params**.
|
||||
|
||||
The stack output `AppUrl` gives you the URL.
|
||||
|
||||
**Knobs you can change in the parameters:**
|
||||
|
||||
| Param | Default | Notes |
|
||||
|---|---|---|
|
||||
| `AppCount` | 2 | Initial Fargate task count; autoscaling can grow to 10 |
|
||||
| `AppCpu` | 1024 (1 vCPU) | Per task |
|
||||
| `AppMemory` | 4096 MiB | Per task |
|
||||
| `EnableAiEngine` | false | Turn on if using AI features |
|
||||
| `AppImage` | `stirlingtools/stirling-pdf:2.11.0` | Pinned to release. Swap for your own ECR URI or newer tag |
|
||||
| `EngineImage` | `stirlingtools/stirling-pdf-ai-engine:2.11.0` | Same |
|
||||
| `DbSecretArn` | (required) | ARN of pre-created Secrets Manager secret holding the Postgres password |
|
||||
| `ValkeyAuthSecretArn` | (required) | ARN of pre-created Secrets Manager secret holding the Valkey AUTH token |
|
||||
|
||||
**Tear-down:** `aws cloudformation delete-stack --stack-name stirling-pdf`. RDS
|
||||
keeps a final snapshot for safety.
|
||||
|
||||
## Path 2 - Terraform (recommended for your SaaS)
|
||||
|
||||
```bash
|
||||
cd terraform
|
||||
cat > terraform.tfvars <<EOF
|
||||
name = "stirling-prod"
|
||||
region = "us-east-1"
|
||||
app_count = 3
|
||||
db_password = "$(openssl rand -base64 24)"
|
||||
engine_shared_secret = "$(openssl rand -hex 32)"
|
||||
valkey_auth_token = "$(openssl rand -hex 32)"
|
||||
EOF
|
||||
terraform init
|
||||
terraform apply
|
||||
```
|
||||
|
||||
The ElastiCache replication group runs with TLS in flight (`rediss://`) and AUTH enabled.
|
||||
`valkey_auth_token` is required - the application connects with `REDIS_PASSWORD` populated
|
||||
from Secrets Manager so a compromised pod cannot sweep the keyspace.
|
||||
|
||||
Tearing down: `terraform destroy`.
|
||||
|
||||
The Terraform module is intentionally a **single file** to keep it easy to
|
||||
fork. For real production you'd split into `modules/{vpc,ecs,rds,elasticache,alb}`
|
||||
and have a separate `envs/{dev,staging,prod}/main.tf` referencing them. Both
|
||||
shapes work; the single file is the starting point.
|
||||
|
||||
## Path 3 - EC2 + Docker Compose
|
||||
|
||||
See [`quickstart/ec2-compose.md`](quickstart/ec2-compose.md).
|
||||
|
||||
## EKS + Helm (for k8s shops)
|
||||
|
||||
The Phase 1 implementation already ships a Helm chart:
|
||||
|
||||
```bash
|
||||
# Assumes you already have an EKS cluster
|
||||
helm install stirling deploy/helm/stirling-pdf/ \
|
||||
--set cluster.engineSharedSecret=$(openssl rand -hex 32) \
|
||||
--set image.repository=<your-ecr-uri>/stirling-pdf
|
||||
```
|
||||
|
||||
The chart includes a Valkey StatefulSet by default. To use ElastiCache instead:
|
||||
|
||||
```bash
|
||||
helm install stirling deploy/helm/stirling-pdf/ \
|
||||
--set cluster.valkey.bundled=false \
|
||||
--set cluster.valkey.externalUrl=redis://your-elasticache:6379 \
|
||||
...
|
||||
```
|
||||
|
||||
## Required AWS permissions (for whoever runs the deploy)
|
||||
|
||||
Minimum set to apply the CloudFormation/Terraform:
|
||||
|
||||
- `ec2:*`, `elasticloadbalancing:*` (VPC + ALB)
|
||||
- `ecs:*`, `iam:CreateRole`, `iam:AttachRolePolicy`, `iam:PutRolePolicy`,
|
||||
`iam:PassRole`
|
||||
- `elasticache:*`
|
||||
- `rds:*`
|
||||
- `secretsmanager:*`
|
||||
- `logs:CreateLogGroup`, `logs:PutRetentionPolicy`
|
||||
- `application-autoscaling:*`
|
||||
- `cloudformation:*` (CFN only)
|
||||
|
||||
Easiest: run as a user with `PowerUserAccess` for the initial bootstrap, then
|
||||
narrow down with IAM Access Analyzer after the stack is up.
|
||||
|
||||
## Sizing rules of thumb
|
||||
|
||||
| Concurrent users | App tasks | App size | Valkey | Postgres |
|
||||
|---|---|---|---|---|
|
||||
| ≤25 | 2 | 1 vCPU / 4 GB | cache.t4g.small | db.t4g.micro |
|
||||
| 25-100 | 3 | 2 vCPU / 4 GB | cache.t4g.small | db.t4g.small |
|
||||
| 100-500 | 4-6 | 2 vCPU / 8 GB | cache.t4g.medium | db.t4g.medium |
|
||||
| 500+ | 6+ (autoscale) | 2 vCPU / 8 GB | cache.r7g.large + replica | db.t4g.large + read replica |
|
||||
|
||||
The defaults in the templates suit ≤25 users; bump `AppCount` /
|
||||
`InstanceClass` to grow.
|
||||
|
||||
## What you still need to set up yourself
|
||||
|
||||
These are not in the templates because they're customer-specific:
|
||||
|
||||
- **Custom domain + HTTPS.** ALB listener on port 443 with an ACM cert (5-min
|
||||
console wizard) + a Route 53 A-record alias to the ALB.
|
||||
- **Email (SES) for password reset / invitations.**
|
||||
- **OAuth/SAML identity provider.** Stirling supports Keycloak, Okta, Azure
|
||||
AD, Google - config goes in `settings.yml`.
|
||||
- **Backups for Valkey.** Optional - ElastiCache supports daily snapshots; turn
|
||||
on `SnapshotRetentionLimit` in the template if you want. Phase 1 state in
|
||||
Valkey is short-TTL so most operators skip it.
|
||||
- **Frontend CDN.** Not required; the app serves static assets fine. If you
|
||||
want CloudFront, point it at the ALB and cache `/static/*`.
|
||||
|
||||
## Common questions
|
||||
|
||||
**Why ECS Fargate, not EKS?** Fargate has zero cluster management - no
|
||||
control plane to maintain. EKS gets cheaper at scale but adds k8s ops. For
|
||||
most enterprises Fargate is the right default.
|
||||
|
||||
**Why ElastiCache for Valkey and not just Redis?** Valkey is the Linux
|
||||
Foundation's BSD-licensed Redis fork; AWS ElastiCache has supported it natively
|
||||
since 2024. Same wire protocol. Stirling's `LettuceConnectionFactory` doesn't
|
||||
care which one - pick whichever your security team is happier with.
|
||||
|
||||
**Why RDS Postgres and not Aurora?** Aurora costs ~3× more for the same TPS in
|
||||
this workload (mostly cold reads for user/team tables). Switch to Aurora if
|
||||
you need read replicas + DR; t4g.micro covers the small-tenant case.
|
||||
|
||||
**What if I want to use my company's existing Postgres / Redis?** Run only
|
||||
the ECS part of the CloudFormation by setting `SPRING_DATASOURCE_URL` and
|
||||
`CLUSTER_VALKEY_URL` in the task definition to point at your
|
||||
existing endpoints. The template doesn't currently expose those as parameters
|
||||
- easy fork.
|
||||
|
||||
**My ops team wants Pulumi / CDK / Crossplane.** All three speak the same
|
||||
underlying APIs the CloudFormation template uses. Translate from the YAML -
|
||||
the resource graph is identical.
|
||||
@@ -0,0 +1,507 @@
|
||||
AWSTemplateFormatVersion: "2010-09-09"
|
||||
Description: >
|
||||
Stirling-PDF clustered deployment on AWS. Provisions an ECS Fargate service
|
||||
(2+ app tasks) + ElastiCache for Valkey + RDS PostgreSQL + ALB. Single
|
||||
CloudFormation stack - click Launch Stack and fill in 4 fields.
|
||||
|
||||
Cost estimate (us-east-1, default sizing): ~$120-160 / month.
|
||||
Tear-down: delete the stack. Everything except the RDS final snapshot goes.
|
||||
|
||||
Parameters:
|
||||
AppImage:
|
||||
Type: String
|
||||
Default: stirlingtools/stirling-pdf:2.11.0
|
||||
Description: Container image with the Phase 1 cluster code. Use your ECR URI for private builds. Pin to an exact version - never :latest in production.
|
||||
|
||||
EngineImage:
|
||||
Type: String
|
||||
Default: stirlingtools/stirling-pdf-ai-engine:2.11.0
|
||||
Description: AI engine image. Leave default if you do not use AI features. Pin to an exact version - never :latest in production.
|
||||
|
||||
DbSecretArn:
|
||||
Type: String
|
||||
AllowedPattern: "^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:secret:.+$"
|
||||
Description: >
|
||||
ARN of a Secrets Manager secret holding the Postgres password as plain SecretString.
|
||||
Create: aws secretsmanager create-secret --name stirling/db-password --secret-string "$(openssl rand -base64 24)"
|
||||
Passing the ARN keeps plaintext out of the rendered template and CloudTrail.
|
||||
|
||||
ValkeyAuthSecretArn:
|
||||
Type: String
|
||||
AllowedPattern: "^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:secret:.+$"
|
||||
Description: >
|
||||
ARN of a Secrets Manager secret holding the ElastiCache AUTH token (16-128 chars).
|
||||
Create: aws secretsmanager create-secret --name stirling/valkey-auth --secret-string "$(openssl rand -hex 32)"
|
||||
Passing the ARN keeps plaintext out of the rendered template and CloudTrail.
|
||||
|
||||
AppCount:
|
||||
Type: Number
|
||||
Default: 2
|
||||
MinValue: 2
|
||||
MaxValue: 10
|
||||
Description: Number of Stirling-PDF app tasks. Multi-instance requires at least 2.
|
||||
|
||||
AppCpu:
|
||||
Type: Number
|
||||
Default: 1024
|
||||
AllowedValues: [512, 1024, 2048, 4096]
|
||||
Description: Fargate CPU units per app task (1024 = 1 vCPU).
|
||||
|
||||
AppMemory:
|
||||
Type: Number
|
||||
Default: 4096
|
||||
AllowedValues: [1024, 2048, 4096, 8192]
|
||||
Description: Fargate memory MiB per app task.
|
||||
|
||||
EnableAiEngine:
|
||||
Type: String
|
||||
Default: "false"
|
||||
AllowedValues: ["true", "false"]
|
||||
Description: Set true to also run the AI engine service.
|
||||
|
||||
Conditions:
|
||||
WithAiEngine: !Equals [!Ref EnableAiEngine, "true"]
|
||||
|
||||
Resources:
|
||||
|
||||
# ----- networking: use default VPC + its subnets to stay simple -----
|
||||
Vpc:
|
||||
Type: AWS::EC2::VPC
|
||||
Properties:
|
||||
CidrBlock: 10.42.0.0/16
|
||||
EnableDnsHostnames: true
|
||||
EnableDnsSupport: true
|
||||
Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-vpc"}]
|
||||
|
||||
Igw:
|
||||
Type: AWS::EC2::InternetGateway
|
||||
IgwAttach:
|
||||
Type: AWS::EC2::VPCGatewayAttachment
|
||||
Properties: {VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw}
|
||||
|
||||
SubnetA:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
AvailabilityZone: !Select [0, !GetAZs ""]
|
||||
CidrBlock: 10.42.1.0/24
|
||||
MapPublicIpOnLaunch: true
|
||||
SubnetB:
|
||||
Type: AWS::EC2::Subnet
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
AvailabilityZone: !Select [1, !GetAZs ""]
|
||||
CidrBlock: 10.42.2.0/24
|
||||
MapPublicIpOnLaunch: true
|
||||
|
||||
PublicRt:
|
||||
Type: AWS::EC2::RouteTable
|
||||
Properties: {VpcId: !Ref Vpc}
|
||||
PublicRoute:
|
||||
Type: AWS::EC2::Route
|
||||
DependsOn: IgwAttach
|
||||
Properties:
|
||||
RouteTableId: !Ref PublicRt
|
||||
DestinationCidrBlock: 0.0.0.0/0
|
||||
GatewayId: !Ref Igw
|
||||
RtAssocA:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties: {SubnetId: !Ref SubnetA, RouteTableId: !Ref PublicRt}
|
||||
RtAssocB:
|
||||
Type: AWS::EC2::SubnetRouteTableAssociation
|
||||
Properties: {SubnetId: !Ref SubnetB, RouteTableId: !Ref PublicRt}
|
||||
|
||||
# ----- security groups -----
|
||||
AlbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Public ALB
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0}
|
||||
- {IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0}
|
||||
|
||||
AppSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Stirling app tasks
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !Ref AlbSg}
|
||||
- {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !GetAtt InternalSelfSg.GroupId}
|
||||
|
||||
InternalSelfSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Allow app tasks to reach each other internally
|
||||
VpcId: !Ref Vpc
|
||||
|
||||
ValkeySg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: Valkey ElastiCache
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 6379, ToPort: 6379, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
DbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Properties:
|
||||
GroupDescription: PostgreSQL
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
EngineLbSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
GroupDescription: Internal ALB in front of the engine tier - only reachable from app tasks
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5001, ToPort: 5001, SourceSecurityGroupId: !Ref AppSg}
|
||||
|
||||
EngineSg:
|
||||
Type: AWS::EC2::SecurityGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
GroupDescription: AI engine - only reachable from the internal engine LB
|
||||
VpcId: !Ref Vpc
|
||||
SecurityGroupIngress:
|
||||
- {IpProtocol: tcp, FromPort: 5001, ToPort: 5001, SourceSecurityGroupId: !Ref EngineLbSg}
|
||||
|
||||
# ----- managed Valkey -----
|
||||
ValkeySubnetGroup:
|
||||
Type: AWS::ElastiCache::SubnetGroup
|
||||
Properties:
|
||||
Description: Stirling Valkey subnet group
|
||||
SubnetIds: [!Ref SubnetA, !Ref SubnetB]
|
||||
|
||||
Valkey:
|
||||
Type: AWS::ElastiCache::ReplicationGroup
|
||||
Properties:
|
||||
ReplicationGroupDescription: Stirling Valkey
|
||||
Engine: valkey
|
||||
EngineVersion: "8.0"
|
||||
CacheNodeType: cache.t4g.small
|
||||
NumCacheClusters: 1
|
||||
AutomaticFailoverEnabled: false
|
||||
CacheSubnetGroupName: !Ref ValkeySubnetGroup
|
||||
SecurityGroupIds: [!Ref ValkeySg]
|
||||
AtRestEncryptionEnabled: true
|
||||
# TLS in flight required when AuthToken is set (ElastiCache enforces this).
|
||||
TransitEncryptionEnabled: true
|
||||
AuthToken: !Sub "{{resolve:secretsmanager:${ValkeyAuthSecretArn}}}"
|
||||
|
||||
# ----- managed Postgres -----
|
||||
DbSubnetGroup:
|
||||
Type: AWS::RDS::DBSubnetGroup
|
||||
Properties:
|
||||
DBSubnetGroupDescription: Stirling Postgres subnet group
|
||||
SubnetIds: [!Ref SubnetA, !Ref SubnetB]
|
||||
|
||||
Postgres:
|
||||
Type: AWS::RDS::DBInstance
|
||||
DeletionPolicy: Snapshot
|
||||
UpdateReplacePolicy: Snapshot
|
||||
Properties:
|
||||
DBInstanceIdentifier: !Sub "${AWS::StackName}-pg"
|
||||
AllocatedStorage: 20
|
||||
DBInstanceClass: db.t4g.micro
|
||||
Engine: postgres
|
||||
EngineVersion: "17.2"
|
||||
MasterUsername: stirling
|
||||
MasterUserPassword: !Sub "{{resolve:secretsmanager:${DbSecretArn}}}"
|
||||
DBName: stirling
|
||||
DBSubnetGroupName: !Ref DbSubnetGroup
|
||||
VPCSecurityGroups: [!Ref DbSg]
|
||||
StorageEncrypted: true
|
||||
PubliclyAccessible: false
|
||||
MultiAZ: false
|
||||
BackupRetentionPeriod: 7
|
||||
|
||||
# ----- secrets -----
|
||||
EngineSharedSecret:
|
||||
Type: AWS::SecretsManager::Secret
|
||||
Properties:
|
||||
Name: !Sub "${AWS::StackName}-engine-secret"
|
||||
Description: X-Engine-Auth shared secret between app and AI engine. Generated by CFN, never passed in.
|
||||
GenerateSecretString:
|
||||
PasswordLength: 64
|
||||
ExcludePunctuation: true
|
||||
|
||||
|
||||
# ----- ECS cluster + roles -----
|
||||
EcsCluster:
|
||||
Type: AWS::ECS::Cluster
|
||||
Properties:
|
||||
ClusterName: !Sub "${AWS::StackName}-cluster"
|
||||
ClusterSettings:
|
||||
- {Name: containerInsights, Value: enabled}
|
||||
|
||||
TaskExecutionRole:
|
||||
Type: AWS::IAM::Role
|
||||
Properties:
|
||||
AssumeRolePolicyDocument:
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Principal: {Service: ecs-tasks.amazonaws.com}
|
||||
Action: sts:AssumeRole
|
||||
ManagedPolicyArns:
|
||||
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
|
||||
Policies:
|
||||
- PolicyName: ReadSecrets
|
||||
PolicyDocument:
|
||||
Statement:
|
||||
- Effect: Allow
|
||||
Action: [secretsmanager:GetSecretValue]
|
||||
Resource:
|
||||
- !Ref EngineSharedSecret
|
||||
- !Ref DbSecretArn
|
||||
- !Ref ValkeyAuthSecretArn
|
||||
|
||||
TaskLogGroup:
|
||||
Type: AWS::Logs::LogGroup
|
||||
Properties:
|
||||
LogGroupName: !Sub "/ecs/${AWS::StackName}"
|
||||
RetentionInDays: 14
|
||||
|
||||
# ----- ALB -----
|
||||
Alb:
|
||||
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
|
||||
Properties:
|
||||
Scheme: internet-facing
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref AlbSg]
|
||||
Type: application
|
||||
|
||||
AppTg:
|
||||
Type: AWS::ElasticLoadBalancingV2::TargetGroup
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
Port: 8080
|
||||
Protocol: HTTP
|
||||
TargetType: ip
|
||||
HealthCheckPath: /api/v1/info/status
|
||||
HealthCheckIntervalSeconds: 30
|
||||
HealthCheckTimeoutSeconds: 10
|
||||
HealthyThresholdCount: 2
|
||||
UnhealthyThresholdCount: 5
|
||||
Matcher: {HttpCode: "200"}
|
||||
# Sticky sessions required - see deploy/aws/README.md.
|
||||
TargetGroupAttributes:
|
||||
- Key: stickiness.enabled
|
||||
Value: 'true'
|
||||
- Key: stickiness.type
|
||||
Value: lb_cookie
|
||||
- Key: stickiness.lb_cookie.duration_seconds
|
||||
Value: '86400'
|
||||
|
||||
AlbListener:
|
||||
Type: AWS::ElasticLoadBalancingV2::Listener
|
||||
Properties:
|
||||
LoadBalancerArn: !Ref Alb
|
||||
Port: 80
|
||||
Protocol: HTTP
|
||||
DefaultActions:
|
||||
- Type: forward
|
||||
TargetGroupArn: !Ref AppTg
|
||||
|
||||
AlbBlockInternalRule:
|
||||
Type: AWS::ElasticLoadBalancingV2::ListenerRule
|
||||
Properties:
|
||||
ListenerArn: !Ref AlbListener
|
||||
Priority: 1
|
||||
Conditions:
|
||||
- Field: path-pattern
|
||||
Values: ["/internal/*"]
|
||||
Actions:
|
||||
- Type: fixed-response
|
||||
FixedResponseConfig:
|
||||
StatusCode: "404"
|
||||
ContentType: text/plain
|
||||
MessageBody: "Not Found"
|
||||
|
||||
# ----- ECS task definition for the Stirling app -----
|
||||
AppTaskDef:
|
||||
Type: AWS::ECS::TaskDefinition
|
||||
Properties:
|
||||
Family: !Sub "${AWS::StackName}-app"
|
||||
Cpu: !Ref AppCpu
|
||||
Memory: !Ref AppMemory
|
||||
NetworkMode: awsvpc
|
||||
RequiresCompatibilities: [FARGATE]
|
||||
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
|
||||
ContainerDefinitions:
|
||||
- Name: stirling
|
||||
Image: !Ref AppImage
|
||||
Essential: true
|
||||
PortMappings:
|
||||
- ContainerPort: 8080
|
||||
Protocol: tcp
|
||||
LogConfiguration:
|
||||
LogDriver: awslogs
|
||||
Options:
|
||||
awslogs-group: !Ref TaskLogGroup
|
||||
awslogs-region: !Ref AWS::Region
|
||||
awslogs-stream-prefix: app
|
||||
Environment:
|
||||
- {Name: CLUSTER_ENABLED, Value: "true"}
|
||||
- {Name: CLUSTER_BACKPLANE, Value: valkey}
|
||||
# rediss:// = TLS. REDIS_PASSWORD injected separately via Secrets below.
|
||||
- {Name: CLUSTER_VALKEY_URL, Value: !Sub "rediss://${Valkey.PrimaryEndPoint.Address}:6379"}
|
||||
- {Name: SPRING_DATASOURCE_URL, Value: !Sub "jdbc:postgresql://${Postgres.Endpoint.Address}:5432/stirling"}
|
||||
- {Name: SPRING_DATASOURCE_USERNAME, Value: stirling}
|
||||
- {Name: DOCKER_ENABLE_SECURITY, Value: "true"}
|
||||
- {Name: SYSTEM_DEFAULTLOCALE, Value: en-US}
|
||||
- !If
|
||||
- WithAiEngine
|
||||
- {Name: AIENGINE_URL, Value: !Sub "http://${EngineAlb.DNSName}:5001"}
|
||||
- !Ref AWS::NoValue
|
||||
Secrets:
|
||||
- {Name: CLUSTER_ENGINE_SHAREDSECRET, ValueFrom: !Ref EngineSharedSecret}
|
||||
- {Name: SPRING_DATASOURCE_PASSWORD, ValueFrom: !Ref DbSecretArn}
|
||||
- {Name: REDIS_PASSWORD, ValueFrom: !Ref ValkeyAuthSecretArn}
|
||||
|
||||
AppService:
|
||||
Type: AWS::ECS::Service
|
||||
DependsOn: AlbListener
|
||||
Properties:
|
||||
ServiceName: !Sub "${AWS::StackName}-app"
|
||||
Cluster: !Ref EcsCluster
|
||||
DesiredCount: !Ref AppCount
|
||||
LaunchType: FARGATE
|
||||
TaskDefinition: !Ref AppTaskDef
|
||||
# Grace period covers Spring Boot warm-up + Valkey handshake (~60-90s total).
|
||||
HealthCheckGracePeriodSeconds: 120
|
||||
DeploymentConfiguration:
|
||||
MinimumHealthyPercent: 50
|
||||
MaximumPercent: 200
|
||||
NetworkConfiguration:
|
||||
AwsvpcConfiguration:
|
||||
AssignPublicIp: ENABLED
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref AppSg, !Ref InternalSelfSg]
|
||||
LoadBalancers:
|
||||
- ContainerName: stirling
|
||||
ContainerPort: 8080
|
||||
TargetGroupArn: !Ref AppTg
|
||||
|
||||
# ----- optional AI engine service -----
|
||||
EngineAlb:
|
||||
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
Scheme: internal
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref EngineLbSg]
|
||||
Type: application
|
||||
|
||||
EngineTg:
|
||||
Type: AWS::ElasticLoadBalancingV2::TargetGroup
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
VpcId: !Ref Vpc
|
||||
Port: 5001
|
||||
Protocol: HTTP
|
||||
TargetType: ip
|
||||
HealthCheckPath: /health
|
||||
HealthCheckIntervalSeconds: 30
|
||||
HealthCheckTimeoutSeconds: 10
|
||||
HealthyThresholdCount: 2
|
||||
UnhealthyThresholdCount: 5
|
||||
Matcher: {HttpCode: "200"}
|
||||
|
||||
EngineAlbListener:
|
||||
Type: AWS::ElasticLoadBalancingV2::Listener
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
LoadBalancerArn: !Ref EngineAlb
|
||||
Port: 5001
|
||||
Protocol: HTTP
|
||||
DefaultActions:
|
||||
- Type: forward
|
||||
TargetGroupArn: !Ref EngineTg
|
||||
|
||||
EngineTaskDef:
|
||||
Type: AWS::ECS::TaskDefinition
|
||||
Condition: WithAiEngine
|
||||
Properties:
|
||||
Family: !Sub "${AWS::StackName}-engine"
|
||||
Cpu: 1024
|
||||
Memory: 2048
|
||||
NetworkMode: awsvpc
|
||||
RequiresCompatibilities: [FARGATE]
|
||||
ExecutionRoleArn: !GetAtt TaskExecutionRole.Arn
|
||||
ContainerDefinitions:
|
||||
- Name: engine
|
||||
Image: !Ref EngineImage
|
||||
PortMappings: [{ContainerPort: 5001, Protocol: tcp}]
|
||||
LogConfiguration:
|
||||
LogDriver: awslogs
|
||||
Options:
|
||||
awslogs-group: !Ref TaskLogGroup
|
||||
awslogs-region: !Ref AWS::Region
|
||||
awslogs-stream-prefix: engine
|
||||
Secrets:
|
||||
- {Name: STIRLING_ENGINE_SHARED_SECRET, ValueFrom: !Ref EngineSharedSecret}
|
||||
|
||||
EngineService:
|
||||
Type: AWS::ECS::Service
|
||||
Condition: WithAiEngine
|
||||
DependsOn: EngineAlbListener
|
||||
Properties:
|
||||
ServiceName: !Sub "${AWS::StackName}-engine"
|
||||
Cluster: !Ref EcsCluster
|
||||
DesiredCount: 1
|
||||
LaunchType: FARGATE
|
||||
TaskDefinition: !Ref EngineTaskDef
|
||||
# Engine model-load warm-up can take 60-90s.
|
||||
HealthCheckGracePeriodSeconds: 120
|
||||
NetworkConfiguration:
|
||||
AwsvpcConfiguration:
|
||||
AssignPublicIp: ENABLED
|
||||
Subnets: [!Ref SubnetA, !Ref SubnetB]
|
||||
SecurityGroups: [!Ref EngineSg]
|
||||
LoadBalancers:
|
||||
- ContainerName: engine
|
||||
ContainerPort: 5001
|
||||
TargetGroupArn: !Ref EngineTg
|
||||
|
||||
# ----- HPA-equivalent: target tracking on CPU -----
|
||||
AppScalingTarget:
|
||||
Type: AWS::ApplicationAutoScaling::ScalableTarget
|
||||
Properties:
|
||||
MaxCapacity: 10
|
||||
MinCapacity: !Ref AppCount
|
||||
ResourceId: !Sub "service/${EcsCluster}/${AppService.Name}"
|
||||
ScalableDimension: ecs:service:DesiredCount
|
||||
ServiceNamespace: ecs
|
||||
RoleARN: !Sub "arn:aws:iam::${AWS::AccountId}:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService"
|
||||
|
||||
AppScalingPolicy:
|
||||
Type: AWS::ApplicationAutoScaling::ScalingPolicy
|
||||
Properties:
|
||||
PolicyName: !Sub "${AWS::StackName}-cpu-target"
|
||||
PolicyType: TargetTrackingScaling
|
||||
ScalingTargetId: !Ref AppScalingTarget
|
||||
TargetTrackingScalingPolicyConfiguration:
|
||||
TargetValue: 70
|
||||
PredefinedMetricSpecification:
|
||||
PredefinedMetricType: ECSServiceAverageCPUUtilization
|
||||
ScaleInCooldown: 60
|
||||
ScaleOutCooldown: 60
|
||||
|
||||
Outputs:
|
||||
AppUrl:
|
||||
Description: Open this URL - Stirling-PDF cluster front door
|
||||
Value: !Sub "http://${Alb.DNSName}/"
|
||||
ClusterName:
|
||||
Value: !Ref EcsCluster
|
||||
ValkeyEndpoint:
|
||||
Value: !GetAtt Valkey.PrimaryEndPoint.Address
|
||||
PostgresEndpoint:
|
||||
Value: !GetAtt Postgres.Endpoint.Address
|
||||
StackTeardown:
|
||||
Description: To delete everything (RDS keeps a final snapshot)
|
||||
Value: !Sub "aws cloudformation delete-stack --stack-name ${AWS::StackName}"
|
||||
@@ -0,0 +1,108 @@
|
||||
# Single-EC2 Quickstart - "just give me Docker on a VM"
|
||||
|
||||
The fastest possible AWS deployment when you don't want managed services. **Good
|
||||
for ≤25 concurrent users on a single beefy VM**. Past that, jump to the
|
||||
CloudFormation or Terraform option which scales horizontally.
|
||||
|
||||
## What you get
|
||||
|
||||
- One EC2 instance running 2 Stirling app containers, 1 Valkey, 1 Postgres,
|
||||
1 nginx LB - exactly the validated `validation/compose.test.yml` topology
|
||||
- ~$25-40/month for a `t3.large`
|
||||
- 5-minute deploy
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Launch an EC2 instance
|
||||
|
||||
- AMI: Amazon Linux 2023 (or Ubuntu 22.04 LTS)
|
||||
- Instance type: `t3.large` (2 vCPU, 8 GB RAM) minimum
|
||||
- Storage: 30 GB gp3
|
||||
- Security group: open `80/tcp` (and `22/tcp` for SSH)
|
||||
- IAM role: none needed
|
||||
|
||||
### 2. SSH in and install Docker
|
||||
|
||||
```bash
|
||||
sudo dnf install -y docker git
|
||||
sudo systemctl enable --now docker
|
||||
sudo usermod -aG docker ec2-user
|
||||
# new shell so the group takes effect
|
||||
exit
|
||||
```
|
||||
|
||||
(On Ubuntu: `sudo apt install -y docker.io docker-compose-plugin git`.)
|
||||
|
||||
### 3. Pull the compose stack
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Stirling-Tools/Stirling-PDF.git
|
||||
cd Stirling-PDF
|
||||
git checkout v2.11.0
|
||||
```
|
||||
|
||||
### 4. Set secrets and start
|
||||
|
||||
```bash
|
||||
export CLUSTER_ENGINE_SHAREDSECRET=$(openssl rand -hex 16)
|
||||
export STIRLING_VALKEY_PASSWORD=$(openssl rand -hex 16)
|
||||
export POSTGRES_PASSWORD=$(openssl rand -hex 16)
|
||||
docker compose -f docker/compose/docker-compose-cluster.yml up -d --build
|
||||
```
|
||||
|
||||
Wait ~2 min for the apps to come up:
|
||||
|
||||
```bash
|
||||
until curl -fsS http://localhost:8080/api/v1/info/status | grep -q UP; do sleep 5; done
|
||||
echo OK
|
||||
```
|
||||
|
||||
### 5. Open it
|
||||
|
||||
`http://<EC2 public IP>/`
|
||||
|
||||
### 6. (Optional) HTTPS
|
||||
|
||||
Slap Caddy or Traefik in front, or use AWS ALB pointing at the EC2 instance.
|
||||
Easiest is Caddy:
|
||||
|
||||
```bash
|
||||
docker run -d --name caddy --restart unless-stopped \
|
||||
-p 80:80 -p 443:443 \
|
||||
-v caddy_data:/data \
|
||||
-v $PWD/Caddyfile:/etc/caddy/Caddyfile \
|
||||
caddy
|
||||
```
|
||||
|
||||
with a tiny `Caddyfile`:
|
||||
|
||||
```
|
||||
stirling.yourdomain.com {
|
||||
reverse_proxy host.docker.internal:8080
|
||||
}
|
||||
```
|
||||
|
||||
Caddy fetches a Let's Encrypt cert automatically.
|
||||
|
||||
## Backups
|
||||
|
||||
- **Postgres**: `docker exec stirling-postgres pg_dump -U stirling stirling > backup-$(date +%F).sql`
|
||||
Cron this and ship to S3 with `aws s3 cp`.
|
||||
- **Valkey**: state is short-TTL (job status, rate-limit counters); no backup
|
||||
needed - losing it on restart just means in-flight async jobs need re-running.
|
||||
|
||||
## Tear-down
|
||||
|
||||
`docker compose -f docker/compose/docker-compose-cluster.yml down -v` then
|
||||
terminate the EC2 instance.
|
||||
|
||||
## Limits of this path
|
||||
|
||||
- Single point of failure (one VM)
|
||||
- Manual scaling: edit `app-3`/`app-4` services into the compose file, restart
|
||||
- No autoscaling
|
||||
- No managed-service backups for Valkey
|
||||
- nginx LB runs on the same VM as the apps
|
||||
|
||||
If any of these matter, use the CloudFormation or Terraform option in this
|
||||
directory instead.
|
||||
Generated
+25
@@ -0,0 +1,25 @@
|
||||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/hashicorp/aws" {
|
||||
version = "5.100.0"
|
||||
constraints = "~> 5.0"
|
||||
hashes = [
|
||||
"h1:H3mU/7URhP0uCRGK8jeQRKxx2XFzEqLiOq/L2Bbiaxs=",
|
||||
"zh:054b8dd49f0549c9a7cc27d159e45327b7b65cf404da5e5a20da154b90b8a644",
|
||||
"zh:0b97bf8d5e03d15d83cc40b0530a1f84b459354939ba6f135a0086c20ebbe6b2",
|
||||
"zh:1589a2266af699cbd5d80737a0fe02e54ec9cf2ca54e7e00ac51c7359056f274",
|
||||
"zh:6330766f1d85f01ae6ea90d1b214b8b74cc8c1badc4696b165b36ddd4cc15f7b",
|
||||
"zh:7c8c2e30d8e55291b86fcb64bdf6c25489d538688545eb48fd74ad622e5d3862",
|
||||
"zh:99b1003bd9bd32ee323544da897148f46a527f622dc3971af63ea3e251596342",
|
||||
"zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
|
||||
"zh:9f8b909d3ec50ade83c8062290378b1ec553edef6a447c56dadc01a99f4eaa93",
|
||||
"zh:aaef921ff9aabaf8b1869a86d692ebd24fbd4e12c21205034bb679b9caf883a2",
|
||||
"zh:ac882313207aba00dd5a76dbd572a0ddc818bb9cbf5c9d61b28fe30efaec951e",
|
||||
"zh:bb64e8aff37becab373a1a0cc1080990785304141af42ed6aa3dd4913b000421",
|
||||
"zh:dfe495f6621df5540d9c92ad40b8067376350b005c637ea6efac5dc15028add4",
|
||||
"zh:f0ddf0eaf052766cfe09dea8200a946519f653c384ab4336e2a4a64fdd6310e9",
|
||||
"zh:f1b7e684f4c7ae1eed272b6de7d2049bb87a0275cb04dbb7cda6636f600699c9",
|
||||
"zh:ff461571e3f233699bf690db319dfe46aec75e58726636a0d97dd9ac6e32fb70",
|
||||
]
|
||||
}
|
||||
+375
@@ -0,0 +1,375 @@
|
||||
Copyright (c) 2017 HashiCorp, Inc.
|
||||
|
||||
Mozilla Public License Version 2.0
|
||||
==================================
|
||||
|
||||
1. Definitions
|
||||
--------------
|
||||
|
||||
1.1. "Contributor"
|
||||
means each individual or legal entity that creates, contributes to
|
||||
the creation of, or owns Covered Software.
|
||||
|
||||
1.2. "Contributor Version"
|
||||
means the combination of the Contributions of others (if any) used
|
||||
by a Contributor and that particular Contributor's Contribution.
|
||||
|
||||
1.3. "Contribution"
|
||||
means Covered Software of a particular Contributor.
|
||||
|
||||
1.4. "Covered Software"
|
||||
means Source Code Form to which the initial Contributor has attached
|
||||
the notice in Exhibit A, the Executable Form of such Source Code
|
||||
Form, and Modifications of such Source Code Form, in each case
|
||||
including portions thereof.
|
||||
|
||||
1.5. "Incompatible With Secondary Licenses"
|
||||
means
|
||||
|
||||
(a) that the initial Contributor has attached the notice described
|
||||
in Exhibit B to the Covered Software; or
|
||||
|
||||
(b) that the Covered Software was made available under the terms of
|
||||
version 1.1 or earlier of the License, but not also under the
|
||||
terms of a Secondary License.
|
||||
|
||||
1.6. "Executable Form"
|
||||
means any form of the work other than Source Code Form.
|
||||
|
||||
1.7. "Larger Work"
|
||||
means a work that combines Covered Software with other material, in
|
||||
a separate file or files, that is not Covered Software.
|
||||
|
||||
1.8. "License"
|
||||
means this document.
|
||||
|
||||
1.9. "Licensable"
|
||||
means having the right to grant, to the maximum extent possible,
|
||||
whether at the time of the initial grant or subsequently, any and
|
||||
all of the rights conveyed by this License.
|
||||
|
||||
1.10. "Modifications"
|
||||
means any of the following:
|
||||
|
||||
(a) any file in Source Code Form that results from an addition to,
|
||||
deletion from, or modification of the contents of Covered
|
||||
Software; or
|
||||
|
||||
(b) any new file in Source Code Form that contains any Covered
|
||||
Software.
|
||||
|
||||
1.11. "Patent Claims" of a Contributor
|
||||
means any patent claim(s), including without limitation, method,
|
||||
process, and apparatus claims, in any patent Licensable by such
|
||||
Contributor that would be infringed, but for the grant of the
|
||||
License, by the making, using, selling, offering for sale, having
|
||||
made, import, or transfer of either its Contributions or its
|
||||
Contributor Version.
|
||||
|
||||
1.12. "Secondary License"
|
||||
means either the GNU General Public License, Version 2.0, the GNU
|
||||
Lesser General Public License, Version 2.1, the GNU Affero General
|
||||
Public License, Version 3.0, or any later versions of those
|
||||
licenses.
|
||||
|
||||
1.13. "Source Code Form"
|
||||
means the form of the work preferred for making modifications.
|
||||
|
||||
1.14. "You" (or "Your")
|
||||
means an individual or a legal entity exercising rights under this
|
||||
License. For legal entities, "You" includes any entity that
|
||||
controls, is controlled by, or is under common control with You. For
|
||||
purposes of this definition, "control" means (a) the power, direct
|
||||
or indirect, to cause the direction or management of such entity,
|
||||
whether by contract or otherwise, or (b) ownership of more than
|
||||
fifty percent (50%) of the outstanding shares or beneficial
|
||||
ownership of such entity.
|
||||
|
||||
2. License Grants and Conditions
|
||||
--------------------------------
|
||||
|
||||
2.1. Grants
|
||||
|
||||
Each Contributor hereby grants You a world-wide, royalty-free,
|
||||
non-exclusive license:
|
||||
|
||||
(a) under intellectual property rights (other than patent or trademark)
|
||||
Licensable by such Contributor to use, reproduce, make available,
|
||||
modify, display, perform, distribute, and otherwise exploit its
|
||||
Contributions, either on an unmodified basis, with Modifications, or
|
||||
as part of a Larger Work; and
|
||||
|
||||
(b) under Patent Claims of such Contributor to make, use, sell, offer
|
||||
for sale, have made, import, and otherwise transfer either its
|
||||
Contributions or its Contributor Version.
|
||||
|
||||
2.2. Effective Date
|
||||
|
||||
The licenses granted in Section 2.1 with respect to any Contribution
|
||||
become effective for each Contribution on the date the Contributor first
|
||||
distributes such Contribution.
|
||||
|
||||
2.3. Limitations on Grant Scope
|
||||
|
||||
The licenses granted in this Section 2 are the only rights granted under
|
||||
this License. No additional rights or licenses will be implied from the
|
||||
distribution or licensing of Covered Software under this License.
|
||||
Notwithstanding Section 2.1(b) above, no patent license is granted by a
|
||||
Contributor:
|
||||
|
||||
(a) for any code that a Contributor has removed from Covered Software;
|
||||
or
|
||||
|
||||
(b) for infringements caused by: (i) Your and any other third party's
|
||||
modifications of Covered Software, or (ii) the combination of its
|
||||
Contributions with other software (except as part of its Contributor
|
||||
Version); or
|
||||
|
||||
(c) under Patent Claims infringed by Covered Software in the absence of
|
||||
its Contributions.
|
||||
|
||||
This License does not grant any rights in the trademarks, service marks,
|
||||
or logos of any Contributor (except as may be necessary to comply with
|
||||
the notice requirements in Section 3.4).
|
||||
|
||||
2.4. Subsequent Licenses
|
||||
|
||||
No Contributor makes additional grants as a result of Your choice to
|
||||
distribute the Covered Software under a subsequent version of this
|
||||
License (see Section 10.2) or under the terms of a Secondary License (if
|
||||
permitted under the terms of Section 3.3).
|
||||
|
||||
2.5. Representation
|
||||
|
||||
Each Contributor represents that the Contributor believes its
|
||||
Contributions are its original creation(s) or it has sufficient rights
|
||||
to grant the rights to its Contributions conveyed by this License.
|
||||
|
||||
2.6. Fair Use
|
||||
|
||||
This License is not intended to limit any rights You have under
|
||||
applicable copyright doctrines of fair use, fair dealing, or other
|
||||
equivalents.
|
||||
|
||||
2.7. Conditions
|
||||
|
||||
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
|
||||
in Section 2.1.
|
||||
|
||||
3. Responsibilities
|
||||
-------------------
|
||||
|
||||
3.1. Distribution of Source Form
|
||||
|
||||
All distribution of Covered Software in Source Code Form, including any
|
||||
Modifications that You create or to which You contribute, must be under
|
||||
the terms of this License. You must inform recipients that the Source
|
||||
Code Form of the Covered Software is governed by the terms of this
|
||||
License, and how they can obtain a copy of this License. You may not
|
||||
attempt to alter or restrict the recipients' rights in the Source Code
|
||||
Form.
|
||||
|
||||
3.2. Distribution of Executable Form
|
||||
|
||||
If You distribute Covered Software in Executable Form then:
|
||||
|
||||
(a) such Covered Software must also be made available in Source Code
|
||||
Form, as described in Section 3.1, and You must inform recipients of
|
||||
the Executable Form how they can obtain a copy of such Source Code
|
||||
Form by reasonable means in a timely manner, at a charge no more
|
||||
than the cost of distribution to the recipient; and
|
||||
|
||||
(b) You may distribute such Executable Form under the terms of this
|
||||
License, or sublicense it under different terms, provided that the
|
||||
license for the Executable Form does not attempt to limit or alter
|
||||
the recipients' rights in the Source Code Form under this License.
|
||||
|
||||
3.3. Distribution of a Larger Work
|
||||
|
||||
You may create and distribute a Larger Work under terms of Your choice,
|
||||
provided that You also comply with the requirements of this License for
|
||||
the Covered Software. If the Larger Work is a combination of Covered
|
||||
Software with a work governed by one or more Secondary Licenses, and the
|
||||
Covered Software is not Incompatible With Secondary Licenses, this
|
||||
License permits You to additionally distribute such Covered Software
|
||||
under the terms of such Secondary License(s), so that the recipient of
|
||||
the Larger Work may, at their option, further distribute the Covered
|
||||
Software under the terms of either this License or such Secondary
|
||||
License(s).
|
||||
|
||||
3.4. Notices
|
||||
|
||||
You may not remove or alter the substance of any license notices
|
||||
(including copyright notices, patent notices, disclaimers of warranty,
|
||||
or limitations of liability) contained within the Source Code Form of
|
||||
the Covered Software, except that You may alter any license notices to
|
||||
the extent required to remedy known factual inaccuracies.
|
||||
|
||||
3.5. Application of Additional Terms
|
||||
|
||||
You may choose to offer, and to charge a fee for, warranty, support,
|
||||
indemnity or liability obligations to one or more recipients of Covered
|
||||
Software. However, You may do so only on Your own behalf, and not on
|
||||
behalf of any Contributor. You must make it absolutely clear that any
|
||||
such warranty, support, indemnity, or liability obligation is offered by
|
||||
You alone, and You hereby agree to indemnify every Contributor for any
|
||||
liability incurred by such Contributor as a result of warranty, support,
|
||||
indemnity or liability terms You offer. You may include additional
|
||||
disclaimers of warranty and limitations of liability specific to any
|
||||
jurisdiction.
|
||||
|
||||
4. Inability to Comply Due to Statute or Regulation
|
||||
---------------------------------------------------
|
||||
|
||||
If it is impossible for You to comply with any of the terms of this
|
||||
License with respect to some or all of the Covered Software due to
|
||||
statute, judicial order, or regulation then You must: (a) comply with
|
||||
the terms of this License to the maximum extent possible; and (b)
|
||||
describe the limitations and the code they affect. Such description must
|
||||
be placed in a text file included with all distributions of the Covered
|
||||
Software under this License. Except to the extent prohibited by statute
|
||||
or regulation, such description must be sufficiently detailed for a
|
||||
recipient of ordinary skill to be able to understand it.
|
||||
|
||||
5. Termination
|
||||
--------------
|
||||
|
||||
5.1. The rights granted under this License will terminate automatically
|
||||
if You fail to comply with any of its terms. However, if You become
|
||||
compliant, then the rights granted under this License from a particular
|
||||
Contributor are reinstated (a) provisionally, unless and until such
|
||||
Contributor explicitly and finally terminates Your grants, and (b) on an
|
||||
ongoing basis, if such Contributor fails to notify You of the
|
||||
non-compliance by some reasonable means prior to 60 days after You have
|
||||
come back into compliance. Moreover, Your grants from a particular
|
||||
Contributor are reinstated on an ongoing basis if such Contributor
|
||||
notifies You of the non-compliance by some reasonable means, this is the
|
||||
first time You have received notice of non-compliance with this License
|
||||
from such Contributor, and You become compliant prior to 30 days after
|
||||
Your receipt of the notice.
|
||||
|
||||
5.2. If You initiate litigation against any entity by asserting a patent
|
||||
infringement claim (excluding declaratory judgment actions,
|
||||
counter-claims, and cross-claims) alleging that a Contributor Version
|
||||
directly or indirectly infringes any patent, then the rights granted to
|
||||
You by any and all Contributors for the Covered Software under Section
|
||||
2.1 of this License shall terminate.
|
||||
|
||||
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
|
||||
end user license agreements (excluding distributors and resellers) which
|
||||
have been validly granted by You or Your distributors under this License
|
||||
prior to termination shall survive termination.
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 6. Disclaimer of Warranty *
|
||||
* ------------------------- *
|
||||
* *
|
||||
* Covered Software is provided under this License on an "as is" *
|
||||
* basis, without warranty of any kind, either expressed, implied, or *
|
||||
* statutory, including, without limitation, warranties that the *
|
||||
* Covered Software is free of defects, merchantable, fit for a *
|
||||
* particular purpose or non-infringing. The entire risk as to the *
|
||||
* quality and performance of the Covered Software is with You. *
|
||||
* Should any Covered Software prove defective in any respect, You *
|
||||
* (not any Contributor) assume the cost of any necessary servicing, *
|
||||
* repair, or correction. This disclaimer of warranty constitutes an *
|
||||
* essential part of this License. No use of any Covered Software is *
|
||||
* authorized under this License except under this disclaimer. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
************************************************************************
|
||||
* *
|
||||
* 7. Limitation of Liability *
|
||||
* -------------------------- *
|
||||
* *
|
||||
* Under no circumstances and under no legal theory, whether tort *
|
||||
* (including negligence), contract, or otherwise, shall any *
|
||||
* Contributor, or anyone who distributes Covered Software as *
|
||||
* permitted above, be liable to You for any direct, indirect, *
|
||||
* special, incidental, or consequential damages of any character *
|
||||
* including, without limitation, damages for lost profits, loss of *
|
||||
* goodwill, work stoppage, computer failure or malfunction, or any *
|
||||
* and all other commercial damages or losses, even if such party *
|
||||
* shall have been informed of the possibility of such damages. This *
|
||||
* limitation of liability shall not apply to liability for death or *
|
||||
* personal injury resulting from such party's negligence to the *
|
||||
* extent applicable law prohibits such limitation. Some *
|
||||
* jurisdictions do not allow the exclusion or limitation of *
|
||||
* incidental or consequential damages, so this exclusion and *
|
||||
* limitation may not apply to You. *
|
||||
* *
|
||||
************************************************************************
|
||||
|
||||
8. Litigation
|
||||
-------------
|
||||
|
||||
Any litigation relating to this License may be brought only in the
|
||||
courts of a jurisdiction where the defendant maintains its principal
|
||||
place of business and such litigation shall be governed by laws of that
|
||||
jurisdiction, without reference to its conflict-of-law provisions.
|
||||
Nothing in this Section shall prevent a party's ability to bring
|
||||
cross-claims or counter-claims.
|
||||
|
||||
9. Miscellaneous
|
||||
----------------
|
||||
|
||||
This License represents the complete agreement concerning the subject
|
||||
matter hereof. If any provision of this License is held to be
|
||||
unenforceable, such provision shall be reformed only to the extent
|
||||
necessary to make it enforceable. Any law or regulation which provides
|
||||
that the language of a contract shall be construed against the drafter
|
||||
shall not be used to construe this License against a Contributor.
|
||||
|
||||
10. Versions of the License
|
||||
---------------------------
|
||||
|
||||
10.1. New Versions
|
||||
|
||||
Mozilla Foundation is the license steward. Except as provided in Section
|
||||
10.3, no one other than the license steward has the right to modify or
|
||||
publish new versions of this License. Each version will be given a
|
||||
distinguishing version number.
|
||||
|
||||
10.2. Effect of New Versions
|
||||
|
||||
You may distribute the Covered Software under the terms of the version
|
||||
of the License under which You originally received the Covered Software,
|
||||
or under the terms of any subsequent version published by the license
|
||||
steward.
|
||||
|
||||
10.3. Modified Versions
|
||||
|
||||
If you create software not governed by this License, and you want to
|
||||
create a new license for such software, you may create and use a
|
||||
modified version of this License if you rename the license and remove
|
||||
any references to the name of the license steward (except to note that
|
||||
such modified license differs from this License).
|
||||
|
||||
10.4. Distributing Source Code Form that is Incompatible With Secondary
|
||||
Licenses
|
||||
|
||||
If You choose to distribute Source Code Form that is Incompatible With
|
||||
Secondary Licenses under the terms of this version of the License, the
|
||||
notice described in Exhibit B of this License must be attached.
|
||||
|
||||
Exhibit A - Source Code Form License Notice
|
||||
-------------------------------------------
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
|
||||
If it is not possible or desirable to put the notice in a particular
|
||||
file, then You may include the notice in a location (such as a LICENSE
|
||||
file in a relevant directory) where a recipient would be likely to look
|
||||
for such a notice.
|
||||
|
||||
You may add additional accurate notices of copyright ownership.
|
||||
|
||||
Exhibit B - "Incompatible With Secondary Licenses" Notice
|
||||
---------------------------------------------------------
|
||||
|
||||
This Source Code Form is "Incompatible With Secondary Licenses", as
|
||||
defined by the Mozilla Public License, v. 2.0.
|
||||
@@ -0,0 +1,642 @@
|
||||
# Stirling-PDF on AWS - Terraform module (single-module starting point).
|
||||
# Production users typically split into modules/{vpc,ecs,rds,elasticache,alb}; this shape
|
||||
# is meant as a copy-and-fill-in template.
|
||||
|
||||
terraform {
|
||||
required_version = ">= 1.6"
|
||||
required_providers {
|
||||
aws = {
|
||||
source = "hashicorp/aws"
|
||||
version = "~> 5.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider "aws" {
|
||||
region = var.region
|
||||
}
|
||||
|
||||
# ----- inputs -----
|
||||
variable "region" {
|
||||
type = string
|
||||
default = "us-east-1"
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
type = string
|
||||
default = "stirling"
|
||||
}
|
||||
|
||||
variable "app_image" {
|
||||
type = string
|
||||
# Never use :latest in production - breaks reproducible deploys and rollback.
|
||||
default = "stirlingtools/stirling-pdf:2.11.0"
|
||||
}
|
||||
|
||||
variable "engine_image" {
|
||||
type = string
|
||||
default = "stirlingtools/stirling-pdf-ai-engine:2.11.0"
|
||||
}
|
||||
|
||||
variable "app_count" {
|
||||
type = number
|
||||
default = 2
|
||||
}
|
||||
|
||||
variable "app_cpu" {
|
||||
type = number
|
||||
default = 1024
|
||||
}
|
||||
|
||||
variable "app_memory" {
|
||||
type = number
|
||||
default = 4096
|
||||
}
|
||||
|
||||
variable "engine_count" {
|
||||
type = number
|
||||
default = 1
|
||||
}
|
||||
|
||||
variable "enable_ai_engine" {
|
||||
type = bool
|
||||
default = false
|
||||
}
|
||||
|
||||
variable "db_password" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "engine_shared_secret" {
|
||||
type = string
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "valkey_auth_token" {
|
||||
# ElastiCache AUTH token (16-128 chars). Generate: openssl rand -hex 32
|
||||
type = string
|
||||
sensitive = true
|
||||
validation {
|
||||
condition = length(var.valkey_auth_token) >= 16 && length(var.valkey_auth_token) <= 128
|
||||
error_message = "valkey_auth_token must be between 16 and 128 characters."
|
||||
}
|
||||
}
|
||||
|
||||
variable "vpc_cidr" {
|
||||
type = string
|
||||
default = "10.42.0.0/16"
|
||||
}
|
||||
|
||||
# ----- networking (slim VPC, two AZs) -----
|
||||
resource "aws_vpc" "main" {
|
||||
cidr_block = var.vpc_cidr
|
||||
enable_dns_hostnames = true
|
||||
enable_dns_support = true
|
||||
tags = {
|
||||
Name = "${var.name}-vpc"
|
||||
}
|
||||
}
|
||||
|
||||
data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
resource "aws_subnet" "a" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = cidrsubnet(var.vpc_cidr, 8, 1)
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
}
|
||||
|
||||
resource "aws_subnet" "b" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = cidrsubnet(var.vpc_cidr, 8, 2)
|
||||
availability_zone = data.aws_availability_zones.available.names[1]
|
||||
map_public_ip_on_launch = true
|
||||
}
|
||||
|
||||
resource "aws_internet_gateway" "igw" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
}
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
gateway_id = aws_internet_gateway.igw.id
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "a" {
|
||||
subnet_id = aws_subnet.a.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "b" {
|
||||
subnet_id = aws_subnet.b.id
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
# ----- security groups -----
|
||||
resource "aws_security_group" "alb" {
|
||||
name = "${var.name}-alb"
|
||||
description = "Public ALB"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 80
|
||||
to_port = 80
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
ingress {
|
||||
from_port = 443
|
||||
to_port = 443
|
||||
protocol = "tcp"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "app" {
|
||||
name = "${var.name}-app"
|
||||
description = "Stirling app tasks"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "alb_to_app" {
|
||||
type = "ingress"
|
||||
from_port = 8080
|
||||
to_port = 8080
|
||||
protocol = "tcp"
|
||||
security_group_id = aws_security_group.app.id
|
||||
source_security_group_id = aws_security_group.alb.id
|
||||
}
|
||||
|
||||
resource "aws_security_group_rule" "app_to_app" {
|
||||
type = "ingress"
|
||||
from_port = 8080
|
||||
to_port = 8080
|
||||
protocol = "tcp"
|
||||
security_group_id = aws_security_group.app.id
|
||||
source_security_group_id = aws_security_group.app.id
|
||||
}
|
||||
|
||||
resource "aws_security_group" "valkey" {
|
||||
name = "${var.name}-valkey"
|
||||
description = "Valkey ElastiCache"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 6379
|
||||
to_port = 6379
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "db" {
|
||||
name = "${var.name}-db"
|
||||
description = "PostgreSQL"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5432
|
||||
to_port = 5432
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "engine_lb" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-lb"
|
||||
description = "Internal ALB in front of engine tier - app tasks only"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5001
|
||||
to_port = 5001
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.app.id]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_security_group" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine"
|
||||
description = "AI engine tasks - only reachable from the internal engine LB"
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
ingress {
|
||||
from_port = 5001
|
||||
to_port = 5001
|
||||
protocol = "tcp"
|
||||
security_groups = [aws_security_group.engine_lb[0].id]
|
||||
}
|
||||
|
||||
egress {
|
||||
from_port = 0
|
||||
to_port = 0
|
||||
protocol = "-1"
|
||||
cidr_blocks = ["0.0.0.0/0"]
|
||||
}
|
||||
}
|
||||
|
||||
# ----- managed Valkey (ElastiCache for Valkey, GA since 2024) -----
|
||||
resource "aws_elasticache_subnet_group" "valkey" {
|
||||
name = "${var.name}-valkey"
|
||||
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
}
|
||||
|
||||
resource "aws_elasticache_replication_group" "valkey" {
|
||||
replication_group_id = "${var.name}-valkey"
|
||||
description = "Stirling Valkey"
|
||||
engine = "valkey"
|
||||
engine_version = "8.0"
|
||||
node_type = "cache.t4g.small"
|
||||
num_cache_clusters = 1
|
||||
automatic_failover_enabled = false
|
||||
subnet_group_name = aws_elasticache_subnet_group.valkey.name
|
||||
security_group_ids = [aws_security_group.valkey.id]
|
||||
at_rest_encryption_enabled = true
|
||||
# TLS in flight required when auth_token is set (ElastiCache enforces this).
|
||||
transit_encryption_enabled = true
|
||||
auth_token = var.valkey_auth_token
|
||||
}
|
||||
|
||||
# ----- managed Postgres -----
|
||||
resource "aws_db_subnet_group" "pg" {
|
||||
name = "${var.name}-pg"
|
||||
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
}
|
||||
|
||||
resource "aws_db_instance" "pg" {
|
||||
identifier = "${var.name}-pg"
|
||||
engine = "postgres"
|
||||
engine_version = "17.2"
|
||||
instance_class = "db.t4g.micro"
|
||||
allocated_storage = 20
|
||||
username = "stirling"
|
||||
password = var.db_password
|
||||
db_name = "stirling"
|
||||
db_subnet_group_name = aws_db_subnet_group.pg.name
|
||||
vpc_security_group_ids = [aws_security_group.db.id]
|
||||
storage_encrypted = true
|
||||
skip_final_snapshot = false
|
||||
final_snapshot_identifier = "${var.name}-pg-final"
|
||||
backup_retention_period = 7
|
||||
}
|
||||
|
||||
# ----- secrets -----
|
||||
resource "aws_secretsmanager_secret" "bundle" {
|
||||
name = "${var.name}-secrets"
|
||||
}
|
||||
|
||||
resource "aws_secretsmanager_secret_version" "bundle" {
|
||||
secret_id = aws_secretsmanager_secret.bundle.id
|
||||
secret_string = jsonencode({
|
||||
engineSharedSecret = var.engine_shared_secret
|
||||
dbPassword = var.db_password
|
||||
valkeyAuthToken = var.valkey_auth_token
|
||||
})
|
||||
}
|
||||
|
||||
# ----- ECS cluster + IAM -----
|
||||
resource "aws_ecs_cluster" "main" {
|
||||
name = "${var.name}-cluster"
|
||||
setting {
|
||||
name = "containerInsights"
|
||||
value = "enabled"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_iam_role" "exec" {
|
||||
name = "${var.name}-exec"
|
||||
assume_role_policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Principal = { Service = "ecs-tasks.amazonaws.com" }
|
||||
Action = "sts:AssumeRole"
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy_attachment" "exec_managed" {
|
||||
role = aws_iam_role.exec.name
|
||||
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
|
||||
}
|
||||
|
||||
resource "aws_iam_role_policy" "exec_read_secrets" {
|
||||
role = aws_iam_role.exec.id
|
||||
policy = jsonencode({
|
||||
Version = "2012-10-17"
|
||||
Statement = [{
|
||||
Effect = "Allow"
|
||||
Action = ["secretsmanager:GetSecretValue"]
|
||||
Resource = aws_secretsmanager_secret.bundle.arn
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
resource "aws_cloudwatch_log_group" "ecs" {
|
||||
name = "/ecs/${var.name}"
|
||||
retention_in_days = 14
|
||||
}
|
||||
|
||||
# ----- ALB + target group + listener (blocks /internal/*) -----
|
||||
resource "aws_lb" "alb" {
|
||||
name = "${var.name}-alb"
|
||||
load_balancer_type = "application"
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.alb.id]
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "app" {
|
||||
name = "${var.name}-app-tg"
|
||||
vpc_id = aws_vpc.main.id
|
||||
port = 8080
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
path = "/api/v1/info/status"
|
||||
matcher = "200"
|
||||
interval = 30
|
||||
timeout = 10
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 5
|
||||
}
|
||||
|
||||
# Sticky sessions required - see deploy/aws/README.md.
|
||||
stickiness {
|
||||
type = "lb_cookie"
|
||||
cookie_duration = 86400
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "http" {
|
||||
load_balancer_arn = aws_lb.alb.arn
|
||||
port = 80
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.app.arn
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener_rule" "block_internal" {
|
||||
listener_arn = aws_lb_listener.http.arn
|
||||
priority = 1
|
||||
|
||||
condition {
|
||||
path_pattern {
|
||||
values = ["/internal/*"]
|
||||
}
|
||||
}
|
||||
|
||||
action {
|
||||
type = "fixed-response"
|
||||
fixed_response {
|
||||
status_code = "404"
|
||||
content_type = "text/plain"
|
||||
message_body = "Not Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ----- app task definition + service -----
|
||||
resource "aws_ecs_task_definition" "app" {
|
||||
family = "${var.name}-app"
|
||||
cpu = var.app_cpu
|
||||
memory = var.app_memory
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
execution_role_arn = aws_iam_role.exec.arn
|
||||
|
||||
container_definitions = jsonencode([{
|
||||
name = "stirling"
|
||||
image = var.app_image
|
||||
essential = true
|
||||
portMappings = [{
|
||||
containerPort = 8080
|
||||
protocol = "tcp"
|
||||
}]
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.ecs.name
|
||||
awslogs-region = var.region
|
||||
awslogs-stream-prefix = "app"
|
||||
}
|
||||
}
|
||||
environment = concat([
|
||||
{ name = "CLUSTER_ENABLED", value = "true" },
|
||||
{ name = "CLUSTER_BACKPLANE", value = "valkey" },
|
||||
# rediss:// = TLS. REDIS_PASSWORD injected separately via secrets below.
|
||||
{ name = "CLUSTER_VALKEY_URL", value = "rediss://${aws_elasticache_replication_group.valkey.primary_endpoint_address}:6379" },
|
||||
{ name = "SPRING_DATASOURCE_URL", value = "jdbc:postgresql://${aws_db_instance.pg.endpoint}/stirling" },
|
||||
{ name = "SPRING_DATASOURCE_USERNAME", value = "stirling" },
|
||||
{ name = "DOCKER_ENABLE_SECURITY", value = "true" },
|
||||
],
|
||||
var.enable_ai_engine ? [
|
||||
{ name = "AIENGINE_URL", value = "http://${aws_lb.engine[0].dns_name}:5001" },
|
||||
] : []
|
||||
)
|
||||
secrets = [
|
||||
{ name = "CLUSTER_ENGINE_SHAREDSECRET", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:engineSharedSecret::" },
|
||||
{ name = "SPRING_DATASOURCE_PASSWORD", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:dbPassword::" },
|
||||
{ name = "REDIS_PASSWORD", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:valkeyAuthToken::" },
|
||||
]
|
||||
}])
|
||||
}
|
||||
|
||||
resource "aws_ecs_service" "app" {
|
||||
name = "${var.name}-app"
|
||||
cluster = aws_ecs_cluster.main.id
|
||||
task_definition = aws_ecs_task_definition.app.arn
|
||||
desired_count = var.app_count
|
||||
launch_type = "FARGATE"
|
||||
deployment_minimum_healthy_percent = 50
|
||||
deployment_maximum_percent = 200
|
||||
# Grace period covers Spring Boot warm-up + Valkey handshake (~60-90s total).
|
||||
health_check_grace_period_seconds = 120
|
||||
|
||||
network_configuration {
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.app.id]
|
||||
assign_public_ip = true
|
||||
}
|
||||
|
||||
load_balancer {
|
||||
target_group_arn = aws_lb_target_group.app.arn
|
||||
container_name = "stirling"
|
||||
container_port = 8080
|
||||
}
|
||||
|
||||
depends_on = [aws_lb_listener.http]
|
||||
}
|
||||
|
||||
# ----- autoscaling -----
|
||||
resource "aws_appautoscaling_target" "app" {
|
||||
max_capacity = 10
|
||||
min_capacity = var.app_count
|
||||
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
|
||||
scalable_dimension = "ecs:service:DesiredCount"
|
||||
service_namespace = "ecs"
|
||||
}
|
||||
|
||||
resource "aws_appautoscaling_policy" "cpu" {
|
||||
name = "${var.name}-cpu-target"
|
||||
policy_type = "TargetTrackingScaling"
|
||||
resource_id = aws_appautoscaling_target.app.resource_id
|
||||
scalable_dimension = aws_appautoscaling_target.app.scalable_dimension
|
||||
service_namespace = aws_appautoscaling_target.app.service_namespace
|
||||
|
||||
target_tracking_scaling_policy_configuration {
|
||||
target_value = 70
|
||||
predefined_metric_specification {
|
||||
predefined_metric_type = "ECSServiceAverageCPUUtilization"
|
||||
}
|
||||
scale_in_cooldown = 60
|
||||
scale_out_cooldown = 60
|
||||
}
|
||||
}
|
||||
|
||||
# ----- AI engine tier (internal LB + task def + service) -----
|
||||
resource "aws_lb" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-alb"
|
||||
internal = true
|
||||
load_balancer_type = "application"
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.engine_lb[0].id]
|
||||
}
|
||||
|
||||
resource "aws_lb_target_group" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine-tg"
|
||||
vpc_id = aws_vpc.main.id
|
||||
port = 5001
|
||||
protocol = "HTTP"
|
||||
target_type = "ip"
|
||||
|
||||
health_check {
|
||||
path = "/health"
|
||||
matcher = "200"
|
||||
interval = 30
|
||||
timeout = 10
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 5
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_lb_listener" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
load_balancer_arn = aws_lb.engine[0].arn
|
||||
port = 5001
|
||||
protocol = "HTTP"
|
||||
|
||||
default_action {
|
||||
type = "forward"
|
||||
target_group_arn = aws_lb_target_group.engine[0].arn
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_ecs_task_definition" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
family = "${var.name}-engine"
|
||||
cpu = 1024
|
||||
memory = 2048
|
||||
network_mode = "awsvpc"
|
||||
requires_compatibilities = ["FARGATE"]
|
||||
execution_role_arn = aws_iam_role.exec.arn
|
||||
|
||||
container_definitions = jsonencode([{
|
||||
name = "engine"
|
||||
image = var.engine_image
|
||||
essential = true
|
||||
portMappings = [{
|
||||
containerPort = 5001
|
||||
protocol = "tcp"
|
||||
}]
|
||||
logConfiguration = {
|
||||
logDriver = "awslogs"
|
||||
options = {
|
||||
awslogs-group = aws_cloudwatch_log_group.ecs.name
|
||||
awslogs-region = var.region
|
||||
awslogs-stream-prefix = "engine"
|
||||
}
|
||||
}
|
||||
secrets = [
|
||||
{ name = "STIRLING_ENGINE_SHARED_SECRET", valueFrom = "${aws_secretsmanager_secret.bundle.arn}:engineSharedSecret::" },
|
||||
]
|
||||
}])
|
||||
}
|
||||
|
||||
resource "aws_ecs_service" "engine" {
|
||||
count = var.enable_ai_engine ? 1 : 0
|
||||
name = "${var.name}-engine"
|
||||
cluster = aws_ecs_cluster.main.id
|
||||
task_definition = aws_ecs_task_definition.engine[0].arn
|
||||
desired_count = var.engine_count
|
||||
launch_type = "FARGATE"
|
||||
# Engine model-load warm-up can take 60-90s.
|
||||
health_check_grace_period_seconds = 120
|
||||
|
||||
network_configuration {
|
||||
subnets = [aws_subnet.a.id, aws_subnet.b.id]
|
||||
security_groups = [aws_security_group.engine[0].id]
|
||||
assign_public_ip = true
|
||||
}
|
||||
|
||||
load_balancer {
|
||||
target_group_arn = aws_lb_target_group.engine[0].arn
|
||||
container_name = "engine"
|
||||
container_port = 5001
|
||||
}
|
||||
|
||||
depends_on = [aws_lb_listener.engine]
|
||||
}
|
||||
|
||||
# ----- outputs -----
|
||||
output "app_url" {
|
||||
value = "http://${aws_lb.alb.dns_name}/"
|
||||
}
|
||||
|
||||
output "valkey_endpoint" {
|
||||
value = aws_elasticache_replication_group.valkey.primary_endpoint_address
|
||||
}
|
||||
|
||||
output "postgres_endpoint" {
|
||||
value = aws_db_instance.pg.endpoint
|
||||
}
|
||||
|
||||
output "cluster_name" {
|
||||
value = aws_ecs_cluster.main.name
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: stirling-pdf
|
||||
description: Stirling-PDF clustered deployment (web + worker + AI engine + Valkey).
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "2.11.0"
|
||||
@@ -0,0 +1,44 @@
|
||||
Stirling-PDF release "{{ .Release.Name }}" has been deployed.
|
||||
|
||||
{{- if .Values.ingress.enabled }}
|
||||
Front door: https://{{ .Values.ingress.host }}/ (via Ingress class "{{ .Values.ingress.className }}")
|
||||
{{- if not .Values.ingress.tls.enabled }}
|
||||
|
||||
WARNING: TLS is disabled on the Ingress (ingress.tls.enabled=false).
|
||||
Cookies, JWTs, and API keys will cross the wire in plaintext. Acceptable
|
||||
for dev / loopback only. Re-enable TLS and provide a secret named
|
||||
"{{ .Values.ingress.tls.secretName }}" (cert-manager or kubectl create secret tls).
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
|
||||
WARNING: bundled Valkey is a single-replica StatefulSet (SPOF) - DEV / EVAL ONLY.
|
||||
Production deployments MUST use an external HA Valkey/Redis: set
|
||||
cluster.valkey.bundled=false
|
||||
cluster.valkey.externalUrl=rediss://<managed-endpoint>:6379
|
||||
Managed options: AWS ElastiCache for Valkey, GCP Memorystore, the Valkey
|
||||
Operator with multi-replica + Sentinel, or any other HA Redis-protocol service.
|
||||
See deploy/aws/README.md for the recommended managed paths.
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (not .Values.cluster.engineSharedSecret) }}
|
||||
|
||||
INFO: cluster.engineSharedSecret was auto-generated and stored in the Secret
|
||||
"{{ .Release.Name }}-cluster-secrets". It is stable across `helm upgrade`
|
||||
(the chart reads the existing Secret on each render) but will regenerate on
|
||||
`helm uninstall` + reinstall. To pin it, retrieve with:
|
||||
kubectl get secret {{ .Release.Name }}-cluster-secrets \
|
||||
-o jsonpath='{.data.engineSharedSecret}' | base64 -d
|
||||
and store it in your secret manager / values file for future installs.
|
||||
{{- end }}
|
||||
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled (not .Values.cluster.valkey.password) }}
|
||||
|
||||
INFO: cluster.valkey.password was auto-generated for the bundled Valkey and
|
||||
stored in "{{ .Release.Name }}-cluster-secrets". Stable across upgrades but
|
||||
regenerates on uninstall + reinstall (which would orphan the persisted
|
||||
Valkey PVC). Pin it explicitly before going beyond eval/dev:
|
||||
kubectl get secret {{ .Release.Name }}-cluster-secrets \
|
||||
-o jsonpath='{.data.valkeyPassword}' | base64 -d
|
||||
{{- end }}
|
||||
@@ -0,0 +1,13 @@
|
||||
{{/* Resolve the Valkey URL - bundled vs. external bring-your-own.
|
||||
Bundled path uses ${REDIS_PASSWORD} (shell expansion at Spring Boot startup) so the
|
||||
password is never written into the rendered manifest or K8s events.
|
||||
*/}}
|
||||
{{- define "stirling-pdf.valkeyUrl" -}}
|
||||
{{- if and .Values.cluster.enabled .Values.cluster.valkey.bundled -}}
|
||||
redis://:${REDIS_PASSWORD}@{{ .Release.Name }}-valkey:6379
|
||||
{{- else if .Values.cluster.valkey.externalUrl -}}
|
||||
{{ .Values.cluster.valkey.externalUrl }}
|
||||
{{- else if .Values.cluster.enabled -}}
|
||||
{{ fail "cluster.valkey.bundled=false requires cluster.valkey.externalUrl to be set (e.g. rediss://user:pw@host:6379)" }}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
@@ -0,0 +1,62 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-engine
|
||||
spec:
|
||||
replicas: {{ .Values.engine.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
spec:
|
||||
containers:
|
||||
- name: engine
|
||||
image: {{ .Values.engine.image.repository }}:{{ .Values.engine.image.tag | default .Chart.AppVersion }}
|
||||
env:
|
||||
- name: STIRLING_ENGINE_SHARED_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 5001
|
||||
# Startup probe: 30 x 5s = 150s grace for model-load warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 5001
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.engine.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-engine
|
||||
spec:
|
||||
selector:
|
||||
app: stirling-pdf
|
||||
role: engine
|
||||
ports:
|
||||
- port: 5001
|
||||
targetPort: 5001
|
||||
@@ -0,0 +1,41 @@
|
||||
{{- if .Values.web.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ .Release.Name }}-web
|
||||
minReplicas: {{ .Values.web.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.web.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.web.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if .Values.worker.autoscaling.enabled }}
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-worker
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: {{ .Release.Name }}-worker
|
||||
minReplicas: {{ .Values.worker.autoscaling.minReplicas }}
|
||||
maxReplicas: {{ .Values.worker.autoscaling.maxReplicas }}
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: {{ .Values.worker.autoscaling.targetCPUUtilizationPercentage }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,45 @@
|
||||
{{- if .Values.ingress.enabled }}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ .Release.Name }}
|
||||
annotations:
|
||||
# Cookie affinity is required - see deploy/aws/README.md "Sticky sessions are required".
|
||||
nginx.ingress.kubernetes.io/affinity: "cookie"
|
||||
nginx.ingress.kubernetes.io/affinity-mode: "persistent"
|
||||
nginx.ingress.kubernetes.io/session-cookie-name: "STIRLING_NODE"
|
||||
nginx.ingress.kubernetes.io/session-cookie-max-age: "86400"
|
||||
# Match the JVM-side 2000MB multipart limit (nginx-ingress default 1MB would 413).
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: "2000m"
|
||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||
# Defense-in-depth: /internal/* is never a public route.
|
||||
nginx.ingress.kubernetes.io/server-snippet: |
|
||||
location ^~ /internal/ { return 404; }
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className }}
|
||||
{{- if .Values.ingress.tls.enabled }}
|
||||
{{- if not .Values.ingress.tls.secretName }}
|
||||
{{- fail "ingress.tls.enabled=true requires ingress.tls.secretName to be set" }}
|
||||
{{- end }}
|
||||
tls:
|
||||
- hosts:
|
||||
{{- if .Values.ingress.tls.hosts }}
|
||||
{{- toYaml .Values.ingress.tls.hosts | nindent 8 }}
|
||||
{{- else }}
|
||||
- {{ .Values.ingress.host }}
|
||||
{{- end }}
|
||||
secretName: {{ .Values.ingress.tls.secretName }}
|
||||
{{- end }}
|
||||
rules:
|
||||
- host: {{ .Values.ingress.host }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ .Release.Name }}-web
|
||||
port:
|
||||
number: 8080
|
||||
{{- end }}
|
||||
@@ -0,0 +1,59 @@
|
||||
{{/*
|
||||
Stable-secret pattern. Resolution order for each field:
|
||||
1. Operator-provided value (.Values.*) wins.
|
||||
2. Otherwise reuse what is already in the cluster Secret (lookup), so
|
||||
`helm upgrade` does NOT churn the value on every run.
|
||||
3. Otherwise generate a fresh 64-char random value (first install).
|
||||
|
||||
Caveats:
|
||||
- `lookup` returns empty during `helm template` / `--dry-run=client`. That
|
||||
is acceptable here: those modes are for inspection, not source of truth.
|
||||
Real `helm install` / `helm upgrade` against a live API server see the
|
||||
existing Secret.
|
||||
- `helm uninstall` removes this Secret. A subsequent `helm install` with no
|
||||
operator override will generate fresh values, and any persisted data tied
|
||||
to the old Valkey password (PVC contents) will be unreadable.
|
||||
*/}}
|
||||
{{- $secretName := printf "%s-cluster-secrets" .Release.Name }}
|
||||
{{- $existing := lookup "v1" "Secret" .Release.Namespace $secretName }}
|
||||
{{- $existingEngine := "" }}
|
||||
{{- $existingValkey := "" }}
|
||||
{{- if $existing }}
|
||||
{{- if hasKey $existing.data "engineSharedSecret" }}
|
||||
{{- $existingEngine = index $existing.data "engineSharedSecret" | b64dec }}
|
||||
{{- end }}
|
||||
{{- if hasKey $existing.data "valkeyPassword" }}
|
||||
{{- $existingValkey = index $existing.data "valkeyPassword" | b64dec }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- $engineSecret := "" }}
|
||||
{{- if .Values.cluster.engineSharedSecret }}
|
||||
{{- $engineSecret = .Values.cluster.engineSharedSecret }}
|
||||
{{- else if $existingEngine }}
|
||||
{{- $engineSecret = $existingEngine }}
|
||||
{{- else }}
|
||||
{{- $engineSecret = randAlphaNum 64 }}
|
||||
{{- end }}
|
||||
|
||||
{{- $needsBundledValkey := and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
{{- $valkeyPassword := "" }}
|
||||
{{- if $needsBundledValkey }}
|
||||
{{- if .Values.cluster.valkey.password }}
|
||||
{{- $valkeyPassword = .Values.cluster.valkey.password }}
|
||||
{{- else if $existingValkey }}
|
||||
{{- $valkeyPassword = $existingValkey }}
|
||||
{{- else }}
|
||||
{{- $valkeyPassword = randAlphaNum 64 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ $secretName }}
|
||||
type: Opaque
|
||||
stringData:
|
||||
engineSharedSecret: {{ $engineSecret | quote }}
|
||||
{{- if $needsBundledValkey }}
|
||||
valkeyPassword: {{ $valkeyPassword | quote }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,80 @@
|
||||
{{- if and .Values.cluster.enabled .Values.cluster.valkey.bundled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-valkey
|
||||
spec:
|
||||
serviceName: {{ .Release.Name }}-valkey
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-valkey
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-valkey
|
||||
spec:
|
||||
containers:
|
||||
- name: valkey
|
||||
image: valkey/valkey:8.0-alpine
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "exec valkey-server --requirepass \"$REDIS_PASSWORD\" --maxmemory 256mb --maxmemory-policy allkeys-lru"
|
||||
env:
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
ports:
|
||||
- containerPort: 6379
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "valkey-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"
|
||||
initialDelaySeconds: 2
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
exec:
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- "valkey-cli -a \"$REDIS_PASSWORD\" --no-auth-warning ping"
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 10
|
||||
resources:
|
||||
{{- toYaml .Values.cluster.valkey.resources | nindent 12 }}
|
||||
{{- if .Values.cluster.valkey.persistence.enabled }}
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
{{- end }}
|
||||
{{- if .Values.cluster.valkey.persistence.enabled }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
{{- if .Values.cluster.valkey.persistence.storageClassName }}
|
||||
storageClassName: {{ .Values.cluster.valkey.persistence.storageClassName }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.cluster.valkey.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-valkey
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: stirling-valkey
|
||||
ports:
|
||||
- port: 6379
|
||||
targetPort: 6379
|
||||
{{- end }}
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
spec:
|
||||
replicas: {{ .Values.web.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: MODE
|
||||
value: FRONTEND
|
||||
- name: CLUSTER_ENABLED
|
||||
value: {{ .Values.cluster.enabled | quote }}
|
||||
- name: CLUSTER_BACKPLANE
|
||||
value: {{ .Values.cluster.backplane }}
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
{{- end }}
|
||||
- name: CLUSTER_VALKEY_URL
|
||||
value: {{ include "stirling-pdf.valkeyUrl" . | quote }}
|
||||
- name: CLUSTER_NODE_ROLE
|
||||
value: web
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: CLUSTER_ENGINE_SHAREDSECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
# Startup probe: 30 x 5s = 150s grace for Spring Boot + Valkey warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.web.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,11 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-web
|
||||
spec:
|
||||
selector:
|
||||
app: stirling-pdf
|
||||
role: web
|
||||
ports:
|
||||
- port: 8080
|
||||
targetPort: 8080
|
||||
@@ -0,0 +1,75 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ .Release.Name }}-worker
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
spec:
|
||||
replicas: {{ .Values.worker.replicas }}
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stirling-pdf
|
||||
role: worker
|
||||
spec:
|
||||
containers:
|
||||
- name: app
|
||||
image: {{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}
|
||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||
env:
|
||||
- name: MODE
|
||||
value: BACKEND
|
||||
- name: CLUSTER_ENABLED
|
||||
value: {{ .Values.cluster.enabled | quote }}
|
||||
- name: CLUSTER_BACKPLANE
|
||||
value: {{ .Values.cluster.backplane }}
|
||||
{{- if and .Values.cluster.enabled (eq .Values.cluster.backplane "valkey") .Values.cluster.valkey.bundled }}
|
||||
- name: REDIS_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: valkeyPassword
|
||||
{{- end }}
|
||||
- name: CLUSTER_VALKEY_URL
|
||||
value: {{ include "stirling-pdf.valkeyUrl" . | quote }}
|
||||
- name: CLUSTER_NODE_ROLE
|
||||
value: worker
|
||||
- name: POD_IP
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: status.podIP
|
||||
- name: CLUSTER_ENGINE_SHAREDSECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ .Release.Name }}-cluster-secrets
|
||||
key: engineSharedSecret
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
# Startup probe: 30 x 5s = 150s grace for Spring Boot + Valkey warm-up. Requires k8s >= 1.18.
|
||||
startupProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
failureThreshold: 30
|
||||
periodSeconds: 5
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /api/v1/info/status
|
||||
port: 8080
|
||||
periodSeconds: 10
|
||||
failureThreshold: 10
|
||||
{{- with .Values.worker.resources }}
|
||||
resources:
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Stirling-PDF Helm chart - clustered install (web + worker + AI engine + Valkey behind Ingress).
|
||||
# Single-instance deploys can run the standard container directly without this chart.
|
||||
|
||||
image:
|
||||
repository: stirlingtools/stirling-pdf
|
||||
# Leave blank to default to .Chart.AppVersion. Override per release.
|
||||
tag: ""
|
||||
pullPolicy: IfNotPresent
|
||||
|
||||
engine:
|
||||
image:
|
||||
repository: stirlingtools/stirling-pdf-ai-engine
|
||||
tag: ""
|
||||
replicas: 2
|
||||
resources:
|
||||
requests:
|
||||
cpu: 250m
|
||||
memory: 512Mi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 1Gi
|
||||
|
||||
web:
|
||||
replicas: 2
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
|
||||
worker:
|
||||
replicas: 2
|
||||
autoscaling:
|
||||
enabled: true
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
targetCPUUtilizationPercentage: 70
|
||||
resources:
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
limits:
|
||||
cpu: "2"
|
||||
memory: 2Gi
|
||||
|
||||
cluster:
|
||||
enabled: true
|
||||
backplane: valkey
|
||||
valkey:
|
||||
# WARNING: bundled Valkey is a single-replica StatefulSet (SPOF) - DEV / EVAL ONLY.
|
||||
# Production: set bundled=false and point externalUrl at HA Valkey/Redis.
|
||||
# See deploy/aws/README.md for managed options (ElastiCache, Memorystore, etc.).
|
||||
bundled: true
|
||||
# Required when bundled=false. Use rediss:// for TLS.
|
||||
externalUrl: ""
|
||||
# Auto-generated on first install if empty (stable across upgrades via Secret lookup).
|
||||
# Set explicitly via --set or values file when using your own secret manager.
|
||||
password: ""
|
||||
persistence:
|
||||
enabled: true
|
||||
size: 1Gi
|
||||
storageClassName: ""
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 384Mi
|
||||
# Java <-> AI engine auth secret. Auto-generated if empty (see NOTES.txt for retrieval).
|
||||
engineSharedSecret: ""
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
host: stirling.example.com
|
||||
className: nginx
|
||||
tls:
|
||||
# WARNING: TLS MUST be on in production - cookies, JWTs, and API keys cross the wire.
|
||||
# Provision the secret below (cert-manager, kubectl create secret tls, etc.) before helm install.
|
||||
# tls.enabled=false is HTTP-only - dev/loopback ONLY.
|
||||
enabled: true
|
||||
secretName: stirling-pdf-tls
|
||||
# Defaults to [ingress.host] when empty.
|
||||
hosts: []
|
||||
@@ -0,0 +1,175 @@
|
||||
{
|
||||
"annotations": {
|
||||
"list": [
|
||||
{
|
||||
"builtIn": 1,
|
||||
"datasource": {"type": "grafana", "uid": "-- Grafana --"},
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"type": "dashboard"
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Starter dashboard for Stirling-PDF cluster mode (plan §7). Add to your Grafana instance pointed at a Prometheus that is scraping /actuator/prometheus on each node. NOTE: requires PR3 (Valkey backplane impls + ClusterMetrics) - panels render 'No data' until that lands.",
|
||||
"editable": true,
|
||||
"fiscalYearStartMonth": 0,
|
||||
"graphTooltip": 1,
|
||||
"id": null,
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"panels": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 0},
|
||||
"id": 1,
|
||||
"options": {"legend": {"displayMode": "table", "showLegend": true}},
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "stirling_cluster_jobs_inflight",
|
||||
"legendFormat": "{{node}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Jobs in flight (per node)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "short"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 0},
|
||||
"id": 2,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "stirling_cluster_queue_depth",
|
||||
"legendFormat": "{{lane}}",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Queue depth (per lane)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 8},
|
||||
"id": 3,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.5, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.99, sum(rate(stirling_cluster_job_wait_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p99",
|
||||
"refId": "C"
|
||||
}
|
||||
],
|
||||
"title": "Job wait time (p50/p95/p99)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "s"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 8},
|
||||
"id": 4,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.5, sum(rate(stirling_cluster_backplane_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p50",
|
||||
"refId": "A"
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "histogram_quantile(0.95, sum(rate(stirling_cluster_backplane_latency_seconds_bucket[5m])) by (le))",
|
||||
"legendFormat": "p95",
|
||||
"refId": "B"
|
||||
}
|
||||
],
|
||||
"title": "Backplane round-trip latency (Valkey p50/p95)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "ops"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 0, "y": 16},
|
||||
"id": 5,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "sum(rate(stirling_cluster_sticky_miss_total[5m]))",
|
||||
"legendFormat": "sticky-session misses / sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Sticky-session miss rate (high = LB affinity broken)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
},
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"fieldConfig": {"defaults": {"color": {"mode": "palette-classic"}, "unit": "ops"}},
|
||||
"gridPos": {"h": 8, "w": 12, "x": 12, "y": 16},
|
||||
"id": 6,
|
||||
"targets": [
|
||||
{
|
||||
"datasource": {"type": "prometheus", "uid": "${DS_PROMETHEUS}"},
|
||||
"expr": "sum(rate(stirling_cluster_ratelimit_rejected_total[5m]))",
|
||||
"legendFormat": "rejections / sec",
|
||||
"refId": "A"
|
||||
}
|
||||
],
|
||||
"title": "Rate-limit rejections (cluster-wide)",
|
||||
"type": "timeseries",
|
||||
"description": "Requires PR3 (Valkey backplane impls + ClusterMetrics) - import after PR3 lands."
|
||||
}
|
||||
],
|
||||
"refresh": "30s",
|
||||
"schemaVersion": 38,
|
||||
"style": "dark",
|
||||
"tags": ["stirling-pdf", "cluster"],
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"current": {"selected": false, "text": "Prometheus", "value": "Prometheus"},
|
||||
"hide": 0,
|
||||
"includeAll": false,
|
||||
"label": "Datasource",
|
||||
"multi": false,
|
||||
"name": "DS_PROMETHEUS",
|
||||
"options": [],
|
||||
"query": "prometheus",
|
||||
"queryValue": "",
|
||||
"refresh": 1,
|
||||
"regex": "",
|
||||
"skipUrlSync": false,
|
||||
"type": "datasource"
|
||||
}
|
||||
]
|
||||
},
|
||||
"time": {"from": "now-1h", "to": "now"},
|
||||
"timepicker": {},
|
||||
"timezone": "",
|
||||
"title": "Stirling-PDF Cluster",
|
||||
"uid": "stirling-cluster-phase1",
|
||||
"version": 1,
|
||||
"weekStart": ""
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# Stirling-PDF unified container. MODE env var selects role: FRONTEND, BACKEND, or BOTH.
|
||||
|
||||
ARG BASE_VERSION=1.0.2
|
||||
ARG BASE_IMAGE=stirlingtools/stirling-pdf-base:${BASE_VERSION}
|
||||
|
||||
# Stage 1: Build the Java application
|
||||
FROM gradle:9.3.1-jdk25 AS app-build
|
||||
|
||||
ARG TASK_VERSION=3.49.1
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& update-ca-certificates \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& ARCH=$(dpkg --print-architecture) \
|
||||
&& curl -fsSL "https://github.com/go-task/task/releases/download/v${TASK_VERSION}/task_${TASK_VERSION}_linux_${ARCH}.deb" -o /tmp/task.deb \
|
||||
&& dpkg -i /tmp/task.deb \
|
||||
&& rm /tmp/task.deb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV JDK_JAVA_OPTIONS="--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \
|
||||
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY build.gradle settings.gradle gradlew ./
|
||||
COPY gradle/ gradle/
|
||||
COPY app/core/build.gradle app/core/
|
||||
COPY app/common/build.gradle app/common/
|
||||
COPY app/proprietary/build.gradle app/proprietary/
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG STIRLING_FLAVOR=proprietary
|
||||
ENV STIRLING_FLAVOR=${STIRLING_FLAVOR}
|
||||
|
||||
RUN STIRLING_FLAVOR=${STIRLING_FLAVOR} \
|
||||
gradle clean build \
|
||||
-PbuildWithFrontend=true \
|
||||
-x spotlessApply -x spotlessCheck -x test -x sonarqube \
|
||||
--no-daemon
|
||||
|
||||
# Stage 2: Extract Spring Boot layers
|
||||
FROM eclipse-temurin:25-jre-noble AS jar-extract
|
||||
WORKDIR /tmp
|
||||
COPY --from=app-build /app/app/core/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --destination /layers
|
||||
|
||||
# Stage 3: Runtime
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG VERSION_TAG
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/spring-boot-loader/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/snapshot-dependencies/ /app/
|
||||
COPY --link --from=jar-extract --chown=1000:1000 /layers/application/ /app/
|
||||
|
||||
COPY --link --chown=1000:1000 scripts/ /scripts/
|
||||
|
||||
ENV MODE=BOTH \
|
||||
BACKEND_INTERNAL_PORT=8081 \
|
||||
STIRLING_AOT_ENABLE="false" \
|
||||
STIRLING_JVM_PROFILE="balanced" \
|
||||
JAVA_CUSTOM_OPTS="" \
|
||||
HOME=/home/stirlingpdfuser \
|
||||
PUID=1000 \
|
||||
PGID=1000 \
|
||||
UMASK=022 \
|
||||
STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \
|
||||
TMPDIR=/tmp/stirling-pdf
|
||||
|
||||
RUN echo "${VERSION_TAG:-dev}" > /etc/stirling_version
|
||||
|
||||
LABEL org.opencontainers.image.title="Stirling-PDF Unified" \
|
||||
org.opencontainers.image.description="Unified container - selectable role via MODE env var." \
|
||||
org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" \
|
||||
org.opencontainers.image.licenses="MIT" \
|
||||
org.opencontainers.image.vendor="Stirling-Tools" \
|
||||
org.opencontainers.image.version="${VERSION_TAG}"
|
||||
|
||||
EXPOSE 8080/tcp
|
||||
STOPSIGNAL SIGTERM
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=15s --start-period=120s --retries=5 \
|
||||
CMD curl -fs --max-time 10 http://localhost:8080${SYSTEM_ROOTURIPATH:-''}/api/v1/info/status || exit 1
|
||||
|
||||
# Map MODE to cluster node role at container start.
|
||||
ENTRYPOINT ["sh", "-c", "case \"$MODE\" in BACKEND) export CLUSTER_NODE_ROLE=worker;; FRONTEND) export CLUSTER_NODE_ROLE=web;; *) export CLUSTER_NODE_ROLE=both;; esac; exec tini -- /scripts/init.sh"]
|
||||
CMD []
|
||||
@@ -0,0 +1,194 @@
|
||||
# Reference clustered deployment: load balancer + N app + N engine + Valkey + Postgres.
|
||||
# Non-k8s enterprise self-host blueprint. /internal/* stays off the LB.
|
||||
#
|
||||
# Required env vars (set in a .env beside this file or via your secret store):
|
||||
# CLUSTER_ENGINE_SHAREDSECRET - generate: openssl rand -hex 32
|
||||
# STIRLING_VALKEY_PASSWORD - generate: openssl rand -hex 32
|
||||
# POSTGRES_PASSWORD - required
|
||||
# STIRLING_PREMIUM_KEY - SERVER/ENTERPRISE license key (cluster mode is a paid feature)
|
||||
|
||||
services:
|
||||
valkey:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
container_name: stirling-valkey
|
||||
restart: unless-stopped
|
||||
command:
|
||||
[
|
||||
"valkey-server",
|
||||
"--requirepass",
|
||||
"${STIRLING_VALKEY_PASSWORD:?STIRLING_VALKEY_PASSWORD must be set in production}",
|
||||
"--maxmemory",
|
||||
"256mb",
|
||||
"--maxmemory-policy",
|
||||
"allkeys-lru"
|
||||
]
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"valkey-cli",
|
||||
"-a",
|
||||
"${STIRLING_VALKEY_PASSWORD:?required}",
|
||||
"--no-auth-warning",
|
||||
"ping"
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
# Do not publish 6379 in prod. Uncomment only for local valkey-cli introspection.
|
||||
# ports:
|
||||
# - "6379:6379"
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 384m
|
||||
reservations:
|
||||
memory: 128m
|
||||
volumes:
|
||||
- valkey-data:/data
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: stirling-postgres
|
||||
environment:
|
||||
POSTGRES_USER: stirling
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||
POSTGRES_DB: stirling
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
reservations:
|
||||
memory: 256m
|
||||
|
||||
app-1:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
container_name: stirling-app-1
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: BOTH
|
||||
CLUSTER_ENABLED: "true"
|
||||
CLUSTER_BACKPLANE: valkey
|
||||
CLUSTER_VALKEY_URL: redis://:${STIRLING_VALKEY_PASSWORD:?required}@valkey:6379
|
||||
CLUSTER_NODE_ID: app-1
|
||||
CLUSTER_NODE_INTERNALADDRESS: app-1:8080
|
||||
CLUSTER_ENGINE_SHAREDSECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
AIENGINE_URL: http://engine-lb:5001
|
||||
STIRLING_PREMIUM_KEY: ${STIRLING_PREMIUM_KEY:?STIRLING_PREMIUM_KEY required for cluster mode}
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/stirling
|
||||
SPRING_DATASOURCE_USERNAME: stirling
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_started
|
||||
engine-lb:
|
||||
condition: service_started
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
reservations:
|
||||
memory: 512m
|
||||
|
||||
app-2:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/Dockerfile.unified
|
||||
container_name: stirling-app-2
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MODE: BOTH
|
||||
CLUSTER_ENABLED: "true"
|
||||
CLUSTER_BACKPLANE: valkey
|
||||
CLUSTER_VALKEY_URL: redis://:${STIRLING_VALKEY_PASSWORD:?required}@valkey:6379
|
||||
CLUSTER_NODE_ID: app-2
|
||||
CLUSTER_NODE_INTERNALADDRESS: app-2:8080
|
||||
CLUSTER_ENGINE_SHAREDSECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
AIENGINE_URL: http://engine-lb:5001
|
||||
STIRLING_PREMIUM_KEY: ${STIRLING_PREMIUM_KEY:?STIRLING_PREMIUM_KEY required for cluster mode}
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/stirling
|
||||
SPRING_DATASOURCE_USERNAME: stirling
|
||||
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD:?required}
|
||||
depends_on:
|
||||
valkey:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_started
|
||||
engine-lb:
|
||||
condition: service_started
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 2g
|
||||
reservations:
|
||||
memory: 512m
|
||||
|
||||
engine-1:
|
||||
build:
|
||||
context: ../../engine
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: stirling-engine-1
|
||||
env_file: ../../engine/.env
|
||||
environment:
|
||||
STIRLING_ENGINE_SHARED_SECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
|
||||
engine-2:
|
||||
build:
|
||||
context: ../../engine
|
||||
dockerfile: Dockerfile.dev
|
||||
container_name: stirling-engine-2
|
||||
env_file: ../../engine/.env
|
||||
environment:
|
||||
STIRLING_ENGINE_SHARED_SECRET: ${CLUSTER_ENGINE_SHAREDSECRET:?required}
|
||||
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1g
|
||||
|
||||
# Internal round-robin LB for the engine tier - not exposed to the host.
|
||||
engine-lb:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: stirling-engine-lb
|
||||
volumes:
|
||||
- ./engine-nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- engine-1
|
||||
- engine-2
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 64m
|
||||
|
||||
lb:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: stirling-lb
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- app-1
|
||||
- app-2
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 128m
|
||||
|
||||
volumes:
|
||||
valkey-data:
|
||||
postgres-data:
|
||||
@@ -0,0 +1,27 @@
|
||||
# nginx LB for the AI engine tier of docker-compose-cluster.yml.
|
||||
# Round-robin is safe (engine is stateless). The separate LB hop exists because the JVM
|
||||
# caches DNS lookups for the process lifetime, so compose round-robin DNS would pin each
|
||||
# JVM to one engine and defeat scaling.
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
upstream stirling_engine {
|
||||
server engine-1:5001;
|
||||
server engine-2:5001;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 5001;
|
||||
|
||||
# Matches JVM multipart cap; nginx default 1MB would 413.
|
||||
client_max_body_size 2000m;
|
||||
|
||||
location / {
|
||||
proxy_pass http://stirling_engine;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# nginx LB for docker-compose-cluster.yml.
|
||||
# Sends all traffic to the Java app (including /api/v1/ai/*, which the app proxies to
|
||||
# the engine with X-Engine-Auth attestation). /internal/* is 404'd. The engine tier
|
||||
# is never exposed directly to clients.
|
||||
|
||||
events { worker_connections 1024; }
|
||||
|
||||
http {
|
||||
upstream stirling_app {
|
||||
# Sticky sessions required - see deploy/aws/README.md for ip_hash caveat + alternatives.
|
||||
ip_hash;
|
||||
server app-1:8080;
|
||||
server app-2:8080;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
|
||||
# Matches JVM multipart.max-file-size; nginx default 1MB would 413.
|
||||
client_max_body_size 2000m;
|
||||
|
||||
location /internal/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://stirling_app;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 600s;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY src/ ./src/
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --frozen
|
||||
|
||||
|
||||
Reference in New Issue
Block a user