From d44f7a8093afe509c7619bf1e50c55278777c023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:17:47 +0200 Subject: [PATCH 01/12] docs(runners): publish provider-neutral profile catalog --- .github/workflows/runner-catalog.yml | 45 +++++++ AGENTS.md | 8 ++ README.md | 6 + RUNNERS.md | 84 +++++++++++++ runner-profiles.json | 171 +++++++++++++++++++++++++++ runner-profiles.yaml | 136 +++++++++++++++++++++ 6 files changed, 450 insertions(+) create mode 100644 .github/workflows/runner-catalog.yml create mode 100644 RUNNERS.md create mode 100644 runner-profiles.json create mode 100644 runner-profiles.yaml diff --git a/.github/workflows/runner-catalog.yml b/.github/workflows/runner-catalog.yml new file mode 100644 index 0000000..737a772 --- /dev/null +++ b/.github/workflows/runner-catalog.yml @@ -0,0 +1,45 @@ +name: Runner catalog publication + +on: + pull_request: + paths: + - RUNNERS.md + - runner-profiles.json + - runner-profiles.yaml + - .github/workflows/runner-catalog.yml + push: + branches: [main] + paths: + - RUNNERS.md + - runner-profiles.json + - runner-profiles.yaml + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Validate generated formats and provenance + shell: ruby {0} + run: | + require "json" + require "yaml" + + json = JSON.parse(File.read("runner-profiles.json")) + yaml = YAML.safe_load(File.read("runner-profiles.yaml"), aliases: false) + abort "JSON and YAML catalogs differ" unless json == yaml + + provenance = json.dig("metadata", "provenance") || {} + abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) + abort "missing source SHA-256" unless provenance.fetch("sha256", "").match?(/\A[0-9a-f]{64}\z/) + abort "unsafe capacity contract" unless json.dig("capacity", "maxConcurrentJobs").to_i.between?(1, 4) + + labels = json.fetch("profiles").map { |profile| profile.fetch("label") } + expected = %w[akua-x64-ci-v2 akua-docker-ci-v2 akua-heavy-ci-v2] + abort "stable labels missing" unless (expected - labels).empty? + + runners = File.read("RUNNERS.md") + abort "README provenance drift" unless runners.include?(provenance.fetch("revision")) && runners.include?(provenance.fetch("sha256")) diff --git a/AGENTS.md b/AGENTS.md index 72b8003..0fa0bf0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,14 @@ This repository maintains the public GitHub organization profile for `akua-dev`. The rendered profile content lives in `profile/README.md`. +## Runner profile contract + +Use `RUNNERS.md` to select stable GitHub Actions labels. Agents should express +required capabilities and resources, never ARC, Firecracker, Kata, Kubernetes, +cloud-provider or node implementation details. `runner-profiles.yaml` and +`runner-profiles.json` are generated from `akua-dev/gitops`; do not edit them or +`RUNNERS.md` directly. + ## Profile README guidance - Keep `profile/README.md` closely aligned with a shorter version of the diff --git a/README.md b/README.md index b8134a6..9204d14 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,9 @@ This repository maintains the public GitHub organization profile for `akua-dev`. The rendered organization profile lives in [profile/README.md](profile/README.md). + +The organization-wide, provider-neutral GitHub Actions runner contract is +published in [RUNNERS.md](RUNNERS.md), with machine-readable +[YAML](runner-profiles.yaml) and [JSON](runner-profiles.json) catalogs. Their +canonical source and generator live in the private `akua-dev/gitops` +repository; provenance is embedded in every generated artifact. diff --git a/RUNNERS.md b/RUNNERS.md new file mode 100644 index 0000000..d0b66cf --- /dev/null +++ b/RUNNERS.md @@ -0,0 +1,84 @@ +# Akua GitHub Actions runner profiles + +This is the provider-neutral contract for Akua's stable self-hosted Linux +runner labels. Workflows select capabilities; the implementation behind a +label may change without requiring repository edits. + +| Label | Profile | Minimum CPU | Minimum memory | Minimum usable disk | Guaranteed capabilities | +| --- | --- | ---: | ---: | ---: | --- | +| `akua-x64-ci-v2` | Linux x64 standard | 2 vCPU | 4096 MiB | 10240 MiB | ordinary Linux tooling | +| `akua-docker-ci-v2` | Linux x64 Docker | 4 vCPU | 6144 MiB | 15360 MiB | Docker, Buildx, service containers | +| `akua-heavy-ci-v2` | Linux x64 heavy | 4 vCPU | 7168 MiB | 20480 MiB | Docker, Buildx, service containers | + +Capacity is shared across these labels and currently capped at **4 concurrent jobs**. This is a safety limit, not a queue-start SLO or a reservation per label. + +## Selection rule + +1. Use `akua-x64-ci-v2` for ordinary Linux x64 work without containers. +2. Use `akua-docker-ci-v2` for Docker, Buildx or service containers. +3. Use `akua-heavy-ci-v2` when declared CPU, memory or disk requirements exceed the Docker profile, or for known memory-heavy build/test suites. + +If a job relies on a resource size, declare it in job-level environment +variables so policy can verify the profile: + +```yaml +env: + AKUA_CI_REQUIRED_VCPU: "4" + AKUA_CI_REQUIRED_MEMORY_MIB: "7000" + AKUA_CI_REQUIRED_DISK_MIB: "18000" +``` + +GitHub-hosted ARM64, Windows and macOS labels remain valid when this catalog +does not provide the required architecture. Never select labels containing +backend/runtime/provider names. + +## `akua-x64-ci-v2` + +Use for: + +- linting, formatting, unit tests and ordinary compilation +- jobs that do not start containers or require large local caches + +Do not use for: + +- Docker, Buildx and GitHub Actions service containers +- nested virtualization, KVM and architecture-specific non-x64 builds + +## `akua-docker-ci-v2` + +Use for: + +- Docker and Buildx image builds +- integration tests using Docker or GitHub Actions service containers + +Do not use for: + +- nested virtualization, KVM and architecture-specific non-x64 builds +- workloads declaring resources above this profile; use the heavy profile + +## `akua-heavy-ci-v2` + +Use for: + +- memory-heavy compilation, packaging and browser or integration suites +- Docker jobs whose declared requirements exceed the Docker profile + +Do not use for: + +- nested virtualization, KVM and architecture-specific non-x64 builds +- workloads requiring more than the stated minimum resource contract + +## Versioning and deprecation + +The catalog contract is `2.0.0` and +the runner image/toolchain contract is `ubuntu-24.04-v1`. +Additive guarantees may increment the catalog minor version. Reduced +guarantees or removed capabilities require a new workflow label. Deprecated +profiles publish announcement, sunset and replacement metadata before +retirement. + +Machine-readable forms: [`runner-profiles.yaml`](runner-profiles.yaml) and +[`runner-profiles.json`](runner-profiles.json). + +Provenance: `akua-dev/gitops@ca39219715c3b6723452d8be79689e73470b5810` path +`clusters/agentos/runner-platform/profiles.yaml`, source SHA-256 `5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598`. diff --git a/runner-profiles.json b/runner-profiles.json new file mode 100644 index 0000000..90aefe9 --- /dev/null +++ b/runner-profiles.json @@ -0,0 +1,171 @@ +{ + "apiVersion": "runners.akua.dev/v1alpha1", + "kind": "RunnerProfileCatalog", + "metadata": { + "name": "akua-linux-x64", + "contractVersion": "2.0.0", + "imageContractVersion": "ubuntu-24.04-v1", + "documentation": "https://github.com/akua-dev/.github/blob/main/RUNNERS.md", + "provenance": { + "repository": "akua-dev/gitops", + "path": "clusters/agentos/runner-platform/profiles.yaml", + "revision": "ca39219715c3b6723452d8be79689e73470b5810", + "sha256": "5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598" + } + }, + "capacity": { + "scope": "organization", + "allocation": "shared", + "maxConcurrentJobs": 4, + "queueSlo": null, + "notes": [ + "Capacity is shared by all three profiles; a profile label does not reserve a private slot.", + "Four concurrent jobs are the current safe contract. Six was only a short load experiment." + ] + }, + "policy": { + "requirementEnvironment": { + "cpu": "AKUA_CI_REQUIRED_VCPU", + "memoryMiB": "AKUA_CI_REQUIRED_MEMORY_MIB", + "diskMiB": "AKUA_CI_REQUIRED_DISK_MIB" + }, + "runtimeSpecificLabelPatterns": [ + "arc", + "fireactions", + "firecracker", + "hetzner", + "kata", + "kubernetes" + ], + "allowedExternalLabelPatterns": [ + "^ubuntu-[A-Za-z0-9._-]+$", + "^windows-[A-Za-z0-9._-]+$", + "^macos-[A-Za-z0-9._-]+$" + ] + }, + "profiles": [ + { + "id": "linux-x64-standard-v2", + "label": "akua-x64-ci-v2", + "class": "linux-x64", + "displayName": "Linux x64 standard", + "status": "active", + "platform": { + "os": "linux", + "distribution": "ubuntu", + "distributionVersion": "24.04", + "architecture": "x86_64" + }, + "minimumResources": { + "vcpu": 2, + "memoryMiB": 4096, + "usableDiskMiB": 10240 + }, + "capabilities": { + "docker": false, + "buildx": false, + "serviceContainers": false, + "privilegedContainers": false, + "nestedVirtualization": false + }, + "workload": { + "recommended": [ + "linting, formatting, unit tests and ordinary compilation", + "jobs that do not start containers or require large local caches" + ], + "exclusions": [ + "Docker, Buildx and GitHub Actions service containers", + "nested virtualization, KVM and architecture-specific non-x64 builds" + ] + }, + "deprecation": { + "deprecated": false, + "announcedAt": null, + "sunsetAt": null, + "replacementLabel": null + } + }, + { + "id": "linux-x64-docker-v2", + "label": "akua-docker-ci-v2", + "class": "docker-x64", + "displayName": "Linux x64 Docker", + "status": "active", + "platform": { + "os": "linux", + "distribution": "ubuntu", + "distributionVersion": "24.04", + "architecture": "x86_64" + }, + "minimumResources": { + "vcpu": 4, + "memoryMiB": 6144, + "usableDiskMiB": 15360 + }, + "capabilities": { + "docker": true, + "buildx": true, + "serviceContainers": true, + "privilegedContainers": true, + "nestedVirtualization": false + }, + "workload": { + "recommended": [ + "Docker and Buildx image builds", + "integration tests using Docker or GitHub Actions service containers" + ], + "exclusions": [ + "nested virtualization, KVM and architecture-specific non-x64 builds", + "workloads declaring resources above this profile; use the heavy profile" + ] + }, + "deprecation": { + "deprecated": false, + "announcedAt": null, + "sunsetAt": null, + "replacementLabel": null + } + }, + { + "id": "linux-x64-heavy-v2", + "label": "akua-heavy-ci-v2", + "class": "heavy-x64", + "displayName": "Linux x64 heavy", + "status": "active", + "platform": { + "os": "linux", + "distribution": "ubuntu", + "distributionVersion": "24.04", + "architecture": "x86_64" + }, + "minimumResources": { + "vcpu": 4, + "memoryMiB": 7168, + "usableDiskMiB": 20480 + }, + "capabilities": { + "docker": true, + "buildx": true, + "serviceContainers": true, + "privilegedContainers": true, + "nestedVirtualization": false + }, + "workload": { + "recommended": [ + "memory-heavy compilation, packaging and browser or integration suites", + "Docker jobs whose declared requirements exceed the Docker profile" + ], + "exclusions": [ + "nested virtualization, KVM and architecture-specific non-x64 builds", + "workloads requiring more than the stated minimum resource contract" + ] + }, + "deprecation": { + "deprecated": false, + "announcedAt": null, + "sunsetAt": null, + "replacementLabel": null + } + } + ] +} diff --git a/runner-profiles.yaml b/runner-profiles.yaml new file mode 100644 index 0000000..a094683 --- /dev/null +++ b/runner-profiles.yaml @@ -0,0 +1,136 @@ +--- +apiVersion: runners.akua.dev/v1alpha1 +kind: RunnerProfileCatalog +metadata: + name: akua-linux-x64 + contractVersion: 2.0.0 + imageContractVersion: ubuntu-24.04-v1 + documentation: https://github.com/akua-dev/.github/blob/main/RUNNERS.md + provenance: + repository: akua-dev/gitops + path: clusters/agentos/runner-platform/profiles.yaml + revision: ca39219715c3b6723452d8be79689e73470b5810 + sha256: 5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598 +capacity: + scope: organization + allocation: shared + maxConcurrentJobs: 4 + queueSlo: + notes: + - Capacity is shared by all three profiles; a profile label does not reserve a private + slot. + - Four concurrent jobs are the current safe contract. Six was only a short load + experiment. +policy: + requirementEnvironment: + cpu: AKUA_CI_REQUIRED_VCPU + memoryMiB: AKUA_CI_REQUIRED_MEMORY_MIB + diskMiB: AKUA_CI_REQUIRED_DISK_MIB + runtimeSpecificLabelPatterns: + - arc + - fireactions + - firecracker + - hetzner + - kata + - kubernetes + allowedExternalLabelPatterns: + - "^ubuntu-[A-Za-z0-9._-]+$" + - "^windows-[A-Za-z0-9._-]+$" + - "^macos-[A-Za-z0-9._-]+$" +profiles: +- id: linux-x64-standard-v2 + label: akua-x64-ci-v2 + class: linux-x64 + displayName: Linux x64 standard + status: active + platform: + os: linux + distribution: ubuntu + distributionVersion: '24.04' + architecture: x86_64 + minimumResources: + vcpu: 2 + memoryMiB: 4096 + usableDiskMiB: 10240 + capabilities: + docker: false + buildx: false + serviceContainers: false + privilegedContainers: false + nestedVirtualization: false + workload: + recommended: + - linting, formatting, unit tests and ordinary compilation + - jobs that do not start containers or require large local caches + exclusions: + - Docker, Buildx and GitHub Actions service containers + - nested virtualization, KVM and architecture-specific non-x64 builds + deprecation: + deprecated: false + announcedAt: + sunsetAt: + replacementLabel: +- id: linux-x64-docker-v2 + label: akua-docker-ci-v2 + class: docker-x64 + displayName: Linux x64 Docker + status: active + platform: + os: linux + distribution: ubuntu + distributionVersion: '24.04' + architecture: x86_64 + minimumResources: + vcpu: 4 + memoryMiB: 6144 + usableDiskMiB: 15360 + capabilities: + docker: true + buildx: true + serviceContainers: true + privilegedContainers: true + nestedVirtualization: false + workload: + recommended: + - Docker and Buildx image builds + - integration tests using Docker or GitHub Actions service containers + exclusions: + - nested virtualization, KVM and architecture-specific non-x64 builds + - workloads declaring resources above this profile; use the heavy profile + deprecation: + deprecated: false + announcedAt: + sunsetAt: + replacementLabel: +- id: linux-x64-heavy-v2 + label: akua-heavy-ci-v2 + class: heavy-x64 + displayName: Linux x64 heavy + status: active + platform: + os: linux + distribution: ubuntu + distributionVersion: '24.04' + architecture: x86_64 + minimumResources: + vcpu: 4 + memoryMiB: 7168 + usableDiskMiB: 20480 + capabilities: + docker: true + buildx: true + serviceContainers: true + privilegedContainers: true + nestedVirtualization: false + workload: + recommended: + - memory-heavy compilation, packaging and browser or integration suites + - Docker jobs whose declared requirements exceed the Docker profile + exclusions: + - nested virtualization, KVM and architecture-specific non-x64 builds + - workloads requiring more than the stated minimum resource contract + deprecation: + deprecated: false + announcedAt: + sunsetAt: + replacementLabel: From 0a56585050936b79b734958cb6ab4e7e3e271cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:30:27 +0200 Subject: [PATCH 02/12] no-mistakes(review): Hardened provider-neutral runner catalog validation and selection --- .github/workflows/runner-catalog.yml | 112 +++++++++++++++++++++++-- RUNNERS.md | 90 +++++++++++++-------- runner-profiles.json | 108 ++++++++++--------------- runner-profiles.yaml | 117 +++++++++++---------------- 4 files changed, 251 insertions(+), 176 deletions(-) diff --git a/.github/workflows/runner-catalog.yml b/.github/workflows/runner-catalog.yml index 737a772..861f885 100644 --- a/.github/workflows/runner-catalog.yml +++ b/.github/workflows/runner-catalog.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - - name: Validate generated formats and provenance + - name: Validate generated formats and catalog semantics shell: ruby {0} run: | require "json" @@ -32,14 +32,110 @@ jobs: yaml = YAML.safe_load(File.read("runner-profiles.yaml"), aliases: false) abort "JSON and YAML catalogs differ" unless json == yaml - provenance = json.dig("metadata", "provenance") || {} + metadata = json.fetch("metadata") + abort "provider-neutral metadata drift" unless metadata.keys.sort == %w[contractVersion documentation name provenance] + + provenance = metadata.fetch("provenance") + expected_provenance = { + "repository" => "akua-dev/gitops", + "path" => "clusters/agentos/runner-platform/profiles.yaml" + } + abort "non-canonical provenance" unless provenance.slice(*expected_provenance.keys) == expected_provenance abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) abort "missing source SHA-256" unless provenance.fetch("sha256", "").match?(/\A[0-9a-f]{64}\z/) - abort "unsafe capacity contract" unless json.dig("capacity", "maxConcurrentJobs").to_i.between?(1, 4) - labels = json.fetch("profiles").map { |profile| profile.fetch("label") } - expected = %w[akua-x64-ci-v2 akua-docker-ci-v2 akua-heavy-ci-v2] - abort "stable labels missing" unless (expected - labels).empty? + capacity = json.fetch("capacity") + abort "unsafe capacity contract" unless capacity.fetch("maxConcurrentJobs") == 4 + + expected_selection = { + "safeMatch" => { + "resources" => "required-at-most-guaranteed-minimum", + "capabilities" => "required-subset-of-guaranteed" + }, + "order" => %w[akua-x64-ci-v2 akua-docker-ci-v2 akua-heavy-ci-v2], + "noMatch" => "external-runner-or-reduce-requirements" + } + policy = json.fetch("policy") + abort "selection policy drift" unless policy.keys.sort == %w[requirementEnvironment selection] + abort "selection policy drift" unless policy.fetch("selection") == expected_selection + + expected_profiles = { + "akua-x64-ci-v2" => { + "id" => "standard-v2", + "class" => "standard", + "displayName" => "Standard", + "minimumResources" => { "vcpu" => 2, "memoryMiB" => 4096, "usableDiskMiB" => 10240 }, + "guaranteed" => ["ordinary build and test tooling"] + }, + "akua-docker-ci-v2" => { + "id" => "docker-v2", + "class" => "docker", + "displayName" => "Docker", + "minimumResources" => { "vcpu" => 4, "memoryMiB" => 6144, "usableDiskMiB" => 15360 }, + "guaranteed" => ["Docker", "Buildx", "service containers", "privileged containers"] + }, + "akua-heavy-ci-v2" => { + "id" => "heavy-v2", + "class" => "heavy", + "displayName" => "Heavy", + "minimumResources" => { "vcpu" => 4, "memoryMiB" => 7168, "usableDiskMiB" => 20480 }, + "guaranteed" => ["Docker", "Buildx", "service containers", "privileged containers"] + } + } + + profiles = json.fetch("profiles") + by_label = profiles.to_h { |profile| [profile.fetch("label"), profile] } + abort "stable labels drift" unless by_label.keys == expected_selection["order"] + expected_profiles.each do |label, expected| + profile = by_label.fetch(label) + abort "provider-specific profile fields" unless profile.keys.sort == %w[capabilities class deprecation displayName id label minimumResources status workload] + abort "profile identity drift" unless profile.slice("id", "class", "displayName") == expected.slice("id", "class", "displayName") + abort "resource guarantee drift" unless profile.fetch("minimumResources") == expected.fetch("minimumResources") + abort "capability guarantee drift" unless profile.dig("capabilities", "guaranteed") == expected.fetch("guaranteed") + abort "profile status drift" unless profile.fetch("status") == "active" + abort "profile deprecation drift" unless profile.fetch("deprecation") == { + "deprecated" => false, + "announcedAt" => nil, + "sunsetAt" => nil, + "replacementLabel" => nil + } + end + + markdown = File.read("RUNNERS.md") + contract_match = markdown.match(//m) + abort "missing structured catalog contract" unless contract_match + markdown_contract = YAML.safe_load(contract_match.fetch(1), aliases: false) + abort "README contract version drift" unless markdown_contract.fetch("contractVersion") == metadata.fetch("contractVersion") + abort "README selection drift" unless markdown_contract.fetch("selection") == policy.fetch("selection") + abort "README provenance drift" unless markdown_contract.fetch("provenance") == provenance - runners = File.read("RUNNERS.md") - abort "README provenance drift" unless runners.include?(provenance.fetch("revision")) && runners.include?(provenance.fetch("sha256")) + header_index = markdown.lines.index { |line| line.start_with?("| Label | Profile |") } + abort "missing profile table" unless header_index + table_lines = markdown.lines[(header_index + 2)..].take_while { |line| line.start_with?("| `akua-") } + table = table_lines.map do |line| + cells = line.strip.split("|", -1)[1...-1].map(&:strip) + abort "malformed profile table" unless cells.length == 8 + cpu = cells.fetch(2).match?(/\A\d+ vCPU\z/) && cells.fetch(2).to_i + memory = cells.fetch(3).match?(/\A\d+ MiB\z/) && cells.fetch(3).to_i + disk = cells.fetch(4).match?(/\A\d+ MiB\z/) && cells.fetch(4).to_i + abort "malformed profile resources" unless cpu && memory && disk + { + "label" => cells.fetch(0).delete("`"), + "displayName" => cells.fetch(1), + "minimumResources" => { "vcpu" => cpu, "memoryMiB" => memory, "usableDiskMiB" => disk }, + "guaranteed" => cells.fetch(5).split(/,\s*/), + "status" => cells.fetch(6), + "deprecation" => cells.fetch(7) + } + end + expected_table = profiles.map do |profile| + { + "label" => profile.fetch("label"), + "displayName" => profile.fetch("displayName"), + "minimumResources" => profile.fetch("minimumResources"), + "guaranteed" => profile.dig("capabilities", "guaranteed"), + "status" => profile.fetch("status"), + "deprecation" => profile.dig("deprecation", "deprecated") ? "deprecated" : "not deprecated" + } + end + abort "README profile semantics drift" unless table == expected_table diff --git a/RUNNERS.md b/RUNNERS.md index d0b66cf..4e391da 100644 --- a/RUNNERS.md +++ b/RUNNERS.md @@ -1,22 +1,36 @@ # Akua GitHub Actions runner profiles -This is the provider-neutral contract for Akua's stable self-hosted Linux -runner labels. Workflows select capabilities; the implementation behind a -label may change without requiring repository edits. +This is the provider-neutral contract for Akua's stable self-hosted runner +labels. Workflows select guaranteed capabilities and resources; the +implementation behind a label may change without requiring repository edits. -| Label | Profile | Minimum CPU | Minimum memory | Minimum usable disk | Guaranteed capabilities | -| --- | --- | ---: | ---: | ---: | --- | -| `akua-x64-ci-v2` | Linux x64 standard | 2 vCPU | 4096 MiB | 10240 MiB | ordinary Linux tooling | -| `akua-docker-ci-v2` | Linux x64 Docker | 4 vCPU | 6144 MiB | 15360 MiB | Docker, Buildx, service containers | -| `akua-heavy-ci-v2` | Linux x64 heavy | 4 vCPU | 7168 MiB | 20480 MiB | Docker, Buildx, service containers | +| Label | Profile | Minimum CPU | Minimum memory | Minimum usable disk | Guaranteed capabilities | Status | Deprecation | +| --- | --- | ---: | ---: | ---: | --- | --- | --- | +| `akua-x64-ci-v2` | Standard | 2 vCPU | 4096 MiB | 10240 MiB | ordinary build and test tooling | active | not deprecated | +| `akua-docker-ci-v2` | Docker | 4 vCPU | 6144 MiB | 15360 MiB | Docker, Buildx, service containers, privileged containers | active | not deprecated | +| `akua-heavy-ci-v2` | Heavy | 4 vCPU | 7168 MiB | 20480 MiB | Docker, Buildx, service containers, privileged containers | active | not deprecated | -Capacity is shared across these labels and currently capped at **4 concurrent jobs**. This is a safety limit, not a queue-start SLO or a reservation per label. +Capacity is shared across these labels and currently capped at **4 concurrent +jobs**. This is a safety limit, not a queue-start SLO or a reservation per +label. ## Selection rule -1. Use `akua-x64-ci-v2` for ordinary Linux x64 work without containers. -2. Use `akua-docker-ci-v2` for Docker, Buildx or service containers. -3. Use `akua-heavy-ci-v2` when declared CPU, memory or disk requirements exceed the Docker profile, or for known memory-heavy build/test suites. +A profile is a safe match only when every declared resource is no greater than +that profile's guaranteed minimum and every required capability is guaranteed. +Select the least capable matching profile in the order shown above. If no +catalog profile matches, use an external runner or reduce the requirements; +never choose an under-provisioned profile. + +1. Use `akua-x64-ci-v2` for ordinary work without containers when its resource + requirements fit the Standard guarantees. +2. Use `akua-docker-ci-v2` for Docker, Buildx or service containers, and for + other work whose requirements exceed Standard but fit the Docker guarantees. +3. Use `akua-heavy-ci-v2` only when requirements exceed Docker but fit all Heavy + guarantees: **4 vCPU, 7168 MiB memory and 20480 MiB usable disk**. A known + heavy suite must also be checked against those bounds. Requirements above + Heavy are explicitly unsupported by this catalog and require an external + runner or reduced requirements. If a job relies on a resource size, declare it in job-level environment variables so policy can verify the profile: @@ -28,10 +42,6 @@ env: AKUA_CI_REQUIRED_DISK_MIB: "18000" ``` -GitHub-hosted ARM64, Windows and macOS labels remain valid when this catalog -does not provide the required architecture. Never select labels containing -backend/runtime/provider names. - ## `akua-x64-ci-v2` Use for: @@ -41,44 +51,58 @@ Use for: Do not use for: -- Docker, Buildx and GitHub Actions service containers -- nested virtualization, KVM and architecture-specific non-x64 builds +- Docker, Buildx and service containers +- workloads whose requirements exceed the Standard guarantees ## `akua-docker-ci-v2` Use for: - Docker and Buildx image builds -- integration tests using Docker or GitHub Actions service containers +- integration tests using Docker or service containers Do not use for: -- nested virtualization, KVM and architecture-specific non-x64 builds -- workloads declaring resources above this profile; use the heavy profile +- workloads whose requirements exceed the Docker guarantees; use Heavy only + when all Heavy bounds fit ## `akua-heavy-ci-v2` Use for: -- memory-heavy compilation, packaging and browser or integration suites -- Docker jobs whose declared requirements exceed the Docker profile +- memory-heavy compilation, packaging and browser or integration suites that + fit the Heavy guarantees +- Docker jobs whose declared requirements exceed Docker but fit Heavy Do not use for: -- nested virtualization, KVM and architecture-specific non-x64 builds -- workloads requiring more than the stated minimum resource contract +- workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable + disk; use an external runner or reduce requirements ## Versioning and deprecation -The catalog contract is `2.0.0` and -the runner image/toolchain contract is `ubuntu-24.04-v1`. -Additive guarantees may increment the catalog minor version. Reduced -guarantees or removed capabilities require a new workflow label. Deprecated -profiles publish announcement, sunset and replacement metadata before -retirement. +The catalog contract is `2.0.0`. Additive guarantees may increment the catalog +minor version. Reduced guarantees or removed capabilities require a new +workflow label. Deprecated profiles publish announcement, sunset and +replacement metadata before retirement. Machine-readable forms: [`runner-profiles.yaml`](runner-profiles.yaml) and [`runner-profiles.json`](runner-profiles.json). -Provenance: `akua-dev/gitops@ca39219715c3b6723452d8be79689e73470b5810` path -`clusters/agentos/runner-platform/profiles.yaml`, source SHA-256 `5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598`. + diff --git a/runner-profiles.json b/runner-profiles.json index 90aefe9..2f65705 100644 --- a/runner-profiles.json +++ b/runner-profiles.json @@ -4,7 +4,6 @@ "metadata": { "name": "akua-linux-x64", "contractVersion": "2.0.0", - "imageContractVersion": "ubuntu-24.04-v1", "documentation": "https://github.com/akua-dev/.github/blob/main/RUNNERS.md", "provenance": { "repository": "akua-dev/gitops", @@ -29,44 +28,35 @@ "memoryMiB": "AKUA_CI_REQUIRED_MEMORY_MIB", "diskMiB": "AKUA_CI_REQUIRED_DISK_MIB" }, - "runtimeSpecificLabelPatterns": [ - "arc", - "fireactions", - "firecracker", - "hetzner", - "kata", - "kubernetes" - ], - "allowedExternalLabelPatterns": [ - "^ubuntu-[A-Za-z0-9._-]+$", - "^windows-[A-Za-z0-9._-]+$", - "^macos-[A-Za-z0-9._-]+$" - ] + "selection": { + "safeMatch": { + "resources": "required-at-most-guaranteed-minimum", + "capabilities": "required-subset-of-guaranteed" + }, + "order": [ + "akua-x64-ci-v2", + "akua-docker-ci-v2", + "akua-heavy-ci-v2" + ], + "noMatch": "external-runner-or-reduce-requirements" + } }, "profiles": [ { - "id": "linux-x64-standard-v2", + "id": "standard-v2", "label": "akua-x64-ci-v2", - "class": "linux-x64", - "displayName": "Linux x64 standard", + "class": "standard", + "displayName": "Standard", "status": "active", - "platform": { - "os": "linux", - "distribution": "ubuntu", - "distributionVersion": "24.04", - "architecture": "x86_64" - }, "minimumResources": { "vcpu": 2, "memoryMiB": 4096, "usableDiskMiB": 10240 }, "capabilities": { - "docker": false, - "buildx": false, - "serviceContainers": false, - "privilegedContainers": false, - "nestedVirtualization": false + "guaranteed": [ + "ordinary build and test tooling" + ] }, "workload": { "recommended": [ @@ -74,8 +64,8 @@ "jobs that do not start containers or require large local caches" ], "exclusions": [ - "Docker, Buildx and GitHub Actions service containers", - "nested virtualization, KVM and architecture-specific non-x64 builds" + "Docker, Buildx and service containers", + "workloads whose requirements exceed the Standard guarantees" ] }, "deprecation": { @@ -86,37 +76,31 @@ } }, { - "id": "linux-x64-docker-v2", + "id": "docker-v2", "label": "akua-docker-ci-v2", - "class": "docker-x64", - "displayName": "Linux x64 Docker", + "class": "docker", + "displayName": "Docker", "status": "active", - "platform": { - "os": "linux", - "distribution": "ubuntu", - "distributionVersion": "24.04", - "architecture": "x86_64" - }, "minimumResources": { "vcpu": 4, "memoryMiB": 6144, "usableDiskMiB": 15360 }, "capabilities": { - "docker": true, - "buildx": true, - "serviceContainers": true, - "privilegedContainers": true, - "nestedVirtualization": false + "guaranteed": [ + "Docker", + "Buildx", + "service containers", + "privileged containers" + ] }, "workload": { "recommended": [ "Docker and Buildx image builds", - "integration tests using Docker or GitHub Actions service containers" + "integration tests using Docker or service containers" ], "exclusions": [ - "nested virtualization, KVM and architecture-specific non-x64 builds", - "workloads declaring resources above this profile; use the heavy profile" + "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" ] }, "deprecation": { @@ -127,37 +111,31 @@ } }, { - "id": "linux-x64-heavy-v2", + "id": "heavy-v2", "label": "akua-heavy-ci-v2", - "class": "heavy-x64", - "displayName": "Linux x64 heavy", + "class": "heavy", + "displayName": "Heavy", "status": "active", - "platform": { - "os": "linux", - "distribution": "ubuntu", - "distributionVersion": "24.04", - "architecture": "x86_64" - }, "minimumResources": { "vcpu": 4, "memoryMiB": 7168, "usableDiskMiB": 20480 }, "capabilities": { - "docker": true, - "buildx": true, - "serviceContainers": true, - "privilegedContainers": true, - "nestedVirtualization": false + "guaranteed": [ + "Docker", + "Buildx", + "service containers", + "privileged containers" + ] }, "workload": { "recommended": [ - "memory-heavy compilation, packaging and browser or integration suites", - "Docker jobs whose declared requirements exceed the Docker profile" + "memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees", + "Docker jobs whose declared requirements exceed Docker but fit Heavy" ], "exclusions": [ - "nested virtualization, KVM and architecture-specific non-x64 builds", - "workloads requiring more than the stated minimum resource contract" + "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" ] }, "deprecation": { diff --git a/runner-profiles.yaml b/runner-profiles.yaml index a094683..75c450e 100644 --- a/runner-profiles.yaml +++ b/runner-profiles.yaml @@ -4,7 +4,6 @@ kind: RunnerProfileCatalog metadata: name: akua-linux-x64 contractVersion: 2.0.0 - imageContractVersion: ubuntu-24.04-v1 documentation: https://github.com/akua-dev/.github/blob/main/RUNNERS.md provenance: repository: akua-dev/gitops @@ -15,7 +14,7 @@ capacity: scope: organization allocation: shared maxConcurrentJobs: 4 - queueSlo: + queueSlo: null notes: - Capacity is shared by all three profiles; a profile label does not reserve a private slot. @@ -26,111 +25,89 @@ policy: cpu: AKUA_CI_REQUIRED_VCPU memoryMiB: AKUA_CI_REQUIRED_MEMORY_MIB diskMiB: AKUA_CI_REQUIRED_DISK_MIB - runtimeSpecificLabelPatterns: - - arc - - fireactions - - firecracker - - hetzner - - kata - - kubernetes - allowedExternalLabelPatterns: - - "^ubuntu-[A-Za-z0-9._-]+$" - - "^windows-[A-Za-z0-9._-]+$" - - "^macos-[A-Za-z0-9._-]+$" + selection: + safeMatch: + resources: required-at-most-guaranteed-minimum + capabilities: required-subset-of-guaranteed + order: + - akua-x64-ci-v2 + - akua-docker-ci-v2 + - akua-heavy-ci-v2 + noMatch: external-runner-or-reduce-requirements profiles: -- id: linux-x64-standard-v2 +- id: standard-v2 label: akua-x64-ci-v2 - class: linux-x64 - displayName: Linux x64 standard + class: standard + displayName: Standard status: active - platform: - os: linux - distribution: ubuntu - distributionVersion: '24.04' - architecture: x86_64 minimumResources: vcpu: 2 memoryMiB: 4096 usableDiskMiB: 10240 capabilities: - docker: false - buildx: false - serviceContainers: false - privilegedContainers: false - nestedVirtualization: false + guaranteed: + - ordinary build and test tooling workload: recommended: - linting, formatting, unit tests and ordinary compilation - jobs that do not start containers or require large local caches exclusions: - - Docker, Buildx and GitHub Actions service containers - - nested virtualization, KVM and architecture-specific non-x64 builds + - Docker, Buildx and service containers + - workloads whose requirements exceed the Standard guarantees deprecation: deprecated: false - announcedAt: - sunsetAt: - replacementLabel: -- id: linux-x64-docker-v2 + announcedAt: null + sunsetAt: null + replacementLabel: null +- id: docker-v2 label: akua-docker-ci-v2 - class: docker-x64 - displayName: Linux x64 Docker + class: docker + displayName: Docker status: active - platform: - os: linux - distribution: ubuntu - distributionVersion: '24.04' - architecture: x86_64 minimumResources: vcpu: 4 memoryMiB: 6144 usableDiskMiB: 15360 capabilities: - docker: true - buildx: true - serviceContainers: true - privilegedContainers: true - nestedVirtualization: false + guaranteed: + - Docker + - Buildx + - service containers + - privileged containers workload: recommended: - Docker and Buildx image builds - - integration tests using Docker or GitHub Actions service containers + - integration tests using Docker or service containers exclusions: - - nested virtualization, KVM and architecture-specific non-x64 builds - - workloads declaring resources above this profile; use the heavy profile + - workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit deprecation: deprecated: false - announcedAt: - sunsetAt: - replacementLabel: -- id: linux-x64-heavy-v2 + announcedAt: null + sunsetAt: null + replacementLabel: null +- id: heavy-v2 label: akua-heavy-ci-v2 - class: heavy-x64 - displayName: Linux x64 heavy + class: heavy + displayName: Heavy status: active - platform: - os: linux - distribution: ubuntu - distributionVersion: '24.04' - architecture: x86_64 minimumResources: vcpu: 4 memoryMiB: 7168 usableDiskMiB: 20480 capabilities: - docker: true - buildx: true - serviceContainers: true - privilegedContainers: true - nestedVirtualization: false + guaranteed: + - Docker + - Buildx + - service containers + - privileged containers workload: recommended: - - memory-heavy compilation, packaging and browser or integration suites - - Docker jobs whose declared requirements exceed the Docker profile + - memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees + - Docker jobs whose declared requirements exceed Docker but fit Heavy exclusions: - - nested virtualization, KVM and architecture-specific non-x64 builds - - workloads requiring more than the stated minimum resource contract + - workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements deprecation: deprecated: false - announcedAt: - sunsetAt: - replacementLabel: + announcedAt: null + sunsetAt: null + replacementLabel: null From b261ef989b489ec2947e1596b8e874a9d56216e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:40:19 +0200 Subject: [PATCH 03/12] no-mistakes(review): Hardened catalog identity, capability, capacity, and provenance checks --- .github/workflows/runner-catalog.yml | 41 ++++++++++++++++++++++-- RUNNERS.md | 8 +++-- runner-profiles.json | 8 +++-- runner-profiles.yaml | 4 ++- test/runner_catalog_test.rb | 48 ++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 test/runner_catalog_test.rb diff --git a/.github/workflows/runner-catalog.yml b/.github/workflows/runner-catalog.yml index 861f885..4308d29 100644 --- a/.github/workflows/runner-catalog.yml +++ b/.github/workflows/runner-catalog.yml @@ -22,11 +22,31 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 + - name: Resolve canonical source revision + id: catalog-provenance + shell: ruby {0} + run: | + require "json" + + provenance = JSON.parse(File.read("runner-profiles.json")).fetch("metadata").fetch("provenance") + abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) + File.open(ENV.fetch("GITHUB_OUTPUT"), "a") { |output| output.puts("revision=#{provenance.fetch("revision")}") } + - name: Checkout canonical runner source + uses: actions/checkout@v6 + with: + repository: akua-dev/gitops + ref: ${{ steps.catalog-provenance.outputs.revision }} + token: ${{ secrets.GITOPS_READ_TOKEN }} + path: .gitops-source + sparse-checkout: clusters/agentos/runner-platform/profiles.yaml + sparse-checkout-cone-mode: false + persist-credentials: false - name: Validate generated formats and catalog semantics shell: ruby {0} run: | require "json" require "yaml" + require "digest" json = JSON.parse(File.read("runner-profiles.json")) yaml = YAML.safe_load(File.read("runner-profiles.yaml"), aliases: false) @@ -34,6 +54,7 @@ jobs: metadata = json.fetch("metadata") abort "provider-neutral metadata drift" unless metadata.keys.sort == %w[contractVersion documentation name provenance] + abort "catalog name drift" unless metadata.fetch("name") == "akua-ci-catalog" provenance = metadata.fetch("provenance") expected_provenance = { @@ -43,9 +64,22 @@ jobs: abort "non-canonical provenance" unless provenance.slice(*expected_provenance.keys) == expected_provenance abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) abort "missing source SHA-256" unless provenance.fetch("sha256", "").match?(/\A[0-9a-f]{64}\z/) + source_path = ".gitops-source/clusters/agentos/runner-platform/profiles.yaml" + abort "canonical source missing" unless File.file?(source_path) + abort "canonical source hash mismatch" unless Digest::SHA256.file(source_path).hexdigest == provenance.fetch("sha256") capacity = json.fetch("capacity") - abort "unsafe capacity contract" unless capacity.fetch("maxConcurrentJobs") == 4 + expected_capacity = { + "scope" => "organization", + "allocation" => "shared", + "maxConcurrentJobs" => 4, + "queueSlo" => nil, + "notes" => [ + "Capacity is shared by all three profiles; a profile label does not reserve a private slot.", + "Four concurrent jobs are the current safe contract. Six was only a short load experiment." + ] + } + abort "unsafe capacity contract" unless capacity == expected_capacity expected_selection = { "safeMatch" => { @@ -72,14 +106,14 @@ jobs: "class" => "docker", "displayName" => "Docker", "minimumResources" => { "vcpu" => 4, "memoryMiB" => 6144, "usableDiskMiB" => 15360 }, - "guaranteed" => ["Docker", "Buildx", "service containers", "privileged containers"] + "guaranteed" => ["Docker", "Buildx", "service containers", "privileged containers", "ordinary build and test tooling"] }, "akua-heavy-ci-v2" => { "id" => "heavy-v2", "class" => "heavy", "displayName" => "Heavy", "minimumResources" => { "vcpu" => 4, "memoryMiB" => 7168, "usableDiskMiB" => 20480 }, - "guaranteed" => ["Docker", "Buildx", "service containers", "privileged containers"] + "guaranteed" => ["Docker", "Buildx", "service containers", "privileged containers", "ordinary build and test tooling"] } } @@ -106,6 +140,7 @@ jobs: abort "missing structured catalog contract" unless contract_match markdown_contract = YAML.safe_load(contract_match.fetch(1), aliases: false) abort "README contract version drift" unless markdown_contract.fetch("contractVersion") == metadata.fetch("contractVersion") + abort "README capacity drift" unless markdown_contract.fetch("capacity") == capacity abort "README selection drift" unless markdown_contract.fetch("selection") == policy.fetch("selection") abort "README provenance drift" unless markdown_contract.fetch("provenance") == provenance diff --git a/RUNNERS.md b/RUNNERS.md index 4e391da..ac6b8e4 100644 --- a/RUNNERS.md +++ b/RUNNERS.md @@ -7,8 +7,8 @@ implementation behind a label may change without requiring repository edits. | Label | Profile | Minimum CPU | Minimum memory | Minimum usable disk | Guaranteed capabilities | Status | Deprecation | | --- | --- | ---: | ---: | ---: | --- | --- | --- | | `akua-x64-ci-v2` | Standard | 2 vCPU | 4096 MiB | 10240 MiB | ordinary build and test tooling | active | not deprecated | -| `akua-docker-ci-v2` | Docker | 4 vCPU | 6144 MiB | 15360 MiB | Docker, Buildx, service containers, privileged containers | active | not deprecated | -| `akua-heavy-ci-v2` | Heavy | 4 vCPU | 7168 MiB | 20480 MiB | Docker, Buildx, service containers, privileged containers | active | not deprecated | +| `akua-docker-ci-v2` | Docker | 4 vCPU | 6144 MiB | 15360 MiB | Docker, Buildx, service containers, privileged containers, ordinary build and test tooling | active | not deprecated | +| `akua-heavy-ci-v2` | Heavy | 4 vCPU | 7168 MiB | 20480 MiB | Docker, Buildx, service containers, privileged containers, ordinary build and test tooling | active | not deprecated | Capacity is shared across these labels and currently capped at **4 concurrent jobs**. This is a safety limit, not a queue-start SLO or a reservation per @@ -91,6 +91,10 @@ Machine-readable forms: [`runner-profiles.yaml`](runner-profiles.yaml) and /m) - abort "missing structured catalog contract" unless contract_match - markdown_contract = YAML.safe_load(contract_match.fetch(1), aliases: false) - abort "README contract version drift" unless markdown_contract.fetch("contractVersion") == metadata.fetch("contractVersion") - abort "README capacity drift" unless markdown_contract.fetch("capacity") == capacity - abort "README selection drift" unless markdown_contract.fetch("selection") == policy.fetch("selection") - abort "README provenance drift" unless markdown_contract.fetch("provenance") == provenance - - header_index = markdown.lines.index { |line| line.start_with?("| Label | Profile |") } - abort "missing profile table" unless header_index - table_lines = markdown.lines[(header_index + 2)..].take_while { |line| line.start_with?("| `akua-") } - table = table_lines.map do |line| - cells = line.strip.split("|", -1)[1...-1].map(&:strip) - abort "malformed profile table" unless cells.length == 8 - cpu = cells.fetch(2).match?(/\A\d+ vCPU\z/) && cells.fetch(2).to_i - memory = cells.fetch(3).match?(/\A\d+ MiB\z/) && cells.fetch(3).to_i - disk = cells.fetch(4).match?(/\A\d+ MiB\z/) && cells.fetch(4).to_i - abort "malformed profile resources" unless cpu && memory && disk - { - "label" => cells.fetch(0).delete("`"), - "displayName" => cells.fetch(1), - "minimumResources" => { "vcpu" => cpu, "memoryMiB" => memory, "usableDiskMiB" => disk }, - "guaranteed" => cells.fetch(5).split(/,\s*/), - "status" => cells.fetch(6), - "deprecation" => cells.fetch(7) - } - end - expected_table = profiles.map do |profile| - { - "label" => profile.fetch("label"), - "displayName" => profile.fetch("displayName"), - "minimumResources" => profile.fetch("minimumResources"), - "guaranteed" => profile.dig("capabilities", "guaranteed"), - "status" => profile.fetch("status"), - "deprecation" => profile.dig("deprecation", "deprecated") ? "deprecated" : "not deprecated" - } - end - abort "README profile semantics drift" unless table == expected_table + - name: Validate public catalog contract + run: ruby script/validate_runner_catalog.rb diff --git a/RUNNERS.md b/RUNNERS.md index ac6b8e4..04a29e0 100644 --- a/RUNNERS.md +++ b/RUNNERS.md @@ -95,6 +95,10 @@ capacity: scope: organization allocation: shared maxConcurrentJobs: 4 + queueSlo: null + notes: + - Capacity is shared by all three profiles; a profile label does not reserve a private slot. + - Four concurrent jobs are the current safe contract. Six was only a short load experiment. selection: safeMatch: resources: required-at-most-guaranteed-minimum diff --git a/runner-catalog-manifest.json b/runner-catalog-manifest.json new file mode 100644 index 0000000..976eb7c --- /dev/null +++ b/runner-catalog-manifest.json @@ -0,0 +1,160 @@ +{ + "manifestVersion": 1, + "source": { + "repository": "akua-dev/gitops", + "path": "clusters/agentos/runner-platform/profiles.yaml", + "revision": "ca39219715c3b6723452d8be79689e73470b5810", + "sha256": "5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598" + }, + "catalog": { + "apiVersion": "runners.akua.dev/v1alpha1", + "kind": "RunnerProfileCatalog", + "metadata": { + "name": "akua-ci-catalog", + "contractVersion": "2.0.0", + "documentation": "https://github.com/akua-dev/.github/blob/main/RUNNERS.md", + "provenance": { + "repository": "akua-dev/gitops", + "path": "clusters/agentos/runner-platform/profiles.yaml", + "revision": "ca39219715c3b6723452d8be79689e73470b5810", + "sha256": "5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598" + } + }, + "capacity": { + "scope": "organization", + "allocation": "shared", + "maxConcurrentJobs": 4, + "queueSlo": null, + "notes": [ + "Capacity is shared by all three profiles; a profile label does not reserve a private slot.", + "Four concurrent jobs are the current safe contract. Six was only a short load experiment." + ] + }, + "policy": { + "requirementEnvironment": { + "cpu": "AKUA_CI_REQUIRED_VCPU", + "memoryMiB": "AKUA_CI_REQUIRED_MEMORY_MIB", + "diskMiB": "AKUA_CI_REQUIRED_DISK_MIB" + }, + "selection": { + "safeMatch": { + "resources": "required-at-most-guaranteed-minimum", + "capabilities": "required-subset-of-guaranteed" + }, + "order": [ + "akua-x64-ci-v2", + "akua-docker-ci-v2", + "akua-heavy-ci-v2" + ], + "noMatch": "external-runner-or-reduce-requirements" + } + }, + "profiles": [ + { + "id": "standard-v2", + "label": "akua-x64-ci-v2", + "class": "standard", + "displayName": "Standard", + "status": "active", + "minimumResources": { + "vcpu": 2, + "memoryMiB": 4096, + "usableDiskMiB": 10240 + }, + "capabilities": { + "guaranteed": [ + "ordinary build and test tooling" + ] + }, + "workload": { + "recommended": [ + "linting, formatting, unit tests and ordinary compilation", + "jobs that do not start containers or require large local caches" + ], + "exclusions": [ + "Docker, Buildx and service containers", + "workloads whose requirements exceed the Standard guarantees" + ] + }, + "deprecation": { + "deprecated": false, + "announcedAt": null, + "sunsetAt": null, + "replacementLabel": null + } + }, + { + "id": "docker-v2", + "label": "akua-docker-ci-v2", + "class": "docker", + "displayName": "Docker", + "status": "active", + "minimumResources": { + "vcpu": 4, + "memoryMiB": 6144, + "usableDiskMiB": 15360 + }, + "capabilities": { + "guaranteed": [ + "Docker", + "Buildx", + "service containers", + "privileged containers", + "ordinary build and test tooling" + ] + }, + "workload": { + "recommended": [ + "Docker and Buildx image builds", + "integration tests using Docker or service containers" + ], + "exclusions": [ + "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" + ] + }, + "deprecation": { + "deprecated": false, + "announcedAt": null, + "sunsetAt": null, + "replacementLabel": null + } + }, + { + "id": "heavy-v2", + "label": "akua-heavy-ci-v2", + "class": "heavy", + "displayName": "Heavy", + "status": "active", + "minimumResources": { + "vcpu": 4, + "memoryMiB": 7168, + "usableDiskMiB": 20480 + }, + "capabilities": { + "guaranteed": [ + "Docker", + "Buildx", + "service containers", + "privileged containers", + "ordinary build and test tooling" + ] + }, + "workload": { + "recommended": [ + "memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees", + "Docker jobs whose declared requirements exceed Docker but fit Heavy" + ], + "exclusions": [ + "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" + ] + }, + "deprecation": { + "deprecated": false, + "announcedAt": null, + "sunsetAt": null, + "replacementLabel": null + } + } + ] + } +} diff --git a/script/validate_runner_catalog.rb b/script/validate_runner_catalog.rb new file mode 100644 index 0000000..cfe4587 --- /dev/null +++ b/script/validate_runner_catalog.rb @@ -0,0 +1,260 @@ +#!/usr/bin/env ruby + +require "digest" +require "json" +require "optparse" +require "yaml" + +class RunnerCatalogValidationError < StandardError; end + +class RunnerCatalogValidator + SOURCE_RELATIVE_PATH = "clusters/agentos/runner-platform/profiles.yaml" + DOCUMENTATION = "https://github.com/akua-dev/.github/blob/main/RUNNERS.md" + BASELINE_CAPABILITY = "ordinary build and test tooling" + CAPABILITY_NAMES = { + "docker" => "Docker", + "buildx" => "Buildx", + "serviceContainers" => "service containers", + "privilegedContainers" => "privileged containers" + }.freeze + SELECTION = { + "safeMatch" => { + "resources" => "required-at-most-guaranteed-minimum", + "capabilities" => "required-subset-of-guaranteed" + }, + "order" => %w[akua-x64-ci-v2 akua-docker-ci-v2 akua-heavy-ci-v2], + "noMatch" => "external-runner-or-reduce-requirements" + }.freeze + PROFILE_KEYS = %w[capabilities class deprecation displayName id label minimumResources status workload].freeze + METADATA_KEYS = %w[contractVersion documentation name provenance].freeze + BANNED_PUBLIC_KEYS = %w[allowedExternalLabelPatterns imageContractVersion platform runtimeSpecificLabelPatterns].freeze + + def initialize(candidate_root:, source_root: nil) + @candidate_root = File.expand_path(candidate_root) + @source_root = source_root && File.expand_path(source_root) + end + + def validate! + catalog = load_catalog + validate_public_contract!(catalog) + validate_markdown!(catalog) + validate_source!(catalog) if @source_root + catalog + rescue KeyError, JSON::ParserError, Psych::Exception, Errno::ENOENT => error + raise RunnerCatalogValidationError, error.message + end + + private + + def load_catalog + json = JSON.parse(File.read(File.join(@candidate_root, "runner-profiles.json"))) + yaml = YAML.safe_load(File.read(File.join(@candidate_root, "runner-profiles.yaml")), aliases: false) + fail_with("JSON and YAML catalogs differ") unless json == yaml + manifest = JSON.parse(File.read(File.join(@candidate_root, "runner-catalog-manifest.json"))) + fail_with("manifest schema drift") unless manifest.keys.sort == %w[catalog manifestVersion source] + fail_with("manifest version drift") unless manifest.fetch("manifestVersion") == 1 + fail_with("manifest provenance drift") unless manifest.fetch("source") == json.dig("metadata", "provenance") + fail_with("manifest catalog drift") unless manifest.fetch("catalog") == json + json + end + + def validate_public_contract!(catalog) + metadata = catalog.fetch("metadata") + fail_with("provider-neutral metadata drift") unless metadata.keys.sort == METADATA_KEYS + fail_with("catalog name drift") unless metadata.fetch("name") == "akua-ci-catalog" + fail_with("documentation drift") unless metadata.fetch("documentation") == DOCUMENTATION + + provenance = metadata.fetch("provenance") + fail_with("non-canonical provenance") unless provenance.slice("repository", "path") == { + "repository" => "akua-dev/gitops", + "path" => SOURCE_RELATIVE_PATH + } + fail_with("missing source revision") unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) + fail_with("missing source SHA-256") unless provenance.fetch("sha256", "").match?(/\A[0-9a-f]{64}\z/) + + capacity = catalog.fetch("capacity") + expected_capacity = { + "scope" => "organization", + "allocation" => "shared", + "maxConcurrentJobs" => 4, + "queueSlo" => nil, + "notes" => [ + "Capacity is shared by all three profiles; a profile label does not reserve a private slot.", + "Four concurrent jobs are the current safe contract. Six was only a short load experiment." + ] + } + fail_with("unsafe capacity contract") unless capacity == expected_capacity + + policy = catalog.fetch("policy") + fail_with("selection policy drift") unless policy.keys.sort == %w[requirementEnvironment selection] + fail_with("selection policy drift") unless policy.fetch("selection") == SELECTION + + profiles = catalog.fetch("profiles") + fail_with("stable labels drift") unless profiles.map { |profile| profile.fetch("label") } == SELECTION.fetch("order") + profiles.each do |profile| + fail_with("provider-specific profile fields") unless profile.keys.sort == PROFILE_KEYS + fail_with("missing baseline capability") unless profile.dig("capabilities", "guaranteed").include?(BASELINE_CAPABILITY) + fail_with("profile status drift") unless profile.fetch("status") == "active" + fail_with("profile deprecation drift") unless profile.fetch("deprecation") == { + "deprecated" => false, + "announcedAt" => nil, + "sunsetAt" => nil, + "replacementLabel" => nil + } + fail_with("runtime or provider detail in public catalog") if contains_banned_key?(profile) + end + end + + def validate_markdown!(catalog) + markdown = File.read(File.join(@candidate_root, "RUNNERS.md")) + contract_match = markdown.match(//m) + fail_with("missing structured catalog contract") unless contract_match + markdown_contract = YAML.safe_load(contract_match[1], aliases: false) + fail_with("README contract version drift") unless markdown_contract.fetch("contractVersion") == catalog.dig("metadata", "contractVersion") + fail_with("README capacity drift") unless markdown_contract.fetch("capacity") == catalog.fetch("capacity") + fail_with("README selection drift") unless markdown_contract.fetch("selection") == catalog.dig("policy", "selection") + fail_with("README provenance drift") unless markdown_contract.fetch("provenance") == catalog.dig("metadata", "provenance") + + header_index = markdown.lines.index { |line| line.start_with?("| Label | Profile |") } + fail_with("missing profile table") unless header_index + table_lines = markdown.lines[(header_index + 2)..].take_while { |line| line.start_with?("| `akua-") } + table = table_lines.map do |line| + cells = line.strip.split("|", -1)[1...-1].map(&:strip) + fail_with("malformed profile table") unless cells.length == 8 + cpu = cells.fetch(2).match?(/\A\d+ vCPU\z/) && cells.fetch(2).to_i + memory = cells.fetch(3).match?(/\A\d+ MiB\z/) && cells.fetch(3).to_i + disk = cells.fetch(4).match?(/\A\d+ MiB\z/) && cells.fetch(4).to_i + fail_with("malformed profile resources") unless cpu && memory && disk + { + "label" => cells.fetch(0).delete("`"), + "displayName" => cells.fetch(1), + "minimumResources" => { "vcpu" => cpu, "memoryMiB" => memory, "usableDiskMiB" => disk }, + "guaranteed" => cells.fetch(5).split(/,\s*/), + "status" => cells.fetch(6), + "deprecation" => cells.fetch(7) + } + end + expected_table = catalog.fetch("profiles").map do |profile| + { + "label" => profile.fetch("label"), + "displayName" => profile.fetch("displayName"), + "minimumResources" => profile.fetch("minimumResources"), + "guaranteed" => profile.dig("capabilities", "guaranteed"), + "status" => profile.fetch("status"), + "deprecation" => profile.dig("deprecation", "deprecated") ? "deprecated" : "not deprecated" + } + end + fail_with("README profile semantics drift") unless table == expected_table + end + + def validate_source!(catalog) + source_path = File.join(@source_root, SOURCE_RELATIVE_PATH) + fail_with("canonical source missing") unless File.file?(source_path) + provenance = catalog.dig("metadata", "provenance") + fail_with("canonical source hash mismatch") unless Digest::SHA256.file(source_path).hexdigest == provenance.fetch("sha256") + source = YAML.safe_load(File.read(source_path), aliases: false) + expected = normalize_source(source, provenance) + fail_with("canonical source normalization drift") unless catalog == expected + end + + def normalize_source(source, provenance) + { + "apiVersion" => source.fetch("apiVersion"), + "kind" => source.fetch("kind"), + "metadata" => { + "name" => "akua-ci-catalog", + "contractVersion" => source.dig("metadata", "contractVersion"), + "documentation" => DOCUMENTATION, + "provenance" => provenance + }, + "capacity" => normalize_capacity(source.fetch("capacity")), + "policy" => { + "requirementEnvironment" => source.dig("policy", "requirementEnvironment"), + "selection" => SELECTION + }, + "profiles" => source.fetch("profiles").map { |profile| normalize_profile(profile) } + } + end + + def normalize_capacity(capacity) + capacity.slice("scope", "allocation", "maxConcurrentJobs", "queueSlo", "notes") + end + + def normalize_profile(source_profile) + label = source_profile.fetch("label") + display_name = source_profile.fetch("displayName").sub(/\ALinux x64 /i, "").split.map(&:capitalize).join(" ") + normalized_class = source_profile.fetch("class").sub(/\Alinux-x64\z/, "standard").sub(/-x64\z/, "") + { + "id" => source_profile.fetch("id").sub(/\Alinux-x64-/, ""), + "label" => label, + "class" => normalized_class, + "displayName" => display_name, + "status" => source_profile.fetch("status"), + "minimumResources" => source_profile.fetch("minimumResources"), + "capabilities" => { "guaranteed" => normalize_capabilities(source_profile.fetch("capabilities")) }, + "workload" => normalize_workload(source_profile.fetch("workload"), label, display_name), + "deprecation" => source_profile.fetch("deprecation").transform_values { |value| value == "" ? nil : value } + } + end + + def normalize_capabilities(capabilities) + result = if capabilities.is_a?(Array) + capabilities.dup + else + CAPABILITY_NAMES.filter_map { |key, name| name if capabilities.fetch(key, false) } + end + result << BASELINE_CAPABILITY unless result.include?(BASELINE_CAPABILITY) + result + end + + def normalize_workload(workload, label, display_name) + recommended = workload.fetch("recommended").map { |text| text.gsub("GitHub Actions ", "") } + if label == "akua-heavy-ci-v2" + recommended[0] = "#{recommended.fetch(0)} that fit the Heavy guarantees" unless recommended.fetch(0).include?("fit the Heavy guarantees") + recommended[1] = recommended.fetch(1).sub("the Docker profile", "Docker") + " but fit Heavy" unless recommended.fetch(1).include?("fit Heavy") + end + exclusions = workload.fetch("exclusions").reject { |text| text.match?(/nested virtualization|KVM|architecture-specific/i) } + exclusions.map! do |text| + case text + when /workloads declaring resources above this profile; use the heavy profile/i + "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" + when /workloads requiring more than the stated minimum resource contract/i + "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" + else + text + end + end + if label == "akua-x64-ci-v2" + exclusions << "workloads whose requirements exceed the Standard guarantees" unless exclusions.any? { |text| text.include?("requirements exceed") } + end + { "recommended" => recommended, "exclusions" => exclusions } + end + + def contains_banned_key?(value) + case value + when Hash + value.any? { |key, child| BANNED_PUBLIC_KEYS.include?(key) || contains_banned_key?(child) } + when Array + value.any? { |child| contains_banned_key?(child) } + else + false + end + end + + def fail_with(message) + raise RunnerCatalogValidationError, message + end +end + +options = { candidate_root: ".", source_root: nil } +OptionParser.new do |parser| + parser.on("--candidate-root PATH") { |path| options[:candidate_root] = path } + parser.on("--source-root PATH") { |path| options[:source_root] = path } +end.parse! + +begin + RunnerCatalogValidator.new(**options).validate! +rescue RunnerCatalogValidationError => error + warn error.message + exit 1 +end diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index 00a7151..78e611d 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -1,48 +1,124 @@ +require "digest" +require "fileutils" require "json" require "minitest/autorun" +require "open3" +require "rbconfig" +require "tmpdir" require "yaml" class RunnerCatalogTest < Minitest::Test + SCRIPT = File.expand_path("../script/validate_runner_catalog.rb", __dir__) + SOURCE_PATH = "clusters/agentos/runner-platform/profiles.yaml" + def setup - @catalog = JSON.parse(File.read("runner-profiles.json")) - @yaml_catalog = YAML.safe_load(File.read("runner-profiles.yaml"), aliases: false) - @profiles = @catalog.fetch("profiles") + @root = File.expand_path("..", __dir__) + @catalog = JSON.parse(File.read(File.join(@root, "runner-profiles.json"))) end - def test_machine_catalogs_are_the_same_contract - assert_equal @catalog, @yaml_catalog - assert_equal "akua-ci-catalog", @catalog.dig("metadata", "name") + def test_public_validator_accepts_current_contract + assert_validator_success(@root) end - def test_capabilities_are_compositional_for_docker_and_heavy - %w[akua-docker-ci-v2 akua-heavy-ci-v2].each do |label| - capabilities = profile(label).dig("capabilities", "guaranteed") - assert_includes capabilities, "ordinary build and test tooling" - assert_includes capabilities, "Docker" + def test_public_validator_rejects_stale_markdown + with_candidate do |candidate| + markdown_path = File.join(candidate, "RUNNERS.md") + markdown = File.read(markdown_path).sub("7168 MiB", "7169 MiB") + File.write(markdown_path, markdown) + assert_validator_failure(candidate, "README profile semantics drift") end end - def test_heavy_is_not_a_match_above_its_guaranteed_resources - refute safe_match?(profile("akua-heavy-ci-v2"), { "vcpu" => 5, "memoryMiB" => 7168, "usableDiskMiB" => 20480 }, ["Docker"]) - assert_equal "external-runner-or-reduce-requirements", @catalog.dig("policy", "selection", "noMatch") + def test_public_validator_rejects_stale_manifest + with_candidate do |candidate| + manifest_path = File.join(candidate, "runner-catalog-manifest.json") + manifest = JSON.parse(File.read(manifest_path)) + manifest.fetch("catalog").fetch("profiles").last.fetch("minimumResources")["vcpu"] = 5 + File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") + assert_validator_failure(candidate, "manifest catalog drift") + end end - def test_capacity_is_shared_and_exactly_four - assert_equal "organization", @catalog.dig("capacity", "scope") - assert_equal "shared", @catalog.dig("capacity", "allocation") - assert_equal 4, @catalog.dig("capacity", "maxConcurrentJobs") + def test_trusted_validator_rejects_fabricated_provenance + with_source_candidate do |candidate, source| + update_provenance(candidate, "a" * 64) + assert_validator_failure(candidate, "canonical source hash mismatch", source) + end + end + + def test_trusted_validator_rejects_stale_source_semantics + with_source_candidate do |candidate, source| + source_file = File.join(source, SOURCE_PATH) + source_catalog = YAML.safe_load(File.read(source_file), aliases: false) + source_catalog.fetch("profiles").last.fetch("minimumResources")["vcpu"] = 5 + File.write(source_file, YAML.dump(source_catalog)) + update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) + assert_validator_failure(candidate, "canonical source normalization drift", source) + end end private - def profile(label) - @profiles.find { |candidate| candidate.fetch("label") == label } + def with_candidate + Dir.mktmpdir do |directory| + candidate = File.join(directory, "candidate") + FileUtils.cp_r(@root, candidate) + yield candidate + end + end + + def with_source_candidate + with_candidate do |candidate| + source = File.join(candidate, "source") + source_file = File.join(source, SOURCE_PATH) + FileUtils.mkdir_p(File.dirname(source_file)) + File.write(source_file, YAML.dump(source_fixture)) + update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) + yield candidate, source + end + end + + def source_fixture + source = Marshal.load(Marshal.dump(@catalog)) + source.fetch("metadata").delete("name") + source.fetch("metadata").delete("documentation") + source.fetch("metadata").delete("provenance") + source.fetch("policy").delete("selection") + source.fetch("profiles").each do |profile| + profile["id"] = profile.fetch("id") + profile["class"] = profile.fetch("class") + profile["capabilities"] = profile.dig("capabilities", "guaranteed") + end + source + end + + def update_provenance(candidate, sha256) + provenance = @catalog.fetch("metadata").fetch("provenance").merge("sha256" => sha256) + json_path = File.join(candidate, "runner-profiles.json") + json = JSON.parse(File.read(json_path)) + json.fetch("metadata")["provenance"] = provenance + File.write(json_path, JSON.pretty_generate(json) + "\n") + File.write(File.join(candidate, "runner-profiles.yaml"), YAML.dump(json)) + markdown_path = File.join(candidate, "RUNNERS.md") + markdown = File.read(markdown_path).sub(/sha256: [0-9a-f]+/, "sha256: #{sha256}") + File.write(markdown_path, markdown) + manifest_path = File.join(candidate, "runner-catalog-manifest.json") + manifest = JSON.parse(File.read(manifest_path)) + manifest["source"] = provenance + manifest.fetch("catalog")["metadata"]["provenance"] = provenance + File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") + end + + def assert_validator_success(candidate) + stdout, stderr, status = Open3.capture3(RbConfig.ruby, SCRIPT, "--candidate-root", candidate) + assert status.success?, "#{stdout}\n#{stderr}" end - def safe_match?(candidate, resources, capabilities) - guaranteed_resources = candidate.fetch("minimumResources") - guaranteed_capabilities = candidate.dig("capabilities", "guaranteed") - resources.all? { |key, value| value <= guaranteed_resources.fetch(key) } && - capabilities.all? { |capability| guaranteed_capabilities.include?(capability) } + def assert_validator_failure(candidate, message, source = nil) + args = [RbConfig.ruby, SCRIPT, "--candidate-root", candidate] + args.concat(["--source-root", source]) if source + stdout, stderr, status = Open3.capture3(*args) + refute status.success?, stdout + assert_includes stderr, message end end From 3288c863c8ea141dc4ee544de1acb3e7ced14b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:00:52 +0200 Subject: [PATCH 05/12] no-mistakes(review): Enforced conservative capability, provenance, and neutrality validation --- script/validate_runner_catalog.rb | 75 +++++++++++++++++++------------ test/runner_catalog_test.rb | 41 ++++++++++++++++- 2 files changed, 86 insertions(+), 30 deletions(-) diff --git a/script/validate_runner_catalog.rb b/script/validate_runner_catalog.rb index cfe4587..ffbf316 100644 --- a/script/validate_runner_catalog.rb +++ b/script/validate_runner_catalog.rb @@ -17,6 +17,37 @@ class RunnerCatalogValidator "serviceContainers" => "service containers", "privilegedContainers" => "privileged containers" }.freeze + PUBLIC_CAPABILITIES = (CAPABILITY_NAMES.values + [BASELINE_CAPABILITY]).freeze + PUBLIC_WORKLOADS = { + "akua-x64-ci-v2" => { + "recommended" => [ + "linting, formatting, unit tests and ordinary compilation", + "jobs that do not start containers or require large local caches" + ], + "exclusions" => [ + "Docker, Buildx and service containers", + "workloads whose requirements exceed the Standard guarantees" + ] + }, + "akua-docker-ci-v2" => { + "recommended" => [ + "Docker and Buildx image builds", + "integration tests using Docker or service containers" + ], + "exclusions" => [ + "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" + ] + }, + "akua-heavy-ci-v2" => { + "recommended" => [ + "memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees", + "Docker jobs whose declared requirements exceed Docker but fit Heavy" + ], + "exclusions" => [ + "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" + ] + } + }.freeze SELECTION = { "safeMatch" => { "resources" => "required-at-most-guaranteed-minimum", @@ -27,7 +58,9 @@ class RunnerCatalogValidator }.freeze PROFILE_KEYS = %w[capabilities class deprecation displayName id label minimumResources status workload].freeze METADATA_KEYS = %w[contractVersion documentation name provenance].freeze - BANNED_PUBLIC_KEYS = %w[allowedExternalLabelPatterns imageContractVersion platform runtimeSpecificLabelPatterns].freeze + PROVENANCE_KEYS = %w[path repository revision sha256].freeze + BANNED_PUBLIC_KEYS = %w[allowedExternalLabelPatterns backend distribution imageContractVersion node platform provider runtime runtimeSpecificLabelPatterns].freeze + BANNED_PUBLIC_VALUE = /\b(?:arc|fireactions|firecracker|hetzner|kata|kubernetes|ubuntu|containerd|kvm|proxmox)\b/i def initialize(candidate_root:, source_root: nil) @candidate_root = File.expand_path(candidate_root) @@ -65,12 +98,14 @@ def validate_public_contract!(catalog) fail_with("documentation drift") unless metadata.fetch("documentation") == DOCUMENTATION provenance = metadata.fetch("provenance") + fail_with("provenance schema drift") unless provenance.keys.sort == PROVENANCE_KEYS fail_with("non-canonical provenance") unless provenance.slice("repository", "path") == { "repository" => "akua-dev/gitops", "path" => SOURCE_RELATIVE_PATH } fail_with("missing source revision") unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) fail_with("missing source SHA-256") unless provenance.fetch("sha256", "").match?(/\A[0-9a-f]{64}\z/) + fail_with("runtime or provider detail in public catalog") if contains_forbidden_detail?(catalog) capacity = catalog.fetch("capacity") expected_capacity = { @@ -101,7 +136,6 @@ def validate_public_contract!(catalog) "sunsetAt" => nil, "replacementLabel" => nil } - fail_with("runtime or provider detail in public catalog") if contains_banned_key?(profile) end end @@ -192,7 +226,7 @@ def normalize_profile(source_profile) "status" => source_profile.fetch("status"), "minimumResources" => source_profile.fetch("minimumResources"), "capabilities" => { "guaranteed" => normalize_capabilities(source_profile.fetch("capabilities")) }, - "workload" => normalize_workload(source_profile.fetch("workload"), label, display_name), + "workload" => normalize_workload(label), "deprecation" => source_profile.fetch("deprecation").transform_values { |value| value == "" ? nil : value } } end @@ -203,39 +237,24 @@ def normalize_capabilities(capabilities) else CAPABILITY_NAMES.filter_map { |key, name| name if capabilities.fetch(key, false) } end - result << BASELINE_CAPABILITY unless result.include?(BASELINE_CAPABILITY) + fail_with("source capability not allowlisted") unless result.all? { |capability| PUBLIC_CAPABILITIES.include?(capability) } + fail_with("source missing baseline capability") unless result.include?(BASELINE_CAPABILITY) result end - def normalize_workload(workload, label, display_name) - recommended = workload.fetch("recommended").map { |text| text.gsub("GitHub Actions ", "") } - if label == "akua-heavy-ci-v2" - recommended[0] = "#{recommended.fetch(0)} that fit the Heavy guarantees" unless recommended.fetch(0).include?("fit the Heavy guarantees") - recommended[1] = recommended.fetch(1).sub("the Docker profile", "Docker") + " but fit Heavy" unless recommended.fetch(1).include?("fit Heavy") - end - exclusions = workload.fetch("exclusions").reject { |text| text.match?(/nested virtualization|KVM|architecture-specific/i) } - exclusions.map! do |text| - case text - when /workloads declaring resources above this profile; use the heavy profile/i - "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" - when /workloads requiring more than the stated minimum resource contract/i - "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" - else - text - end - end - if label == "akua-x64-ci-v2" - exclusions << "workloads whose requirements exceed the Standard guarantees" unless exclusions.any? { |text| text.include?("requirements exceed") } - end - { "recommended" => recommended, "exclusions" => exclusions } + def normalize_workload(label) + fail_with("source profile label not allowlisted") unless PUBLIC_WORKLOADS.key?(label) + PUBLIC_WORKLOADS.fetch(label) end - def contains_banned_key?(value) + def contains_forbidden_detail?(value) case value when Hash - value.any? { |key, child| BANNED_PUBLIC_KEYS.include?(key) || contains_banned_key?(child) } + value.any? { |key, child| BANNED_PUBLIC_KEYS.include?(key) || contains_forbidden_detail?(key) || contains_forbidden_detail?(child) } when Array - value.any? { |child| contains_banned_key?(child) } + value.any? { |child| contains_forbidden_detail?(child) } + when String + value.match?(BANNED_PUBLIC_VALUE) else false end diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index 78e611d..817e71a 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -39,6 +39,33 @@ def test_public_validator_rejects_stale_manifest end end + def test_trusted_validator_rejects_source_overclaim_without_explicit_baseline + with_source_candidate do |candidate, source| + source_file = File.join(source, SOURCE_PATH) + source_catalog = YAML.safe_load(File.read(source_file), aliases: false) + source_catalog.fetch("profiles").last.fetch("capabilities").delete("ordinary build and test tooling") + File.write(source_file, YAML.dump(source_catalog)) + update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) + assert_validator_failure(candidate, "source missing baseline capability", source) + end + end + + def test_public_validator_rejects_extra_provenance_fields + with_candidate do |candidate| + update_provenance(candidate, @catalog.dig("metadata", "provenance", "sha256"), "provider" => "example") + assert_validator_failure(candidate, "provenance schema drift") + end + end + + def test_public_validator_rejects_forbidden_implementation_values + with_candidate do |candidate| + catalog = JSON.parse(File.read(File.join(candidate, "runner-profiles.json"))) + catalog.fetch("profiles").last.fetch("workload").fetch("recommended") << "Kubernetes-backed builds" + write_catalog(candidate, catalog) + assert_validator_failure(candidate, "runtime or provider detail in public catalog") + end + end + def test_trusted_validator_rejects_fabricated_provenance with_source_candidate do |candidate, source| update_provenance(candidate, "a" * 64) @@ -92,8 +119,8 @@ def source_fixture source end - def update_provenance(candidate, sha256) - provenance = @catalog.fetch("metadata").fetch("provenance").merge("sha256" => sha256) + def update_provenance(candidate, sha256, extra = {}) + provenance = @catalog.fetch("metadata").fetch("provenance").merge("sha256" => sha256).merge(extra) json_path = File.join(candidate, "runner-profiles.json") json = JSON.parse(File.read(json_path)) json.fetch("metadata")["provenance"] = provenance @@ -109,6 +136,16 @@ def update_provenance(candidate, sha256) File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") end + def write_catalog(candidate, catalog) + File.write(File.join(candidate, "runner-profiles.json"), JSON.pretty_generate(catalog) + "\n") + File.write(File.join(candidate, "runner-profiles.yaml"), YAML.dump(catalog)) + manifest_path = File.join(candidate, "runner-catalog-manifest.json") + manifest = JSON.parse(File.read(manifest_path)) + manifest["catalog"] = catalog + manifest["source"] = catalog.dig("metadata", "provenance") + File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") + end + def assert_validator_success(candidate) stdout, stderr, status = Open3.capture3(RbConfig.ruby, SCRIPT, "--candidate-root", candidate) assert status.success?, "#{stdout}\n#{stderr}" From 4f84c2403294847f85a3a1ea0e27921c49be021e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:14:21 +0200 Subject: [PATCH 06/12] no-mistakes(review): Added fail-closed bootstrap and source-derived catalog validation --- .github/workflows/runner-catalog.yml | 10 ++ README.md | 7 + script/require_trusted_catalog_workflow.rb | 16 ++ script/validate_runner_catalog.rb | 193 +++++++++++++++------ test/runner_catalog_test.rb | 94 +++++++++- 5 files changed, 264 insertions(+), 56 deletions(-) create mode 100644 script/require_trusted_catalog_workflow.rb diff --git a/.github/workflows/runner-catalog.yml b/.github/workflows/runner-catalog.yml index aa3dcff..c3bc6c4 100644 --- a/.github/workflows/runner-catalog.yml +++ b/.github/workflows/runner-catalog.yml @@ -31,5 +31,15 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 + - name: Checkout base commit for bootstrap boundary + if: github.event_name == 'pull_request' + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: .base + persist-credentials: false + - name: Require trusted verifier on base commit + if: github.event_name == 'pull_request' + run: ruby script/require_trusted_catalog_workflow.rb --base-root .base - name: Validate public catalog contract run: ruby script/validate_runner_catalog.rb diff --git a/README.md b/README.md index 9204d14..45523c5 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,10 @@ published in [RUNNERS.md](RUNNERS.md), with machine-readable [YAML](runner-profiles.yaml) and [JSON](runner-profiles.json) catalogs. Their canonical source and generator live in the private `akua-dev/gitops` repository; provenance is embedded in every generated artifact. + +Publication is fail-closed until the trusted provenance workflow is already +installed on the base branch. The first catalog pull request therefore requires +a separately merged bootstrap of +[runner-catalog-trusted.yml](.github/workflows/runner-catalog-trusted.yml); +the ordinary pull-request workflow has no private-source credentials and cannot +replace that boundary. diff --git a/script/require_trusted_catalog_workflow.rb b/script/require_trusted_catalog_workflow.rb new file mode 100644 index 0000000..66ce7d5 --- /dev/null +++ b/script/require_trusted_catalog_workflow.rb @@ -0,0 +1,16 @@ +#!/usr/bin/env ruby + +require "optparse" + +TRUSTED_WORKFLOW = ".github/workflows/runner-catalog-trusted.yml" + +options = { base_root: "." } +OptionParser.new do |parser| + parser.on("--base-root PATH") { |path| options[:base_root] = path } +end.parse! + +workflow = File.join(options.fetch(:base_root), TRUSTED_WORKFLOW) +unless File.file?(workflow) + warn "trusted runner catalog workflow is not installed on the base commit" + exit 1 +end diff --git a/script/validate_runner_catalog.rb b/script/validate_runner_catalog.rb index ffbf316..71910ce 100644 --- a/script/validate_runner_catalog.rb +++ b/script/validate_runner_catalog.rb @@ -18,34 +18,96 @@ class RunnerCatalogValidator "privilegedContainers" => "privileged containers" }.freeze PUBLIC_CAPABILITIES = (CAPABILITY_NAMES.values + [BASELINE_CAPABILITY]).freeze - PUBLIC_WORKLOADS = { + PROFILE_DEFINITIONS = { "akua-x64-ci-v2" => { - "recommended" => [ - "linting, formatting, unit tests and ordinary compilation", - "jobs that do not start containers or require large local caches" - ], - "exclusions" => [ - "Docker, Buildx and service containers", - "workloads whose requirements exceed the Standard guarantees" - ] + "sourceId" => "linux-x64-standard-v2", + "sourceClass" => "linux-x64", + "sourceDisplayName" => "Linux x64 standard", + "id" => "standard-v2", + "class" => "standard", + "displayName" => "Standard" }, "akua-docker-ci-v2" => { - "recommended" => [ - "Docker and Buildx image builds", - "integration tests using Docker or service containers" - ], - "exclusions" => [ - "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" - ] + "sourceId" => "linux-x64-docker-v2", + "sourceClass" => "docker-x64", + "sourceDisplayName" => "Linux x64 Docker", + "id" => "docker-v2", + "class" => "docker", + "displayName" => "Docker" }, "akua-heavy-ci-v2" => { - "recommended" => [ - "memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees", - "Docker jobs whose declared requirements exceed Docker but fit Heavy" - ], - "exclusions" => [ - "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" - ] + "sourceId" => "linux-x64-heavy-v2", + "sourceClass" => "heavy-x64", + "sourceDisplayName" => "Linux x64 heavy", + "id" => "heavy-v2", + "class" => "heavy", + "displayName" => "Heavy" + } + }.freeze + SOURCE_WORKLOAD_NORMALIZATIONS = { + "akua-x64-ci-v2" => { + "source" => { + "recommended" => [ + "linting, formatting, unit tests and ordinary compilation", + "jobs that do not start containers or require large local caches" + ], + "exclusions" => [ + "Docker, Buildx and GitHub Actions service containers", + "nested virtualization, KVM and architecture-specific non-x64 builds" + ] + }, + "public" => { + "recommended" => [ + "linting, formatting, unit tests and ordinary compilation", + "jobs that do not start containers or require large local caches" + ], + "exclusions" => [ + "Docker, Buildx and service containers", + "workloads whose requirements exceed the Standard guarantees" + ] + } + }, + "akua-docker-ci-v2" => { + "source" => { + "recommended" => [ + "Docker and Buildx image builds", + "integration tests using Docker or GitHub Actions service containers" + ], + "exclusions" => [ + "nested virtualization, KVM and architecture-specific non-x64 builds", + "workloads declaring resources above this profile; use the heavy profile" + ] + }, + "public" => { + "recommended" => [ + "Docker and Buildx image builds", + "integration tests using Docker or service containers" + ], + "exclusions" => [ + "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" + ] + } + }, + "akua-heavy-ci-v2" => { + "source" => { + "recommended" => [ + "memory-heavy compilation, packaging and browser or integration suites", + "Docker jobs whose declared requirements exceed the Docker profile" + ], + "exclusions" => [ + "nested virtualization, KVM and architecture-specific non-x64 builds", + "workloads requiring more than the stated minimum resource contract" + ] + }, + "public" => { + "recommended" => [ + "memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees", + "Docker jobs whose declared requirements exceed Docker but fit Heavy" + ], + "exclusions" => [ + "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" + ] + } } }.freeze SELECTION = { @@ -59,8 +121,13 @@ class RunnerCatalogValidator PROFILE_KEYS = %w[capabilities class deprecation displayName id label minimumResources status workload].freeze METADATA_KEYS = %w[contractVersion documentation name provenance].freeze PROVENANCE_KEYS = %w[path repository revision sha256].freeze - BANNED_PUBLIC_KEYS = %w[allowedExternalLabelPatterns backend distribution imageContractVersion node platform provider runtime runtimeSpecificLabelPatterns].freeze - BANNED_PUBLIC_VALUE = /\b(?:arc|fireactions|firecracker|hetzner|kata|kubernetes|ubuntu|containerd|kvm|proxmox)\b/i + PUBLIC_API_VERSION = "runners.akua.dev/v1alpha1" + PUBLIC_KIND = "RunnerProfileCatalog" + REQUIREMENT_ENVIRONMENT = { + "cpu" => "AKUA_CI_REQUIRED_VCPU", + "memoryMiB" => "AKUA_CI_REQUIRED_MEMORY_MIB", + "diskMiB" => "AKUA_CI_REQUIRED_DISK_MIB" + }.freeze def initialize(candidate_root:, source_root: nil) @candidate_root = File.expand_path(candidate_root) @@ -94,6 +161,8 @@ def load_catalog def validate_public_contract!(catalog) metadata = catalog.fetch("metadata") fail_with("provider-neutral metadata drift") unless metadata.keys.sort == METADATA_KEYS + fail_with("catalog api schema drift") unless catalog.fetch("apiVersion") == PUBLIC_API_VERSION && catalog.fetch("kind") == PUBLIC_KIND + fail_with("catalog contract version drift") unless metadata.fetch("contractVersion") == "2.0.0" fail_with("catalog name drift") unless metadata.fetch("name") == "akua-ci-catalog" fail_with("documentation drift") unless metadata.fetch("documentation") == DOCUMENTATION @@ -105,8 +174,6 @@ def validate_public_contract!(catalog) } fail_with("missing source revision") unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) fail_with("missing source SHA-256") unless provenance.fetch("sha256", "").match?(/\A[0-9a-f]{64}\z/) - fail_with("runtime or provider detail in public catalog") if contains_forbidden_detail?(catalog) - capacity = catalog.fetch("capacity") expected_capacity = { "scope" => "organization", @@ -122,13 +189,22 @@ def validate_public_contract!(catalog) policy = catalog.fetch("policy") fail_with("selection policy drift") unless policy.keys.sort == %w[requirementEnvironment selection] + fail_with("requirement environment drift") unless policy.fetch("requirementEnvironment") == REQUIREMENT_ENVIRONMENT fail_with("selection policy drift") unless policy.fetch("selection") == SELECTION profiles = catalog.fetch("profiles") fail_with("stable labels drift") unless profiles.map { |profile| profile.fetch("label") } == SELECTION.fetch("order") profiles.each do |profile| fail_with("provider-specific profile fields") unless profile.keys.sort == PROFILE_KEYS + definition = PROFILE_DEFINITIONS.fetch(profile.fetch("label")) { fail_with("stable labels drift") } + fail_with("public profile identity drift") unless profile.slice("id", "class", "displayName") == definition.slice("id", "class", "displayName") + fail_with("public profile resources drift") unless profile.fetch("minimumResources").keys.sort == %w[memoryMiB usableDiskMiB vcpu] + fail_with("public profile resources drift") unless profile.fetch("minimumResources").values.all? { |value| value.is_a?(Integer) && value.positive? } + capabilities = profile.fetch("capabilities") + fail_with("public capability schema drift") unless capabilities.keys == ["guaranteed"] + fail_with("public capability vocabulary drift") unless capabilities.fetch("guaranteed").all? { |capability| PUBLIC_CAPABILITIES.include?(capability) } fail_with("missing baseline capability") unless profile.dig("capabilities", "guaranteed").include?(BASELINE_CAPABILITY) + fail_with("public workload semantics drift") unless profile.fetch("workload") == SOURCE_WORKLOAD_NORMALIZATIONS.fetch(profile.fetch("label")).fetch("public") fail_with("profile status drift") unless profile.fetch("status") == "active" fail_with("profile deprecation drift") unless profile.fetch("deprecation") == { "deprecated" => false, @@ -192,9 +268,11 @@ def validate_source!(catalog) end def normalize_source(source, provenance) + fail_with("canonical source api schema drift") unless source.fetch("apiVersion") == PUBLIC_API_VERSION && source.fetch("kind") == PUBLIC_KIND + fail_with("canonical source contract version drift") unless source.dig("metadata", "contractVersion") == "2.0.0" { - "apiVersion" => source.fetch("apiVersion"), - "kind" => source.fetch("kind"), + "apiVersion" => PUBLIC_API_VERSION, + "kind" => PUBLIC_KIND, "metadata" => { "name" => "akua-ci-catalog", "contractVersion" => source.dig("metadata", "contractVersion"), @@ -203,7 +281,7 @@ def normalize_source(source, provenance) }, "capacity" => normalize_capacity(source.fetch("capacity")), "policy" => { - "requirementEnvironment" => source.dig("policy", "requirementEnvironment"), + "requirementEnvironment" => normalize_requirement_environment(source.dig("policy", "requirementEnvironment")), "selection" => SELECTION }, "profiles" => source.fetch("profiles").map { |profile| normalize_profile(profile) } @@ -211,22 +289,43 @@ def normalize_source(source, provenance) end def normalize_capacity(capacity) - capacity.slice("scope", "allocation", "maxConcurrentJobs", "queueSlo", "notes") + expected = { + "scope" => "organization", + "allocation" => "shared", + "maxConcurrentJobs" => 4, + "queueSlo" => nil, + "notes" => [ + "Capacity is shared by all three profiles; a profile label does not reserve a private slot.", + "Four concurrent jobs are the current safe contract. Six was only a short load experiment." + ] + } + fail_with("canonical source capacity drift") unless capacity == expected + expected + end + + def normalize_requirement_environment(environment) + fail_with("canonical source requirement environment drift") unless environment == REQUIREMENT_ENVIRONMENT + REQUIREMENT_ENVIRONMENT end def normalize_profile(source_profile) label = source_profile.fetch("label") - display_name = source_profile.fetch("displayName").sub(/\ALinux x64 /i, "").split.map(&:capitalize).join(" ") - normalized_class = source_profile.fetch("class").sub(/\Alinux-x64\z/, "standard").sub(/-x64\z/, "") + definition = PROFILE_DEFINITIONS.fetch(label) { fail_with("canonical source profile label drift") } + expected_source_identity = { + "id" => definition.fetch("sourceId"), + "class" => definition.fetch("sourceClass"), + "displayName" => definition.fetch("sourceDisplayName") + } + fail_with("canonical source profile identity drift") unless source_profile.slice(*expected_source_identity.keys) == expected_source_identity { - "id" => source_profile.fetch("id").sub(/\Alinux-x64-/, ""), + "id" => definition.fetch("id"), "label" => label, - "class" => normalized_class, - "displayName" => display_name, + "class" => definition.fetch("class"), + "displayName" => definition.fetch("displayName"), "status" => source_profile.fetch("status"), "minimumResources" => source_profile.fetch("minimumResources"), "capabilities" => { "guaranteed" => normalize_capabilities(source_profile.fetch("capabilities")) }, - "workload" => normalize_workload(label), + "workload" => normalize_workload(source_profile.fetch("workload"), label), "deprecation" => source_profile.fetch("deprecation").transform_values { |value| value == "" ? nil : value } } end @@ -242,22 +341,10 @@ def normalize_capabilities(capabilities) result end - def normalize_workload(label) - fail_with("source profile label not allowlisted") unless PUBLIC_WORKLOADS.key?(label) - PUBLIC_WORKLOADS.fetch(label) - end - - def contains_forbidden_detail?(value) - case value - when Hash - value.any? { |key, child| BANNED_PUBLIC_KEYS.include?(key) || contains_forbidden_detail?(key) || contains_forbidden_detail?(child) } - when Array - value.any? { |child| contains_forbidden_detail?(child) } - when String - value.match?(BANNED_PUBLIC_VALUE) - else - false - end + def normalize_workload(workload, label) + normalization = SOURCE_WORKLOAD_NORMALIZATIONS.fetch(label) { fail_with("source profile label not allowlisted") } + fail_with("canonical source workload drift") unless workload == normalization.fetch("source") + normalization.fetch("public") end def fail_with(message) diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index 817e71a..f0e0b5b 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -62,7 +62,7 @@ def test_public_validator_rejects_forbidden_implementation_values catalog = JSON.parse(File.read(File.join(candidate, "runner-profiles.json"))) catalog.fetch("profiles").last.fetch("workload").fetch("recommended") << "Kubernetes-backed builds" write_catalog(candidate, catalog) - assert_validator_failure(candidate, "runtime or provider detail in public catalog") + assert_validator_failure(candidate, "public workload semantics drift") end end @@ -84,6 +84,48 @@ def test_trusted_validator_rejects_stale_source_semantics end end + def test_initial_pull_request_fails_without_trusted_base_workflow + Dir.mktmpdir do |base_root| + script = File.expand_path("../script/require_trusted_catalog_workflow.rb", __dir__) + stdout, stderr, status = Open3.capture3(RbConfig.ruby, script, "--base-root", base_root) + refute status.success?, stdout + assert_includes stderr, "not installed on the base commit" + end + end + + def test_bootstrap_boundary_accepts_installed_trusted_workflow + Dir.mktmpdir do |base_root| + workflow = File.join(base_root, ".github/workflows/runner-catalog-trusted.yml") + FileUtils.mkdir_p(File.dirname(workflow)) + File.write(workflow, "name: trusted\n") + script = File.expand_path("../script/require_trusted_catalog_workflow.rb", __dir__) + stdout, stderr, status = Open3.capture3(RbConfig.ruby, script, "--base-root", base_root) + assert status.success?, "#{stdout}\n#{stderr}" + end + end + + def test_trusted_validator_rejects_source_workload_drift + with_source_candidate do |candidate, source| + source_file = File.join(source, SOURCE_PATH) + source_catalog = YAML.safe_load(File.read(source_file), aliases: false) + source_catalog.fetch("profiles").last.fetch("workload").fetch("recommended")[0] = "provider-backed compilation" + File.write(source_file, YAML.dump(source_catalog)) + update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) + assert_validator_failure(candidate, "canonical source workload drift", source) + end + end + + def test_trusted_validator_rejects_provider_specific_source_class + with_source_candidate do |candidate, source| + source_file = File.join(source, SOURCE_PATH) + source_catalog = YAML.safe_load(File.read(source_file), aliases: false) + source_catalog.fetch("profiles").last["class"] = "aws-x64" + File.write(source_file, YAML.dump(source_catalog)) + update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) + assert_validator_failure(candidate, "canonical source profile identity drift", source) + end + end + private def with_candidate @@ -112,9 +154,55 @@ def source_fixture source.fetch("metadata").delete("provenance") source.fetch("policy").delete("selection") source.fetch("profiles").each do |profile| - profile["id"] = profile.fetch("id") - profile["class"] = profile.fetch("class") + label = profile.fetch("label") + profile["id"] = { + "akua-x64-ci-v2" => "linux-x64-standard-v2", + "akua-docker-ci-v2" => "linux-x64-docker-v2", + "akua-heavy-ci-v2" => "linux-x64-heavy-v2" + }.fetch(label) + profile["class"] = { + "akua-x64-ci-v2" => "linux-x64", + "akua-docker-ci-v2" => "docker-x64", + "akua-heavy-ci-v2" => "heavy-x64" + }.fetch(label) + profile["displayName"] = { + "akua-x64-ci-v2" => "Linux x64 standard", + "akua-docker-ci-v2" => "Linux x64 Docker", + "akua-heavy-ci-v2" => "Linux x64 heavy" + }.fetch(label) profile["capabilities"] = profile.dig("capabilities", "guaranteed") + profile["workload"] = { + "akua-x64-ci-v2" => { + "recommended" => [ + "linting, formatting, unit tests and ordinary compilation", + "jobs that do not start containers or require large local caches" + ], + "exclusions" => [ + "Docker, Buildx and GitHub Actions service containers", + "nested virtualization, KVM and architecture-specific non-x64 builds" + ] + }, + "akua-docker-ci-v2" => { + "recommended" => [ + "Docker and Buildx image builds", + "integration tests using Docker or GitHub Actions service containers" + ], + "exclusions" => [ + "nested virtualization, KVM and architecture-specific non-x64 builds", + "workloads declaring resources above this profile; use the heavy profile" + ] + }, + "akua-heavy-ci-v2" => { + "recommended" => [ + "memory-heavy compilation, packaging and browser or integration suites", + "Docker jobs whose declared requirements exceed the Docker profile" + ], + "exclusions" => [ + "nested virtualization, KVM and architecture-specific non-x64 builds", + "workloads requiring more than the stated minimum resource contract" + ] + } + }.fetch(label) end source end From 3e005e4757fc8c65c981b1a1fe8e009b8552b86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:24:58 +0200 Subject: [PATCH 07/12] no-mistakes(review): Converted catalog change to base-owned bootstrap validation --- .github/workflows/runner-catalog-trusted.yml | 24 +- .github/workflows/runner-catalog.yml | 31 +- AGENTS.md | 8 - README.md | 20 +- RUNNERS.md | 116 ------ runner-catalog-manifest.json | 160 -------- runner-profiles.json | 151 ------- runner-profiles.yaml | 115 ------ script/require_trusted_catalog_workflow.rb | 16 - script/validate_runner_catalog.rb | 15 +- test/runner_catalog_test.rb | 410 ++++++++++++------- 11 files changed, 312 insertions(+), 754 deletions(-) delete mode 100644 RUNNERS.md delete mode 100644 runner-catalog-manifest.json delete mode 100644 runner-profiles.json delete mode 100644 runner-profiles.yaml delete mode 100644 script/require_trusted_catalog_workflow.rb diff --git a/.github/workflows/runner-catalog-trusted.yml b/.github/workflows/runner-catalog-trusted.yml index 27d6be8..101cdf7 100644 --- a/.github/workflows/runner-catalog-trusted.yml +++ b/.github/workflows/runner-catalog-trusted.yml @@ -8,37 +8,41 @@ on: - runner-profiles.json - runner-profiles.yaml - script/validate_runner_catalog.rb - - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml push: branches: [main] paths: - - RUNNERS.md - - runner-catalog-manifest.json - - runner-profiles.json - - runner-profiles.yaml - script/validate_runner_catalog.rb - - .github/workflows/runner-catalog.yml + - test/runner_catalog_test.rb - .github/workflows/runner-catalog-trusted.yml permissions: contents: read jobs: + bootstrap: + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Exercise trusted validator contract + run: ruby test/runner_catalog_test.rb + verify: + if: github.event_name == 'pull_request_target' runs-on: ubuntu-24.04 steps: - - name: Checkout trusted validator + - name: Checkout trusted validator from base uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.base.sha || github.sha }} + ref: ${{ github.event.pull_request.base.sha }} path: .trusted persist-credentials: false - name: Checkout candidate catalog uses: actions/checkout@v6 with: - repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} - ref: ${{ github.event.pull_request.head.sha || github.sha }} + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} path: .candidate persist-credentials: false - name: Resolve canonical source revision diff --git a/.github/workflows/runner-catalog.yml b/.github/workflows/runner-catalog.yml index c3bc6c4..94a1a1a 100644 --- a/.github/workflows/runner-catalog.yml +++ b/.github/workflows/runner-catalog.yml @@ -1,27 +1,27 @@ -name: Runner catalog publication +name: Runner catalog validator bootstrap on: pull_request: paths: - - RUNNERS.md - - runner-catalog-manifest.json - - runner-profiles.json - - runner-profiles.yaml - script/validate_runner_catalog.rb - test/runner_catalog_test.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml - push: - branches: [main] - paths: - RUNNERS.md - runner-catalog-manifest.json - runner-profiles.json - runner-profiles.yaml + push: + branches: [main] + paths: - script/validate_runner_catalog.rb - test/runner_catalog_test.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml + - RUNNERS.md + - runner-catalog-manifest.json + - runner-profiles.json + - runner-profiles.yaml permissions: contents: read @@ -31,15 +31,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - - name: Checkout base commit for bootstrap boundary - if: github.event_name == 'pull_request' - uses: actions/checkout@v6 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: .base - persist-credentials: false - - name: Require trusted verifier on base commit - if: github.event_name == 'pull_request' - run: ruby script/require_trusted_catalog_workflow.rb --base-root .base - - name: Validate public catalog contract + - name: Exercise trusted validator contract + run: ruby test/runner_catalog_test.rb + - name: Validate published catalog when present + if: hashFiles('runner-profiles.json') != '' run: ruby script/validate_runner_catalog.rb diff --git a/AGENTS.md b/AGENTS.md index 0fa0bf0..72b8003 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,14 +3,6 @@ This repository maintains the public GitHub organization profile for `akua-dev`. The rendered profile content lives in `profile/README.md`. -## Runner profile contract - -Use `RUNNERS.md` to select stable GitHub Actions labels. Agents should express -required capabilities and resources, never ARC, Firecracker, Kata, Kubernetes, -cloud-provider or node implementation details. `runner-profiles.yaml` and -`runner-profiles.json` are generated from `akua-dev/gitops`; do not edit them or -`RUNNERS.md` directly. - ## Profile README guidance - Keep `profile/README.md` closely aligned with a shorter version of the diff --git a/README.md b/README.md index 45523c5..fba4147 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,13 @@ This repository maintains the public GitHub organization profile for `akua-dev`. The rendered organization profile lives in [profile/README.md](profile/README.md). -The organization-wide, provider-neutral GitHub Actions runner contract is -published in [RUNNERS.md](RUNNERS.md), with machine-readable -[YAML](runner-profiles.yaml) and [JSON](runner-profiles.json) catalogs. Their -canonical source and generator live in the private `akua-dev/gitops` -repository; provenance is embedded in every generated artifact. +This change installs the base-owned, secret-free validation infrastructure for +the provider-neutral runner catalog. It intentionally publishes no runner +catalog yet. -Publication is fail-closed until the trusted provenance workflow is already -installed on the base branch. The first catalog pull request therefore requires -a separately merged bootstrap of -[runner-catalog-trusted.yml](.github/workflows/runner-catalog-trusted.yml); -the ordinary pull-request workflow has no private-source credentials and cannot -replace that boundary. +After this bootstrap merges to `main`, create the follow-up catalog publication +change from that merge commit. That change adds `RUNNERS.md`, +`runner-profiles.yaml`, `runner-profiles.json`, and +`runner-catalog-manifest.json`; its pull request is then checked by the +base-owned [`runner-catalog-trusted.yml`](.github/workflows/runner-catalog-trusted.yml) +workflow against the private `akua-dev/gitops` source. diff --git a/RUNNERS.md b/RUNNERS.md deleted file mode 100644 index 04a29e0..0000000 --- a/RUNNERS.md +++ /dev/null @@ -1,116 +0,0 @@ -# Akua GitHub Actions runner profiles - -This is the provider-neutral contract for Akua's stable self-hosted runner -labels. Workflows select guaranteed capabilities and resources; the -implementation behind a label may change without requiring repository edits. - -| Label | Profile | Minimum CPU | Minimum memory | Minimum usable disk | Guaranteed capabilities | Status | Deprecation | -| --- | --- | ---: | ---: | ---: | --- | --- | --- | -| `akua-x64-ci-v2` | Standard | 2 vCPU | 4096 MiB | 10240 MiB | ordinary build and test tooling | active | not deprecated | -| `akua-docker-ci-v2` | Docker | 4 vCPU | 6144 MiB | 15360 MiB | Docker, Buildx, service containers, privileged containers, ordinary build and test tooling | active | not deprecated | -| `akua-heavy-ci-v2` | Heavy | 4 vCPU | 7168 MiB | 20480 MiB | Docker, Buildx, service containers, privileged containers, ordinary build and test tooling | active | not deprecated | - -Capacity is shared across these labels and currently capped at **4 concurrent -jobs**. This is a safety limit, not a queue-start SLO or a reservation per -label. - -## Selection rule - -A profile is a safe match only when every declared resource is no greater than -that profile's guaranteed minimum and every required capability is guaranteed. -Select the least capable matching profile in the order shown above. If no -catalog profile matches, use an external runner or reduce the requirements; -never choose an under-provisioned profile. - -1. Use `akua-x64-ci-v2` for ordinary work without containers when its resource - requirements fit the Standard guarantees. -2. Use `akua-docker-ci-v2` for Docker, Buildx or service containers, and for - other work whose requirements exceed Standard but fit the Docker guarantees. -3. Use `akua-heavy-ci-v2` only when requirements exceed Docker but fit all Heavy - guarantees: **4 vCPU, 7168 MiB memory and 20480 MiB usable disk**. A known - heavy suite must also be checked against those bounds. Requirements above - Heavy are explicitly unsupported by this catalog and require an external - runner or reduced requirements. - -If a job relies on a resource size, declare it in job-level environment -variables so policy can verify the profile: - -```yaml -env: - AKUA_CI_REQUIRED_VCPU: "4" - AKUA_CI_REQUIRED_MEMORY_MIB: "7000" - AKUA_CI_REQUIRED_DISK_MIB: "18000" -``` - -## `akua-x64-ci-v2` - -Use for: - -- linting, formatting, unit tests and ordinary compilation -- jobs that do not start containers or require large local caches - -Do not use for: - -- Docker, Buildx and service containers -- workloads whose requirements exceed the Standard guarantees - -## `akua-docker-ci-v2` - -Use for: - -- Docker and Buildx image builds -- integration tests using Docker or service containers - -Do not use for: - -- workloads whose requirements exceed the Docker guarantees; use Heavy only - when all Heavy bounds fit - -## `akua-heavy-ci-v2` - -Use for: - -- memory-heavy compilation, packaging and browser or integration suites that - fit the Heavy guarantees -- Docker jobs whose declared requirements exceed Docker but fit Heavy - -Do not use for: - -- workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable - disk; use an external runner or reduce requirements - -## Versioning and deprecation - -The catalog contract is `2.0.0`. Additive guarantees may increment the catalog -minor version. Reduced guarantees or removed capabilities require a new -workflow label. Deprecated profiles publish announcement, sunset and -replacement metadata before retirement. - -Machine-readable forms: [`runner-profiles.yaml`](runner-profiles.yaml) and -[`runner-profiles.json`](runner-profiles.json). - - diff --git a/runner-catalog-manifest.json b/runner-catalog-manifest.json deleted file mode 100644 index 976eb7c..0000000 --- a/runner-catalog-manifest.json +++ /dev/null @@ -1,160 +0,0 @@ -{ - "manifestVersion": 1, - "source": { - "repository": "akua-dev/gitops", - "path": "clusters/agentos/runner-platform/profiles.yaml", - "revision": "ca39219715c3b6723452d8be79689e73470b5810", - "sha256": "5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598" - }, - "catalog": { - "apiVersion": "runners.akua.dev/v1alpha1", - "kind": "RunnerProfileCatalog", - "metadata": { - "name": "akua-ci-catalog", - "contractVersion": "2.0.0", - "documentation": "https://github.com/akua-dev/.github/blob/main/RUNNERS.md", - "provenance": { - "repository": "akua-dev/gitops", - "path": "clusters/agentos/runner-platform/profiles.yaml", - "revision": "ca39219715c3b6723452d8be79689e73470b5810", - "sha256": "5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598" - } - }, - "capacity": { - "scope": "organization", - "allocation": "shared", - "maxConcurrentJobs": 4, - "queueSlo": null, - "notes": [ - "Capacity is shared by all three profiles; a profile label does not reserve a private slot.", - "Four concurrent jobs are the current safe contract. Six was only a short load experiment." - ] - }, - "policy": { - "requirementEnvironment": { - "cpu": "AKUA_CI_REQUIRED_VCPU", - "memoryMiB": "AKUA_CI_REQUIRED_MEMORY_MIB", - "diskMiB": "AKUA_CI_REQUIRED_DISK_MIB" - }, - "selection": { - "safeMatch": { - "resources": "required-at-most-guaranteed-minimum", - "capabilities": "required-subset-of-guaranteed" - }, - "order": [ - "akua-x64-ci-v2", - "akua-docker-ci-v2", - "akua-heavy-ci-v2" - ], - "noMatch": "external-runner-or-reduce-requirements" - } - }, - "profiles": [ - { - "id": "standard-v2", - "label": "akua-x64-ci-v2", - "class": "standard", - "displayName": "Standard", - "status": "active", - "minimumResources": { - "vcpu": 2, - "memoryMiB": 4096, - "usableDiskMiB": 10240 - }, - "capabilities": { - "guaranteed": [ - "ordinary build and test tooling" - ] - }, - "workload": { - "recommended": [ - "linting, formatting, unit tests and ordinary compilation", - "jobs that do not start containers or require large local caches" - ], - "exclusions": [ - "Docker, Buildx and service containers", - "workloads whose requirements exceed the Standard guarantees" - ] - }, - "deprecation": { - "deprecated": false, - "announcedAt": null, - "sunsetAt": null, - "replacementLabel": null - } - }, - { - "id": "docker-v2", - "label": "akua-docker-ci-v2", - "class": "docker", - "displayName": "Docker", - "status": "active", - "minimumResources": { - "vcpu": 4, - "memoryMiB": 6144, - "usableDiskMiB": 15360 - }, - "capabilities": { - "guaranteed": [ - "Docker", - "Buildx", - "service containers", - "privileged containers", - "ordinary build and test tooling" - ] - }, - "workload": { - "recommended": [ - "Docker and Buildx image builds", - "integration tests using Docker or service containers" - ], - "exclusions": [ - "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" - ] - }, - "deprecation": { - "deprecated": false, - "announcedAt": null, - "sunsetAt": null, - "replacementLabel": null - } - }, - { - "id": "heavy-v2", - "label": "akua-heavy-ci-v2", - "class": "heavy", - "displayName": "Heavy", - "status": "active", - "minimumResources": { - "vcpu": 4, - "memoryMiB": 7168, - "usableDiskMiB": 20480 - }, - "capabilities": { - "guaranteed": [ - "Docker", - "Buildx", - "service containers", - "privileged containers", - "ordinary build and test tooling" - ] - }, - "workload": { - "recommended": [ - "memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees", - "Docker jobs whose declared requirements exceed Docker but fit Heavy" - ], - "exclusions": [ - "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" - ] - }, - "deprecation": { - "deprecated": false, - "announcedAt": null, - "sunsetAt": null, - "replacementLabel": null - } - } - ] - } -} diff --git a/runner-profiles.json b/runner-profiles.json deleted file mode 100644 index bad3d2c..0000000 --- a/runner-profiles.json +++ /dev/null @@ -1,151 +0,0 @@ -{ - "apiVersion": "runners.akua.dev/v1alpha1", - "kind": "RunnerProfileCatalog", - "metadata": { - "name": "akua-ci-catalog", - "contractVersion": "2.0.0", - "documentation": "https://github.com/akua-dev/.github/blob/main/RUNNERS.md", - "provenance": { - "repository": "akua-dev/gitops", - "path": "clusters/agentos/runner-platform/profiles.yaml", - "revision": "ca39219715c3b6723452d8be79689e73470b5810", - "sha256": "5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598" - } - }, - "capacity": { - "scope": "organization", - "allocation": "shared", - "maxConcurrentJobs": 4, - "queueSlo": null, - "notes": [ - "Capacity is shared by all three profiles; a profile label does not reserve a private slot.", - "Four concurrent jobs are the current safe contract. Six was only a short load experiment." - ] - }, - "policy": { - "requirementEnvironment": { - "cpu": "AKUA_CI_REQUIRED_VCPU", - "memoryMiB": "AKUA_CI_REQUIRED_MEMORY_MIB", - "diskMiB": "AKUA_CI_REQUIRED_DISK_MIB" - }, - "selection": { - "safeMatch": { - "resources": "required-at-most-guaranteed-minimum", - "capabilities": "required-subset-of-guaranteed" - }, - "order": [ - "akua-x64-ci-v2", - "akua-docker-ci-v2", - "akua-heavy-ci-v2" - ], - "noMatch": "external-runner-or-reduce-requirements" - } - }, - "profiles": [ - { - "id": "standard-v2", - "label": "akua-x64-ci-v2", - "class": "standard", - "displayName": "Standard", - "status": "active", - "minimumResources": { - "vcpu": 2, - "memoryMiB": 4096, - "usableDiskMiB": 10240 - }, - "capabilities": { - "guaranteed": [ - "ordinary build and test tooling" - ] - }, - "workload": { - "recommended": [ - "linting, formatting, unit tests and ordinary compilation", - "jobs that do not start containers or require large local caches" - ], - "exclusions": [ - "Docker, Buildx and service containers", - "workloads whose requirements exceed the Standard guarantees" - ] - }, - "deprecation": { - "deprecated": false, - "announcedAt": null, - "sunsetAt": null, - "replacementLabel": null - } - }, - { - "id": "docker-v2", - "label": "akua-docker-ci-v2", - "class": "docker", - "displayName": "Docker", - "status": "active", - "minimumResources": { - "vcpu": 4, - "memoryMiB": 6144, - "usableDiskMiB": 15360 - }, - "capabilities": { - "guaranteed": [ - "Docker", - "Buildx", - "service containers", - "privileged containers", - "ordinary build and test tooling" - ] - }, - "workload": { - "recommended": [ - "Docker and Buildx image builds", - "integration tests using Docker or service containers" - ], - "exclusions": [ - "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" - ] - }, - "deprecation": { - "deprecated": false, - "announcedAt": null, - "sunsetAt": null, - "replacementLabel": null - } - }, - { - "id": "heavy-v2", - "label": "akua-heavy-ci-v2", - "class": "heavy", - "displayName": "Heavy", - "status": "active", - "minimumResources": { - "vcpu": 4, - "memoryMiB": 7168, - "usableDiskMiB": 20480 - }, - "capabilities": { - "guaranteed": [ - "Docker", - "Buildx", - "service containers", - "privileged containers", - "ordinary build and test tooling" - ] - }, - "workload": { - "recommended": [ - "memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees", - "Docker jobs whose declared requirements exceed Docker but fit Heavy" - ], - "exclusions": [ - "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" - ] - }, - "deprecation": { - "deprecated": false, - "announcedAt": null, - "sunsetAt": null, - "replacementLabel": null - } - } - ] -} diff --git a/runner-profiles.yaml b/runner-profiles.yaml deleted file mode 100644 index aea0b2f..0000000 --- a/runner-profiles.yaml +++ /dev/null @@ -1,115 +0,0 @@ ---- -apiVersion: runners.akua.dev/v1alpha1 -kind: RunnerProfileCatalog -metadata: - name: akua-ci-catalog - contractVersion: 2.0.0 - documentation: https://github.com/akua-dev/.github/blob/main/RUNNERS.md - provenance: - repository: akua-dev/gitops - path: clusters/agentos/runner-platform/profiles.yaml - revision: ca39219715c3b6723452d8be79689e73470b5810 - sha256: 5d3c88e7fa34d77209463bd39bcbad592a7d2feda751199add358d5f109bc598 -capacity: - scope: organization - allocation: shared - maxConcurrentJobs: 4 - queueSlo: null - notes: - - Capacity is shared by all three profiles; a profile label does not reserve a private - slot. - - Four concurrent jobs are the current safe contract. Six was only a short load - experiment. -policy: - requirementEnvironment: - cpu: AKUA_CI_REQUIRED_VCPU - memoryMiB: AKUA_CI_REQUIRED_MEMORY_MIB - diskMiB: AKUA_CI_REQUIRED_DISK_MIB - selection: - safeMatch: - resources: required-at-most-guaranteed-minimum - capabilities: required-subset-of-guaranteed - order: - - akua-x64-ci-v2 - - akua-docker-ci-v2 - - akua-heavy-ci-v2 - noMatch: external-runner-or-reduce-requirements -profiles: -- id: standard-v2 - label: akua-x64-ci-v2 - class: standard - displayName: Standard - status: active - minimumResources: - vcpu: 2 - memoryMiB: 4096 - usableDiskMiB: 10240 - capabilities: - guaranteed: - - ordinary build and test tooling - workload: - recommended: - - linting, formatting, unit tests and ordinary compilation - - jobs that do not start containers or require large local caches - exclusions: - - Docker, Buildx and service containers - - workloads whose requirements exceed the Standard guarantees - deprecation: - deprecated: false - announcedAt: null - sunsetAt: null - replacementLabel: null -- id: docker-v2 - label: akua-docker-ci-v2 - class: docker - displayName: Docker - status: active - minimumResources: - vcpu: 4 - memoryMiB: 6144 - usableDiskMiB: 15360 - capabilities: - guaranteed: - - Docker - - Buildx - - service containers - - privileged containers - - ordinary build and test tooling - workload: - recommended: - - Docker and Buildx image builds - - integration tests using Docker or service containers - exclusions: - - workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit - deprecation: - deprecated: false - announcedAt: null - sunsetAt: null - replacementLabel: null -- id: heavy-v2 - label: akua-heavy-ci-v2 - class: heavy - displayName: Heavy - status: active - minimumResources: - vcpu: 4 - memoryMiB: 7168 - usableDiskMiB: 20480 - capabilities: - guaranteed: - - Docker - - Buildx - - service containers - - privileged containers - - ordinary build and test tooling - workload: - recommended: - - memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees - - Docker jobs whose declared requirements exceed Docker but fit Heavy - exclusions: - - workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements - deprecation: - deprecated: false - announcedAt: null - sunsetAt: null - replacementLabel: null diff --git a/script/require_trusted_catalog_workflow.rb b/script/require_trusted_catalog_workflow.rb deleted file mode 100644 index 66ce7d5..0000000 --- a/script/require_trusted_catalog_workflow.rb +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env ruby - -require "optparse" - -TRUSTED_WORKFLOW = ".github/workflows/runner-catalog-trusted.yml" - -options = { base_root: "." } -OptionParser.new do |parser| - parser.on("--base-root PATH") { |path| options[:base_root] = path } -end.parse! - -workflow = File.join(options.fetch(:base_root), TRUSTED_WORKFLOW) -unless File.file?(workflow) - warn "trusted runner catalog workflow is not installed on the base commit" - exit 1 -end diff --git a/script/validate_runner_catalog.rb b/script/validate_runner_catalog.rb index 71910ce..d4f8819 100644 --- a/script/validate_runner_catalog.rb +++ b/script/validate_runner_catalog.rb @@ -15,7 +15,8 @@ class RunnerCatalogValidator "docker" => "Docker", "buildx" => "Buildx", "serviceContainers" => "service containers", - "privilegedContainers" => "privileged containers" + "privilegedContainers" => "privileged containers", + "ordinaryBuildAndTestTooling" => BASELINE_CAPABILITY }.freeze PUBLIC_CAPABILITIES = (CAPABILITY_NAMES.values + [BASELINE_CAPABILITY]).freeze PROFILE_DEFINITIONS = { @@ -155,6 +156,7 @@ def load_catalog fail_with("manifest version drift") unless manifest.fetch("manifestVersion") == 1 fail_with("manifest provenance drift") unless manifest.fetch("source") == json.dig("metadata", "provenance") fail_with("manifest catalog drift") unless manifest.fetch("catalog") == json + fail_with("catalog schema drift") unless json.keys.sort == %w[apiVersion capacity kind metadata policy profiles] json end @@ -220,6 +222,7 @@ def validate_markdown!(catalog) contract_match = markdown.match(//m) fail_with("missing structured catalog contract") unless contract_match markdown_contract = YAML.safe_load(contract_match[1], aliases: false) + fail_with("README contract schema drift") unless markdown_contract.keys.sort == %w[capacity contractVersion provenance selection] fail_with("README contract version drift") unless markdown_contract.fetch("contractVersion") == catalog.dig("metadata", "contractVersion") fail_with("README capacity drift") unless markdown_contract.fetch("capacity") == catalog.fetch("capacity") fail_with("README selection drift") unless markdown_contract.fetch("selection") == catalog.dig("policy", "selection") @@ -331,10 +334,12 @@ def normalize_profile(source_profile) end def normalize_capabilities(capabilities) - result = if capabilities.is_a?(Array) - capabilities.dup - else - CAPABILITY_NAMES.filter_map { |key, name| name if capabilities.fetch(key, false) } + fail_with("source capability schema drift") unless capabilities.is_a?(Hash) + fail_with("source capability schema drift") unless capabilities.keys.all? { |key| CAPABILITY_NAMES.key?(key) } + result = CAPABILITY_NAMES.each_with_object([]) do |(key, name), values| + value = capabilities.fetch(key, false) + fail_with("source capability schema drift") unless value == true || value == false + values << name if value end fail_with("source capability not allowlisted") unless result.all? { |capability| PUBLIC_CAPABILITIES.include?(capability) } fail_with("source missing baseline capability") unless result.include?(BASELINE_CAPABILITY) diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index f0e0b5b..f3c4e41 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -10,97 +10,144 @@ class RunnerCatalogTest < Minitest::Test SCRIPT = File.expand_path("../script/validate_runner_catalog.rb", __dir__) SOURCE_PATH = "clusters/agentos/runner-platform/profiles.yaml" + DOCUMENTATION = "https://github.com/akua-dev/.github/blob/main/RUNNERS.md" + BASELINE = "ordinary build and test tooling" + REVISION = "a" * 40 + SHA256 = "0" * 64 + PROVENANCE = { + "repository" => "akua-dev/gitops", + "path" => SOURCE_PATH, + "revision" => REVISION, + "sha256" => SHA256 + }.freeze + CAPACITY = { + "scope" => "organization", + "allocation" => "shared", + "maxConcurrentJobs" => 4, + "queueSlo" => nil, + "notes" => [ + "Capacity is shared by all three profiles; a profile label does not reserve a private slot.", + "Four concurrent jobs are the current safe contract. Six was only a short load experiment." + ] + }.freeze + SELECTION = { + "safeMatch" => { + "resources" => "required-at-most-guaranteed-minimum", + "capabilities" => "required-subset-of-guaranteed" + }, + "order" => %w[akua-x64-ci-v2 akua-docker-ci-v2 akua-heavy-ci-v2], + "noMatch" => "external-runner-or-reduce-requirements" + }.freeze + REQUIREMENT_ENVIRONMENT = { + "cpu" => "AKUA_CI_REQUIRED_VCPU", + "memoryMiB" => "AKUA_CI_REQUIRED_MEMORY_MIB", + "diskMiB" => "AKUA_CI_REQUIRED_DISK_MIB" + }.freeze + SOURCE_IDENTITIES = { + "akua-x64-ci-v2" => ["linux-x64-standard-v2", "linux-x64", "Linux x64 standard", "standard-v2", "standard", "Standard"], + "akua-docker-ci-v2" => ["linux-x64-docker-v2", "docker-x64", "Linux x64 Docker", "docker-v2", "docker", "Docker"], + "akua-heavy-ci-v2" => ["linux-x64-heavy-v2", "heavy-x64", "Linux x64 heavy", "heavy-v2", "heavy", "Heavy"] + }.freeze + SOURCE_WORKLOADS = { + "akua-x64-ci-v2" => { + "recommended" => [ + "linting, formatting, unit tests and ordinary compilation", + "jobs that do not start containers or require large local caches" + ], + "exclusions" => [ + "Docker, Buildx and GitHub Actions service containers", + "nested virtualization, KVM and architecture-specific non-x64 builds" + ] + }, + "akua-docker-ci-v2" => { + "recommended" => [ + "Docker and Buildx image builds", + "integration tests using Docker or GitHub Actions service containers" + ], + "exclusions" => [ + "nested virtualization, KVM and architecture-specific non-x64 builds", + "workloads declaring resources above this profile; use the heavy profile" + ] + }, + "akua-heavy-ci-v2" => { + "recommended" => [ + "memory-heavy compilation, packaging and browser or integration suites", + "Docker jobs whose declared requirements exceed the Docker profile" + ], + "exclusions" => [ + "nested virtualization, KVM and architecture-specific non-x64 builds", + "workloads requiring more than the stated minimum resource contract" + ] + } + }.freeze - def setup - @root = File.expand_path("..", __dir__) - @catalog = JSON.parse(File.read(File.join(@root, "runner-profiles.json"))) - end - - def test_public_validator_accepts_current_contract - assert_validator_success(@root) + def test_public_validator_accepts_fixture_contract + with_candidate { |candidate| assert_validator_success(candidate) } end def test_public_validator_rejects_stale_markdown with_candidate do |candidate| - markdown_path = File.join(candidate, "RUNNERS.md") - markdown = File.read(markdown_path).sub("7168 MiB", "7169 MiB") - File.write(markdown_path, markdown) + path = File.join(candidate, "RUNNERS.md") + File.write(path, File.read(path).sub("7168 MiB", "7169 MiB")) assert_validator_failure(candidate, "README profile semantics drift") end end - def test_public_validator_rejects_stale_manifest + def test_public_validator_rejects_extra_markdown_contract_keys with_candidate do |candidate| - manifest_path = File.join(candidate, "runner-catalog-manifest.json") - manifest = JSON.parse(File.read(manifest_path)) - manifest.fetch("catalog").fetch("profiles").last.fetch("minimumResources")["vcpu"] = 5 - File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") - assert_validator_failure(candidate, "manifest catalog drift") + path = File.join(candidate, "RUNNERS.md") + File.write(path, File.read(path).sub("contractVersion: 2.0.0\n", "contractVersion: 2.0.0\nprovider: example\n")) + assert_validator_failure(candidate, "README contract schema drift") end end - def test_trusted_validator_rejects_source_overclaim_without_explicit_baseline - with_source_candidate do |candidate, source| - source_file = File.join(source, SOURCE_PATH) - source_catalog = YAML.safe_load(File.read(source_file), aliases: false) - source_catalog.fetch("profiles").last.fetch("capabilities").delete("ordinary build and test tooling") - File.write(source_file, YAML.dump(source_catalog)) - update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) - assert_validator_failure(candidate, "source missing baseline capability", source) + def test_public_validator_rejects_stale_manifest + with_candidate do |candidate| + path = File.join(candidate, "runner-catalog-manifest.json") + manifest = JSON.parse(File.read(path)) + manifest.fetch("catalog").fetch("profiles").last.fetch("minimumResources")["vcpu"] = 5 + File.write(path, JSON.pretty_generate(manifest) + "\n") + assert_validator_failure(candidate, "manifest catalog drift") end end def test_public_validator_rejects_extra_provenance_fields with_candidate do |candidate| - update_provenance(candidate, @catalog.dig("metadata", "provenance", "sha256"), "provider" => "example") + catalog = read_catalog(candidate) + catalog.fetch("metadata").fetch("provenance")["provider"] = "example" + write_candidate(candidate, catalog) assert_validator_failure(candidate, "provenance schema drift") end end - def test_public_validator_rejects_forbidden_implementation_values + def test_public_validator_rejects_provider_specific_public_workload_values with_candidate do |candidate| - catalog = JSON.parse(File.read(File.join(candidate, "runner-profiles.json"))) - catalog.fetch("profiles").last.fetch("workload").fetch("recommended") << "Kubernetes-backed builds" - write_catalog(candidate, catalog) + catalog = read_catalog(candidate) + catalog.fetch("profiles").last.fetch("workload").fetch("recommended") << "provider-backed builds" + write_candidate(candidate, catalog) assert_validator_failure(candidate, "public workload semantics drift") end end - def test_trusted_validator_rejects_fabricated_provenance + def test_trusted_validator_rejects_truthy_non_boolean_capabilities with_source_candidate do |candidate, source| - update_provenance(candidate, "a" * 64) - assert_validator_failure(candidate, "canonical source hash mismatch", source) + source_file = File.join(source, SOURCE_PATH) + source_catalog = YAML.safe_load(File.read(source_file), aliases: false) + source_catalog.fetch("profiles").last.fetch("capabilities")["docker"] = "false" + File.write(source_file, YAML.dump(source_catalog)) + update_source_hash(candidate, source_file) + assert_validator_failure(candidate, "source capability schema drift", source) end end - def test_trusted_validator_rejects_stale_source_semantics + def test_trusted_validator_rejects_missing_baseline_capability with_source_candidate do |candidate, source| source_file = File.join(source, SOURCE_PATH) source_catalog = YAML.safe_load(File.read(source_file), aliases: false) - source_catalog.fetch("profiles").last.fetch("minimumResources")["vcpu"] = 5 + source_catalog.fetch("profiles").last.fetch("capabilities").delete("ordinaryBuildAndTestTooling") File.write(source_file, YAML.dump(source_catalog)) - update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) - assert_validator_failure(candidate, "canonical source normalization drift", source) - end - end - - def test_initial_pull_request_fails_without_trusted_base_workflow - Dir.mktmpdir do |base_root| - script = File.expand_path("../script/require_trusted_catalog_workflow.rb", __dir__) - stdout, stderr, status = Open3.capture3(RbConfig.ruby, script, "--base-root", base_root) - refute status.success?, stdout - assert_includes stderr, "not installed on the base commit" - end - end - - def test_bootstrap_boundary_accepts_installed_trusted_workflow - Dir.mktmpdir do |base_root| - workflow = File.join(base_root, ".github/workflows/runner-catalog-trusted.yml") - FileUtils.mkdir_p(File.dirname(workflow)) - File.write(workflow, "name: trusted\n") - script = File.expand_path("../script/require_trusted_catalog_workflow.rb", __dir__) - stdout, stderr, status = Open3.capture3(RbConfig.ruby, script, "--base-root", base_root) - assert status.success?, "#{stdout}\n#{stderr}" + update_source_hash(candidate, source_file) + assert_validator_failure(candidate, "source missing baseline capability", source) end end @@ -110,28 +157,40 @@ def test_trusted_validator_rejects_source_workload_drift source_catalog = YAML.safe_load(File.read(source_file), aliases: false) source_catalog.fetch("profiles").last.fetch("workload").fetch("recommended")[0] = "provider-backed compilation" File.write(source_file, YAML.dump(source_catalog)) - update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) + update_source_hash(candidate, source_file) assert_validator_failure(candidate, "canonical source workload drift", source) end end - def test_trusted_validator_rejects_provider_specific_source_class + def test_trusted_validator_rejects_provider_specific_source_identity with_source_candidate do |candidate, source| source_file = File.join(source, SOURCE_PATH) source_catalog = YAML.safe_load(File.read(source_file), aliases: false) source_catalog.fetch("profiles").last["class"] = "aws-x64" File.write(source_file, YAML.dump(source_catalog)) - update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) + update_source_hash(candidate, source_file) assert_validator_failure(candidate, "canonical source profile identity drift", source) end end + def test_trusted_validator_rejects_stale_source_resources + with_source_candidate do |candidate, source| + source_file = File.join(source, SOURCE_PATH) + source_catalog = YAML.safe_load(File.read(source_file), aliases: false) + source_catalog.fetch("profiles").last.fetch("minimumResources")["vcpu"] = 5 + File.write(source_file, YAML.dump(source_catalog)) + update_source_hash(candidate, source_file) + assert_validator_failure(candidate, "canonical source normalization drift", source) + end + end + private def with_candidate Dir.mktmpdir do |directory| candidate = File.join(directory, "candidate") - FileUtils.cp_r(@root, candidate) + FileUtils.mkdir_p(candidate) + write_candidate(candidate, public_catalog) yield candidate end end @@ -141,97 +200,162 @@ def with_source_candidate source = File.join(candidate, "source") source_file = File.join(source, SOURCE_PATH) FileUtils.mkdir_p(File.dirname(source_file)) - File.write(source_file, YAML.dump(source_fixture)) - update_provenance(candidate, Digest::SHA256.file(source_file).hexdigest) + File.write(source_file, YAML.dump(source_catalog)) + update_source_hash(candidate, source_file) yield candidate, source end end - def source_fixture - source = Marshal.load(Marshal.dump(@catalog)) - source.fetch("metadata").delete("name") - source.fetch("metadata").delete("documentation") - source.fetch("metadata").delete("provenance") - source.fetch("policy").delete("selection") - source.fetch("profiles").each do |profile| - label = profile.fetch("label") - profile["id"] = { - "akua-x64-ci-v2" => "linux-x64-standard-v2", - "akua-docker-ci-v2" => "linux-x64-docker-v2", - "akua-heavy-ci-v2" => "linux-x64-heavy-v2" - }.fetch(label) - profile["class"] = { - "akua-x64-ci-v2" => "linux-x64", - "akua-docker-ci-v2" => "docker-x64", - "akua-heavy-ci-v2" => "heavy-x64" - }.fetch(label) - profile["displayName"] = { - "akua-x64-ci-v2" => "Linux x64 standard", - "akua-docker-ci-v2" => "Linux x64 Docker", - "akua-heavy-ci-v2" => "Linux x64 heavy" - }.fetch(label) - profile["capabilities"] = profile.dig("capabilities", "guaranteed") - profile["workload"] = { - "akua-x64-ci-v2" => { - "recommended" => [ - "linting, formatting, unit tests and ordinary compilation", - "jobs that do not start containers or require large local caches" - ], - "exclusions" => [ - "Docker, Buildx and GitHub Actions service containers", - "nested virtualization, KVM and architecture-specific non-x64 builds" - ] - }, - "akua-docker-ci-v2" => { - "recommended" => [ - "Docker and Buildx image builds", - "integration tests using Docker or GitHub Actions service containers" - ], - "exclusions" => [ - "nested virtualization, KVM and architecture-specific non-x64 builds", - "workloads declaring resources above this profile; use the heavy profile" - ] - }, - "akua-heavy-ci-v2" => { - "recommended" => [ - "memory-heavy compilation, packaging and browser or integration suites", - "Docker jobs whose declared requirements exceed the Docker profile" - ], - "exclusions" => [ - "nested virtualization, KVM and architecture-specific non-x64 builds", - "workloads requiring more than the stated minimum resource contract" - ] - } + def public_catalog(provenance = PROVENANCE) + { + "apiVersion" => "runners.akua.dev/v1alpha1", + "kind" => "RunnerProfileCatalog", + "metadata" => { + "name" => "akua-ci-catalog", + "contractVersion" => "2.0.0", + "documentation" => DOCUMENTATION, + "provenance" => provenance + }, + "capacity" => CAPACITY, + "policy" => { + "requirementEnvironment" => REQUIREMENT_ENVIRONMENT, + "selection" => SELECTION + }, + "profiles" => public_profiles + } + end + + def public_profiles + SOURCE_IDENTITIES.map do |label, values| + source_id, source_class, source_name, id, profile_class, display_name = values + capabilities = if label == "akua-x64-ci-v2" + [BASELINE] + else + ["Docker", "Buildx", "service containers", "privileged containers", BASELINE] + end + resources = { + "akua-x64-ci-v2" => { "vcpu" => 2, "memoryMiB" => 4096, "usableDiskMiB" => 10240 }, + "akua-docker-ci-v2" => { "vcpu" => 4, "memoryMiB" => 6144, "usableDiskMiB" => 15360 }, + "akua-heavy-ci-v2" => { "vcpu" => 4, "memoryMiB" => 7168, "usableDiskMiB" => 20480 } }.fetch(label) + { + "id" => id, + "label" => label, + "class" => profile_class, + "displayName" => display_name, + "status" => "active", + "minimumResources" => resources, + "capabilities" => { "guaranteed" => capabilities }, + "workload" => public_workload(label), + "deprecation" => { "deprecated" => false, "announcedAt" => nil, "sunsetAt" => nil, "replacementLabel" => nil } + } end - source - end - - def update_provenance(candidate, sha256, extra = {}) - provenance = @catalog.fetch("metadata").fetch("provenance").merge("sha256" => sha256).merge(extra) - json_path = File.join(candidate, "runner-profiles.json") - json = JSON.parse(File.read(json_path)) - json.fetch("metadata")["provenance"] = provenance - File.write(json_path, JSON.pretty_generate(json) + "\n") - File.write(File.join(candidate, "runner-profiles.yaml"), YAML.dump(json)) - markdown_path = File.join(candidate, "RUNNERS.md") - markdown = File.read(markdown_path).sub(/sha256: [0-9a-f]+/, "sha256: #{sha256}") - File.write(markdown_path, markdown) - manifest_path = File.join(candidate, "runner-catalog-manifest.json") - manifest = JSON.parse(File.read(manifest_path)) - manifest["source"] = provenance - manifest.fetch("catalog")["metadata"]["provenance"] = provenance - File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") - end - - def write_catalog(candidate, catalog) + end + + def public_workload(label) + { + "akua-x64-ci-v2" => { + "recommended" => [ + "linting, formatting, unit tests and ordinary compilation", + "jobs that do not start containers or require large local caches" + ], + "exclusions" => [ + "Docker, Buildx and service containers", + "workloads whose requirements exceed the Standard guarantees" + ] + }, + "akua-docker-ci-v2" => { + "recommended" => [ + "Docker and Buildx image builds", + "integration tests using Docker or service containers" + ], + "exclusions" => [ + "workloads whose requirements exceed the Docker guarantees; use Heavy only when all Heavy bounds fit" + ] + }, + "akua-heavy-ci-v2" => { + "recommended" => [ + "memory-heavy compilation, packaging and browser or integration suites that fit the Heavy guarantees", + "Docker jobs whose declared requirements exceed Docker but fit Heavy" + ], + "exclusions" => [ + "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" + ] + } + }.fetch(label) + end + + def source_catalog + catalog = public_catalog + catalog.fetch("metadata").delete("name") + catalog.fetch("metadata").delete("documentation") + catalog.fetch("metadata").delete("provenance") + catalog.fetch("policy").delete("selection") + catalog["profiles"] = catalog.fetch("profiles").map do |profile| + label = profile.fetch("label") + source_id, source_class, source_name = SOURCE_IDENTITIES.fetch(label) + capability_hash = { + "docker" => profile.dig("capabilities", "guaranteed").include?("Docker"), + "buildx" => profile.dig("capabilities", "guaranteed").include?("Buildx"), + "serviceContainers" => profile.dig("capabilities", "guaranteed").include?("service containers"), + "privilegedContainers" => profile.dig("capabilities", "guaranteed").include?("privileged containers"), + "ordinaryBuildAndTestTooling" => true + } + profile.merge( + "id" => source_id, + "class" => source_class, + "displayName" => source_name, + "capabilities" => capability_hash, + "workload" => SOURCE_WORKLOADS.fetch(label) + ) + end + catalog + end + + def write_candidate(candidate, catalog) File.write(File.join(candidate, "runner-profiles.json"), JSON.pretty_generate(catalog) + "\n") File.write(File.join(candidate, "runner-profiles.yaml"), YAML.dump(catalog)) - manifest_path = File.join(candidate, "runner-catalog-manifest.json") - manifest = JSON.parse(File.read(manifest_path)) - manifest["catalog"] = catalog - manifest["source"] = catalog.dig("metadata", "provenance") - File.write(manifest_path, JSON.pretty_generate(manifest) + "\n") + manifest = { "manifestVersion" => 1, "source" => catalog.dig("metadata", "provenance"), "catalog" => catalog } + File.write(File.join(candidate, "runner-catalog-manifest.json"), JSON.pretty_generate(manifest) + "\n") + File.write(File.join(candidate, "RUNNERS.md"), markdown_for(catalog)) + end + + def markdown_for(catalog) + rows = catalog.fetch("profiles").map do |profile| + resources = profile.fetch("minimumResources") + capabilities = profile.dig("capabilities", "guaranteed").join(", ") + deprecated = profile.dig("deprecation", "deprecated") ? "deprecated" : "not deprecated" + "| `#{profile.fetch("label")}` | #{profile.fetch("displayName")} | #{resources.fetch("vcpu")} vCPU | #{resources.fetch("memoryMiB")} MiB | #{resources.fetch("usableDiskMiB")} MiB | #{capabilities} | #{profile.fetch("status")} | #{deprecated} |" + end + contract = { + "contractVersion" => catalog.dig("metadata", "contractVersion"), + "capacity" => catalog.fetch("capacity"), + "selection" => catalog.dig("policy", "selection"), + "provenance" => catalog.dig("metadata", "provenance") + } + contract_yaml = YAML.dump(contract).sub("---\n", "").lines.map { |line| " #{line}" }.join + <<~MARKDOWN + # Akua GitHub Actions runner profiles + + | Label | Profile | Minimum CPU | Minimum memory | Minimum usable disk | Guaranteed capabilities | Status | Deprecation | + | --- | --- | ---: | ---: | ---: | --- | --- | --- | + #{rows.join("\n")} + + + MARKDOWN + end + + def read_catalog(candidate) + JSON.parse(File.read(File.join(candidate, "runner-profiles.json"))) + end + + def update_source_hash(candidate, source_file) + catalog = read_catalog(candidate) + provenance = catalog.fetch("metadata").fetch("provenance").merge("sha256" => Digest::SHA256.file(source_file).hexdigest) + catalog.fetch("metadata")["provenance"] = provenance + write_candidate(candidate, catalog) end def assert_validator_success(candidate) From a3d56daedc47b00bc72ebdac7865053daff83096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:33:57 +0200 Subject: [PATCH 08/12] no-mistakes(review): Hardened Markdown semantics and derived Heavy resource bounds --- script/validate_runner_catalog.rb | 81 ++++++++++++++++++++++++++++--- test/runner_catalog_test.rb | 75 +++++++++++++++++++++++++--- 2 files changed, 142 insertions(+), 14 deletions(-) diff --git a/script/validate_runner_catalog.rb b/script/validate_runner_catalog.rb index d4f8819..ad011bf 100644 --- a/script/validate_runner_catalog.rb +++ b/script/validate_runner_catalog.rb @@ -106,7 +106,7 @@ class RunnerCatalogValidator "Docker jobs whose declared requirements exceed Docker but fit Heavy" ], "exclusions" => [ - "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" + "workloads requiring more than %{vcpu} vCPU, %{memoryMiB} MiB memory or %{usableDiskMiB} MiB usable disk; use an external runner or reduce requirements" ] } } @@ -206,7 +206,7 @@ def validate_public_contract!(catalog) fail_with("public capability schema drift") unless capabilities.keys == ["guaranteed"] fail_with("public capability vocabulary drift") unless capabilities.fetch("guaranteed").all? { |capability| PUBLIC_CAPABILITIES.include?(capability) } fail_with("missing baseline capability") unless profile.dig("capabilities", "guaranteed").include?(BASELINE_CAPABILITY) - fail_with("public workload semantics drift") unless profile.fetch("workload") == SOURCE_WORKLOAD_NORMALIZATIONS.fetch(profile.fetch("label")).fetch("public") + fail_with("public workload semantics drift") unless profile.fetch("workload") == public_workload_for(profile.fetch("label"), profile.fetch("minimumResources")) fail_with("profile status drift") unless profile.fetch("status") == "active" fail_with("profile deprecation drift") unless profile.fetch("deprecation") == { "deprecated" => false, @@ -228,9 +228,12 @@ def validate_markdown!(catalog) fail_with("README selection drift") unless markdown_contract.fetch("selection") == catalog.dig("policy", "selection") fail_with("README provenance drift") unless markdown_contract.fetch("provenance") == catalog.dig("metadata", "provenance") - header_index = markdown.lines.index { |line| line.start_with?("| Label | Profile |") } + lines = markdown.lines.map(&:chomp) + contract_matches = markdown.to_enum(:scan, //m).map { Regexp.last_match } + fail_with("README contract multiplicity drift") unless contract_matches.length == 1 + header_index = lines.index { |line| line.start_with?("| Label | Profile |") } fail_with("missing profile table") unless header_index - table_lines = markdown.lines[(header_index + 2)..].take_while { |line| line.start_with?("| `akua-") } + table_lines = lines[(header_index + 2)..].take_while { |line| line.start_with?("| `akua-") } table = table_lines.map do |line| cells = line.strip.split("|", -1)[1...-1].map(&:strip) fail_with("malformed profile table") unless cells.length == 8 @@ -258,6 +261,47 @@ def validate_markdown!(catalog) } end fail_with("README profile semantics drift") unless table == expected_table + fail_with("README document semantics drift") unless markdown_text_model(lines, header_index, table_lines, contract_matches.fetch(0)) == expected_markdown_text_model(catalog) + end + + def markdown_text_model(lines, header_index, table_lines, contract_match) + contract_start = lines.index { |line| line == "" } + table_end = header_index + 2 + table_lines.length + ignored = (contract_start..contract_end).to_a + (header_index...table_end).to_a + lines.each_with_index.reject { |_line, index| ignored.include?(index) }.map(&:first).reject(&:empty?) + end + + def expected_markdown_text_model(catalog) + heavy = catalog.fetch("profiles").find { |profile| profile.fetch("label") == "akua-heavy-ci-v2" } + heavy_resources = heavy.fetch("minimumResources") + lines = [ + "# Akua GitHub Actions runner profiles", + "This provider-neutral catalog defines the stable runner labels and their conservative guarantees.", + "Workflows select a label by declared resources and capabilities; the implementation behind a label may change without repository edits.", + "Capacity is shared across all profiles and capped at #{catalog.dig("capacity", "maxConcurrentJobs")} concurrent jobs. A label does not reserve a private slot.", + "## Selection rules", + "1. Select the first profile whose guaranteed resources meet the declared requirements and whose capabilities contain every required capability.", + "2. Use the stable label in workflow configuration; do not infer implementation details from the label.", + "3. Use `akua-heavy-ci-v2` only when requirements exceed Docker but fit Heavy: #{heavy_resources.fetch("vcpu")} vCPU, #{heavy_resources.fetch("memoryMiB")} MiB memory and #{heavy_resources.fetch("usableDiskMiB")} MiB usable disk.", + "Requirements above Heavy, or capabilities absent from every profile, require an external runner or reduced requirements.", + "Required-resource inputs are supplied through AKUA_CI_REQUIRED_VCPU, AKUA_CI_REQUIRED_MEMORY_MIB and AKUA_CI_REQUIRED_DISK_MIB.", + "## Profile guarantees" + ] + catalog.fetch("profiles").each do |profile| + workload = profile.fetch("workload") + lines << "## `#{profile.fetch("label")}`" + lines << "Recommended uses:" + lines.concat(workload.fetch("recommended").map { |item| "- #{item}" }) + lines << "Exclusions:" + lines.concat(workload.fetch("exclusions").map { |item| "- #{item}" }) + end + lines.concat([ + "## Versioning and deprecation", + "The public contract version is #{catalog.dig("metadata", "contractVersion")}. Profiles are active and not deprecated unless the structured catalog says otherwise.", + "The machine-readable catalogs and provenance manifest are the canonical serialized projections of this document." + ]) + lines end def validate_source!(catalog) @@ -320,15 +364,18 @@ def normalize_profile(source_profile) "displayName" => definition.fetch("sourceDisplayName") } fail_with("canonical source profile identity drift") unless source_profile.slice(*expected_source_identity.keys) == expected_source_identity + resources = source_profile.fetch("minimumResources") + fail_with("canonical source resource schema drift") unless resources.is_a?(Hash) && resources.keys.sort == %w[memoryMiB usableDiskMiB vcpu] + fail_with("canonical source resource schema drift") unless resources.values.all? { |value| value.is_a?(Integer) && value.positive? } { "id" => definition.fetch("id"), "label" => label, "class" => definition.fetch("class"), "displayName" => definition.fetch("displayName"), "status" => source_profile.fetch("status"), - "minimumResources" => source_profile.fetch("minimumResources"), + "minimumResources" => resources, "capabilities" => { "guaranteed" => normalize_capabilities(source_profile.fetch("capabilities")) }, - "workload" => normalize_workload(source_profile.fetch("workload"), label), + "workload" => normalize_workload(source_profile.fetch("workload"), label, resources), "deprecation" => source_profile.fetch("deprecation").transform_values { |value| value == "" ? nil : value } } end @@ -346,10 +393,28 @@ def normalize_capabilities(capabilities) result end - def normalize_workload(workload, label) + def normalize_workload(workload, label, resources) normalization = SOURCE_WORKLOAD_NORMALIZATIONS.fetch(label) { fail_with("source profile label not allowlisted") } fail_with("canonical source workload drift") unless workload == normalization.fetch("source") - normalization.fetch("public") + public_workload_for(label, resources) + end + + def public_workload_for(label, resources) + normalization = SOURCE_WORKLOAD_NORMALIZATIONS.fetch(label) { fail_with("source profile label not allowlisted") } + public_workload = normalization.fetch("public").each_with_object({}) do |(key, values), copy| + copy[key] = values.dup + end + return public_workload unless label == "akua-heavy-ci-v2" + + public_workload["exclusions"] = public_workload.fetch("exclusions").map do |exclusion| + format( + exclusion, + vcpu: resources.fetch("vcpu"), + memoryMiB: resources.fetch("memoryMiB"), + usableDiskMiB: resources.fetch("usableDiskMiB") + ) + end + public_workload end def fail_with(message) diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index f3c4e41..010fa13 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -88,7 +88,7 @@ def test_public_validator_accepts_fixture_contract def test_public_validator_rejects_stale_markdown with_candidate do |candidate| path = File.join(candidate, "RUNNERS.md") - File.write(path, File.read(path).sub("7168 MiB", "7169 MiB")) + File.write(path, File.read(path).sub("| `akua-heavy-ci-v2` | Heavy | 4 vCPU | 7168 MiB", "| `akua-heavy-ci-v2` | Heavy | 4 vCPU | 7169 MiB")) assert_validator_failure(candidate, "README profile semantics drift") end end @@ -101,6 +101,14 @@ def test_public_validator_rejects_extra_markdown_contract_keys end end + def test_public_validator_rejects_unmodeled_markdown_prose + with_candidate do |candidate| + path = File.join(candidate, "RUNNERS.md") + File.open(path, "a") { |file| file.puts("\nProvider-backed execution details") } + assert_validator_failure(candidate, "README document semantics drift") + end + end + def test_public_validator_rejects_stale_manifest with_candidate do |candidate| path = File.join(candidate, "runner-catalog-manifest.json") @@ -184,6 +192,21 @@ def test_trusted_validator_rejects_stale_source_resources end end + def test_trusted_validator_derives_heavy_markdown_bound_from_source_resources + with_source_candidate do |candidate, source| + source_file = File.join(source, SOURCE_PATH) + source_catalog = YAML.safe_load(File.read(source_file), aliases: false) + source_catalog.fetch("profiles").last.fetch("minimumResources")["vcpu"] = 5 + File.write(source_file, YAML.dump(source_catalog)) + catalog = read_catalog(candidate) + catalog.fetch("profiles").last.fetch("minimumResources")["vcpu"] = 5 + catalog.fetch("profiles").last["workload"] = public_workload("akua-heavy-ci-v2", catalog.fetch("profiles").last.fetch("minimumResources")) + write_candidate(candidate, catalog) + update_source_hash(candidate, source_file) + assert_validator_success(candidate, source) + end + end + private def with_candidate @@ -246,13 +269,13 @@ def public_profiles "status" => "active", "minimumResources" => resources, "capabilities" => { "guaranteed" => capabilities }, - "workload" => public_workload(label), + "workload" => public_workload(label, resources), "deprecation" => { "deprecated" => false, "announcedAt" => nil, "sunsetAt" => nil, "replacementLabel" => nil } } end end - def public_workload(label) + def public_workload(label, resources) { "akua-x64-ci-v2" => { "recommended" => [ @@ -279,7 +302,12 @@ def public_workload(label) "Docker jobs whose declared requirements exceed Docker but fit Heavy" ], "exclusions" => [ - "workloads requiring more than 4 vCPU, 7168 MiB memory or 20480 MiB usable disk; use an external runner or reduce requirements" + format( + "workloads requiring more than %d vCPU, %d MiB memory or %d MiB usable disk; use an external runner or reduce requirements", + vcpu: resources.fetch("vcpu"), + memoryMiB: resources.fetch("memoryMiB"), + usableDiskMiB: resources.fetch("usableDiskMiB") + ) ] } }.fetch(label) @@ -334,9 +362,42 @@ def markdown_for(catalog) "provenance" => catalog.dig("metadata", "provenance") } contract_yaml = YAML.dump(contract).sub("---\n", "").lines.map { |line| " #{line}" }.join + heavy = catalog.fetch("profiles").find { |profile| profile.fetch("label") == "akua-heavy-ci-v2" } + heavy_resources = heavy.fetch("minimumResources") + profile_text = catalog.fetch("profiles").map do |profile| + workload = profile.fetch("workload") + [ + "## `#{profile.fetch("label")}`", + "Recommended uses:", + *workload.fetch("recommended").map { |item| "- #{item}" }, + "Exclusions:", + *workload.fetch("exclusions").map { |item| "- #{item}" } + ] + end.flatten.join("\n") <<~MARKDOWN # Akua GitHub Actions runner profiles + This provider-neutral catalog defines the stable runner labels and their conservative guarantees. + Workflows select a label by declared resources and capabilities; the implementation behind a label may change without repository edits. + Capacity is shared across all profiles and capped at #{catalog.dig("capacity", "maxConcurrentJobs")} concurrent jobs. A label does not reserve a private slot. + + ## Selection rules + + 1. Select the first profile whose guaranteed resources meet the declared requirements and whose capabilities contain every required capability. + 2. Use the stable label in workflow configuration; do not infer implementation details from the label. + 3. Use `akua-heavy-ci-v2` only when requirements exceed Docker but fit Heavy: #{heavy_resources.fetch("vcpu")} vCPU, #{heavy_resources.fetch("memoryMiB")} MiB memory and #{heavy_resources.fetch("usableDiskMiB")} MiB usable disk. + Requirements above Heavy, or capabilities absent from every profile, require an external runner or reduced requirements. + Required-resource inputs are supplied through AKUA_CI_REQUIRED_VCPU, AKUA_CI_REQUIRED_MEMORY_MIB and AKUA_CI_REQUIRED_DISK_MIB. + + ## Profile guarantees + + #{profile_text} + + ## Versioning and deprecation + + The public contract version is #{catalog.dig("metadata", "contractVersion")}. Profiles are active and not deprecated unless the structured catalog says otherwise. + The machine-readable catalogs and provenance manifest are the canonical serialized projections of this document. + | Label | Profile | Minimum CPU | Minimum memory | Minimum usable disk | Guaranteed capabilities | Status | Deprecation | | --- | --- | ---: | ---: | ---: | --- | --- | --- | #{rows.join("\n")} @@ -358,8 +419,10 @@ def update_source_hash(candidate, source_file) write_candidate(candidate, catalog) end - def assert_validator_success(candidate) - stdout, stderr, status = Open3.capture3(RbConfig.ruby, SCRIPT, "--candidate-root", candidate) + def assert_validator_success(candidate, source = nil) + args = [RbConfig.ruby, SCRIPT, "--candidate-root", candidate] + args.concat(["--source-root", source]) if source + stdout, stderr, status = Open3.capture3(*args) assert status.success?, "#{stdout}\n#{stderr}" end From bf434403fd6a5419af33dfbfcc6c445bc1ebba73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:42:27 +0200 Subject: [PATCH 09/12] no-mistakes(review): Hardened Markdown schema and trusted push provenance validation --- .github/workflows/runner-catalog-trusted.yml | 46 ++++++++++++++++++++ script/validate_runner_catalog.rb | 3 ++ test/runner_catalog_test.rb | 33 ++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/.github/workflows/runner-catalog-trusted.yml b/.github/workflows/runner-catalog-trusted.yml index 101cdf7..e10391a 100644 --- a/.github/workflows/runner-catalog-trusted.yml +++ b/.github/workflows/runner-catalog-trusted.yml @@ -8,12 +8,18 @@ on: - runner-profiles.json - runner-profiles.yaml - script/validate_runner_catalog.rb + - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml push: branches: [main] paths: + - RUNNERS.md + - runner-catalog-manifest.json + - runner-profiles.json + - runner-profiles.yaml - script/validate_runner_catalog.rb - test/runner_catalog_test.rb + - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml permissions: @@ -28,6 +34,46 @@ jobs: - name: Exercise trusted validator contract run: ruby test/runner_catalog_test.rb + verify-published: + if: github.event_name == 'push' + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - name: Detect published catalog + id: publication + shell: bash + run: | + if [[ -e RUNNERS.md || -e runner-catalog-manifest.json || -e runner-profiles.json || -e runner-profiles.yaml ]]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + - name: Resolve canonical source revision + if: steps.publication.outputs.present == 'true' + id: catalog-provenance + shell: ruby {0} + run: | + require "json" + + catalog = JSON.parse(File.read("runner-profiles.json")) + provenance = catalog.fetch("metadata").fetch("provenance") + abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) + File.open(ENV.fetch("GITHUB_OUTPUT"), "a") { |output| output.puts("revision=#{provenance.fetch("revision")}") } + - name: Checkout canonical runner source + if: steps.publication.outputs.present == 'true' + uses: actions/checkout@v6 + with: + repository: akua-dev/gitops + ref: ${{ steps.catalog-provenance.outputs.revision }} + token: ${{ secrets.GITOPS_READ_TOKEN }} + path: .gitops-source + sparse-checkout: clusters/agentos/runner-platform/profiles.yaml + sparse-checkout-cone-mode: false + persist-credentials: false + - name: Validate generated catalog against canonical source + if: steps.publication.outputs.present == 'true' + run: ruby script/validate_runner_catalog.rb --candidate-root . --source-root .gitops-source + verify: if: github.event_name == 'pull_request_target' runs-on: ubuntu-24.04 diff --git a/script/validate_runner_catalog.rb b/script/validate_runner_catalog.rb index ad011bf..8771d36 100644 --- a/script/validate_runner_catalog.rb +++ b/script/validate_runner_catalog.rb @@ -129,6 +129,8 @@ class RunnerCatalogValidator "memoryMiB" => "AKUA_CI_REQUIRED_MEMORY_MIB", "diskMiB" => "AKUA_CI_REQUIRED_DISK_MIB" }.freeze + MARKDOWN_TABLE_HEADER = "| Label | Profile | Minimum CPU | Minimum memory | Minimum usable disk | Guaranteed capabilities | Status | Deprecation |".freeze + MARKDOWN_TABLE_SEPARATOR = "| --- | --- | ---: | ---: | ---: | --- | --- | --- |".freeze def initialize(candidate_root:, source_root: nil) @candidate_root = File.expand_path(candidate_root) @@ -233,6 +235,7 @@ def validate_markdown!(catalog) fail_with("README contract multiplicity drift") unless contract_matches.length == 1 header_index = lines.index { |line| line.start_with?("| Label | Profile |") } fail_with("missing profile table") unless header_index + fail_with("README table schema drift") unless lines.fetch(header_index) == MARKDOWN_TABLE_HEADER && lines.fetch(header_index + 1) == MARKDOWN_TABLE_SEPARATOR table_lines = lines[(header_index + 2)..].take_while { |line| line.start_with?("| `akua-") } table = table_lines.map do |line| cells = line.strip.split("|", -1)[1...-1].map(&:strip) diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index 010fa13..7533915 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -101,6 +101,22 @@ def test_public_validator_rejects_extra_markdown_contract_keys end end + def test_public_validator_rejects_markdown_table_header_drift + with_candidate do |candidate| + path = File.join(candidate, "RUNNERS.md") + File.write(path, File.read(path).sub("| Label | Profile | Minimum CPU |", "| Label | Profile | Backend |")) + assert_validator_failure(candidate, "README table schema drift") + end + end + + def test_public_validator_rejects_markdown_table_separator_drift + with_candidate do |candidate| + path = File.join(candidate, "RUNNERS.md") + File.write(path, File.read(path).sub("| --- | --- | ---: |", "| --- | --- | --- |")) + assert_validator_failure(candidate, "README table schema drift") + end + end + def test_public_validator_rejects_unmodeled_markdown_prose with_candidate do |candidate| path = File.join(candidate, "RUNNERS.md") @@ -207,6 +223,23 @@ def test_trusted_validator_derives_heavy_markdown_bound_from_source_resources end end + def test_trusted_workflow_verifies_published_catalogs_on_main_push + workflow = YAML.safe_load(File.read(File.expand_path("../.github/workflows/runner-catalog-trusted.yml", __dir__)), aliases: false) + trigger = workflow.fetch(true) + push_paths = trigger.fetch("push").fetch("paths") + %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml script/validate_runner_catalog.rb test/runner_catalog_test.rb .github/workflows/runner-catalog.yml .github/workflows/runner-catalog-trusted.yml].each do |path| + assert_includes push_paths, path + end + verify = workflow.fetch("jobs").fetch("verify-published") + assert_equal "github.event_name == 'push'", verify.fetch("if") + steps = verify.fetch("steps") + source_checkout = steps.find { |step| step.fetch("with", {}).fetch("repository", nil) == "akua-dev/gitops" } + refute_nil source_checkout + assert_equal "${{ secrets.GITOPS_READ_TOKEN }}", source_checkout.fetch("with").fetch("token") + validation = steps.find { |step| step.fetch("name", "") == "Validate generated catalog against canonical source" } + assert_equal "ruby script/validate_runner_catalog.rb --candidate-root . --source-root .gitops-source", validation.fetch("run") + end + private def with_candidate From e7a5c650eb9711cac4ebf2be1c879b5061d6a7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:52:26 +0200 Subject: [PATCH 10/12] no-mistakes(review): Hardened catalog lifecycle validation across bootstrap and publication --- .github/workflows/runner-catalog-trusted.yml | 42 ++++++--- .github/workflows/runner-catalog.yml | 6 ++ script/check_runner_catalog_lifecycle.rb | 59 +++++++++++++ test/runner_catalog_lifecycle_test.rb | 91 ++++++++++++++++++++ test/runner_catalog_test.rb | 8 +- 5 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 script/check_runner_catalog_lifecycle.rb create mode 100644 test/runner_catalog_lifecycle_test.rb diff --git a/.github/workflows/runner-catalog-trusted.yml b/.github/workflows/runner-catalog-trusted.yml index e10391a..8fd4408 100644 --- a/.github/workflows/runner-catalog-trusted.yml +++ b/.github/workflows/runner-catalog-trusted.yml @@ -8,6 +8,7 @@ on: - runner-profiles.json - runner-profiles.yaml - script/validate_runner_catalog.rb + - script/check_runner_catalog_lifecycle.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml push: @@ -18,7 +19,9 @@ on: - runner-profiles.json - runner-profiles.yaml - script/validate_runner_catalog.rb + - script/check_runner_catalog_lifecycle.rb - test/runner_catalog_test.rb + - test/runner_catalog_lifecycle_test.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml @@ -33,23 +36,36 @@ jobs: - uses: actions/checkout@v6 - name: Exercise trusted validator contract run: ruby test/runner_catalog_test.rb + - name: Exercise catalog lifecycle contract + run: ruby test/runner_catalog_lifecycle_test.rb verify-published: if: github.event_name == 'push' runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v6 - - name: Detect published catalog - id: publication - shell: bash + with: + fetch-depth: 0 + - name: Materialize previous main tree + env: + BEFORE: ${{ github.event.before }} run: | - if [[ -e RUNNERS.md || -e runner-catalog-manifest.json || -e runner-profiles.json || -e runner-profiles.yaml ]]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" + mkdir -p .previous + if [[ -z "$BEFORE" || "$BEFORE" == "0000000000000000000000000000000000000000" ]]; then + exit 0 fi + git cat-file -e "${BEFORE}^{commit}" + for path in RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml; do + if git cat-file -e "${BEFORE}:${path}" 2>/dev/null; then + mkdir -p ".previous/$(dirname "$path")" + git show "${BEFORE}:${path}" > ".previous/$path" + fi + done + - name: Check catalog lifecycle + id: lifecycle + run: echo "action=$(ruby script/check_runner_catalog_lifecycle.rb --previous-root .previous --current-root .)" >> "$GITHUB_OUTPUT" - name: Resolve canonical source revision - if: steps.publication.outputs.present == 'true' + if: steps.lifecycle.outputs.action == 'validate' id: catalog-provenance shell: ruby {0} run: | @@ -60,7 +76,7 @@ jobs: abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) File.open(ENV.fetch("GITHUB_OUTPUT"), "a") { |output| output.puts("revision=#{provenance.fetch("revision")}") } - name: Checkout canonical runner source - if: steps.publication.outputs.present == 'true' + if: steps.lifecycle.outputs.action == 'validate' uses: actions/checkout@v6 with: repository: akua-dev/gitops @@ -71,7 +87,7 @@ jobs: sparse-checkout-cone-mode: false persist-credentials: false - name: Validate generated catalog against canonical source - if: steps.publication.outputs.present == 'true' + if: steps.lifecycle.outputs.action == 'validate' run: ruby script/validate_runner_catalog.rb --candidate-root . --source-root .gitops-source verify: @@ -91,7 +107,11 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} path: .candidate persist-credentials: false + - name: Check catalog lifecycle + id: lifecycle + run: echo "action=$(ruby .trusted/script/check_runner_catalog_lifecycle.rb --previous-root .trusted --current-root .candidate)" >> "$GITHUB_OUTPUT" - name: Resolve canonical source revision + if: steps.lifecycle.outputs.action == 'validate' id: catalog-provenance shell: ruby {0} run: | @@ -102,6 +122,7 @@ jobs: abort "missing source revision" unless provenance.fetch("revision", "").match?(/\A[0-9a-f]{40}\z/) File.open(ENV.fetch("GITHUB_OUTPUT"), "a") { |output| output.puts("revision=#{provenance.fetch("revision")}") } - name: Checkout canonical runner source + if: steps.lifecycle.outputs.action == 'validate' uses: actions/checkout@v6 with: repository: akua-dev/gitops @@ -112,4 +133,5 @@ jobs: sparse-checkout-cone-mode: false persist-credentials: false - name: Validate generated catalog against canonical source + if: steps.lifecycle.outputs.action == 'validate' run: ruby .trusted/script/validate_runner_catalog.rb --candidate-root .candidate --source-root .gitops-source diff --git a/.github/workflows/runner-catalog.yml b/.github/workflows/runner-catalog.yml index 94a1a1a..2001066 100644 --- a/.github/workflows/runner-catalog.yml +++ b/.github/workflows/runner-catalog.yml @@ -4,7 +4,9 @@ on: pull_request: paths: - script/validate_runner_catalog.rb + - script/check_runner_catalog_lifecycle.rb - test/runner_catalog_test.rb + - test/runner_catalog_lifecycle_test.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml - RUNNERS.md @@ -15,7 +17,9 @@ on: branches: [main] paths: - script/validate_runner_catalog.rb + - script/check_runner_catalog_lifecycle.rb - test/runner_catalog_test.rb + - test/runner_catalog_lifecycle_test.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml - RUNNERS.md @@ -33,6 +37,8 @@ jobs: - uses: actions/checkout@v6 - name: Exercise trusted validator contract run: ruby test/runner_catalog_test.rb + - name: Exercise catalog lifecycle contract + run: ruby test/runner_catalog_lifecycle_test.rb - name: Validate published catalog when present if: hashFiles('runner-profiles.json') != '' run: ruby script/validate_runner_catalog.rb diff --git a/script/check_runner_catalog_lifecycle.rb b/script/check_runner_catalog_lifecycle.rb new file mode 100644 index 0000000..86da2e5 --- /dev/null +++ b/script/check_runner_catalog_lifecycle.rb @@ -0,0 +1,59 @@ +#!/usr/bin/env ruby + +require "optparse" + +class RunnerCatalogLifecycleError < StandardError; end + +class RunnerCatalogLifecycle + FILES = %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml].freeze + + def initialize(previous_root:, current_root:) + @previous_root = File.expand_path(previous_root) + @current_root = File.expand_path(current_root) + end + + def action + previous = state(@previous_root) + current = state(@current_root) + case [previous, current] + when %w[absent absent], %w[absent complete], %w[complete complete] + previous == "absent" && current == "absent" ? "skip" : "validate" + when ["partial", "absent"], ["partial", "partial"], ["partial", "complete"] + fail_with("previous catalog is partial") + when ["absent", "partial"] + fail_with("current catalog is partial") + when ["complete", "absent"], ["complete", "partial"] + fail_with("published catalog cannot be deleted or partial") + else + fail_with("unsupported catalog lifecycle transition") + end + end + + private + + def state(root) + present = FILES.count { |path| File.file?(File.join(root, path)) } + return "absent" if present.zero? + return "complete" if present == FILES.length + + "partial" + end + + def fail_with(message) + raise RunnerCatalogLifecycleError, message + end +end + +options = {} +OptionParser.new do |parser| + parser.on("--previous-root PATH") { |path| options[:previous_root] = path } + parser.on("--current-root PATH") { |path| options[:current_root] = path } +end.parse! + +begin + abort "missing lifecycle roots" unless options.keys.sort == %i[current_root previous_root] + puts RunnerCatalogLifecycle.new(**options).action +rescue RunnerCatalogLifecycleError => error + warn error.message + exit 1 +end diff --git a/test/runner_catalog_lifecycle_test.rb b/test/runner_catalog_lifecycle_test.rb new file mode 100644 index 0000000..fa5caff --- /dev/null +++ b/test/runner_catalog_lifecycle_test.rb @@ -0,0 +1,91 @@ +require "fileutils" +require "minitest/autorun" +require "open3" +require "rbconfig" +require "tmpdir" + +class RunnerCatalogLifecycleTest < Minitest::Test + SCRIPT = File.expand_path("../script/check_runner_catalog_lifecycle.rb", __dir__) + FILES = %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml].freeze + + def test_bootstrap_without_catalog_skips_source_validation + with_roots do |previous, current| + assert_action(previous, current, "skip") + end + end + + def test_partial_catalog_fails_closed_before_publication + with_roots do |previous, current| + File.write(File.join(current, "runner-profiles.json"), "{}") + assert_failure(previous, current, "current catalog is partial") + end + end + + def test_complete_publication_is_validated + with_roots do |previous, current| + write_catalog(current) + assert_action(previous, current, "validate") + end + end + + def test_published_catalog_remains_validated + with_roots do |previous, current| + write_catalog(previous) + write_catalog(current) + assert_action(previous, current, "validate") + end + end + + def test_published_catalog_cannot_be_fully_deleted + with_roots do |previous, current| + write_catalog(previous) + assert_failure(previous, current, "published catalog cannot be deleted or partial") + end + end + + def test_published_catalog_cannot_become_partial + with_roots do |previous, current| + write_catalog(previous) + write_catalog(current) + File.delete(File.join(current, "RUNNERS.md")) + assert_failure(previous, current, "published catalog cannot be deleted or partial") + end + end + + private + + def with_roots + Dir.mktmpdir do |directory| + previous = File.join(directory, "previous") + current = File.join(directory, "current") + FileUtils.mkdir_p(previous) + FileUtils.mkdir_p(current) + yield previous, current + end + end + + def write_catalog(root) + FILES.each { |path| File.write(File.join(root, path), "catalog\n") } + end + + def assert_action(previous, current, expected) + stdout, stderr, status = execute_lifecycle(previous, current) + assert status.success?, stderr + assert_equal expected, stdout.strip + end + + def assert_failure(previous, current, message) + stdout, stderr, status = execute_lifecycle(previous, current) + refute status.success?, stdout + assert_includes stderr, message + end + + def execute_lifecycle(previous, current) + Open3.capture3( + RbConfig.ruby, + SCRIPT, + "--previous-root", previous, + "--current-root", current + ) + end +end diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index 7533915..b76e8b1 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -227,7 +227,7 @@ def test_trusted_workflow_verifies_published_catalogs_on_main_push workflow = YAML.safe_load(File.read(File.expand_path("../.github/workflows/runner-catalog-trusted.yml", __dir__)), aliases: false) trigger = workflow.fetch(true) push_paths = trigger.fetch("push").fetch("paths") - %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml script/validate_runner_catalog.rb test/runner_catalog_test.rb .github/workflows/runner-catalog.yml .github/workflows/runner-catalog-trusted.yml].each do |path| + %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml script/validate_runner_catalog.rb script/check_runner_catalog_lifecycle.rb test/runner_catalog_test.rb test/runner_catalog_lifecycle_test.rb .github/workflows/runner-catalog.yml .github/workflows/runner-catalog-trusted.yml].each do |path| assert_includes push_paths, path end verify = workflow.fetch("jobs").fetch("verify-published") @@ -238,6 +238,12 @@ def test_trusted_workflow_verifies_published_catalogs_on_main_push assert_equal "${{ secrets.GITOPS_READ_TOKEN }}", source_checkout.fetch("with").fetch("token") validation = steps.find { |step| step.fetch("name", "") == "Validate generated catalog against canonical source" } assert_equal "ruby script/validate_runner_catalog.rb --candidate-root . --source-root .gitops-source", validation.fetch("run") + lifecycle = steps.find { |step| step.fetch("name", "") == "Check catalog lifecycle" } + assert_equal "echo \"action=$(ruby script/check_runner_catalog_lifecycle.rb --previous-root .previous --current-root .)\" >> \"$GITHUB_OUTPUT\"", lifecycle.fetch("run") + assert_equal "steps.lifecycle.outputs.action == 'validate'", validation.fetch("if") + pull_request_verify = workflow.fetch("jobs").fetch("verify") + pull_request_lifecycle = pull_request_verify.fetch("steps").find { |step| step.fetch("name", "") == "Check catalog lifecycle" } + assert_equal "echo \"action=$(ruby .trusted/script/check_runner_catalog_lifecycle.rb --previous-root .trusted --current-root .candidate)\" >> \"$GITHUB_OUTPUT\"", pull_request_lifecycle.fetch("run") end private From dc7cd042523c9fe9dd32b4d27c7f989f98d2dfcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:00:06 +0200 Subject: [PATCH 11/12] no-mistakes(review): Added immutable trusted workflow boundary checks --- .github/workflows/runner-catalog-trusted.yml | 9 ++ .github/workflows/runner-catalog.yml | 6 + script/check_runner_catalog_trust_boundary.rb | 112 ++++++++++++++++ test/runner_catalog_test.rb | 23 ---- test/runner_catalog_trust_boundary_test.rb | 124 ++++++++++++++++++ 5 files changed, 251 insertions(+), 23 deletions(-) create mode 100644 script/check_runner_catalog_trust_boundary.rb create mode 100644 test/runner_catalog_trust_boundary_test.rb diff --git a/.github/workflows/runner-catalog-trusted.yml b/.github/workflows/runner-catalog-trusted.yml index 8fd4408..a68e8b8 100644 --- a/.github/workflows/runner-catalog-trusted.yml +++ b/.github/workflows/runner-catalog-trusted.yml @@ -9,6 +9,7 @@ on: - runner-profiles.yaml - script/validate_runner_catalog.rb - script/check_runner_catalog_lifecycle.rb + - script/check_runner_catalog_trust_boundary.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml push: @@ -20,8 +21,10 @@ on: - runner-profiles.yaml - script/validate_runner_catalog.rb - script/check_runner_catalog_lifecycle.rb + - script/check_runner_catalog_trust_boundary.rb - test/runner_catalog_test.rb - test/runner_catalog_lifecycle_test.rb + - test/runner_catalog_trust_boundary_test.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml @@ -38,6 +41,8 @@ jobs: run: ruby test/runner_catalog_test.rb - name: Exercise catalog lifecycle contract run: ruby test/runner_catalog_lifecycle_test.rb + - name: Exercise trust boundary contract + run: ruby test/runner_catalog_trust_boundary_test.rb verify-published: if: github.event_name == 'push' @@ -107,6 +112,10 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} path: .candidate persist-credentials: false + - name: Verify trusted boundary + run: ruby .trusted/script/check_runner_catalog_trust_boundary.rb --trusted-root .trusted --candidate-root .candidate + - name: Exercise base-owned infrastructure + run: ruby .trusted/test/runner_catalog_test.rb && ruby .trusted/test/runner_catalog_lifecycle_test.rb && ruby .trusted/test/runner_catalog_trust_boundary_test.rb - name: Check catalog lifecycle id: lifecycle run: echo "action=$(ruby .trusted/script/check_runner_catalog_lifecycle.rb --previous-root .trusted --current-root .candidate)" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/runner-catalog.yml b/.github/workflows/runner-catalog.yml index 2001066..6962f79 100644 --- a/.github/workflows/runner-catalog.yml +++ b/.github/workflows/runner-catalog.yml @@ -5,8 +5,10 @@ on: paths: - script/validate_runner_catalog.rb - script/check_runner_catalog_lifecycle.rb + - script/check_runner_catalog_trust_boundary.rb - test/runner_catalog_test.rb - test/runner_catalog_lifecycle_test.rb + - test/runner_catalog_trust_boundary_test.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml - RUNNERS.md @@ -18,8 +20,10 @@ on: paths: - script/validate_runner_catalog.rb - script/check_runner_catalog_lifecycle.rb + - script/check_runner_catalog_trust_boundary.rb - test/runner_catalog_test.rb - test/runner_catalog_lifecycle_test.rb + - test/runner_catalog_trust_boundary_test.rb - .github/workflows/runner-catalog.yml - .github/workflows/runner-catalog-trusted.yml - RUNNERS.md @@ -39,6 +43,8 @@ jobs: run: ruby test/runner_catalog_test.rb - name: Exercise catalog lifecycle contract run: ruby test/runner_catalog_lifecycle_test.rb + - name: Exercise trust boundary contract + run: ruby test/runner_catalog_trust_boundary_test.rb - name: Validate published catalog when present if: hashFiles('runner-profiles.json') != '' run: ruby script/validate_runner_catalog.rb diff --git a/script/check_runner_catalog_trust_boundary.rb b/script/check_runner_catalog_trust_boundary.rb new file mode 100644 index 0000000..600ca92 --- /dev/null +++ b/script/check_runner_catalog_trust_boundary.rb @@ -0,0 +1,112 @@ +#!/usr/bin/env ruby + +require "digest" +require "optparse" +require "yaml" + +class RunnerCatalogTrustBoundaryError < StandardError; end + +class RunnerCatalogTrustBoundary + TRUST_FILES = [ + ".github/workflows/runner-catalog-trusted.yml", + "script/check_runner_catalog_trust_boundary.rb", + "script/check_runner_catalog_lifecycle.rb", + "script/validate_runner_catalog.rb" + ].freeze + CATALOG_FILES = %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml].freeze + TRUSTED_WORKFLOW_PATHS = [ + *CATALOG_FILES, + "script/validate_runner_catalog.rb", + "script/check_runner_catalog_lifecycle.rb", + "script/check_runner_catalog_trust_boundary.rb", + ".github/workflows/runner-catalog.yml", + ".github/workflows/runner-catalog-trusted.yml" + ].freeze + + def initialize(trusted_root:, candidate_root:) + @trusted_root = File.expand_path(trusted_root) + @candidate_root = File.expand_path(candidate_root) + end + + def validate! + trusted_workflow = parse_workflow(@trusted_root) + candidate_workflow = parse_workflow(@candidate_root) + validate_workflow_model!(trusted_workflow) + fail_with("trusted workflow semantic drift") unless workflow_model(trusted_workflow) == workflow_model(candidate_workflow) + TRUST_FILES.each do |path| + trusted = File.join(@trusted_root, path) + candidate = File.join(@candidate_root, path) + fail_with("trusted boundary file missing: #{path}") unless File.file?(trusted) && File.file?(candidate) + fail_with("trusted boundary file modified: #{path}") unless Digest::SHA256.file(trusted).hexdigest == Digest::SHA256.file(candidate).hexdigest + end + true + rescue KeyError, Psych::Exception, Errno::ENOENT => error + raise RunnerCatalogTrustBoundaryError, error.message + end + + private + + def parse_workflow(root) + path = File.join(root, TRUST_FILES.first) + fail_with("trusted workflow missing") unless File.file?(path) + YAML.safe_load(File.read(path), aliases: false) + end + + def workflow_model(workflow) + trigger = workflow.fetch(true) + verify = workflow.fetch("jobs").fetch("verify") + { + "pullRequestTargetPaths" => trigger.fetch("pull_request_target").fetch("paths").sort, + "pushPaths" => trigger.fetch("push").fetch("paths").sort, + "verifyIf" => verify.fetch("if"), + "verifySteps" => verify.fetch("steps").map { |step| step_model(step) } + } + end + + def step_model(step) + { + "name" => step.fetch("name", nil), + "uses" => step.fetch("uses", nil), + "run" => step.fetch("run", nil), + "if" => step.fetch("if", nil), + "with" => step.fetch("with", {}).slice("ref", "path", "repository", "token", "persist-credentials", "sparse-checkout", "sparse-checkout-cone-mode") + } + end + + def validate_workflow_model!(workflow) + model = workflow_model(workflow) + expected_paths = TRUSTED_WORKFLOW_PATHS.sort + fail_with("trusted workflow catalog paths drift") unless model.fetch("pullRequestTargetPaths") == expected_paths + fail_with("trusted workflow push paths drift") unless model.fetch("pushPaths") == (expected_paths + ["test/runner_catalog_lifecycle_test.rb", "test/runner_catalog_test.rb", "test/runner_catalog_trust_boundary_test.rb"]).sort + fail_with("trusted workflow verify trigger drift") unless model.fetch("verifyIf") == "github.event_name == 'pull_request_target'" + steps = workflow.fetch("jobs").fetch("verify").fetch("steps") + boundary_index = steps.index { |step| step.fetch("name", "") == "Verify trusted boundary" } + tests_index = steps.index { |step| step.fetch("name", "") == "Exercise base-owned infrastructure" } + source_index = steps.index { |step| step.fetch("with", {}).fetch("repository", nil) == "akua-dev/gitops" } + fail_with("trusted workflow boundary step missing") unless boundary_index + fail_with("base-owned infrastructure tests missing") unless tests_index + fail_with("trusted source checkout missing") unless source_index + fail_with("trusted workflow boundary ordering drift") unless boundary_index < source_index && tests_index < source_index + fail_with("trusted boundary must use base-owned files") unless steps.fetch(boundary_index).fetch("run") == "ruby .trusted/script/check_runner_catalog_trust_boundary.rb --trusted-root .trusted --candidate-root .candidate" + fail_with("base-owned tests must use trusted files") unless steps.fetch(tests_index).fetch("run") == "ruby .trusted/test/runner_catalog_test.rb && ruby .trusted/test/runner_catalog_lifecycle_test.rb && ruby .trusted/test/runner_catalog_trust_boundary_test.rb" + end + + def fail_with(message) + raise RunnerCatalogTrustBoundaryError, message + end +end + +options = {} +OptionParser.new do |parser| + parser.on("--trusted-root PATH") { |path| options[:trusted_root] = path } + parser.on("--candidate-root PATH") { |path| options[:candidate_root] = path } +end.parse! + +begin + abort "missing trust-boundary roots" unless options.keys.sort == %i[candidate_root trusted_root] + RunnerCatalogTrustBoundary.new(**options).validate! + puts "trusted" +rescue RunnerCatalogTrustBoundaryError => error + warn error.message + exit 1 +end diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index b76e8b1..ad1c54e 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -223,29 +223,6 @@ def test_trusted_validator_derives_heavy_markdown_bound_from_source_resources end end - def test_trusted_workflow_verifies_published_catalogs_on_main_push - workflow = YAML.safe_load(File.read(File.expand_path("../.github/workflows/runner-catalog-trusted.yml", __dir__)), aliases: false) - trigger = workflow.fetch(true) - push_paths = trigger.fetch("push").fetch("paths") - %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml script/validate_runner_catalog.rb script/check_runner_catalog_lifecycle.rb test/runner_catalog_test.rb test/runner_catalog_lifecycle_test.rb .github/workflows/runner-catalog.yml .github/workflows/runner-catalog-trusted.yml].each do |path| - assert_includes push_paths, path - end - verify = workflow.fetch("jobs").fetch("verify-published") - assert_equal "github.event_name == 'push'", verify.fetch("if") - steps = verify.fetch("steps") - source_checkout = steps.find { |step| step.fetch("with", {}).fetch("repository", nil) == "akua-dev/gitops" } - refute_nil source_checkout - assert_equal "${{ secrets.GITOPS_READ_TOKEN }}", source_checkout.fetch("with").fetch("token") - validation = steps.find { |step| step.fetch("name", "") == "Validate generated catalog against canonical source" } - assert_equal "ruby script/validate_runner_catalog.rb --candidate-root . --source-root .gitops-source", validation.fetch("run") - lifecycle = steps.find { |step| step.fetch("name", "") == "Check catalog lifecycle" } - assert_equal "echo \"action=$(ruby script/check_runner_catalog_lifecycle.rb --previous-root .previous --current-root .)\" >> \"$GITHUB_OUTPUT\"", lifecycle.fetch("run") - assert_equal "steps.lifecycle.outputs.action == 'validate'", validation.fetch("if") - pull_request_verify = workflow.fetch("jobs").fetch("verify") - pull_request_lifecycle = pull_request_verify.fetch("steps").find { |step| step.fetch("name", "") == "Check catalog lifecycle" } - assert_equal "echo \"action=$(ruby .trusted/script/check_runner_catalog_lifecycle.rb --previous-root .trusted --current-root .candidate)\" >> \"$GITHUB_OUTPUT\"", pull_request_lifecycle.fetch("run") - end - private def with_candidate diff --git a/test/runner_catalog_trust_boundary_test.rb b/test/runner_catalog_trust_boundary_test.rb new file mode 100644 index 0000000..05ade53 --- /dev/null +++ b/test/runner_catalog_trust_boundary_test.rb @@ -0,0 +1,124 @@ +require "digest" +require "fileutils" +require "minitest/autorun" +require "open3" +require "rbconfig" +require "tmpdir" +require "yaml" + +class RunnerCatalogTrustBoundaryTest < Minitest::Test + SCRIPT = File.expand_path("../script/check_runner_catalog_trust_boundary.rb", __dir__) + TRUST_FILES = [ + ".github/workflows/runner-catalog-trusted.yml", + "script/check_runner_catalog_trust_boundary.rb", + "script/check_runner_catalog_lifecycle.rb", + "script/validate_runner_catalog.rb" + ].freeze + CATALOG_FILES = %w[RUNNERS.md runner-catalog-manifest.json runner-profiles.json runner-profiles.yaml].freeze + WORKFLOW_PATHS = [ + *CATALOG_FILES, + "script/validate_runner_catalog.rb", + "script/check_runner_catalog_lifecycle.rb", + "script/check_runner_catalog_trust_boundary.rb", + ".github/workflows/runner-catalog.yml", + ".github/workflows/runner-catalog-trusted.yml" + ].freeze + + def test_current_trust_root_accepts_itself + stdout, stderr, status = execute(Dir.pwd, Dir.pwd) + assert status.success?, stderr + assert_equal "trusted", stdout.strip + end + + def test_unchanged_fixture_trust_root_passes + with_roots do |trusted, candidate| + write_trust_root(trusted) + FileUtils.cp_r("#{trusted}/.", candidate) + assert_success(trusted, candidate) + end + end + + def test_weakened_fixture_workflow_fails_semantically + with_roots do |trusted, candidate| + write_trust_root(trusted) + FileUtils.cp_r("#{trusted}/.", candidate) + workflow_path = File.join(candidate, ".github/workflows/runner-catalog-trusted.yml") + workflow = YAML.safe_load(File.read(workflow_path), aliases: false) + workflow.fetch("jobs").fetch("verify").fetch("steps").delete_if { |step| step.fetch("name", "") == "Verify trusted boundary" } + File.write(workflow_path, YAML.dump(workflow)) + assert_failure(trusted, candidate, "trusted workflow semantic drift") + end + end + + def test_modified_fixture_verifier_fails_integrity_check + with_roots do |trusted, candidate| + write_trust_root(trusted) + FileUtils.cp_r("#{trusted}/.", candidate) + File.open(File.join(candidate, "script/validate_runner_catalog.rb"), "a") { |file| file.puts("changed") } + assert_failure(trusted, candidate, "trusted boundary file modified: script/validate_runner_catalog.rb") + end + end + + def test_deleted_fixture_trust_workflow_fails_integrity_check + with_roots do |trusted, candidate| + write_trust_root(trusted) + FileUtils.cp_r("#{trusted}/.", candidate) + File.delete(File.join(candidate, ".github/workflows/runner-catalog-trusted.yml")) + assert_failure(trusted, candidate, "trusted workflow missing") + end + end + + private + + def workflow + { + true => { + "pull_request_target" => { "paths" => WORKFLOW_PATHS }, + "push" => { "branches" => ["main"], "paths" => WORKFLOW_PATHS + ["test/runner_catalog_test.rb", "test/runner_catalog_lifecycle_test.rb", "test/runner_catalog_trust_boundary_test.rb"] } + }, + "jobs" => { + "verify" => { + "if" => "github.event_name == 'pull_request_target'", + "steps" => [ + { "name" => "Verify trusted boundary", "run" => "ruby .trusted/script/check_runner_catalog_trust_boundary.rb --trusted-root .trusted --candidate-root .candidate" }, + { "name" => "Exercise base-owned infrastructure", "run" => "ruby .trusted/test/runner_catalog_test.rb && ruby .trusted/test/runner_catalog_lifecycle_test.rb && ruby .trusted/test/runner_catalog_trust_boundary_test.rb" }, + { "name" => "Checkout canonical runner source", "uses" => "actions/checkout@v6", "with" => { "repository" => "akua-dev/gitops", "token" => "${{ secrets.GITOPS_READ_TOKEN }}" } } + ] + } + } + } + end + + def with_roots + Dir.mktmpdir do |directory| + trusted = File.join(directory, "trusted") + candidate = File.join(directory, "candidate") + FileUtils.mkdir_p(trusted) + FileUtils.mkdir_p(candidate) + yield trusted, candidate + end + end + + def write_trust_root(root) + FileUtils.mkdir_p(File.join(root, ".github/workflows")) + FileUtils.mkdir_p(File.join(root, "script")) + File.write(File.join(root, ".github/workflows/runner-catalog-trusted.yml"), YAML.dump(workflow)) + TRUST_FILES.drop(1).each { |path| File.write(File.join(root, path), "base-owned\n") } + end + + def execute(trusted, candidate) + Open3.capture3(RbConfig.ruby, SCRIPT, "--trusted-root", trusted, "--candidate-root", candidate) + end + + def assert_success(trusted, candidate) + stdout, stderr, status = execute(trusted, candidate) + assert status.success?, stderr + assert_equal "trusted", stdout.strip + end + + def assert_failure(trusted, candidate, message) + stdout, stderr, status = execute(trusted, candidate) + refute status.success?, stdout + assert_includes stderr, message + end +end From 56f16b0bd88bf503a5067d7f33a5b7548bca8e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robin=20Br=C3=A4mer?= <22003767+robinbraemer@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:12:06 +0200 Subject: [PATCH 12/12] no-mistakes(lint): Cleaned unused Ruby test locals --- test/runner_catalog_test.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/runner_catalog_test.rb b/test/runner_catalog_test.rb index ad1c54e..33c46f0 100644 --- a/test/runner_catalog_test.rb +++ b/test/runner_catalog_test.rb @@ -266,7 +266,7 @@ def public_catalog(provenance = PROVENANCE) def public_profiles SOURCE_IDENTITIES.map do |label, values| - source_id, source_class, source_name, id, profile_class, display_name = values + id, profile_class, display_name = values.last(3) capabilities = if label == "akua-x64-ci-v2" [BASELINE] else