AWSTemplateFormatVersion: "2010-09-09"
Description: >-
  ALOS for AWS (AWS Marketplace): the ALOS app in your AWS account (portable mode, docs/aws.md) on ECS Fargate, its
  SQLite database and files on encrypted EFS, HTTPS at a load balancer, generated secrets in Secrets Manager, and a
  yearly licence checked with AWS License Manager. Optional: ALOS Mediate (its own Marketplace listing) as an
  advisory pre-check on agent actions, from your model package subscription, reachable only with the app's role.
  Identity, governance metadata and the agent registry stay on the ALOS platform (ALOS_API_URL); records, files, the
  audit trail and Mediate's decisions stay in this account. One copy only: no high availability yet.

# ALOS for AWS 1.0.0, as linked from the AWS Marketplace listing (product prod-vgjwh4i6thkyw). Generated from
# deploy/aws/alos-for-aws.yaml with its two placeholders filled in: ImageUri is the 1.0.0 image by digest in AWS
# Marketplace's registry, and MarketplaceProductSku is the listing's product id (to be confirmed by the test
# subscription: AWS Marketplace documents License Manager's ProductSKU as the product's "Product ID"; the buyer
# account's ListReceivedLicenses shows the value the licence carries). Change the source, then regenerate this file.
#
# Source: TEKIMAX/alos-v4 deploy/aws/alos-for-aws.yaml. The app's network, storage, load balancer, service and alarms
# are the same blocks as deploy/aws/alos-app.yaml (scripts/aws-templates.test.mjs keeps them identical); Mediate's
# are deploy/aws/mediate/gateway/mediate-gateway.yaml's, in IAM mode only. One file, no nested stacks, so the
# Marketplace listing ships a single template.

Parameters:
  # ---------------------------------------------------------------- where ALOS answers
  PublicUrl:
    Type: String
    Description: The address people reach ALOS at, e.g. https://alos.example.com. Must match the certificate's name. For a first look without a domain, leave CertificateArn empty and set this to http://<LoadBalancerDns> after the stack is up (update the stack); sign-in stays off over http.
    AllowedPattern: "^https?://[^/]+$"
  CertificateArn:
    Type: String
    Default: ""
    Description: ACM certificate for PublicUrl's host, in this region. Empty = HTTP only (evaluation; sign-in cannot work).
  TlsMode:
    Type: String
    Default: standard
    AllowedValues: [standard, strict, fips]
    Description: >-
      HTTPS policy (only with a certificate). All three use post-quantum key exchange (hybrid ML-KEM) with every client
      that offers it. standard = TLS 1.3, or TLS 1.2 with restricted ciphers for older clients (AWS's recommended
      policy). strict = TLS 1.3 only; old clients and some inspecting proxies cannot connect. fips = standard with
      FIPS 140-3 validated cryptography.

  # ---------------------------------------------------------------- the app
  ImageUri:
    Type: String
    Default: 709825985650.dkr.ecr.us-east-1.amazonaws.com/tekimax/tekimax-alos-for-aws@sha256:a585c3084b9bef6ee8651d8856353c57b2165fbb940e1832e386263fdc64a9ac
    Description: Leave as is. The ALOS app image in AWS Marketplace's registry, set by TEKIMAX for this listing version.
  Cpu:
    Type: Number
    Default: 1024
    AllowedValues: [512, 1024, 2048, 4096]
  MemoryMiB:
    Type: Number
    Default: 2048
    AllowedValues: [1024, 2048, 4096, 8192]
  SettingsSecretArn:
    Type: String
    Default: ""
    Description: >-
      Leave empty on a first install: the stack generates the cookie password, redaction key and audit seal key, each
      in its own secret, kept if the stack is deleted. To reinstall over a kept data volume, give a secret (JSON) with
      WORKOS_COOKIE_PASSWORD, REDACTION_KEY and AUDIT_SEAL_HMAC_KEY holding the old values, or old redactions and
      checkpoint signatures cannot be read.
  AuditSignAlg:
    Type: String
    Default: hmac-sha256
    AllowedValues: [none, hmac-sha256]
    Description: Audit checkpoint signatures. hmac-sha256 signs each checkpoint with this deployment's own generated key, so a checkpoint holds against the database alone; each is still timestamped by the external authority. none signs nothing (the seal still runs).

  # ---------------------------------------------------------------- ALOS Mediate (optional)
  MediateModelPackageArn:
    Type: String
    Default: ""
    Description: >-
      Optional. Your ALOS Mediate model package ARN for this Region, from its own AWS Marketplace subscription. Empty =
      no Mediate. Set, the stack runs Mediate here (a SageMaker endpoint and a gateway that only this app's role may
      call) and ALOS asks it before an agent makes a change on its own: anything but "proceed" waits for a person.
  MediateInstanceType:
    Type: String
    Default: ml.g6.xlarge
    AllowedValues: [ml.g6.xlarge, ml.g6.2xlarge, ml.g6.4xlarge, ml.g6e.xlarge, ml.g6e.2xlarge, ml.m5.xlarge, ml.m5.2xlarge, ml.m5.4xlarge, ml.c5.2xlarge, ml.c5.4xlarge]
    Description: GPU (ml.g6*) to check agent actions live (under 100 ms each through the API); CPU (ml.m5/ml.c5) for testing (seconds each).
  MediateMinInstances:
    Type: Number
    Default: 1
    MinValue: 1
    MaxValue: 20
    Description: Instances always running. Use 2 or more in production. Your SageMaker quota for the instance type ("<type> for endpoint usage") must allow MediateMaxInstances.
  MediateMaxInstances:
    Type: Number
    Default: 4
    MinValue: 1
    MaxValue: 40
    Description: Upper limit for automatic scale-out under load. One ml.g6.xlarge handled about 50 decisions per second in TEKIMAX's load test.
  MediateThrottleRatePerSecond:
    Type: Number
    Default: 100
    MinValue: 1
    Description: Steady requests per second the Mediate API accepts; above it callers get HTTP 429.
  MediateLatencyAlarmMs:
    Type: Number
    Default: 2000
    MinValue: 100
    Description: Alert when the slowest 10% of Mediate decisions take longer than this (milliseconds, inside the model) for 5 minutes.
  MediateCodeBucket:
    Type: String
    Default: tekimax-mediate-downloads
    Description: Leave as is. TEKIMAX's bucket with the Mediate gateway code. It is in us-east-1, and Lambda reads code from its own Region, so Mediate deploys in us-east-1 unless TEKIMAX gives you a bucket for your Region.
  MediateCodeKey:
    Type: String
    Default: mediate-gateway/1.2.0/mediate-gateway.zip
    Description: Leave as is.

  # ---------------------------------------------------------------- licence and platform (leave as is)
  MarketplaceProductSku:
    Type: String
    Default: prod-vgjwh4i6thkyw
    Description: Leave as is. The listing's product id, which the app checks the licence against with AWS License Manager. Empty = no licence check.
  MarketplaceEntitlement:
    Type: String
    Default: organization
    Description: Leave as is. The listing's pricing dimension (its API name).
  MarketplaceEntitlementUnit:
    Type: String
    Default: Count
    AllowedValues: [Count, None]
    Description: Leave as is. Count for a quantity dimension (one organization), None for a tier.
  AlosApiUrl:
    Type: String
    Default: https://alos.tekimax.ai/api/v1
    Description: The ALOS platform API this deployment is governed by.
  WorkosClientId:
    Type: String
    Default: client_01KTD8WBQ93V72NFBGW4B7BZBR
    Description: The sign-in client (public). The deployment's https callback must be registered with it (send TEKIMAX the address).
  WorkosIssuerHosts:
    Type: String
    Default: api.tekimax.ai
  WorkosAuthkitHost:
    Type: String
    Default: signin.tekimax.ai

  # ---------------------------------------------------------------- operations
  AlarmEmail:
    Type: String
    Default: ""
    Description: Optional. Who is told when ALOS (or Mediate) is unhealthy, answering errors, or failing closed.
  LogRetentionDays:
    Type: Number
    Default: 365
    AllowedValues: [30, 90, 180, 365, 731, 1827, 3653]
  VpcCidr:
    Type: String
    Default: 10.42.0.0/16

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: {default: Where ALOS answers}
        Parameters: [PublicUrl, CertificateArn, TlsMode]
      - Label: {default: ALOS app}
        Parameters: [Cpu, MemoryMiB, AuditSignAlg, SettingsSecretArn]
      - Label: {default: "ALOS Mediate (optional, needs its own subscription)"}
        Parameters: [MediateModelPackageArn, MediateInstanceType, MediateMinInstances, MediateMaxInstances, MediateThrottleRatePerSecond, MediateLatencyAlarmMs]
      - Label: {default: Operations}
        Parameters: [AlarmEmail, LogRetentionDays, VpcCidr]
      - Label: {default: Listing and platform (leave as is)}
        Parameters: [ImageUri, MarketplaceProductSku, MarketplaceEntitlement, MarketplaceEntitlementUnit, AlosApiUrl, WorkosClientId, WorkosIssuerHosts, WorkosAuthkitHost, MediateCodeBucket, MediateCodeKey]
    ParameterLabels:
      PublicUrl: {default: ALOS address}
      CertificateArn: {default: Certificate (ACM ARN)}
      TlsMode: {default: HTTPS policy}
      Cpu: {default: CPU units}
      MemoryMiB: {default: Memory (MiB)}
      AuditSignAlg: {default: Audit checkpoint signature}
      SettingsSecretArn: {default: Existing settings secret (reinstall only)}
      MediateModelPackageArn: {default: Mediate model package ARN}
      MediateInstanceType: {default: Mediate instance type}
      MediateMinInstances: {default: Mediate minimum instances}
      MediateMaxInstances: {default: Mediate maximum instances}
      MediateThrottleRatePerSecond: {default: Mediate requests per second}
      MediateLatencyAlarmMs: {default: Mediate slow decision alarm (ms)}
      AlarmEmail: {default: Alarm email}
      LogRetentionDays: {default: Log retention (days)}
      VpcCidr: {default: Network range (CIDR)}
      ImageUri: {default: App image}
      MarketplaceProductSku: {default: Listing product id}
      MarketplaceEntitlement: {default: Listing pricing dimension}
      MarketplaceEntitlementUnit: {default: Dimension unit}
      AlosApiUrl: {default: ALOS platform API}
      WorkosClientId: {default: Sign-in client}
      WorkosIssuerHosts: {default: Sign-in issuer hosts}
      WorkosAuthkitHost: {default: Sign-in page host}
      MediateCodeBucket: {default: Mediate code bucket}
      MediateCodeKey: {default: Mediate code key}

Rules:
  ListingFilledIn:
    Assertions:
      - Assert: !Not [!Equals [!Ref ImageUri, MARKETPLACE_IMAGE_URI]]
        AssertDescription: The app image is still the placeholder. Launch ALOS from its AWS Marketplace listing, which fills it in.
      - Assert: !Not [!Equals [!Ref MarketplaceProductSku, MARKETPLACE_PRODUCT_SKU]]
        AssertDescription: The listing's product id is still the placeholder. Launch ALOS from its AWS Marketplace listing, which fills it in.

Mappings:
  # Load balancer security policies (docs.aws.amazon.com/elasticloadbalancing/latest/application/describe-ssl-policies.html).
  TlsPolicy:
    standard: {Name: ELBSecurityPolicy-TLS13-1-2-Res-PQ-2025-09}
    strict: {Name: ELBSecurityPolicy-TLS13-1-3-PQ-2025-09}
    fips: {Name: ELBSecurityPolicy-TLS13-1-2-Res-FIPS-PQ-2025-09}
  # Mediate scales out when an instance averages this many decisions per minute: about 60% of what one instance
  # sustained in TEKIMAX's load test (deploy/aws/mediate/gateway/mediate-gateway.yaml).
  MediateScaleTarget:
    ml.g6.xlarge: {PerMinute: 1800}
    ml.g6.2xlarge: {PerMinute: 1800}
    ml.g6.4xlarge: {PerMinute: 1800}
    ml.g6e.xlarge: {PerMinute: 2400}
    ml.g6e.2xlarge: {PerMinute: 2400}
    ml.m5.xlarge: {PerMinute: 25}
    ml.m5.2xlarge: {PerMinute: 50}
    ml.m5.4xlarge: {PerMinute: 90}
    ml.c5.2xlarge: {PerMinute: 50}
    ml.c5.4xlarge: {PerMinute: 90}

Conditions:
  HasCertificate: !Not [!Equals [!Ref CertificateArn, ""]]
  NoCertificate: !Equals [!Ref CertificateArn, ""]
  HasAlarmEmail: !Not [!Equals [!Ref AlarmEmail, ""]]
  GenerateSettings: !Equals [!Ref SettingsSecretArn, ""]
  HasMediate: !Not [!Equals [!Ref MediateModelPackageArn, ""]]
  ChecksLicence: !Not [!Equals [!Ref MarketplaceProductSku, ""]]

Resources:
  # ---------------------------------------------------------------- network: two public subnets, no NAT
  # The task needs outbound HTTPS (the ALOS API, sign-in, the image registry). A public IP on the task is the
  # cheapest way; its security group admits only the load balancer, so nothing reaches it directly.
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: [{Key: Name, Value: !Sub "${AWS::StackName}-alos"}]

  InternetGateway:
    Type: AWS::EC2::InternetGateway
  GatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties: {VpcId: !Ref Vpc, InternetGatewayId: !Ref InternetGateway}

  SubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !Select [0, !Cidr [!Ref VpcCidr, 4, 12]]
      MapPublicIpOnLaunch: false
  SubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref Vpc
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !Select [1, !Cidr [!Ref VpcCidr, 4, 12]]
      MapPublicIpOnLaunch: false

  RouteTable:
    Type: AWS::EC2::RouteTable
    Properties: {VpcId: !Ref Vpc}
  DefaultRoute:
    Type: AWS::EC2::Route
    DependsOn: GatewayAttachment
    Properties: {RouteTableId: !Ref RouteTable, DestinationCidrBlock: 0.0.0.0/0, GatewayId: !Ref InternetGateway}
  SubnetARoutes:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: {SubnetId: !Ref SubnetA, RouteTableId: !Ref RouteTable}
  SubnetBRoutes:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties: {SubnetId: !Ref SubnetB, RouteTableId: !Ref RouteTable}

  LoadBalancerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ALOS load balancer
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - {IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0}
        - {IpProtocol: tcp, FromPort: 80, ToPort: 80, CidrIp: 0.0.0.0/0}

  AppSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ALOS app - reachable only from its load balancer
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - {IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !Ref LoadBalancerSecurityGroup}

  StorageSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: ALOS data volume - reachable only from the app
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - {IpProtocol: tcp, FromPort: 2049, ToPort: 2049, SourceSecurityGroupId: !Ref AppSecurityGroup}

  # ---------------------------------------------------------------- data: encrypted EFS, backed up daily
  # Holds alos.sqlite (and its WAL files) and the blob folders. Retained if the stack is deleted: a customer's
  # records are never removed by an infrastructure change.
  DataVolume:
    Type: AWS::EFS::FileSystem
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      Encrypted: true
      PerformanceMode: generalPurpose
      ThroughputMode: elastic
      BackupPolicy: {Status: ENABLED}
      FileSystemTags: [{Key: Name, Value: !Sub "${AWS::StackName}-alos-data"}]

  DataMountA:
    Type: AWS::EFS::MountTarget
    Properties: {FileSystemId: !Ref DataVolume, SubnetId: !Ref SubnetA, SecurityGroups: [!Ref StorageSecurityGroup]}
  DataMountB:
    Type: AWS::EFS::MountTarget
    Properties: {FileSystemId: !Ref DataVolume, SubnetId: !Ref SubnetB, SecurityGroups: [!Ref StorageSecurityGroup]}

  # The image runs as `node` (uid 1000); the access point gives it /data as its own and nothing else.
  DataAccessPoint:
    Type: AWS::EFS::AccessPoint
    Properties:
      FileSystemId: !Ref DataVolume
      PosixUser: {Uid: "1000", Gid: "1000"}
      RootDirectory:
        Path: /alos
        CreationInfo: {OwnerUid: "1000", OwnerGid: "1000", Permissions: "700"}

  # ---------------------------------------------------------------- load balancer
  LoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Scheme: internet-facing
      Type: application
      Subnets: [!Ref SubnetA, !Ref SubnetB]
      SecurityGroups: [!Ref LoadBalancerSecurityGroup]
      LoadBalancerAttributes:
        - {Key: idle_timeout.timeout_seconds, Value: "300"}
        - {Key: routing.http.drop_invalid_header_fields.enabled, Value: "true"}

  TargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      VpcId: !Ref Vpc
      TargetType: ip
      Protocol: HTTP
      Port: 8080
      HealthCheckPath: /api/health
      HealthCheckIntervalSeconds: 15
      HealthyThresholdCount: 2
      UnhealthyThresholdCount: 4
      TargetGroupAttributes:
        - {Key: deregistration_delay.timeout_seconds, Value: "20"}

  HttpsListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Condition: HasCertificate
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Protocol: HTTPS
      Port: 443
      SslPolicy: !FindInMap [TlsPolicy, !Ref TlsMode, Name]
      Certificates: [{CertificateArn: !Ref CertificateArn}]
      DefaultActions: [{Type: forward, TargetGroupArn: !Ref TargetGroup}]

  HttpRedirectListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Condition: HasCertificate
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Protocol: HTTP
      Port: 80
      DefaultActions:
        - Type: redirect
          RedirectConfig: {Protocol: HTTPS, Port: "443", StatusCode: HTTP_301}

  # Evaluation only: with no certificate the app is served over plain HTTP, and sign-in stays off.
  HttpListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Condition: NoCertificate
    Properties:
      LoadBalancerArn: !Ref LoadBalancer
      Protocol: HTTP
      Port: 80
      DefaultActions: [{Type: forward, TargetGroupArn: !Ref TargetGroup}]

  # ---------------------------------------------------------------- the app
  LogGroup:
    Type: AWS::Logs::LogGroup
    Properties: {RetentionInDays: !Ref LogRetentionDays}

  Cluster:
    Type: AWS::ECS::Cluster
    Properties:
      ClusterSettings: [{Name: containerInsights, Value: enabled}]

  # ---------------------------------------------------------------- settings: generated here, never typed in
  # Three secrets, one generated value each (CloudFormation generates one key per secret). Kept if the stack is
  # deleted, like the data volume: the redaction key reads old redactions and the seal key checks old checkpoints.
  CookieSecret:
    Type: AWS::SecretsManager::Secret
    Condition: GenerateSettings
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      Description: ALOS session cookie password (generated)
      GenerateSecretString:
        SecretStringTemplate: "{}"
        GenerateStringKey: WORKOS_COOKIE_PASSWORD
        PasswordLength: 48
        ExcludePunctuation: true

  RedactionSecret:
    Type: AWS::SecretsManager::Secret
    Condition: GenerateSettings
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      Description: ALOS redaction key (generated; rotating it makes stored redactions unreadable)
      GenerateSecretString:
        SecretStringTemplate: "{}"
        GenerateStringKey: REDACTION_KEY
        PasswordLength: 48
        ExcludePunctuation: true

  SealSecret:
    Type: AWS::SecretsManager::Secret
    Condition: GenerateSettings
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      Description: ALOS audit seal key (generated; rotating it makes earlier checkpoint signatures uncheckable)
      GenerateSecretString:
        SecretStringTemplate: "{}"
        GenerateStringKey: AUDIT_SEAL_HMAC_KEY
        PasswordLength: 48
        ExcludePunctuation: true

  # Pulls the image, reads the settings secrets, writes logs. Nothing else.
  ExecutionRole:
    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: read-settings
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: secretsmanager:GetSecretValue
                Resource: !If [GenerateSettings, [!Ref CookieSecret, !Ref RedactionSecret, !Ref SealSecret], [!Ref SettingsSecretArn]]

  # What the running app may do in AWS: mount its own data volume through its own access point; check out and
  # return its own licence (License Manager has no resource-level permissions for these calls); and, with
  # Mediate, call that one gateway and nothing else.
  TaskRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement: [{Effect: Allow, Principal: {Service: ecs-tasks.amazonaws.com}, Action: sts:AssumeRole}]
      Policies:
        - PolicyName: own-data-volume
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: [elasticfilesystem:ClientMount, elasticfilesystem:ClientWrite]
                Resource: !GetAtt DataVolume.Arn
                Condition: {StringEquals: {"elasticfilesystem:AccessPointArn": !GetAtt DataAccessPoint.Arn}}
        - !If
          - ChecksLicence
          - PolicyName: marketplace-licence
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action: [license-manager:CheckoutLicense, license-manager:CheckInLicense, license-manager:GetLicense, license-manager:ExtendLicenseConsumption, license-manager:ListReceivedLicenses]
                  Resource: "*"
          - !Ref AWS::NoValue
        - !If
          - HasMediate
          - PolicyName: ask-mediate
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action: execute-api:Invoke
                  Resource: !Sub "arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${MediateApi}/*/POST/mcp"
          - !Ref AWS::NoValue

  TaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "${AWS::StackName}-alos"
      RequiresCompatibilities: [FARGATE]
      NetworkMode: awsvpc
      Cpu: !Ref Cpu
      Memory: !Ref MemoryMiB
      RuntimePlatform: {CpuArchitecture: X86_64, OperatingSystemFamily: LINUX}
      ExecutionRoleArn: !GetAtt ExecutionRole.Arn
      TaskRoleArn: !GetAtt TaskRole.Arn
      Volumes:
        - Name: data
          EFSVolumeConfiguration:
            FilesystemId: !Ref DataVolume
            TransitEncryption: ENABLED
            AuthorizationConfig: {AccessPointId: !Ref DataAccessPoint, IAM: ENABLED}
        - Name: tmp
      ContainerDefinitions:
        - Name: alos
          Image: !Ref ImageUri
          Essential: true
          ReadonlyRootFilesystem: true
          User: "1000:1000"
          PortMappings: [{ContainerPort: 8080, Protocol: tcp}]
          MountPoints:
            - {SourceVolume: data, ContainerPath: /data, ReadOnly: false}
            - {SourceVolume: tmp, ContainerPath: /tmp, ReadOnly: false}
          LinuxParameters:
            Capabilities: {Drop: [ALL]}
            InitProcessEnabled: true
          StopTimeout: 30
          Environment:
            - {Name: ALOS_PUBLIC_URL, Value: !Ref PublicUrl}
            - {Name: ALOS_TRUST_PROXY, Value: "1"}
            - {Name: ALOS_EMAIL, Value: "off"}
            - {Name: ALOS_CRON, Value: "1"}
            - {Name: ALOS_API_URL, Value: !Ref AlosApiUrl}
            - {Name: WORKOS_CLIENT_ID, Value: !Ref WorkosClientId}
            - {Name: WORKOS_ISSUER_HOSTS, Value: !Ref WorkosIssuerHosts}
            - {Name: WORKOS_AUTHKIT_HOST, Value: !Ref WorkosAuthkitHost}
            - {Name: AUDIT_SIGN_ALG, Value: !Ref AuditSignAlg}
            - {Name: DEV_OPEN, Value: "0"}
            # Sign-in without the identity provider's secret key (docs/portability.md), on HTTPS only: over plain
            # http (evaluation) sign-in stays off and every signed-in route answers 401.
            - {Name: WORKOS_KEYLESS, Value: !If [HasCertificate, "1", "0"]}
            # The licence check (worker/marketplace/licence.ts): empty SKU = no check.
            - {Name: AWS_REGION, Value: !Ref AWS::Region}
            - {Name: MARKETPLACE_PRODUCT_SKU, Value: !Ref MarketplaceProductSku}
            - {Name: MARKETPLACE_ENTITLEMENT, Value: !Ref MarketplaceEntitlement}
            - {Name: MARKETPLACE_ENTITLEMENT_UNIT, Value: !Ref MarketplaceEntitlementUnit}
            # Mediate as an advisory pre-check on agent changes (worker/mediate/advisor.ts): empty = not asked.
            - {Name: MEDIATE_API_URL, Value: !If [HasMediate, !Sub "https://${MediateApi}.execute-api.${AWS::Region}.amazonaws.com", ""]}
          Secrets:
            - {Name: WORKOS_COOKIE_PASSWORD, ValueFrom: !If [GenerateSettings, !Sub "${CookieSecret}:WORKOS_COOKIE_PASSWORD::", !Sub "${SettingsSecretArn}:WORKOS_COOKIE_PASSWORD::"]}
            - {Name: REDACTION_KEY, ValueFrom: !If [GenerateSettings, !Sub "${RedactionSecret}:REDACTION_KEY::", !Sub "${SettingsSecretArn}:REDACTION_KEY::"]}
            - {Name: AUDIT_SEAL_HMAC_KEY, ValueFrom: !If [GenerateSettings, !Sub "${SealSecret}:AUDIT_SEAL_HMAC_KEY::", !Sub "${SettingsSecretArn}:AUDIT_SEAL_HMAC_KEY::"]}
          HealthCheck:
            Command: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
            Interval: 30
            Timeout: 5
            Retries: 3
            StartPeriod: 60
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref LogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: alos

  # ECS refuses a target group until a listener joins it to the load balancer. Both listeners are conditional, so
  # the service waits on this handle, which names whichever one exists.
  ListenerReady:
    Type: AWS::CloudFormation::WaitConditionHandle
    Metadata:
      Listener: !If [HasCertificate, !Ref HttpsListener, !Ref HttpListener]

  # Exactly one copy, and never two at once: SQLite has one writer, and limits and jobs live in memory. A new
  # version stops the old task before starting the new one (a short gap during updates, by design).
  Service:
    Type: AWS::ECS::Service
    DependsOn: [DataMountA, DataMountB, ListenerReady]
    Properties:
      Cluster: !Ref Cluster
      LaunchType: FARGATE
      PlatformVersion: LATEST
      DesiredCount: 1
      DeploymentConfiguration:
        MinimumHealthyPercent: 0
        MaximumPercent: 100
        DeploymentCircuitBreaker: {Enable: true, Rollback: true}
      HealthCheckGracePeriodSeconds: 120
      EnableExecuteCommand: false
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: ENABLED
          Subnets: [!Ref SubnetA, !Ref SubnetB]
          SecurityGroups: [!Ref AppSecurityGroup]
      LoadBalancers: [{ContainerName: alos, ContainerPort: 8080, TargetGroupArn: !Ref TargetGroup}]
      TaskDefinition: !Ref TaskDefinition

  # ---------------------------------------------------------------- alerts
  AlarmTopic:
    Type: AWS::SNS::Topic
    Properties: {DisplayName: ALOS alerts}

  AlarmSubscription:
    Type: AWS::SNS::Subscription
    Condition: HasAlarmEmail
    Properties: {TopicArn: !Ref AlarmTopic, Protocol: email, Endpoint: !Ref AlarmEmail}

  UnhealthyAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmDescription: ALOS is not answering its health check.
      Namespace: AWS/ApplicationELB
      MetricName: HealthyHostCount
      Dimensions:
        - {Name: TargetGroup, Value: !GetAtt TargetGroup.TargetGroupFullName}
        - {Name: LoadBalancer, Value: !GetAtt LoadBalancer.LoadBalancerFullName}
      Statistic: Minimum
      Period: 60
      EvaluationPeriods: 5
      DatapointsToAlarm: 3
      Threshold: 1
      ComparisonOperator: LessThanThreshold
      TreatMissingData: breaching
      AlarmActions: [!Ref AlarmTopic]
      OKActions: [!Ref AlarmTopic]

  ServerErrorAlarm:
    Type: AWS::CloudWatch::Alarm
    Properties:
      AlarmDescription: ALOS is answering server errors (5xx).
      Namespace: AWS/ApplicationELB
      MetricName: HTTPCode_Target_5XX_Count
      Dimensions: [{Name: LoadBalancer, Value: !GetAtt LoadBalancer.LoadBalancerFullName}]
      Statistic: Sum
      Period: 60
      EvaluationPeriods: 5
      DatapointsToAlarm: 2
      Threshold: 5
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: notBreaching
      AlarmActions: [!Ref AlarmTopic]
      OKActions: [!Ref AlarmTopic]

  # ---------------------------------------------------------------- ALOS Mediate (only with MediateModelPackageArn)
  # deploy/aws/mediate/gateway/mediate-gateway.yaml in IAM mode: a network-isolated SageMaker endpoint from the
  # buyer's own model package subscription, the gateway Lambda, and an HTTP API whose every route needs SigV4. Only
  # the app's task role is granted execute-api:Invoke (TaskRole above). Advisory and fail-closed: an error or a
  # timeout answers escalate, and the app treats anything but proceed as "wait for a person".
  MediateModelRole:
    Type: AWS::IAM::Role
    Condition: HasMediate
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement: [{Effect: Allow, Principal: {Service: sagemaker.amazonaws.com}, Action: sts:AssumeRole}]
      Policies:
        - PolicyName: run
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: [logs:CreateLogGroup, logs:CreateLogStream, logs:PutLogEvents, logs:DescribeLogStreams]
                Resource: !Sub arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/sagemaker/*
              - Effect: Allow
                Action: cloudwatch:PutMetricData
                Resource: "*"
                Condition: {StringEquals: {"cloudwatch:namespace": ["/aws/sagemaker/Endpoints", "AWS/SageMaker"]}}

  MediateModel:
    Type: AWS::SageMaker::Model
    Condition: HasMediate
    Properties:
      ExecutionRoleArn: !GetAtt MediateModelRole.Arn
      EnableNetworkIsolation: true
      Containers:
        - ModelPackageName: !Ref MediateModelPackageArn

  MediateEndpointConfig:
    Type: AWS::SageMaker::EndpointConfig
    Condition: HasMediate
    Properties:
      ProductionVariants:
        - VariantName: main
          ModelName: !GetAtt MediateModel.ModelName
          InstanceType: !Ref MediateInstanceType
          InitialInstanceCount: !Ref MediateMinInstances
          InitialVariantWeight: 1
          ContainerStartupHealthCheckTimeoutInSeconds: 900
          ModelDataDownloadTimeoutInSeconds: 900

  MediateEndpoint:
    Type: AWS::SageMaker::Endpoint
    Condition: HasMediate
    Properties:
      EndpointConfigName: !GetAtt MediateEndpointConfig.EndpointConfigName
      RetainAllVariantProperties: true

  MediateScalableTarget:
    Type: AWS::ApplicationAutoScaling::ScalableTarget
    Condition: HasMediate
    Properties:
      ServiceNamespace: sagemaker
      ScalableDimension: sagemaker:variant:DesiredInstanceCount
      ResourceId: !Sub endpoint/${MediateEndpoint.EndpointName}/variant/main
      MinCapacity: !Ref MediateMinInstances
      MaxCapacity: !Ref MediateMaxInstances

  MediateScalingPolicy:
    Type: AWS::ApplicationAutoScaling::ScalingPolicy
    Condition: HasMediate
    Properties:
      PolicyName: !Sub ${AWS::StackName}-mediate-invocations
      PolicyType: TargetTrackingScaling
      ScalingTargetId: !Ref MediateScalableTarget
      TargetTrackingScalingPolicyConfiguration:
        PredefinedMetricSpecification: {PredefinedMetricType: SageMakerVariantInvocationsPerInstance}
        TargetValue: !FindInMap [MediateScaleTarget, !Ref MediateInstanceType, PerMinute]
        ScaleOutCooldown: 60
        ScaleInCooldown: 600

  MediateGatewayRole:
    Type: AWS::IAM::Role
    Condition: HasMediate
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement: [{Effect: Allow, Principal: {Service: lambda.amazonaws.com}, Action: sts:AssumeRole}]
      Policies:
        - PolicyName: invoke-mediate
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: [sagemaker:InvokeEndpoint, sagemaker:DescribeEndpoint]
                Resource: !Sub arn:${AWS::Partition}:sagemaker:${AWS::Region}:${AWS::AccountId}:endpoint/${MediateEndpoint.EndpointName}
              - Effect: Allow
                Action: [logs:CreateLogStream, logs:PutLogEvents]
                Resource: !GetAtt MediateGatewayLogGroup.Arn

  MediateGatewayLogGroup:
    Type: AWS::Logs::LogGroup
    Condition: HasMediate
    Properties:
      RetentionInDays: !Ref LogRetentionDays

  MediateGateway:
    Type: AWS::Lambda::Function
    Condition: HasMediate
    Properties:
      Description: ALOS Mediate API and MCP gateway (advisory, fails closed)
      Runtime: python3.12
      Handler: app.handler
      Architectures: [arm64]
      MemorySize: 256
      Timeout: 29
      Role: !GetAtt MediateGatewayRole.Arn
      Code: {S3Bucket: !Ref MediateCodeBucket, S3Key: !Ref MediateCodeKey}
      LoggingConfig: {LogGroup: !Ref MediateGatewayLogGroup}
      Environment:
        Variables:
          ENDPOINT_NAME: !GetAtt MediateEndpoint.EndpointName
          ENDPOINT_TIMEOUT_S: "12"

  MediateApi:
    Type: AWS::ApiGatewayV2::Api
    Condition: HasMediate
    Properties:
      Name: !Sub ${AWS::StackName}-mediate
      ProtocolType: HTTP
      Description: ALOS Mediate API and MCP endpoint (IAM sign-in)

  MediateIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Condition: HasMediate
    Properties:
      ApiId: !Ref MediateApi
      IntegrationType: AWS_PROXY
      IntegrationUri: !GetAtt MediateGateway.Arn
      PayloadFormatVersion: "2.0"
      TimeoutInMillis: 29000

  MediateDecideRoute:
    Type: AWS::ApiGatewayV2::Route
    Condition: HasMediate
    Properties:
      ApiId: !Ref MediateApi
      RouteKey: POST /v1/decide
      Target: !Sub integrations/${MediateIntegration}
      AuthorizationType: AWS_IAM

  MediateHealthRoute:
    Type: AWS::ApiGatewayV2::Route
    Condition: HasMediate
    Properties:
      ApiId: !Ref MediateApi
      RouteKey: GET /v1/health
      Target: !Sub integrations/${MediateIntegration}
      AuthorizationType: AWS_IAM

  MediateMcpRoute:
    Type: AWS::ApiGatewayV2::Route
    Condition: HasMediate
    Properties:
      ApiId: !Ref MediateApi
      RouteKey: POST /mcp
      Target: !Sub integrations/${MediateIntegration}
      AuthorizationType: AWS_IAM

  MediateAccessLogGroup:
    Type: AWS::Logs::LogGroup
    Condition: HasMediate
    Properties:
      RetentionInDays: !Ref LogRetentionDays

  MediateStage:
    Type: AWS::ApiGatewayV2::Stage
    Condition: HasMediate
    Properties:
      ApiId: !Ref MediateApi
      StageName: $default
      AutoDeploy: true
      DefaultRouteSettings:
        ThrottlingRateLimit: !Ref MediateThrottleRatePerSecond
        ThrottlingBurstLimit: 200
      AccessLogSettings:
        DestinationArn: !GetAtt MediateAccessLogGroup.Arn
        Format: '{"requestId":"$context.requestId","route":"$context.routeKey","status":"$context.status","caller":"$context.identity.userArn","ms":"$context.responseLatency"}'

  MediateInvokePermission:
    Type: AWS::Lambda::Permission
    Condition: HasMediate
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref MediateGateway
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${MediateApi}/*

  # The gateway logs error "unavailable" each time it answers escalate because the model did not answer.
  MediateFailClosedMetric:
    Type: AWS::Logs::MetricFilter
    Condition: HasMediate
    Properties:
      LogGroupName: !Ref MediateGatewayLogGroup
      FilterPattern: '{ $.error = "unavailable" }'
      MetricTransformations:
        - MetricNamespace: !Sub ALOS/Mediate/${AWS::StackName}
          MetricName: FailClosed
          MetricValue: "1"
          DefaultValue: 0

  MediateFailClosedAlarm:
    Type: AWS::CloudWatch::Alarm
    Condition: HasMediate
    Properties:
      AlarmDescription: Mediate answered escalate because the model did not answer (error or timeout). Agent changes are waiting for people.
      Namespace: !Sub ALOS/Mediate/${AWS::StackName}
      MetricName: FailClosed
      Statistic: Sum
      Period: 60
      EvaluationPeriods: 5
      DatapointsToAlarm: 2
      Threshold: 5
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: notBreaching
      AlarmActions: [!Ref AlarmTopic]
      OKActions: [!Ref AlarmTopic]

  MediateModelErrorAlarm:
    Type: AWS::CloudWatch::Alarm
    Condition: HasMediate
    Properties:
      AlarmDescription: The Mediate endpoint returned server errors (5xx).
      Namespace: AWS/SageMaker
      MetricName: Invocation5XXErrors
      Dimensions: [{Name: EndpointName, Value: !GetAtt MediateEndpoint.EndpointName}, {Name: VariantName, Value: main}]
      Statistic: Sum
      Period: 60
      EvaluationPeriods: 5
      DatapointsToAlarm: 2
      Threshold: 1
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: notBreaching
      AlarmActions: [!Ref AlarmTopic]
      OKActions: [!Ref AlarmTopic]

  MediateLatencyAlarm:
    Type: AWS::CloudWatch::Alarm
    Condition: HasMediate
    Properties:
      AlarmDescription: Mediate is slow; the slowest 10% of decisions are over the limit. Add instances or raise MediateMaxInstances.
      Namespace: AWS/SageMaker
      MetricName: ModelLatency
      Dimensions: [{Name: EndpointName, Value: !GetAtt MediateEndpoint.EndpointName}, {Name: VariantName, Value: main}]
      ExtendedStatistic: p90
      Period: 60
      EvaluationPeriods: 5
      DatapointsToAlarm: 5
      Threshold: !Sub "${MediateLatencyAlarmMs}000"
      ComparisonOperator: GreaterThanThreshold
      TreatMissingData: notBreaching
      AlarmActions: [!Ref AlarmTopic]
      OKActions: [!Ref AlarmTopic]

  MediateGatewayErrorAlarm:
    Type: AWS::CloudWatch::Alarm
    Condition: HasMediate
    Properties:
      AlarmDescription: The Mediate gateway function failed.
      Namespace: AWS/Lambda
      MetricName: Errors
      Dimensions: [{Name: FunctionName, Value: !Ref MediateGateway}]
      Statistic: Sum
      Period: 60
      EvaluationPeriods: 5
      DatapointsToAlarm: 2
      Threshold: 1
      ComparisonOperator: GreaterThanOrEqualToThreshold
      TreatMissingData: notBreaching
      AlarmActions: [!Ref AlarmTopic]
      OKActions: [!Ref AlarmTopic]

Outputs:
  AppUrl:
    Description: Where ALOS answers
    Value: !Ref PublicUrl
  LoadBalancerDns:
    Description: Point your domain's CNAME (or alias) here, then send TEKIMAX the https address so sign-in accepts it
    Value: !GetAtt LoadBalancer.DNSName
  DataVolumeId:
    Description: The EFS file system holding the database and files (kept if the stack is deleted)
    Value: !Ref DataVolume
  LogGroup:
    Value: !Ref LogGroup
  AlarmTopicArn:
    Description: Subscribe more people or a pager to these alerts
    Value: !Ref AlarmTopic
  TargetGroupArn:
    Description: The app's target group; its target health says whether ALOS is up behind HTTPS
    Value: !Ref TargetGroup
  MediateApiUrl:
    Condition: HasMediate
    Description: ALOS Mediate's API (IAM sign-in; only this stack's app role may call it). ALOS uses its /mcp address.
    Value: !Sub https://${MediateApi}.execute-api.${AWS::Region}.amazonaws.com
  MediateEndpointName:
    Condition: HasMediate
    Value: !GetAtt MediateEndpoint.EndpointName
