Per-account quotas on hosted minutes, running jobs, sandboxes and repositories (#121)
The account that owns a repository pays: hosted Actions jobs and agent sandboxes count toward its monthly hosted minutes; running hosted jobs past its limit wait in the queue; sandboxes past its limit are refused; and new, imported or transferred repositories past its limit are refused. Defaults come from QUOTA_* and a site administrator can override them per account. Accounts run by a site administrator have no quotas. Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent e162310commit 1631fd0e91ba4585bdda7ae017597fdb13c92e82Browse files
16 files changed+523−9
Files (16)
- apps/diffinery/docs/reference/limits.md+12−1
- apps/diffinery/docs/security/audit-log.md+1−0
- apps/diffinery/docs/self-hosting/configuration.md+1−0
- apps/diffinery/migrations/0018_quotas.sql+23−0
- apps/diffinery/src/docs/api-meta.ts+22−0
- apps/diffinery/src/env.ts+5−0
- apps/diffinery/src/http/api.ts+53−0
- apps/diffinery/src/lib/directory.ts+3−0
- apps/diffinery/src/lib/quotas.ts+145−0
- apps/diffinery/src/repo/RepoDO.ts+15−2
- apps/diffinery/src/repo/actions.ts+45−3
- apps/diffinery/src/runner/containers.ts+16−1
- apps/diffinery/test/e2e/security.test.mjs+61−1
- apps/diffinery/test/unit/actions-runtime.test.ts+10−1
- apps/diffinery/test/unit/quotas.test.ts+87−0
- apps/diffinery/test/unit/stubs/d1.ts+24−0
modifiedapps/diffinery/docs/reference/limits.md+12−1View file
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | title: Limits |
| 3 | -description: Every hard limit in Diffinery and where it applies: sign-in, tokens, API, search, archives, releases, git, Actions, sandboxes, agent tasks, agent review, packages, imports, email. | |
| 3 | +description: Every hard limit in Diffinery and where it applies: account quotas, sign-in, tokens, API, search, archives, releases, git, Actions, sandboxes, agent tasks, agent review, packages, imports, email. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | This page lists the fixed limits built into Diffinery: sizes, counts, timeouts and lifetimes. Use it when a push, a workflow, an upload or an API call fails with a size or count error, or when you plan something large. Values come from the code of the running version. Where a limit comes from Cloudflare rather than from Diffinery, the table says so. |
| @@ -166,6 +166,17 @@ | ||
| 166 | 166 | | Merge queue build concurrency | 1 to 20, default 5 | How many merge groups a queue builds and tests at the same time (`mergeQueue.buildConcurrency`). | |
| 167 | 167 | | Merge queue check timeout | 1 to 360 minutes, default 60 | How long a merge group's required checks may take before the pull request leaves the queue (`mergeQueue.checkTimeoutMinutes`). | |
| 168 | 168 | |
| 169 | +## Account quotas | |
| 170 | + | |
| 171 | +Quotas are per account, and the account that pays is the one that owns the repository (a person or an organization), whoever started the work. A site administrator can change any of them for one account (`PATCH /api/v3/admin/accounts/{login}/quotas`), and an operator can change the defaults with the `QUOTA_*` variables in [Configuration](/docs/self-hosting/configuration). Accounts run by a site administrator have no quotas: their own account, and organizations with a site administrator as an owner. See what an account uses with `GET /api/v3/user/usage` or `GET /api/v3/orgs/{org}/usage` (owners and admins). | |
| 172 | + | |
| 173 | +| Quota | Default | What happens past it | | |
| 174 | +| --- | --- | --- | | |
| 175 | +| Hosted minutes per month | 3,000 | Hosted Actions jobs and agent sandboxes (including agent tasks and reviews) add up their running time, rounded up to the minute, per calendar month in UTC. Once it is used up, a hosted job fails without starting ("The job was not started: this account has used its 3000 hosted minutes for this month ..."), and a new sandbox is refused with `429`. Self-hosted runners don't count. | | |
| 176 | +| Hosted jobs at once | 5 | Across all the account's repositories. Further jobs wait as queued and start, oldest first, as running ones finish (checked at least once a minute). A job that waits 6 hours fails with "The job waited 6 hours for one of this account's hosted jobs to finish, and was not started." | | |
| 177 | +| Agent sandboxes open at once | 5 | Across all the account's repositories, including agent tasks' and reviews' sandboxes. A new one is refused with `429 this account already has 5 agent sandboxes open; close one or wait for one to finish`. | | |
| 178 | +| Repositories | 500 | Creating, importing or transferring in a repository past it is refused with `403 <account> has reached its limit of 500 repositories; delete one or ask a site administrator to raise it`. | | |
| 179 | + | |
| 169 | 180 | ## Actions |
| 170 | 181 | |
| 171 | 182 | | Limit | Value | Applies to | |
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
| @@ -105,6 +105,7 @@ | ||
| 105 | 105 | | `org.member.set`, `org.member.remove` | A site administrator adds themselves with **Add people**, a member's role is changed, or a member is removed | the organization's login | `user`, `role` | |
| 106 | 106 | | `invite.create` | A site admin creates an invite from **Site administration** | the email address | none | |
| 107 | 107 | | `site.invite` | A site admin invites someone with `POST /api/v3/invitations` | none | `email` | |
| 108 | +| `account.quotas.set` | A site administrator changes an account's quotas with `PATCH /api/v3/admin/accounts/{login}/quotas` | the account's login | the quotas sent, e.g. `hosted_minutes` | | |
| 108 | 109 | |
| 109 | 110 | ### Repositories |
| 110 | 111 |
modifiedapps/diffinery/docs/self-hosting/configuration.md+1−0View file
| @@ -29,6 +29,7 @@ | ||
| 29 | 29 | | `ARTIFACT_QUOTA_BYTES` | Not set | No | Workflow artifact storage per repository, in bytes: artifacts that haven't expired plus uploads in progress. Default `10737418240` (10 GB). Anything that isn't a positive whole number counts as not set. Add it under `vars` in `wrangler.jsonc`; the deploy keeps it. When a repository reaches it, uploads fail with `413 the repository's artifact storage quota (N bytes) is used up; delete artifacts or wait for them to expire`. See [Caches and artifacts](/docs/reference/limits#caches-and-artifacts). | |
| 30 | 30 | | `DEV_AUTH` | Not set in production; `dev@diffinery.local` in the `dev` environment | Never in production | Local development only. See [DEV_AUTH](#dev_auth). | |
| 31 | 31 | | `IMPORT_MAX_REPO_KB` | Not set | No | The largest GitHub repository a one-repository import accepts, in KB as GitHub reports a repository's `size`. Default `10485760` (10 GB). An empty value, `0`, a negative number or text that isn't a number counts as not set. A larger source is refused before the import starts with `422 the repository is about N GB, over the M GB import limit; ask an administrator to raise IMPORT_MAX_REPO_KB or split the repository`. See [GitHub imports](#github-imports). | |
| 32 | +| `QUOTA_HOSTED_MINUTES`, `QUOTA_HOSTED_JOBS`, `QUOTA_SANDBOXES`, `QUOTA_REPOSITORIES` | Not set | No | The default per-account quotas: hosted minutes per month (default `3000`), hosted Actions jobs at once (`5`), agent sandboxes open at once (`5`) and repositories (`500`). `-1` is unlimited; text that isn't a whole number counts as not set. Site administrators change them for one account with `PATCH /api/v3/admin/accounts/{login}/quotas`, and accounts run by a site administrator have none. See [Limits](/docs/reference/limits#account-quotas). | | |
| 32 | 33 | | `IMPORT_GHE_HOST` | Not set | No | The host name of a GitHub Enterprise Server (like `ghe.example.com`) that imports may download release assets from. Without it, imports download release assets only from github.com and its asset hosts, and assets of an Enterprise import are skipped with a warning. | |
| 33 | 34 | | `GITHUB_OAUTH_CLIENT_ID` | Not set | No | The client ID of a GitHub OAuth app, for linking GitHub accounts with a GitHub sign-in instead of a gist. Needs `GITHUB_OAUTH_CLIENT_SECRET` too. See [GitHub imports](#github-imports). | |
| 34 | 35 | | `IMPORT_GIT_AGENT` | Not set in production; `external` in the `dev` environment | Never in production | With `external`, an import's Git transfer doesn't start a container: it waits for an import agent started by hand (see [The dev environment](#the-dev-environment)). | |
addedapps/diffinery/migrations/0018_quotas.sql+23−0View file
| @@ -1,0 +1,23 @@ | ||
| 1 | +-- Hosted compute an account pays for (the owner of the repository): Actions jobs on hosted runners | |
| 2 | +-- and agent sandboxes. A row is open until the job or sandbox ends (lib/quotas.ts). | |
| 3 | +CREATE TABLE IF NOT EXISTS compute_usage ( | |
| 4 | + id TEXT PRIMARY KEY, | |
| 5 | + account_id TEXT NOT NULL, | |
| 6 | + repo_id TEXT NOT NULL, | |
| 7 | + kind TEXT NOT NULL, | |
| 8 | + started_at TEXT NOT NULL, | |
| 9 | + ended_at TEXT | |
| 10 | +); | |
| 11 | +CREATE INDEX IF NOT EXISTS compute_usage_open ON compute_usage(account_id, kind, ended_at); | |
| 12 | +CREATE INDEX IF NOT EXISTS compute_usage_started ON compute_usage(account_id, started_at); | |
| 13 | + | |
| 14 | +-- A site administrator's per-account overrides of the default quotas; NULL keeps the default, -1 is unlimited. | |
| 15 | +CREATE TABLE IF NOT EXISTS account_quotas ( | |
| 16 | + account_id TEXT PRIMARY KEY REFERENCES accounts(id), | |
| 17 | + hosted_minutes INTEGER, | |
| 18 | + hosted_jobs INTEGER, | |
| 19 | + sandboxes INTEGER, | |
| 20 | + repositories INTEGER, | |
| 21 | + updated_at TEXT NOT NULL, | |
| 22 | + updated_by TEXT | |
| 23 | +); |
modifiedapps/diffinery/src/docs/api-meta.ts+22−0View file
| @@ -45,6 +45,10 @@ | ||
| 45 | 45 | "GET /user/memberships/orgs": { scope: "read" }, |
| 46 | 46 | "GET /user/memberships/orgs/{org}": { scope: "read" }, |
| 47 | 47 | "PATCH /user/memberships/orgs/{org}": { scope: "write" }, |
| 48 | + "GET /user/usage": { scope: "read", anyToken: true }, | |
| 49 | + "GET /orgs/{org}/usage": { scope: "read", anyToken: true }, | |
| 50 | + "GET /admin/accounts/{login}/quotas": { scope: "read", anyToken: true }, | |
| 51 | + "PATCH /admin/accounts/{login}/quotas": { scope: "admin" }, | |
| 48 | 52 | "DELETE /orgs/{org}/invitations/{id}": { scope: "admin" }, |
| 49 | 53 | "GET /invitations": { scope: "read" }, |
| 50 | 54 | "DELETE /invitations/{id}": { scope: "admin" }, |
| @@ -124,6 +128,24 @@ | ||
| 124 | 128 | }, |
| 125 | 129 | "DELETE /user/avatar": { summary: "Remove your profile picture", description: "Diffinery only. Goes back to generated initials and returns the `avatar_url`. Same token rules as setting one. Audited.", access: "account" }, |
| 126 | 130 | "GET /users/{login}": { summary: "Get an account", description: "A user, organization or agent by login.", access: "account" }, |
| 131 | + "GET /user/usage": { | |
| 132 | + summary: "Get your quotas and usage", | |
| 133 | + description: "Hosted minutes used this month, hosted jobs running, sandboxes open and repositories owned, each with its `limit` (`-1` is unlimited), and `exempt` for accounts run by a site administrator. An agent gets its owner's. See [Limits](/docs/reference/limits#account-quotas).", | |
| 134 | + access: "account", | |
| 135 | + }, | |
| 136 | + "GET /orgs/{org}/usage": { summary: "Get an organization's quotas and usage", description: "The same as `GET /user/usage`, for the organization's owners and admins.", access: "org-admin" }, | |
| 137 | + "GET /admin/accounts/{login}/quotas": { summary: "Get an account's quotas and usage", description: "Site administrators only.", access: "site-admin" }, | |
| 138 | + "PATCH /admin/accounts/{login}/quotas": { | |
| 139 | + summary: "Change an account's quotas", | |
| 140 | + description: "Site administrators only. Send only the quotas to change: a whole number, `-1` for unlimited, or `null` for the default. Audited as `account.quotas.set`.", | |
| 141 | + access: "site-admin", | |
| 142 | + body: { | |
| 143 | + hosted_minutes: ["integer", "Hosted minutes per calendar month (UTC)."], | |
| 144 | + hosted_jobs: ["integer", "Hosted Actions jobs running at once."], | |
| 145 | + sandboxes: ["integer", "Agent sandboxes open at once."], | |
| 146 | + repositories: ["integer", "Repositories the account owns."], | |
| 147 | + }, | |
| 148 | + }, | |
| 127 | 149 | "GET /user/orgs": { summary: "List your organizations", description: "Each organization carries your `role` in it (member, admin or owner).", access: "account" }, |
| 128 | 150 | "GET /user/repository_invitations": { summary: "List your repository invitations", description: "Pending invitations to repositories, sent to your username or your email address.", access: "account" }, |
| 129 | 151 | "PATCH /user/repository_invitations/{id}": { summary: "Accept a repository invitation", description: "You get the role it offers. Audited as `invite.accept`.", access: "account", status: 204 }, |
modifiedapps/diffinery/src/env.ts+5−0View file
| @@ -44,6 +44,11 @@ | ||
| 44 | 44 | IMPORTER?: DurableObjectNamespace<ImportContainer>; |
| 45 | 45 | /** "external": an import agent started outside the Worker claims the Git transfer (local development and tests). */ |
| 46 | 46 | IMPORT_GIT_AGENT?: string; |
| 47 | + /** Default per-account quotas (lib/quotas.ts); -1 is unlimited. Site administrators override them per account. */ | |
| 48 | + QUOTA_HOSTED_MINUTES?: string; | |
| 49 | + QUOTA_HOSTED_JOBS?: string; | |
| 50 | + QUOTA_SANDBOXES?: string; | |
| 51 | + QUOTA_REPOSITORIES?: string; | |
| 47 | 52 | /** Largest source repository an import will take, in KB (GitHub `size`); default 10 GB. */ |
| 48 | 53 | IMPORT_MAX_REPO_KB?: string; |
| 49 | 54 | /** Optional GitHub OAuth app, for linking an account through the web flow instead of a gist. */ |
modifiedapps/diffinery/src/http/api.ts+53−0View file
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | import { callerFor, parseScopes, requireAccount, requireAccountScope, requireBrowserSession, requireRecentSignIn, requireUnlimitedToken, tokenRepos, type AppContext, type AppVars } from "../auth/principal.js"; |
| 5 | 5 | import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js"; |
| 6 | 6 | import { encryptSecret } from "../lib/crypto.js"; |
| 7 | +import { monthStart, quotasFor, setQuotas, usageOf } from "../lib/quotas.js"; | |
| 7 | 8 | import { publicJwk } from "../lib/jwt.js"; |
| 8 | 9 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 9 | 10 | import { atLeast, ROLE_LEVEL, type ProtectionRules, type Role } from "../repo/types.js"; |
| @@ -78,6 +79,58 @@ | ||
| 78 | 79 | if (!a) throw new HttpError(404, "not found"); |
| 79 | 80 | return c.json(userJson(c, a)); |
| 80 | 81 | }); |
| 82 | +// Quotas and what an account uses of them (lib/quotas.ts): your own, an organization's for its owners | |
| 83 | +// and admins, and any account's for site administrators, who can also change them. | |
| 84 | +const usageJson = async (c: AppContext, account: Account) => { | |
| 85 | + const [q, u] = await Promise.all([quotasFor(c.env, account.id), usageOf(c.env, account.id)]); | |
| 86 | + return { | |
| 87 | + account: account.login, | |
| 88 | + exempt: q.exempt, | |
| 89 | + period_start: monthStart(), | |
| 90 | + hosted_minutes: { used: u.hosted_minutes, limit: q.hosted_minutes }, | |
| 91 | + hosted_jobs: { running: u.hosted_jobs, limit: q.hosted_jobs }, | |
| 92 | + sandboxes: { open: u.sandboxes, limit: q.sandboxes }, | |
| 93 | + repositories: { count: u.repositories, limit: q.repositories }, | |
| 94 | + }; | |
| 95 | +}; | |
| 96 | +api.get("/user/usage", async (c) => { | |
| 97 | + requireAccountScope(c, "read"); | |
| 98 | + const me = requireAccount(c); | |
| 99 | + return c.json(await usageJson(c, me.kind === "agent" && me.owner_id ? ((await c.get("dir").account(me.owner_id)) ?? me) : me)); | |
| 100 | +}); | |
| 101 | +api.get("/orgs/:org/usage", async (c) => { | |
| 102 | + requireAccountScope(c, "read"); | |
| 103 | + const a = requireAccount(c); | |
| 104 | + const dir = c.get("dir"); | |
| 105 | + const org = await dir.accountByLogin(c.req.param("org")); | |
| 106 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 107 | + const role = await dir.orgRole(org.id, a.id); | |
| 108 | + if (!role && !a.site_admin) throw new HttpError(404, "not found"); | |
| 109 | + if (role !== "owner" && role !== "admin" && !a.site_admin) throw new HttpError(403, "only organization owners and admins can see its usage"); | |
| 110 | + return c.json(await usageJson(c, org)); | |
| 111 | +}); | |
| 112 | +const quotaTarget = async (c: AppContext) => { | |
| 113 | + const a = requireAccount(c); | |
| 114 | + if (!a.site_admin) throw new HttpError(403, "only site administrators can see and change quotas"); | |
| 115 | + const account = await c.get("dir").accountByLogin(c.req.param("login") ?? ""); | |
| 116 | + if (!account || account.kind === "agent") throw new HttpError(404, "no person or organization with that name"); | |
| 117 | + return { a, account }; | |
| 118 | +}; | |
| 119 | +api.get("/admin/accounts/:login/quotas", async (c) => { | |
| 120 | + requireAccountScope(c, "read"); | |
| 121 | + const { account } = await quotaTarget(c); | |
| 122 | + return c.json(await usageJson(c, account)); | |
| 123 | +}); | |
| 124 | +api.patch("/admin/accounts/:login/quotas", async (c) => { | |
| 125 | + requireAccountScope(c, "admin"); | |
| 126 | + requireUnlimitedToken(c, "change quotas"); | |
| 127 | + const { a, account } = await quotaTarget(c); | |
| 128 | + const b = await body<Record<string, number | null>>(c); | |
| 129 | + if (!b || typeof b !== "object" || Array.isArray(b)) throw new HttpError(422, "send the quotas to change, e.g. {\"hosted_minutes\": 6000}"); | |
| 130 | + await setQuotas(c.env, account.id, b, a.login); | |
| 131 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: a.id, actorLogin: a.login, action: "account.quotas.set", target: account.login, detail: b })); | |
| 132 | + return c.json(await usageJson(c, account)); | |
| 133 | +}); | |
| 81 | 134 | api.get("/user/orgs", async (c) => c.json((await c.get("dir").orgsFor(requireAccount(c).id)).map((o) => ({ ...userJson(c, o), role: o.role })))); |
| 82 | 135 | api.post("/user/orgs", async (c) => { |
| 83 | 136 | requireAccountScope(c, "admin"); |
modifiedapps/diffinery/src/lib/directory.ts+3−0View file
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | import { enqueueMail } from "../mail/send.js"; |
| 7 | 7 | import { archivePrefix, deletePrefixes } from "./storage-keys.js"; |
| 8 | 8 | import { inviteProof } from "../auth/invite-proof.js"; |
| 9 | +import { checkRepositoryQuota } from "./quotas.js"; | |
| 9 | 10 | |
| 10 | 11 | export interface Account { |
| 11 | 12 | id: string; |
| @@ -678,6 +679,7 @@ | ||
| 678 | 679 | if (role === "member") creatorGrant = subject; |
| 679 | 680 | } else if (owner.id !== creator.id && !creator.site_admin) throw new HttpError(403, "you can only create repositories for yourself or your organizations"); |
| 680 | 681 | if (await this.repo(owner.login, input.name)) throw new HttpError(422, "a repository with this name already exists"); |
| 682 | + await checkRepositoryQuota(this.env, owner.id, owner.login); | |
| 681 | 683 | const id = newId(); |
| 682 | 684 | const t = now(); |
| 683 | 685 | const branch = input.defaultBranch || "main"; |
| @@ -759,6 +761,7 @@ | ||
| 759 | 761 | const name = newName?.trim() || repo.name; |
| 760 | 762 | if (!isValidName(name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'", { errors: [{ resource: "Repository", code: "invalid", field: "new_name" }] }); |
| 761 | 763 | if (await this.repo(target.login, name)) throw new HttpError(422, `${target.login} already has a repository named ${name}`, { errors: [{ resource: "Repository", code: "custom", field: "name", message: "name already exists on this account" }] }); |
| 764 | + await checkRepositoryQuota(this.env, target.id, target.login); | |
| 762 | 765 | // Packages linked to the repository move with it; an image of the same name under the target would collide. |
| 763 | 766 | const clash = await this.db |
| 764 | 767 | .prepare("SELECT p.name FROM packages p WHERE p.owner_id = ? AND p.name IN (SELECT name FROM packages WHERE repo_id = ?) LIMIT 1") |
addedapps/diffinery/src/lib/quotas.ts+145−0View file
| @@ -1,0 +1,145 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import { HttpError, now } from "./util.js"; | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * Per-account quotas on what Diffinery runs and keeps for an account. The account that pays is the | |
| 6 | + * owner of the repository (a person or an organization), whoever started the work. | |
| 7 | + * | |
| 8 | + * - hosted_minutes: minutes of hosted Actions jobs and agent sandboxes per calendar month (UTC). | |
| 9 | + * - hosted_jobs: hosted Actions jobs running at once across the account's repositories; more wait. | |
| 10 | + * - sandboxes: agent sandboxes open at once across the account's repositories. | |
| 11 | + * - repositories: repositories the account owns. | |
| 12 | + * | |
| 13 | + * Defaults come from the environment (QUOTA_*) and otherwise DEFAULT_QUOTAS; a site administrator | |
| 14 | + * can override them per account (account_quotas). -1 means unlimited. Accounts run by a site | |
| 15 | + * administrator (one, or an organization with one as owner) have no quotas, so the instance's own | |
| 16 | + * work never stops on them. | |
| 17 | + */ | |
| 18 | +export type QuotaKey = "hosted_minutes" | "hosted_jobs" | "sandboxes" | "repositories"; | |
| 19 | +export const QUOTA_KEYS: readonly QuotaKey[] = ["hosted_minutes", "hosted_jobs", "sandboxes", "repositories"]; | |
| 20 | +export type Quotas = Record<QuotaKey, number>; | |
| 21 | + | |
| 22 | +export const DEFAULT_QUOTAS: Quotas = { hosted_minutes: 3000, hosted_jobs: 5, sandboxes: 5, repositories: 500 }; | |
| 23 | +const ENV_KEYS: Record<QuotaKey, string> = { hosted_minutes: "QUOTA_HOSTED_MINUTES", hosted_jobs: "QUOTA_HOSTED_JOBS", sandboxes: "QUOTA_SANDBOXES", repositories: "QUOTA_REPOSITORIES" }; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * The longest a job or sandbox can run (a job's 6 hours plus start-up, a sandbox's 24-hour token): | |
| 27 | + * a row still open after that is counted as ended then, so a lost end can't hold a slot forever. | |
| 28 | + */ | |
| 29 | +export const MAX_RUN_MS: Record<"job" | "sandbox", number> = { job: 7 * 3600_000, sandbox: 24 * 3600_000 }; | |
| 30 | + | |
| 31 | +const UNLIMITED: Quotas = { hosted_minutes: -1, hosted_jobs: -1, sandboxes: -1, repositories: -1 }; | |
| 32 | + | |
| 33 | +function envDefault(env: Env, k: QuotaKey): number { | |
| 34 | + const raw = (env as unknown as Record<string, string | undefined>)[ENV_KEYS[k]]; | |
| 35 | + const n = raw === undefined || raw === "" ? NaN : Math.trunc(Number(raw)); | |
| 36 | + return Number.isFinite(n) && n >= -1 ? n : DEFAULT_QUOTAS[k]; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** Whether the account is run by a site administrator (itself, or an organization with one as owner). */ | |
| 40 | +async function adminRun(env: Env, accountId: string): Promise<boolean> { | |
| 41 | + const r = await env.DB.prepare( | |
| 42 | + "SELECT 1 FROM accounts a WHERE a.id = ? AND (a.site_admin = 1 OR EXISTS (SELECT 1 FROM org_members m JOIN accounts o ON o.id = m.user_id WHERE m.org_id = a.id AND m.role = 'owner' AND o.site_admin = 1 AND o.status = 'active'))", | |
| 43 | + ) | |
| 44 | + .bind(accountId) | |
| 45 | + .first(); | |
| 46 | + return !!r; | |
| 47 | +} | |
| 48 | + | |
| 49 | +/** The quotas that apply to an account, and whether they're its own (overridden) or the defaults. */ | |
| 50 | +export async function quotasFor(env: Env, accountId: string): Promise<Quotas & { exempt: boolean }> { | |
| 51 | + if (await adminRun(env, accountId)) return { ...UNLIMITED, exempt: true }; | |
| 52 | + const o = await env.DB.prepare("SELECT * FROM account_quotas WHERE account_id = ?").bind(accountId).first<Record<QuotaKey, number | null>>(); | |
| 53 | + const q = {} as Quotas; | |
| 54 | + for (const k of QUOTA_KEYS) q[k] = o?.[k] ?? envDefault(env, k); | |
| 55 | + return { ...q, exempt: false }; | |
| 56 | +} | |
| 57 | + | |
| 58 | +/** Start of the current calendar month (UTC). */ | |
| 59 | +export function monthStart(at = new Date()): string { | |
| 60 | + return new Date(Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), 1)).toISOString(); | |
| 61 | +} | |
| 62 | + | |
| 63 | +export interface Usage { | |
| 64 | + /** Whole minutes of hosted jobs and sandboxes this month, rounded up per job or sandbox. */ | |
| 65 | + hosted_minutes: number; | |
| 66 | + hosted_jobs: number; | |
| 67 | + sandboxes: number; | |
| 68 | + repositories: number; | |
| 69 | +} | |
| 70 | + | |
| 71 | +/** What an account uses now. */ | |
| 72 | +export async function usageOf(env: Env, accountId: string, at = new Date()): Promise<Usage> { | |
| 73 | + const t = at.getTime(); | |
| 74 | + const month = monthStart(at); | |
| 75 | + // Anything that started earlier ended before the month began (or is counted as ended by then). | |
| 76 | + const rows = await env.DB.prepare("SELECT kind, started_at, ended_at FROM compute_usage WHERE account_id = ? AND started_at >= ?") | |
| 77 | + .bind(accountId, new Date(Math.min(Date.parse(month), t) - MAX_RUN_MS.sandbox).toISOString()) | |
| 78 | + .all<{ kind: "job" | "sandbox"; started_at: string; ended_at: string | null }>(); | |
| 79 | + let minutes = 0; | |
| 80 | + let jobs = 0; | |
| 81 | + let sandboxes = 0; | |
| 82 | + for (const r of rows.results) { | |
| 83 | + const start = Date.parse(r.started_at); | |
| 84 | + const cap = start + MAX_RUN_MS[r.kind]; | |
| 85 | + const end = Math.min(r.ended_at ? Date.parse(r.ended_at) : t, cap); | |
| 86 | + const from = Math.max(start, Date.parse(month)); | |
| 87 | + if (end > from) minutes += Math.ceil((end - from) / 60_000); | |
| 88 | + if (!r.ended_at && t < cap) { | |
| 89 | + if (r.kind === "job") jobs++; | |
| 90 | + else sandboxes++; | |
| 91 | + } | |
| 92 | + } | |
| 93 | + const repos = await env.DB.prepare("SELECT count(*) AS n FROM repos WHERE owner_id = ?").bind(accountId).first<{ n: number }>(); | |
| 94 | + return { hosted_minutes: minutes, hosted_jobs: jobs, sandboxes, repositories: repos?.n ?? 0 }; | |
| 95 | +} | |
| 96 | + | |
| 97 | +const within = (limit: number, used: number) => limit < 0 || used < limit; | |
| 98 | + | |
| 99 | +/** | |
| 100 | + * Whether the account may start a hosted job or a sandbox now: "ok", "wait" (a job waits for one of | |
| 101 | + * the account's running jobs to end), or an error message (minutes used up, or too many sandboxes). | |
| 102 | + */ | |
| 103 | +export async function mayStart(env: Env, accountId: string, kind: "job" | "sandbox"): Promise<{ ok: true } | { ok: false; wait: boolean; message: string }> { | |
| 104 | + const q = await quotasFor(env, accountId); | |
| 105 | + if (q.exempt) return { ok: true }; | |
| 106 | + const u = await usageOf(env, accountId); | |
| 107 | + if (!within(q.hosted_minutes, u.hosted_minutes)) | |
| 108 | + return { ok: false, wait: false, message: `this account has used its ${q.hosted_minutes} hosted minutes for this month (Actions jobs on hosted runners and agent sandboxes); they renew on the 1st (UTC). Self-hosted runners still work` }; | |
| 109 | + if (kind === "job" && !within(q.hosted_jobs, u.hosted_jobs)) return { ok: false, wait: true, message: `waiting for one of this account's ${q.hosted_jobs} hosted jobs to finish` }; | |
| 110 | + if (kind === "sandbox" && !within(q.sandboxes, u.sandboxes)) return { ok: false, wait: false, message: `this account already has ${q.sandboxes} agent sandboxes open; close one or wait for one to finish` }; | |
| 111 | + return { ok: true }; | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** Record that a hosted job or sandbox started; `id` is the job or sandbox id. */ | |
| 115 | +export async function startCompute(env: Env, row: { id: string; accountId: string; repoId: string; kind: "job" | "sandbox" }): Promise<void> { | |
| 116 | + await env.DB.prepare("INSERT OR REPLACE INTO compute_usage (id, account_id, repo_id, kind, started_at) VALUES (?, ?, ?, ?, ?)").bind(row.id, row.accountId, row.repoId, row.kind, now()).run(); | |
| 117 | +} | |
| 118 | + | |
| 119 | +/** Record that a hosted job or sandbox ended (idempotent). */ | |
| 120 | +export async function endCompute(env: Env, id: string): Promise<void> { | |
| 121 | + await env.DB.prepare("UPDATE compute_usage SET ended_at = ? WHERE id = ? AND ended_at IS NULL").bind(now(), id).run(); | |
| 122 | +} | |
| 123 | + | |
| 124 | +/** Refuse a new repository past the owner's quota. */ | |
| 125 | +export async function checkRepositoryQuota(env: Env, ownerId: string, ownerLogin: string): Promise<void> { | |
| 126 | + const q = await quotasFor(env, ownerId); | |
| 127 | + if (q.exempt || q.repositories < 0) return; | |
| 128 | + const n = (await env.DB.prepare("SELECT count(*) AS n FROM repos WHERE owner_id = ?").bind(ownerId).first<{ n: number }>())?.n ?? 0; | |
| 129 | + if (n >= q.repositories) throw new HttpError(403, `${ownerLogin} has reached its limit of ${q.repositories} repositories; delete one or ask a site administrator to raise it`); | |
| 130 | +} | |
| 131 | + | |
| 132 | +/** A site administrator's overrides for an account; null clears one back to the default. */ | |
| 133 | +export async function setQuotas(env: Env, accountId: string, patch: Partial<Record<QuotaKey, number | null>>, by: string): Promise<void> { | |
| 134 | + for (const [k, v] of Object.entries(patch)) { | |
| 135 | + if (!QUOTA_KEYS.includes(k as QuotaKey)) throw new HttpError(422, `unknown quota ${k}; use ${QUOTA_KEYS.join(", ")}`); | |
| 136 | + if (v !== null && (typeof v !== "number" || !Number.isInteger(v) || v < -1 || v > 1e9)) throw new HttpError(422, `${k} must be a whole number, -1 for unlimited, or null for the default`); | |
| 137 | + } | |
| 138 | + const cur = (await env.DB.prepare("SELECT * FROM account_quotas WHERE account_id = ?").bind(accountId).first<Record<QuotaKey, number | null>>()) ?? null; | |
| 139 | + const next = Object.fromEntries(QUOTA_KEYS.map((k) => [k, k in patch ? (patch[k] ?? null) : (cur?.[k] ?? null)])) as Record<QuotaKey, number | null>; | |
| 140 | + await env.DB.prepare( | |
| 141 | + "INSERT INTO account_quotas (account_id, hosted_minutes, hosted_jobs, sandboxes, repositories, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(account_id) DO UPDATE SET hosted_minutes = excluded.hosted_minutes, hosted_jobs = excluded.hosted_jobs, sandboxes = excluded.sandboxes, repositories = excluded.repositories, updated_at = excluded.updated_at, updated_by = excluded.updated_by", | |
| 142 | + ) | |
| 143 | + .bind(accountId, next.hosted_minutes, next.hosted_jobs, next.sandboxes, next.repositories, now(), by) | |
| 144 | + .run(); | |
| 145 | +} |
modifiedapps/diffinery/src/repo/RepoDO.ts+15−2View file
| @@ -73,6 +73,8 @@ | ||
| 73 | 73 | export const MAX_PURGE_OBJECTS = 300_000; |
| 74 | 74 | /** Storage key that marks a deleted repository whose R2 objects are still being deleted. */ |
| 75 | 75 | const PURGE_KEY = "purge"; |
| 76 | +/** How often jobs waiting on the account's hosted-job quota are retried. */ | |
| 77 | +const HELD_RETRY_MS = 60_000; | |
| 76 | 78 | |
| 77 | 79 | export type TreeItem = { |
| 78 | 80 | name: string; |
| @@ -221,15 +223,26 @@ | ||
| 221 | 223 | this.ctx.waitUntil(p.catch((e) => console.error("background task failed", e))); |
| 222 | 224 | } |
| 223 | 225 | |
| 224 | - /** Set (or with null, clear) the object's single alarm; the merge queue uses it for check timeouts. */ | |
| 226 | + /** | |
| 227 | + * Set (or with null, clear) the object's single alarm. The merge queue uses it for check timeouts; | |
| 228 | + * while jobs wait on the account's hosted-job quota it also fires every minute to retry them. | |
| 229 | + */ | |
| 225 | 230 | armAlarm(at: number | null): void { |
| 226 | - this.ctx.waitUntil(at === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(at)); | |
| 231 | + const held = this.actions.hasAccountHeldJobs() ? Date.now() + HELD_RETRY_MS : null; | |
| 232 | + const next = at === null ? held : held === null ? at : Math.min(at, held); | |
| 233 | + this.ctx.waitUntil(next === null ? this.ctx.storage.deleteAlarm() : this.ctx.storage.setAlarm(next)); | |
| 227 | 234 | } |
| 228 | 235 | |
| 229 | 236 | override async alarm(): Promise<void> { |
| 230 | 237 | const purging = await this.ctx.storage.get<string>(PURGE_KEY); |
| 231 | 238 | if (purging) return this.purge(purging); |
| 232 | 239 | await this.queue.process(); |
| 240 | + if (this.actions.hasAccountHeldJobs()) { | |
| 241 | + await this.actions.releaseAccountHeld(); | |
| 242 | + // Keep the queue's own alarm if it set an earlier one. | |
| 243 | + const current = await this.ctx.storage.getAlarm(); | |
| 244 | + if (this.actions.hasAccountHeldJobs() && (current === null || current <= Date.now() || current > Date.now() + HELD_RETRY_MS)) await this.ctx.storage.setAlarm(Date.now() + HELD_RETRY_MS); | |
| 245 | + } | |
| 233 | 246 | } |
| 234 | 247 | |
| 235 | 248 | /** Queue an event (webhooks, notifications) and show it live to open pages; `live: false` skips the pages (e.g. draft releases, which readers can't see). */ |
modifiedapps/diffinery/src/repo/actions.ts+45−3View file
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | import { signRuntimeToken } from "../lib/runtime-auth.js"; |
| 23 | 23 | import { ghRepo, numericId } from "../http/gh.js"; |
| 24 | 24 | import { HttpError, newId, now, randomToken, sha256 } from "../lib/util.js"; |
| 25 | +import { endCompute, mayStart, startCompute } from "../lib/quotas.js"; | |
| 25 | 26 | import type { Env } from "../env.js"; |
| 26 | 27 | import type { RepoDO } from "./RepoDO.js"; |
| 27 | 28 | import { completePayload } from "./event-payloads.js"; |
| @@ -983,6 +984,19 @@ | ||
| 983 | 984 | 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}` }); |
| 984 | 985 | return; |
| 985 | 986 | } |
| 987 | + // The account that owns the repository pays (lib/quotas.ts): past its monthly minutes the job | |
| 988 | + // fails; past its hosted jobs at once it waits (fleet_held = 2, retried by the alarm) for one to end. | |
| 989 | + const quota = await mayStart(this.env, m.ownerId, "job").catch((e: unknown) => { | |
| 990 | + console.error("quota check failed", e); | |
| 991 | + return { ok: true } as const; | |
| 992 | + }); | |
| 993 | + if (!quota.ok) { | |
| 994 | + if (!quota.wait) return this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job was not started: ${quota.message}.` } }); | |
| 995 | + this.sql.exec("UPDATE jobs SET status = 'queued', fleet_held = 2 WHERE id = ?", jobId); | |
| 996 | + 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}` }); | |
| 997 | + this.repo.armAlarm(null); | |
| 998 | + return; | |
| 999 | + } | |
| 986 | 1000 | // One repository can't take the whole fleet: past its share, the job waits for one of its own to finish. |
| 987 | 1001 | 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; |
| 988 | 1002 | if (busy >= MAX_HOSTED_JOBS) { |
| @@ -994,6 +1008,7 @@ | ||
| 994 | 1008 | const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`; |
| 995 | 1009 | const container = `job-${jobId}`; |
| 996 | 1010 | this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId); |
| 1011 | + await startCompute(this.env, { id: jobId, accountId: m.ownerId, repoId: m.repoId, kind: "job" }).catch((e: unknown) => console.error("compute usage failed", e)); | |
| 997 | 1012 | 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}` }); |
| 998 | 1013 | try { |
| 999 | 1014 | const stub = this.env.RUNNER.get(this.env.RUNNER.idFromName(container)); |
| @@ -1219,6 +1234,8 @@ | ||
| 1219 | 1234 | return; |
| 1220 | 1235 | } |
| 1221 | 1236 | this.sql.exec("UPDATE jobs SET status = 'queued', container = NULL, token_hash = NULL, placement_tries = ? WHERE id = ?", tries, jobId); |
| 1237 | + // Not running while it waits: it holds none of the account's hosted jobs and uses no minutes. | |
| 1238 | + await endCompute(this.env, jobId).catch((e: unknown) => console.error("compute usage failed", e)); | |
| 1222 | 1239 | this.repo.broadcast(`run:${job.run_id}`, { action: "job", jobId }); |
| 1223 | 1240 | try { |
| 1224 | 1241 | const instance = await this.env.RUN_WORKFLOW.get(job.run_id); |
| @@ -1261,9 +1278,26 @@ | ||
| 1261 | 1278 | return true; |
| 1262 | 1279 | } |
| 1263 | 1280 | |
| 1281 | + /** Whether jobs wait on the account's hosted-job quota (RepoDO retries them on its alarm). */ | |
| 1282 | + hasAccountHeldJobs(): boolean { | |
| 1283 | + return this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM jobs WHERE fleet_held = 2 AND status = 'queued'").one().n > 0; | |
| 1284 | + } | |
| 1285 | + | |
| 1286 | + /** Try again to start jobs waiting on the account's hosted-job quota, oldest first, until one waits again. */ | |
| 1287 | + async releaseAccountHeld(): Promise<void> { | |
| 1288 | + for (const { id } of this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held = 2 AND status = 'queued' ORDER BY rowid").toArray()) { | |
| 1289 | + await this.launch(id); | |
| 1290 | + if (this.jobRow(id).fleet_held === 2) return; | |
| 1291 | + } | |
| 1292 | + } | |
| 1293 | + | |
| 1264 | 1294 | async jobTimedOut(jobId: string): Promise<void> { |
| 1265 | 1295 | const j = this.jobRow(jobId); |
| 1266 | 1296 | if (j.status === "completed") return; |
| 1297 | + if (j.status === "queued" && j.fleet_held === 2) { | |
| 1298 | + await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: "The job waited 6 hours for one of this account's hosted jobs to finish, and was not started." } }, "timed_out"); | |
| 1299 | + return; | |
| 1300 | + } | |
| 1267 | 1301 | await this.stopContainer(j); |
| 1268 | 1302 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job exceeded its timeout of ${j.timeout_minutes} minutes.` } }, "timed_out"); |
| 1269 | 1303 | } |
| @@ -1760,9 +1794,10 @@ | ||
| 1760 | 1794 | } |
| 1761 | 1795 | // An ephemeral runner is done once its one job is. |
| 1762 | 1796 | if (job.runner_id) this.sql.exec("DELETE FROM runners WHERE id = ? AND ephemeral = 1 AND token_hash IS NULL", job.runner_id); |
| 1763 | - // A hosted job hands its slot to the oldest job waiting for one. | |
| 1797 | + // A hosted job hands its slot to the oldest job waiting for one (the repository's or the account's). | |
| 1764 | 1798 | if (!job.labels && job.container) { |
| 1765 | - 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]; | |
| 1799 | + await endCompute(this.env, jobId).catch((e: unknown) => console.error("compute usage failed", e)); | |
| 1800 | + const held = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held > 0 AND status = 'queued' ORDER BY rowid LIMIT 1").toArray()[0]; | |
| 1766 | 1801 | if (held) await this.launch(held.id); |
| 1767 | 1802 | } |
| 1768 | 1803 | // A job that held its concurrency group hands it to the oldest pending job (of any run). |
| @@ -1981,6 +2016,9 @@ | ||
| 1981 | 2016 | .one().n; |
| 1982 | 2017 | if (open >= MAX_OPEN_SANDBOXES) throw new HttpError(429, `you have ${open} open sandboxes in this repository, the most allowed; close one first`); |
| 1983 | 2018 | } |
| 2019 | + // The account that owns the repository pays for sandboxes too (lib/quotas.ts). | |
| 2020 | + const quota = await mayStart(this.env, this.repo.meta.ownerId, "sandbox"); | |
| 2021 | + if (!quota.ok) throw new HttpError(429, quota.message); | |
| 1984 | 2022 | const sha = await this.repo.resolve(ref ?? "HEAD"); |
| 1985 | 2023 | if (!sha) throw new HttpError(422, "unknown ref"); |
| 1986 | 2024 | const id = newId(); |
| @@ -1997,6 +2035,7 @@ | ||
| 1997 | 2035 | task ? "task" : "api", |
| 1998 | 2036 | task?.personId ?? null, |
| 1999 | 2037 | ); |
| 2038 | + await startCompute(this.env, { id, accountId: m.ownerId, repoId: m.repoId, kind: "sandbox" }); | |
| 2000 | 2039 | const stub = this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)); |
| 2001 | 2040 | try { |
| 2002 | 2041 | await stub.boot({ |
| @@ -2009,10 +2048,11 @@ | ||
| 2009 | 2048 | GIT_AUTHOR_EMAIL: `${caller.login}@users.noreply.diffinery.com`, |
| 2010 | 2049 | GIT_COMMITTER_NAME: caller.login, |
| 2011 | 2050 | GIT_COMMITTER_EMAIL: `${caller.login}@users.noreply.diffinery.com`, |
| 2012 | - }, secret, id); | |
| 2051 | + }, secret, id, { id, accountId: m.ownerId, repoId: m.repoId }); | |
| 2013 | 2052 | } catch (e) { |
| 2014 | 2053 | console.error("sandbox boot failed", e); |
| 2015 | 2054 | this.sql.exec("UPDATE sandboxes SET status = 'failed' WHERE id = ?", id); |
| 2055 | + await endCompute(this.env, id).catch(() => {}); | |
| 2016 | 2056 | await stub.shutdown().catch(() => {}); |
| 2017 | 2057 | throw new HttpError(503, "the sandbox container did not start; try again"); |
| 2018 | 2058 | } |
| @@ -2031,6 +2071,7 @@ | ||
| 2031 | 2071 | async closeSandbox(id: string, caller: Caller): Promise<void> { |
| 2032 | 2072 | this.getSandbox(id, caller); |
| 2033 | 2073 | this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id); |
| 2074 | + await endCompute(this.env, id); | |
| 2034 | 2075 | await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown(); |
| 2035 | 2076 | } |
| 2036 | 2077 | |
| @@ -2039,6 +2080,7 @@ | ||
| 2039 | 2080 | const s = this.sql.exec<{ kind: string }>("SELECT kind FROM sandboxes WHERE id = ?", id).toArray()[0]; |
| 2040 | 2081 | if (s?.kind !== "task") return; |
| 2041 | 2082 | this.sql.exec("UPDATE sandboxes SET status = 'closed' WHERE id = ?", id); |
| 2083 | + await endCompute(this.env, id); | |
| 2042 | 2084 | await this.env.SANDBOX.get(this.env.SANDBOX.idFromName(id)).shutdown(); |
| 2043 | 2085 | } |
| 2044 | 2086 | } |
modifiedapps/diffinery/src/runner/containers.ts+16−1View file
| @@ -2,6 +2,7 @@ | ||
| 2 | 2 | import { Container } from "@cloudflare/containers"; |
| 3 | 3 | import type { StopParams } from "@cloudflare/containers"; |
| 4 | 4 | import type { Env } from "../env.js"; |
| 5 | +import { endCompute, startCompute } from "../lib/quotas.js"; | |
| 5 | 6 | |
| 6 | 7 | /** |
| 7 | 8 | * One container per workflow job. The runner agent inside fetches its job spec |
| @@ -51,8 +52,10 @@ | ||
| 51 | 52 | override sleepAfter = "30m"; |
| 52 | 53 | override enableInternet = true; |
| 53 | 54 | |
| 54 | - async boot(envVars: Record<string, string>, secret: string, _name?: string): Promise<void> { | |
| 55 | + async boot(envVars: Record<string, string>, secret: string, _name?: string, usage?: { id: string; accountId: string; repoId: string }): Promise<void> { | |
| 55 | 56 | await this.ctx.storage.put("secret", secret); |
| 57 | + // Its running time counts against the repository owner's hosted minutes (lib/quotas.ts). | |
| 58 | + if (usage) await this.ctx.storage.put("usage", usage); | |
| 56 | 59 | // The first start on a host after a deploy pulls the runner image, which takes |
| 57 | 60 | // well over the library's 8 s / 20 s defaults. |
| 58 | 61 | await this.startAndWaitForPorts({ |
| @@ -65,6 +68,13 @@ | ||
| 65 | 68 | /** Forward an API call (JSON text) to the sandbox's HTTP server with its secret attached. */ |
| 66 | 69 | async call(path: string, body: string): Promise<{ status: number; body: string; json: boolean }> { |
| 67 | 70 | const secret = await this.ctx.storage.get<string>("secret"); |
| 71 | + // A sandbox that slept after 30 idle minutes starts again for this call: its time counts again. | |
| 72 | + const usage = await this.ctx.storage.get<{ id: string; accountId: string; repoId: string; part?: number }>("usage"); | |
| 73 | + if (usage && !this.ctx.container?.running) { | |
| 74 | + const part = (usage.part ?? 0) + 1; | |
| 75 | + await startCompute(this.env, { id: `${usage.id}:${part}`, accountId: usage.accountId, repoId: usage.repoId, kind: "sandbox" }); | |
| 76 | + await this.ctx.storage.put("usage", { ...usage, part }); | |
| 77 | + } | |
| 68 | 78 | const res = await this.containerFetch(`http://sandbox${path}`, { |
| 69 | 79 | method: "POST", |
| 70 | 80 | headers: { "content-type": "application/json", authorization: `Bearer ${secret}` }, |
| @@ -76,6 +86,11 @@ | ||
| 76 | 86 | async shutdown(): Promise<void> { |
| 77 | 87 | if (this.ctx.container?.running) await this.stop(); |
| 78 | 88 | } |
| 89 | + | |
| 90 | + override async onStop(): Promise<void> { | |
| 91 | + const usage = await this.ctx.storage.get<{ id: string; part?: number }>("usage"); | |
| 92 | + if (usage) await endCompute(this.env, usage.part ? `${usage.id}:${usage.part}` : usage.id); | |
| 93 | + } | |
| 79 | 94 | } |
| 80 | 95 | |
| 81 | 96 | /** |
modifiedapps/diffinery/test/e2e/security.test.mjs+61−1View file
| @@ -39,7 +39,7 @@ | ||
| 39 | 39 | |
| 40 | 40 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 41 | 41 | const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; |
| 42 | -const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") }; | |
| 42 | +const T = { sully: tok("sully"), olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") }; | |
| 43 | 43 | const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}`, sam: `sam-session-${randomBytes(12).toString("hex")}`, oliveOld: `olive-old-${randomBytes(12).toString("hex")}`, oliveStale: `olive-stale-${randomBytes(12).toString("hex")}`, oliveSpare: `olive-spare-${randomBytes(12).toString("hex")}` }; |
| 44 | 44 | const OWNER = "olive"; |
| 45 | 45 | |
| @@ -139,6 +139,9 @@ | ||
| 139 | 139 | "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", |
| 140 | 140 | [ |
| 141 | 141 | account("u-olive", "olive", "user", "active"), |
| 142 | + account("u-sully", "sully", "user", "active"), | |
| 143 | + "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'", | |
| 144 | + token("t-sully", "u-sully", T.sully, ["admin"]), | |
| 142 | 145 | account("a-bot", "olive-bot", "agent", "active", "u-olive"), |
| 143 | 146 | account("u-rita", "rita", "user", "active"), |
| 144 | 147 | account("u-penny", "penny", "user", "pending"), |
| @@ -2354,3 +2357,60 @@ | ||
| 2354 | 2357 | await status(204, "DELETE", `/repos/${OWNER}/invited-then-deleted`); |
| 2355 | 2358 | assert.deepEqual(await ok("GET", "/user/repository_invitations", undefined, T.rita), []); |
| 2356 | 2359 | }); |
| 2360 | + | |
| 2361 | +test("security: per-account quotas on hosted minutes, hosted jobs at once, sandboxes and repositories", async () => { | |
| 2362 | + // Only site administrators see or change another account's quotas. | |
| 2363 | + await status(403, "PATCH", "/admin/accounts/rita/quotas", { repositories: 1 }); | |
| 2364 | + await status(403, "GET", "/admin/accounts/rita/quotas", undefined, T.rita); | |
| 2365 | + await status(422, "PATCH", "/admin/accounts/rita/quotas", { storage: 1 }, T.sully); | |
| 2366 | + const set = await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: 1, hosted_minutes: 0, sandboxes: 0 }, T.sully); | |
| 2367 | + assert.deepEqual([set.repositories.limit, set.hosted_minutes.limit, set.sandboxes.limit, set.exempt], [1, 0, 0, false]); | |
| 2368 | + await until("the audit entry", async () => auditLog("account.quotas.set").some((e) => e.target === "rita" && e.actor_login === "sully")); | |
| 2369 | + // rita sees her own usage and limits. | |
| 2370 | + const mine = await ok("GET", "/user/usage", undefined, T.rita); | |
| 2371 | + assert.deepEqual([mine.account, mine.repositories.limit, mine.hosted_minutes.used], ["rita", 1, 0]); | |
| 2372 | + | |
| 2373 | + // ------------------------------------------------------------ repositories | |
| 2374 | + const owned = mine.repositories.count; | |
| 2375 | + await setRitaRepos(owned + 1); | |
| 2376 | + await ok("POST", "/user/repos", { name: "rita-quota-1" }, T.rita); | |
| 2377 | + assert.match((await status(403, "POST", "/user/repos", { name: "rita-quota-2" }, T.rita)).message, /rita has reached its limit of \d+ repositories/); | |
| 2378 | + | |
| 2379 | + // ------------------------------------------------------------ hosted minutes: a hosted job isn't started | |
| 2380 | + const R = "/repos/rita/rita-quota-1"; | |
| 2381 | + await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n") }, T.rita); | |
| 2382 | + const job = await until("the job to fail", async () => { | |
| 2383 | + const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs; | |
| 2384 | + if (!runs.length) return null; | |
| 2385 | + const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs; | |
| 2386 | + return jobs[0]?.status === "completed" ? { run: runs[0].id, job: jobs[0] } : null; | |
| 2387 | + }); | |
| 2388 | + assert.equal(job.job.conclusion, "failure"); | |
| 2389 | + const page = await (await fetch(`${base}/rita/rita-quota-1/actions/runs/${job.run}`, { headers: { authorization: `Bearer ${T.rita}` } })).text(); | |
| 2390 | + assert.match(page, /used its 0 hosted minutes for this month/); | |
| 2391 | + | |
| 2392 | + // ------------------------------------------------------------ sandboxes: the minutes count for them too, and so does their own limit | |
| 2393 | + assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /used its 0 hosted minutes/); | |
| 2394 | + await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_minutes: null, hosted_jobs: 0 }, T.sully); | |
| 2395 | + assert.match((await status(429, "POST", `${R}/diffinery/sandboxes`, {}, T.rita)).message, /already has 0 agent sandboxes open/); | |
| 2396 | + | |
| 2397 | + // ------------------------------------------------------------ hosted jobs at once: the job waits, then starts once there's room | |
| 2398 | + const file = await ok("GET", `${R}/contents/.github/workflows/ci.yml`, undefined, T.rita); | |
| 2399 | + await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "again", sha: file.sha, content: b64("name: ci\non: push\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo again\n") }, T.rita); | |
| 2400 | + const held = await until("a second run", async () => { | |
| 2401 | + const runs = (await ok("GET", `${R}/actions/runs`, undefined, T.rita)).workflow_runs; | |
| 2402 | + if (runs.length < 2) return null; | |
| 2403 | + const jobs = (await ok("GET", `${R}/actions/runs/${runs[0].id}/jobs`, undefined, T.rita)).jobs; | |
| 2404 | + return jobs[0] ? { run: runs[0].id, job: jobs[0] } : null; | |
| 2405 | + }); | |
| 2406 | + await sleep(3000); | |
| 2407 | + assert.equal((await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status, "queued"); | |
| 2408 | + // With room again, the alarm starts it within a minute (here it then fails: no containers under wrangler dev). | |
| 2409 | + await ok("PATCH", "/admin/accounts/rita/quotas", { hosted_jobs: null }, T.sully); | |
| 2410 | + await until("the held job to leave the queue", async () => (await ok("GET", `${R}/actions/runs/${held.run}/jobs`, undefined, T.rita)).jobs[0].status !== "queued", 120_000); | |
| 2411 | +}); | |
| 2412 | + | |
| 2413 | +/** Set rita's repository quota (site administrators only). */ | |
| 2414 | +async function setRitaRepos(n) { | |
| 2415 | + await ok("PATCH", "/admin/accounts/rita/quotas", { repositories: n }, T.sully); | |
| 2416 | +} |
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+10−1View file
| @@ -2,6 +2,7 @@ | ||
| 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 | 4 | import { Actions, MAX_ACTIVE_RUNS, MAX_HOSTED_JOBS, MAX_PLACEMENT_TRIES, placementDelay } from "../../src/repo/actions.js"; |
| 5 | +import { testD1 } from "./stubs/d1.js"; | |
| 5 | 6 | import { DEFAULT_ARTIFACT_QUOTA, Runtime, artifactFileName, artifactObjectKeys } from "../../src/repo/runtime.js"; |
| 6 | 7 | import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js"; |
| 7 | 8 | import type { RepoDO } from "../../src/repo/RepoDO.js"; |
| @@ -688,13 +689,17 @@ | ||
| 688 | 689 | const events: { type: string; payload: Record<string, unknown> }[] = []; |
| 689 | 690 | const repo = { |
| 690 | 691 | sql, |
| 691 | - meta: { repoId: "r", owner: "acme", name: "app", defaultBranch: "main" }, | |
| 692 | + meta: { repoId: "r", owner: "acme", ownerId: "acme", name: "app", defaultBranch: "main" }, | |
| 692 | 693 | upsertCheck: () => {}, |
| 693 | 694 | broadcast: () => {}, |
| 694 | 695 | deploys: { jobCompleted: () => {} }, |
| 695 | 696 | runtime: { sweepJob: async () => {} }, |
| 696 | 697 | } as unknown as RepoDO; |
| 698 | + const DB = testD1(); | |
| 699 | + DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, created_at) VALUES ('acme', 'acme', 'org', 'active', ?)").run(new Date().toISOString()); | |
| 700 | + const running = () => DB.sqlite.prepare("SELECT id FROM compute_usage WHERE ended_at IS NULL").all(); | |
| 697 | 701 | const env = { |
| 702 | + DB, | |
| 698 | 703 | PUBLIC_URL: "https://d.test", |
| 699 | 704 | BUCKET: memoryBucket().bucket, |
| 700 | 705 | RUNNER: { |
| @@ -719,14 +724,18 @@ | ||
| 719 | 724 | await launch("j1"); |
| 720 | 725 | expect(job("j1")).toMatchObject({ status: "queued", conclusion: null, container: null, placement_tries: 1 }); |
| 721 | 726 | expect(events).toEqual([{ type: "job-j1", payload: { retryInSeconds: placementDelay(1) } }]); |
| 727 | + // While it waits it isn't running, so it holds none of the account's hosted jobs (lib/quotas.ts). | |
| 728 | + expect(running()).toEqual([]); | |
| 722 | 729 | |
| 723 | 730 | // Its container stopping before the runner reported in counts the same way; a started job's loss doesn't. |
| 724 | 731 | full = false; |
| 725 | 732 | await actions.relaunchJob("j1"); |
| 726 | 733 | expect(job("j1")).toMatchObject({ status: "waiting", container: "job-j1" }); |
| 734 | + expect(running()).toEqual([{ id: "j1" }]); | |
| 727 | 735 | await actions.containerStopped("j1", null); |
| 728 | 736 | expect(job("j1")).toMatchObject({ status: "queued", placement_tries: 2 }); |
| 729 | 737 | expect(events.at(-1)).toEqual({ type: "job-j1", payload: { retryInSeconds: placementDelay(2) } }); |
| 738 | + expect(running()).toEqual([]); | |
| 730 | 739 | await launch("j2"); |
| 731 | 740 | sql.exec("UPDATE jobs SET status = 'in_progress' WHERE id = 'j2'"); |
| 732 | 741 | await actions.containerStopped("j2", null); |
addedapps/diffinery/test/unit/quotas.test.ts+87−0View file
| @@ -1,0 +1,87 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { checkRepositoryQuota, DEFAULT_QUOTAS, endCompute, mayStart, monthStart, quotasFor, setQuotas, startCompute, usageOf } from "../../src/lib/quotas.js"; | |
| 3 | +import type { Env } from "../../src/env.js"; | |
| 4 | +import { testD1 } from "./stubs/d1.js"; | |
| 5 | + | |
| 6 | +const at = "2026-10-01T00:00:00.000Z"; | |
| 7 | + | |
| 8 | +function setup(vars: Record<string, string> = {}) { | |
| 9 | + const DB = testD1(); | |
| 10 | + const add = (id: string, kind: string, siteAdmin = 0) => DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, site_admin, created_at) VALUES (?, ?, ?, 'active', ?, ?)").run(id, id, kind, siteAdmin, at); | |
| 11 | + add("ann", "user"); | |
| 12 | + add("root", "user", 1); | |
| 13 | + add("acme", "org"); | |
| 14 | + add("ops", "org"); | |
| 15 | + DB.sqlite.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme', 'ann', 'owner', ?), ('ops', 'root', 'owner', ?)").run(at, at); | |
| 16 | + const usage = (id: string, account: string, kind: "job" | "sandbox", start: string, end: string | null) => | |
| 17 | + DB.sqlite.prepare("INSERT INTO compute_usage (id, account_id, repo_id, kind, started_at, ended_at) VALUES (?, ?, 'r', ?, ?, ?)").run(id, account, kind, start, end); | |
| 18 | + return { env: { DB, ...vars } as unknown as Env, DB, usage }; | |
| 19 | +} | |
| 20 | + | |
| 21 | +describe("quotas", () => { | |
| 22 | + it("uses the defaults, the environment's, then a site administrator's override", async () => { | |
| 23 | + const { env } = setup(); | |
| 24 | + expect(await quotasFor(env, "ann")).toEqual({ ...DEFAULT_QUOTAS, exempt: false }); | |
| 25 | + const fromEnv = setup({ QUOTA_HOSTED_MINUTES: "120", QUOTA_SANDBOXES: "junk" }).env; | |
| 26 | + expect((await quotasFor(fromEnv, "ann")).hosted_minutes).toBe(120); | |
| 27 | + expect((await quotasFor(fromEnv, "ann")).sandboxes).toBe(DEFAULT_QUOTAS.sandboxes); | |
| 28 | + await setQuotas(env, "ann", { hosted_jobs: 1, repositories: -1 }, "root"); | |
| 29 | + await setQuotas(env, "ann", { hosted_minutes: 10 }, "root"); | |
| 30 | + expect(await quotasFor(env, "ann")).toEqual({ ...DEFAULT_QUOTAS, hosted_jobs: 1, repositories: -1, hosted_minutes: 10, exempt: false }); | |
| 31 | + await setQuotas(env, "ann", { hosted_jobs: null }, "root"); | |
| 32 | + expect((await quotasFor(env, "ann")).hosted_jobs).toBe(DEFAULT_QUOTAS.hosted_jobs); | |
| 33 | + await expect(setQuotas(env, "ann", { storage: 1 } as never, "root")).rejects.toThrow(/unknown quota storage/); | |
| 34 | + await expect(setQuotas(env, "ann", { hosted_jobs: -5 }, "root")).rejects.toThrow(/whole number/); | |
| 35 | + }); | |
| 36 | + | |
| 37 | + it("doesn't limit accounts run by a site administrator", async () => { | |
| 38 | + const { env } = setup(); | |
| 39 | + expect((await quotasFor(env, "root")).exempt).toBe(true); | |
| 40 | + expect((await quotasFor(env, "ops")).exempt).toBe(true); | |
| 41 | + expect((await quotasFor(env, "acme")).exempt).toBe(false); | |
| 42 | + }); | |
| 43 | + | |
| 44 | + it("counts this month's minutes and what is running now", async () => { | |
| 45 | + const { env, usage } = setup(); | |
| 46 | + const now = new Date("2026-10-13T12:00:00.000Z"); | |
| 47 | + usage("j1", "acme", "job", "2026-10-13T10:00:00.000Z", "2026-10-13T10:10:30.000Z"); // 11 minutes (rounded up) | |
| 48 | + usage("j2", "acme", "job", "2026-10-13T11:30:00.000Z", null); // running: 30 minutes so far | |
| 49 | + usage("s1", "acme", "sandbox", "2026-10-13T11:00:00.000Z", null); // open: 60 minutes | |
| 50 | + usage("j3", "acme", "job", "2026-09-30T23:50:00.000Z", "2026-10-01T00:20:00.000Z"); // 20 of its minutes are this month's | |
| 51 | + usage("j4", "acme", "job", "2026-09-10T10:00:00.000Z", "2026-09-10T11:00:00.000Z"); // last month | |
| 52 | + usage("j5", "acme", "job", "2026-10-12T00:00:00.000Z", null); // lost its end: counted as ended after 7 hours | |
| 53 | + usage("j6", "ann", "job", "2026-10-13T11:00:00.000Z", null); // someone else's | |
| 54 | + expect(await usageOf(env, "acme", now)).toEqual({ hosted_minutes: 11 + 30 + 60 + 20 + 420, hosted_jobs: 1, sandboxes: 1, repositories: 0 }); | |
| 55 | + expect(monthStart(now)).toBe("2026-10-01T00:00:00.000Z"); | |
| 56 | + }); | |
| 57 | + | |
| 58 | + it("refuses work past the minutes, holds jobs past the running jobs, and refuses sandboxes past theirs", async () => { | |
| 59 | + const { env } = setup(); | |
| 60 | + await setQuotas(env, "acme", { hosted_jobs: 1, sandboxes: 1 }, "root"); | |
| 61 | + expect(await mayStart(env, "acme", "job")).toEqual({ ok: true }); | |
| 62 | + await startCompute(env, { id: "job-a", accountId: "acme", repoId: "r", kind: "job" }); | |
| 63 | + expect(await mayStart(env, "acme", "job")).toMatchObject({ ok: false, wait: true }); | |
| 64 | + await endCompute(env, "job-a"); | |
| 65 | + await endCompute(env, "job-a"); | |
| 66 | + expect(await mayStart(env, "acme", "job")).toEqual({ ok: true }); | |
| 67 | + await startCompute(env, { id: "sb", accountId: "acme", repoId: "r", kind: "sandbox" }); | |
| 68 | + expect(await mayStart(env, "acme", "sandbox")).toMatchObject({ ok: false, wait: false, message: expect.stringMatching(/already has 1 agent sandboxes open/) }); | |
| 69 | + // A job still starts: sandboxes and jobs have their own counts. | |
| 70 | + expect(await mayStart(env, "acme", "job")).toEqual({ ok: true }); | |
| 71 | + await setQuotas(env, "acme", { hosted_minutes: 0 }, "root"); | |
| 72 | + for (const kind of ["job", "sandbox"] as const) expect(await mayStart(env, "acme", kind)).toMatchObject({ ok: false, wait: false, message: expect.stringMatching(/used its 0 hosted minutes for this month/) }); | |
| 73 | + // The administrator's own organization is never held up. | |
| 74 | + await setQuotas(env, "ops", { hosted_minutes: 0 }, "root"); | |
| 75 | + expect(await mayStart(env, "ops", "job")).toEqual({ ok: true }); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it("caps the repositories an account owns", async () => { | |
| 79 | + const { env, DB } = setup(); | |
| 80 | + await setQuotas(env, "ann", { repositories: 1 }, "root"); | |
| 81 | + await checkRepositoryQuota(env, "ann", "ann"); | |
| 82 | + DB.sqlite.prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES ('r1', 'ann', 'one', '', 'private', 'main', ?, ?)").run(at, at); | |
| 83 | + await expect(checkRepositoryQuota(env, "ann", "ann")).rejects.toThrow(/ann has reached its limit of 1 repositories/); | |
| 84 | + await setQuotas(env, "ann", { repositories: -1 }, "root"); | |
| 85 | + await checkRepositoryQuota(env, "ann", "ann"); | |
| 86 | + }); | |
| 87 | +}); |
addedapps/diffinery/test/unit/stubs/d1.ts+24−0View file
| @@ -1,0 +1,24 @@ | ||
| 1 | +import { DatabaseSync } from "node:sqlite"; | |
| 2 | +import { readdirSync, readFileSync } from "node:fs"; | |
| 3 | +import { join } from "node:path"; | |
| 4 | + | |
| 5 | +/** A D1Database over node:sqlite with every migration applied: enough for prepare/bind/first/all/run and batch. */ | |
| 6 | +export function testD1(): D1Database & { sqlite: DatabaseSync } { | |
| 7 | + const db = new DatabaseSync(":memory:"); | |
| 8 | + const dir = join(import.meta.dirname, "../../../migrations"); | |
| 9 | + for (const f of readdirSync(dir).filter((f) => f.endsWith(".sql")).sort()) db.exec(readFileSync(join(dir, f), "utf8")); | |
| 10 | + const statement = (query: string, bindings: unknown[] = []) => ({ | |
| 11 | + bind: (...b: unknown[]) => statement(query, b), | |
| 12 | + first: async <T>() => (db.prepare(query).get(...(bindings as never[])) as T | undefined) ?? null, | |
| 13 | + all: async <T>() => ({ results: db.prepare(query).all(...(bindings as never[])) as T[], success: true, meta: {} }), | |
| 14 | + run: async () => { | |
| 15 | + const r = db.prepare(query).run(...(bindings as never[])); | |
| 16 | + return { success: true, meta: { changes: Number(r.changes) } }; | |
| 17 | + }, | |
| 18 | + }); | |
| 19 | + return { | |
| 20 | + sqlite: db, | |
| 21 | + prepare: (q: string) => statement(q), | |
| 22 | + batch: async (stmts: Array<{ run: () => Promise<unknown> }>) => Promise.all(stmts.map((s) => s.run())), | |
| 23 | + } as unknown as D1Database & { sqlite: DatabaseSync }; | |
| 24 | +} |