diff --git a/deploy/aws/README.md b/deploy/aws/README.md new file mode 100644 index 0000000000..5aad91bfa2 --- /dev/null +++ b/deploy/aws/README.md @@ -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 -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 </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. diff --git a/deploy/aws/cloudformation/stirling-pdf-aws.yaml b/deploy/aws/cloudformation/stirling-pdf-aws.yaml new file mode 100644 index 0000000000..9973755525 --- /dev/null +++ b/deploy/aws/cloudformation/stirling-pdf-aws.yaml @@ -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}" diff --git a/deploy/aws/quickstart/ec2-compose.md b/deploy/aws/quickstart/ec2-compose.md new file mode 100644 index 0000000000..1cfef08b3e --- /dev/null +++ b/deploy/aws/quickstart/ec2-compose.md @@ -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:///` + +### 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. diff --git a/deploy/aws/terraform/.terraform.lock.hcl b/deploy/aws/terraform/.terraform.lock.hcl new file mode 100644 index 0000000000..92a2bcccd8 --- /dev/null +++ b/deploy/aws/terraform/.terraform.lock.hcl @@ -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", + ] +} diff --git a/deploy/aws/terraform/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt b/deploy/aws/terraform/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt new file mode 100644 index 0000000000..b9ac071eb6 --- /dev/null +++ b/deploy/aws/terraform/.terraform/providers/registry.terraform.io/hashicorp/aws/5.100.0/windows_amd64/LICENSE.txt @@ -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. diff --git a/deploy/aws/terraform/main.tf b/deploy/aws/terraform/main.tf new file mode 100644 index 0000000000..f6ab7af5a5 --- /dev/null +++ b/deploy/aws/terraform/main.tf @@ -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 +} diff --git a/deploy/helm/stirling-pdf/Chart.yaml b/deploy/helm/stirling-pdf/Chart.yaml new file mode 100644 index 0000000000..601b2e486c --- /dev/null +++ b/deploy/helm/stirling-pdf/Chart.yaml @@ -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" diff --git a/deploy/helm/stirling-pdf/templates/NOTES.txt b/deploy/helm/stirling-pdf/templates/NOTES.txt new file mode 100644 index 0000000000..4aa4418144 --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/NOTES.txt @@ -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://: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 }} diff --git a/deploy/helm/stirling-pdf/templates/_helpers.tpl b/deploy/helm/stirling-pdf/templates/_helpers.tpl new file mode 100644 index 0000000000..13f020ed82 --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/_helpers.tpl @@ -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 -}} diff --git a/deploy/helm/stirling-pdf/templates/engine-deployment.yaml b/deploy/helm/stirling-pdf/templates/engine-deployment.yaml new file mode 100644 index 0000000000..af415900b4 --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/engine-deployment.yaml @@ -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 diff --git a/deploy/helm/stirling-pdf/templates/hpa.yaml b/deploy/helm/stirling-pdf/templates/hpa.yaml new file mode 100644 index 0000000000..eeb1125da5 --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/hpa.yaml @@ -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 }} diff --git a/deploy/helm/stirling-pdf/templates/ingress.yaml b/deploy/helm/stirling-pdf/templates/ingress.yaml new file mode 100644 index 0000000000..b32c91fad4 --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/ingress.yaml @@ -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 }} diff --git a/deploy/helm/stirling-pdf/templates/secrets.yaml b/deploy/helm/stirling-pdf/templates/secrets.yaml new file mode 100644 index 0000000000..dcb7377d1a --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/secrets.yaml @@ -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 }} diff --git a/deploy/helm/stirling-pdf/templates/valkey.yaml b/deploy/helm/stirling-pdf/templates/valkey.yaml new file mode 100644 index 0000000000..aa181a412a --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/valkey.yaml @@ -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 }} diff --git a/deploy/helm/stirling-pdf/templates/web-deployment.yaml b/deploy/helm/stirling-pdf/templates/web-deployment.yaml new file mode 100644 index 0000000000..566a17ba1d --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/web-deployment.yaml @@ -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 }} diff --git a/deploy/helm/stirling-pdf/templates/web-service.yaml b/deploy/helm/stirling-pdf/templates/web-service.yaml new file mode 100644 index 0000000000..2c54d611ca --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/web-service.yaml @@ -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 diff --git a/deploy/helm/stirling-pdf/templates/worker-deployment.yaml b/deploy/helm/stirling-pdf/templates/worker-deployment.yaml new file mode 100644 index 0000000000..99d6468da6 --- /dev/null +++ b/deploy/helm/stirling-pdf/templates/worker-deployment.yaml @@ -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 }} diff --git a/deploy/helm/stirling-pdf/values.yaml b/deploy/helm/stirling-pdf/values.yaml new file mode 100644 index 0000000000..3c90fb3d97 --- /dev/null +++ b/deploy/helm/stirling-pdf/values.yaml @@ -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: [] diff --git a/deploy/observability/grafana-cluster-dashboard.json b/deploy/observability/grafana-cluster-dashboard.json new file mode 100644 index 0000000000..a621c32842 --- /dev/null +++ b/deploy/observability/grafana-cluster-dashboard.json @@ -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": "" +} diff --git a/docker/Dockerfile.unified b/docker/Dockerfile.unified new file mode 100644 index 0000000000..db1be3e4e5 --- /dev/null +++ b/docker/Dockerfile.unified @@ -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 [] diff --git a/docker/compose/docker-compose-cluster.yml b/docker/compose/docker-compose-cluster.yml new file mode 100644 index 0000000000..19a65b3f4d --- /dev/null +++ b/docker/compose/docker-compose-cluster.yml @@ -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: diff --git a/docker/compose/engine-nginx.conf b/docker/compose/engine-nginx.conf new file mode 100644 index 0000000000..43c673e769 --- /dev/null +++ b/docker/compose/engine-nginx.conf @@ -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; + } + } +} diff --git a/docker/compose/nginx.conf b/docker/compose/nginx.conf new file mode 100644 index 0000000000..1ad89e6e13 --- /dev/null +++ b/docker/compose/nginx.conf @@ -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; + } + } +} diff --git a/engine/Dockerfile.dev b/engine/Dockerfile.dev index 5020fd0590..672cfd2d9e 100644 --- a/engine/Dockerfile.dev +++ b/engine/Dockerfile.dev @@ -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