OIDC environment and pull request subjects; a repository runs at most 20 hosted jobs and keeps 500 runs unfinished (#82)
* OIDC tokens use GitHub's environment and pull request subjects, and carry repository, owner and actor ids A job deploying to an environment got the same sub as any job on its branch, so a cloud trust policy couldn't require the environment's protection rules, and GitHub-style environment subjects never matched. Without numeric ids, a repository recreated under a reused name minted identical tokens. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo * One repository runs at most 20 hosted jobs at once and keeps at most 500 runs unfinished A workflow could dispatch itself in a loop, and one repository's runs could take every hosted runner container on the instance. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo --------- Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 2838763commit 797793ee8b2ba8232507eb3ee6ae4c757f0c6193Browse files
10 files changed+144−18
Files (10)
- apps/diffinery/docs/actions/environments.md+0−2
- apps/diffinery/docs/actions/oidc.md+12−10
- apps/diffinery/docs/reference/limits.md+2−0
- apps/diffinery/docs/security/overview.md+1−1
- apps/diffinery/src/http/api.ts+1−1
- apps/diffinery/src/http/runner.ts+11−1
- apps/diffinery/src/repo/actions.ts+32−0
- apps/diffinery/src/repo/schema.ts+2−0
- apps/diffinery/test/e2e/security.test.mjs+30−2
- apps/diffinery/test/unit/actions-runtime.test.ts+53−1
modifiedapps/diffinery/docs/actions/environments.md+0−2View file
| @@ -770,7 +770,6 @@ | ||
| 770 | 770 | - **Reading or updating a single branch pattern.** You can list, add and delete patterns, but not `GET` or `PUT` one by id. |
| 771 | 771 | - **Sealed-box secrets.** Send `value`, not `encrypted_value`. |
| 772 | 772 | - **Organization-level environments and environment secrets.** Environments belong to one repository. |
| 773 | -- **OIDC environment claims.** OIDC tokens for jobs with `environment:` have no `environment` claim, and their `sub` stays `repo:<owner>/<repo>:ref:<ref>`. See [OpenID Connect (OIDC)](/docs/actions/oidc). | |
| 774 | 773 | - **`on: deployment` and `on: deployment_status` workflow triggers.** Accepted but never started. |
| 775 | 774 | |
| 776 | 775 | ## Differences from GitHub |
| @@ -787,7 +786,6 @@ | ||
| 787 | 786 | | `environment.url` | Can use `steps` outputs | `steps` references are ignored | |
| 788 | 787 | | Deployment `auto_merge` | Merges the default branch into the ref | Ignored | |
| 789 | 788 | | Branch patterns | `fnmatch` syntax with character classes | `*`, `**` and `?` only, unlike workflow filters | |
| 790 | -| OIDC | `environment` claim and an environment-based `sub` | No `environment` claim | | |
| 791 | 789 | |
| 792 | 790 | ## Troubleshooting |
| 793 | 791 |
modifiedapps/diffinery/docs/actions/oidc.md+12−10View file
| @@ -55,7 +55,7 @@ | ||
| 55 | 55 | "response_types_supported": ["id_token"], |
| 56 | 56 | "subject_types_supported": ["public"], |
| 57 | 57 | "id_token_signing_alg_values_supported": ["ES256"], |
| 58 | - "claims_supported": ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"] | |
| 58 | + "claims_supported": ["sub", "aud", "iss", "repository", "repository_id", "repository_owner", "repository_owner_id", "environment", "ref", "ref_type", "sha", "workflow", "event_name", "actor", "actor_id", "run_id"] | |
| 59 | 59 | } |
| 60 | 60 | ``` |
| 61 | 61 | |
| @@ -94,23 +94,30 @@ | ||
| 94 | 94 | |
| 95 | 95 | ## Claims |
| 96 | 96 | |
| 97 | -Every token has exactly these claims. All values are strings except the timestamps. | |
| 97 | +Every token has these claims. All values are strings except the timestamps. | |
| 98 | 98 | |
| 99 | 99 | | Claim | Example | Meaning | |
| 100 | 100 | |---|---|---| |
| 101 | 101 | | `iss` | `https://diffinery.com` | Issuer. | |
| 102 | 102 | | `aud` | `sts.amazonaws.com` | The requested audience, or `https://diffinery.com` if none was given. A single string. | |
| 103 | -| `sub` | `repo:acme/website:ref:refs/heads/main` | Always `repo:<owner>/<repo>:ref:<ref>`. | | |
| 103 | +| `sub` | `repo:acme/website:ref:refs/heads/main` | As on GitHub: `repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for a `pull_request` run, and `repo:<owner>/<repo>:ref:<ref>` otherwise. | | |
| 104 | 104 | | `iat`, `nbf` | `1760000000` | Issue time and not-before time (the same). | |
| 105 | 105 | | `exp` | `1760000600` | Expiry, 10 minutes after issue. | |
| 106 | 106 | | `repository` | `acme/website` | Repository that runs the workflow. | |
| 107 | +| `repository_id` | `2918374650` | The repository's numeric id. It stays the same when the repository is renamed or transferred, and a new repository with an old name gets a new one. | | |
| 107 | 108 | | `repository_owner` | `acme` | Its owner (user or organization). | |
| 109 | +| `repository_owner_id` | `3482910375` | The owner's numeric id, which a new account with a reused name doesn't get. | | |
| 110 | +| `environment` | `production` | The environment the job deployed to. Only on jobs with `environment:`, once its protection rules passed. | | |
| 111 | +| `ref_type` | `branch` | `branch` or `tag`. | | |
| 112 | +| `head_ref`, `base_ref` | `feature`, `main` | The pull request's branches, on pull request runs only. | | |
| 113 | +| `run_number`, `run_attempt` | `128`, `1` | The run's number and attempt. | | |
| 108 | 114 | | `ref` | `refs/heads/main` | The run's ref. See the table below. | |
| 109 | 115 | | `sha` | `9fceb02d0ae598e95dc970b74767f19372d61af8` | The commit the run checked out. | |
| 110 | 116 | | `workflow` | `Deploy` | The workflow's `name`, or its file path when it has none. | |
| 111 | 117 | | `event_name` | `push` | The event that started the run. | |
| 112 | 118 | | `run_id` | `128` | The run number, as a string. It's the same value as `github.run_id`, and stays the same when the run is re-run. | |
| 113 | 119 | | `actor` | `diffy` | Who started the run. | |
| 120 | +| `actor_id` | `1938475620` | Their numeric id. | | |
| 114 | 121 | | `job_workflow_ref` | `acme/website/.github/workflows/deploy.yml@refs/heads/main` | The run's top-level workflow file and ref. | |
| 115 | 122 | |
| 116 | 123 | The token header is `{"alg": "ES256", "typ": "JWT", "kid": "..."}`. |
| @@ -131,15 +138,10 @@ | ||
| 131 | 138 | |
| 132 | 139 | ## Jobs that use an environment |
| 133 | 140 | |
| 134 | -Tokens don't change when a job has `environment:`. There's no `environment` claim, and `sub` stays `repo:<owner>/<repo>:ref:<ref>`; GitHub's `repo:<owner>/<repo>:environment:<name>` form is never issued. A trust policy written for GitHub that matches `sub` against `...:environment:production` never matches a Diffinery token. | |
| 141 | +A job with `environment:` gets GitHub's environment form of `sub`, `repo:<owner>/<repo>:environment:<name>`, and an `environment` claim. The token is only issued after the environment's deployment branch policy has passed, so a trust policy on `repo:acme/website:environment:production` only trusts jobs that were allowed to deploy to `production` (see [Deployments and environments](/docs/actions/environments)). | |
| 135 | 142 | |
| 136 | -To get the same protection, combine two checks: | |
| 143 | +To keep trusting a reused name out, also check `repository_id` and `repository_owner_id` where your provider lets you: a repository deleted and recreated, or an owner name taken by a new account, gets new ids. | |
| 137 | 144 | |
| 138 | -1. In your cloud provider, trust only the refs that may deploy, for example `repo:acme/website:ref:refs/heads/main`. | |
| 139 | -2. In Diffinery, give the deploy job `environment: production` and set that environment's deployment branch policy to the same refs, so no other branch can run the job (see [Deployments and environments](/docs/actions/environments)). | |
| 140 | - | |
| 141 | -Unlike environment secrets, an OIDC token is issued to any job that has `id-token: write`, whatever its environment and whoever started the run. The `sub`, `ref` and `event_name` claims are what tell your provider where the request came from. | |
| 142 | - | |
| 143 | 145 | ## AWS |
| 144 | 146 | |
| 145 | 147 | AWS accepts Diffinery tokens through an IAM OIDC identity provider and a role whose trust policy names it. |
modifiedapps/diffinery/docs/reference/limits.md+2−0View file
| @@ -179,6 +179,8 @@ | ||
| 179 | 179 | | Starting a job | 3 retries | Starting a job's container is retried 3 times with exponential backoff from 10 seconds before the job fails with "Could not start a runner". | |
| 180 | 180 | | Hosted runner size | Cloudflare Containers `standard-2` | Each `runs-on: ubuntu-latest` job runs in its own container of this instance type. | |
| 181 | 181 | | Hosted job containers at once | 100 | `max_instances` of the runner container class. | |
| 182 | +| Hosted jobs at once per repository | 20 | Further jobs stay queued and start, oldest first, as the repository's own jobs finish, so one repository can't take every container. Self-hosted jobs don't count. | | |
| 183 | +| Unfinished runs per repository | 500 | Past it, a `workflow_dispatch` or `repository_dispatch` is refused with `429 this repository has 500 unfinished workflow runs, the most allowed; wait for some to finish`, and other events start no run. | | |
| 182 | 184 | | Jobs during a deploy | Not interrupted | A deploy doesn't replace the container of a running hosted job: it finishes on the version it started with. If the platform replaces a job's container anyway, the job keeps going for up to 13 minutes, then is cancelled. | |
| 183 | 185 | | Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. | |
| 184 | 186 | | Log line length | 16,384 characters | Longer lines are cut and end with `…`. | |
modifiedapps/diffinery/docs/security/overview.md+1−1View file
| @@ -242,7 +242,7 @@ | ||
| 242 | 242 | |
| 243 | 243 | ## OIDC id tokens |
| 244 | 244 | |
| 245 | -Jobs that declare `permissions: id-token: write` can request a short-lived OIDC id token to authenticate to cloud providers without stored credentials. Tokens are signed with ES256 by a key configured on the instance, expire after 10 minutes, use the instance URL as issuer (and as the default audience, unless the job asks for another one), and have the subject `repo:<owner>/<repo>:ref:<ref>`. The public keys are at `https://diffinery.com/.well-known/jwks`. A job without the permission gets `403` with `this job lacks permissions: id-token: write`. See [OIDC](/docs/actions/oidc). | |
| 245 | +Jobs that declare `permissions: id-token: write` can request a short-lived OIDC id token to authenticate to cloud providers without stored credentials. Tokens are signed with ES256 by a key configured on the instance, expire after 10 minutes, use the instance URL as issuer (and as the default audience, unless the job asks for another one), and have GitHub's subject forms (`repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for pull request runs, `repo:<owner>/<repo>:ref:<ref>` otherwise), with numeric `repository_id` and `repository_owner_id` claims that a reused name doesn't inherit. The public keys are at `https://diffinery.com/.well-known/jwks`. A job without the permission gets `403` with `this job lacks permissions: id-token: write`. See [OIDC](/docs/actions/oidc). | |
| 246 | 246 | |
| 247 | 247 | ## Webhook signatures |
| 248 | 248 |
modifiedapps/diffinery/src/http/api.ts+1−1View file
| @@ -1038,7 +1038,7 @@ | ||
| 1038 | 1038 | // OIDC discovery for workflow id tokens. |
| 1039 | 1039 | export const wellKnown = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 1040 | 1040 | wellKnown.get("/.well-known/openid-configuration", (c) => |
| 1041 | - c.json({ issuer: c.env.PUBLIC_URL, jwks_uri: `${c.env.PUBLIC_URL}/.well-known/jwks`, response_types_supported: ["id_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], claims_supported: ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"] }), | |
| 1041 | + c.json({ issuer: c.env.PUBLIC_URL, jwks_uri: `${c.env.PUBLIC_URL}/.well-known/jwks`, response_types_supported: ["id_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], claims_supported: ["sub", "aud", "iss", "repository", "repository_id", "repository_owner", "repository_owner_id", "environment", "ref", "ref_type", "sha", "workflow", "event_name", "actor", "actor_id", "run_id"] }), | |
| 1042 | 1042 | ); |
| 1043 | 1043 | wellKnown.get("/.well-known/jwks", (c) => c.json({ keys: c.env.OIDC_PRIVATE_KEY ? [publicJwk(JSON.parse(c.env.OIDC_PRIVATE_KEY))] : [] })); |
| 1044 | 1044 |
modifiedapps/diffinery/src/http/runner.ts+11−1View file
| @@ -141,12 +141,22 @@ | ||
| 141 | 141 | const value = await signEs256(JSON.parse(c.env.OIDC_PRIVATE_KEY), { |
| 142 | 142 | iss: c.env.PUBLIC_URL, |
| 143 | 143 | aud: c.req.query("audience") ?? c.env.PUBLIC_URL, |
| 144 | - sub: `repo:${g.repository}:ref:${g.ref}`, | |
| 144 | + // GitHub's forms: a job in an environment, a pull request, or a branch or tag. | |
| 145 | + sub: g.environment ? `repo:${g.repository}:environment:${g.environment}` : g.event_name === "pull_request" ? `repo:${g.repository}:pull_request` : `repo:${g.repository}:ref:${g.ref}`, | |
| 145 | 146 | iat: nowS, |
| 146 | 147 | nbf: nowS, |
| 147 | 148 | exp: nowS + 600, |
| 148 | 149 | repository: g.repository, |
| 150 | + repository_id: g.repository_id, | |
| 149 | 151 | repository_owner: g.repository_owner, |
| 152 | + repository_owner_id: g.repository_owner_id, | |
| 153 | + actor_id: g.actor_id, | |
| 154 | + environment: g.environment, | |
| 155 | + ref_type: g.ref_type, | |
| 156 | + head_ref: g.head_ref || undefined, | |
| 157 | + base_ref: g.base_ref || undefined, | |
| 158 | + run_number: g.run_number, | |
| 159 | + run_attempt: g.run_attempt, | |
| 150 | 160 | ref: g.ref, |
| 151 | 161 | sha: g.sha, |
| 152 | 162 | workflow: g.workflow, |
modifiedapps/diffinery/src/repo/actions.ts+32−0View file
| @@ -44,6 +44,10 @@ | ||
| 44 | 44 | /** Every push reads and parses a commit's workflow files: at most this many, each at most this large. */ |
| 45 | 45 | export const MAX_WORKFLOW_FILES = 256; |
| 46 | 46 | export const MAX_WORKFLOW_BYTES = 512 * 1024; |
| 47 | +/** Hosted jobs one repository runs at once (GitHub's free plan allows 20 per account); more wait their turn. */ | |
| 48 | +export const MAX_HOSTED_JOBS = 20; | |
| 49 | +/** Runs one repository may have unfinished at once; past it, new ones aren't started. */ | |
| 50 | +export const MAX_ACTIVE_RUNS = 500; | |
| 47 | 51 | const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i; |
| 48 | 52 | |
| 49 | 53 | export type RunRow = { |
| @@ -98,6 +102,7 @@ | ||
| 98 | 102 | concurrency_group: string | null; |
| 99 | 103 | concurrency_cancel: number; |
| 100 | 104 | log_chars: number; |
| 105 | + fleet_held: number; | |
| 101 | 106 | }; |
| 102 | 107 | |
| 103 | 108 | export type RunInfo = { |
| @@ -496,6 +501,13 @@ | ||
| 496 | 501 | // runs, so workflows cannot trigger themselves in a loop; workflow_dispatch and |
| 497 | 502 | // repository_dispatch are the exceptions. |
| 498 | 503 | if (isJobCaller(o.caller) && o.event !== "workflow_dispatch" && o.event !== "repository_dispatch") return null; |
| 504 | + // A dispatch loop (or a flood of pushes) stops growing here. | |
| 505 | + const active = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE status != 'completed'").one().n; | |
| 506 | + if (active >= MAX_ACTIVE_RUNS) { | |
| 507 | + if (o.event === "workflow_dispatch" || o.event === "repository_dispatch") throw new HttpError(429, `this repository has ${active} unfinished workflow runs, the most allowed; wait for some to finish`); | |
| 508 | + console.warn(`not starting ${wf.path}: ${active} unfinished runs`); | |
| 509 | + return null; | |
| 510 | + } | |
| 499 | 511 | const id = newId(); |
| 500 | 512 | const t = now(); |
| 501 | 513 | const run: RunRow = { |
| @@ -914,6 +926,14 @@ | ||
| 914 | 926 | this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); |
| 915 | 927 | return; |
| 916 | 928 | } |
| 929 | + // One repository can't take the whole fleet: past its share, the job waits for one of its own to finish. | |
| 930 | + const busy = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM jobs WHERE labels IS NULL AND container IS NOT NULL AND status IN ('waiting', 'in_progress') AND id != ?", jobId).one().n; | |
| 931 | + if (busy >= MAX_HOSTED_JOBS) { | |
| 932 | + this.sql.exec("UPDATE jobs SET status = 'queued', fleet_held = 1 WHERE id = ?", jobId); | |
| 933 | + this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` }); | |
| 934 | + return; | |
| 935 | + } | |
| 936 | + this.sql.exec("UPDATE jobs SET fleet_held = 0 WHERE id = ?", jobId); | |
| 917 | 937 | const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`; |
| 918 | 938 | const container = `job-${jobId}`; |
| 919 | 939 | this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId); |
| @@ -1171,6 +1191,13 @@ | ||
| 1171 | 1191 | const ctx = this.githubContext(run, localKey(job.job_key, exp.parentOf.get(job.job_key) ?? null), token) as Record<string, Json>; |
| 1172 | 1192 | const github: Record<string, string> = {}; |
| 1173 | 1193 | for (const [k, v] of Object.entries(ctx)) if (typeof v === "string" || typeof v === "number") github[k] = String(v); |
| 1194 | + // Ids don't change when a name is reused, so a cloud trust policy can pin them; the environment is the one | |
| 1195 | + // whose protection rules this job passed (runnerSpec records its deployment). | |
| 1196 | + github.repository_owner_id = String(numericId(this.repo.meta.ownerId)); | |
| 1197 | + const actor = JSON.parse(run.actor) as Actor; | |
| 1198 | + if (actor.id) github.actor_id = String(numericId(actor.id)); | |
| 1199 | + const env = this.sql.exec<{ environment: string }>("SELECT environment FROM deployments WHERE job_id = ?", jobId).toArray()[0]; | |
| 1200 | + if (env) github.environment = env.environment; | |
| 1174 | 1201 | return { allowed, github }; |
| 1175 | 1202 | } |
| 1176 | 1203 | |
| @@ -1374,6 +1401,11 @@ | ||
| 1374 | 1401 | const queued = siblings.find((s) => s.status === "queued"); |
| 1375 | 1402 | if (queued) await this.launch(queued.id); |
| 1376 | 1403 | } |
| 1404 | + // A hosted job hands its slot to the oldest job waiting for one. | |
| 1405 | + if (!job.labels && job.container) { | |
| 1406 | + const held = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held = 1 AND status = 'queued' ORDER BY rowid LIMIT 1").toArray()[0]; | |
| 1407 | + if (held) await this.launch(held.id); | |
| 1408 | + } | |
| 1377 | 1409 | // A job that held its concurrency group hands it to the oldest pending job (of any run). |
| 1378 | 1410 | if (job.concurrency_group && (job.status === "waiting" || job.status === "in_progress")) { |
| 1379 | 1411 | const next = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE concurrency_group = ? AND status = 'pending' ORDER BY rowid LIMIT 1", job.concurrency_group).toArray()[0]; |
modifiedapps/diffinery/src/repo/schema.ts+2−0View file
| @@ -177,6 +177,8 @@ | ||
| 177 | 177 | ["jobs", "concurrency_cancel", "INTEGER NOT NULL DEFAULT 0"], |
| 178 | 178 | // Characters of log text stored so far (repo/actions.ts MAX_JOB_LOG). |
| 179 | 179 | ["jobs", "log_chars", "INTEGER NOT NULL DEFAULT 0"], |
| 180 | + // 1 while a queued job waits for one of the repository's hosted runner slots (repo/actions.ts MAX_HOSTED_JOBS). | |
| 181 | + ["jobs", "fleet_held", "INTEGER NOT NULL DEFAULT 0"], | |
| 180 | 182 | // The GitHub import that wrote this row (null for everything else); a retry may only overwrite its own rows. |
| 181 | 183 | ["issues", "imported", "TEXT"], |
| 182 | 184 | ["pulls", "imported", "TEXT"], |
modifiedapps/diffinery/test/e2e/security.test.mjs+30−2View file
| @@ -21,7 +21,7 @@ | ||
| 21 | 21 | // Agents land changes on protected branches only through auto-merge (or the |
| 22 | 22 | // merge queue) where a person allowed it and a required check gates it, never by bypass. |
| 23 | 23 | import { spawn, spawnSync, execFileSync } from "node:child_process"; |
| 24 | -import { createHash, randomBytes } from "node:crypto"; | |
| 24 | +import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; | |
| 25 | 25 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; |
| 26 | 26 | import { gzipSync } from "node:zlib"; |
| 27 | 27 | import { tmpdir } from "node:os"; |
| @@ -140,7 +140,7 @@ | ||
| 140 | 140 | ); |
| 141 | 141 | dev = spawn( |
| 142 | 142 | "pnpm", |
| 143 | - ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], | |
| 143 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `OIDC_PRIVATE_KEY:${JSON.stringify(generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }))}`, "--show-interactive-dev-session=false"], | |
| 144 | 144 | { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, |
| 145 | 145 | ); |
| 146 | 146 | dev.stdout.on("data", (d) => (devLog += d)); |
| @@ -1810,3 +1810,31 @@ | ||
| 1810 | 1810 | assert.equal(taken.status, 403); |
| 1811 | 1811 | assert.match(await taken.text(), /named after the guild\/vault repository/); |
| 1812 | 1812 | }); |
| 1813 | + | |
| 1814 | +test("security: OIDC tokens name the environment a job deployed to, and carry ids that survive a reused name", async () => { | |
| 1815 | + const R = `/repos/${OWNER}/oidc-claims`; | |
| 1816 | + await repoWithReadme("oidc-claims"); | |
| 1817 | + const wf = "name: ci\non: push\npermissions:\n id-token: write\n contents: read\njobs:\n plain:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo hi\n deploy:\n runs-on: [self-hosted, linux]\n environment: production\n steps:\n - run: echo hi\n"; | |
| 1818 | + await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) }); | |
| 1819 | + const tokens = {}; | |
| 1820 | + await until("both jobs claimed", async () => { | |
| 1821 | + const r = await api("POST", `${R}/actions/runners/claim`, { name: "oidc-runner", labels: ["linux"] }); | |
| 1822 | + if (r.status === 200) { | |
| 1823 | + const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json(); | |
| 1824 | + const res = await fetch(`${base}/runner/jobs/${r.json.job_id}/oidc?audience=sts.example.com`, { headers: { authorization: `Bearer ${r.json.token}` } }); | |
| 1825 | + assert.equal(res.status, 200, await res.clone().text()); | |
| 1826 | + tokens[spec.jobKey] = JSON.parse(Buffer.from((await res.json()).value.split(".")[1], "base64url").toString()); | |
| 1827 | + await fetch(`${base}/runner/jobs/${r.json.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${r.json.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) }); | |
| 1828 | + } | |
| 1829 | + return Object.keys(tokens).length === 2; | |
| 1830 | + }); | |
| 1831 | + const repo = await ok("GET", R); | |
| 1832 | + assert.equal(tokens.plain.sub, `repo:${OWNER}/oidc-claims:ref:refs/heads/main`); | |
| 1833 | + assert.equal(tokens.deploy.sub, `repo:${OWNER}/oidc-claims:environment:production`); | |
| 1834 | + assert.equal(tokens.deploy.environment, "production"); | |
| 1835 | + assert.equal(tokens.plain.environment, undefined); | |
| 1836 | + assert.equal(tokens.plain.aud, "sts.example.com"); | |
| 1837 | + assert.equal(tokens.plain.repository_id, String(repo.id)); | |
| 1838 | + assert.equal(tokens.plain.repository_owner_id, String(repo.owner.id)); | |
| 1839 | + assert.match(tokens.plain.actor_id, /^\d+$/); | |
| 1840 | +}); |
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+53−1View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import { BodyTooLarge, STEP_OPS, batchParts, blockKey, chunkKey, compose, finalKey, listStaged, parseBlockList, parseRange, planCompose, putStream, runComposeStep, stagingPrefix, type BlobBucket, type ComposeLimits, type ComposeStep } from "../../src/lib/blob-store.js"; |
| 3 | 3 | import { DatabaseSync } from "node:sqlite"; |
| 4 | -import { Actions } from "../../src/repo/actions.js"; | |
| 4 | +import { Actions, MAX_ACTIVE_RUNS, MAX_HOSTED_JOBS } from "../../src/repo/actions.js"; | |
| 5 | 5 | import { DEFAULT_ARTIFACT_QUOTA, Runtime, artifactFileName, artifactObjectKeys } from "../../src/repo/runtime.js"; |
| 6 | 6 | import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; |
| 7 | 7 | import type { RepoDO } from "../../src/repo/RepoDO.js"; |
| @@ -643,3 +643,55 @@ | ||
| 643 | 643 | expect(f.sql.exec("SELECT key FROM caches ORDER BY key").toArray()).toEqual([{ key: "refs/heads/main\nnew" }, { key: "refs/heads/main\nrecent" }]); |
| 644 | 644 | }); |
| 645 | 645 | }); |
| 646 | + | |
| 647 | +describe("hosted runner slots", () => { | |
| 648 | + it("a repository runs at most MAX_HOSTED_JOBS hosted jobs at once; the rest wait their turn", async () => { | |
| 649 | + const sql = sqlStorage(); | |
| 650 | + sql.exec(REPO_SCHEMA); | |
| 651 | + migrateRepoSchema(sql as unknown as SqlStorage); | |
| 652 | + const started: string[] = []; | |
| 653 | + const repo = { | |
| 654 | + sql, | |
| 655 | + meta: { repoId: "r", owner: "acme", name: "app", defaultBranch: "main" }, | |
| 656 | + upsertCheck: () => {}, | |
| 657 | + broadcast: () => {}, | |
| 658 | + deploys: { jobCompleted: () => {} }, | |
| 659 | + runtime: { sweepJob: async () => {} }, | |
| 660 | + } as unknown as RepoDO; | |
| 661 | + const env = { | |
| 662 | + PUBLIC_URL: "https://d.test", | |
| 663 | + BUCKET: memoryBucket().bucket, | |
| 664 | + RUNNER: { idFromName: (n: string) => n, get: (n: string) => ({ startJob: async () => void started.push(n), stopJob: async () => {} }) }, | |
| 665 | + RUN_WORKFLOW: { get: async () => ({ sendEvent: async () => {} }) }, | |
| 666 | + } as unknown as Env; | |
| 667 | + const actions = new Actions(repo, env); | |
| 668 | + const t = new Date().toISOString(); | |
| 669 | + sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES ('run1', 1, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'in_progress', ?, ?, '{}')", t, t); | |
| 670 | + for (let i = 0; i < MAX_HOSTED_JOBS + 2; i++) sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status) VALUES (?, 'run1', ?, ?, 'queued')", `j${i}`, `k${i}`, `k${i}`); | |
| 671 | + const launch = (id: string) => (actions as unknown as { launch(id: string): Promise<void> }).launch(id); | |
| 672 | + for (let i = 0; i < MAX_HOSTED_JOBS + 2; i++) await launch(`j${i}`); | |
| 673 | + expect(started).toHaveLength(MAX_HOSTED_JOBS); | |
| 674 | + const held = () => sql.exec("SELECT id FROM jobs WHERE fleet_held = 1 AND status = 'queued' ORDER BY rowid").toArray().map((r) => r.id); | |
| 675 | + expect(held()).toEqual([`j${MAX_HOSTED_JOBS}`, `j${MAX_HOSTED_JOBS + 1}`]); | |
| 676 | + // A finished job hands its slot to the oldest one waiting. | |
| 677 | + await (actions as unknown as { completeJob(id: string, c: unknown): Promise<void> }).completeJob("j0", { conclusion: "success", outputs: {} }); | |
| 678 | + expect(started).toHaveLength(MAX_HOSTED_JOBS + 1); | |
| 679 | + expect(started.at(-1)).toBe(`job-j${MAX_HOSTED_JOBS}`); | |
| 680 | + expect(held()).toEqual([`j${MAX_HOSTED_JOBS + 1}`]); | |
| 681 | + }); | |
| 682 | +}); | |
| 683 | + | |
| 684 | +describe("unfinished runs", () => { | |
| 685 | + it("past MAX_ACTIVE_RUNS a dispatch is refused and other events start nothing", async () => { | |
| 686 | + const sql = sqlStorage(); | |
| 687 | + sql.exec(REPO_SCHEMA); | |
| 688 | + migrateRepoSchema(sql as unknown as SqlStorage); | |
| 689 | + const actions = new Actions({ sql, meta: { repoId: "r", owner: "acme", name: "app", defaultBranch: "main" } } as unknown as RepoDO, { PUBLIC_URL: "https://d.test" } as unknown as Env); | |
| 690 | + const t = new Date().toISOString(); | |
| 691 | + for (let i = 0; i < MAX_ACTIVE_RUNS; i++) sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES (?, ?, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'queued', ?, ?, '{}')", `run${i}`, i + 1, t, t); | |
| 692 | + const wf = { path: ".github/workflows/w.yml", on: {}, jobs: {} } as never; | |
| 693 | + const caller = { id: "u1", login: "ada", kind: "user" as const }; | |
| 694 | + await expect(actions.createRun(wf, { event: "workflow_dispatch", ref: "refs/heads/main", sha: "abc", payload: {}, caller, title: "w" })).rejects.toThrow(/500 unfinished workflow runs/); | |
| 695 | + expect(await actions.createRun(wf, { event: "push", ref: "refs/heads/main", sha: "abc", payload: {}, caller, title: "w" })).toBeNull(); | |
| 696 | + }); | |
| 697 | +}); |