AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  EagleDesk Enterprise in your own AWS account (https://eagledesk.app). One ECS Fargate task behind an HTTPS load
  balancer; PostgreSQL on Amazon RDS (password made and kept by AWS Secrets Manager); an encrypted EFS volume for the
  working files (Salesforce CLI logins, git copies, attachments); backups copied to a versioned S3 bucket; the AI on
  this account's Amazon Bedrock. Nothing is reachable from the internet except the load balancer.

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: {default: EagleDesk}
        Parameters: [ImageUri, DomainName, CertificateArn, AllowedCidr]
      - Label: {default: Size and safety}
        Parameters: [TaskCpu, TaskMemory, DbInstanceClass, DbMultiAz, DbDeletionProtection, BackupDays]
      - Label: {default: AWS Marketplace}
        Parameters: [MarketplaceSku]
    ParameterLabels:
      ImageUri: {default: Container image}
      DomainName: {default: Address people open}
      CertificateArn: {default: HTTPS certificate (ACM)}
      AllowedCidr: {default: Who may reach it}
      MarketplaceSku: {default: Marketplace product (set by the listing)}

Parameters:
  ImageUri:
    Type: String
    Description: the EagleDesk image - from AWS Marketplace (the listing's Launch page) or your own repository, e.g. 123456789012.dkr.ecr.us-east-1.amazonaws.com/eagledesk:<tag>
  DomainName:
    Type: String
    Description: the address people open, e.g. eagledesk.yourcompany.com (point it at the LoadBalancerDns output with a CNAME)
    AllowedPattern: "^[a-z0-9.-]+\\.[a-z]{2,}$"
  CertificateArn:
    Type: String
    Description: an issued ACM certificate for that address, in this region
    AllowedPattern: "^arn:aws[a-z-]*:acm:.+"
  AllowedCidr:
    Type: String
    Default: 0.0.0.0/0
    Description: addresses allowed to open EagleDesk (e.g. the company's VPN range); 0.0.0.0/0 = anyone, who still has to sign in
  TaskCpu:
    Type: String
    Default: "1024"
    AllowedValues: ["1024", "2048", "4096"]
    Description: 1024 = 1 vCPU
  TaskMemory:
    Type: String
    Default: "3072"
    AllowedValues: ["2048", "3072", "4096", "8192", "16384"]
    Description: MB of memory (the Salesforce CLI and the AI need about 2 GB)
  DbInstanceClass:
    Type: String
    Default: db.t4g.micro
    AllowedValues: [db.t4g.micro, db.t4g.small, db.t4g.medium, db.m7g.large]
  DbMultiAz:
    Type: String
    Default: "false"
    AllowedValues: ["true", "false"]
    Description: a standby copy in a second zone (about twice the database cost)
  DbDeletionProtection:
    Type: String
    Default: "false"
    AllowedValues: ["true", "false"]
    Description: '"true" for real use - the database can then not be deleted with the stack'
  BackupDays:
    Type: Number
    Default: 90
    MinValue: 1
    Description: how long backups are kept in the S3 bucket
  MarketplaceSku:
    Type: String
    Default: ""
    Description: leave empty - the AWS Marketplace image already knows its product and checks the buyer's contract in AWS License Manager. (Only for testing a contract with a non-Marketplace image.)

Conditions:
  MultiAz: !Equals [!Ref DbMultiAz, "true"]
  HasMarketplaceSku: !Not [!Equals [!Ref MarketplaceSku, ""]]

Resources:
  # ---------------------------------------------------------------- network: 2 public subnets (load balancer, the task) and 2 private ones (database, files)
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.80.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: [{Key: Name, Value: !Ref AWS::StackName}]
  Igw:
    Type: AWS::EC2::InternetGateway
  IgwAttach:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties: {VpcId: !Ref Vpc, InternetGatewayId: !Ref Igw}
  PublicA:
    Type: AWS::EC2::Subnet
    Properties: {VpcId: !Ref Vpc, CidrBlock: 10.80.0.0/24, AvailabilityZone: !Select [0, !GetAZs ""], Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-public-a"}]}
  PublicB:
    Type: AWS::EC2::Subnet
    Properties: {VpcId: !Ref Vpc, CidrBlock: 10.80.1.0/24, AvailabilityZone: !Select [1, !GetAZs ""], Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-public-b"}]}
  PrivateA:
    Type: AWS::EC2::Subnet
    Properties: {VpcId: !Ref Vpc, CidrBlock: 10.80.10.0/24, AvailabilityZone: !Select [0, !GetAZs ""], Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-private-a"}]}
  PrivateB:
    Type: AWS::EC2::Subnet
    Properties: {VpcId: !Ref Vpc, CidrBlock: 10.80.11.0/24, AvailabilityZone: !Select [1, !GetAZs ""], Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-private-b"}]}
  PublicRoutes:
    Type: AWS::EC2::RouteTable
    Properties: {VpcId: !Ref Vpc}
  PublicDefault:
    Type: AWS::EC2::Route
    DependsOn: IgwAttach
    Properties: {RouteTableId: !Ref PublicRoutes, DestinationCidrBlock: 0.0.0.0/0, GatewayId: !Ref Igw}
  PublicARoutes:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: {SubnetId: !Ref PublicA, RouteTableId: !Ref PublicRoutes}
  PublicBRoutes:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: {SubnetId: !Ref PublicB, RouteTableId: !Ref PublicRoutes}
  PrivateRoutes:  # no way out to the internet: only the VPC
    Type: AWS::EC2::RouteTable
    Properties: {VpcId: !Ref Vpc}
  PrivateARoutes:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: {SubnetId: !Ref PrivateA, RouteTableId: !Ref PrivateRoutes}
  PrivateBRoutes:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: {SubnetId: !Ref PrivateB, RouteTableId: !Ref PrivateRoutes}

  LbSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: EagleDesk load balancer - HTTPS from the allowed addresses
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - {IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: !Ref AllowedCidr}
        - {IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: !Ref AllowedCidr}
  AppSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: EagleDesk server - only from the load balancer
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - {IpProtocol: tcp, FromPort: 8765, ToPort: 8765, SourceSecurityGroupId: !Ref LbSg}
  DbSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: EagleDesk database - only from the EagleDesk server
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - {IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref AppSg}
  FilesSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: EagleDesk files (EFS) - only from the EagleDesk server
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - {IpProtocol: tcp, FromPort: 2049, ToPort: 2049, SourceSecurityGroupId: !Ref AppSg}

  # ---------------------------------------------------------------- database: PostgreSQL on RDS, encrypted, password in Secrets Manager
  DbSubnets:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: EagleDesk database subnets (private)
      SubnetIds: [!Ref PrivateA, !Ref PrivateB]
  Database:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Snapshot
    UpdateReplacePolicy: Snapshot
    Properties:
      Engine: postgres
      EngineVersion: "17"
      DBInstanceClass: !Ref DbInstanceClass
      AllocatedStorage: "20"
      MaxAllocatedStorage: 200
      StorageType: gp3
      StorageEncrypted: true
      DBName: eagledesk
      MasterUsername: eagledesk
      ManageMasterUserPassword: true
      MultiAZ: !If [MultiAz, true, false]
      PubliclyAccessible: false
      DBSubnetGroupName: !Ref DbSubnets
      VPCSecurityGroups: [!Ref DbSg]
      BackupRetentionPeriod: 7
      CopyTagsToSnapshot: true
      DeletionProtection: !Ref DbDeletionProtection
      AutoMinorVersionUpgrade: true
      EnablePerformanceInsights: false

  # ---------------------------------------------------------------- working files: EFS, encrypted, one access point as the container's user
  Files:
    Type: AWS::EFS::FileSystem
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      Encrypted: true
      PerformanceMode: generalPurpose
      ThroughputMode: elastic
      BackupPolicy: {Status: ENABLED}
      LifecyclePolicies: [{TransitionToIA: AFTER_30_DAYS}]
      FileSystemTags: [{Key: Name, Value: !Sub "${AWS::StackName}-files"}]
  FilesMountA:
    Type: AWS::EFS::MountTarget
    Properties: {FileSystemId: !Ref Files, SubnetId: !Ref PrivateA, SecurityGroups: [!Ref FilesSg]}
  FilesMountB:
    Type: AWS::EFS::MountTarget
    Properties: {FileSystemId: !Ref Files, SubnetId: !Ref PrivateB, SecurityGroups: [!Ref FilesSg]}
  FilesAccess:
    Type: AWS::EFS::AccessPoint
    Properties:
      FileSystemId: !Ref Files
      PosixUser: {Uid: "10001", Gid: "10001"}
      RootDirectory:
        Path: /eagledesk
        CreationInfo: {OwnerUid: "10001", OwnerGid: "10001", Permissions: "750"}

  # ---------------------------------------------------------------- backups: S3, versioned, encrypted, never public
  Backups:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      VersioningConfiguration: {Status: Enabled}
      BucketEncryption:
        ServerSideEncryptionConfiguration: [{ServerSideEncryptionByDefault: {SSEAlgorithm: AES256}}]
      PublicAccessBlockConfiguration: {BlockPublicAcls: true, BlockPublicPolicy: true, IgnorePublicAcls: true, RestrictPublicBuckets: true}
      OwnershipControls: {Rules: [{ObjectOwnership: BucketOwnerEnforced}]}
      LifecycleConfiguration:
        Rules:
          - Id: keep-backups
            Status: Enabled
            Prefix: eagledesk/
            ExpirationInDays: !Ref BackupDays
            NoncurrentVersionExpiration: {NoncurrentDays: 30}
  BackupsTlsOnly:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref Backups
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Deny
            Principal: "*"
            Action: "s3:*"
            Resource: [!GetAtt Backups.Arn, !Sub "${Backups.Arn}/*"]
            Condition: {Bool: {aws:SecureTransport: "false"}}

  # ---------------------------------------------------------------- the server: ECS Fargate, one task
  Logs:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/eagledesk/${AWS::StackName}"
      RetentionInDays: 90
  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterSettings: [{Name: containerInsights, Value: disabled}]
  ExecutionRole:  # ECS itself: pull the image, write logs, read the database password
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement: [{Effect: Allow, Principal: {Service: ecs-tasks.amazonaws.com}, Action: sts:AssumeRole}]
      ManagedPolicyArns: [!Sub "arn:${AWS::Partition}:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"]
      Policies:
        - PolicyName: database-password
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: secretsmanager:GetSecretValue
                Resource: !GetAtt Database.MasterUserSecret.SecretArn
  TaskRole:  # EagleDesk: the AI on Bedrock, backups in the bucket, its files
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement: [{Effect: Allow, Principal: {Service: ecs-tasks.amazonaws.com}, Action: sts:AssumeRole}]
      Policies:
        - PolicyName: eagledesk
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Sid: Bedrock
                Effect: Allow
                Action: [bedrock:InvokeModel, bedrock:InvokeModelWithResponseStream, bedrock:ListInferenceProfiles, bedrock:GetInferenceProfile]
                Resource: "*"
              - Sid: BackupsList
                Effect: Allow
                Action: s3:ListBucket
                Resource: !GetAtt Backups.Arn
              - Sid: Backups
                Effect: Allow
                Action: [s3:PutObject, s3:GetObject]
                Resource: !Sub "${Backups.Arn}/eagledesk/*"
              - Sid: Files
                Effect: Allow
                Action: [elasticfilesystem:ClientMount, elasticfilesystem:ClientWrite]
                Resource: !GetAtt Files.Arn
                Condition: {StringEquals: {elasticfilesystem:AccessPointArn: !GetAtt FilesAccess.Arn}}
              - Sid: MarketplaceLicense  # the buyer's EagleDesk contract (read it, and record that it is in use)
                Effect: Allow
                Action: [license-manager:ListReceivedLicenses, license-manager:CheckoutLicense, license-manager:CheckInLicense,
                         license-manager:ExtendLicenseConsumption, license-manager:GetLicense]
                Resource: "*"
  Task:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "${AWS::StackName}-eagledesk"
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: !Ref TaskCpu
      Memory: !Ref TaskMemory
      RuntimePlatform: {OperatingSystemFamily: LINUX, CpuArchitecture: X86_64}
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      Volumes:
        - Name: data
          EFSVolumeConfiguration:
            FilesystemId: !Ref Files
            TransitEncryption: ENABLED
            AuthorizationConfig: {AccessPointId: !Ref FilesAccess, IAM: ENABLED}
      ContainerDefinitions:
        - Name: eagledesk
          Image: !Ref ImageUri
          Essential: true
          PortMappings: [{ContainerPort: 8765, Protocol: tcp}]
          MountPoints: [{SourceVolume: data, ContainerPath: /data}]
          Environment:
            - {Name: FIXDESK_PUBLIC_URL, Value: !Sub "https://${DomainName}"}
            - {Name: FIXDESK_DATABASE_URL, Value: !Sub "postgresql://eagledesk@${Database.Endpoint.Address}:${Database.Endpoint.Port}/eagledesk?sslmode=require"}
            - {Name: FIXDESK_BACKUP_S3, Value: !Sub "s3://${Backups}/eagledesk"}
            - {Name: AWS_REGION, Value: !Ref AWS::Region}
            - !If [HasMarketplaceSku, {Name: FIXDESK_MARKETPLACE_SKU, Value: !Ref MarketplaceSku}, !Ref AWS::NoValue]  # empty: the image's own
          Secrets:
            - {Name: FIXDESK_DATABASE_PASSWORD, ValueFrom: !Sub "${Database.MasterUserSecret.SecretArn}:password::"}
          LogConfiguration:
            LogDriver: awslogs
            Options: {awslogs-group: !Ref Logs, awslogs-region: !Ref AWS::Region, awslogs-stream-prefix: eagledesk}

  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Scheme: internet-facing
      Type: application
      Subnets: [!Ref PublicA, !Ref PublicB]
      SecurityGroups: [!Ref LbSg]
      LoadBalancerAttributes:
        - {Key: idle_timeout.timeout_seconds, Value: "600"}  # the AI's live updates stay open
        - {Key: routing.http.drop_invalid_header_fields.enabled, Value: "true"}
  Targets:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      VpcId: !Ref Vpc
      TargetType: ip
      Protocol: HTTP
      Port: 8765
      HealthCheckPath: /api/version
      HealthCheckIntervalSeconds: 15
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 4
      TargetGroupAttributes: [{Key: deregistration_delay.timeout_seconds, Value: "20"}]
  Https:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Port: 443
      Protocol: HTTPS
      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
      Certificates: [{CertificateArn: !Ref CertificateArn}]
      DefaultActions: [{Type: forward, TargetGroupArn: !Ref Targets}]
  HttpToHttps:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Port: 80
      Protocol: HTTP
      DefaultActions: [{Type: redirect, RedirectConfig: {Protocol: HTTPS, Port: "443", StatusCode: HTTP_301}}]

  Service:
    Type: AWS::ECS::Service
    DependsOn: [Https, FilesMountA, FilesMountB]
    Properties:
      Cluster: !Ref Cluster
      TaskDefinition: !Ref Task
      LaunchType: FARGATE
      DesiredCount: 1
      DeploymentConfiguration:  # never two EagleDesk servers at once on the same data
        MinimumHealthyPercent: 0
        MaximumPercent: 100
        DeploymentCircuitBreaker: {Enable: true, Rollback: true}
      HealthCheckGracePeriodSeconds: 180
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED  # its way out to Salesforce, Bedrock and S3 (no NAT gateway to pay for); no way in but the load balancer
          Subnets: [!Ref PublicA, !Ref PublicB]
          SecurityGroups: [!Ref AppSg]
      LoadBalancers: [{ContainerName: eagledesk, ContainerPort: 8765, TargetGroupArn: !Ref Targets}]

Outputs:
  Url:
    Description: EagleDesk, once the CNAME below is in place
    Value: !Sub "https://${DomainName}"
  LoadBalancerDns:
    Description: point DomainName at this with a CNAME record (in Cloudflare - DNS only, grey cloud)
    Value: !GetAtt LoadBalancer.DNSName
  SalesforceCallback:
    Description: the callback URL for the company's Salesforce External Client App
    Value: !Sub "https://${DomainName}/sf/callback"
  SsoRedirect:
    Description: the sign-in redirect URI for the company's identity provider
    Value: !Sub "https://${DomainName}/sso/callback"
  SetupCodeWhere:
    Description: the first administrator's setup code is in this log group ("Setup code for the first administrator")
    Value: !Ref Logs
  BackupBucket:
    Value: !Ref Backups
  DatabaseEndpoint:
    Value: !GetAtt Database.Endpoint.Address
