AWSTemplateFormatVersion: "2010-09-09"
Description: CloudFormation stack for https://runs-on.com
Transform: AWS::LanguageExtensions

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "Main configuration [required]"
        Parameters:
          - GithubOrganization
          - LicenseKey
          - EmailAddress
          - Environment
      - Label:
          default: "Networking and security [optional]"
        Parameters:
          - Private
          - Ipv6Enabled
          - SSHAllowed
          - SSHCidrRange
          - EnableWAF
          - EnableAdminRoutes
          - EncryptEbs
          - RunnerCustomPolicy
          - AppCustomPolicy
      - Label:
          default: "Runner behavior and storage [optional]"
        Parameters:
          - EnableBedrock
          - EnableEfs
          - EnableEphemeralRegistry
          - RunnerCustomTags
          - RunnerMaxRuntime
          - RunnerConfigAutoExtendsFrom
          - S3CacheExpirationInDays
      - Label:
          default: "App runtime and maintenance [optional]"
        Parameters:
          - AppSize
          - AppCapacityProvider
          - AppGithubApiStrategy
          - AppRegistry
          - LoggerLevel
          - SpotCircuitBreaker
          - MaintenanceMode
      - Label:
          default: "Observability, cost, and integrations [optional]"
        Parameters:
          - OtelExporterEndpoint
          - OtelExporterHeaders
          - OtelExporterTemporality
          - CostReportsEnabled
          - AlertTopicSlackWebhookUrl
          - AppBudgetDailyUsd
          - CostAllocationTag
          - IntegrationStepSecurityApiKey
Parameters:
  # Main configuration [required]
  GithubOrganization:
    Type: String
    Description: "If your GitHub organization lives at github.com/my-org, then enter: my-org. IMPORTANT: this field is case-sensitive."
    MinLength: 1

  LicenseKey:
    Type: String
    Description: |
      License key for RunsOn, either as a string or SSM parameter ARN (with the format `arn:aws:ssm:REGION:ACCOUNT:parameter/STACK_NAME/license-key`). Get one at https://runs-on.com/pricing.
    NoEcho: true
    MinLength: 1

  EmailAddress:
    Type: String
    Description: Email address for cost and alert reports. You will receive a confirmation email from AWS at this address during the installation (check your spam folder if needed) so make sure to use a valid address. You must confirm it if you want to receive cost and alert reports.
    MinLength: 1

  Environment:
    Type: String
    Default: "production"
    MinLength: 1
    Description: "Make RunsOn only listen to jobs with an `env` job label matching this value. Only change this if you know what you are doing. See https://runs-on.com/configuration/environments/ for more details."

  # Networking and security [optional]
  Private:
    Type: String
    Default: "false"
    AllowedValues:
      - "only"
      - "always"
      - "true"
      - "false"
    Description: "Enable ('true', 'always', 'only') or disable ('false') private networking. If 'true', your jobs can opt-in to launch in private subnets with the `private=true` label, and they will get a static egress IP. If 'always', all jobs will run in private subnets, and they will all get a static egress IP, unless you manually opt-out in your job definition with the `private=false` label. If 'only', then jobs can only launch in private subnets. Note that enabling it will create 1 managed NAT gateway, with the corresponding costs. More details at https://runs-on.com/networking/static-ips/."

  Ipv6Enabled:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
    Description: Enable or disable IPv6 for runners. Disabling ipv6 might help with Docker Hub rate limiting issues.

  SSHAllowed:
    Type: String
    Default: "true"
    AllowedValues:
      - "true"
      - "false"
    Description: Allow inbound SSH connections from the specified CIDR range to the runners.

  SSHCidrRange:
    Type: String
    Default: 0.0.0.0/0
    Description: CIDR range for inbound SSH access. By default, only repository collaborators with admin permission will be able to SSH into the runner instances.
    MinLength: 1

  EnableWAF:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
    Description: "Enable AWS WAF for the public ingress."

  EnableAdminRoutes:
    Type: String
    Default: "true"
    AllowedValues:
      - "true"
      - "false"
    Description: "Enable the admin Lambda routes (`/`, `/setup`, `/setup/{proxy+}`, `/readyz`) on the public ingress."

  EncryptEbs:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
    Description: "Encrypt the EBS volumes of the runner instances. Encryption will slow the boot time of runner instances by ~10s."

  RunnerCustomPolicy:
    Type: String
    Default: ""
    Description: "Optional managed IAM Policy ARN to assign to the EC2 runner instances. Can be used to give runners access to AWS resources that you manage."

  AppCustomPolicy:
    Type: String
    Default: ""
    Description: "Optional managed IAM Policy ARN to assign to the RunsOn service role. Can be used to e.g. allow access to KMS decryption keys for AMIs."

  # Runner behavior and storage [optional]
  EnableBedrock:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
    Description: "Enable Amazon Bedrock access for EC2 runner instances. When enabled, runners can use their instance profile credentials with Bedrock-compatible AI agents such as Claude Code or OpenCode."

  EnableEfs:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
    Description: "Enable EFS shared filesystem for runners. If enabled, an EFS volume will be created and made available to runners. Runners will need appropriate permissions and tools (e.g., nfs-utils) to mount it."

  EnableEphemeralRegistry:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
    Description: "If enabled, create an ECR ephemeral registry that can be used as a type=registry cache backend for Docker BuildKit. The registry will be created with a 10-day lifecycle policy, and runners will automatically be granted push/pull access."

  RunnerCustomTags:
    Type: CommaDelimitedList
    Default: ""
    Description: "Optional custom tags for the runner instances (e.g. 'key1=value1,key2=value2'). Tag keys cannot start with `runs-on`, and can only use letters (a-z, A-Z), numbers (0-9), and the following characters: + - = . , _ : @ (https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/Using_Tags.html#tag-restrictions)"

  RunnerMaxRuntime:
    Type: Number
    Default: 720
    MinValue: 30
    Description: Maximum runtime in minutes for runners. After this time, the runner will be forcefully terminated.

  RunnerConfigAutoExtendsFrom:
    Type: String
    Default: ".github-private"
    MinLength: 1
    Description: "When set to a repository name (e.g., '.github-private'), forces all repository configurations to extend from that repository. The config will be read from `<repo-name>/.github/runs-on.yml`. This allows you to provide a base configuration that applies to all repositories, even if they don't have a local runs-on.yml file. Set to '.' to disable auto-extend and use each repository's local extends (if any). Make sure the GitHub App has access to the specified repository."

  S3CacheExpirationInDays:
    Type: Number
    Default: 10
    Description: "Number of days to keep cache entries in the S3 cache bucket."

  # App runtime and maintenance [optional]
  AppSize:
    Type: String
    Default: "small"
    AllowedValues:
      - "small"
      - "medium"
      - "high"
      - "xhigh"
    Description: "Preset for the RunsOn control plane. This sets the worker service CPU/memory, queued-job concurrency, and the default EC2 launch/termination rate-limit assumptions together. Higher concurrency presets assume you have raised the relevant EC2 quotas (https://docs.aws.amazon.com/AWSEC2/latest/APIReference/throttling.html)."

  AppCapacityProvider:
    Type: String
    Default: "FARGATE"
    AllowedValues:
      - "FARGATE"
      - "FARGATE_SPOT"
    Description: "ECS Fargate capacity provider for the RunsOn control plane. FARGATE_SPOT lowers idle cost but can briefly stop the single control-plane task until Spot capacity is available; interrupted in-flight queue messages retry after the SQS visibility timeout."

  AppGithubApiStrategy:
    Type: String
    Default: "normal"
    AllowedValues:
      - "normal"
      - "conservative"
    Description: "Use normal or conservative GitHub API strategy. The conservative strategy will attempt to save GitHub API tokens by not automatically de-registering runners from the GitHub API after they are no longer needed. It's more elegant to clean them up (recommended), but in any case GitHub will eventually clean them up on their side after 24h. Especially useful if you launch tens of thousands of jobs every day."

  AppRegistry:
    Type: String
    Default: "public.ecr.aws/c5h5o9k1/runs-on/runs-on"
    Description: "Docker image (public) registry for the RunsOn service."

  LoggerLevel:
    Type: String
    Default: "info"
    AllowedValues:
      - "trace"
      - "debug"
      - "info"
      - "warn"
    Description: "Log level for the RunsOn server. Options: trace (most verbose), debug, info (default), warn (least verbose)."

  SpotCircuitBreaker:
    Type: String
    Default: "2/15/30"
    AllowedPattern: "^(false|[0-9]+/[0-9]+/[0-9]+)$"
    Description: "Enable (`COUNT/WINDOW_MINUTES/RECOVERY_MINUTES`) or disable (`false`) the spot instance circuit breaker. If enabled, the circuit breaker will prevent the stack from launching new spot instances during RECOVERY_MINUTES if there was at least COUNT spot interruptions during the last WINDOW_MINUTES. For instance 2/15/30 will prevent the stack from launching new spot instances during 30 minutes if there was at least 2 spot interruptions during the last 15 minutes."

  MaintenanceMode:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
    Description: "Enable maintenance mode (scales the worker service to zero)"

  # Observability, cost, and integrations [optional]
  OtelExporterEndpoint:
    Type: String
    Default: ""
    Description: "OTLP endpoint URL (e.g., ingest.eu.signoz.cloud:443, or https://otlp-gateway-prod-eu-west-0.grafana.net/otlp). Only HTTP(s) protocol is supported. Leave empty for no metrics export."

  OtelExporterHeaders:
    Type: String
    Default: ""
    NoEcho: true
    Description: "Headers for OTLP endpoint in W3C Baggage format: 'key1=value1,key2=value2' (e.g., 'signoz-ingestion-key=ABCD1234'). Leave empty if not required."

  OtelExporterTemporality:
    Type: String
    Default: "cumulative"
    AllowedValues:
      - "cumulative"
      - "delta"
    Description: "OTLP metrics temporality. Use cumulative (default) or delta (required by Datadog)."

  CostReportsEnabled:
    Type: String
    Default: "true"
    AllowedValues:
      - "true"
      - "false"
    Description: Enable or disable cost reports sent by email.

  AlertTopicSlackWebhookUrl:
    Type: String
    Description: Slack incoming webhook URL for alert notifications.
    Default: ""
    NoEcho: true

  AppBudgetDailyUsd:
    Type: Number
    Default: "10"
    Description: "Create a daily AWS cost budget in USD for this stack, filtered by the configured cost allocation tag. The budget evaluates UTC billing periods rather than real-time operational usage. For AWS Organizations member accounts, activate the cost allocation tag in the management account's Billing settings."

  CostAllocationTag:
    Type: String
    Default: "stack"
    Description: "Cost allocation tag key to use for all the deployed resources. The value for this tag will be set to the stack name. Updating this after creation will require deleting the stack and recreating it."

  IntegrationStepSecurityApiKey:
    Type: String
    Default: ""
    NoEcho: true
    Description: "API key for Step Security (see https://stepsecurity.io/ for more details)."

Mappings:
  App:
    Tags:
      BootstrapTag: v0.1.12
      AppTag: v3.0.7
      ImageTag: v3.0.7@sha256:9cf610b9fb9c5a71ac8d5c1ae55fbf438a9be4fdc7aea75aa999d8adf843a6b0
  AppSize:
    small:
      Cpu: 256
      Memory: 512
    medium:
      Cpu: 256
      Memory: 512
    high:
      Cpu: 512
      Memory: 1024
    xhigh:
      Cpu: 512
      Memory: 1024

Conditions:
  CostReportsEnabled: !Equals [!Ref CostReportsEnabled, "true"]
  AlertTopicSlackWebhookProvided: !Not [!Equals [!Ref AlertTopicSlackWebhookUrl, ""]]
  PrivateEnabled: !Not [!Equals [!Ref Private, "false"]]
  HasPrivateSubnet: !Condition PrivateEnabled
  HasEncryptEbs: !Equals [!Ref EncryptEbs, "true"]
  IsPrivateOnly: !Equals [!Ref Private, "only"]
  RunnerCustomPolicyProvided: !Not [!Equals [!Ref RunnerCustomPolicy, ""]]
  AppCustomPolicyProvided: !Not [!Equals [!Ref AppCustomPolicy, ""]]
  MaintenanceModeEnabled: !Equals [!Ref MaintenanceMode, "true"]
  WafEnabled: !Equals [!Ref EnableWAF, "true"]
  AdminRoutesEnabled: !Equals [!Ref EnableAdminRoutes, "true"]
  AdminRoutesDisabled: !Equals [!Ref EnableAdminRoutes, "false"]
  HasIpv6Enabled: !Equals [!Ref Ipv6Enabled, "true"]
  HasSSHAllowed: !Equals [!Ref SSHAllowed, "true"]
  BedrockEnabled: !Equals [!Ref EnableBedrock, "true"]
  CreateEfs: !Equals [!Ref EnableEfs, "true"]
  CreateEphemeralRegistry: !Equals [!Ref EnableEphemeralRegistry, "true"]
Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.1.0.0/16
      EnableDnsSupport: true
      EnableDnsHostnames: true
      InstanceTenancy: default
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-VPC

  VPCIPv6CidrBlock:
    Type: AWS::EC2::VPCCidrBlock
    Properties:
      VpcId: !Ref VPC
      AmazonProvidedIpv6CidrBlock: true

  PrivateSubnet1RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnet1RouteTable

  PrivateSubnet2RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnet2RouteTable

  PrivateSubnet1Route:
    Condition: HasPrivateSubnet
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateSubnet1RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref PrivateSubnetGateway1

  PrivateSubnet1RouteIPv6:
    Condition: HasPrivateSubnet
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateSubnet1RouteTable
      DestinationIpv6CidrBlock: ::/0
      EgressOnlyInternetGatewayId: !Ref EgressOnlyInternetGateway

  PrivateSubnet2Route:
    Condition: HasPrivateSubnet
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateSubnet2RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref PrivateSubnetGateway1

  PrivateSubnet2RouteIPv6:
    Condition: HasPrivateSubnet
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateSubnet2RouteTable
      DestinationIpv6CidrBlock: ::/0
      EgressOnlyInternetGatewayId: !Ref EgressOnlyInternetGateway

  EgressOnlyInternetGateway:
    Type: AWS::EC2::EgressOnlyInternetGateway
    Properties:
      VpcId: !Ref VPC

  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateSubnet1RouteTable

  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateSubnet2RouteTable

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    DependsOn: VPCIPv6CidrBlock
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !Select [8, !Cidr [!GetAtt [VPC, CidrBlock], 16, 12]]
      MapPublicIpOnLaunch: false
      AssignIpv6AddressOnCreation: true
      Ipv6CidrBlock:
        Fn::Select:
          - 0
          - Fn::Cidr:
            - Fn::Select:
              - 0
              - Fn::GetAtt:
                - VPC
                - Ipv6CidrBlocks
            - 256
            - 64
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnet1

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    DependsOn: VPCIPv6CidrBlock
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !Select [9, !Cidr [!GetAtt [VPC, CidrBlock], 16, 12]]
      MapPublicIpOnLaunch: false
      AssignIpv6AddressOnCreation: true
      Ipv6CidrBlock:
        Fn::Select:
          - 1
          - Fn::Cidr:
            - Fn::Select:
              - 0
              - Fn::GetAtt:
                - VPC
                - Ipv6CidrBlocks
            - 256
            - 64
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnet2

  PrivateSubnetGateway1:
    Condition: HasPrivateSubnet
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatGatewayElasticIP.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnetGateway1

  NatGatewayElasticIP:
    Condition: HasPrivateSubnet
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-NatGatewayElasticIP

  PublicSubnet1:
    Type: AWS::EC2::Subnet
    DependsOn: VPCIPv6CidrBlock
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [0, !GetAZs ""]
      CidrBlock: !Select [0, !Cidr [!GetAtt [VPC, CidrBlock], 16, 12]]
      MapPublicIpOnLaunch: !If [IsPrivateOnly, false, true]
      AssignIpv6AddressOnCreation: true
      Ipv6CidrBlock:
        Fn::Select:
          - 3
          - Fn::Cidr:
            - Fn::Select:
              - 0
              - Fn::GetAtt:
                - VPC
                - Ipv6CidrBlocks
            - 256
            - 64
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicSubnet1

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    DependsOn: VPCIPv6CidrBlock
    Properties:
      VpcId: !Ref VPC
      AvailabilityZone: !Select [1, !GetAZs ""]
      CidrBlock: !Select [1, !Cidr [!GetAtt [VPC, CidrBlock], 16, 12]]
      MapPublicIpOnLaunch: !If [IsPrivateOnly, false, true]
      AssignIpv6AddressOnCreation: true
      Ipv6CidrBlock:
        Fn::Select:
          - 4
          - Fn::Cidr:
            - Fn::Select:
              - 0
              - Fn::GetAtt:
                - VPC
                - Ipv6CidrBlocks
            - 256
            - 64
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicSubnet2

  S3VpcEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref VPC
      ServiceName:
        Fn::Sub: "com.amazonaws.${AWS::Region}.s3"
      VpcEndpointType: Gateway
      RouteTableIds:
        - !Ref PublicRouteTable
        - !Ref PrivateSubnet1RouteTable
        - !Ref PrivateSubnet2RouteTable

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-InternetGateway

  AttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicRouteTable

  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  PublicRouteIPv6:
    Type: AWS::EC2::Route
    DependsOn: AttachGateway
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationIpv6CidrBlock: ::/0
      GatewayId: !Ref InternetGateway

  SubnetRouteTableAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable

  SubnetRouteTableAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable

  # --- EFS Resources ---
  EfsFileSystem:
    Condition: CreateEfs
    Type: AWS::EFS::FileSystem
    UpdateReplacePolicy: Retain
    DeletionPolicy: RetainExceptOnCreate
    Properties:
      Encrypted: true
      FileSystemTags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-EFS

  EfsSecurityGroup:
    Condition: CreateEfs
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: !Sub "Allow NFS traffic from RunsOn EC2 instances to EFS ${AWS::StackName}"
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 2049
          ToPort: 2049
          SourceSecurityGroupId: !GetAtt SecurityGroup.GroupId
          Description: "Allow NFS from RunsOn EC2 instances"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: Name
          Value: !Sub ${AWS::StackName}-EFSSecurityGroup

  EfsMountTarget1:
    Condition: CreateEfs
    Type: AWS::EFS::MountTarget
    Properties:
      FileSystemId: !Ref EfsFileSystem
      SubnetId: !Ref PublicSubnet1 # Mount in public subnet for simplicity, runners in private can still reach
      SecurityGroups:
        - !Ref EfsSecurityGroup

  EfsMountTarget2:
    Condition: CreateEfs
    Type: AWS::EFS::MountTarget
    Properties:
      FileSystemId: !Ref EfsFileSystem
      SubnetId: !Ref PublicSubnet2
      SecurityGroups:
        - !Ref EfsSecurityGroup

  # --- End EFS Resources ---

  # --- Ephemeral Registry Resources ---
  EphemeralRegistry:
    Condition: CreateEphemeralRegistry
    Type: AWS::ECR::Repository
    UpdateReplacePolicy: Retain
    DeletionPolicy: RetainExceptOnCreate
    Properties:
      ImageScanningConfiguration:
        ScanOnPush: false
      ImageTagMutability: MUTABLE
      LifecyclePolicy:
        LifecyclePolicyText: |
          {
            "rules": [
              {
                "rulePriority": 1,
                "description": "Expire images older than 10 days",
                "selection": {
                  "tagStatus": "any",
                  "countType": "sinceImagePushed",
                  "countUnit": "days",
                  "countNumber": 10
                },
                "action": {
                  "type": "expire"
                }
              }
            ]
          }
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
  # --- End Ephemeral Registry Resources ---

  EC2FleetLaunchTemplateLinuxDefault:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      TagSpecifications:
        - ResourceType: launch-template
          Tags:
            - Key: !Ref CostAllocationTag
              Value: !Ref AWS::StackName
            - Key: runs-on-stack-name
              Value: !Ref AWS::StackName
            - Key: "runs-on/launch-template-name"
              Value: !Join ["-", [!Ref AWS::StackName, "LinuxDefault"]]
      LaunchTemplateData:
        TagSpecifications:
          - ResourceType: instance
            Tags:
              - Key: !Ref CostAllocationTag
                Value: !Ref AWS::StackName
              - Key: runs-on-stack-name
                Value: !Ref AWS::StackName
          - ResourceType: volume
            Tags:
              - Key: !Ref CostAllocationTag
                Value: !Ref AWS::StackName
              - Key: runs-on-stack-name
                Value: !Ref AWS::StackName
          - ResourceType: network-interface
            Tags:
              - Key: !Ref CostAllocationTag
                Value: !Ref AWS::StackName
              - Key: runs-on-stack-name
                Value: !Ref AWS::StackName
        IamInstanceProfile:
          Arn: !GetAtt EC2InstanceProfile.Arn
        EbsOptimized: "true"
        MetadataOptions:
          HttpTokens: required
          HttpPutResponseHopLimit: 2
        InstanceInitiatedShutdownBehavior: "terminate"
        NetworkInterfaces:
          - DeleteOnTermination: true
            DeviceIndex: "0"
            AssociatePublicIpAddress: !If [IsPrivateOnly, false, true]
            Groups:
              - !Ref SecurityGroup
            Ipv6AddressCount: !If [HasIpv6Enabled, 1, 0]
        UserData:
          Fn::Base64: !Sub
            # BEGIN AUTO-GENERATED LINUX USER-DATA
            - |
              #!/bin/bash -ex
              date -u
              mkdir -p /etc/runs-on /runs-on
              cat <<EOF > /etc/runs-on/bootstrap.env
              RUNS_ON_RUNNER_MAX_RUNTIME="${RunnerMaxRuntime}"
              RUNS_ON_LOG_GROUP_NAME="${EC2InstanceLogGroup}"
              AWS_REGION="${Region}"
              RUNS_ON_S3_BUCKET_CACHE="${S3BucketCache}"
              RUNS_ON_ROLE_ID="${RoleId}"
              RUNS_ON_BOOTSTRAP_TAG="${BootstrapTag}"
              RUNS_ON_APP_TAG="${AppTag}"
              RUNS_ON_AGENT_S3_BUCKET="${AgentS3Bucket}"
              ${EfsEnvLine}
              ${EphemeralRegistryEnvLine}
              EOF

              cat <<'EOF' > /usr/local/bin/runs-on-bootstrap.sh
              #!/bin/bash
              set -euo pipefail

              set -a
              . /etc/runs-on/bootstrap.env
              set +a

              BOOTSTRAP_BIN="/usr/local/bin/runs-on-bootstrap-$RUNS_ON_BOOTSTRAP_TAG"
              AGENT_BINARY_URL="$RUNS_ON_AGENT_S3_BUCKET/agent-linux-$(uname -m)"

              _the_end() {
                exit_code=$?
                if [ "$exit_code" -ne 0 ] ; then
                  echo "Bootstrap exited with error ($exit_code). Sleeping 60s for troubleshooting/log shipping..."
                  sleep 60
                fi

                echo "THE END"
                shutdown -h now
              }
              trap _the_end EXIT INT TERM

              if [ ! -f "$BOOTSTRAP_BIN" ]; then
                time curl -L --connect-timeout 3 --max-time 15 --retry 5 -s \
                  "https://github.com/runs-on/bootstrap/releases/download/$RUNS_ON_BOOTSTRAP_TAG/bootstrap-$RUNS_ON_BOOTSTRAP_TAG-linux-$(uname -m)" \
                  -o "$BOOTSTRAP_BIN"
              fi

              chmod a+x "$BOOTSTRAP_BIN"
              "$BOOTSTRAP_BIN" --exec --post-exec shutdown "$AGENT_BINARY_URL"
              EOF
              chmod a+x /usr/local/bin/runs-on-bootstrap.sh

              cat <<'EOF' > /etc/systemd/system/runs-on-bootstrap.service
              [Unit]
              Description=RunsOn Bootstrap Service
              After=network-online.target local-fs.target
              Wants=network-online.target
              Requires=local-fs.target

              [Service]
              Type=simple
              ExecStart=/bin/bash -lc '/usr/local/bin/runs-on-bootstrap.sh 2>&1 | tee -a /runs-on/output.log /var/log/cloud-init-output.log'
              StandardOutput=journal+console
              StandardError=journal+console
              Restart=no

              [Install]
              WantedBy=multi-user.target
              EOF

              if ! systemctl --no-block enable --now runs-on-bootstrap.service; then
                echo "Failed to enable/start runs-on-bootstrap.service via systemd, falling back to inline bootstrap."
                /usr/local/bin/runs-on-bootstrap.sh 2>&1 | tee -a /runs-on/output.log /var/log/cloud-init-output.log
              fi
            # END AUTO-GENERATED LINUX USER-DATA
            - RunnerMaxRuntime: !Ref RunnerMaxRuntime
              EC2InstanceLogGroup: !Ref EC2InstanceLogGroup
              Region: !Ref AWS::Region
              S3BucketCache: !Ref S3BucketCache
              RoleId: !GetAtt EC2InstanceRole.RoleId
              AppTag: !FindInMap [App, Tags, AppTag]
              BootstrapTag: !FindInMap [App, Tags, BootstrapTag]
              AgentS3Bucket: !Join ["", ["s3://", !Ref S3BucketCache, "/agents/", !FindInMap [App, Tags, AppTag]]]
              EfsEnvLine: !If [CreateEfs, !Sub 'RUNS_ON_EFS_ID="${EfsFileSystem}"', '']
              EphemeralRegistryEnvLine: !If [CreateEphemeralRegistry, !Sub 'RUNS_ON_ECR_CACHE="${EphemeralRegistry.RepositoryUri}"', '']

  EC2FleetLaunchTemplateWindowsDefault:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      TagSpecifications:
        - ResourceType: launch-template
          Tags:
            - Key: !Ref CostAllocationTag
              Value: !Ref AWS::StackName
            - Key: runs-on-stack-name
              Value: !Ref AWS::StackName
            - Key: "runs-on/launch-template-name"
              Value: !Join ["-", [!Ref AWS::StackName, "WindowsDefault"]]
      LaunchTemplateData:
        TagSpecifications:
          - ResourceType: instance
            Tags:
              - Key: !Ref CostAllocationTag
                Value: !Ref AWS::StackName
              - Key: runs-on-stack-name
                Value: !Ref AWS::StackName
          - ResourceType: volume
            Tags:
              - Key: !Ref CostAllocationTag
                Value: !Ref AWS::StackName
              - Key: runs-on-stack-name
                Value: !Ref AWS::StackName
          - ResourceType: network-interface
            Tags:
              - Key: !Ref CostAllocationTag
                Value: !Ref AWS::StackName
              - Key: runs-on-stack-name
                Value: !Ref AWS::StackName
        IamInstanceProfile:
          Arn: !GetAtt EC2InstanceProfile.Arn
        EbsOptimized: "true"
        MetadataOptions:
          HttpTokens: required
          HttpPutResponseHopLimit: 2
        InstanceInitiatedShutdownBehavior: "terminate"
        NetworkInterfaces:
          - DeleteOnTermination: true
            DeviceIndex: "0"
            AssociatePublicIpAddress: !If [IsPrivateOnly, false, true]
            Groups:
              - !Ref SecurityGroup
            Ipv6AddressCount: !If [HasIpv6Enabled, 1, 0]
        UserData:
          Fn::Base64: !Sub
            # BEGIN AUTO-GENERATED WINDOWS USER-DATA
            - |
              <powershell>
              Get-Date -format s
              $env:RUNS_ON_RUNNER_MAX_RUNTIME = "${RunnerMaxRuntime}"
              $env:RUNS_ON_LOG_GROUP_NAME = "${EC2InstanceLogGroup}"
              $env:AWS_REGION = "${Region}"
              $env:RUNS_ON_S3_BUCKET_CACHE = "${S3BucketCache}"
              ${EfsEnvLine}
              ${EphemeralRegistryEnvLine}
              # Enable and start SSM Agent service
              try {
                Set-Service -Name "AmazonSSMAgent" -StartupType Automatic -ErrorAction SilentlyContinue
                Start-Service -Name "AmazonSSMAgent" -ErrorAction SilentlyContinue
                Write-Output "SSM Agent service enabled and started"
              } catch {
                Write-Output "Warning: Failed to start SSM Agent service: $($_.Exception.Message)"
              }
              $bootstrapBin = "C:\runs-on\bootstrap-${BootstrapTag}.exe"
              $bootstrapFailed = $false
              $bootstrapExitCode = 0
              try {
                New-Item -ItemType Directory -Force -Path (Split-Path $bootstrapBin)
                if (-not (Test-Path $bootstrapBin)) {
                  # Enable TLS 1.2 for Windows Server 2016 compatibility
                  [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
                  # Disable progress bar for much faster downloads (can be 100x faster)
                  $ProgressPreference = 'SilentlyContinue'
                  Invoke-WebRequest -Uri "https://github.com/runs-on/bootstrap/releases/download/${BootstrapTag}/bootstrap-${BootstrapTag}-windows-$env:PROCESSOR_ARCHITECTURE.exe" -OutFile $bootstrapBin -UseBasicParsing
                }
                Add-MpPreference -ExclusionProcess $bootstrapBin -ErrorAction SilentlyContinue
                & $bootstrapBin --exec --post-exec shutdown "${AgentS3Bucket}/agent-windows-$env:PROCESSOR_ARCHITECTURE.exe"
                $bootstrapExitCode = $LASTEXITCODE
                if ($bootstrapExitCode -ne 0) {
                  throw "Bootstrap exited with code $bootstrapExitCode"
                }
              } catch {
                $bootstrapFailed = $true
                if ($bootstrapExitCode -eq 0) {
                  $bootstrapExitCode = 1
                }
                Write-Output "Bootstrap failed: $($_.Exception.Message)"
                throw
              } finally {
                if ($bootstrapFailed) {
                  Write-Output "Bootstrap failed (exit code $bootstrapExitCode). Sleeping 60s for troubleshooting/log shipping..."
                  Start-Sleep -Seconds 60
                }
                Write-Output "user-data: Going to shut down now..."
                Stop-Computer -Force
              }
              </powershell>
              <detach>true</detach>
              <persist>true</persist>
            # END AUTO-GENERATED WINDOWS USER-DATA
            - RunnerMaxRuntime: !Ref RunnerMaxRuntime
              EC2InstanceLogGroup: !Ref EC2InstanceLogGroup
              Region: !Ref AWS::Region
              S3BucketCache: !Ref S3BucketCache
              BootstrapTag: !FindInMap [App, Tags, BootstrapTag]
              AgentS3Bucket: !Join ["", ["s3://", !Ref S3BucketCache, "/agents/", !FindInMap [App, Tags, AppTag]]]
              EfsEnvLine: !If [CreateEfs, !Sub '$env:RUNS_ON_EFS_ID = "${EfsFileSystem}"', '']
              EphemeralRegistryEnvLine: !If [CreateEphemeralRegistry, !Sub '$env:RUNS_ON_ECR_CACHE = "${EphemeralRegistry.RepositoryUri}"', '']

  EC2FleetLaunchTemplateLinuxDefaultNested:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub "${AWS::StackName}-linux-default-nested"
      TagSpecifications:
        - ResourceType: launch-template
          Tags:
            - Key: !Ref CostAllocationTag
              Value: !Ref AWS::StackName
            - Key: runs-on-stack-name
              Value: !Ref AWS::StackName
            - Key: "runs-on/launch-template-name"
              Value: !Join ["-", [!Ref AWS::StackName, "LinuxDefaultNested"]]
      LaunchTemplateData:
        MetadataOptions:
          HttpTokens: required

  ManagedLaunchTemplateLinuxDefaultNested:
    Type: Custom::LaunchTemplate
    Properties:
      ServiceToken: !GetAtt ManagedLaunchTemplateFunction.Arn
      RefreshToken: !Ref ManagedLaunchTemplateFunctionVersion
      TargetLaunchTemplateId: !Ref EC2FleetLaunchTemplateLinuxDefaultNested
      SourceLaunchTemplateId: !Ref EC2FleetLaunchTemplateLinuxDefault
      SourceLaunchTemplateVersion: !GetAtt EC2FleetLaunchTemplateLinuxDefault.LatestVersionNumber
      NestedVirtualization: true

  EC2FleetLaunchTemplateLinuxPrivate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub "${AWS::StackName}-linux-private"
      TagSpecifications:
        - ResourceType: launch-template
          Tags:
            - Key: !Ref CostAllocationTag
              Value: !Ref AWS::StackName
            - Key: runs-on-stack-name
              Value: !Ref AWS::StackName
            - Key: "runs-on/launch-template-name"
              Value: !Join ["-", [!Ref AWS::StackName, "LinuxPrivate"]]
      LaunchTemplateData:
        MetadataOptions:
          HttpTokens: required

  ManagedLaunchTemplateLinuxPrivate:
    Type: Custom::LaunchTemplate
    Properties:
      ServiceToken: !GetAtt ManagedLaunchTemplateFunction.Arn
      RefreshToken: !Ref ManagedLaunchTemplateFunctionVersion
      TargetLaunchTemplateId: !Ref EC2FleetLaunchTemplateLinuxPrivate
      SourceLaunchTemplateId: !Ref EC2FleetLaunchTemplateLinuxDefault
      SourceLaunchTemplateVersion: !GetAtt EC2FleetLaunchTemplateLinuxDefault.LatestVersionNumber
      Private: true

  EC2FleetLaunchTemplateLinuxPrivateNested:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub "${AWS::StackName}-linux-private-nested"
      TagSpecifications:
        - ResourceType: launch-template
          Tags:
            - Key: !Ref CostAllocationTag
              Value: !Ref AWS::StackName
            - Key: runs-on-stack-name
              Value: !Ref AWS::StackName
            - Key: "runs-on/launch-template-name"
              Value: !Join ["-", [!Ref AWS::StackName, "LinuxPrivateNested"]]
      LaunchTemplateData:
        MetadataOptions:
          HttpTokens: required

  ManagedLaunchTemplateLinuxPrivateNested:
    Type: Custom::LaunchTemplate
    Properties:
      ServiceToken: !GetAtt ManagedLaunchTemplateFunction.Arn
      RefreshToken: !Ref ManagedLaunchTemplateFunctionVersion
      TargetLaunchTemplateId: !Ref EC2FleetLaunchTemplateLinuxPrivateNested
      SourceLaunchTemplateId: !Ref EC2FleetLaunchTemplateLinuxDefault
      SourceLaunchTemplateVersion: !GetAtt EC2FleetLaunchTemplateLinuxDefault.LatestVersionNumber
      Private: true
      NestedVirtualization: true

  EC2FleetLaunchTemplateWindowsDefaultNested:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub "${AWS::StackName}-windows-default-nested"
      TagSpecifications:
        - ResourceType: launch-template
          Tags:
            - Key: !Ref CostAllocationTag
              Value: !Ref AWS::StackName
            - Key: runs-on-stack-name
              Value: !Ref AWS::StackName
            - Key: "runs-on/launch-template-name"
              Value: !Join ["-", [!Ref AWS::StackName, "WindowsDefaultNested"]]
      LaunchTemplateData:
        MetadataOptions:
          HttpTokens: required

  ManagedLaunchTemplateWindowsDefaultNested:
    Type: Custom::LaunchTemplate
    Properties:
      ServiceToken: !GetAtt ManagedLaunchTemplateFunction.Arn
      RefreshToken: !Ref ManagedLaunchTemplateFunctionVersion
      TargetLaunchTemplateId: !Ref EC2FleetLaunchTemplateWindowsDefaultNested
      SourceLaunchTemplateId: !Ref EC2FleetLaunchTemplateWindowsDefault
      SourceLaunchTemplateVersion: !GetAtt EC2FleetLaunchTemplateWindowsDefault.LatestVersionNumber
      NestedVirtualization: true

  EC2FleetLaunchTemplateWindowsPrivate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub "${AWS::StackName}-windows-private"
      TagSpecifications:
        - ResourceType: launch-template
          Tags:
            - Key: !Ref CostAllocationTag
              Value: !Ref AWS::StackName
            - Key: runs-on-stack-name
              Value: !Ref AWS::StackName
            - Key: "runs-on/launch-template-name"
              Value: !Join ["-", [!Ref AWS::StackName, "WindowsPrivate"]]
      LaunchTemplateData:
        MetadataOptions:
          HttpTokens: required

  ManagedLaunchTemplateWindowsPrivate:
    Type: Custom::LaunchTemplate
    Properties:
      ServiceToken: !GetAtt ManagedLaunchTemplateFunction.Arn
      RefreshToken: !Ref ManagedLaunchTemplateFunctionVersion
      TargetLaunchTemplateId: !Ref EC2FleetLaunchTemplateWindowsPrivate
      SourceLaunchTemplateId: !Ref EC2FleetLaunchTemplateWindowsDefault
      SourceLaunchTemplateVersion: !GetAtt EC2FleetLaunchTemplateWindowsDefault.LatestVersionNumber
      Private: true

  EC2FleetLaunchTemplateWindowsPrivateNested:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub "${AWS::StackName}-windows-private-nested"
      TagSpecifications:
        - ResourceType: launch-template
          Tags:
            - Key: !Ref CostAllocationTag
              Value: !Ref AWS::StackName
            - Key: runs-on-stack-name
              Value: !Ref AWS::StackName
            - Key: "runs-on/launch-template-name"
              Value: !Join ["-", [!Ref AWS::StackName, "WindowsPrivateNested"]]
      LaunchTemplateData:
        MetadataOptions:
          HttpTokens: required

  ManagedLaunchTemplateWindowsPrivateNested:
    Type: Custom::LaunchTemplate
    Properties:
      ServiceToken: !GetAtt ManagedLaunchTemplateFunction.Arn
      RefreshToken: !Ref ManagedLaunchTemplateFunctionVersion
      TargetLaunchTemplateId: !Ref EC2FleetLaunchTemplateWindowsPrivateNested
      SourceLaunchTemplateId: !Ref EC2FleetLaunchTemplateWindowsDefault
      SourceLaunchTemplateVersion: !GetAtt EC2FleetLaunchTemplateWindowsDefault.LatestVersionNumber
      Private: true
      NestedVirtualization: true

  SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for SSH access and EFS egress
      VpcId:
        Ref: VPC
      SecurityGroupIngress:
        - !If
          - HasSSHAllowed
          - CidrIp:
              Fn::Sub: "${SSHCidrRange}"
            FromPort: 22
            ToPort: 22
            IpProtocol: tcp
            Description: "Allow SSH Ingress"
          - !Ref AWS::NoValue
      SecurityGroupEgress:
        # Default Egress Rule (Allow All IPv4)
        - IpProtocol: "-1"
          CidrIp: "0.0.0.0/0"
          Description: "Allow all outbound IPv4 traffic"
        # Default Egress Rule (Allow All IPv6)
        - IpProtocol: "-1"
          CidrIpv6: "::/0"
          Description: "Allow all outbound IPv6 traffic"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  S3BucketCache:
    Type: AWS::S3::Bucket
    UpdateReplacePolicy: Retain
    DeletionPolicy: RetainExceptOnCreate
    Properties:
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: aws:kms
            BucketKeyEnabled: true
      VersioningConfiguration:
        Status: Suspended
      LifecycleConfiguration:
        Rules:
          - Id: ExpireRunnerConfig
            Prefix: runners/
            Status: Enabled
            ExpirationInDays: 1
            NoncurrentVersionExpiration:
              NoncurrentDays: 1
          - Id: ExpireCache
            Prefix: cache/
            Status: Enabled
            ExpirationInDays: !Ref S3CacheExpirationInDays
            NoncurrentVersionExpiration:
              NoncurrentDays: 1
          - Id: CleanupIncompleteMultipartUploads
            Status: Enabled
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 1
          - Id: CleanupExpiredObjectDeleteMarkers
            Status: Enabled
            ExpiredObjectDeleteMarker: true
      LoggingConfiguration:
        DestinationBucketName: !Ref S3BucketLogging
        LogFilePrefix: s3-cache-access-logs/
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: "runs-on/purpose"
          Value: "cache"

  S3BucketCachePolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3BucketCache
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: DenyUnencryptedConnections
            Effect: Deny
            Principal: "*"
            Action: "s3:*"
            Resource:
              - !Sub "arn:aws:s3:::${S3BucketCache}"
              - !Sub "arn:aws:s3:::${S3BucketCache}/*"
            Condition:
              Bool:
                aws:SecureTransport: false

  S3BucketLogging:
    Type: AWS::S3::Bucket
    UpdateReplacePolicy: Retain
    DeletionPolicy: RetainExceptOnCreate
    Properties:
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: aws:kms
            BucketKeyEnabled: true
      VersioningConfiguration:
        Status: Enabled
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced
      LifecycleConfiguration:
        Rules:
          - Id: DeleteOldLogs
            Status: Enabled
            ExpirationInDays: 30
            NoncurrentVersionExpiration:
              NoncurrentDays: 7
          - Id: CleanupIncompleteMultipartUploads
            Status: Enabled
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
          - Id: CleanupExpiredObjectDeleteMarkers
            Status: Enabled
            ExpiredObjectDeleteMarker: true
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
        - Key: "runs-on/purpose"
          Value: "s3-access-logs"

  S3BucketLoggingPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref S3BucketLogging
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: DenyUnencryptedConnections
            Effect: Deny
            Principal: "*"
            Action: "s3:*"
            Resource:
              - !Sub "arn:aws:s3:::${S3BucketLogging}"
              - !Sub "arn:aws:s3:::${S3BucketLogging}/*"
            Condition:
              Bool:
                aws:SecureTransport: false
          - Sid: S3ServerAccessLogsPolicy
            Effect: Allow
            Principal:
              Service: logging.s3.amazonaws.com
            Action: "s3:PutObject"
            Resource:
              - !Sub "arn:aws:s3:::${S3BucketLogging}/s3-cache-access-logs/*"
            Condition:
              StringEquals:
                aws:SourceAccount: !Sub ${AWS::AccountId}
              ArnLike:
                aws:Sourcearn:
                  - !Sub "arn:aws:s3:::${S3BucketCache}"

  EC2InstanceResourceGroup:
    Type: AWS::ResourceGroups::Group
    Properties:
      Name: !Sub "${AWS::StackName}-EC2Instances"
      Description: "Resource group for EC2 instances belonging to this stack"
      ResourceQuery:
        Type: TAG_FILTERS_1_0
        Query:
          ResourceTypeFilters:
            - "AWS::EC2::Instance"
          TagFilters:
            - Key: "runs-on-stack-name"
              Values:
                - !Ref AWS::StackName
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  EC2InstanceLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 7
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  EC2InstanceRole:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicyArns:
        !If
          - RunnerCustomPolicyProvided
          - [!Ref RunnerCustomPolicy, "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", "arn:aws:iam::aws:policy/AmazonElasticContainerRegistryPublicFullAccess"]
          - ["arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore", "arn:aws:iam::aws:policy/AmazonElasticContainerRegistryPublicFullAccess"]
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ReadOnly
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:DescribeTags
                  - ec2:DescribeAvailabilityZones
                Resource: "*"
        - PolicyName: CreateTags
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:CreateTags
                Resource: "*"
                Condition:
                  StringEquals:
                    "aws:ARN": "${ec2:SourceInstanceARN}"
        - PolicyName: CreateTagsOnVolumesAndSnapshots
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:CreateTags
                Resource:
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:volume/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}:*:snapshot/*"
        - PolicyName: SendLogs
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "logs:PutLogEvents"
                  - "logs:PutRetentionPolicy"
                  - "logs:DescribeLogStreams"
                  - "logs:DescribeLogGroups"
                  - "logs:CreateLogStream"
                  - "logs:CreateLogGroup"
                Resource:
                - !Sub "${EC2InstanceLogGroup.Arn}"
                - !Sub "${EC2InstanceLogGroup.Arn}:*"
        - PolicyName: PutMetrics
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - cloudwatch:PutMetricData
                Resource: "*"
                Condition:
                  StringEquals:
                    cloudwatch:namespace:
                      - "RunsOn/Runners"
                      - "CWAgent"
        - PolicyName: GetMetrics
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                # can't restrict on specific namespaces unfortunately
                Action:
                  - cloudwatch:GetMetricData
                  - cloudwatch:GetMetricStatistics
                Resource: "*"
        - PolicyName: EC2AccessS3BucketPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:GetBucketLocation
                Resource:
                  - !Sub "arn:aws:s3:::${S3BucketCache}"
              - Effect: Allow
                Action:
                  - s3:ListBucket
                  - s3:ListBucketMultipartUploads
                Resource:
                  - !Sub "arn:aws:s3:::${S3BucketCache}"
                Condition:
                  StringLike:
                    s3:prefix:
                      - cache
                      - cache/*
                      - agents
                      - agents/*
                      - !Join ["", ["runners/", "${aws:userid}", "/"]]
                      - !Join ["", ["runners/", "${aws:userid}", "/*"]]
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                  - s3:DeleteObject
                  - s3:ListMultipartUploadParts
                Resource:
                  - !Sub "arn:aws:s3:::${S3BucketCache}/cache/*"
              - Effect: Allow
                Action:
                  - s3:GetObject
                Resource:
                  - !Join
                    - ""
                    - [
                        !Sub "arn:aws:s3:::${S3BucketCache}/runners/",
                        "${aws:userid}/*",
                      ]
              - Effect: Allow
                Action:
                  - s3:GetObject
                Resource:
                  - !Sub "arn:aws:s3:::${S3BucketCache}/agents/*"
        - PolicyName: VolumeSnapshotDescribe
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:DescribeVolumes
                  - ec2:DescribeSnapshots
                Resource: "*"
        - PolicyName: VolumeSnapshotCreate
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:CreateVolume
                  - ec2:CreateSnapshot
                Resource:
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:volume/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}::snapshot/*" # no account id in snapshot ARN
        - PolicyName: VolumeSnapshotLifecycle
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:AttachVolume
                  - ec2:DetachVolume
                  - ec2:DeleteVolume
                  - ec2:DeleteSnapshot
                Resource:
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:volume/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}::snapshot/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/*"
                Condition:
                  StringEquals:
                    "aws:ResourceTag/runs-on-stack-name": !Ref AWS::StackName
        - PolicyName: EnableDetailedMonitoring
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:MonitorInstances
                Resource:
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/*"
                Condition:
                  StringEquals:
                    "aws:ResourceTag/runs-on-stack-name": !Ref AWS::StackName
        - !If
          - BedrockEnabled
          - PolicyName: BedrockAccess
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    - bedrock:InvokeModel
                    - bedrock:InvokeModelWithResponseStream
                    - bedrock:ListInferenceProfiles
                  Resource:
                    - "arn:aws:bedrock:*:*:foundation-model/*"
                    - "arn:aws:bedrock:*:*:inference-profile/*"
                    - "arn:aws:bedrock:*:*:application-inference-profile/*"
          - !Ref AWS::NoValue
        - !If
          - CreateEfs
          - PolicyName: EfsMountAccess
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    # Required for mounting
                    - elasticfilesystem:ClientMount
                    - elasticfilesystem:ClientWrite
                    - elasticfilesystem:DescribeMountTargets
                    # Required by efs-utils helper to find mount target IP
                    - ec2:DescribeSubnets
                    - ec2:DescribeNetworkInterfaces
                  Resource: "*" # Scoping down requires knowing EFS ARN, Subnet ARNs, etc. Keep broad for simplicity.
          - !Ref AWS::NoValue
        - !If
          - CreateEphemeralRegistry
          - PolicyName: EphemeralRegistryAccess
            PolicyDocument:
              Version: "2012-10-17"
              Statement:
                - Effect: Allow
                  Action:
                    # Needed for docker login
                    - ecr:GetAuthorizationToken
                  Resource: "*" # GetAuthorizationToken requires "*" resource
                - Effect: Allow
                  Action:
                    # Pull permissions
                    - ecr:BatchCheckLayerAvailability
                    - ecr:GetDownloadUrlForLayer
                    - ecr:BatchGetImage
                    # Push permissions
                    - ecr:InitiateLayerUpload
                    - ecr:UploadLayerPart
                    - ecr:CompleteLayerUpload
                    - ecr:PutImage
                  Resource: !GetAtt EphemeralRegistry.Arn
          - !Ref AWS::NoValue
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles:
        - !Ref EC2InstanceRole

  SchedulerCostReport:
    Condition: CostReportsEnabled
    Type: AWS::Scheduler::Schedule
    Properties:
      ScheduleExpression: "cron(5 0 * * ? *)"
      ScheduleExpressionTimezone: "UTC"
      FlexibleTimeWindow:
        Mode: "OFF"
      Target:
        Arn: !GetAtt RunsOnQueueEvents.Arn
        RoleArn: !GetAtt SchedulerInvokeRole.Arn
        RetryPolicy:
          MaximumRetryAttempts: 0
        Input: '{"detail-type":"RunsOn Cost Report"}'

  SchedulerCostAllocationTag:
    Type: AWS::Scheduler::Schedule
    Properties:
      ScheduleExpression: "cron(10 0 * * ? *)"
      ScheduleExpressionTimezone: "UTC"
      FlexibleTimeWindow:
        Mode: "OFF"
      Target:
        Arn: !GetAtt RunsOnQueueEvents.Arn
        RoleArn: !GetAtt SchedulerInvokeRole.Arn
        RetryPolicy:
          MaximumRetryAttempts: 0
        Input: '{"detail-type":"RunsOn Cost Allocation Tag"}'

  RunsOnGitHubRunnerCacheRefreshSchedule:
    Type: AWS::Scheduler::Schedule
    Properties:
      ScheduleExpression: "cron(15 0 * * ? *)"
      ScheduleExpressionTimezone: "UTC"
      FlexibleTimeWindow:
        Mode: "OFF"
      Target:
        Arn: !GetAtt RunsOnGitHubRunnerCacheRefreshFunction.Arn
        RoleArn: !GetAtt SchedulerInvokeRole.Arn
        RetryPolicy:
          MaximumRetryAttempts: 0
        Input: '{}'

  RunsOnGitHubRunnerCacheRefreshSeed:
    Type: Custom::RunsOnGitHubRunnerCacheRefreshSeed
    Properties:
      ServiceToken: !GetAtt RunsOnGitHubRunnerCacheRefreshFunction.Arn
      Bucket: !Ref S3BucketCache

  SchedulerInvokeRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: scheduler.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: "AllowSQSSendMessage"
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action: sqs:SendMessage
                Resource: !GetAtt RunsOnQueueEvents.Arn
              - Effect: Allow
                Action: lambda:InvokeFunction
                Resource: !GetAtt RunsOnGitHubRunnerCacheRefreshFunction.Arn

  RunsOnWorkerCluster:
    Type: AWS::ECS::Cluster
    Properties:
      CapacityProviders:
        - FARGATE
        - FARGATE_SPOT
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnWorkerExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: ecs-tasks.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnWorkerLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 30
      LogGroupName: !Sub "/aws/ecs/${AWS::StackName}/runs-on-worker"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnPublicIngressLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 14
      LogGroupName: !Sub "/aws/lambda/${RunsOnPublicIngressFunction}"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnGitHubAppsSetupLogGroup:
    Condition: AdminRoutesEnabled
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 14
      LogGroupName: !Sub "/aws/lambda/${RunsOnGitHubAppsSetupFunction}"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnGitHubWafSyncLogGroup:
    Condition: WafEnabled
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 14
      LogGroupName: !Sub "/aws/lambda/${RunsOnGitHubWafSyncFunction}"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnGitHubRunnerCacheRefreshLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      RetentionInDays: 14
      LogGroupName: !Sub "/aws/lambda/${RunsOnGitHubRunnerCacheRefreshFunction}"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnPublicIngressAllowedIpsIPv4:
    Condition: WafEnabled
    Type: AWS::WAFv2::IPSet
    Properties:
      Name: !Sub "${AWS::StackName}-allowed-ips-ipv4"
      Scope: REGIONAL
      IPAddressVersion: IPV4
      Addresses:
        - "192.0.2.1/32"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnPublicIngressAllowedIpsIPv6:
    Condition: WafEnabled
    Type: AWS::WAFv2::IPSet
    Properties:
      Name: !Sub "${AWS::StackName}-allowed-ips-ipv6"
      Scope: REGIONAL
      IPAddressVersion: IPV6
      Addresses:
        - "2001:db8::1/128"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnGitHubWafSyncRole:
    Condition: WafEnabled
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: RunsOnGitHubWafSync
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - wafv2:GetIPSet
                  - wafv2:UpdateIPSet
                Resource:
                  - !GetAtt RunsOnPublicIngressAllowedIpsIPv4.Arn
                  - !GetAtt RunsOnPublicIngressAllowedIpsIPv6.Arn
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnGitHubRunnerCacheRefreshRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: RunsOnGitHubRunnerCacheRefresh
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3:ListBucket
                Resource: !GetAtt S3BucketCache.Arn
                Condition:
                  StringLike:
                    s3:prefix:
                      - agents/github-runner
                      - agents/github-runner/*
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                Resource: !Sub "${S3BucketCache.Arn}/agents/github-runner/*"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnPublicIngressRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: RunsOnPublicIngress
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - sqs:SendMessage
                Resource: !GetAtt RunsOnQueueWebhooks.Arn
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnGitHubAppsSetupRole:
    Condition: AdminRoutesEnabled
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: RunsOnGitHubAppsSetup
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                Resource:
                  - !Ref RunsOnGitHubAppsSecret
                  - !Ref RunsOnStackConfigSecret
              - Effect: Allow
                Action:
                  - secretsmanager:PutSecretValue
                Resource: !Ref RunsOnGitHubAppsSecret
              - Effect: Allow
                Action:
                  - ssm:GetParameter
                Resource: !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}/license/status"
              - Effect: Allow
                Action:
                  - sns:ListSubscriptionsByTopic
                Resource: !Ref AlertTopic
              - Effect: Allow
                Action:
                  - iam:GetRole
                  - iam:CreateServiceLinkedRole
                Resource: "*"
              - Effect: Allow
                Action:
                  - sqs:SendMessage
                Resource: !GetAtt RunsOnQueueSystem.Arn
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnPublicIngressFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs24.x
      Handler: index.handler
      Role: !GetAtt RunsOnPublicIngressRole.Arn
      Timeout: 15
      MemorySize: 256
      Environment:
        Variables:
          RUNS_ON_WEBHOOKS_QUEUE_URL: !Ref RunsOnQueueWebhooks
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      Code:
        ZipFile: |
          'use strict';

          const crypto = require('node:crypto');

          const WORKFLOW_JOB_GROUP_BUCKETS = 32;
          const WORKFLOW_RUN_GROUP_BUCKETS = 16;

          function createDefaultAws() {
            let initialized = false;
            let sqs;
            let SendMessageCommand;

            function init() {
              if (initialized) return;
              initialized = true;
              const sqsSdk = require('@aws-sdk/client-sqs');

              sqs = new sqsSdk.SQSClient({});
              SendMessageCommand = sqsSdk.SendMessageCommand;
            }

            return {
              async sendQueueMessage(params) {
                init();
                return sqs.send(new SendMessageCommand(params));
              },
            };
          }

          function normalizedHeaders(headers) {
            return Object.fromEntries(Object.entries(headers || {}).map(([key, value]) => [String(key).toLowerCase(), value]));
          }

          function normalizePath(path, stage) {
            const raw = String(path || '').trim() || '/';
            if (!stage) return raw;
            const stagePrefix = '/' + String(stage).replace(/^\/+|\/+$/g, '');
            if (raw === stagePrefix) return '/';
            if (raw.startsWith(stagePrefix + '/')) return raw.slice(stagePrefix.length);
            return raw;
          }

          function requestFor(event) {
            const requestContext = event.requestContext || {};
            const stage = String(requestContext.stage || '').trim();
            return {
              method: String(event.httpMethod || '').trim(),
              path: normalizePath(event.path || '/', stage),
              requestId: String(requestContext.requestId || '').trim(),
              headers: normalizedHeaders(event.headers),
              body: event.body,
              isBase64Encoded: !!event.isBase64Encoded,
            };
          }

          function payloadBuffer(request) {
            const body = String(request?.body || '');
            return request?.isBase64Encoded ? Buffer.from(body, 'base64') : Buffer.from(body, 'utf8');
          }

          function extractJobId(payload, eventType) {
            if (eventType === 'workflow_job') return Number(payload?.workflow_job?.id || 0);
            return 0;
          }

          function extractRunId(payload, eventType) {
            if (eventType === 'workflow_job') return Number(payload?.workflow_job?.run_id || 0);
            if (eventType === 'workflow_run') return Number(payload?.workflow_run?.id || 0);
            return 0;
          }

          function webhookLogFields(payload, eventType, deliveryId) {
            const fields = {
              delivery_id: deliveryId,
              event_type: eventType,
              action: String(payload?.action || '').trim(),
              repository: String(payload?.repository?.full_name || '').trim(),
            };

            if (eventType === 'workflow_job') {
              fields.job_id = Number(payload?.workflow_job?.id || 0);
              fields.run_id = Number(payload?.workflow_job?.run_id || 0);
              fields.job_name = String(payload?.workflow_job?.name || '').trim();
              fields.workflow_name = String(payload?.workflow_job?.workflow_name || '').trim();
              fields.job_url = String(payload?.workflow_job?.html_url || payload?.workflow_job?.url || '').trim();
              fields.labels = Array.isArray(payload?.workflow_job?.labels) ? payload.workflow_job.labels : [];
              return fields;
            }

            if (eventType === 'workflow_run') {
              fields.run_id = Number(payload?.workflow_run?.id || 0);
              fields.workflow_name = String(payload?.workflow_run?.name || '').trim();
              fields.run_url = String(payload?.workflow_run?.html_url || payload?.workflow_run?.url || '').trim();
              fields.status = String(payload?.workflow_run?.status || '').trim();
              fields.conclusion = String(payload?.workflow_run?.conclusion || '').trim();
            }

            return fields;
          }

          function webhookGroupId(payload, eventType, deliveryId, requestId) {
            // Keep the webhook queue FIFO so SQS still gives us delivery deduplication
            // and in-order processing within a bucket. The important ordering boundary
            // for the hot path is the individual workflow job, not the whole workflow
            // run. Large matrix runs can emit many sibling jobs at once, and grouping all
            // of them by run artificially serializes queued-job ingestion behind a single
            // FIFO lane. Grouping workflow_job events by job ID keeps queued -> waiting ->
            // queued -> in_progress -> completed ordered for one job while letting other
            // jobs from the same run advance in parallel.
            const jobId = extractJobId(payload, eventType);
            if (jobId > 0) return 'job-' + String(jobId % WORKFLOW_JOB_GROUP_BUCKETS);

            // workflow_run events are not the hot path that triggers scheduling, so keep
            // them grouped at run granularity. That preserves a stable ordering model for
            // run-level metadata updates without coupling workflow_job throughput to it.
            const runId = extractRunId(payload, eventType);
            if (runId > 0) return 'run-' + String(runId % WORKFLOW_RUN_GROUP_BUCKETS);

            // Installation and malformed payloads do not have stable job/run identity.
            // Hash the delivery/request identity into a bounded fallback bucket so we
            // still satisfy FIFO queue requirements without creating unbounded group IDs.
            const fallback = String(deliveryId || requestId || 'fallback').trim();
            const digest = crypto.createHash('sha256').update(fallback).digest('hex');
            return 'delivery-' + digest.slice(0, 8);
          }

          function log(level, message, fields = {}) {
            const entry = {
              level,
              message,
              ...fields,
            };
            const rendered = JSON.stringify(entry);
            if (level === 'error') {
              console.error(rendered);
              return;
            }
            if (level === 'warn') {
              console.warn(rendered);
              return;
            }
            console.log(rendered);
          }

          function jsonResponse(statusCode, body) {
            return {
              statusCode,
              headers: { 'content-type': 'application/json' },
              body: JSON.stringify(body),
            };
          }

          function methodNotAllowed(allowedMethods) {
            return {
              statusCode: 405,
              headers: {
                'content-type': 'application/json',
                allow: allowedMethods.join(', '),
              },
              body: JSON.stringify({ error: 'method not allowed' }),
            };
          }

          function createHandler(options = {}) {
            const aws = options.aws || createDefaultAws();

            async function enqueueWebhook(request) {
              const headers = request.headers;
              const deliveryId = String(headers['x-github-delivery'] || '').trim();
              const eventType = String(headers['x-github-event'] || '').trim();
              const provided = String(headers['x-hub-signature-256'] || '').trim();
              const payload = payloadBuffer(request).toString('utf8');

              let parsedBody;
              try {
                parsedBody = JSON.parse(payload);
              } catch (_) {
                parsedBody = null;
              }

              const logFields = webhookLogFields(parsedBody, eventType, deliveryId);
              log('info', 'Received GitHub webhook', logFields);
              const groupId = webhookGroupId(parsedBody, eventType, deliveryId, request.requestId);
              // Keep deduplication tied to the GitHub delivery so a retried webhook does
              // not get re-enqueued, even though different lifecycle events for the same
              // job are intentionally free to land in the same FIFO job bucket.
              const deduplicationId = String(deliveryId || request.requestId || groupId).trim();
              log('info', 'Queueing GitHub webhook', {
                message_group_id: groupId,
                ...logFields,
              });
              await aws.sendQueueMessage({
                QueueUrl: process.env.RUNS_ON_WEBHOOKS_QUEUE_URL,
                MessageBody: JSON.stringify({
                  event_type: eventType,
                  delivery_id: deliveryId,
                  signature_256: provided,
                  payload,
                }),
                MessageDeduplicationId: deduplicationId,
                MessageGroupId: groupId,
              });
              log('info', 'Queued GitHub webhook', {
                message_group_id: groupId,
                ...logFields,
              });
              return jsonResponse(202, { status: 'accepted' });
            }

            return async function handler(event) {
              const request = requestFor(event);
              const method = request.method;
              const path = request.path;
              const requestId = request.requestId;

              log('info', 'Public ingress request received', {
                request_id: requestId,
                method,
                path,
              });

              try {
                let response;
                if (path === '/github/webhooks') {
                  response = method === 'POST' ? await enqueueWebhook(request) : methodNotAllowed(['POST']);
                } else {
                  response = jsonResponse(404, { error: 'not found' });
                }

                log('info', 'Public ingress request completed', {
                  request_id: requestId,
                  method,
                  path,
                  status_code: response.statusCode,
                });
                return response;
              } catch (error) {
                log('error', 'Public ingress request failed', {
                  request_id: requestId,
                  method,
                  path,
                  error: String(error?.message || error),
                });
                return jsonResponse(500, { error: 'internal server error' });
              }
            };
          }

          module.exports = {
            createHandler,
            handler: createHandler(),
          };
  RunsOnGitHubAppsSetupFunction:
    Condition: AdminRoutesEnabled
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs24.x
      Handler: index.handler
      Role: !GetAtt RunsOnGitHubAppsSetupRole.Arn
      Timeout: 15
      MemorySize: 256
      Environment:
        Variables:
          RUNS_ON_GITHUB_ORG: !Ref GithubOrganization
          RUNS_ON_GITHUB_APPS_SECRET_ARN: !Ref RunsOnGitHubAppsSecret
          RUNS_ON_STACK_CONFIG_SECRET_ARN: !Ref RunsOnStackConfigSecret
          RUNS_ON_STACK_CONFIG_SECRET_VERSION: !GetAtt StackConfigMaterializer.StackConfigSecretVersion
          RUNS_ON_LICENSE_STATUS_SSM_PARAMETER: !Ref RunsOnLicenseStatusParameter
          RUNS_ON_APP_TAG: !FindInMap [App, Tags, AppTag]
          RUNS_ON_PUBLIC_BASE_URL: !Sub "https://${RunsOnPublicIngressRestApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
          RUNS_ON_TOPIC_ARN: !Ref AlertTopic
          RUNS_ON_SYSTEM_QUEUE_URL: !Ref RunsOnQueueSystem
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      Code:
        ZipFile: |
          'use strict';

          const crypto = require('node:crypto');

          const SETUP_STATE_MAX_AGE_SECONDS = 30 * 60;

          function createDefaultAws() {
            let initialized = false;
            let secrets;
            let sqs;
            let sns;
            let iam;
            let ssm;
            let GetSecretValueCommand;
            let PutSecretValueCommand;
            let SendMessageCommand;
            let ListSubscriptionsByTopicCommand;
            let GetRoleCommand;
            let CreateServiceLinkedRoleCommand;
            let GetParameterCommand;

            function init() {
              if (initialized) return;
              initialized = true;
              const secretsSdk = require('@aws-sdk/client-secrets-manager');
              const sqsSdk = require('@aws-sdk/client-sqs');
              const snsSdk = require('@aws-sdk/client-sns');
              const iamSdk = require('@aws-sdk/client-iam');
              const ssmSdk = require('@aws-sdk/client-ssm');

              secrets = new secretsSdk.SecretsManagerClient({});
              sqs = new sqsSdk.SQSClient({});
              sns = new snsSdk.SNSClient({});
              iam = new iamSdk.IAMClient({});
              ssm = new ssmSdk.SSMClient({});
              GetSecretValueCommand = secretsSdk.GetSecretValueCommand;
              PutSecretValueCommand = secretsSdk.PutSecretValueCommand;
              SendMessageCommand = sqsSdk.SendMessageCommand;
              ListSubscriptionsByTopicCommand = snsSdk.ListSubscriptionsByTopicCommand;
              GetRoleCommand = iamSdk.GetRoleCommand;
              CreateServiceLinkedRoleCommand = iamSdk.CreateServiceLinkedRoleCommand;
              GetParameterCommand = ssmSdk.GetParameterCommand;
            }

            return {
              async getSecret(params) {
                init();
                return secrets.send(new GetSecretValueCommand(params));
              },
              async putSecret(params) {
                init();
                return secrets.send(new PutSecretValueCommand(params));
              },
              async sendQueueMessage(params) {
                init();
                return sqs.send(new SendMessageCommand(params));
              },
              async listSubscriptionsByTopic(params) {
                init();
                return sns.send(new ListSubscriptionsByTopicCommand(params));
              },
              async getRole(params) {
                init();
                return iam.send(new GetRoleCommand(params));
              },
              async createServiceLinkedRole(params) {
                init();
                return iam.send(new CreateServiceLinkedRoleCommand(params));
              },
              async getParameter(params) {
                init();
                return ssm.send(new GetParameterCommand(params));
              },
            };
          }

          function githubBaseUrl() {
            const enterprise = String(process.env.RUNS_ON_GITHUB_ENTERPRISE_URL || '').trim();
            return enterprise ? enterprise.replace(/\/+$/, '') + '/' : 'https://github.com/';
          }

          function githubApiUrl() {
            const enterprise = String(process.env.RUNS_ON_GITHUB_ENTERPRISE_URL || '').trim();
            return enterprise ? enterprise.replace(/\/+$/, '') + '/api/v3/' : 'https://api.github.com/';
          }

          function normalizedHeaders(headers) {
            return Object.fromEntries(Object.entries(headers || {}).map(([key, value]) => [String(key).toLowerCase(), value]));
          }

          function normalizePath(path, stage) {
            const raw = String(path || '').trim() || '/';
            if (!stage) return raw;
            const stagePrefix = '/' + String(stage).replace(/^\/+|\/+$/g, '');
            if (raw === stagePrefix) return '/';
            if (raw.startsWith(stagePrefix + '/')) return raw.slice(stagePrefix.length);
            return raw;
          }

          function requestFor(event) {
            const requestContext = event.requestContext || {};
            const stage = String(requestContext.stage || '').trim();
            return {
              method: String(event.httpMethod || '').trim(),
              path: normalizePath(event.path || '/', stage),
              requestId: String(requestContext.requestId || '').trim(),
              headers: normalizedHeaders(event.headers),
              queryStringParameters: event.queryStringParameters || {},
            };
          }

          function baseUrl() {
            const configured = String(process.env.RUNS_ON_PUBLIC_BASE_URL || '').trim();
            if (!configured) throw new Error('RUNS_ON_PUBLIC_BASE_URL is required');
            return configured.replace(/\/+$/, '');
          }

          function log(level, message, fields = {}) {
            const entry = {
              level,
              message,
              ...fields,
            };
            const rendered = JSON.stringify(entry);
            if (level === 'error') {
              console.error(rendered);
              return;
            }
            if (level === 'warn') {
              console.warn(rendered);
              return;
            }
            console.log(rendered);
          }

          function jsonResponse(statusCode, body) {
            return {
              statusCode,
              headers: { 'content-type': 'application/json' },
              body: JSON.stringify(body),
            };
          }

          function methodNotAllowed(allowedMethods) {
            return {
              statusCode: 405,
              headers: {
                'content-type': 'application/json',
                allow: allowedMethods.join(', '),
              },
              body: JSON.stringify({ error: 'method not allowed' }),
            };
          }

          function htmlResponse(body, statusCode = 200) {
            return {
              statusCode,
              headers: { 'content-type': 'text/html; charset=utf-8' },
              body,
            };
          }

          function redirect(location) {
            let target = String(location || '').trim();
            if (!/^https?:\/\//.test(target)) {
              target = appUrl(target);
            }
            return { statusCode: 302, headers: { location: target }, body: '' };
          }

          function appUrl(path) {
            const raw = String(path || '').trim();
            const normalized = raw.startsWith('/') ? raw : '/' + raw;
            const configured = String(process.env.RUNS_ON_PUBLIC_BASE_URL || '').trim();
            if (configured) {
              return configured.replace(/\/+$/, '') + normalized;
            }
            return normalized;
          }

          function base64UrlEncode(value) {
            return Buffer.from(value).toString('base64url');
          }

          function base64UrlDecode(value) {
            return Buffer.from(String(value || ''), 'base64url').toString('utf8');
          }

          function timingSafeEqualString(left, right) {
            const leftBuffer = Buffer.from(String(left || ''));
            const rightBuffer = Buffer.from(String(right || ''));
            if (leftBuffer.length !== rightBuffer.length) return false;
            return crypto.timingSafeEqual(leftBuffer, rightBuffer);
          }

          function setupStateSigningKey(licenseKey) {
            const rawLicenseKey = String(licenseKey || '').trim();
            if (!rawLicenseKey) {
              throw new Error('stack config LicenseKey is required for setup state signing');
            }
            return crypto
              .createHmac('sha256', rawLicenseKey)
              .update('runs-on setup state')
              .update('\0')
              .update(configuredGitHubOrg())
              .update('\0')
              .update(baseUrl())
              .digest();
          }

          function signSetupState(payload, licenseKey) {
            const encodedPayload = base64UrlEncode(JSON.stringify(payload));
            const signature = crypto
              .createHmac('sha256', setupStateSigningKey(licenseKey))
              .update(encodedPayload)
              .digest('base64url');
            return `${encodedPayload}.${signature}`;
          }

          function setupStatePayload(kind, idFactory, now, extra = {}) {
            return {
              v: 1,
              kind,
              org: configuredGitHubOrg(),
              base_url: baseUrl(),
              iat: Math.floor(now() / 1000),
              nonce: idFactory(),
              ...extra,
            };
          }

          function createSetupState(kind, licenseKey, idFactory, now, extra = {}) {
            return signSetupState(setupStatePayload(kind, idFactory, now, extra), licenseKey);
          }

          function verifySetupState(token, expectedKind, licenseKey, now, expected = {}) {
            const rawToken = String(token || '').trim();
            if (!rawToken) return { ok: false, message: 'The setup callback is missing its signed state.' };

            const parts = rawToken.split('.');
            if (parts.length !== 2 || !parts[0] || !parts[1]) {
              return { ok: false, message: 'The setup callback state is malformed.' };
            }

            const expectedSignature = crypto
              .createHmac('sha256', setupStateSigningKey(licenseKey))
              .update(parts[0])
              .digest('base64url');
            if (!timingSafeEqualString(parts[1], expectedSignature)) {
              return { ok: false, message: 'The setup callback state signature is invalid.' };
            }

            let payload;
            try {
              payload = JSON.parse(base64UrlDecode(parts[0]));
            } catch (_) {
              return { ok: false, message: 'The setup callback state payload is invalid.' };
            }

            const issuedAt = Number(payload?.iat || 0);
            const nowSeconds = Math.floor(now() / 1000);
            if (!Number.isFinite(issuedAt) || issuedAt <= 0 || nowSeconds - issuedAt > SETUP_STATE_MAX_AGE_SECONDS || issuedAt - nowSeconds > 60) {
              return { ok: false, message: 'The setup callback state has expired.' };
            }
            if (payload?.v !== 1 || payload?.kind !== expectedKind) {
              return { ok: false, message: 'The setup callback state does not match this setup flow.' };
            }
            if (!stringsEqualFold(payload?.org, configuredGitHubOrg()) || payload?.base_url !== baseUrl()) {
              return { ok: false, message: 'The setup callback state does not match this RunsOn stack.' };
            }
            if (expected.primaryAppId && String(payload?.primary_app_id || '') !== String(expected.primaryAppId)) {
              return { ok: false, message: 'The setup callback state does not match the current primary GitHub App.' };
            }

            return { ok: true, payload };
          }

          function stringsEqualFold(left, right) {
            return String(left || '').toLowerCase() === String(right || '').toLowerCase();
          }

          function escapeHtml(value) {
            return String(value || '')
              .replaceAll('&', '&amp;')
              .replaceAll('<', '&lt;')
              .replaceAll('>', '&gt;')
              .replaceAll('"', '&quot;')
              .replaceAll("'", '&#39;');
          }

          function pageCss() {
            return `:root{color-scheme:light;--page-bg:#11111b;--page-bg-accent:#11111b;--panel:#fff;--panel-muted:#f8fafc;--line:#e5e7eb;--line-strong:#23272f;--text:#111827;--muted:#4b5563;--accent:#7540bf;--accent-hover:#5a2d99;--accent-soft:#f5f0ff;--brand:#eab308;--brand-soft:#fefce8;--brand-muted:rgba(234,179,8,.18);--healthy:#166534;--healthy-soft:#ecfdf5;--warning:#92400e;--warning-soft:#fffbeb;--error:#b91c1c;--error-soft:#fef2f2}*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:linear-gradient(180deg,var(--page-bg) 0%,var(--page-bg-accent) 24%,#eef2f7 24%,#eef2f7 100%);color:var(--text)}a{color:var(--accent);text-decoration:underline;text-decoration-color:rgba(117,64,191,.4);text-underline-offset:0.18em}a:hover{text-decoration-color:var(--accent)}.page{max-width:980px;margin:0 auto;padding:28px 20px 56px}.top-bar{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;padding:0 0 24px;border-bottom:1px solid rgba(255,255,255,.14)}.top-bar-brand{display:flex;align-items:center;gap:12px;text-decoration:none}.top-bar-brand:hover{text-decoration:none}.logo{width:48px;height:48px;display:block;filter:drop-shadow(0 0 8px rgba(234,179,8,.3))}.brand-name{margin:0;font-size:18px;font-weight:800;letter-spacing:-0.01em;color:#fff}.nav-bar{display:flex;flex-wrap:wrap;gap:18px}.nav-link{display:inline-flex;align-items:center;padding:10px 0 8px;border-bottom:2px solid transparent;font-size:14px;font-weight:600;color:#8b92a1;text-decoration:none;transition:border-color .15s,color .15s}.nav-link:hover{color:#e2e5eb;text-decoration:none}.nav-link.nav-active{border-bottom-color:#c9afe4;color:#c9afe4}.card{background:var(--panel);border:1px solid var(--line-strong);padding:36px 34px 32px}.title{margin:0 0 12px;font-size:38px;line-height:1.02;letter-spacing:-0.04em}.lead{max-width:720px;margin:0 0 24px;font-size:17px;line-height:1.7;color:var(--muted)}.setup-copy{max-width:760px;margin:0 0 18px;font-size:16px;line-height:1.7;color:var(--muted)}.actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:20px}.button,.icon-button{display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--accent);background:var(--accent);color:#fff;font:inherit;font-weight:700;cursor:pointer;text-decoration:none;transition:background .15s,color .15s,border-color .15s,box-shadow .15s}.button{min-height:44px;padding:0 16px}.button:hover,.icon-button:hover{background:var(--accent-hover);border-color:var(--accent-hover);color:#fff;text-decoration:none}.button.secondary,.button.ghost,.icon-button.secondary{background:#fff;color:var(--accent);border-color:#d7caef}.button.secondary:hover,.button.ghost:hover,.icon-button.secondary:hover{background:var(--accent-soft);border-color:var(--accent)}.button.cta{min-height:50px;padding:0 28px;font-size:16px;box-shadow:0 2px 12px rgba(117,64,191,.25);letter-spacing:.01em}.button.cta:hover{box-shadow:0 4px 20px rgba(117,64,191,.35)}.private-badge{display:inline-flex;align-items:center;gap:6px;padding:6px 14px;border-radius:6px;background:rgba(234,179,8,.08);border:1px solid rgba(234,179,8,.22);color:#92700e;font-size:13px;font-weight:600;margin-bottom:6px}.private-badge::before{content:'';display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--brand);flex:0 0 auto}.section{margin-top:30px;padding-top:22px;border-top:1px solid var(--line)}.section-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:0 0 14px}.section h2,.section-header h2{margin:0 0 14px;font-size:20px;font-weight:700;letter-spacing:0;color:var(--text)}.section-header h2{margin:0}.icon-button{width:40px;height:40px;padding:0;font-size:18px;line-height:1}.app-list{display:grid;gap:10px}.app-card{padding:16px 20px;border:1px solid var(--line);border-left:3px solid var(--brand-muted);background:var(--panel-muted);transition:border-left-color .15s}.app-card:hover{border-left-color:var(--brand)}.app-row{display:flex;align-items:center;gap:16px;justify-content:space-between}.app-identity{flex:1;min-width:0}.app-header{display:flex;align-items:center;gap:8px;margin:0 0 4px;flex-wrap:wrap}.app-name{margin:0;font-size:15px;font-weight:700;letter-spacing:-0.02em}.app-url{font-size:12px;color:var(--muted);text-decoration:none;display:block;margin-bottom:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.app-url:hover{color:var(--accent);text-decoration:none}.app-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700;flex-shrink:0}.app-tag::before{content:'';display:inline-block;width:6px;height:6px;border-radius:50%;flex:0 0 auto}.app-tag.healthy{background:var(--healthy-soft);color:var(--healthy)}.app-tag.healthy::before{background:#22c55e}.app-tag.warning{background:var(--warning-soft);color:var(--warning)}.app-tag.warning::before{background:#f59e0b}.app-tag.error{background:var(--error-soft);color:var(--error)}.app-tag.error::before{background:#ef4444}.app-actions{flex-shrink:0;display:flex;flex-wrap:wrap;gap:8px}.app-actions .button{white-space:nowrap}.section-desc{margin:0 0 14px;font-size:14px;color:var(--muted);line-height:1.6}.app-meta{margin:0;color:var(--muted);font-size:13px;line-height:1.5}.code{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;background:#eef2f7;padding:2px 6px}.muted{color:var(--muted)}.status-badge{display:inline-flex;align-items:center;gap:7px;padding:5px 12px 5px 10px;border-radius:6px;font-size:13px;font-weight:700;letter-spacing:.01em;margin-bottom:18px}.status-badge::before{content:'';display:inline-block;width:8px;height:8px;border-radius:50%;flex:0 0 auto}.status-badge.healthy{background:var(--healthy-soft);color:var(--healthy);border:1px solid rgba(22,101,52,.12)}.status-badge.healthy::before{background:#22c55e}.status-badge.warning{background:var(--warning-soft);color:var(--warning);border:1px solid rgba(146,64,14,.12)}.status-badge.warning::before{background:#f59e0b}.status-badge.error{background:var(--error-soft);color:var(--error);border:1px solid rgba(185,28,28,.12)}.status-badge.error::before{background:#ef4444}.resource-list{margin:0;padding:0;list-style:none;display:grid;gap:2px}.resource-link{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border-radius:8px;font-size:14px;font-weight:500;color:var(--text);text-decoration:none;border:1px solid transparent;transition:background .12s,border-color .12s,color .12s}.resource-link:hover{background:var(--accent-soft);border-color:#ddd0f7;color:var(--accent);text-decoration:none}.resource-arrow{color:var(--accent);opacity:.5;font-size:14px;transition:opacity .12s}.resource-link:hover .resource-arrow{opacity:1}.notice{margin:24px 0 0;padding:11px 16px;border-left:3px solid var(--accent);background:var(--accent-soft);font-size:13px;color:#5a3d8a;line-height:1.6}.health{margin-top:30px}.health-grid{display:grid;gap:12px}.health-item{display:grid;grid-template-columns:12px minmax(0,1fr);gap:14px;align-items:flex-start;padding:16px 18px;border:1px solid var(--line);border-left-width:4px;background:var(--panel-muted);transition:border-left-color .15s}.health-dot{width:10px;height:10px;margin-top:5px;background:#9ca3af;flex:0 0 auto}.health-item.healthy{border-left-color:#22c55e;background:var(--healthy-soft)}.health-item.healthy .health-dot{background:#22c55e}.health-item.warning{border-left-color:#f59e0b;background:var(--warning-soft)}.health-item.warning .health-dot{background:#f59e0b}.health-item.error{border-left-color:#ef4444;background:var(--error-soft)}.health-item.error .health-dot{background:#ef4444}.health-name{margin:0 0 4px;font-size:15px;font-weight:800;letter-spacing:-0.02em}.health-message,.health-hint{margin:0;font-size:14px;line-height:1.6;color:#6b7280}.health-hint{margin-top:6px}@media (max-width:640px){body{background:linear-gradient(180deg,var(--page-bg) 0%,var(--page-bg-accent) 18%,#eef2f7 18%,#eef2f7 100%)}.page{padding:18px 14px 40px}.top-bar{flex-direction:column;align-items:flex-start;padding-bottom:18px}.nav-bar{gap:14px}.card{padding:26px 22px 24px}.title{font-size:30px}.lead,.setup-copy{font-size:16px}.section-header{align-items:center}.app-row{flex-direction:column;align-items:flex-start}.app-actions{width:100%}.app-actions .button{flex:1 1 auto;text-align:center;justify-content:center}}`;
          }

          function toneForStatus(status) {
            if (status === 'ready' || status === 'healthy') return 'healthy';
            if (status === 'error') return 'error';
            return 'warning';
          }

          function renderNav(activePath) {
            if (!activePath) return '';
            const items = [
              { label: 'Apps', path: '/setup/apps' },
              { label: 'Status', path: '/setup/success' },
            ];
            const links = items.map((item) => {
              const active = item.path === activePath ? ' nav-active' : '';
              return `<a class="nav-link${active}" href="${escapeHtml(appUrl(item.path))}">${escapeHtml(item.label)}</a>`;
            }).join('');
            return `<nav class="nav-bar">${links}</nav>`;
          }

          function renderDocument(title, lead, body, activePath, preTitle) {
            const nav = renderNav(activePath);
            const preTitleHtml = preTitle || '';
            const notice = `<p class="notice">These setup pages can be disabled by setting the <span class="code">EnableAdminRoutes</span> stack parameter to <span class="code">false</span>.</p>`;
            return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(title)}</title><style>${pageCss()}</style></head><body><main class="page"><header class="top-bar"><a class="top-bar-brand" href="${escapeHtml(appUrl('/setup/apps'))}"><img class="logo" src="https://runs-on.com/logo.png" alt="RunsOn logo"></a>${nav}</header><section class="card">${preTitleHtml}<h1 class="title">${escapeHtml(title)}</h1><p class="lead">${escapeHtml(lead)}</p>${body}${notice}</section></main></body></html>`;
          }

          function renderPage(title, lead, body, statusCode = 200, activePath = null, preTitle = '') {
            return htmlResponse(renderDocument(title, lead, body, activePath, preTitle), statusCode);
          }

          function configuredGitHubOrg() {
            return String(process.env.RUNS_ON_GITHUB_ORG || '').trim();
          }

          function appSlugFromConfig(appConfig) {
            const explicit = String(appConfig?.slug || '').trim();
            if (explicit) return explicit;

            const rawUrl = String(appConfig?.html_url || '').trim();
            if (!rawUrl) return '';

            try {
              const pathname = new URL(rawUrl).pathname.replace(/\/+$/, '');
              const match = pathname.match(/\/apps\/([^/]+)$/) || pathname.match(/\/settings\/apps\/([^/]+)$/);
              return match ? decodeURIComponent(match[1]) : '';
            } catch (_) {
              return '';
            }
          }

          function appPublicUrl(appConfig) {
            const rawUrl = String(appConfig?.html_url || '').trim();
            if (rawUrl) return rawUrl;

            const slug = appSlugFromConfig(appConfig);
            return slug ? githubBaseUrl() + 'apps/' + encodeURIComponent(slug) : '';
          }

          function appSettingsUrl(appConfig) {
            const slug = appSlugFromConfig(appConfig);
            if (!slug) return '';

            const org = configuredGitHubOrg();
            const ownerPath = org
              ? 'organizations/' + encodeURIComponent(org) + '/settings/apps/'
              : 'settings/apps/';
            return githubBaseUrl() + ownerPath + encodeURIComponent(slug);
          }

          function appInstallationUrl(appConfig) {
            const settingsUrl = appSettingsUrl(appConfig);
            if (settingsUrl) {
              return settingsUrl.replace(/\/+$/, '') + '/installations';
            }

            const publicUrl = appPublicUrl(appConfig);
            return publicUrl ? publicUrl.replace(/\/+$/, '') + '/installations/new' : '';
          }

          function appNewInstallationUrl(appConfig) {
            const publicUrl = appPublicUrl(appConfig);
            return publicUrl ? publicUrl.replace(/\/+$/, '') + '/installations/new' : '';
          }

          function manifestForPrimary() {
            const url = baseUrl();
            return {
              default_events: ['workflow_run', 'workflow_job', 'meta'],
              default_permissions: {
                administration: 'write',
                single_file: 'write',
                metadata: 'read',
                members: 'read',
                actions: 'write',
                deployments: 'read',
              },
              single_file_paths: ['.github/runs-on.yml'],
              description: 'Deploy ephemeral and cheap self-hosted runners for your GitHub Actions workflows, in your AWS account',
              hook_attributes: { url: url + '/github/webhooks' },
              name: `RunsOn [${process.env.RUNS_ON_GITHUB_ORG}]`,
              public: false,
              redirect_url: url + '/setup/convert',
              setup_url: url + '/setup/success',
              setup_on_update: true,
              url: 'https://runs-on.com',
              version: 'v1',
            };
          }

          function manifestForBoost(idFactory) {
            const url = baseUrl();
            const suffix = String(idFactory()).replaceAll('-', '').slice(0, 8);
            return {
              default_permissions: {
                administration: 'write',
              },
              description: 'API-only RunsOn GitHub App for same-organization rate-limit expansion',
              name: `RunsOn API Boost [${process.env.RUNS_ON_GITHUB_ORG}] ${suffix}`,
              public: false,
              redirect_url: url + '/setup/apps/convert',
              setup_url: url + '/setup/apps',
              url: 'https://runs-on.com',
              version: 'v1',
            };
          }

          function buildPendingPersistedGitHubApps(appConfig, existingPersisted = null) {
            return {
              primary: {
                app_config: appConfig,
              },
              boosts: existingPersisted?.boosts && typeof existingPersisted.boosts === 'object' ? existingPersisted.boosts : {},
            };
          }

          function clonePersisted(persisted) {
            if (!persisted || typeof persisted !== 'object') return {};
            return JSON.parse(JSON.stringify(persisted));
          }

          function boostAppConfigForPersistence(appConfig) {
            const persisted = { ...appConfig };
            delete persisted.webhook_secret;
            delete persisted.webhookSecret;
            return persisted;
          }

          function stateForPersisted(persisted) {
            const primary = persisted?.primary || {};
            const appConfig = primary?.app_config || {};
            const state = {
              persisted: persisted && typeof persisted === 'object' ? persisted : null,
              hasPrimaryApp: !!appConfig?.id,
              appId: appConfig?.id ? String(appConfig.id) : '',
              appName: String(appConfig?.name || '').trim(),
              appUrl: appPublicUrl(appConfig),
              appHref: '',
              installationUrl: '',
              installationsUrl: '',
            };
            state.appHref = appSettingsUrl(appConfig) || state.appUrl;
            state.installationUrl = appNewInstallationUrl(appConfig);
            state.installationsUrl = appInstallationUrl(appConfig);
            return state;
          }

          function readyConfigFromStackConfig(config) {
            return {
              private: String(config?.Private || '').trim(),
            };
          }

          function renderSetupPage(licenseKey, idFactory, now) {
            const setupState = createSetupState('primary', licenseKey, idFactory, now);
            const manifest = manifestForPrimary();
            const action = githubBaseUrl() + 'organizations/' + process.env.RUNS_ON_GITHUB_ORG + '/settings/apps/new';
            return renderPage(
              'Welcome to RunsOn',
              'You are just a few clicks away from fast, secure, self-hosted GitHub runners at the best price possible.',
              `<p class="setup-copy">Create and install a private GitHub App in your organization so this RunsOn stack can receive workflow webhooks and manage self-hosted runners for your jobs.</p><p class="setup-copy">This GitHub App stays private to your organization. RunsOn uses it only to get the permissions needed to launch and manage runners for your workflows.</p><div class="private-badge">Private to your organization</div><form action="${escapeHtml(action)}" method="post"><input type="hidden" name="manifest" value="${escapeHtml(JSON.stringify(manifest))}"><input type="hidden" name="state" value="${escapeHtml(setupState)}"><div class="actions"><button class="button cta" type="submit">Register GitHub App</button></div></form><p class="setup-copy">Note: RunsOn is not affiliated with GitHub.</p>${renderResources()}`,
            );
          }

          function renderHealthIndicators(indicators) {
            if (!Array.isArray(indicators) || indicators.length === 0) return '';
            const header = `<div class="section-header"><h2>Health checks</h2><button class="icon-button secondary" type="button" title="Refresh health checks" aria-label="Refresh health checks" onclick="window.location.reload()">&#8635;</button></div>`;
            const items = indicators.map((indicator) => {
              const tone = toneForStatus(indicator.status);
              const hint = indicator.hint ? `<p class="health-hint">${escapeHtml(indicator.hint)}</p>` : '';
              const link = indicator.url ? `<p class="health-hint"><a href="${escapeHtml(indicator.url)}">Learn more</a></p>` : '';
              return `<article class="health-item ${escapeHtml(tone)}"><div class="health-dot"></div><div><p class="health-name">${escapeHtml(indicator.name)}</p><p class="health-message">${escapeHtml(indicator.message)}</p>${hint}${link}</div></article>`;
            }).join('');
            return `<section class="health section">${header}<div class="health-grid">${items}</div></section>`;
          }

          function renderResources() {
            const links = [
              { href: 'https://runs-on.com/docs/', label: 'Documentation' },
              { href: 'https://github.com/runs-on/runs-on', label: 'GitHub project' },
              { href: 'https://github.com/runs-on/runs-on/releases', label: 'Latest releases' },
              { href: 'https://runs-on.com/pricing/', label: 'Pricing &amp; license' },
            ];
            const items = links.map(({ href, label }) => `<li><a class="resource-link" href="${escapeHtml(href)}">${label}<span class="resource-arrow">&#8594;</span></a></li>`).join('');
            return `<section class="section"><div class="section-header"><h2>Resources</h2></div><ul class="resource-list">${items}</ul></section>`;
          }

          function renderSetupSuccessPage(state, indicators) {
            const title = state.hasPrimaryApp ? 'RunsOn setup complete' : 'RunsOn setup';
            const summary = state.hasPrimaryApp
              ? 'GitHub App configuration is stored for this stack.'
              : 'GitHub App setup is still in progress.';

            let preTitle = '';
            if (state.hasPrimaryApp && Array.isArray(indicators) && indicators.length > 0) {
              const allHealthy = indicators.every((i) => toneForStatus(i.status) === 'healthy');
              const hasError = indicators.some((i) => toneForStatus(i.status) === 'error');
              const tone = allHealthy ? 'healthy' : hasError ? 'error' : 'warning';
              const label = allHealthy ? 'All checks passed' : hasError ? 'Issues detected' : 'Attention needed';
              preTitle = `<div class="status-badge ${escapeHtml(tone)}">${escapeHtml(label)}</div>`;
            }

            return renderPage(title, summary, `${renderHealthIndicators(indicators)}${renderResources()}`, 200, '/setup/success', preTitle);
          }

          function boostEntries(persisted) {
            const boosts = persisted?.boosts && typeof persisted.boosts === 'object' ? persisted.boosts : {};
            return Object.entries(boosts)
              .sort(([a], [b]) => a.localeCompare(b))
              .map(([appKey, boost]) => {
                const appConfig = boost?.app_config || {};
                return {
                  appKey,
                  appUrl: appPublicUrl(appConfig),
                  appHref: appSettingsUrl(appConfig) || appPublicUrl(appConfig),
                  installationUrl: appInstallationUrl(appConfig),
                  label: String(boost?.github_app_label || appConfig?.slug || appConfig?.name || appKey).trim() || appKey,
                };
              });
          }

          function renderAppCard(name, appHref, meta, actions, tag) {
            const tagHtml = tag ? `<span class="app-tag ${escapeHtml(tag.tone)}">${escapeHtml(tag.label)}</span>` : '';
            const urlHtml = appHref ? `<a class="app-url" href="${escapeHtml(appHref)}">${escapeHtml(appHref.replace('https://', ''))}</a>` : '';
            const metaHtml = meta ? `<p class="app-meta">${meta}</p>` : '';
            const actionsHtml = actions.length > 0 ? `<div class="app-actions">${actions.join('')}</div>` : '';
            return `<div class="app-card"><div class="app-row"><div class="app-identity"><div class="app-header"><span class="app-name">${escapeHtml(name)}</span>${tagHtml}</div>${urlHtml}${metaHtml}</div>${actionsHtml}</div></div>`;
          }

          function renderAppsPage(state, idFactory, licenseKey, now) {
            const action = githubBaseUrl() + 'organizations/' + process.env.RUNS_ON_GITHUB_ORG + '/settings/apps/new';
            const setupState = createSetupState('boost', licenseKey, idFactory, now, { primary_app_id: state.appId });
            const boostManifest = manifestForBoost(idFactory);

            const primaryActions = [];
            if (state.installationsUrl) {
              primaryActions.push(`<a class="button ghost" href="${escapeHtml(state.installationsUrl)}">Open Installations</a>`);
            }
            const primaryCard = renderAppCard(
              state.appName || 'Primary GitHub App',
              state.appHref,
              'Manages workflow webhook intake for this stack.',
              primaryActions,
              { tone: 'healthy', label: 'Connected' },
            );

            const boosts = boostEntries(state.persisted);
            const boostCards = boosts.length === 0
              ? '<p class="muted">No API boost apps configured yet.</p>'
              : `<div class="app-list">${boosts.map((boost) => {
                const boostActions = boost.installationUrl
                  ? [`<a class="button secondary" href="${escapeHtml(boost.installationUrl)}">Open Installations</a>`]
                  : [];
                return renderAppCard(
                  boost.label,
                  boost.appHref,
                  `app key: <span class="code">${escapeHtml(boost.appKey)}</span>`,
                  boostActions,
                  { tone: 'healthy', label: 'Connected' },
                );
              }).join('')}</div>`;

            return renderPage(
              'GitHub apps',
              'Manage the control-plane GitHub App and add same-organization API boost apps.',
              `<section class="section"><h2>Primary app</h2>${primaryCard}</section><section class="section"><h2>API boost apps</h2><p class="section-desc">Only add extra GitHub Apps to this organization after your logs show GitHub rate-limit issues while RunsOn is generating runner JIT tokens.</p>${boostCards}<form action="${escapeHtml(action)}" method="post"><input type="hidden" name="manifest" value="${escapeHtml(JSON.stringify(boostManifest))}"><input type="hidden" name="state" value="${escapeHtml(setupState)}"><div class="actions" style="margin-top:20px"><button class="button" type="submit">Add Boost App</button></div></form></section>`,
              200,
              '/setup/apps',
            );
          }

          function renderProblemPage(title, lead, message, statusCode = 400) {
            return renderPage(
              title,
              lead,
              `<p class="muted">${escapeHtml(message)}</p><div class="actions"><a class="button secondary" href="${escapeHtml(appUrl('/setup/apps'))}">Back to Apps</a></div>`,
              statusCode,
            );
          }

          function isResourceNotFound(error) {
            const code = String(error?.name || error?.Code || error?.code || '').trim();
            return code === 'ResourceNotFoundException';
          }

          function isAlreadyExists(error) {
            const message = String(error?.message || error || '').toLowerCase();
            return message.includes('already exists') || message.includes('invalidinput');
          }

          function isPendingConfirmation(value) {
            return String(value || '').includes('PendingConfirmation');
          }

          function parseLicenseStatus(raw) {
            const parsed = JSON.parse(String(raw || '').trim());
            if (!parsed || typeof parsed !== 'object') {
              throw new Error('license status payload must be an object');
            }
            const status = String(parsed.status || '').trim().toLowerCase();
            if (!status) {
              throw new Error('license status is required');
            }
            return {
              status,
              message: String(parsed.message || '').trim(),
              errors: Array.isArray(parsed.errors) ? parsed.errors.map((value) => String(value || '').trim()).filter(Boolean) : [],
            };
          }

          async function loadStackConfig(aws) {
            const arn = String(process.env.RUNS_ON_STACK_CONFIG_SECRET_ARN || '').trim();
            if (!arn) {
              log('warn', 'Stack config secret ARN is not configured');
              return {};
            }

            const params = { SecretId: arn };
            const version = String(process.env.RUNS_ON_STACK_CONFIG_SECRET_VERSION || '').trim();
            if (version) {
              params.VersionId = version;
            }

            try {
              const output = await aws.getSecret(params);
              const raw = String(output?.SecretString || '').trim();
              if (!raw) {
                return {};
              }
              let parsed = null;
              try {
                parsed = JSON.parse(raw);
              } catch (_) {
                parsed = null;
              }
              return parsed && typeof parsed === 'object' ? parsed : {};
            } catch (error) {
              if (isResourceNotFound(error)) {
                log('warn', 'Stack config secret not found');
                return {};
              }
              throw error;
            }
          }

          async function loadStackReadyConfig(aws) {
            return readyConfigFromStackConfig(await loadStackConfig(aws));
          }

          function licenseKeyFromStackConfig(config) {
            return String(config?.LicenseKey || '').trim();
          }

          function createGitHubAppJwt(appConfig, now = () => Date.now()) {
            const appID = Number(appConfig?.id || appConfig?.app_id || 0);
            const pem = String(appConfig?.pem || appConfig?.PEM || '').trim();
            if (!appID || !pem) {
              throw new Error('converted GitHub App response is missing app ID or PEM');
            }

            const issuedAt = Math.floor(now() / 1000) - 60;
            const payload = {
              iat: issuedAt,
              exp: issuedAt + 10 * 60,
              iss: String(appID),
            };
            const encodedHeader = base64UrlEncode(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
            const encodedPayload = base64UrlEncode(JSON.stringify(payload));
            const signature = crypto
              .createSign('RSA-SHA256')
              .update(`${encodedHeader}.${encodedPayload}`)
              .sign(pem)
              .toString('base64url');
            return `${encodedHeader}.${encodedPayload}.${signature}`;
          }

          async function buildHealthIndicators(aws) {
            const indicators = [];

            const licenseIndicator = {
              name: 'License',
              description: 'RunsOn license validation status',
              status: 'warning',
              message: 'Verifying',
            };
            const licenseStatusParameter = String(process.env.RUNS_ON_LICENSE_STATUS_SSM_PARAMETER || '').trim();
            if (licenseStatusParameter) {
              try {
                const result = await aws.getParameter({ Name: licenseStatusParameter });
                const value = String(result?.Parameter?.Value || '').trim();
                if (value) {
                  const snapshot = parseLicenseStatus(value);
                  if (snapshot.status === 'valid') {
                    licenseIndicator.status = 'healthy';
                    licenseIndicator.message = snapshot.message || 'License valid';
                  } else if (snapshot.status === 'invalid') {
                    licenseIndicator.status = 'error';
                    licenseIndicator.message = snapshot.message || 'License invalid';
                    if (snapshot.errors.length > 0) {
                      licenseIndicator.hint = snapshot.errors.join('; ');
                    }
                    licenseIndicator.url = 'https://runs-on.com/pricing/';
                  } else {
                    licenseIndicator.status = 'warning';
                    licenseIndicator.message = snapshot.message || 'Verifying';
                  }
                }
              } catch (_) {
                licenseIndicator.status = 'warning';
                licenseIndicator.message = 'Verifying';
              }
            }
            indicators.push(licenseIndicator);

            const topicArn = String(process.env.RUNS_ON_TOPIC_ARN || '').trim();
            const emailIndicator = {
              name: 'Email notifications',
              description: 'SNS email subscription confirmation status',
              hint: 'Check your email inbox for a confirmation email from AWS',
              url: 'https://runs-on.com/monitoring/alerts/',
              status: 'warning',
              message: 'No email subscription found',
            };
            if (!topicArn) {
              emailIndicator.status = 'error';
              emailIndicator.message = 'SNS topic ARN is not configured';
            } else {
              try {
                const result = await aws.listSubscriptionsByTopic({ TopicArn: topicArn });
                let hasConfirmedEmail = false;
                let hasUnconfirmedEmail = false;
                for (const subscription of result?.Subscriptions || []) {
                  if (subscription?.Protocol === 'email') {
                    if (!isPendingConfirmation(subscription?.SubscriptionArn)) {
                      hasConfirmedEmail = true;
                    } else {
                      hasUnconfirmedEmail = true;
                    }
                  }
                }
                if (hasConfirmedEmail) {
                  emailIndicator.status = 'healthy';
                  emailIndicator.message = 'Email subscription confirmed and active';
                } else if (hasUnconfirmedEmail) {
                  emailIndicator.status = 'warning';
                  emailIndicator.message = 'Email subscription pending confirmation';
                }
              } catch (_) {
                emailIndicator.status = 'error';
                emailIndicator.message = 'Unable to check subscription status';
              }
            }
            indicators.push(emailIndicator);

            const spotIndicator = {
              name: 'EC2 Spot Instance Service Role',
              description: 'Required role for launching spot instances',
              status: 'healthy',
              message: 'AWSServiceRoleForEC2Spot role found and active',
            };
            try {
              await aws.getRole({ RoleName: 'AWSServiceRoleForEC2Spot' });
            } catch (error) {
              try {
                await aws.createServiceLinkedRole({ AWSServiceName: 'spot.amazonaws.com' });
                spotIndicator.message = 'AWSServiceRoleForEC2Spot role created and active';
              } catch (createError) {
                if (isAlreadyExists(createError)) {
                  spotIndicator.message = 'AWSServiceRoleForEC2Spot role found and active';
                } else {
                  spotIndicator.status = 'error';
                  spotIndicator.message = 'Failed to create AWSServiceRoleForEC2Spot role';
                }
              }
            }
            indicators.push(spotIndicator);

            return indicators;
          }

          function createHandler(options = {}) {
            const aws = options.aws || createDefaultAws();
            const fetchImpl = options.fetchImpl || fetch;
            const uuid = options.uuid || (() => crypto.randomUUID());
            const now = options.now || (() => Date.now());
            const createAppJwt = options.createAppJwt || ((appConfig) => createGitHubAppJwt(appConfig, now));

            async function loadState() {
              const arn = String(process.env.RUNS_ON_GITHUB_APPS_SECRET_ARN || '').trim();
              if (!arn) {
                log('warn', 'GitHub apps setup secret ARN is not configured');
                return stateForPersisted(null);
              }

              try {
                const output = await aws.getSecret({ SecretId: arn });
                const raw = String(output?.SecretString || '').trim();
                if (!raw) {
                  return stateForPersisted(null);
                }
                let parsed = null;
                try {
                  parsed = JSON.parse(raw);
                } catch (_) {
                  parsed = null;
                }
                const state = stateForPersisted(parsed);
                log('info', 'Loaded GitHub apps setup state', {
                  has_primary_app: state.hasPrimaryApp,
                  app_id: state.appId,
                });
                return state;
              } catch (error) {
                if (isResourceNotFound(error)) {
                  log('warn', 'GitHub apps setup secret not found');
                  return stateForPersisted(null);
                }
                throw error;
              }
            }

            async function readyz() {
              const [state, readyConfig] = await Promise.all([
                loadState(),
                loadStackReadyConfig(aws),
              ]);
              const appTag = String(process.env.RUNS_ON_APP_TAG || '').trim();
              const githubAppConfigured = state.hasPrimaryApp;
              return jsonResponse(githubAppConfigured ? 200 : 503, {
                app_tag: appTag,
                app_version: appTag,
                github_app_configured: githubAppConfigured,
                ready_config: readyConfig,
              });
            }

            async function putPersistedGitHubApps(persisted, secretVersionId) {
              return aws.putSecret({
                SecretId: process.env.RUNS_ON_GITHUB_APPS_SECRET_ARN,
                SecretString: JSON.stringify(persisted),
                ClientRequestToken: secretVersionId,
              });
            }

            async function enqueueGitHubAppsChanged(appKey, reason, secretVersionId) {
              const normalizedAppKey = String(appKey || '').trim();
              const normalizedReason = String(reason || '').trim();
              log('info', 'Queueing GitHub app lifecycle event', {
                queue_type: 'system',
                queue_event: 'github_apps_changed',
                app_key: normalizedAppKey,
                reason: normalizedReason,
              });
              return aws.sendQueueMessage({
                QueueUrl: process.env.RUNS_ON_SYSTEM_QUEUE_URL,
                MessageBody: JSON.stringify({
                  event: 'github_apps_changed',
                  app_key: normalizedAppKey,
                  reason: normalizedReason,
                }),
                MessageDeduplicationId: secretVersionId,
                MessageGroupId: 'app-lifecycle',
              });
            }

            async function convertManifest(code) {
              const response = await fetchImpl(githubApiUrl() + 'app-manifests/' + encodeURIComponent(code) + '/conversions', {
                method: 'POST',
                headers: {
                  Accept: 'application/vnd.github+json',
                  'User-Agent': 'runs-on-setup',
                },
              });
              if (!response.ok) {
                log('error', 'GitHub App manifest conversion failed', {
                  status: response.status,
                });
                return { error: jsonResponse(500, { error: 'app manifest conversion failed', status: response.status }) };
              }
              return { appConfig: await response.json() };
            }

            async function verifyConvertedAppOwner(appConfig) {
              let jwt;
              try {
                jwt = createAppJwt(appConfig);
              } catch (error) {
                log('warn', 'Rejecting converted GitHub App without usable app credentials', {
                  error: String(error?.message || error),
                });
                return { ok: false, message: 'GitHub did not return usable app credentials for ownership validation.' };
              }

              const response = await fetchImpl(githubApiUrl() + 'app', {
                method: 'GET',
                headers: {
                  Accept: 'application/vnd.github+json',
                  Authorization: `Bearer ${jwt}`,
                  'User-Agent': 'runs-on-setup',
                },
              });
              if (!response.ok) {
                log('warn', 'GitHub App ownership validation failed', {
                  status: response.status,
                });
                return { ok: false, message: 'GitHub App ownership could not be validated.' };
              }

              const app = await response.json();
              const ownerLogin = String(app?.owner?.login || '').trim();
              if (!ownerLogin || !stringsEqualFold(ownerLogin, configuredGitHubOrg())) {
                log('warn', 'Rejecting GitHub App owned outside configured organization', {
                  configured_org: configuredGitHubOrg(),
                  owner_login: ownerLogin,
                });
                return { ok: false, message: 'The converted GitHub App is not owned by the configured RunsOn GitHub organization.' };
              }
              return { ok: true, ownerLogin };
            }

            function validateSetupCallbackState(request, kind, licenseKey, expected = {}) {
              const verification = verifySetupState(request.queryStringParameters?.state || '', kind, licenseKey, now, expected);
              if (verification.ok) return null;
              log('warn', 'Rejecting GitHub App setup callback with invalid state', {
                setup_kind: kind,
                reason: verification.message,
              });
              return renderProblemPage('GitHub App setup failed', 'The setup callback could not be validated.', verification.message, 400);
            }

            async function convertPrimary(request, currentState, licenseKey) {
              const code = request.queryStringParameters?.code || '';
              if (!code) {
                log('warn', 'Rejecting setup convert request without code');
                return jsonResponse(400, { error: 'missing code parameter' });
              }
              const stateError = validateSetupCallbackState(request, 'primary', licenseKey);
              if (stateError) return stateError;

              log('info', 'Processing primary GitHub App manifest conversion');
              const conversion = await convertManifest(code);
              if (conversion.error) return conversion.error;

              const appConfig = conversion.appConfig;
              const ownerValidation = await verifyConvertedAppOwner(appConfig);
              if (!ownerValidation.ok) {
                return renderProblemPage('GitHub App setup failed', 'The GitHub App owner could not be validated.', ownerValidation.message, 403);
              }

              const secretVersionId = uuid();
              const persisted = buildPendingPersistedGitHubApps(appConfig, currentState.persisted);
              await putPersistedGitHubApps(persisted, secretVersionId);
              await enqueueGitHubAppsChanged('primary', 'setup_convert', secretVersionId);
              const nextState = stateForPersisted(persisted);
              log('info', 'Stored primary GitHub App config and queued activation', {
                app_id: nextState.appId,
                installation_url: nextState.installationUrl,
              });
              return redirect(nextState.installationUrl || '/setup/success');
            }

            async function convertBoost(request, currentState, licenseKey) {
              const code = request.queryStringParameters?.code || '';
              if (!code) {
                log('warn', 'Rejecting boost convert request without code');
                return jsonResponse(400, { error: 'missing code parameter' });
              }
              const stateError = validateSetupCallbackState(request, 'boost', licenseKey, { primaryAppId: currentState.appId });
              if (stateError) return stateError;

              log('info', 'Processing GitHub boost app manifest conversion');
              const conversion = await convertManifest(code);
              if (conversion.error) return conversion.error;

              const appConfig = conversion.appConfig || {};
              const appKey = String(appConfig.slug || '').trim();
              if (!appKey) {
                return renderProblemPage('Boost app setup failed', 'GitHub did not return a stable app slug.', 'The GitHub manifest conversion response did not contain a slug, so the app could not be stored safely.', 500);
              }
              if (appKey === 'primary') {
                return renderProblemPage('Boost app setup failed', 'The generated GitHub app slug is reserved.', 'Choose a different app name and try again.', 409);
              }

              const persisted = clonePersisted(currentState.persisted);
              persisted.boosts = persisted.boosts && typeof persisted.boosts === 'object' ? persisted.boosts : {};
              if (persisted.boosts[appKey]) {
                return renderProblemPage('Boost app already exists', 'A boost app with this GitHub slug is already configured for the stack.', `The app key ${appKey} is already present in the shared GitHub apps secret.`, 409);
              }

              const ownerValidation = await verifyConvertedAppOwner(appConfig);
              if (!ownerValidation.ok) {
                return renderProblemPage('Boost app setup failed', 'The GitHub App owner could not be validated.', ownerValidation.message, 403);
              }

              persisted.boosts[appKey] = {
                app_config: boostAppConfigForPersistence(appConfig),
                github_app_label: appKey,
              };

              const secretVersionId = uuid();
              await putPersistedGitHubApps(persisted, secretVersionId);
              await enqueueGitHubAppsChanged(appKey, 'boost_setup_convert', secretVersionId);
              const installationUrl = appNewInstallationUrl(appConfig);
              log('info', 'Stored boost GitHub App config and queued reconcile', {
                app_key: appKey,
                app_id: Number(appConfig.id || 0),
                installation_url: installationUrl,
              });
              return redirect(installationUrl || '/setup/apps');
            }

            async function setupSuccess() {
              const state = await loadState();
              if (!state.hasPrimaryApp) return redirect('/setup');
              const indicators = await buildHealthIndicators(aws);
              log('info', 'Rendering setup success page', {
                has_primary_app: state.hasPrimaryApp,
                app_id: state.appId,
              });
              return renderSetupSuccessPage(state, indicators);
            }

            return async function handler(event) {
              const request = requestFor(event);
              const method = request.method;
              const path = request.path;
              const requestId = request.requestId;

              log('info', 'GitHub apps setup request received', {
                request_id: requestId,
                method,
                path,
              });

              try {
                let response;
                if (path === '/') {
                  response = method === 'GET' ? redirect('/setup') : methodNotAllowed(['GET']);
                } else if (path === '/readyz') {
                  response = method === 'GET' ? await readyz() : methodNotAllowed(['GET']);
                } else if (path === '/setup') {
                  if (method !== 'GET') {
                    response = methodNotAllowed(['GET']);
                  } else {
                    const state = await loadState();
                    if (state.hasPrimaryApp) {
                      response = redirect('/setup/apps');
                    } else {
                      const stackConfig = await loadStackConfig(aws);
                      response = renderSetupPage(licenseKeyFromStackConfig(stackConfig), uuid, now);
                    }
                  }
                } else if (path === '/setup/convert') {
                  if (method !== 'GET') {
                    response = methodNotAllowed(['GET']);
                  } else {
                    const state = await loadState();
                    if (state.hasPrimaryApp) {
                      response = redirect('/setup/apps');
                    } else {
                      const stackConfig = await loadStackConfig(aws);
                      response = await convertPrimary(request, state, licenseKeyFromStackConfig(stackConfig));
                    }
                  }
                } else if (path === '/setup/success') {
                  if (method !== 'GET') {
                    response = methodNotAllowed(['GET']);
                  } else {
                    response = await setupSuccess();
                  }
                } else if (path === '/setup/apps') {
                  if (method !== 'GET') {
                    response = methodNotAllowed(['GET']);
                  } else {
                    const state = await loadState();
                    if (!state.hasPrimaryApp) {
                      response = redirect('/setup');
                    } else {
                      const stackConfig = await loadStackConfig(aws);
                      response = renderAppsPage(state, uuid, licenseKeyFromStackConfig(stackConfig), now);
                    }
                  }
                } else if (path === '/setup/apps/convert') {
                  if (method !== 'GET') {
                    response = methodNotAllowed(['GET']);
                  } else {
                    const state = await loadState();
                    if (!state.hasPrimaryApp) {
                      response = redirect('/setup');
                    } else {
                      const stackConfig = await loadStackConfig(aws);
                      response = await convertBoost(request, state, licenseKeyFromStackConfig(stackConfig));
                    }
                  }
                } else {
                  response = jsonResponse(404, { error: 'not found' });
                }

                log('info', 'GitHub apps setup request completed', {
                  request_id: requestId,
                  method,
                  path,
                  status_code: response.statusCode,
                });
                return response;
              } catch (error) {
                log('error', 'GitHub apps setup request failed', {
                  request_id: requestId,
                  method,
                  path,
                  error: String(error?.message || error),
                });
                return jsonResponse(500, { error: 'internal server error' });
              }
            };
          }

          module.exports = {
            buildPendingPersistedGitHubApps,
            createHandler,
            handler: createHandler(),
            renderSetupSuccessPage,
          };
  RunsOnGitHubWafSyncFunction:
    Condition: WafEnabled
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs24.x
      Handler: index.handler
      Role: !GetAtt RunsOnGitHubWafSyncRole.Arn
      Timeout: 30
      MemorySize: 256
      Environment:
        Variables:
          RUNS_ON_GITHUB_META_URL: "https://api.github.com/meta"
          RUNS_ON_WAF_SCOPE: REGIONAL
          RUNS_ON_WAF_IPV4_IP_SET_ID: !GetAtt RunsOnPublicIngressAllowedIpsIPv4.Id
          RUNS_ON_WAF_IPV4_IP_SET_NAME: !Sub "${AWS::StackName}-allowed-ips-ipv4"
          RUNS_ON_WAF_IPV6_IP_SET_ID: !GetAtt RunsOnPublicIngressAllowedIpsIPv6.Id
          RUNS_ON_WAF_IPV6_IP_SET_NAME: !Sub "${AWS::StackName}-allowed-ips-ipv6"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      Code:
        ZipFile: |
          'use strict';

          function createDefaultAws() {
            let initialized = false;
            let wafv2;
            let GetIPSetCommand;
            let UpdateIPSetCommand;

            function init() {
              if (initialized) return;
              initialized = true;
              const wafSdk = require('@aws-sdk/client-wafv2');
              wafv2 = new wafSdk.WAFV2Client({});
              GetIPSetCommand = wafSdk.GetIPSetCommand;
              UpdateIPSetCommand = wafSdk.UpdateIPSetCommand;
            }

            return {
              async getIPSet(params) {
                init();
                return wafv2.send(new GetIPSetCommand(params));
              },
              async updateIPSet(params) {
                init();
                return wafv2.send(new UpdateIPSetCommand(params));
              },
            };
          }

          function log(level, message, fields = {}) {
            const entry = {
              level,
              message,
              ...fields,
            };
            const rendered = JSON.stringify(entry);
            if (level === 'error') {
              console.error(rendered);
              return;
            }
            if (level === 'warn') {
              console.warn(rendered);
              return;
            }
            console.log(rendered);
          }

          function trimString(value) {
            return String(value || '').trim();
          }

          function uniqueSorted(addresses) {
            return Array.from(new Set((addresses || []).map((value) => trimString(value)).filter(Boolean))).sort();
          }

          function splitHookCidrs(meta) {
            const hooks = Array.isArray(meta?.hooks) ? meta.hooks : [];
            if (hooks.length === 0) {
              throw new Error('GitHub meta response did not include any webhook hook CIDRs');
            }

            const ipv4 = [];
            const ipv6 = [];
            for (const cidr of hooks) {
              const normalized = trimString(cidr);
              if (!normalized) continue;
              if (normalized.includes(':')) {
                ipv6.push(normalized);
              } else {
                ipv4.push(normalized);
              }
            }

            if (ipv4.length === 0 && ipv6.length === 0) {
              throw new Error('GitHub meta response did not include any valid webhook hook CIDRs');
            }

            return {
              ipv4: uniqueSorted(ipv4),
              ipv6: uniqueSorted(ipv6),
            };
          }

          function configFromEvent(event = {}, env = process.env) {
            const properties = event?.ResourceProperties || {};
            const input = event?.input && typeof event.input === 'object' ? event.input : {};

            return {
              metaUrl: trimString(properties.MetaUrl || input.meta_url || env.RUNS_ON_GITHUB_META_URL || 'https://api.github.com/meta'),
              scope: trimString(properties.Scope || input.scope || env.RUNS_ON_WAF_SCOPE || 'REGIONAL') || 'REGIONAL',
              ipv4Id: trimString(properties.IPv4IPSetId || input.ipv4_ip_set_id || env.RUNS_ON_WAF_IPV4_IP_SET_ID),
              ipv4Name: trimString(properties.IPv4IPSetName || input.ipv4_ip_set_name || env.RUNS_ON_WAF_IPV4_IP_SET_NAME),
              ipv6Id: trimString(properties.IPv6IPSetId || input.ipv6_ip_set_id || env.RUNS_ON_WAF_IPV6_IP_SET_ID),
              ipv6Name: trimString(properties.IPv6IPSetName || input.ipv6_ip_set_name || env.RUNS_ON_WAF_IPV6_IP_SET_NAME),
            };
          }

          async function fetchMeta(fetchImpl, metaUrl) {
            const response = await fetchImpl(metaUrl, {
              headers: {
                Accept: 'application/json',
                'User-Agent': 'runs-on-waf-sync',
              },
            });

            if (!response.ok) {
              throw new Error(`GitHub meta request failed with status ${response.status}`);
            }
            return response.json();
          }

          async function syncIPSet(aws, params, desiredAddresses) {
            const current = await aws.getIPSet({
              Id: params.id,
              Name: params.name,
              Scope: params.scope,
            });

            const nextAddresses = uniqueSorted(desiredAddresses);
            const existingAddresses = uniqueSorted(current?.IPSet?.Addresses || []);
            if (JSON.stringify(existingAddresses) === JSON.stringify(nextAddresses)) {
              return {
                updated: false,
                address_count: nextAddresses.length,
              };
            }

            await aws.updateIPSet({
              Id: params.id,
              Name: params.name,
              Scope: params.scope,
              Addresses: nextAddresses,
              LockToken: current?.LockToken,
            });

            return {
              updated: true,
              address_count: nextAddresses.length,
            };
          }

          async function performSync(event, options = {}) {
            const aws = options.aws || createDefaultAws();
            const fetchImpl = options.fetchImpl || fetch;
            const config = configFromEvent(event, options.env || process.env);

            if (!config.ipv4Id || !config.ipv4Name || !config.ipv6Id || !config.ipv6Name) {
              throw new Error('Both IPv4 and IPv6 WAF IP set identifiers must be configured');
            }

            const meta = await fetchMeta(fetchImpl, config.metaUrl);
            const hooks = splitHookCidrs(meta);

            const ipv4 = await syncIPSet(aws, {
              id: config.ipv4Id,
              name: config.ipv4Name,
              scope: config.scope,
            }, hooks.ipv4);

            const ipv6 = await syncIPSet(aws, {
              id: config.ipv6Id,
              name: config.ipv6Name,
              scope: config.scope,
            }, hooks.ipv6);

            const result = {
              meta_url: config.metaUrl,
              ipv4,
              ipv6,
            };
            log('info', 'Synchronized GitHub webhook CIDRs into WAF IP sets', result);
            return result;
          }

          async function sendCloudFormationResponse(event, status, data, reason) {
            const responseUrl = trimString(event?.ResponseURL);
            if (!responseUrl) return;

            const body = JSON.stringify({
              Status: status,
              Reason: reason || `See CloudWatch Logs for details: ${trimString(event?.LogicalResourceId) || 'RunsOnGitHubWafSyncSeed'}`,
              PhysicalResourceId: trimString(event?.PhysicalResourceId) || trimString(event?.LogicalResourceId) || 'RunsOnGitHubWafSyncSeed',
              StackId: trimString(event?.StackId),
              RequestId: trimString(event?.RequestId),
              LogicalResourceId: trimString(event?.LogicalResourceId),
              Data: data || {},
            });

            await fetch(responseUrl, {
              method: 'PUT',
              headers: {
                'content-type': '',
                'content-length': String(Buffer.byteLength(body)),
              },
              body,
            });
          }

          function isCloudFormationCustomResourceEvent(event) {
            return !!event?.ResponseURL && !!event?.RequestType;
          }

          async function createHandler(event, options = {}) {
            if (isCloudFormationCustomResourceEvent(event)) {
              if (event.RequestType === 'Delete') {
                await sendCloudFormationResponse(event, 'SUCCESS', { skipped: true }, 'Delete request acknowledged');
                return { skipped: true };
              }

              try {
                const result = await performSync(event, options);
                await sendCloudFormationResponse(event, 'SUCCESS', result);
                return result;
              } catch (error) {
                const message = String(error?.message || error);
                log('error', 'CloudFormation GitHub WAF seed failed', { error: message });
                await sendCloudFormationResponse(event, 'FAILED', { error: message }, message);
                throw error;
              }
            }

            return performSync(event, options);
          }

          module.exports = {
            configFromEvent,
            createHandler,
            handler: (event) => createHandler(event),
            splitHookCidrs,
            uniqueSorted,
          };
  RunsOnGitHubRunnerCacheRefreshFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: nodejs24.x
      Handler: index.handler
      Role: !GetAtt RunsOnGitHubRunnerCacheRefreshRole.Arn
      Timeout: 300
      MemorySize: 512
      Environment:
        Variables:
          RUNS_ON_RUNNER_CACHE_BUCKET: !Ref S3BucketCache
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      Code:
        ZipFile: |
          'use strict';

          const { createHash, randomUUID } = require('node:crypto');
          const fs = require('node:fs');
          const fsp = require('node:fs/promises');
          const os = require('node:os');
          const path = require('node:path');
          const { Readable } = require('node:stream');
          const { pipeline } = require('node:stream/promises');

          const defaultReleaseUrl = 'https://api.github.com/repos/actions/runner/releases/latest';
          const defaultPrefix = 'agents/github-runner';
          const defaultManifestKey = `${defaultPrefix}/latest.json`;
          const runnerVersionPattern = /^\d+\.\d+\.\d+$/;

          function createDefaultAws() {
            let initialized = false;
            let s3;
            let HeadObjectCommand;
            let GetObjectCommand;
            let PutObjectCommand;

            function init() {
              if (initialized) return;
              initialized = true;
              const s3Sdk = require('@aws-sdk/client-s3');
              s3 = new s3Sdk.S3Client({});
              HeadObjectCommand = s3Sdk.HeadObjectCommand;
              GetObjectCommand = s3Sdk.GetObjectCommand;
              PutObjectCommand = s3Sdk.PutObjectCommand;
            }

            return {
              async headObject(params) {
                init();
                return s3.send(new HeadObjectCommand(params));
              },
              async getObject(params) {
                init();
                return s3.send(new GetObjectCommand(params));
              },
              async putObject(params) {
                init();
                return s3.send(new PutObjectCommand(params));
              },
            };
          }

          function log(level, message, fields = {}) {
            const entry = { level, message, ...fields };
            const rendered = JSON.stringify(entry);
            if (level === 'error') {
              console.error(rendered);
              return;
            }
            if (level === 'warn') {
              console.warn(rendered);
              return;
            }
            console.log(rendered);
          }

          function trimString(value) {
            return String(value || '').trim();
          }

          function joinS3Key(...parts) {
            return parts
              .map((value) => trimString(value).replace(/^\/+|\/+$/g, ''))
              .filter(Boolean)
              .join('/');
          }

          function configFromEvent(event = {}, env = process.env) {
            const properties = event?.ResourceProperties || {};
            const input = event?.input && typeof event.input === 'object' ? event.input : {};
            const prefix = trimString(properties.Prefix || input.prefix || env.RUNS_ON_GITHUB_RUNNER_CACHE_PREFIX || defaultPrefix) || defaultPrefix;

            return {
              bucket: trimString(properties.Bucket || input.bucket || env.RUNS_ON_RUNNER_CACHE_BUCKET),
              prefix,
              manifestKey: trimString(properties.ManifestKey || input.manifest_key || env.RUNS_ON_GITHUB_RUNNER_MANIFEST_KEY || `${prefix}/latest.json`) || defaultManifestKey,
              releaseUrl: trimString(properties.ReleaseUrl || input.release_url || env.RUNS_ON_GITHUB_RUNNER_RELEASE_URL || defaultReleaseUrl) || defaultReleaseUrl,
            };
          }

          function runnerAssetSpecs(version) {
            return [
              { key: 'linux-x64', filename: `actions-runner-linux-x64-${version}.tar.gz`, contentType: 'application/gzip' },
              { key: 'linux-arm64', filename: `actions-runner-linux-arm64-${version}.tar.gz`, contentType: 'application/gzip' },
              { key: 'windows-x64', filename: `actions-runner-win-x64-${version}.zip`, contentType: 'application/zip' },
              { key: 'windows-arm64', filename: `actions-runner-win-arm64-${version}.zip`, contentType: 'application/zip' },
            ].map((asset) => ({
              ...asset,
              githubUrl: `https://github.com/actions/runner/releases/download/v${version}/${asset.filename}`,
            }));
          }

          async function fetchLatestVersion(fetchImpl, releaseUrl) {
            const response = await fetchImpl(releaseUrl, {
              headers: {
                Accept: 'application/json',
                'User-Agent': 'runs-on-github-runner-cache-refresh',
              },
            });
            if (!response.ok) {
              throw new Error(`GitHub latest runner release request failed with status ${response.status}`);
            }

            const payload = await response.json();
            const version = trimString(payload?.tag_name).replace(/^v/, '');
            if (!runnerVersionPattern.test(version)) {
              throw new Error(`GitHub latest runner release response did not include a valid tag_name: ${payload?.tag_name}`);
            }
            return version;
          }

          async function getObjectString(aws, bucket, key) {
            const result = await aws.getObject({ Bucket: bucket, Key: key });
            if (!result?.Body) {
              return '';
            }
            if (typeof result.Body.transformToString === 'function') {
              return result.Body.transformToString();
            }

            let body = '';
            for await (const chunk of result.Body) {
              body += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
            }
            return body;
          }

          async function loadExistingManifest(aws, bucket, key) {
            try {
              const body = await getObjectString(aws, bucket, key);
              if (!trimString(body)) {
                return null;
              }
              return JSON.parse(body);
            } catch (error) {
              if (isNotFoundError(error)) {
                return null;
              }
              throw error;
            }
          }

          async function objectExists(aws, bucket, key) {
            try {
              await aws.headObject({ Bucket: bucket, Key: key });
              return true;
            } catch (error) {
              if (isNotFoundError(error)) {
                return false;
              }
              throw error;
            }
          }

          function isNotFoundError(error) {
            const name = trimString(error?.name);
            const code = trimString(error?.Code || error?.code);
            return name === 'NotFound' ||
              name === 'NoSuchKey' ||
              code === 'NotFound' ||
              code === 'NoSuchKey' ||
              error?.$metadata?.httpStatusCode === 404;
          }

          async function downloadToFile(fetchImpl, url, filePath) {
            const response = await fetchImpl(url, {
              headers: {
                Accept: 'application/octet-stream',
                'User-Agent': 'runs-on-github-runner-cache-refresh',
              },
              redirect: 'follow',
            });
            if (!response.ok) {
              throw new Error(`GitHub runner asset request failed with status ${response.status} for ${url}`);
            }
            if (!response.body) {
              throw new Error(`GitHub runner asset response did not include a body for ${url}`);
            }

            await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(filePath));
          }

          async function sha256ForFile(filePath) {
            const hash = createHash('sha256');
            const stream = fs.createReadStream(filePath);
            for await (const chunk of stream) {
              hash.update(chunk);
            }
            return hash.digest('hex');
          }

          function manifestAssetChanged(existing, expectedUrl) {
            return trimString(existing?.url) !== expectedUrl ||
              trimString(existing?.sha256) === '';
          }

          async function mirrorRunnerAsset(aws, fetchImpl, config, version, spec, existingManifest) {
            const objectKey = joinS3Key(config.prefix, version, spec.filename);
            const s3Url = `s3://${config.bucket}/${objectKey}`;
            const existingAsset = existingManifest?.assets?.[spec.key];
            if (
              trimString(existingManifest?.version) === version &&
              !manifestAssetChanged(existingAsset, s3Url) &&
              await objectExists(aws, config.bucket, objectKey)
            ) {
              return {
                reused: true,
                asset: {
                  url: s3Url,
                  sha256: trimString(existingAsset.sha256),
                },
              };
            }

            const tempPath = path.join(os.tmpdir(), `${randomUUID()}-${spec.filename}`);
            try {
              await downloadToFile(fetchImpl, spec.githubUrl, tempPath);
              const sha256 = await sha256ForFile(tempPath);
              await aws.putObject({
                Bucket: config.bucket,
                Key: objectKey,
                Body: fs.createReadStream(tempPath),
                ContentType: spec.contentType,
              });

              return {
                reused: false,
                asset: {
                  url: s3Url,
                  sha256,
                },
              };
            } finally {
              await fsp.rm(tempPath, { force: true });
            }
          }

          function manifestsEqual(left, right) {
            return JSON.stringify(left || null) === JSON.stringify(right || null);
          }

          async function performSync(event, options = {}) {
            const aws = options.aws || createDefaultAws();
            const fetchImpl = options.fetchImpl || fetch;
            const config = configFromEvent(event, options.env || process.env);
            if (!config.bucket) {
              throw new Error('RUNS_ON_RUNNER_CACHE_BUCKET must be configured');
            }

            const version = await fetchLatestVersion(fetchImpl, config.releaseUrl);
            const existingManifest = await loadExistingManifest(aws, config.bucket, config.manifestKey);
            const specs = runnerAssetSpecs(version);

            let reused = 0;
            let uploaded = 0;
            const assets = {};
            for (const spec of specs) {
              const mirrored = await mirrorRunnerAsset(aws, fetchImpl, config, version, spec, existingManifest);
              assets[spec.key] = mirrored.asset;
              if (mirrored.reused) {
                reused += 1;
              } else {
                uploaded += 1;
              }
            }

            const manifest = { version, assets };
            const changed = uploaded > 0 || !manifestsEqual(existingManifest, manifest);
            if (changed) {
              await aws.putObject({
                Bucket: config.bucket,
                Key: config.manifestKey,
                Body: JSON.stringify(manifest, null, 2),
                ContentType: 'application/json',
              });
            }

            const result = {
              bucket: config.bucket,
              manifest_key: config.manifestKey,
              version,
              asset_count: specs.length,
              reused,
              uploaded,
              changed,
            };
            log('info', 'Synchronized GitHub runner mirror into S3 cache', result);
            return result;
          }

          async function sendCloudFormationResponse(event, status, data, reason, fetchImpl = fetch) {
            const responseUrl = trimString(event?.ResponseURL);
            if (!responseUrl) return;

            const body = JSON.stringify({
              Status: status,
              Reason: reason || `See CloudWatch Logs for details: ${trimString(event?.LogicalResourceId) || 'RunsOnGitHubRunnerCacheRefreshSeed'}`,
              PhysicalResourceId: trimString(event?.PhysicalResourceId) || trimString(event?.LogicalResourceId) || 'RunsOnGitHubRunnerCacheRefreshSeed',
              StackId: trimString(event?.StackId),
              RequestId: trimString(event?.RequestId),
              LogicalResourceId: trimString(event?.LogicalResourceId),
              Data: data || {},
            });

            await fetchImpl(responseUrl, {
              method: 'PUT',
              headers: {
                'content-type': '',
                'content-length': String(Buffer.byteLength(body)),
              },
              body,
            });
          }

          function isCloudFormationCustomResourceEvent(event) {
            return !!event?.ResponseURL && !!event?.RequestType;
          }

          async function createHandler(event, options = {}) {
            if (isCloudFormationCustomResourceEvent(event)) {
              if (event.RequestType === 'Delete') {
                await sendCloudFormationResponse(event, 'SUCCESS', { skipped: true }, 'Delete request acknowledged', options.fetchImpl || fetch);
                return { skipped: true };
              }

              try {
                const result = await performSync(event, options);
                await sendCloudFormationResponse(event, 'SUCCESS', result, undefined, options.fetchImpl || fetch);
                return result;
              } catch (error) {
                const message = String(error?.message || error);
                log('error', 'CloudFormation GitHub runner cache refresh seed failed', { error: message });
                await sendCloudFormationResponse(event, 'FAILED', { error: message }, message, options.fetchImpl || fetch);
                throw error;
              }
            }

            try {
              return await performSync(event, options);
            } catch (error) {
              const message = String(error?.message || error);
              log('error', 'GitHub runner cache refresh failed', { error: message });
              throw error;
            }
          }

          module.exports = {
            configFromEvent,
            createHandler,
            fetchLatestVersion,
            handler: (event) => createHandler(event),
            runnerAssetSpecs,
            sendCloudFormationResponse,
          };
  RunsOnGitHubWafSyncRule:
    Condition: WafEnabled
    Type: AWS::Events::Rule
    Properties:
      Description: Refreshes the public ingress WAF webhook IP allowlist every hour.
      ScheduleExpression: rate(1 hour)
      State: ENABLED
      Targets:
        - Arn: !GetAtt RunsOnGitHubWafSyncFunction.Arn
          Id: github-waf-sync

  RunsOnGitHubWafSyncInvokePermission:
    Condition: WafEnabled
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref RunsOnGitHubWafSyncFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RunsOnGitHubWafSyncRule.Arn

  RunsOnGitHubWafSyncSeed:
    Condition: WafEnabled
    Type: Custom::RunsOnGitHubWafSyncSeed
    Properties:
      ServiceToken: !GetAtt RunsOnGitHubWafSyncFunction.Arn
      MetaUrl: "https://api.github.com/meta"
      Scope: REGIONAL
      IPv4IPSetId: !GetAtt RunsOnPublicIngressAllowedIpsIPv4.Id
      IPv4IPSetName: !Sub "${AWS::StackName}-allowed-ips-ipv4"
      IPv6IPSetId: !GetAtt RunsOnPublicIngressAllowedIpsIPv6.Id
      IPv6IPSetName: !Sub "${AWS::StackName}-allowed-ips-ipv6"

  RunsOnPublicIngressWebAcl:
    Condition: WafEnabled
    Type: AWS::WAFv2::WebACL
    Properties:
      Name: !Sub "${AWS::StackName}-waf"
      Scope: REGIONAL
      Description: WAF for RunsOn public ingress - restricts the webhook endpoint to GitHub webhook IP ranges.
      DefaultAction:
        Block: {}
      Rules:
        - Name: AllowNonWebhookPaths
          Priority: 1
          Action:
            Allow: {}
          Statement:
            NotStatement:
              Statement:
                ByteMatchStatement:
                  PositionalConstraint: ENDS_WITH
                  SearchString: "/github/webhooks"
                  FieldToMatch:
                    UriPath: {}
                  TextTransformations:
                    - Priority: 0
                      Type: NONE
          VisibilityConfig:
            CloudWatchMetricsEnabled: true
            MetricName: !Sub "${AWS::StackName}-allow-non-webhook-paths"
            SampledRequestsEnabled: true
        - Name: AllowWebhookIPv4
          Priority: 2
          Action:
            Allow: {}
          Statement:
            AndStatement:
              Statements:
                - ByteMatchStatement:
                    PositionalConstraint: ENDS_WITH
                    SearchString: "/github/webhooks"
                    FieldToMatch:
                      UriPath: {}
                    TextTransformations:
                      - Priority: 0
                        Type: NONE
                - IPSetReferenceStatement:
                    Arn: !GetAtt RunsOnPublicIngressAllowedIpsIPv4.Arn
          VisibilityConfig:
            CloudWatchMetricsEnabled: true
            MetricName: !Sub "${AWS::StackName}-allow-webhook-ipv4"
            SampledRequestsEnabled: true
        - Name: AllowWebhookIPv6
          Priority: 3
          Action:
            Allow: {}
          Statement:
            AndStatement:
              Statements:
                - ByteMatchStatement:
                    PositionalConstraint: ENDS_WITH
                    SearchString: "/github/webhooks"
                    FieldToMatch:
                      UriPath: {}
                    TextTransformations:
                      - Priority: 0
                        Type: NONE
                - IPSetReferenceStatement:
                    Arn: !GetAtt RunsOnPublicIngressAllowedIpsIPv6.Arn
          VisibilityConfig:
            CloudWatchMetricsEnabled: true
            MetricName: !Sub "${AWS::StackName}-allow-webhook-ipv6"
            SampledRequestsEnabled: true
      VisibilityConfig:
        CloudWatchMetricsEnabled: true
        MetricName: !Sub "${AWS::StackName}-waf"
        SampledRequestsEnabled: true
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnPublicIngressRestApi:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: !Sub "${AWS::StackName}-public-ingress"
      EndpointConfiguration:
        Types:
          - REGIONAL
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnPublicIngressRestApiSetupResource:
    Condition: AdminRoutesEnabled
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ParentId: !GetAtt RunsOnPublicIngressRestApi.RootResourceId
      PathPart: "setup"

  RunsOnPublicIngressRestApiSetupProxyResource:
    Condition: AdminRoutesEnabled
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ParentId: !Ref RunsOnPublicIngressRestApiSetupResource
      PathPart: "{proxy+}"

  RunsOnPublicIngressRestApiGitHubResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ParentId: !GetAtt RunsOnPublicIngressRestApi.RootResourceId
      PathPart: "github"

  RunsOnPublicIngressRestApiGitHubWebhooksResource:
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ParentId: !Ref RunsOnPublicIngressRestApiGitHubResource
      PathPart: "webhooks"

  RunsOnPublicIngressRestApiReadyzResource:
    Condition: AdminRoutesEnabled
    Type: AWS::ApiGateway::Resource
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ParentId: !GetAtt RunsOnPublicIngressRestApi.RootResourceId
      PathPart: "readyz"

  RunsOnPublicIngressRestApiMethodAnyRoot:
    Condition: AdminRoutesEnabled
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ResourceId: !GetAtt RunsOnPublicIngressRestApi.RootResourceId
      HttpMethod: GET
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RunsOnGitHubAppsSetupFunction.Arn}/invocations"

  RunsOnPublicIngressRestApiMethodAnySetup:
    Condition: AdminRoutesEnabled
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ResourceId: !Ref RunsOnPublicIngressRestApiSetupResource
      HttpMethod: GET
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RunsOnGitHubAppsSetupFunction.Arn}/invocations"

  RunsOnPublicIngressRestApiMethodAnySetupProxy:
    Condition: AdminRoutesEnabled
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ResourceId: !Ref RunsOnPublicIngressRestApiSetupProxyResource
      HttpMethod: GET
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RunsOnGitHubAppsSetupFunction.Arn}/invocations"

  RunsOnPublicIngressRestApiMethodPostGitHubWebhooks:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ResourceId: !Ref RunsOnPublicIngressRestApiGitHubWebhooksResource
      HttpMethod: POST
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RunsOnPublicIngressFunction.Arn}/invocations"

  RunsOnPublicIngressRestApiMethodGetReadyz:
    Condition: AdminRoutesEnabled
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      ResourceId: !Ref RunsOnPublicIngressRestApiReadyzResource
      HttpMethod: GET
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY
        IntegrationHttpMethod: POST
        Uri: !Sub "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RunsOnGitHubAppsSetupFunction.Arn}/invocations"

  RunsOnPublicIngressRestApiDeploymentWithSetup:
    Condition: AdminRoutesEnabled
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - RunsOnPublicIngressRestApiMethodAnyRoot
      - RunsOnPublicIngressRestApiMethodAnySetup
      - RunsOnPublicIngressRestApiMethodAnySetupProxy
      - RunsOnPublicIngressRestApiMethodPostGitHubWebhooks
      - RunsOnPublicIngressRestApiMethodGetReadyz
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      Description: !Sub "Public ingress deployment root=${RunsOnGitHubAppsSetupFunction.Arn} setup=${RunsOnGitHubAppsSetupFunction.Arn} setup-proxy=${RunsOnGitHubAppsSetupFunction.Arn} readyz=${RunsOnGitHubAppsSetupFunction.Arn} webhooks=${RunsOnPublicIngressFunction.Arn}"

  RunsOnPublicIngressRestApiDeploymentWebhookOnly:
    Condition: AdminRoutesDisabled
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - RunsOnPublicIngressRestApiMethodPostGitHubWebhooks
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      Description: !Sub "Public ingress deployment webhook-only webhooks=${RunsOnPublicIngressFunction.Arn}"

  RunsOnPublicIngressRestApiStage:
    Type: AWS::ApiGateway::Stage
    Properties:
      RestApiId: !Ref RunsOnPublicIngressRestApi
      DeploymentId: !If [AdminRoutesEnabled, !Ref RunsOnPublicIngressRestApiDeploymentWithSetup, !Ref RunsOnPublicIngressRestApiDeploymentWebhookOnly]
      StageName: prod
      MethodSettings:
        - ResourcePath: "/*"
          HttpMethod: "*"
          LoggingLevel: "OFF"
          MetricsEnabled: true
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnPublicIngressWebAclAssociation:
    Condition: WafEnabled
    Type: AWS::WAFv2::WebACLAssociation
    DependsOn:
      - RunsOnGitHubWafSyncSeed
    Properties:
      WebACLArn: !GetAtt RunsOnPublicIngressWebAcl.Arn
      ResourceArn: !Sub "arn:aws:apigateway:${AWS::Region}::/restapis/${RunsOnPublicIngressRestApi}/stages/prod"

  RunsOnPublicIngressRestApiInvokePublicIngressPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref RunsOnPublicIngressFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RunsOnPublicIngressRestApi}/*"

  RunsOnPublicIngressRestApiInvokeGitHubAppsSetupPermission:
    Condition: AdminRoutesEnabled
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref RunsOnGitHubAppsSetupFunction
      Principal: apigateway.amazonaws.com
      SourceArn: !Sub "arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RunsOnPublicIngressRestApi}/*"

  RunsOnStackConfigSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub "/runs-on/${AWS::StackName}/stack-config"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnGitHubAppsSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: !Sub "/runs-on/${AWS::StackName}/github-apps"
      SecretString: "{}"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnLicenseStatusParameter:
    Type: AWS::SSM::Parameter
    Properties:
      Name: !Sub "/${AWS::StackName}/license/status"
      Type: String
      Value: >-
        {"status":"verifying","valid":false,"message":"Verifying","errors":[],"app_version":""}
      Tags:
        runs-on-stack-name: !Ref AWS::StackName

  ManagedLaunchTemplateRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ManageLaunchTemplates
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:DescribeLaunchTemplateVersions
                  - ec2:CreateLaunchTemplateVersion
                Resource: "*"
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  ManagedLaunchTemplateFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.11
      Handler: index.handler
      Role: !GetAtt ManagedLaunchTemplateRole.Arn
      Timeout: 300
      MemorySize: 256
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      Code:
        ZipFile: |
          from __future__ import annotations

          import copy
          import hashlib
          import json
          import xml.etree.ElementTree as ET
          from urllib import error as urllib_error, parse as urllib_parse, request as urllib_request

          try:
              import boto3
          except ImportError:
              boto3 = None

          try:
              import cfnresponse
          except ImportError:
              cfnresponse = None

          try:
              from botocore.auth import SigV4Auth
              from botocore.awsrequest import AWSRequest
              from botocore.serialize import create_serializer
          except ImportError:
              SigV4Auth = None
              AWSRequest = None
              create_serializer = None


          SOURCE_FIELDS = (
              "TagSpecifications",
              "IamInstanceProfile",
              "EbsOptimized",
              "MetadataOptions",
              "InstanceInitiatedShutdownBehavior",
              "Monitoring",
              "NetworkInterfaces",
              "UserData",
              "CpuOptions",
          )
          SUPPORTED_CPU_OPTION_FIELDS = ("AmdSevSnp", "CoreCount", "ThreadsPerCore")
          _EC2_CLIENT = None


          def require_dependency(value, name):
              if value is None:
                  raise RuntimeError(f"{name} is unavailable in this environment")
              return value


          def ec2_client():
              global _EC2_CLIENT
              if _EC2_CLIENT is None:
                  require_dependency(boto3, "boto3")
                  _EC2_CLIENT = boto3.client("ec2")
              return _EC2_CLIENT


          def boto3_session(session=None):
              if session is not None:
                  return session
              require_dependency(boto3, "boto3")
              return boto3.session.Session()


          def enabled(value):
              return value in (True, "true", "True")


          def source_launch_template_data(launch_template_id, version, ec2=None):
              client = ec2 or ec2_client()
              versions = client.describe_launch_template_versions(
                  LaunchTemplateId=launch_template_id,
                  Versions=[str(version)],
              )["LaunchTemplateVersions"]
              if not versions:
                  raise RuntimeError(f"launch template source version not found for {launch_template_id}:{version}")
              source = versions[0]["LaunchTemplateData"]
              data = {}
              for field in SOURCE_FIELDS:
                  if field in source:
                      data[field] = copy.deepcopy(source[field])
              return data


          def desired_launch_template_data(properties, ec2=None):
              data = source_launch_template_data(
                  properties["SourceLaunchTemplateId"],
                  properties["SourceLaunchTemplateVersion"],
                  ec2=ec2,
              )
              if enabled(properties.get("Private")):
                  interfaces = copy.deepcopy(data.get("NetworkInterfaces", []))
                  if not interfaces:
                      raise RuntimeError("private managed launch templates require at least one network interface")
                  interfaces[0]["AssociatePublicIpAddress"] = False
                  data["NetworkInterfaces"] = interfaces
              if enabled(properties.get("NestedVirtualization")):
                  cpu = copy.deepcopy(data.get("CpuOptions", {}))
                  cpu["NestedVirtualization"] = "enabled"
                  data["CpuOptions"] = cpu
              return data


          def supported_launch_template_data(data):
              supported = copy.deepcopy(data)
              cpu = supported.get("CpuOptions")
              if isinstance(cpu, dict):
                  filtered = {key: copy.deepcopy(value) for key, value in cpu.items() if key in SUPPORTED_CPU_OPTION_FIELDS}
                  if filtered:
                      supported["CpuOptions"] = filtered
                  else:
                      supported.pop("CpuOptions", None)
              return supported


          def version_description(data):
              payload = json.dumps(data, sort_keys=True, separators=(",", ":"))
              digest = hashlib.sha256(payload.encode()).hexdigest()
              return f"runs-on-derived/{digest}"


          def client_token(launch_template_id, description):
              # EC2 idempotency tokens must remain unique per target launch template even
              # when two templates derive the same payload and therefore share a digest.
              return f"{launch_template_id}/{description}"


          def xml_text(root, name):
              node = root.find(f".//{{*}}{name}")
              if node is not None and node.text:
                  return node.text.strip()
              return ""


          def xml_message(payload):
              try:
                  root = ET.fromstring(payload)
              except ET.ParseError:
                  return payload.decode("utf-8", "replace")
              for name in ("Message", "message"):
                  text = xml_text(root, name)
                  if text:
                      return text
              return payload.decode("utf-8", "replace")


          def query_pairs(body):
              if isinstance(body, dict):
                  return list(body.items())
              if isinstance(body, bytes):
                  body = body.decode("utf-8")
              return urllib_parse.parse_qsl(body, keep_blank_values=True)


          def encoded_query_body(body, nested_enabled):
              pairs = query_pairs(body)
              if nested_enabled:
                  pairs.append(("LaunchTemplateData.CpuOptions.NestedVirtualization", "enabled"))
              return urllib_parse.urlencode(pairs).encode("utf-8")


          def serialize_create_launch_template_version_request(launch_template_id, description, data, ec2=None):
              client = ec2 or ec2_client()
              serializer_factory = require_dependency(create_serializer, "botocore.serialize.create_serializer")
              service_model = client.meta.service_model
              serializer = serializer_factory(service_model.metadata["protocol"])
              request = serializer.serialize_to_request(
                  {
                      "ClientToken": client_token(launch_template_id, description),
                      "LaunchTemplateId": launch_template_id,
                      "LaunchTemplateData": supported_launch_template_data(data),
                      "VersionDescription": description,
                  },
                  service_model.operation_model("CreateLaunchTemplateVersion"),
              )
              body = encoded_query_body(
                  request.get("body", ""),
                  data.get("CpuOptions", {}).get("NestedVirtualization") == "enabled",
              )
              headers = dict(request.get("headers", {}))
              headers.setdefault("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
              return body, headers


          def signed_ec2_query(body, headers, ec2=None, session=None):
              client = ec2 or ec2_client()
              aws_request_cls = require_dependency(AWSRequest, "botocore.awsrequest.AWSRequest")
              signer_cls = require_dependency(SigV4Auth, "botocore.auth.SigV4Auth")
              credentials = boto3_session(session).get_credentials()
              if credentials is None:
                  raise RuntimeError("aws credentials unavailable for EC2 query signing")
              aws_request = aws_request_cls(
                  method="POST",
                  url=client.meta.endpoint_url,
                  data=body,
                  headers=headers,
              )
              signer_cls(credentials.get_frozen_credentials(), "ec2", client.meta.region_name).add_auth(aws_request)
              prepared = aws_request.prepare()
              signed_body = prepared.body if prepared.body is not None else body
              if isinstance(signed_body, str):
                  signed_body = signed_body.encode("utf-8")
              try:
                  with urllib_request.urlopen(
                      urllib_request.Request(
                          prepared.url,
                          data=signed_body,
                          headers=dict(prepared.headers),
                          method="POST",
                      )
                  ) as response:
                      return response.read()
              except urllib_error.HTTPError as http_error:
                  raise RuntimeError(xml_message(http_error.read()))


          def create_launch_template_version(launch_template_id, description, data, ec2=None, session=None):
              body, headers = serialize_create_launch_template_version_request(
                  launch_template_id,
                  description,
                  data,
                  ec2=ec2,
              )
              root = ET.fromstring(signed_ec2_query(body, headers, ec2=ec2, session=session))
              version = xml_text(root, "versionNumber")
              if not version:
                  raise RuntimeError("launch template version number missing from EC2 response")
              return version


          def matching_version(launch_template_id, description, ec2=None):
              client = ec2 or ec2_client()
              token = None
              while True:
                  params = {"LaunchTemplateId": launch_template_id}
                  if token is not None:
                      params["NextToken"] = token
                  response = client.describe_launch_template_versions(**params)
                  for version in response["LaunchTemplateVersions"]:
                      if version.get("VersionDescription") == description:
                          return str(version["VersionNumber"])
                  token = response.get("NextToken")
                  if token is None:
                      return None


          def ensure_launch_template_version(properties, ec2=None, session=None):
              data = desired_launch_template_data(properties, ec2=ec2)
              description = version_description(data)
              launch_template_id = properties["TargetLaunchTemplateId"]
              version = matching_version(launch_template_id, description, ec2=ec2)
              if version is None:
                  version = create_launch_template_version(
                      launch_template_id,
                      description,
                      data,
                      ec2=ec2,
                      session=session,
                  )
              return launch_template_id, version


          def send_response(event, context, status, data, physical_resource_id, reason=None):
              response_module = require_dependency(cfnresponse, "cfnresponse")
              response_module.send(event, context, status, data, physical_resource_id, reason=reason)


          def handler(event, context):
              properties = event["ResourceProperties"]
              physical_id = event.get("PhysicalResourceId") or properties["TargetLaunchTemplateId"]
              try:
                  if event["RequestType"] == "Delete":
                      return send_response(event, context, cfnresponse.SUCCESS, {}, physical_id)
                  launch_template_id, version = ensure_launch_template_version(properties)
                  send_response(
                      event,
                      context,
                      cfnresponse.SUCCESS,
                      {
                          "LaunchTemplateRef": f"{launch_template_id}:{version}",
                      },
                      launch_template_id,
                  )
              except Exception as exc:
                  send_response(
                      event,
                      context,
                      cfnresponse.FAILED,
                      {},
                      physical_id,
                      reason=str(exc),
                  )
  ManagedLaunchTemplateFunctionVersion:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref ManagedLaunchTemplateFunction

  StackConfigMaterializerRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: ManageStackConfigMaterializer
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - secretsmanager:PutSecretValue
                Resource: !Ref RunsOnStackConfigSecret
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  StackConfigMaterializerFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.11
      Handler: index.handler
      Role: !GetAtt StackConfigMaterializerRole.Arn
      Timeout: 300
      MemorySize: 256
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      Code:
        ZipFile: |
          import boto3,json,hashlib
          import cfnresponse
          from botocore.exceptions import ClientError

          S=boto3.client("secretsmanager")

          def error_code(error):
              return error.response.get("Error",{}).get("Code","")

          def handler(event,context):
              p=event["ResourceProperties"];stack=p["StackConfigBase"]["StackName"];pid=f"{stack}-stack-config-materializer"
              try:
                  if event["RequestType"]=="Delete":
                      return cfnresponse.send(event,context,cfnresponse.SUCCESS,{},pid)
                  cfg=dict(p["StackConfigBase"])
                  for key in (
                      "LaunchTemplateLinuxDefault",
                      "LaunchTemplateLinuxDefaultNested",
                      "LaunchTemplateWindowsDefault",
                      "LaunchTemplateWindowsDefaultNested",
                      "LaunchTemplateLinuxPrivate",
                      "LaunchTemplateLinuxPrivateNested",
                      "LaunchTemplateWindowsPrivate",
                      "LaunchTemplateWindowsPrivateNested",
                  ):
                      cfg[key]=p[key]
                  payload=json.dumps(cfg,sort_keys=True,separators=(",",":"));vid=hashlib.sha256(payload.encode()).hexdigest()
                  try:
                      vid=S.put_secret_value(SecretId=p["StackConfigSecretArn"],SecretString=payload,ClientRequestToken=vid).get("VersionId",vid)
                  except ClientError as e:
                      if error_code(e)!="ResourceExistsException":
                          raise
                  data={"StackConfigSecretVersion":vid}
                  cfnresponse.send(event,context,cfnresponse.SUCCESS,data,pid)
              except Exception as e:
                  cfnresponse.send(event,context,cfnresponse.FAILED,{},pid,reason=str(e))

  StackConfigMaterializerFunctionVersion:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref StackConfigMaterializerFunction

  StackConfigMaterializer:
    Type: Custom::StackConfigMaterializer
    Properties:
      ServiceToken: !GetAtt StackConfigMaterializerFunction.Arn
      RefreshToken: !Ref StackConfigMaterializerFunctionVersion
      StackConfigSecretArn: !Ref RunsOnStackConfigSecret
      StackConfigBase:
        AwsAccountId: !Ref AWS::AccountId
        Private: !Ref Private
        GithubOrganization: !Ref GithubOrganization
        Region: !Ref AWS::Region
        LicenseKey: !Ref LicenseKey
        BucketCache: !Ref S3BucketCache
        InstanceRoleName: !Ref EC2InstanceRole
        SshAllowed: !Ref SSHAllowed
        PublicSubnetIds: !Join [",", [!Ref PublicSubnet1, !Ref PublicSubnet2]]
        PrivateSubnetIds: !Join [",", [!Ref PrivateSubnet1, !Ref PrivateSubnet2]]
        TopicArn: !Ref AlertTopic
        RunnerCustomTags: !Join [",", !Ref RunnerCustomTags]
        RunnerMaxRuntime: !Ref RunnerMaxRuntime
        RunnerConfigAutoExtendsFrom: !Ref RunnerConfigAutoExtendsFrom
        EbsEncryptionKey: !If [HasEncryptEbs, "alias/aws/ebs", ""]
        AppGithubApiStrategy: !Ref AppGithubApiStrategy
        AppTag: !FindInMap [App, Tags, AppTag]
        GitHubAppsSecretArn: !Ref RunsOnGitHubAppsSecret
        StackName: !Ref AWS::StackName
        AppSize: !Ref AppSize
        QueueWebhooks: !Ref RunsOnQueueWebhooks
        QueueSystem: !Ref RunsOnQueueSystem
        QueueEvents: !Ref RunsOnQueueEvents
        LocksTable: !Ref RunsOnLocksTable
        WorkflowJobsTable: !Ref RunsOnWorkflowJobsTable
        CostReportsEnabled: !Ref CostReportsEnabled
        CostAllocationTag: !Ref CostAllocationTag
        SpotCircuitBreaker: !Ref SpotCircuitBreaker
        IntegrationStepSecurityApiKey: !Ref IntegrationStepSecurityApiKey
        VPCId: !Ref VPC
        NetworkingStack: "embedded"
        InfrastructureSource: "cloudformation"
        Ec2InstanceLogGroupArn: !GetAtt EC2InstanceLogGroup.Arn
        IngressURL: !Sub "https://${RunsOnPublicIngressRestApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
        ServiceLogGroupName: !Ref RunsOnWorkerLogGroup
      LaunchTemplateLinuxDefault: !Sub "${EC2FleetLaunchTemplateLinuxDefault}:${EC2FleetLaunchTemplateLinuxDefault.LatestVersionNumber}"
      LaunchTemplateLinuxDefaultNested: !GetAtt ManagedLaunchTemplateLinuxDefaultNested.LaunchTemplateRef
      LaunchTemplateWindowsDefault: !Sub "${EC2FleetLaunchTemplateWindowsDefault}:${EC2FleetLaunchTemplateWindowsDefault.LatestVersionNumber}"
      LaunchTemplateWindowsDefaultNested: !GetAtt ManagedLaunchTemplateWindowsDefaultNested.LaunchTemplateRef
      LaunchTemplateLinuxPrivate: !GetAtt ManagedLaunchTemplateLinuxPrivate.LaunchTemplateRef
      LaunchTemplateLinuxPrivateNested: !GetAtt ManagedLaunchTemplateLinuxPrivateNested.LaunchTemplateRef
      LaunchTemplateWindowsPrivate: !GetAtt ManagedLaunchTemplateWindowsPrivate.LaunchTemplateRef
      LaunchTemplateWindowsPrivateNested: !GetAtt ManagedLaunchTemplateWindowsPrivateNested.LaunchTemplateRef

  RunsOnServiceRole:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicyArns:
        !If [AppCustomPolicyProvided, [!Ref AppCustomPolicy], []]
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ecs-tasks.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: AppRunnerEC2Permissions
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ec2:DescribeImages
                  - ec2:DescribeInstanceTypes
                  - ec2:DescribeInstances
                  - ec2:DescribeSubnets
                  - ec2:DescribeRouteTables
                  - ec2:DescribeVolumes
                  - ec2:DescribeSnapshots
                  - ec2:DescribeLaunchTemplateVersions
                  - ce:GetCostAndUsage
                  - ce:UpdateCostAllocationTagsStatus
                  - cloudwatch:PutMetricData
                  - cloudwatch:GetMetricData
                  - cloudwatch:DescribeAlarms
                  - cloudtrail:LookupEvents
                  - iam:GetRole
                  - sns:ListSubscriptionsByTopic
                Resource: "*"
              - Effect: Allow
                Action:
                  - iam:CreateServiceLinkedRole
                Resource: "arn:aws:iam::*:role/aws-service-role/spot.amazonaws.com/AWSServiceRoleForEC2Spot"
                Condition:
                  StringEquals:
                    "iam:AWSServiceName": "spot.amazonaws.com"
              - Effect: Allow
                Action:
                  - ec2:CreateFleet
                  - ec2:DeleteFleets
                Resource: "*"
              - Effect: Allow
                Action:
                  - ec2:CreateTags
                  - ec2:RunInstances
                Resource:
                  - !Sub "arn:aws:ec2:${AWS::Region}::image/*"
                  - !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:*"
              - Effect: Allow
                Action:
                  - iam:PassRole
                  - iam:GetRole
                Resource: !GetAtt EC2InstanceRole.Arn
              - Effect: Allow
                Action:
                  - ssm:PutParameter
                  - ssm:GetParameter
                  - ssm:GetParameters
                  - ssm:DeleteParameter
                  - ssm:DeleteParameters
                Resource:
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter/${AWS::StackName}/*"
              - Effect: Allow
                Action:
                  - secretsmanager:GetSecretValue
                Resource:
                  - !Ref RunsOnStackConfigSecret
                  - !Ref RunsOnGitHubAppsSecret
              - Effect: Allow
                Action:
                  - ec2:TerminateInstances
                  - ec2:StopInstances
                  - ec2:StartInstances
                Resource: !Sub "arn:aws:ec2:${AWS::Region}:${AWS::AccountId}:instance/*"
                Condition:
                  StringEquals:
                    "aws:ResourceTag/runs-on-stack-name": !Ref AWS::StackName
              - Effect: Allow
                Action:
                  - ec2:DeleteVolume
                  - ec2:DeleteSnapshot
                Resource: "*"
                Condition:
                  StringEquals:
                    "aws:ResourceTag/runs-on-stack-name": !Ref AWS::StackName
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                  - s3:ListBucket
                Resource:
                  - Fn::Sub: arn:aws:s3:::${S3BucketCache}
                  - Fn::Sub: arn:aws:s3:::${S3BucketCache}/*
              - Effect: Allow
                Action:
                  - s3:PutObject
                Resource:
                  - Fn::Sub: arn:aws:s3:::${S3BucketCache}/runners/*
                  - Fn::Sub: arn:aws:s3:::${S3BucketCache}/agents/*
              - Effect: Allow
                Action:
                  - sns:Publish
                Resource: !Ref AlertTopic
              - Effect: Allow
                Action:
                  - sqs:SendMessage
                  - sqs:ReceiveMessage
                  - sqs:ChangeMessageVisibility
                  - sqs:DeleteMessage
                  - sqs:GetQueueAttributes
                Resource:
                  - !GetAtt RunsOnQueueWebhooks.Arn
                  - !GetAtt RunsOnQueueSystem.Arn
                  - !GetAtt RunsOnQueueEvents.Arn
              - Effect: Allow
                Action:
                  - dynamodb:PutItem
                  - dynamodb:GetItem
                  - dynamodb:UpdateItem
                  - dynamodb:DeleteItem
                Resource: !GetAtt RunsOnLocksTable.Arn
              - Effect: Allow
                Action:
                  - dynamodb:PutItem
                  - dynamodb:GetItem
                  - dynamodb:Query
                  - dynamodb:BatchGetItem
                  - dynamodb:UpdateItem
                  - dynamodb:Scan
                Resource:
                  - !GetAtt RunsOnWorkflowJobsTable.Arn
                  - !Sub "${RunsOnWorkflowJobsTable.Arn}/index/*"
              - Effect: Allow
                Action:
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - logs:DescribeLogStreams
                Resource:
                  - !Sub "${RunsOnWorkerLogGroup.Arn}:*"

  RunsOnWorkerTaskDefinition:
    Type: AWS::ECS::TaskDefinition
    Properties:
      Family: !Sub "${AWS::StackName}-runs-on-worker"
      Cpu: !FindInMap [AppSize, !Ref AppSize, Cpu]
      Memory: !FindInMap [AppSize, !Ref AppSize, Memory]
      NetworkMode: awsvpc
      RequiresCompatibilities:
        - FARGATE
      ExecutionRoleArn: !GetAtt RunsOnWorkerExecutionRole.Arn
      TaskRoleArn: !GetAtt RunsOnServiceRole.Arn
      RuntimePlatform:
        CpuArchitecture: ARM64
        OperatingSystemFamily: LINUX
      ContainerDefinitions:
        - Name: runs-on-worker
          Image: !Sub
            - "${AppRegistry}:${ImageTag}"
            - ImageTag: !FindInMap [App, Tags, ImageTag]
          Essential: true
          StopTimeout: 30
          Command:
            - /app/dist/flexd
          Environment:
            - Name: RUNS_ON_ENV
              Value: !Ref Environment
            - Name: RUNS_ON_STACK_NAME
              Value: !Ref AWS::StackName
            - Name: RUNS_ON_APP_TAG
              Value: !FindInMap [App, Tags, AppTag]
            - Name: RUNS_ON_STACK_CONFIG_SECRET_ARN
              Value: !Ref RunsOnStackConfigSecret
            - Name: RUNS_ON_STACK_CONFIG_SECRET_VERSION
              Value: !GetAtt StackConfigMaterializer.StackConfigSecretVersion
            - Name: RUNS_ON_GITHUB_APPS_SECRET_ARN
              Value: !Ref RunsOnGitHubAppsSecret
            - Name: OTEL_EXPORTER_OTLP_ENDPOINT
              Value: !Ref OtelExporterEndpoint
            - Name: OTEL_EXPORTER_OTLP_HEADERS
              Value: !Ref OtelExporterHeaders
            - Name: OTEL_EXPORTER_OTLP_TEMPORALITY
              Value: !Ref OtelExporterTemporality
            - Name: RUNS_ON_LOGGER_LEVEL
              Value: !Ref LoggerLevel
          HealthCheck:
            Command:
              - CMD
              - /app/dist/healthprobe
            Interval: 5
            Timeout: 5
            Retries: 3
            StartPeriod: 15
          LogConfiguration:
            LogDriver: awslogs
            Options:
              awslogs-group: !Ref RunsOnWorkerLogGroup
              awslogs-region: !Ref AWS::Region
              awslogs-stream-prefix: runs-on-worker
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnWorkerService:
    Type: AWS::ECS::Service
    Properties:
      Cluster: !Ref RunsOnWorkerCluster
      DesiredCount: !If [MaintenanceModeEnabled, 0, 1]
      CapacityProviderStrategy:
        - CapacityProvider: !Ref AppCapacityProvider
          Weight: 1
      PlatformVersion: LATEST
      TaskDefinition: !Ref RunsOnWorkerTaskDefinition
      PropagateTags: SERVICE
      EnableECSManagedTags: true
      DeploymentConfiguration:
        MaximumPercent: 200
        MinimumHealthyPercent: 0
      NetworkConfiguration:
        AwsvpcConfiguration:
          AssignPublicIp: !If [HasPrivateSubnet, DISABLED, ENABLED]
          SecurityGroups: [!Ref SecurityGroup]
          Subnets: !If
            - HasPrivateSubnet
            - [!Ref PrivateSubnet1, !Ref PrivateSubnet2]
            - [!Ref PublicSubnet1, !Ref PublicSubnet2]
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  DailyCostBudget:
    Type: AWS::Budgets::Budget
    Properties:
      Budget:
        BudgetName: !Sub "${AWS::StackName}-app-daily-budget"
        BudgetType: COST
        TimeUnit: DAILY
        BudgetLimit:
          Amount: !Ref AppBudgetDailyUsd
          Unit: USD
        CostFilters:
          TagKeyValue:
            - !Join
              - ""
              - - "user:"
                - !Ref CostAllocationTag
                - "$"
                - !Ref AWS::StackName
      NotificationsWithSubscribers:
        - Notification:
            NotificationType: ACTUAL
            ComparisonOperator: GREATER_THAN
            ThresholdType: PERCENTAGE
            Threshold: 100
          Subscribers:
            - SubscriptionType: SNS
              Address: !Ref AlertTopic
      ResourceTags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnQueueWebhooksDeadLetter:
    Type: AWS::SQS::Queue
    Properties:
      FifoQueue: true
      MessageRetentionPeriod: 259200 # 3 days
      SqsManagedSseEnabled: true

  RunsOnQueueWebhooksDeadLetterPolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref RunsOnQueueWebhooksDeadLetter
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: "*"
            Action: "sqs:SendMessage"
            Resource: !GetAtt RunsOnQueueWebhooksDeadLetter.Arn
            Condition:
              ArnEquals:
                aws:SourceArn: !Ref RunsOnQueueWebhooks

  RunsOnQueueWebhooks:
    Type: AWS::SQS::Queue
    Properties:
      FifoQueue: true
      ContentBasedDeduplication: false
      MessageRetentionPeriod: 86400 # 24h
      ReceiveMessageWaitTimeSeconds: 10
      VisibilityTimeout: 120 # AWS recommends 6 times the max processing time
      SqsManagedSseEnabled: true
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt RunsOnQueueWebhooksDeadLetter.Arn
        maxReceiveCount: 3
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnQueueSystemDeadLetter:
    Type: AWS::SQS::Queue
    Properties:
      MessageRetentionPeriod: 259200 # 3 days
      FifoQueue: true
      SqsManagedSseEnabled: true
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnQueueSystem:
    Type: AWS::SQS::Queue
    Properties:
      FifoQueue: true
      ContentBasedDeduplication: true
      MessageRetentionPeriod: 86400 # 24h
      ReceiveMessageWaitTimeSeconds: 10
      VisibilityTimeout: 120
      SqsManagedSseEnabled: true
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt RunsOnQueueSystemDeadLetter.Arn
        maxReceiveCount: 3
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  RunsOnQueueEvents:
    Type: AWS::SQS::Queue
    Properties:
      MessageRetentionPeriod: 7200
      ReceiveMessageWaitTimeSeconds: 20
      VisibilityTimeout: 120
      SqsManagedSseEnabled: true
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  # DynamoDB table for distributed locking
  RunsOnLocksTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "${AWS::StackName}-locks"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: key
          AttributeType: S
      KeySchema:
        - AttributeName: key
          KeyType: HASH
      TimeToLiveSpecification:
        AttributeName: expiresAt
        Enabled: true
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  # DynamoDB table for workflow job tracking
  RunsOnWorkflowJobsTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Sub "${AWS::StackName}-workflow-jobs"
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: job_id
          AttributeType: N
        - AttributeName: reconcile_partition
          AttributeType: S
        - AttributeName: reconcile_after_unix
          AttributeType: N
        - AttributeName: pending_work_partition
          AttributeType: S
        - AttributeName: pending_work_not_before_unix
          AttributeType: N
        - AttributeName: created_at_date
          AttributeType: S
        - AttributeName: created_at_unix
          AttributeType: N
      KeySchema:
        - AttributeName: job_id
          KeyType: HASH
      GlobalSecondaryIndexes:
        - IndexName: reconcile-index
          KeySchema:
            - AttributeName: reconcile_partition
              KeyType: HASH
            - AttributeName: reconcile_after_unix
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
        - IndexName: pending-work-index
          KeySchema:
            - AttributeName: pending_work_partition
              KeyType: HASH
            - AttributeName: pending_work_not_before_unix
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
        - IndexName: daily-activity-index
          KeySchema:
            - AttributeName: created_at_date
              KeyType: HASH
            - AttributeName: created_at_unix
              KeyType: RANGE
          Projection:
            ProjectionType: INCLUDE
            NonKeyAttributes:
              - installation_id
              - org_name
              - repo_name
              - job_id
      TimeToLiveSpecification:
        AttributeName: ttl
        Enabled: true
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  SpotInstanceEventRule:
    Type: AWS::Events::Rule
    Properties:
      Description: "Rule to capture EC2 Spot Instance interruption notices"
      EventPattern:
        source:
          - aws.ec2
        detail-type:
          - EC2 Spot Instance Interruption Warning
          - EC2 Instance State-change Notification
      State: ENABLED
      Targets:
        - Arn: !GetAtt RunsOnQueueEvents.Arn
          Id: InstanceEvents

  SpotInstanceEventRulePermission:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref RunsOnQueueEvents
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: events.amazonaws.com
            Action: sqs:SendMessage
            Resource: !GetAtt RunsOnQueueEvents.Arn
            Condition:
              ArnEquals:
                aws:SourceArn: !GetAtt SpotInstanceEventRule.Arn

  AlertTopic:
    Type: AWS::SNS::Topic
    Properties:
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      DisplayName: RunsOn Alerts

  AlertTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Properties:
      Topics:
        - !Ref AlertTopic
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Sid: AllowBudgetsPublish
            Effect: Allow
            Principal:
              Service: budgets.amazonaws.com
            Action: sns:Publish
            Resource: !Ref AlertTopic
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId

  AlertTopicSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      Protocol: email
      TopicArn: !Ref AlertTopic
      Endpoint: !Ref EmailAddress

  AlertTopicSlackWebhookRole:
    Condition: AlertTopicSlackWebhookProvided
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName

  AlertTopicSlackWebhookFunction:
    Condition: AlertTopicSlackWebhookProvided
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.11
      Handler: index.handler
      Role: !GetAtt AlertTopicSlackWebhookRole.Arn
      Timeout: 10
      MemorySize: 128
      Environment:
        Variables:
          SLACK_WEBHOOK_URL: !Ref AlertTopicSlackWebhookUrl
          STACK_NAME: !Ref AWS::StackName
      Tags:
        - Key: !Ref CostAllocationTag
          Value: !Ref AWS::StackName
        - Key: runs-on-stack-name
          Value: !Ref AWS::StackName
      Code:
        ZipFile: |
          import json,logging,os,time,urllib.error,urllib.request
          logger=logging.getLogger()
          logger.setLevel(logging.INFO)
          STATE_COLORS={"alarm":"danger","ok":"good","insufficient_data":"warning"}
          STATE_EMOJI={"ALARM":":rotating_light:","OK":":white_check_mark:","INSUFFICIENT_DATA":":warning:"}

          def get_alert_color(subject,state=None):
              if state:
                  return STATE_COLORS.get(state.lower(),"#439FE0")
              subject=(subject or "").lower()
              if any(word in subject for word in ("error","fail","❌")):
                  return "danger"
              if any(word in subject for word in ("warn","⚠️","unable")):
                  return "warning"
              if any(word in subject for word in ("success","✅","complete")):
                  return "good"
              return "#439FE0"

          def handler(event,context):
              webhook_url=os.environ.get("SLACK_WEBHOOK_URL")
              stack_name=os.environ.get("STACK_NAME","RunsOn")
              if not webhook_url:
                  logger.error("Slack webhook URL is not configured")
                  return {"statusCode":500,"body":"Slack webhook URL is not configured"}
              for record in event.get("Records",[]):
                  sns=record.get("Sns",{})
                  subject=sns.get("Subject","")
                  message=sns.get("Message","")
                  title=subject or "Alert"
                  text=message
                  color=get_alert_color(subject)
                  try:
                      alarm_data=json.loads(message)
                      if "AlarmName" in alarm_data:
                          state=alarm_data.get("NewStateValue","UNKNOWN")
                          title=f'{STATE_EMOJI.get(state,":question:")} CloudWatch Alarm: {state}'
                          text=f"*Alarm:* {alarm_data.get('AlarmName','N/A')}\n*Reason:* {alarm_data.get('NewStateReason','N/A')}"
                          color=get_alert_color(None,state)
                      else:
                          title=subject or "JSON Message"
                          text=f"```\n{json.dumps(alarm_data,indent=2)}\n```"
                  except (json.JSONDecodeError,TypeError):
                      pass
                  data=json.dumps({"username":stack_name,"icon_url":"https://runs-on.com/logo.png","attachments":[{"color":color,"title":title,"text":text,"footer":stack_name,"ts":int(time.time())}]}).encode("utf-8")
                  request=urllib.request.Request(webhook_url,data=data,headers={"Content-Type":"application/json"},method="POST")
                  try:
                      with urllib.request.urlopen(request,timeout=5) as response:
                          logger.info("Sent alert to Slack with status %s", response.status)
                  except urllib.error.HTTPError as error:
                      logger.error("Failed to send alert to Slack: HTTP %s %s", error.code, error.reason)
                  except Exception as error:  # noqa: BLE001
                      logger.exception("Unexpected error sending alert to Slack: %s", error)
              return {"statusCode":200,"body":"Processed SNS records"}

  AlertTopicSlackWebhookPermission:
    Condition: AlertTopicSlackWebhookProvided
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !Ref AlertTopicSlackWebhookFunction
      Principal: sns.amazonaws.com
      SourceArn: !Ref AlertTopic

  AlertTopicSlackWebhookSubscription:
    Condition: AlertTopicSlackWebhookProvided
    Type: AWS::SNS::Subscription
    DependsOn:
      - AlertTopicSlackWebhookPermission
    Properties:
      Protocol: lambda
      TopicArn: !Ref AlertTopic
      Endpoint: !GetAtt AlertTopicSlackWebhookFunction.Arn

  RunsOnDashboard:
    Type: AWS::CloudWatch::Dashboard
    Properties:
      DashboardBody: !Sub |
        {
          "widgets": [
            {
              "type": "metric",
              "x": 0,
              "y": 0,
              "width": 6,
              "height": 6,
              "properties": {
                "metrics": [
                  [ "AWS/SQS", "ApproximateNumberOfMessagesVisible", "QueueName", "${RunsOnQueueWebhooks.QueueName}" ]
                ],
                "period": 300,
                "stat": "Maximum",
                "region": "${AWS::Region}",
                "title": "Jobs Currently Queued",
                "view": "timeSeries"
              }
            },
            {
              "type": "log",
              "x": 6,
              "y": 0,
              "width": 6,
              "height": 6,
              "properties": {
                "query": "SOURCE '${RunsOnWorkerLogGroup}'\n| filter metric_type = \"job_event\" and status = \"scheduled\"\n| stats count() as RunnersScheduled",
                "region": "${AWS::Region}",
                "title": "Total Runners Scheduled (Current Period)",
                "view": "table"
              }
            },
            {
              "type": "log",
              "x": 12,
              "y": 0,
              "width": 6,
              "height": 6,
              "properties": {
                "query": "SOURCE '${RunsOnWorkerLogGroup}'\n| filter metric_type = \"job_event\" and status = \"scheduled\"\n| stats count() as RunnersScheduled by bin(5m) as t\n| sort t asc",
                "region": "${AWS::Region}",
                "title": "Runners Scheduled over time (5min intervals)",
                "view": "timeSeries"
              }
            },
            {
              "type": "log",
              "x": 18,
              "y": 0,
              "width": 6,
              "height": 6,
              "properties": {
                "query": "SOURCE '${RunsOnWorkerLogGroup}'\n| filter metric_type = \"job_event\" and ispresent(overall_queue_duration_seconds)\n| stats pct(internal_queue_duration_seconds, 90) as internal_P90, pct(internal_queue_duration_seconds, 50) as internal_P50, pct(overall_queue_duration_seconds, 90) as overall_P90, pct(overall_queue_duration_seconds, 50) as overall_P50 by bin(1m) as t\n| sort t asc",
                "region": "${AWS::Region}",
                "title": "Internal/Overall Queue Duration Percentiles (P50/P90)",
                "view": "timeSeries",
                "yAxis": {
                  "left": {
                    "min": 0,
                    "label": "Seconds"
                  }
                }
              }
            },
            {
              "type": "log",
              "x": 0,
              "y": 6,
              "width": 12,
              "height": 6,
              "properties": {
                "query": "SOURCE '${RunsOnWorkerLogGroup}'\n| filter level = \"error\"\n| fields @timestamp, message\n| sort @timestamp desc\n| limit 50",
                "region": "${AWS::Region}",
                "title": "Recent Error Messages (Latest 50)",
                "view": "table"
              }
            },
            {
              "type": "log",
              "x": 12,
              "y": 6,
              "width": 12,
              "height": 6,
              "properties": {
                "query": "SOURCE '${RunsOnWorkerLogGroup}'\n| filter metric_type = \"snapshot\"\n| fields @timestamp, spot_circuit_breaker.active as active, spot_circuit_breaker.interruption_count as interruption_count\n| stats min(active) as circuit_breaker_active, min(interruption_count) as interruptions by bin(1m) as t\n| sort t asc",
                "region": "${AWS::Region}",
                "title": "Spot Circuit Breaker Status",
                "view": "timeSeries"
              }
            },
            {
              "type": "log",
              "x": 0,
              "y": 12,
              "width": 24,
              "height": 6,
              "properties": {
                "query": "SOURCE '${RunsOnWorkerLogGroup}'\n| filter metric_type = \"spot_interruption\"\n| fields @timestamp, interruption_time, trip_count, recovery_minutes, circuit_breaker_active\n| sort @timestamp desc\n| limit 50",
                "region": "${AWS::Region}",
                "title": "Recent Spot Interruptions (Last 50)",
                "view": "table"
              }
            }
          ]
        }

Outputs:
  RunsOnAwsAccountId:
    Description: AWS Account ID
    Value: !Ref AWS::AccountId
  RunsOnEntryPoint:
    Description: Entrypoint for the RunsOn service
    Value: !Sub "https://${RunsOnPublicIngressRestApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
  RunsOnServiceLogGroupName:
    Description: Log group for the RunsOn service
    Value: !Ref RunsOnWorkerLogGroup
  RunsOnAppTag:
    Description: RunsOn application tag
    Value: !FindInMap [App, Tags, AppTag]
  RunsOnInstanceRoleName:
    Description: Name of the instance role for the RunsOn service
    Value: !Ref EC2InstanceRole
  RunsOnRegion:
    Description: AWS region
    Value: !Ref AWS::Region
  RunsOnVPCId:
    Description: VPC ID
    Value: !Ref VPC
    Export:
      Name: !Sub "${AWS::StackName}-VPCId"
  RunsOnPublicSubnetIds:
    Description: Public subnet IDs
    Value: !Join [",", [!Ref PublicSubnet1, !Ref PublicSubnet2]]
  RunsOnPrivateSubnetIds:
    Description: Private subnet IDs
    Value: !Join [",", [!Ref PrivateSubnet1, !Ref PrivateSubnet2]]
  RunsOnEgressStaticIps:
    Description: Static IPs for egress traffic (if configured)
    Value: !If [HasPrivateSubnet, !Ref NatGatewayElasticIP, ""]
  RunsOnSecurityGroupId:
    Description: Security group for runners
    Value: !Ref SecurityGroup
  RunsOnBucketCache:
    Description: S3 bucket for storing cache artifacts
    Value: !Ref S3BucketCache
  RunsOnTopicArn:
    Description: SNS Topic where email alerts and reports are sent
    Value: !Ref AlertTopic
  RunsOnLaunchTemplateLinuxDefault:
    Description: Default Linux launch template for the RunsOn service
    Value: !Sub "${EC2FleetLaunchTemplateLinuxDefault}:${EC2FleetLaunchTemplateLinuxDefault.LatestVersionNumber}"
  RunsOnLaunchTemplateLinuxDefaultNested:
    Description: Default Linux nested launch template for the RunsOn service
    Value: !GetAtt ManagedLaunchTemplateLinuxDefaultNested.LaunchTemplateRef
  RunsOnLaunchTemplateWindowsDefault:
    Description: Default Windows launch template for the RunsOn service
    Value: !Sub "${EC2FleetLaunchTemplateWindowsDefault}:${EC2FleetLaunchTemplateWindowsDefault.LatestVersionNumber}"
  RunsOnLaunchTemplateWindowsDefaultNested:
    Description: Default Windows nested launch template for the RunsOn service
    Value: !GetAtt ManagedLaunchTemplateWindowsDefaultNested.LaunchTemplateRef
  RunsOnLaunchTemplateLinuxPrivate:
    Description: Private Linux launch template for the RunsOn service
    Value: !GetAtt ManagedLaunchTemplateLinuxPrivate.LaunchTemplateRef
  RunsOnLaunchTemplateLinuxPrivateNested:
    Description: Private Linux nested launch template for the RunsOn service
    Value: !GetAtt ManagedLaunchTemplateLinuxPrivateNested.LaunchTemplateRef
  RunsOnLaunchTemplateWindowsPrivate:
    Description: Private Windows launch template for the RunsOn service
    Value: !GetAtt ManagedLaunchTemplateWindowsPrivate.LaunchTemplateRef
  RunsOnLaunchTemplateWindowsPrivateNested:
    Description: Private Windows nested launch template for the RunsOn service
    Value: !GetAtt ManagedLaunchTemplateWindowsPrivateNested.LaunchTemplateRef
  RunsOnQueueWebhooks:
    Description: SQS FIFO queue for validated webhook deliveries
    Value: !Ref RunsOnQueueWebhooks
  RunsOnQueueSystem:
    Description: SQS FIFO queue for internal system events
    Value: !Ref RunsOnQueueSystem
  RunsOnQueueEvents:
    Description: SQS queue for the RunsOn service, used for events from other AWS services
    Value: !Ref RunsOnQueueEvents
  RunsOnLocksTable:
    Description: DynamoDB table for distributed locking
    Value: !Ref RunsOnLocksTable
  RunsOnWorkflowJobsTable:
    Description: DynamoDB table for workflow job tracking
    Value: !Ref RunsOnWorkflowJobsTable
  RunsOnServiceArn:
    Description: ARN of the ECS worker service
    Value: !Ref RunsOnWorkerService
  RunsOnServiceRoleArn:
    Description: ARN of the ECS worker task role
    Value: !GetAtt RunsOnServiceRole.Arn
  RunsOnStackConfigSecretArn:
    Description: ARN of the stack config secret
    Value: !Ref RunsOnStackConfigSecret
  RunsOnLicenseStatusParameterName:
    Description: Name of the SSM parameter storing the latest license status
    Value: !Ref RunsOnLicenseStatusParameter
  RunsOnEC2InstanceLogGroupArn:
    Description: Log group ARN for the EC2 instances
    Value: !Ref EC2InstanceLogGroup
  RunsOnEfsFileSystemId:
    Condition: CreateEfs
    Description: EFS File System ID (if enabled)
    Value: !Ref EfsFileSystem
  RunsOnEphemeralRegistryUri:
    Condition: CreateEphemeralRegistry
    Description: ECR Repository URI for Ephemeral Registry (if enabled)
    Value: !GetAtt EphemeralRegistry.RepositoryUri
