This is an automated email from the ASF dual-hosted git repository.

shahar1 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-ci-infra.git


The following commit(s) were added to refs/heads/main by this push:
     new b34ae2d  Retire the RunsOn CloudFormation stack (#87)
b34ae2d is described below

commit b34ae2dd7dec4d2e37b45f3eac8d26f3762cc044
Author: Shahar Epstein <[email protected]>
AuthorDate: Fri Nov 6 14:42:03 2026 +0200

    Retire the RunsOn CloudFormation stack (#87)
    
    Every job of apache/airflow has run on the runs-on-flex stack since 
2026-11-01
    (apache/airflow#76239), so the CloudFormation stack, the Terraform (RunsOn) 
workflow and the
    three roles that planned and applied it have no caller left. The stack 
itself is destroyed by
    hand before this merges: the apply role could only update it, never delete 
it, and
    runs-on-access is applied by hand anyway.
    
    Generated-by: Claude Fable 5.1
---
 .asf.yaml                                    |   2 +-
 .github/dependabot.yml                       |   4 +-
 .github/workflows/runs-on-usage-report.yml   |   4 +-
 .github/workflows/terraform-runs-on-flex.yml |   4 +-
 .github/workflows/terraform-runs-on.yml      | 118 ------------
 README.rst                                   |   6 +-
 terraform/runs-on-access/README.rst          |  67 ++++---
 terraform/runs-on-access/main.tf             | 205 +--------------------
 terraform/runs-on-access/runs-on-flex.tf     |  16 +-
 terraform/runs-on-flex/README.rst            |  55 +++---
 terraform/runs-on-flex/main.tf               |   4 +-
 terraform/runs-on/.terraform.lock.hcl        |  28 ---
 terraform/runs-on/README.rst                 | 259 ---------------------------
 terraform/runs-on/main.tf                    |  32 ----
 terraform/runs-on/provider.tf                |  31 ----
 terraform/runs-on/runs-on.yml.example        |  91 ----------
 terraform/runs-on/state.tf                   |  28 ---
 terraform/runs-on/variables.tf               |  65 -------
 terraform/runs-on/version.tf                 |  27 ---
 19 files changed, 78 insertions(+), 968 deletions(-)

diff --git a/.asf.yaml b/.asf.yaml
index 9f09211..207b1c4 100644
--- a/.asf.yaml
+++ b/.asf.yaml
@@ -62,7 +62,7 @@ github:
   oidc_subject:
     use_immutable_subject: true
   environments:
-    # Gates terraform apply of terraform/runs-on: the AWS apply role trusts 
only this environment.
+    # Gates terraform apply of terraform/runs-on-flex: the AWS apply role 
trusts only this environment.
     runs-on-prod:
       required_reviewers:
         - id: potiuk
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index ebc5af7..e0bd437 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -52,11 +52,9 @@ updates:
         patterns: ["*"]
 
   # Updates the providers in .terraform.lock.hcl and the version constraints 
in version.tf. A
-  # terraform/runs-on change is planned on its pull request and applied after 
merge, behind the
-  # runs-on-prod approval.
+  # terraform/runs-on-access change is applied by hand by an account admin 
after merge.
   - package-ecosystem: terraform
     directories:
-      - /terraform/runs-on
       - /terraform/runs-on-access
     schedule:
       interval: weekly
diff --git a/.github/workflows/runs-on-usage-report.yml 
b/.github/workflows/runs-on-usage-report.yml
index c516598..5da7138 100644
--- a/.github/workflows/runs-on-usage-report.yml
+++ b/.github/workflows/runs-on-usage-report.yml
@@ -92,8 +92,8 @@ jobs:
               gh issue create --title "${ISSUE_TITLE}" --body "${summary}
 
           The RunsOn stack launches Spot first and falls back to on-demand 
when Spot capacity is
-          short or while its Spot circuit breaker is open 
(\`SpotCircuitBreaker\` in
-          \`terraform/runs-on/main.tf\`). On-demand instances cost more and 
count against the
+          short or while its Spot circuit breaker is open 
(\`spot_circuit_breaker\` in
+          \`terraform/runs-on-flex/main.tf\`). On-demand instances cost more 
and count against the
           smaller on-demand vCPU quota, so jobs queue once it is full. The 
RunsOn CloudWatch
           dashboard shows the circuit breaker state. This issue gets a comment 
every week while the
           share stays above ${THRESHOLD_PERCENT}%, and is closed once it is 
back under."
diff --git a/.github/workflows/terraform-runs-on-flex.yml 
b/.github/workflows/terraform-runs-on-flex.yml
index 9981f49..5f10903 100644
--- a/.github/workflows/terraform-runs-on-flex.yml
+++ b/.github/workflows/terraform-runs-on-flex.yml
@@ -16,8 +16,8 @@
 # under the License.
 ---
 # Plans terraform/runs-on-flex on pull requests and on main; applies the plan 
made on main once a
-# required reviewer approves the runs-on-prod environment. Same flow as 
terraform-runs-on.yml; the
-# AWS roles live in terraform/runs-on-access and are applied by hand.
+# required reviewer approves the runs-on-prod environment. The AWS roles live 
in
+# terraform/runs-on-access and are applied by hand.
 name: Terraform (RunsOn Flex)
 
 on:  # yamllint disable-line rule:truthy
diff --git a/.github/workflows/terraform-runs-on.yml 
b/.github/workflows/terraform-runs-on.yml
deleted file mode 100644
index 89d9123..0000000
--- a/.github/workflows/terraform-runs-on.yml
+++ /dev/null
@@ -1,118 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
----
-# Plans terraform/runs-on on pull requests and on main; applies the plan made 
on main once a
-# required reviewer approves the runs-on-prod environment. The AWS roles live 
in
-# terraform/runs-on-access and are applied by hand.
-name: Terraform (RunsOn)
-
-on:  # yamllint disable-line rule:truthy
-  pull_request:
-    # README and example edits must not request an apply; -detailed-exitcode 
cannot filter no-op
-    # plans because every plan shows the masked LicenseKey diff (see 
terraform/runs-on/README.rst).
-    paths:
-      - "terraform/runs-on/*.tf"
-      - "terraform/runs-on/.terraform.lock.hcl"
-      - ".github/workflows/terraform-runs-on.yml"
-      # Pull requests only: a plan proves the plan role still admits pull 
requests after an account
-      # admin applied the changed trust policies by hand. On main the change 
needs no apply.
-      - "terraform/runs-on-access/*.tf"
-  push:
-    branches: [main]
-    paths:
-      - "terraform/runs-on/*.tf"
-      - "terraform/runs-on/.terraform.lock.hcl"
-      - ".github/workflows/terraform-runs-on.yml"
-
-permissions: {}
-
-concurrency:
-  group: terraform-runs-on-${{ github.ref }}
-  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
-
-env:
-  AWS_REGION: us-east-2
-  TF_IN_AUTOMATION: "true"
-  TF_INPUT: "false"
-
-jobs:
-  plan:
-    # Fork pull requests get no OIDC token, so they cannot plan.
-    if: >-
-      github.event_name == 'push' ||
-      github.event.pull_request.head.repo.full_name == github.repository
-    runs-on: ubuntu-slim
-    permissions:
-      contents: read
-      id-token: write
-    defaults:
-      run:
-        working-directory: terraform/runs-on
-    steps:
-      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
-        with:
-          persist-credentials: false
-      - uses: 
aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd  
# v6.3.0
-        with:
-          role-to-assume: arn:aws:iam::827901512104:role/runs-on-terraform-plan
-          aws-region: ${{ env.AWS_REGION }}
-      - uses: 
hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e  # v4.0.1
-        with:
-          terraform_version: 1.16.2
-          terraform_wrapper: false
-      - run: terraform init -lockfile=readonly
-      # The plan role cannot write the lock file; apply takes the lock and 
refuses a stale plan.
-      - run: terraform plan -lock=false -out=runs-on.tfplan
-      - if: github.event_name == 'push'
-        uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 
 # v7.0.2
-        with:
-          name: runs-on-tfplan
-          path: terraform/runs-on/runs-on.tfplan
-          retention-days: 7
-
-  apply:
-    needs: plan
-    if: github.event_name == 'push'
-    # Not ubuntu-slim: its 15-minute job limit is shorter than a stack update 
may take (the stack's
-    # timeout_in_minutes is 30), and a killed apply leaves the state lock held.
-    runs-on: ubuntu-24.04
-    # Required reviewers and the main-only branch policy come from .asf.yaml.
-    environment: runs-on-prod
-    permissions:
-      contents: read
-      id-token: write
-    defaults:
-      run:
-        working-directory: terraform/runs-on
-    steps:
-      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # 
v7.0.1
-        with:
-          persist-credentials: false
-      - uses: 
aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd  
# v6.3.0
-        with:
-          role-to-assume: 
arn:aws:iam::827901512104:role/runs-on-terraform-apply
-          aws-region: ${{ env.AWS_REGION }}
-      - uses: 
hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e  # v4.0.1
-        with:
-          terraform_version: 1.16.2
-          terraform_wrapper: false
-      - run: terraform init -lockfile=readonly
-      - uses: 
actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333  # v8.0.2
-        with:
-          name: runs-on-tfplan
-          path: terraform/runs-on
-      - run: terraform apply runs-on.tfplan
diff --git a/README.rst b/README.rst
index 304beb5..e33ef28 100644
--- a/README.rst
+++ b/README.rst
@@ -23,7 +23,7 @@ RunsOn deployment
 
 `AIP-118 
<https://cwiki.apache.org/confluence/spaces/AIRFLOW/pages/451974652>`_ selected
 `RunsOn <https://runs-on.com/>`_ for Airflow's self-hosted GitHub Actions 
runners. The Terraform
-module that creates its CloudFormation stack, and the runner presets, live in
-`terraform/runs-on <terraform/runs-on/README.rst>`_; the ``Terraform 
(RunsOn)`` workflow plans
-and applies it, with the AWS roles from
+module that deploys its stack lives in
+`terraform/runs-on-flex <terraform/runs-on-flex/README.rst>`_; the ``Terraform 
(RunsOn Flex)``
+workflow plans and applies it, with the AWS roles from
 `terraform/runs-on-access <terraform/runs-on-access/README.rst>`_.
diff --git a/terraform/runs-on-access/README.rst 
b/terraform/runs-on-access/README.rst
index 8b48218..d450d5b 100644
--- a/terraform/runs-on-access/README.rst
+++ b/terraform/runs-on-access/README.rst
@@ -18,43 +18,42 @@
 AWS access for the RunsOn Terraform workflow
 ============================================
 
-The IAM roles that let ``.github/workflows/terraform-runs-on.yml`` plan and 
apply
-`terraform/runs-on <../runs-on/README.rst>`_ without any stored AWS 
credential. This module is
-applied **by hand by an account admin** (own state key 
``tf-state/runs-on-access.tfstate``) and
-never by the workflow, so the workflow cannot widen its own permissions.
+The IAM roles that let ``.github/workflows/terraform-runs-on-flex.yml`` plan 
and apply
+`terraform/runs-on-flex <../runs-on-flex/README.rst>`_ without any stored AWS 
credential, and the
+role of the weekly usage report. This module is applied **by hand by an 
account admin** (own state
+key ``tf-state/runs-on-access.tfstate``) and never by the workflow, so the 
workflow cannot widen
+its own permissions.
 
 Roles
 -----
 
-``runs-on-terraform-plan``
+``runs-on-terraform-flex-plan``
     Assumed through GitHub OIDC by pull requests of 
``apache/airflow-ci-infra`` and by pushes to
-    ``main``. Reads the stack, the state and the license-key parameter 
**without** decryption
-    (``kms:Decrypt`` is denied explicitly: the ``aws/ssm`` key policy would 
otherwise let
-    ``ssm:GetParameter`` return the plain key). Plans run with 
``-lock=false``; the role writes
-    nothing. Fork pull requests get no OIDC token and cannot use it.
-``runs-on-terraform-apply``
+    ``main``. ``ReadOnlyAccess``, which refreshing the module's resources 
needs, with denies that
+    keep secrets out of a pull request's reach: no S3 object but this module's 
state, no
+    ``kms:Decrypt`` through SSM (the ``aws/ssm`` key policy would otherwise let
+    ``ssm:GetParameter`` return the plain license key) and no Secrets Manager 
values. Plans run
+    with ``-lock=false``; the role writes nothing. Fork pull requests get no 
OIDC token and cannot
+    use it.
+``runs-on-terraform-flex-apply``
     Assumed only by jobs in the ``runs-on-prod`` environment, which 
``.asf.yaml`` limits to
     ``main`` and to deployments approved by a required reviewer other than the 
person who
-    triggered them. It can call ``cloudformation:UpdateStack`` on the 
``runs-on`` stack only with
-    one of the release templates RunsOn publishes
-    
(``https://runs-on.s3.eu-west-1.amazonaws.com/cloudformation/template-vX.Y.Z.yaml``,
 matched
-    with ``?`` globs so pre-release and other objects in the bucket do not 
qualify), so a RunsOn
-    upgrade or rollback needs only the reviewed ``runs_on_version`` change in
-    ``terraform/runs-on``. It can also delete the stack, pass the service role 
below and write
-    the state and its lock file. It holds no infrastructure permissions.
-``runs-on-cloudformation``
-    The stack's CloudFormation service role, which performs every create, 
update and delete.
-    RunsOn documents only "admin rights" for deploying its stack, and its 
template touches IAM,
-    VPC, ECS, Lambda, API Gateway, SQS, DynamoDB, S3, SNS and Budgets, with a 
set that changes
-    between releases, so the role has ``AdministratorAccess`` with explicit 
denies on top: IAM
-    writes only to the roles and instance profile the template names
-    (``runs-on-<logical id>-*``) and to service-linked roles, no 
``sts:AssumeRole*``, and no
-    access to the ``airflow-ci-tfstate`` bucket. The role may attach only the 
two managed
-    policies the template uses, so the ``AppCustomPolicy`` / 
``RunnerCustomPolicy`` stack
-    parameters cannot give the stack's roles (or the CI runners) more 
permissions; a custom policy
-    needs that list extended here first. A template release that adds an IAM 
resource
-    with a new logical-ID prefix fails its update with ``AccessDenied`` until 
the list in
-    ``main.tf`` is extended.
+    triggered them. It creates the stack's resources itself. RunsOn documents 
only "admin rights"
+    for deploying its stack, and the module's resource set changes between 
releases, so the role
+    has ``AdministratorAccess`` with explicit denies on top 
(``runs-on-flex.tf``): IAM writes only
+    to ``runs-on-flex-*`` roles and instance profiles and to service-linked 
roles, every role it
+    creates must carry the ``runs-on-flex-boundary`` permissions boundary, 
only the two managed
+    policies the module attaches, no ``sts:AssumeRole*``, no S3 but the 
stack's cache bucket
+    (``runs-on-flex-cache-*``) and this module's state and lock file, and 
us-east-2 only (IAM,
+    STS and Budgets are global). A module release that names an IAM role 
outside that prefix or
+    attaches another managed policy fails its apply with ``AccessDenied`` 
until the lists in
+    ``runs-on-flex.tf`` are extended.
+``runs-on-flex-boundary``
+    The permissions boundary of every role the stack creates, so the ceiling 
of what a Lambda
+    function, an ECS task or a runner instance of the stack can do whatever 
its own policies say:
+    no IAM writes, ``iam:PassRole`` and ``sts:AssumeRole`` only on the stack's 
own roles,
+    us-east-2 only (plus the global endpoints the module's roles call) and no 
access to the
+    ``airflow-ci-tfstate`` bucket.
 ``runs-on-usage-report``
     Assumed through GitHub OIDC by workflows running on ``main`` of 
``apache/airflow-ci-infra``,
     for the weekly ``.github/workflows/runs-on-usage-report.yml``. It can only 
call
@@ -93,13 +92,11 @@ Bootstrap order
 
        cd terraform/runs-on-access
        terraform init
-       terraform plan -out=runs-on-access.tfplan   # 4 roles and their 
policies, nothing else
+       terraform plan -out=runs-on-access.tfplan   # 3 roles, their policies 
and the boundary
        terraform apply runs-on-access.tfplan
 
-5. The same admin applies ``terraform/runs-on`` once by hand, which sets 
``iam_role_arn`` on the
-   stack (the workflow would only do it on the next push that changes 
``terraform/runs-on``'s
-   ``.tf`` files).
-   From then on the workflow plans and applies.
+   From then on the ``Terraform (RunsOn Flex)`` workflow plans and applies
+   ``terraform/runs-on-flex``.
 
 Switching to the immutable subject
 ----------------------------------
diff --git a/terraform/runs-on-access/main.tf b/terraform/runs-on-access/main.tf
index fe3d3de..425369f 100644
--- a/terraform/runs-on-access/main.tf
+++ b/terraform/runs-on-access/main.tf
@@ -15,19 +15,10 @@
 # specific language governing permissions and limitations
 # under the License.
 
+# What every role here shares: the GitHub OIDC trust of 
apache/airflow-ci-infra. The roles of the
+# Terraform (RunsOn Flex) workflow are in runs-on-flex.tf.
 locals {
   account_id = data.aws_caller_identity.current.account_id
-  stack_arn  = 
"arn:aws:cloudformation:us-east-2:${local.account_id}:stack/runs-on/*"
-  state      = "arn:aws:s3:::airflow-ci-tfstate/tf-state/runs-on.tfstate"
-
-  # Any release template RunsOn publishes (vX.Y.Z, one-digit major, one- or 
two-digit minor and
-  # patch), so an upgrade (or a rollback) is a reviewed change of 
runs_on_version in
-  # terraform/runs-on alone. "?" matches exactly one character, unlike "*", so 
pre-release or other
-  # objects in the bucket (template-v3.6.0-rc1.yaml, sub-paths) and other 
buckets stay out of reach.
-  runs_on_template_urls = [
-    for p in setproduct(["?", "??"], ["?", "??"]) :
-    
"https://runs-on.s3.eu-west-1.amazonaws.com/cloudformation/template-v?.${p[0]}.${p[1]}.yaml";
-  ]
 
   # The repository is pinned by its immutable IDs (repository_owner_id and 
repository_id claims),
   # so a renamed or re-created repository cannot match. Fork pull requests get 
no OIDC token at all,
@@ -50,21 +41,6 @@ locals {
   }
   # The weekly report runs on a schedule, so only main.
   usage_report_subjects = [for s in local.github_subject_prefixes : 
"${s}:ref:refs/heads/main"]
-
-  # IAM names the RunsOn template generates for the runs-on stack: 
<stack>-<logical id>-<suffix>.
-  # Matching the logical-ID prefixes rather than "runs-on-*" keeps every other 
role in the account,
-  # including the ones defined here, out of reach of the CloudFormation 
service role.
-  stack_role_prefixes = [
-    "RunsOn", "EC2Instance", "ManagedLaunchTemplate", "SchedulerInvoke", 
"StackConfigMaterializer",
-    "NetworkFirewallEndpointResolver", "AlertTopicSlackWebhook",
-  ]
-  stack_iam_resources = concat(
-    [for prefix in local.stack_role_prefixes : 
"arn:aws:iam::${local.account_id}:role/runs-on-${prefix}*"],
-    [
-      
"arn:aws:iam::${local.account_id}:instance-profile/runs-on-EC2InstanceProfile*",
-      "arn:aws:iam::${local.account_id}:role/aws-service-role/*",
-    ],
-  )
 }
 
 data "aws_caller_identity" "current" {}
@@ -106,110 +82,6 @@ data "aws_iam_policy_document" "github_trust" {
   }
 }
 
-resource "aws_iam_role" "terraform" {
-  for_each = local.github_subjects
-
-  name               = "runs-on-terraform-${each.key}"
-  description        = "terraform ${each.key} of terraform/runs-on from 
apache/airflow-ci-infra GitHub Actions"
-  assume_role_policy = data.aws_iam_policy_document.github_trust[each.key].json
-}
-
-# What terraform plan needs: refresh the stack, prove the license-key 
parameter exists and read
-# the state. Plans run with -lock=false, so this policy grants no writes.
-data "aws_iam_policy_document" "read" {
-  statement {
-    actions   = ["cloudformation:DescribeStacks", 
"cloudformation:DescribeStackEvents", "cloudformation:GetTemplate"]
-    resources = [local.stack_arn]
-  }
-  statement {
-    actions   = ["ssm:GetParameter"]
-    resources = 
["arn:aws:ssm:us-east-2:${local.account_id}:parameter/runs-on/license-key"]
-  }
-  # The aws/ssm key policy lets any principal in the account decrypt through 
SSM, so
-  # GetParameter alone would also return the plain license key. Terraform 
never asks for it. The
-  # deny is limited to SSM so an SSE-KMS state bucket would stay readable.
-  statement {
-    effect    = "Deny"
-    actions   = ["kms:Decrypt"]
-    resources = ["*"]
-    condition {
-      test     = "StringEquals"
-      variable = "kms:ViaService"
-      values   = ["ssm.us-east-2.amazonaws.com"]
-    }
-  }
-  statement {
-    actions   = ["s3:ListBucket"]
-    resources = ["arn:aws:s3:::airflow-ci-tfstate"]
-    condition {
-      test     = "StringEquals"
-      variable = "s3:prefix"
-      values   = ["tf-state/runs-on.tfstate"]
-    }
-  }
-  statement {
-    actions   = ["s3:GetObject"]
-    resources = [local.state]
-  }
-}
-
-resource "aws_iam_role_policy" "read" {
-  for_each = aws_iam_role.terraform
-
-  name   = "terraform-read"
-  role   = each.value.name
-  policy = data.aws_iam_policy_document.read.json
-}
-
-# The apply role can only resubmit the stack from RunsOn's published 
templates, or delete it.
-# CloudFormation then acts as the stack's service role below, so this role 
holds no
-# infrastructure permissions.
-data "aws_iam_policy_document" "apply" {
-  statement {
-    actions   = ["cloudformation:UpdateStack"]
-    resources = [local.stack_arn]
-    condition {
-      test     = "StringLike"
-      variable = "cloudformation:TemplateUrl"
-      values   = local.runs_on_template_urls
-    }
-  }
-  # Own statement: the TemplateUrl condition above would deny a DeleteStack, 
which carries no
-  # template.
-  statement {
-    actions   = ["cloudformation:DeleteStack"]
-    resources = [local.stack_arn]
-  }
-  # The provider sends the default tags on every UpdateStack.
-  statement {
-    actions   = ["cloudformation:TagResource", "cloudformation:UntagResource"]
-    resources = [local.stack_arn]
-  }
-  statement {
-    actions   = ["iam:PassRole"]
-    resources = [aws_iam_role.cloudformation.arn]
-    condition {
-      test     = "StringEquals"
-      variable = "iam:PassedToService"
-      values   = ["cloudformation.amazonaws.com"]
-    }
-  }
-  statement {
-    actions   = ["s3:PutObject"]
-    resources = [local.state]
-  }
-  statement {
-    actions   = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
-    resources = ["${local.state}.tflock"]
-  }
-}
-
-resource "aws_iam_role_policy" "apply" {
-  name   = "terraform-apply"
-  role   = aws_iam_role.terraform["apply"].name
-  policy = data.aws_iam_policy_document.apply.json
-}
-
 # Read-only role for .github/workflows/runs-on-usage-report.yml, which 
compares the account's
 # on-demand and Spot vCPU usage. GetMetricData has no resource-level 
permissions.
 resource "aws_iam_role" "usage_report" {
@@ -230,76 +102,3 @@ resource "aws_iam_role_policy" "usage_report" {
   role   = aws_iam_role.usage_report.name
   policy = data.aws_iam_policy_document.usage_report.json
 }
-
-data "aws_iam_policy_document" "cloudformation_trust" {
-  statement {
-    actions = ["sts:AssumeRole"]
-    principals {
-      type        = "Service"
-      identifiers = ["cloudformation.amazonaws.com"]
-    }
-  }
-}
-
-resource "aws_iam_role" "cloudformation" {
-  name               = "runs-on-cloudformation"
-  description        = "Service role CloudFormation uses to update the runs-on 
stack"
-  assume_role_policy = data.aws_iam_policy_document.cloudformation_trust.json
-}
-
-# RunsOn documents only "admin rights" for deploying its stack: the template 
creates IAM, VPC,
-# ECS, Lambda, API Gateway, SQS, DynamoDB, S3, SNS and Budgets resources, and 
the set changes
-# between releases. AdministratorAccess follows that; the denies below keep 
the role away from
-# everything in the account that is not the stack's own.
-resource "aws_iam_role_policy_attachment" "cloudformation" {
-  role       = aws_iam_role.cloudformation.name
-  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
-}
-
-data "aws_iam_policy_document" "cloudformation_guardrails" {
-  statement {
-    sid    = "OnlyTheStackIam"
-    effect = "Deny"
-    actions = [
-      "iam:Add*", "iam:Attach*", "iam:Change*", "iam:Create*", 
"iam:Deactivate*", "iam:Delete*",
-      "iam:Detach*", "iam:Enable*", "iam:PassRole", "iam:Put*", "iam:Remove*", 
"iam:Reset*",
-      "iam:Set*", "iam:Tag*", "iam:Untag*", "iam:Update*", "iam:Upload*",
-    ]
-    not_resources = local.stack_iam_resources
-  }
-  # Stack parameters (AppCustomPolicy, RunnerCustomPolicy) can attach any 
managed policy to the
-  # stack's own roles, which the IAM deny above exempts. The template itself 
attaches only these
-  # two.
-  statement {
-    sid       = "OnlyTheTemplateManagedPolicies"
-    effect    = "Deny"
-    actions   = ["iam:AttachRolePolicy"]
-    resources = ["*"]
-    condition {
-      test     = "ArnNotEquals"
-      variable = "iam:PolicyARN"
-      values = [
-        "arn:aws:iam::aws:policy/AmazonSSMManagedEC2InstanceDefaultPolicy",
-        
"arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy",
-      ]
-    }
-  }
-  statement {
-    sid       = "NoRoleHopping"
-    effect    = "Deny"
-    actions   = ["sts:AssumeRole", "sts:AssumeRoleWithWebIdentity", 
"sts:AssumeRoleWithSAML"]
-    resources = ["*"]
-  }
-  statement {
-    sid       = "NoTerraformState"
-    effect    = "Deny"
-    actions   = ["s3:*"]
-    resources = ["arn:aws:s3:::airflow-ci-tfstate", 
"arn:aws:s3:::airflow-ci-tfstate/*"]
-  }
-}
-
-resource "aws_iam_role_policy" "cloudformation_guardrails" {
-  name   = "guardrails"
-  role   = aws_iam_role.cloudformation.name
-  policy = data.aws_iam_policy_document.cloudformation_guardrails.json
-}
diff --git a/terraform/runs-on-access/runs-on-flex.tf 
b/terraform/runs-on-access/runs-on-flex.tf
index 06a98b6..ff4d5f9 100644
--- a/terraform/runs-on-access/runs-on-flex.tf
+++ b/terraform/runs-on-access/runs-on-flex.tf
@@ -15,13 +15,13 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# Roles of the Terraform (RunsOn Flex) workflow, which applies 
terraform/runs-on-flex: RunsOn's
-# Terraform module instead of its CloudFormation template. No CloudFormation 
service role sits in
-# between, so the apply role itself creates the infrastructure. It gets what 
the CloudFormation
-# service role in main.tf has (AdministratorAccess with denies) and three 
limits more: it acts
-# only in us-east-2 (IAM and Budgets are global), it reaches no S3 bucket but 
the stack's cache
-# buckets and its own state, and every role it creates carries the 
runs-on-flex-boundary
-# permissions boundary, so no role of the stack can be given more than the 
boundary allows.
+# Roles of the Terraform (RunsOn Flex) workflow, which applies 
terraform/runs-on-flex from RunsOn's
+# Terraform module. No CloudFormation service role sits in between, so the 
apply role itself
+# creates the infrastructure: AdministratorAccess with denies on top. It 
writes IAM only under the
+# stack's own prefix, it acts only in us-east-2 (IAM and Budgets are global), 
it reaches no S3
+# bucket but the stack's cache buckets and its own state, and every role it 
creates carries the
+# runs-on-flex-boundary permissions boundary, so no role of the stack can be 
given more than the
+# boundary allows.
 locals {
   flex_stack = "runs-on-flex"
   flex_state = "arn:aws:s3:::airflow-ci-tfstate/tf-state/runs-on-flex.tfstate"
@@ -98,7 +98,7 @@ resource "aws_iam_role_policy" "flex_plan_guardrails" {
 }
 
 # RunsOn documents only "admin rights" for deploying the stack, and the 
module's resource set
-# changes between releases, so AdministratorAccess as for the CloudFormation 
service role.
+# changes between releases, so AdministratorAccess.
 resource "aws_iam_role_policy_attachment" "flex_apply" {
   role       = aws_iam_role.flex_terraform["apply"].name
   policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
diff --git a/terraform/runs-on-flex/README.rst 
b/terraform/runs-on-flex/README.rst
index 65a0646..157d113 100644
--- a/terraform/runs-on-flex/README.rst
+++ b/terraform/runs-on-flex/README.rst
@@ -18,17 +18,17 @@
 RunsOn Flex — the runners from RunsOn's Terraform module
 ========================================================
 
-This module deploys the same RunsOn runners as `terraform/runs-on 
<../runs-on/README.rst>`_, from
-RunsOn's `Terraform module 
<https://github.com/runs-on/terraform-aws-runs-on>`_ (Flex, v3.5.1)
-instead of its CloudFormation template, into a VPC this module creates with a 
public subnet in
-each of the three us-east-2 zones. Both stacks run side by side until the jobs 
of
-``apache/airflow`` have moved here; the CloudFormation stack is then removed 
in a separate change.
+This module deploys Airflow's RunsOn runners from RunsOn's
+`Terraform module <https://github.com/runs-on/terraform-aws-runs-on>`_ (Flex, 
v3.5.1) into a VPC
+this module creates with a public subnet in each of the three us-east-2 zones. 
It replaced the
+``runs-on`` CloudFormation stack (RunsOn's template, ``terraform/runs-on`` 
until its retirement),
+which served the jobs of ``apache/airflow`` until 2026-11-01.
 
 Why this module
 ---------------
 
-The CloudFormation template places runners in the first two zones of the 
region, use2-az1 and
-use2-az2 for this account, and has no parameter to change that. On 2026-10-30 
those two zones
+The CloudFormation template placed runners in the first two zones of the 
region, use2-az1 and
+use2-az2 for this account, and had no parameter to change that. On 2026-10-30 
those two zones
 had the worst Spot capacity in the region for the runners' instance families:
 
 =================================  ==========  ==========  ==========
@@ -48,10 +48,10 @@ Files
 
 ``network.tf``
     VPC (``vpc_cidr``, default ``10.3.0.0/16``), internet gateway, one public 
subnet per zone ID
-    and an S3 gateway endpoint. No NAT: runners get public IPs, as on the 
CloudFormation stack.
+    and an S3 gateway endpoint. No NAT: runners get public IPs.
 ``main.tf``
-    The ``runs-on/runs-on/aws//modules/flex`` module with the CloudFormation 
stack's settings, the
-    license-key lookup and the permissions boundary of the stack's roles.
+    The ``runs-on/runs-on/aws//modules/flex`` module with the settings carried 
over from the
+    CloudFormation stack, the license-key lookup and the permissions boundary 
of the stack's roles.
 ``variables.tf``
     All variables have defaults: ``stack_name`` (``runs-on-flex``), 
``runs_on_environment``
     (``flex``), ``github_organization``, ``alert_email``, 
``enable_admin_routes``
@@ -59,13 +59,14 @@ Files
 ``state.tf``
     S3 backend ``airflow-ci-tfstate`` / ``tf-state/runs-on-flex.tfstate``.
 
-How it differs from the CloudFormation stack
---------------------------------------------
+How it differs from the retired CloudFormation stack
+----------------------------------------------------
 
-- **Environment.** The stack takes only jobs whose label carries ``env=flex``; 
the CloudFormation
-  stack keeps the default ``production`` and ignores them. That is what lets 
both run at once.
-- **Who creates the resources.** The CloudFormation stack is applied by a role 
that can only
-  resubmit RunsOn's published template; CloudFormation's service role does the 
work. Here the
+- **Environment.** The stack takes only jobs whose label carries ``env=flex``. 
A job without
+  ``env`` targets RunsOn's default ``production`` environment, which no stack 
serves any more, so
+  it stays queued.
+- **Who creates the resources.** The CloudFormation stack was applied by a 
role that could only
+  resubmit RunsOn's published template; CloudFormation's service role did the 
work. Here the
   ``runs-on-terraform-flex-apply`` role creates everything itself, with 
AdministratorAccess and
   the denies in `terraform/runs-on-access/runs-on-flex.tf 
<../runs-on-access/runs-on-flex.tf>`_:
   IAM writes only on ``runs-on-flex-*`` roles and only with the 
``runs-on-flex-boundary``
@@ -83,9 +84,8 @@ Setup
 1. **Access (account admin, once).** Apply `terraform/runs-on-access 
<../runs-on-access/README.rst>`_:
    it adds the ``runs-on-terraform-flex-plan`` and ``-apply`` roles and the
    ``runs-on-flex-boundary`` policy.
-2. **License key (manual, once).** The same RunsOn license as the 
CloudFormation stack, under
-   this stack's name. The module receives only the parameter's ARN, so the key 
never reaches
-   Terraform state:
+2. **License key (manual, once).** The RunsOn license, under this stack's 
name. The module
+   receives only the parameter's ARN, so the key never reaches Terraform state:
 
    .. code-block:: bash
 
@@ -99,17 +99,12 @@ Setup
    ``Terraform (RunsOn Flex)`` workflow plans on ``main`` and applies once a 
reviewer approves
    ``runs-on-prod``. Set ``enable_admin_routes = true`` in that first change.
 4. **GitHub App (with ASF Infra).** Open the ``entry_point`` output → 
**Register app**. This
-   creates a second private GitHub App for this stack; ASF Infra installs it on
-   ``apache/airflow`` (and ``.github-private``, for the presets). Then set
-   ``enable_admin_routes`` back to ``false`` in a follow-up change.
-5. **Move the jobs.** In ``apache/airflow``, add ``/env=flex`` to the RunsOn 
label templates in
-   ``dev/breeze/src/airflow_breeze/global_constants.py``. All jobs of a run 
must move together:
-   the stash actions keep image tars in the cache bucket of the stack that ran 
the job, so a
-   job on the other stack would not find them. Rolling back is removing 
``/env=flex``.
-6. **Retire the CloudFormation stack** once the runs have been green on this 
stack for a while:
-   a separate change in ``terraform/runs-on`` (remove ``prevent_destroy``, 
destroy), the old
-   GitHub App uninstalled by ASF Infra, and the ``runs-on-terraform-plan``, 
``-apply`` and
-   ``runs-on-cloudformation`` roles removed from ``runs-on-access``.
+   creates a private GitHub App for this stack; ASF Infra installs it on 
``apache/airflow``. Then
+   set ``enable_admin_routes`` back to ``false`` in a follow-up change.
+5. **Point the jobs here.** In ``apache/airflow``, the RunsOn label templates 
in
+   ``dev/breeze/src/airflow_breeze/global_constants.py`` carry ``/env=flex``. 
All jobs of a run
+   must target the same stack: the stash actions keep image tars in the cache 
bucket of the stack
+   that ran the job, so a job on another stack would not find them.
 
 Locally, an account admin runs the same steps as the workflow::
 
diff --git a/terraform/runs-on-flex/main.tf b/terraform/runs-on-flex/main.tf
index 7ed8ce4..aefd3c5 100644
--- a/terraform/runs-on-flex/main.tf
+++ b/terraform/runs-on-flex/main.tf
@@ -42,7 +42,7 @@ module "runs_on" {
   # Created in terraform/runs-on-access; the apply role cannot create a stack 
role without it.
   permission_boundary_arn = 
"arn:aws:iam::${data.aws_caller_identity.current.account_id}:policy/${var.stack_name}-boundary"
 
-  # The values of the CloudFormation stack (terraform/runs-on/main.tf); 
everything else keeps the
+  # The values carried over from the retired CloudFormation stack; everything 
else keeps the
   # module default.
   ssh_allowed           = false
   ssm_allowed           = true
@@ -52,7 +52,7 @@ module "runs_on" {
   cache_expiration_days = 10
   app_size              = "small"
   # With hundreds of runners two interruptions in 15 minutes are routine, so 
the default 2/15/30
-  # keeps Spot off (terraform/runs-on/main.tf).
+  # keeps Spot off.
   spot_circuit_breaker = "30/15/10"
   enable_cost_reports  = "daily"
   app_budget_daily_usd = 10 # alert only, not a spending cap
diff --git a/terraform/runs-on/.terraform.lock.hcl 
b/terraform/runs-on/.terraform.lock.hcl
deleted file mode 100644
index 5b3d3ef..0000000
--- a/terraform/runs-on/.terraform.lock.hcl
+++ /dev/null
@@ -1,28 +0,0 @@
-# This file is maintained automatically by "terraform init".
-# Manual edits may be lost in future updates.
-
-provider "registry.terraform.io/hashicorp/aws" {
-  version     = "6.70.0"
-  constraints = "~> 6.0"
-  hashes = [
-    "h1:FTJvoIDVz+x6B26k+fxXHn0NOKMvVOP9qLz6VdycFkE=",
-    "h1:HCr4MYgxlkFBOAeWKOoktNNzZaQX8ZZY4YTxmL/1V/Y=",
-    "h1:L+05gOXA+kdYTtayIw0WxdaTp3JryZCMZpfrJVhoJA0=",
-    "zh:068ca7ed4fc77e3d63d7fac54515a426a32f3dc143b55a6ad00963fe68289955",
-    "zh:2d8c016b3809ab32f536b01bf2c98fcbdeb066b7b7e50f756ff6ff1e0b0539c7",
-    "zh:450d960b60ea81268bd355600340787254f750d76e0075208a6fb903d7336b09",
-    "zh:50447833cce21288ffc331342f9d83854e80515e2d86e8f5776a07ce18866b20",
-    "zh:58acc041b080778ded75320e241ebcf6cf2ed145bede644b86a4784ce46086c6",
-    "zh:7e899a44a1da95fefb1b04b231cf7cdb5871f482f60a710ede78bf25ac265724",
-    "zh:8b141d13f4b84d7a47bc5a4855d0fc985191317b3249603532d4de03db5741c5",
-    "zh:9035357f7d065ce8d35a9b05337977910683219c0f13fa09155d28f9b32e23a7",
-    "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425",
-    "zh:9ba0990a876d2641a185ca88263719afc4d5ac43533bdb147437f0073d2cd74d",
-    "zh:a8773c7a02e1b3290df89351ee493fd8b74f807f92a7f69534e05901d4515761",
-    "zh:ab0ebb298578eeeffe0fbf0a8b27ce99acd4419b7049272f1ab87e8c47229407",
-    "zh:b88a3f22127bfb17604adc53e9bdb8ed0eddf4b9b77bf19871cba2e7fcd5b4a3",
-    "zh:c7b1fb41e7f086b670f78932ede2b539903a8d71bac06f55f14a1a33d09a1ccf",
-    "zh:df482bd93f51125f78feab7742aae1d7f22c7108ee2dc47787c4a1d611e6ccfd",
-    "zh:fca401ffaa42d5f350d667123569de51a23de8c30b87ce0bd9d86fc80835fd4d",
-  ]
-}
diff --git a/terraform/runs-on/README.rst b/terraform/runs-on/README.rst
deleted file mode 100644
index 33dbe16..0000000
--- a/terraform/runs-on/README.rst
+++ /dev/null
@@ -1,259 +0,0 @@
-.. Licensed to the Apache Software Foundation (ASF) under one
-   or more contributor license agreements.  See the NOTICE file
-   distributed with this work for additional information
-   regarding copyright ownership.  The ASF licenses this file
-   to you under the Apache License, Version 2.0 (the
-   "License"); you may not use this file except in compliance
-   with the License.  You may obtain a copy of the License at
-
-..   http://www.apache.org/licenses/LICENSE-2.0
-
-.. Unless required by applicable law or agreed to in writing,
-   software distributed under the License is distributed on an
-   "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-   KIND, either express or implied.  See the License for the
-   specific language governing permissions and limitations
-   under the License.
-
-RunsOn — self-hosted CI runners on AWS
-======================================
-
-`RunsOn <https://runs-on.com/>`_ is the approach selected by
-`AIP-118 
<https://cwiki.apache.org/confluence/spaces/AIRFLOW/pages/451974652>`_ for 
Airflow's
-self-hosted GitHub Actions runners. It packages runner provisioning into a 
CloudFormation stack in
-Airflow's AWS account: a small control plane (ECS Fargate + API Gateway) 
receives GitHub's
-``workflow_job`` webhooks and launches one ephemeral EC2 instance per job, 
Spot first, sized from
-the job's ``runs-on:`` label. RunsOn's `free open-source license 
<https://runs-on.com/billing/free/>`_
-covers the product; EC2/EBS/S3 usage is billed to the account.
-
-This module creates the stack for the **apache** organization from RunsOn's 
published,
-version-pinned template, with the configuration validated on 2026-09-25 (full 
``Tests (AMD)``
-graph green apart from two non-hermetic tests — see the AIP-118 appendix).
-
-Files
------
-
-``main.tf``
-    The ``aws_cloudformation_stack`` resource, its parameters and the 
license-key lookup.
-``variables.tf``
-    All variables have defaults: ``stack_name`` (``runs-on``), 
``github_organization``
-    (``apache``), ``alert_email`` (``[email protected]``), 
``runs_on_version``
-    (``v3.5.1``), ``enable_admin_routes`` (``false``) and ``vpc_cidr`` 
(``10.2.0.0/16``). The
-    region (``us-east-2``) is not a variable: it is hard-coded in 
``provider.tf``, the backend in
-    ``state.tf`` and the commands below, so a move changes all of them.
-``state.tf``
-    S3 backend ``airflow-ci-tfstate`` / ``tf-state/runs-on.tfstate`` (its own 
key next to the
-    EKS states).
-``runs-on.yml.example``
-    Runner presets for Airflow's three size classes, to be committed as 
``.github/runs-on.yml``
-    in the organization's ``.github-private`` repository (or a repository's 
default branch).
-
-Step 1 — create the license-key parameter (manual, once)
---------------------------------------------------------
-
-RunsOn accepts ``LicenseKey`` either as plain text or as the ARN of an SSM 
Parameter Store
-parameter that must be named ``/<stack name>/license-key`` (`reference
-<https://runs-on.com/docs/maintenance/configuration/flex/cloudformation/>`_). 
The ARN form keeps
-the key out of Terraform variables, plan output and state: ``main.tf`` reads 
the parameter
-**without decryption** (only to prove it exists) and passes its ARN; the 
stack's service role is
-the only principal that decrypts it. Secrets Manager is not supported by the 
template.
-
-.. code-block:: bash
-
-    aws ssm put-parameter --region us-east-2 \
-        --name /runs-on/license-key \
-        --type SecureString \
-        --description "RunsOn license key for the runs-on stack (GitHub 
organization apache)" \
-        --value 'RUNS-ON-LICENSE-KEY'
-
-Use the default SSM KMS key (``alias/aws/ssm``): the template grants the 
stack's role access to
-that key; a customer key would need an extra key policy. Never put the key in 
``terraform.tfvars``.
-Rotating the key does not go through Terraform: ``LicenseKey`` stays the same 
ARN, so an apply
-does not make the stack read the new value. The control-plane service reads 
the parameter itself,
-so overwrite it and restart the service:
-
-.. code-block:: bash
-
-    aws ssm put-parameter --region us-east-2 --name /runs-on/license-key \
-        --type SecureString --overwrite --value 'NEW-RUNS-ON-LICENSE-KEY'
-    service="$(aws cloudformation describe-stack-resource --region us-east-2 \
-        --stack-name runs-on --logical-resource-id RunsOnWorkerService \
-        --query StackResourceDetail.PhysicalResourceId --output text)"
-    aws ecs update-service --region us-east-2 --force-new-deployment \
-        --cluster "$(cut -d / -f 2 <<< "${service}")" --service "${service}"
-
-The free open-source license is issued per GitHub organization; the key must 
be the one issued
-for ``apache``.
-
-Step 2 — plan and apply
------------------------
-
-Changes go through pull requests. The ``Terraform (RunsOn)`` workflow
-(``.github/workflows/terraform-runs-on.yml``) runs only when this module's 
``.tf`` files, its
-``.terraform.lock.hcl`` or the workflow file change; edits to this README or to
-``runs-on.yml.example`` do not trigger it. It plans such pull requests, plans 
again on ``main``
-after the merge, and applies that plan once a required reviewer approves the 
``runs-on-prod``
-environment. The plan artifact is kept 7 days; if approval comes later, use 
"Re-run all jobs" on
-the run, which plans again. Do that only while the run is still the latest one 
on ``main``:
-re-running replays the original commit, so once a newer run has applied, 
re-running an older one
-plans a revert. The workflow authenticates with GitHub OIDC; the AWS roles it 
assumes, and the
-CloudFormation service role the stack runs as, are in
-`terraform/runs-on-access <../runs-on-access/README.rst>`_, which is applied 
by hand first.
-
-The state is in the S3 backend of ``state.tf``, locked with ``use_lockfile``. 
An account admin can
-still run the same steps locally::
-
-    cd terraform/runs-on
-    terraform init
-    terraform plan -out=runs-on.tfplan
-    terraform apply runs-on.tfplan          # ~10 minutes on creation
-    terraform output
-
-The ``apache`` stack already exists: it was created on 2026-09-25 with local 
state, which was then
-moved to S3 with ``terraform init -migrate-state``. A plan against it must 
therefore never propose
-to create the stack; if it does, Terraform is not reading the S3 state, and an 
apply would fail
-with ``AlreadyExistsException``. On a fresh account the plan shows exactly one 
resource to create
-(``aws_cloudformation_stack.runs_on``) and reads two data sources (the SSM 
parameter and the
-caller identity).
-
-Step 3 — register the GitHub App and the presets
-------------------------------------------------
-
-1. Apply with ``-var enable_admin_routes=true``, open the ``entry_point`` 
output → **Register
-   app**, then apply again without the variable to remove the ``/setup`` 
routes from the public
-   entry point. This creates a private GitHub App in the organization and 
installs it; for
-   ``apache`` this is done together with ASF Infra, who also grant the app 
access to
-   ``apache/airflow`` and to ``.github-private`` if the presets live there. 
Re-registering the
-   app needs the same temporary apply.
-2. Commit ``runs-on.yml.example`` as ``.github/runs-on.yml`` in 
``apache/.github-private``
-   (stack parameter ``RunnerConfigAutoExtendsFrom``, default 
``.github-private``). RunsOn reads
-   ``.github/runs-on.yml`` in a public repository **only from its default 
branch**, so presets
-   cannot be trialled from a feature branch; the validation therefore spelled 
the same values
-   out in the labels.
-3. Point jobs at the runners:
-
-   .. code-block:: yaml
-
-       runs-on: runs-on=${{ github.run_id }}/runner=airflow-amd-medium
-
-   ``runs-on=<run id>`` is mandatory (it routes the job and namespaces the 
runner). Labels can
-   also carry everything inline, which is what the validation did:
-   
``runs-on=<id>/family=m7a+m7i+m6a+m6i/image=ubuntu22-full-x64/volume=150gb/cpu=8/ram=32``.
-   ``runs-on/action`` (per-job cost line in the job summary) is already on the 
ASF GitHub Actions
-   allowlist.
-
-Upgrades and changes
---------------------
-
-Bump ``runs_on_version`` (or any parameter) and ``terraform plan`` / 
``apply``: the resource
-always resubmits the template URL, which is what RunsOn's `upgrade guide
-<https://runs-on.com/docs/maintenance/upgrades/>`_ requires (a reused 
processed template can
-silently skip parameter changes). Minor and patch releases have no downtime; 
major releases may.
-Rollback is the previous ``runs_on_version``. The apply role may deploy any 
release template from
-RunsOn's bucket (``template-vX.Y.Z.yaml``,
-`terraform/runs-on-access <../runs-on-access/README.rst>`_) and 
``runs_on_version`` must look like
-``v3.5.1``, so an upgrade or rollback needs no admin step.
-What still needs an admin is IAM: the CloudFormation service role may only 
manage the IAM roles
-named in ``stack_role_prefixes`` and attach the two managed policies listed in
-``terraform/runs-on-access``. Before a bump compare the new template's 
``AWS::IAM::Role``,
-``AWS::IAM::InstanceProfile`` and ``ManagedPolicyArns`` with those lists; 
anything new fails the
-stack update until an admin adds it there and applies that module.
-Template SHA-256s: ``v3.3.2`` 
``3017805d77c1d717fdd229a1e1242db12c985944699d3d8dca01f6f28b35c16d``,
-``v3.5.1`` 
``9acd6fd154e2bb921f95466c45380d2e3d7bd718553a12af4e5c76d15915bd57``.
-
-Every plan shows ``"LicenseKey" = "****" -> 
"arn:...:parameter/runs-on/license-key"``:
-CloudFormation masks ``NoEcho`` parameters when Terraform reads the stack, and 
the AWS provider
-stores the mask. The value sent on apply is the same ARN, so that line alone 
changes nothing. Do
-not add it to ``ignore_changes``: the provider would then send the literal 
``****`` as the license
-key.
-
-``prevent_destroy`` is set on the stack: a ``terraform destroy`` fails until 
the lifecycle block is
-removed on purpose. Deleting the stack also needs its cache bucket emptied 
first if CloudFormation
-stops on it.
-
-What the stack contains
------------------------
-
-VPC ``10.2.0.0/16`` with public and private subnets (``Private=false`` → 
runners get public IPs and
-no NAT, so each runner pulls from Docker Hub from its own address), an ECS 
Fargate service for the
-control plane, an API Gateway entry point (``/github/webhooks``, plus ``/``, 
``/setup`` and
-``/readyz`` only while ``enable_admin_routes`` is true), an S3 cache bucket 
(``cache_bucket``
-output; the runners' instance role can read and write the ``cache/`` prefix, 
lifecycle
-``S3CacheExpirationInDays``), the EC2 instance role and launch templates, 
DynamoDB tables for
-locks and job tracking, an SNS alert topic, an AWS Budget 
(``AppBudgetDailyUsd``,
-alert only — not a spending cap), the CloudWatch dashboard 
``RunsOnDashboard-*`` (queue health,
-scheduling trend, queue-duration P50/P90, Spot circuit breaker), log groups
-``/aws/ecs/<stack>/runs-on-worker`` and ``<stack>-EC2InstanceLogGroup-*``, and 
a daily cost report
-by email.
-
-Observability
--------------
-
-- CloudWatch dashboard and log groups above; ``roc`` CLI (`docs
-  <https://runs-on.com/docs/observability/cli/>`_): ``roc stack doctor``, 
``roc stack logs``,
-  ``roc logs <job url> --watch``, ``roc connect <job url>`` over SSM 
(``SSMAllowed=true``).
-- ``.github/workflows/runs-on-usage-report.yml`` checks every Monday how much 
of the last week's
-  EC2 vCPU-hours in us-east-2 ran on on-demand instances. Above 30% it keeps 
one issue open in
-  this repository and comments each week's numbers on it, and it closes the 
issue once the share
-  is back under. The numbers are account-wide (``AWS/Usage`` metrics), so any 
other EC2
-  instances in the region count too.
-- Optional OTLP export (``OtelExporterEndpoint`` / ``OtelExporterHeaders`` + 
``extras=otel`` on the
-  label) to SigNoz or any OTLP backend; RunsOn publishes a SigNoz dashboard. 
Not deployed by
-  the stack.
-
-Things learned in the validation
---------------------------------
-
-- Runner instances use IMDSv2 with a hop limit of 2, so Docker containers 
started by a job can
-  obtain the instance role's credentials. Tests that assume "no AWS 
credentials" must set
-  ``AWS_EC2_METADATA_DISABLED=true`` (fixed in ``apache/airflow#73707``). The 
same credentials
-  can read and write the whole ``cache/`` prefix, so any job on these runners 
can overwrite
-  objects that later runs download.
-- Untrusted code is meant to stay off these runners. Airflow's CI 
(``apache/airflow#74889``)
-  routes only pushes, schedules and dispatches on ``apache/airflow``, branches 
of
-  ``apache/airflow`` itself, and pull requests labelled ``use self-hosted 
runners`` that a
-  committer approved at their head commit to RunsOn; everything else runs on 
GitHub-hosted
-  runners. That routing is cooperative, not a boundary: a ``pull_request`` run 
uses the workflow
-  file of the pull request, so a fork can ask for a RunsOn label itself. The 
boundary is GitHub's
-  fork-workflow approval, which must be set to "Require approval for all 
external contributors"
-  for ``apache/airflow`` (ASF Infra).
-- Airflow's stash actions (image tars, mount caches, prek cache) store 
payloads in the stack's
-  cache bucket under ``cache/airflow-stash/``, so multi-gigabyte payloads 
never leave the region.
-  Payloads are content-addressed 
(``cache/airflow-stash/sha256/<digest>.tar``); the digest itself
-  is kept as a GitHub-scoped ASF stash, and a restore whose download does not 
match it is a cache
-  miss. Overwriting an object in the bucket therefore cannot change what a 
later run restores.
-- Job pickup (job queued → runner started) was 29 s at the median, 51 s at 
p90; a full
-  ``Tests (AMD)`` graph finished in 53 minutes.
-- Magic cache (``extras=s3-cache``) was not enabled: it only speeds up 
``actions/cache`` and
-  ``setup-*`` caches and requires ``runs-on/action`` before every 
``upload-artifact``.
-- EC2 quotas in us-east-2 (``All Standard Spot Instance Requests`` 3000 vCPU, 
on-demand 2000)
-  were enough for ~130 concurrent jobs.
-
-Spot capacity
--------------
-
-On 2026-10-25..27 the stack launched 4706 Spot and 4038 on-demand runners. The 
worker log
-(``force_on_demand_reason`` on each "Using on-demand instances" line) gives 
the cause, and none
-of it was the Spot quota (peak 2332 of 3000 vCPU); the on-demand quota filled 
up instead
-(2004 of 2000 on 2026-10-26 16:00 UTC, 1653 ``VcpuLimitExceeded``):
-
-- ``spot_disabled`` (2641): the default ``SpotCircuitBreaker`` ``2/15/30`` 
kept Spot off; none
-  since the ``30/15/10`` apply on 2026-10-26 20:29 UTC.
-- ``force_on_demand`` (678): x64 Spot capacity misses. Up to v3.4 one miss 
sends every job to
-  on-demand for five minutes; from v3.5.0 only the affected runners go.
-- ``spot_recovery_rerun`` (719): RunsOn's rerun of an interrupted job, 
on-demand by design.
-
-Of 2156 Spot capacity errors in a day, 1959 were x64 (``m7a+m7i+m6a+m6i``) and 
197 arm64.
-More instance families give the Spot allocator more pools, so the x64 presets 
in
-``runs-on.yml.example`` also allow ``m8a``, ``m8i``, ``m5a`` and ``m5`` (Spot 
``2xlarge`` on
-2026-10-27: 0.08-0.16 USD/hour against 0.12-0.15 for the existing families). 
Since
-``apache/airflow#75822`` the jobs (which spell their labels out instead of 
using these presets)
-also take Spot from capacity-optimized pools (``spot=co``) and accept up to 
twice their size
-(``cpu=8+16/ram=32+64``). Capacity-optimized ignores price, so a job can land 
on the larger size
-at up to twice the hourly price; the weekly report and the daily cost report 
show whether the net
-cost goes down.
-
-The CloudFormation template places runners in two Availability Zones 
(us-east-2a and 2b,
-fixed by the template). RunsOn's Spot spread across zones 
(``RUNS_ON_SPOT_FORCE_ZONE_SPREAD``,
-v3.5.0 beta) and custom subnets exist only in its Terraform module, so a third 
zone means
-moving off the CloudFormation stack.
diff --git a/terraform/runs-on/main.tf b/terraform/runs-on/main.tf
deleted file mode 100644
index f76f3af..0000000
--- a/terraform/runs-on/main.tf
+++ /dev/null
@@ -1,32 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-# The license key is never in Terraform state: RunsOn accepts LicenseKey as 
the ARN of an SSM
-# SecureString parameter that MUST be named /<stack name>/license-key, and 
only the stack's
-# service role decrypts it. The parameter is created by hand (see README.rst); 
this lookup
-# without decryption only proves it exists before the stack is planned, 
storing the ciphertext.
-data "aws_ssm_parameter" "license_key" {
-  name            = "/${var.stack_name}/license-key"
-  with_decryption = false
-}
-
-data "aws_caller_identity" "current" {}
-
-# The stack was removed from this configuration on purpose: every job of 
apache/airflow runs on
-# terraform/runs-on-flex since 2026-11-01, and applying this change is what 
deletes the stack
-# (the Terraform (RunsOn) workflow, after the runs-on-prod approval). The 
template retains the
-# cache and access-log buckets (DeletionPolicy RetainExceptOnCreate); delete 
them by hand.
diff --git a/terraform/runs-on/provider.tf b/terraform/runs-on/provider.tf
deleted file mode 100644
index 452a66a..0000000
--- a/terraform/runs-on/provider.tf
+++ /dev/null
@@ -1,31 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-# Credentials come from the environment (AWS_PROFILE locally, OIDC role in the
-# future GitHub Actions workflow) — do not hardcode a profile here.
-provider "aws" {
-  # Also hard-coded in the backend (state.tf) and the README commands; change 
all together.
-  region = "us-east-2"
-
-  default_tags {
-    tags = {
-      Project   = "airflow-ci"
-      ManagedBy = "terraform"
-      Repo      = "apache/airflow-ci-infra"
-    }
-  }
-}
diff --git a/terraform/runs-on/runs-on.yml.example 
b/terraform/runs-on/runs-on.yml.example
deleted file mode 100644
index ee45cbd..0000000
--- a/terraform/runs-on/runs-on.yml.example
+++ /dev/null
@@ -1,91 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
----
-# RunsOn runner presets for the Airflow CI size classes, as 
`.github/runs-on.yml`.
-#
-# RunsOn reads this file only from a public repository's default branch, or 
from the
-# organisation's `.github-private` repository (stack parameter 
RunnerConfigAutoExtendsFrom).
-# The 2026-09-25 validation could not use presets for that reason and spelled 
the same values
-# out in job labels instead; with presets in place a job label becomes
-# `runs-on=${{ github.run_id }}/runner=airflow-amd-medium`.
-#
-# Classes are the EC2 equivalents of the measured EKS classes (AIP-118 
appendix):
-# small 4 vCPU / 16 GB, medium 8 / 32, xl 32 / 128. The xl class gets 128 GB 
because xdist
-# parallelism follows the host's vCPUs. Each class also accepts up to twice 
its size, and Spot comes
-# from capacity-optimized pools (`spot: co`), as in the job labels of 
apache/airflow#75822; RunsOn
-# reruns an interrupted job on on-demand. `volume` replaces the GitHub-hosted 
runners' root + /mnt
-# disks.
-runners:
-  airflow-amd-small:
-    cpu: [4, 8]
-    ram: [16, 32]
-    family: ["m8a", "m8i", "m7a", "m7i", "m6a", "m6i", "m5a", "m5"]
-    image: ubuntu22-full-x64
-    volume: 150gb
-    spot: co
-  airflow-amd-medium:
-    cpu: [8, 16]
-    ram: [32, 64]
-    family: ["m8a", "m8i", "m7a", "m7i", "m6a", "m6i", "m5a", "m5"]
-    image: ubuntu22-full-x64
-    volume: 150gb
-    spot: co
-  airflow-amd-xl:
-    cpu: [32, 64]
-    ram: [128, 256]
-    family: ["m8a", "m8i", "m7a", "m7i", "m6a", "m6i", "m5a", "m5"]
-    image: ubuntu22-full-x64
-    volume: 150gb
-    spot: co
-  airflow-arm-small:
-    cpu: [4, 8]
-    ram: [16, 32]
-    family: ["m8g", "m7g", "m6g"]
-    image: ubuntu22-full-arm64
-    volume: 150gb
-    spot: co
-  airflow-arm-medium:
-    cpu: [8, 16]
-    ram: [32, 64]
-    family: ["m8g", "m7g", "m6g"]
-    image: ubuntu22-full-arm64
-    volume: 150gb
-    spot: co
-  airflow-arm-xl:
-    cpu: [32, 64]
-    ram: [128, 256]
-    family: ["m8g", "m7g", "m6g"]
-    image: ubuntu22-full-arm64
-    volume: 150gb
-    spot: co
-  airflow-amd-in-memory:
-    cpu: [8, 16]
-    ram: [32, 128]
-    family: ["m8a", "m8i", "m7a", "m7i", "m6a", "m6i", "m5a", "m5",
-             "r8i", "r7a", "r7i", "r6a", "r6i"]
-    image: ubuntu22-full-x64
-    volume: 150gb
-    spot: co
-    extras: ["tmpfs"]
-  airflow-arm-in-memory:
-    cpu: [8, 16]
-    ram: [32, 128]
-    family: ["m8g", "m7g", "m6g", "r8g", "r7g", "r6g"]
-    image: ubuntu22-full-arm64
-    volume: 150gb
-    spot: co
-    extras: ["tmpfs"]
diff --git a/terraform/runs-on/state.tf b/terraform/runs-on/state.tf
deleted file mode 100644
index 36b2b44..0000000
--- a/terraform/runs-on/state.tf
+++ /dev/null
@@ -1,28 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-terraform {
-  backend "s3" {
-    region = "us-east-2"
-    bucket = "airflow-ci-tfstate"
-    # Own key: the EKS deployment (tf-state/eks-airflow-ci.tfstate) and the 
2024
-    # state (tf-state/eks.tfstate) must not be mixed with this one.
-    key          = "tf-state/runs-on.tfstate"
-    encrypt      = true
-    use_lockfile = true
-  }
-}
diff --git a/terraform/runs-on/variables.tf b/terraform/runs-on/variables.tf
deleted file mode 100644
index 7998cb1..0000000
--- a/terraform/runs-on/variables.tf
+++ /dev/null
@@ -1,65 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-variable "stack_name" {
-  description = <<-EOT
-    CloudFormation stack name. RunsOn derives the license-key SSM parameter 
name from it
-    (/<stack_name>/license-key), so changing it means re-creating the 
parameter.
-  EOT
-  type        = string
-  default     = "runs-on"
-}
-
-variable "runs_on_version" {
-  description = "RunsOn release to deploy; selects the version-pinned 
CloudFormation template."
-  type        = string
-  default     = "v3.5.1"
-
-  # The apply role may deploy only release templates of this shape from 
RunsOn's bucket
-  # (runs_on_template_urls in terraform/runs-on-access).
-  validation {
-    condition     = can(regex("^v[0-9]\\.[0-9]{1,2}\\.[0-9]{1,2}$", 
var.runs_on_version))
-    error_message = "runs_on_version must be a RunsOn release tag such as 
v3.5.1."
-  }
-}
-
-variable "github_organization" {
-  description = "GitHub organization the runners serve (case-sensitive)."
-  type        = string
-  default     = "apache"
-}
-
-variable "alert_email" {
-  description = "Recipient of RunsOn cost reports and alerts; AWS sends an SNS 
confirmation email on creation."
-  type        = string
-  default     = "[email protected]"
-}
-
-variable "enable_admin_routes" {
-  description = <<-EOT
-    Expose the /setup admin routes on the public entry point. Set to true only 
while registering
-    (or re-registering) the GitHub App, then back to false.
-  EOT
-  type        = bool
-  default     = false
-}
-
-variable "vpc_cidr" {
-  description = "CIDR of the VPC the stack creates; must not overlap existing 
VPCs in the account (10.1.0.0/16 is taken)."
-  type        = string
-  default     = "10.2.0.0/16"
-}
diff --git a/terraform/runs-on/version.tf b/terraform/runs-on/version.tf
deleted file mode 100644
index f7cee6f..0000000
--- a/terraform/runs-on/version.tf
+++ /dev/null
@@ -1,27 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements.  See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership.  The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License.  You may obtain a copy of the License at
-#
-#   http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied.  See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-terraform {
-  required_providers {
-    aws = {
-      source  = "hashicorp/aws"
-      version = "~> 6.0"
-    }
-  }
-  # S3-native state locking (use_lockfile) is GA from 1.11
-  required_version = ">= 1.11.0"
-}

Reply via email to