From 2cc6ca06b2c2f9b6d4b47aad51f9248ae8bfbdb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 16:15:58 +0000 Subject: [PATCH 001/124] docs: add revised Krater spec and Weave/SkyPilot integration docs Revises the Sep 24 Project Ganymede portal spec after checking it against the Weave codebase and the SkyPilot docs and source: - Budget enforcement moves into Krater (SkyPilot admin policy + spend reconciler); SkyPilot has no dollar-budget feature - Roles come from a proposed Weave groups claim + directory API - Submission is gated on full Slack membership (weave#118) - Reviews attach to immutable revisions; budget is an append-only ledger Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- README.md | 9 ++ docs/SPEC.md | 271 +++++++++++++++++++++++++++++++++++ docs/skypilot-integration.md | 122 ++++++++++++++++ docs/weave-integration.md | 130 +++++++++++++++++ 4 files changed, 532 insertions(+) create mode 100644 docs/SPEC.md create mode 100644 docs/skypilot-integration.md create mode 100644 docs/weave-integration.md diff --git a/README.md b/README.md index 6c955c2..957ef05 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,11 @@ # Krater The management utility for Project Ganymede + +Krater is the Project Ganymede portal: proposal review, compute budget allocation and enforcement, and the public +gallery of completed projects. + +## Docs + +- [Spec](docs/SPEC.md): product spec, data model, workflow, deployment, open questions +- [Weave integration](docs/weave-integration.md): sign-in, roles, and the Weave changes Krater depends on +- [SkyPilot integration](docs/skypilot-integration.md): workspaces, the admin-policy launch gate, and the spend reconciler diff --git a/docs/SPEC.md b/docs/SPEC.md new file mode 100644 index 0000000..5c6c7bf --- /dev/null +++ b/docs/SPEC.md @@ -0,0 +1,271 @@ +# Krater — Project Ganymede Approval & Compute Allocation Portal + +Revised Sep 26, 2026. Supersedes the Sep 24 draft (drafted with @Adam). +The previous draft was checked against the Weave codebase and the SkyPilot docs and source. This revision fixes the +places where it assumed capabilities that don't exist. The integration details are in +[weave-integration.md](weave-integration.md) and [skypilot-integration.md](skypilot-integration.md). + +## What changed from the Sep 24 draft + +| Area | Sep 24 draft | This revision | Why | +| --- | --- | --- | --- | +| Budget enforcement | Handed to "SkyPilot's internal budgeting" | **Krater enforces it**, using a SkyPilot admin policy and a spend reconciler | SkyPilot has no dollar budgets, only per-instance `max_hourly_cost` and estimated `cost-report` | +| Reviewer role | "A claim Krater reads from Weave" | Same, but it needs **new Weave work**: a `groups` claim, a `slack_id` claim, and a user directory API | Weave's claims today are only name, username, email, phone and `admin` | +| Slack membership | "Weave users are already Slack members" | **Only full Slack members can submit** (checked by Krater for now) | Weave signup is open, new users are single-channel guests, and some never join Slack ([weave#118](https://github.com/patchworklabsorg/weave/issues/118)) | +| Reviews | Attached to the project | Attached to a **project revision** | Otherwise approvals of an old version count toward a resubmitted one | +| Amendments | Approved → back through review | Approved project **stays approved** while an amendment revision is reviewed | Otherwise compute is cut off during review | +| Budget | `budget_approved` column + `BudgetReclaim` table | **Append-only budget ledger** | One place for the ceiling and for the audit trail | +| Comments | Mirrored from Slack into Krater's DB | **Not mirrored in v1**; Krater links to the channel | Mirroring means handling Slack edits, deletes and threads, for little gain | +| Deployment | "Two containers" | Portal, worker, Postgres, SkyPilot API server (+ optional sign-in proxy) | That's what it actually takes | + +## Overview & scope + +**Krater** is the web portal for Project Ganymede (Patchwork Labs). It does three things: + +1. Reviews and approves member-submitted project proposals. +2. Allocates a dollar-denominated compute budget to approved projects, and **enforces** it against SkyPilot. +3. Hosts a public gallery of completed Ganymede projects. + +**Out of scope:** running compute. SkyPilot, renting GPUs mainly from Vast.ai, runs in its own deployment. Krater doesn't +schedule or run jobs, but it *does* own: + +- provisioning a SkyPilot workspace for each approved project; +- gating launches against the project's remaining budget; +- tearing down a project's clusters once it's over budget. + +SkyPilot has no budget feature of its own to hand that off to (see [skypilot-integration.md](skypilot-integration.md)). + +**Core capabilities** + +- Proposal submission, with drafts and resubmission after rejection +- A configurable approval policy (reviewer sign-off), with review happening in a private Slack channel per project +- Dollar-based compute budgets, enforced through SkyPilot +- A two-stage lifecycle: proposal review, then a separate completion review before a project goes public +- A public gallery of completed, open-source projects +- Admin override at every stage, fully audited + +## Roles & authentication + +Three roles: + +- **Submitter:** any Ganymede member who is also a **full Slack member** (see below). Can draft, submit, edit and + resubmit after rejection, amend an approved project, and submit their project for completion review. +- **Reviewer:** can approve or reject proposals and completion requests. Can't review a project they submitted or are a + credited builder on. +- **Admin:** a *Ganymede* admin. Can override the normal flow at any point: approve or reject at either stage, and adjust + or reclaim budget. This is **not** Weave's existing `admin` flag, which means Weave operations admin. + +A "logged in but can't submit" tier and a public/anonymous tier are still deferred. The gallery itself is public. + +**Authentication** is OIDC against Weave (Authorization Code + PKCE; Weave enforces PKCE). All Weave access goes through +one adapter module (`WeaveClient`) so Weave changes don't spread through the app. The contract, including the Weave work +it depends on, is in [weave-integration.md](weave-integration.md). + +**Role data lives in Weave.** Krater reads membership and roles from a `groups` claim at login, and from Weave's directory +API when it needs fresh data (Slack button clicks, inviting reviewers). It never stores a role as its own source of truth. +Proposed groups: `ganymede:member`, `ganymede:reviewer`, `ganymede:admin`, and later `ganymede:reviewer:`. + +**Slack membership gate (interim).** Signing in through Weave doesn't guarantee Slack membership. Weave signup is open, +new users join Slack as single-channel guests until they accept the code of conduct, and Slack won't add guests to +another channel. So until [weave#118](https://github.com/patchworklabsorg/weave/issues/118) lands: + +- On draft → submit, Krater looks the user up in Slack (`users.lookupByEmail` with the email claim), or by `slack_id` + once Weave exposes it. +- The user must exist, and not be deleted, `is_restricted`, or `is_ultra_restricted`. +- If the check fails, the user can still save drafts but sees "Join the Patchwork Labs Slack and accept the code of + conduct to submit", with a link to Weave. +- The check runs again right before channel creation, so a submission never fails halfway through. + +Known limitation: Weave users can have several email addresses, and the email claim is the primary one. If someone's +Slack account uses a different address, the lookup misses them until the `slack_id` claim exists. + +## Data model + +Postgres. All money is stored as **integer cents** (`*_cents`). Names are suggestions; refine during implementation. + +**User** (a cache of the Weave identity; no role data) +- `id`, `weave_sub` (Weave's `p_id`, e.g. `PWL5A1B2C3D4`; unique), `display_name`, `email`, `slack_user_id` + (nullable), `groups_cached` (last-seen claim; for display only, never for authorization), `last_login_at` + +**Project** +- `id`, `title`, `submitter_id`, `status`, `current_revision_id`, `approved_revision_id`, `repo_url`, + `slack_channel_id`, `skypilot_workspace`, `created_at`, `updated_at` +- `status` values: + - `draft` + - `pending_review` + - `changes_requested` + - `approved` + - `pending_completion_review` + - `completion_changes_requested` + - `completed` + - `withdrawn` + +**ProjectRevision** (an immutable snapshot of what was reviewed) +- `id`, `project_id`, `number`, `kind` (`proposal` | `amendment` | `completion`), `write_up`, + `budget_requested_cents`, completion fields (`demo_url`, `screenshot_keys[]`, `credited_builder_ids[]`, `tags[]`), + `submitted_at`, `outcome` (`pending` | `approved` | `rejected` | `superseded`) +- Drafts are edited in place. Submitting freezes them into a revision, and resubmitting creates the next one. + +**Review** +- `id`, `revision_id`, `reviewer_id`, `decision` (`approve` | `reject`), `reason` (required on reject), `source` + (`slack` | `web`), `created_at` + +**BudgetEntry** (append-only ledger; the project's ceiling is the sum of its entries) +- `id`, `project_id`, `kind` (`initial_approval` | `amendment` | `admin_adjustment` | `reclaim`), `amount_cents` + (signed), `actor_id`, `reason`, `revision_id` (nullable), `created_at` + +**SpendSnapshot** (written by the reconciler; see [skypilot-integration.md](skypilot-integration.md)) +- `id`, `project_id`, `estimated_spend_cents`, `source` (`skypilot_cost_report`), `taken_at` + +**ApprovalPolicy** (configuration, not code) +- `id`, `stage` (`proposal` | `completion`), `min_budget_cents` (nullable; for tiering by budget size), + `min_approvals` (currently 1), `required_group` (nullable; e.g. `ganymede:reviewer:senior`) + +**AuditEvent** +- `id`, `actor_id`, `action` (e.g. `admin_approve`, `admin_reject`, `budget_adjust`, `policy_change`, + `launch_blocked`, `teardown`), `project_id`, `payload` (jsonb), `reason`, `created_at` + +**GalleryEntry:** a view over `completed` projects plus their approved completion revision. It isn't a separate table. + +## Proposal & review workflow + +1. **Draft.** The submitter fills in a title, requested budget and write-up (repo link optional). They can save and come + back later. +2. **Submit.** Krater runs the Slack membership check, freezes the draft into revision N, sets `pending_review`, creates + the project's private Slack channel if it doesn't exist yet, invites the submitter and all current reviewers (from + Weave's directory API), posts the review message with Approve/Reject buttons, and posts a line in the master feed + channel. +3. **Review.** Reviewers discuss freely in the channel. Decisions come in through the buttons, or through the web UI as a + fallback. For every decision, Krater: + - checks with Weave's directory API that the clicker is a reviewer right now (not from a cached claim); + - rejects self-review (submitter or credited builder); + - records the Review against the current revision; + - asks `ApprovalPolicyService` whether the policy is now satisfied. +4. **Approve.** When the policy is satisfied, the revision is approved, a `BudgetEntry(initial_approval)` is written, the + project's SkyPilot workspace is provisioned, and the project moves to `approved`. +5. **Reject.** A reason is required. The project moves to `changes_requested`, and the submitter edits and resubmits + (step 2, same project, new revision). +6. **Amend.** An `approved` project can have its scope or budget amended. This creates an `amendment` revision reviewed + under the same policy. **The project stays `approved` and keeps its current ceiling while the amendment is + reviewed.** If the amendment is approved, the budget difference is written to the ledger. If it's rejected, nothing + changes. +7. **Withdraw.** The submitter or an admin can withdraw a project at any point. The channel is archived, unspent budget + is reclaimed, and the workspace is torn down. + +Only approvals on the **current** revision count. A new revision marks earlier pending ones as `superseded`. + +`ApprovalPolicyService` is its own module from day one. Given a revision, it finds the matching `ApprovalPolicy` rows +(by stage and budget size) and decides whether the approvals so far meet them. Multi-approval and reviewer tiers are then +just new rows. + +## Slack integration + +Slack is where review happens, not just where notifications go. Krater gets **its own Slack app**. Weave's app already +uses the one interactivity URL a Slack app can have, for code-of-conduct acceptance. + +**Per-project private channel:** created on first submission and reused for amendments and the completion review. +Members are the submitter, credited builders (once added), and all reviewers. When a reviewer is added in Weave, they +get invited to open project channels by a periodic job, or by a Weave webhook once those actually fire. The channel is +archived when the project ends up `completed` or `withdrawn`. + +**Master feed channel:** one top-level post per new submission, for visibility across Ganymede. It carries no decisions. + +**Source of truth:** Krater's database holds decisions, budgets and state. Slack holds discussion. Comments are **not** +mirrored into Krater in v1; the project page links to the channel instead. + +**App requirements** +- Bot scopes: `groups:write`, `groups:write.invites`, `chat:write`, `users:read`, `users:read.email` (for the interim + membership check). +- An HTTPS interactivity endpoint on the portal, reachable by Slack. +- Slack signature verification (`X-Slack-Signature` with the signing secret, and a check on how old the timestamp is). + Weave's `SlackSignatureVerification` concern is a working reference. +- Slack must get an acknowledgement within 3s. Do the real work in the worker, then update the message. +- Invites and posts must be safe to retry: `already_in_channel` counts as success, and failures are retried from the + worker. + +**Not needed:** a separate completion announcement. The gallery is enough (revisit later). + +## Completion flow & public gallery + +The submitter fills in the completion fields (final write-up, screenshots, demo link, repo link, credited builders, +tags) and submits them as a `completion` revision. The project moves to `pending_completion_review`. Review happens in +the same channel, under the `completion` stage policy. + +- **Approved:** the project becomes `completed`. It's published to the gallery, spend is frozen at its final value, + unspent budget is reclaimed to the ledger, and the SkyPilot workspace is torn down. +- **Rejected:** the project moves to `completion_changes_requested`. The submitter revises and resubmits. + +**Public gallery** (no auth). Each entry shows the write-up, screenshots, demo link, repo link, credited builders, +compute spent (latest reconciled estimate, labelled as an estimate), and tags for browsing. + +**Screenshot storage:** S3-compatible object storage, as a placeholder until a provider is chosen (see Open questions). +Uploads use presigned URLs, and only objects from approved completion revisions are served publicly. + +## Budget handling + +- The **ceiling** is the sum of the project's `BudgetEntry` rows. Approvals, amendments, admin adjustments and reclaims + all add entries, so the ledger doubles as the audit trail. +- **Spend** is SkyPilot's estimate (catalog price × uptime), pulled regularly by the reconciler. It's an estimate: + Vast's live prices and disk charges mean the real bill differs. Show it as "≈ $X estimated", and reconcile against Vast + billing out of band. +- **Enforcement** (details in [skypilot-integration.md](skypilot-integration.md)): + - Every launch goes through Krater's admin policy endpoint. It's rejected if the project isn't `approved` or its + estimated spend has reached the ceiling. Allowed launches are forced to autodown and capped with `max_hourly_cost`. + - The reconciler warns the project channel at 80% of the ceiling, and tears down the project's clusters and managed + jobs at 100%. +- **No automatic expiry.** Stalled projects are reclaimed manually by an admin (`BudgetEntry(reclaim)`). + +## Admin overrides + +At any point an admin can: +- approve or reject at either review stage, bypassing `ApprovalPolicy`; +- adjust an approved project's budget up or down, or reclaim unspent funds; +- withdraw a project. + +Every override writes an `AuditEvent` with who, what, when and a required reason. Budget changes also write a +`BudgetEntry`. Overrides are posted in the project's channel so reviewers can see them. + +## Deployment architecture + +Everything runs in Docker and is defined in one Compose file, so it can run on any host. The existing host (alastor) is +arm64; SkyPilot publishes arm64 images, so that works. + +| Service | What it is | +| --- | --- | +| `portal` | FastAPI web app: UI, OIDC, Slack endpoints, public gallery, SkyPilot admin-policy endpoint | +| `worker` | Same image, runs background jobs: Slack work, the spend reconciler, reviewer-channel sync | +| `db` | Postgres | +| `skypilot` | SkyPilot API server with the Vast credentials; out of scope except for its configuration | +| `auth-proxy` (optional) | oauth2-proxy in front of SkyPilot, if members use the SkyPilot dashboard or CLI directly (see Open questions) | + +Networking: +- Slack must reach `portal` over public HTTPS. +- The admin-policy endpoint is **internal only**. SkyPilot's policy calls carry no authentication, so the endpoint must be + reachable only from the `skypilot` container. + +**Stack:** Python + FastAPI + Postgres. Python matters because Krater imports SkyPilot's own request decoder +(`sky.admin_policy.UserRequest`) and client SDK, rather than reimplementing SkyPilot's wire format. + +## Open questions + +1. **Screenshot storage provider.** S3-compatible for now; pick a provider (self-hosted MinIO next to the portal, or + R2/B2/S3). A reminder is set to circle back. +2. **How members use SkyPilot.** Proposed default: Krater issues a SkyPilot service-account token per project, limited to + that project's workspace, and shows it to the project team. This needs a spike: confirm service accounts and + workspace limits work without SSO on a plain Docker deployment. SkyPilot documents SSO/RBAC only for Helm. The + alternative is oauth2-proxy with Weave as the OIDC issuer. +3. **What happens at the ceiling.** Proposed: warn at 80%, block new launches and tear down at 100%, with no grace + period. Consider a small admin-configurable grace so a running training job isn't killed at 100.1%. +4. **Weave changes.** Krater's v1 depends on the `groups` claim, the `slack_id` claim and the directory endpoints in + [weave-integration.md](weave-integration.md). The Slack gate in [weave#118](https://github.com/patchworklabsorg/weave/issues/118) + is tracked; the rest still need issues. + +## Parked / future work + +Designed for, not built now: + +- **In-progress project visibility:** letting members see active projects, to avoid duplicate work. +- **Multi-approval policy:** new `ApprovalPolicy` rows with `min_approvals > 1`. +- **Reviewer tiers by budget size:** `ApprovalPolicy.min_budget_cents` plus `required_group`. +- **Comment mirroring from Slack,** if the channel link turns out not to be enough. +- **Reconciling against real Vast billing** instead of SkyPilot's estimates. diff --git a/docs/skypilot-integration.md b/docs/skypilot-integration.md new file mode 100644 index 0000000..ea44754 --- /dev/null +++ b/docs/skypilot-integration.md @@ -0,0 +1,122 @@ +# SkyPilot integration & budget enforcement + +SkyPilot runs Ganymede's compute, mainly renting GPUs from Vast.ai. It runs as its own API server. Krater doesn't +schedule or run jobs, but it **does** enforce each project's dollar ceiling, because SkyPilot has no budget feature to +hand that job to. + +Checked against the SkyPilot docs (docs.skypilot.ai) and `skypilot-org/skypilot` master on 2026-09-26. + +## What SkyPilot does and doesn't provide + +| Need | SkyPilot | Notes | +| --- | --- | --- | +| Dollar budget per project/team | ❌ None | No budget, quota or spend-cap feature in the CLI, SDK, workspaces or config | +| Price cap | ⚠️ `resources.max_hourly_cost` | **Per instance, per hour.** Filters which instances can be picked; doesn't cap a total | +| Spend figures | ⚠️ `sky cost-report` / `cost_report()` | Per-cluster **estimate**: catalog price × uptime. The docstring says it "may not be accurate for the cluster with autostop/use_spot set or terminated/stopped on the cloud console." Rows include `workspace` and `total_cost` | +| Isolating projects | ✅ Workspaces | `private: true` + `allowed_users`. The API server has `/workspaces/create`, `/update`, `/delete`, `/batch_add_users`, `/batch_remove_users` (these return async request IDs) | +| Gating launches | ✅ Admin policies | Server-side `validate_and_mutate(UserRequest)`. Can reject or rewrite any launch. `RestfulAdminPolicy` POSTs the request to a URL and treats **HTTP 400 as a rejection** | +| Machine access | ✅ Service-account tokens | `/users/service-account-tokens`; restrict one to a workspace by giving it the `user` role and adding it to `allowed_users` | +| SSO / RBAC | ⚠️ Helm only | Any OIDC issuer works through oauth2-proxy, so Weave could be the issuer, but it's documented only for the Helm chart. RBAC only works with SSO. Plain Docker gets basic auth | +| arm64 images | ✅ | `linux/amd64` and `linux/arm64` are published | + +### Vast.ai caveats + +- Vast support is **community-maintained**. +- Not supported on Vast: multi-node clusters, mounting object stores, custom disk or network tiers, and HA controllers. +- **Pricing drift:** SkyPilot prices Vast from a cached catalog (`vast/vms.csv`), but the actual rental is chosen from + live offers (`search_offers`), and Vast can charge extra for disk. SkyPilot's spend estimate will not match the Vast + bill exactly. +- The `datacenter_only` option limits Vast to datacenter hosts (not consumer machines). Consider turning it on for + reliability. + +## Design + +``` + ┌──────────────── Krater ────────────────┐ + approve ──►│ provisioner ── /workspaces/create ─────┼──► SkyPilot API server ──► Vast.ai + │ │ │ + │ policy endpoint ◄── RestfulAdminPolicy ┼─────────┘ (every launch) + │ │ + │ reconciler ── cost_report, down/cancel ┼──► SkyPilot API server + └────────────────────────────────────────┘ +``` + +### 1. Provisioning (on approval) + +When a project's proposal is approved, a worker job: + +1. Creates a private workspace named `ganymede-` with `allowed_users` set to the project team. Other + clouds are disabled in that workspace, so it can only use Vast. +2. Saves the workspace name on `Project.skypilot_workspace`. +3. Sets up the project team's access to that workspace. How is an open question (see below). + +When the project is completed or withdrawn: tear down its clusters and managed jobs, cut off its access, and keep the +workspace until the cost history has been recorded, then delete it. + +Krater calls the API server with an **admin** service-account token, stored as a secret in the portal and worker. + +### 2. Admin policy endpoint (every launch) + +The SkyPilot server config points at Krater: + +```yaml +# SkyPilot API server config +admin_policy: http://portal:8000/internal/skypilot/policy +``` + +For each request, the endpoint decodes the body with `sky.admin_policy.UserRequest.decode(...)`, reads +`skypilot_config.active_workspace`, and maps it to a project. + +**Reject** (HTTP 400, with a message the user will see) if: +- the workspace doesn't belong to a Krater project, or the project isn't `approved` (for example, it's completed, + withdrawn or still in review); +- the project's latest estimated spend is at or above its ceiling; +- the request asks for something Vast doesn't support, such as multi-node. This is optional but gives friendlier errors. + +**Change** the request (return `MutatedUserRequest.encode()`) so that: +- the cluster autodowns after idling (e.g. `idle_minutes: 30`, `down: true`) unless the user asked for something + stricter; +- `max_hourly_cost` is capped at a per-project limit. The simple version is a global default; later it could be + something like 5% of the remaining budget; +- the cluster is labelled with the project ID, if resource labels work on Vast. Otherwise the workspace alone is enough + to attribute spend. + +Every rejection writes an `AuditEvent(launch_blocked)` and posts a short note in the project's Slack channel. + +**Security:** `RestfulAdminPolicy` sends **no auth header**. The endpoint must only be reachable on the internal Docker +network, never on the public router. Also check that requests come from the `skypilot` container's address. + +**Availability:** if the endpoint is down, every launch fails (SkyPilot raises `RestfulPolicyError`). That's the right +failure mode for a budget gate, but the endpoint must be cheap. It reads the latest `SpendSnapshot` from Postgres and +never calls SkyPilot back. + +### 3. Spend reconciler (periodic) + +Every 5 minutes, a worker job: + +1. Calls `cost_report(days=…)` with the admin token and groups `total_cost` by `workspace` → project. +2. Writes a `SpendSnapshot(estimated_spend_cents)` for each active project. +3. At **≥ 80%** of the ceiling, posts a one-time warning in the project channel. +4. At **≥ 100%**, tears down the project workspace's clusters and cancels its managed jobs, then writes + `AuditEvent(teardown)` and posts in the channel. The policy endpoint already blocks new launches from this point. + +Estimates only grow while a cluster is up, so the reconciler errs toward stopping early. + +### 4. Final spend + +When a project is completed or withdrawn, the reconciler takes a final snapshot after teardown. That figure becomes the +gallery's "compute spent" (shown as an estimate), and the unspent remainder is written to the ledger as a +`BudgetEntry(reclaim)`. + +## Things to verify in a spike (before building on them) + +1. `cost_report` run with an admin service account returns **every** user's clusters, with `workspace` filled in, and + includes managed-job clusters. The jobs controller's own cost isn't attributed to any project. +2. The workspace create, update and add-users endpoints work with a service-account token on a plain Docker (non-Helm) + deployment, and don't need a server restart. +3. **Member access:** can a service-account token limited to a single workspace be issued per project without SSO? If + so, that's the v1 access model (Krater shows the token to the project team). If not, fall back to oauth2-proxy in + front of the API server with Weave as the OIDC issuer, and map users to workspaces by email. +4. Whether autodown and `max_hourly_cost` changes made by the policy are respected for Vast launches and managed jobs. +5. How far `cost_report` drifts from actual Vast billing on a few real runs. That sets the safety margin (if any) to take + off the ceiling. diff --git a/docs/weave-integration.md b/docs/weave-integration.md new file mode 100644 index 0000000..8970dad --- /dev/null +++ b/docs/weave-integration.md @@ -0,0 +1,130 @@ +# Weave integration + +Weave ([patchworklabsorg/weave](https://github.com/patchworklabsorg/weave)) is Patchwork Labs' identity provider: a +Rails app with Doorkeeper and `doorkeeper-openid_connect`, running at `https://weave.patchworklabs.org`. Krater uses it +for sign-in and for roles. All Weave access goes through one adapter, `WeaveClient`. + +## What Weave provides today + +Checked against Weave `main` on 2026-09-26. + +| Capability | Status | Where | +| --- | --- | --- | +| OIDC Authorization Code + PKCE | ✅ PKCE is **required** (`force_pkce`) | `config/initializers/doorkeeper.rb` | +| Discovery, JWKS, RS256 id_tokens | ✅ `/.well-known/openid-configuration` | `config/routes.rb` (`use_doorkeeper_openid_connect`) | +| UserInfo | ✅ `/oauth/userinfo`; accepts `openid` or `profile` tokens | `app/controllers/oauth/userinfo_controller.rb` | +| `sub` | ✅ The user's `p_id`, e.g. `PWL5A1B2C3D4`. Stable and uppercase | `doorkeeper_openid_connect.rb` | +| Claims | `name`, `given_name`, `family_name`, `preferred_username`, `updated_at` (profile); `email`, `email_verified` (email); `phone_number` (phone); `admin` (admin) | `doorkeeper_openid_connect.rb` | +| Access token lifetime | 2 hours, plus refresh tokens | `doorkeeper.rb` | +| Service API with API-key auth | ⚠️ The auth plumbing exists (`X-Api-Key`, usage logging), but the only endpoint is `GET /api/v1/health` | `app/controllers/api/v1/base_controller.rb` | +| Outbound webhooks (`user.updated`, …) | ❌ Event types are defined, but **nothing sends them** | `app/models/service/webhook.rb` | +| Reviewer / group / Ganymede roles | ❌ Weave roles are `user/admin/superadmin/owner`, plus `is_staff` and `is_board` flags | `app/models/user.rb` | +| Slack user ID | ⚠️ Stored as `users.slack_id`, but not exposed to other apps | `app/models/user.rb` | + +Heads-up: `docs/OAUTH.md` in Weave documents `GET /api/v1/users/me` and `POST /api/v1/auth/authenticate`. **Neither +exists.** Build against `config/routes.rb`, not that doc. + +## Required Weave changes + +Krater v1 depends on these. Each should be its own Weave issue or PR. + +### 1. `groups` claim (new `groups` scope) + +A list of strings in the id_token and UserInfo, e.g.: + +```json +{ "groups": ["ganymede:member", "ganymede:reviewer"] } +``` + +- `ganymede:member`: allowed to use Krater at all. +- `ganymede:reviewer`: allowed to review. +- `ganymede:admin`: Krater admin. This is separate from Weave's `admin` claim, which means Weave operations admin and + must not be reused. +- Later: `ganymede:reviewer:` for reviewer tiers by budget size. + +Weave needs somewhere to store and manage these, e.g. a `user_groups` table plus a UI for admins to edit it. It's better +to make this generic than to add another boolean like `is_board`, since tiers are already planned. + +### 2. `slack_id` claim + +Weave already stores `slack_id`. Exposing it (under `profile`, or a new `slack` scope) lets Krater match a Slack button +click to a user without email lookups. Email lookups are unreliable now that Weave users can have several addresses. + +### 3. Directory endpoints (service-key auth) + +Checking claims at sign-in isn't enough. Krater has to: + +- invite **every** reviewer to each new project channel, including ones who have never signed in to Krater; +- check a Slack button click, which arrives with only a Slack user ID; +- notice when someone loses reviewer status, without waiting for their next sign-in. + +Proposed, under the existing `Api::V1::BaseController` auth: + +``` +GET /api/v1/users?group=ganymede:reviewer → [{ sub, name, email, slack_id, groups, status }] +GET /api/v1/users/by_slack_id/:slack_id → { sub, name, email, slack_id, groups, status } +GET /api/v1/users/:sub → { sub, name, email, slack_id, groups, status } +``` + +Responses should use a serializer (per Weave's conventions) and never include the database `id`. `status` lets Krater +refuse suspended or deactivated users. + +### 4. Slack membership in claims + +Tracked in [weave#118](https://github.com/patchworklabsorg/weave/issues/118). Until then Krater checks Slack itself (see +[SPEC.md → Roles & authentication](SPEC.md#roles--authentication)). + +### Related Weave bug + +Weave's OAuth sign-in check (`resource_owner_authenticator`) only verifies that the email is confirmed. It doesn't check +whether the account is locked or suspended, or whether the session is still live. `User#lock!` also doesn't revoke +Doorkeeper tokens. As a result, a locked or suspended user can still sign in to Krater, and their refresh tokens keep +working. Until that's fixed, Krater checks `status` through the directory API before any review decision or budget +action. + +## Krater-side contract + +### Registration + +Register Krater in Weave at `/admin/oauth_applications`: + +- **Confidential client.** +- Redirect URI: `https:///auth/callback`, plus `http://localhost:/auth/callback` for development. +- Scopes: `openid profile email groups`. Add `slack` if `slack_id` gets its own scope. +- Issue a separate **service key** for the directory API. + +### `WeaveClient` interface + +```python +class WeaveClient(Protocol): + # OIDC + def authorization_url(self, state: str, code_verifier: str) -> str: ... + def exchange_code(self, code: str, code_verifier: str) -> WeaveIdentity: ... # verifies id_token via JWKS + + # Directory (service key) + def get_user(self, sub: str) -> WeaveUser | None: ... + def get_user_by_slack_id(self, slack_id: str) -> WeaveUser | None: ... + def list_users_in_group(self, group: str) -> list[WeaveUser]: ... +``` + +```python +@dataclass(frozen=True) +class WeaveUser: + sub: str # PWL…; Krater's external ID + name: str + email: str + slack_id: str | None + groups: frozenset[str] + active: bool # status == "active" +``` + +Rules: + +- **Authorization uses fresh data.** Review decisions, admin actions and budget changes call `get_user` or + `get_user_by_slack_id` at the time of the action. `User.groups_cached` is for display only. +- **Sign-in:** reject users without `ganymede:member`. Store `weave_sub`, the display name, email, `slack_id` and + `groups_cached`. +- **Short cache is fine.** Directory responses can be cached for about 60 seconds to absorb bursts of Slack clicks. +- **Until the Weave changes land,** `WeaveClient` can be backed by a stub: a config file mapping `sub` or email to + groups, plus a Slack `users.lookupByEmail` lookup. That lets Krater development start now without leaking stub logic + outside the adapter. From 65b3c34ea2a98d72efac813ead5a2dac294b9342 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:26:53 +0000 Subject: [PATCH 002/124] docs: one SkyPilot workspace per project, members sign in with Weave SkyPilot's oauth2-proxy integration is two env vars on the API server, not a Helm-only feature, so members can sign in with Weave on the Docker Compose deployment. Replace the per-project token proposal with private per-project workspaces plus Weave sign-in limited to ganymede:member, and note that the Weave claims/directory work now exists on a branch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docs/SPEC.md | 16 +++++------ docs/skypilot-integration.md | 52 +++++++++++++++++++++++++++++++----- docs/weave-integration.md | 3 ++- 3 files changed, 56 insertions(+), 15 deletions(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 5c6c7bf..06fecd7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -236,7 +236,7 @@ arm64; SkyPilot publishes arm64 images, so that works. | `worker` | Same image, runs background jobs: Slack work, the spend reconciler, reviewer-channel sync | | `db` | Postgres | | `skypilot` | SkyPilot API server with the Vast credentials; out of scope except for its configuration | -| `auth-proxy` (optional) | oauth2-proxy in front of SkyPilot, if members use the SkyPilot dashboard or CLI directly (see Open questions) | +| `auth-proxy` | oauth2-proxy with Weave as the OIDC issuer; members sign in to the SkyPilot CLI and dashboard through it | Networking: - Slack must reach `portal` over public HTTPS. @@ -250,15 +250,15 @@ Networking: 1. **Screenshot storage provider.** S3-compatible for now; pick a provider (self-hosted MinIO next to the portal, or R2/B2/S3). A reminder is set to circle back. -2. **How members use SkyPilot.** Proposed default: Krater issues a SkyPilot service-account token per project, limited to - that project's workspace, and shows it to the project team. This needs a spike: confirm service accounts and - workspace limits work without SSO on a plain Docker deployment. SkyPilot documents SSO/RBAC only for Helm. The - alternative is oauth2-proxy with Weave as the OIDC issuer. +2. **How members use SkyPilot:** decided. One private SkyPilot workspace per project, and members sign in to SkyPilot + with Weave (oauth2-proxy, limited to `ganymede:member`). See + [skypilot-integration.md §0](skypilot-integration.md#0-member-access-one-workspace-per-project-sign-in-with-weave). + Still to confirm in the spike: the full flow on Docker Compose. 3. **What happens at the ceiling.** Proposed: warn at 80%, block new launches and tear down at 100%, with no grace period. Consider a small admin-configurable grace so a running training job isn't killed at 100.1%. -4. **Weave changes.** Krater's v1 depends on the `groups` claim, the `slack_id` claim and the directory endpoints in - [weave-integration.md](weave-integration.md). The Slack gate in [weave#118](https://github.com/patchworklabsorg/weave/issues/118) - is tracked; the rest still need issues. +4. **Weave changes.** The `groups` claim, `slack_id` claim and directory endpoints are implemented on Weave branch + `claude/exciting-sagan-7oh2zh` (not merged yet). The Slack membership gate is tracked in + [weave#118](https://github.com/patchworklabsorg/weave/issues/118). ## Parked / future work diff --git a/docs/skypilot-integration.md b/docs/skypilot-integration.md index ea44754..5d2314a 100644 --- a/docs/skypilot-integration.md +++ b/docs/skypilot-integration.md @@ -15,8 +15,8 @@ Checked against the SkyPilot docs (docs.skypilot.ai) and `skypilot-org/skypilot` | Spend figures | ⚠️ `sky cost-report` / `cost_report()` | Per-cluster **estimate**: catalog price × uptime. The docstring says it "may not be accurate for the cluster with autostop/use_spot set or terminated/stopped on the cloud console." Rows include `workspace` and `total_cost` | | Isolating projects | ✅ Workspaces | `private: true` + `allowed_users`. The API server has `/workspaces/create`, `/update`, `/delete`, `/batch_add_users`, `/batch_remove_users` (these return async request IDs) | | Gating launches | ✅ Admin policies | Server-side `validate_and_mutate(UserRequest)`. Can reject or rewrite any launch. `RestfulAdminPolicy` POSTs the request to a URL and treats **HTTP 400 as a rejection** | -| Machine access | ✅ Service-account tokens | `/users/service-account-tokens`; restrict one to a workspace by giving it the `user` role and adding it to `allowed_users` | -| SSO / RBAC | ⚠️ Helm only | Any OIDC issuer works through oauth2-proxy, so Weave could be the issuer, but it's documented only for the Helm chart. RBAC only works with SSO. Plain Docker gets basic auth | +| Machine access | ✅ Service-account tokens | Bearer tokens (`/users/service-account-tokens`). Krater's admin token uses this | +| Member sign-in (SSO) + RBAC | ✅ via oauth2-proxy | The docs only show the Helm chart, but in the source it's two env vars on the API server (`SKYPILOT_AUTH_OAUTH2_PROXY_ENABLED=true`, `SKYPILOT_AUTH_OAUTH2_PROXY_BASE_URL`) pointing at an oauth2-proxy. That works in Docker Compose. Users are identified by email. RBAC (roles, private workspaces) needs SSO to be on | | arm64 images | ✅ | `linux/amd64` and `linux/arm64` are published | ### Vast.ai caveats @@ -39,8 +39,46 @@ Checked against the SkyPilot docs (docs.skypilot.ai) and `skypilot-org/skypilot` │ │ │ reconciler ── cost_report, down/cancel ┼──► SkyPilot API server └────────────────────────────────────────┘ + + member ── sky CLI / dashboard ──► oauth2-proxy ──(Weave OIDC)──► SkyPilot API server +``` + +### 0. Member access: one workspace per project, sign-in with Weave + +- **Identity:** members sign in to SkyPilot with their Weave account. oauth2-proxy runs as its own container, with Weave + as the OIDC issuer. The SkyPilot API server delegates authentication to it through the two env vars above. The CLI + works too: `sky api login -e https://` opens a browser to the Weave sign-in. +- **Who may sign in:** oauth2-proxy only lets in Ganymede members. It requests the `groups` scope from Weave and + requires `ganymede:member`. +- **Isolation:** each approved project gets its own **private** workspace. `allowed_users` is set to the project team's + emails (the same `email` claim oauth2-proxy passes on). A member on two projects can use both workspaces and picks one + per launch. Everyone else can't see the workspace at all. +- **Roles:** SkyPilot's default role for new users is `user`. Only Krater's service account (and SkyPilot operators) + get `admin`, since SkyPilot admins can see and edit every workspace. +- **Offboarding:** when a project is completed or withdrawn, Krater removes the team from `allowed_users` before tearing + the workspace down. Someone suspended in Weave can't sign in again. Existing SkyPilot sessions last until they expire, + so keep the oauth2-proxy cookie lifetime short (e.g. 8h). + +oauth2-proxy settings (sketch): + +```ini +provider = "oidc" +oidc_issuer_url = "https://weave.patchworklabs.org" +client_id = "" # a separate confidential Weave app, not Krater's +client_secret = "" +scope = "openid email profile groups" +oidc_groups_claim = "groups" +allowed_groups = ["ganymede:member"] +email_domains = ["*"] +redirect_url = "https:///oauth2/callback" +cookie_expire = "8h" +code_challenge_method = "S256" # Weave requires PKCE ``` +Depends on Weave's `groups` claim (on Weave branch `claude/exciting-sagan-7oh2zh`, not merged yet). Until it lands, drop +`allowed_groups` and rely on private workspaces alone. Anyone with a Weave account could then sign in, but they'd see +nothing. + ### 1. Provisioning (on approval) When a project's proposal is approved, a worker job: @@ -48,7 +86,8 @@ When a project's proposal is approved, a worker job: 1. Creates a private workspace named `ganymede-` with `allowed_users` set to the project team. Other clouds are disabled in that workspace, so it can only use Vast. 2. Saves the workspace name on `Project.skypilot_workspace`. -3. Sets up the project team's access to that workspace. How is an open question (see below). +3. Keeps `allowed_users` in step with the team: the submitter plus credited builders, by their Weave email. Members + sign in with Weave (see section 0); no per-project tokens are handed out. When the project is completed or withdrawn: tear down its clusters and managed jobs, cut off its access, and keep the workspace until the cost history has been recorded, then delete it. @@ -114,9 +153,10 @@ gallery's "compute spent" (shown as an estimate), and the unspent remainder is w includes managed-job clusters. The jobs controller's own cost isn't attributed to any project. 2. The workspace create, update and add-users endpoints work with a service-account token on a plain Docker (non-Helm) deployment, and don't need a server restart. -3. **Member access:** can a service-account token limited to a single workspace be issued per project without SSO? If - so, that's the v1 access model (Krater shows the token to the project team). If not, fall back to oauth2-proxy in - front of the API server with Weave as the OIDC issuer, and map users to workspaces by email. +3. **Member access:** end to end on Docker Compose: oauth2-proxy + Weave sign-in, `sky api login` from the CLI, a + private workspace blocking a non-member, and a member launching on Vast in it. Also check that SkyPilot's user + identity is the email (so `allowed_users` entries match), and whether allowing users into a workspace needs them to + have signed in once first. 4. Whether autodown and `max_hourly_cost` changes made by the policy are respected for Vast launches and managed jobs. 5. How far `cost_report` drifts from actual Vast billing on a few real runs. That sets the safety margin (if any) to take off the ceiling. diff --git a/docs/weave-integration.md b/docs/weave-integration.md index 8970dad..5b86af2 100644 --- a/docs/weave-integration.md +++ b/docs/weave-integration.md @@ -26,7 +26,8 @@ exists.** Build against `config/routes.rb`, not that doc. ## Required Weave changes -Krater v1 depends on these. Each should be its own Weave issue or PR. +Krater v1 depends on these. Changes 1–3 are implemented on Weave branch `claude/exciting-sagan-7oh2zh` (not merged yet; +see that branch's `docs/OAUTH.md`). The directory API there requires a service key with the `directory:read` scope. ### 1. `groups` claim (new `groups` scope) From 6e917f6fa980f32940270954c0e12d7c440e0c15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:40:17 +0000 Subject: [PATCH 003/124] docs: point to the Weave Krater-Integration branch Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docs/skypilot-integration.md | 2 +- docs/weave-integration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/skypilot-integration.md b/docs/skypilot-integration.md index 5d2314a..76fe458 100644 --- a/docs/skypilot-integration.md +++ b/docs/skypilot-integration.md @@ -75,7 +75,7 @@ cookie_expire = "8h" code_challenge_method = "S256" # Weave requires PKCE ``` -Depends on Weave's `groups` claim (on Weave branch `claude/exciting-sagan-7oh2zh`, not merged yet). Until it lands, drop +Depends on Weave's `groups` claim (on Weave branch `Krater-Integration`, not merged yet). Until it lands, drop `allowed_groups` and rely on private workspaces alone. Anyone with a Weave account could then sign in, but they'd see nothing. diff --git a/docs/weave-integration.md b/docs/weave-integration.md index 5b86af2..ddee906 100644 --- a/docs/weave-integration.md +++ b/docs/weave-integration.md @@ -26,7 +26,7 @@ exists.** Build against `config/routes.rb`, not that doc. ## Required Weave changes -Krater v1 depends on these. Changes 1–3 are implemented on Weave branch `claude/exciting-sagan-7oh2zh` (not merged yet; +Krater v1 depends on these. Changes 1–3 are implemented on Weave branch `Krater-Integration` (not merged yet; see that branch's `docs/OAUTH.md`). The directory API there requires a service key with the `directory:read` scope. ### 1. `groups` claim (new `groups` scope) From e6e8dc22763c0dd9d211eb6eea3f65a46fe82124 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 17:40:25 +0000 Subject: [PATCH 004/124] docs: fix remaining Weave branch reference Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docs/SPEC.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/SPEC.md b/docs/SPEC.md index 06fecd7..c221e41 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -257,7 +257,7 @@ Networking: 3. **What happens at the ceiling.** Proposed: warn at 80%, block new launches and tear down at 100%, with no grace period. Consider a small admin-configurable grace so a running training job isn't killed at 100.1%. 4. **Weave changes.** The `groups` claim, `slack_id` claim and directory endpoints are implemented on Weave branch - `claude/exciting-sagan-7oh2zh` (not merged yet). The Slack membership gate is tracked in + `Krater-Integration` (not merged yet). The Slack membership gate is tracked in [weave#118](https://github.com/patchworklabsorg/weave/issues/118). ## Parked / future work From 2d17282d3702b17e9590f712f9958af1ff9ffd71 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:55:06 +0000 Subject: [PATCH 005/124] docs: add CLAUDE.md with stack, layout and conventions Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- CLAUDE.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..09c9118 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# CLAUDE.md + +Guidance for Claude Code (and humans) working in this repo. + +## What this is + +Krater is the Project Ganymede portal (Patchwork Labs): proposal review, compute budget allocation/enforcement, and a +public gallery. **Read `docs/SPEC.md` before changing behavior.** Integration contracts live in +`docs/weave-integration.md` (sign-in, roles, directory API) and `docs/skypilot-integration.md` (budget enforcement). + +## Stack + +- Python 3.12, managed with **uv** (`pyproject.toml` + `uv.lock`) +- **FastAPI** web app, server-rendered **Jinja2** templates (no SPA; progressive enhancement with HTMX is fine) +- **SQLAlchemy 2.0** (typed `Mapped[...]` models, sync sessions, psycopg 3) + **Alembic** migrations +- **Postgres** (16+) +- **procrastinate** (Postgres-backed) for background jobs and periodic tasks — no Redis +- **Authlib** for OIDC against Weave; Starlette `SessionMiddleware` for the session cookie +- **pytest** against a real Postgres test database; **ruff** for lint + format + +## Commands + +```bash +uv sync # install deps (incl. dev) +uv run alembic upgrade head # migrate (DATABASE_URL must be set) +uv run alembic revision --autogenerate -m "..." # new migration; always review the output +uv run uvicorn krater.web.app:create_app --factory --reload # web +uv run procrastinate --app=krater.worker.app.app worker # worker +uv run pytest # tests (TEST_DATABASE_URL, see tests/conftest.py) +uv run ruff check . && uv run ruff format --check . +docker compose up --build # full stack: portal, worker, db +``` + +## Layout + +``` +krater/ + config.py Settings (pydantic-settings, env vars prefixed KRATER_) + db.py engine, session factory, Base + models/ SQLAlchemy models, one module per aggregate + services/ domain logic (framework-free): approval_policy, projects, budget, audit + weave/ WeaveClient protocol + OIDC/directory implementation + dev stub + web/ FastAPI app factory, routers, deps (current user, authz), templates/, static/ + worker/ procrastinate app + tasks +alembic/ migrations +tests/ mirrors krater/ layout +``` + +## Conventions + +- **Services own the rules.** Routers parse input, call a service, render. Services take a `Session` plus an explicit + actor and raise domain errors (`krater.services.errors`); they never import FastAPI. +- **Money is integer cents** (`*_cents` columns, `int` in Python). Never floats. +- **Primary keys are UUIDs** (`uuid.uuid4`), safe to show in URLs. +- **Roles come from Weave groups** (`ganymede:member`, `ganymede:reviewer`, `ganymede:admin`), never from Krater's DB. + `User.groups_cached` is display-only. Authorization for review decisions, admin actions and budget changes re-checks + via `WeaveClient.get_user()` at the time of the action. +- **All Weave access goes through `krater.weave`.** Nothing else imports httpx for Weave or knows Weave's URLs. +- **Every admin override writes an `AuditEvent`** (who, what, reason). Budget changes write a `BudgetEntry` (append-only; + never update or delete ledger rows). +- **Stub mode** (`KRATER_WEAVE_MODE=stub`) replaces Weave with a local fixture of fake users for dev and tests. The app + must refuse to start in stub mode when `KRATER_ENV=production`. +- Timestamps are timezone-aware UTC (`DateTime(timezone=True)`). +- Tests: every service rule gets a unit test; every route gets at least a happy-path and an authz test. +- Keep comments for the *why*; match the surrounding style. + +## Brand + +Patchwork palette: Ink `#2a2050` (text), Grape `#6c3ec1` (primary accent), Deep Purple `#4b006e`, Lavender `#c7b7e2`, +Quilt `#f7f5fb` (background). From 0ad1a692592dcd093a527fb81538db5e8c7e634e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:14:15 +0000 Subject: [PATCH 006/124] Add project scaffolding, settings and database base pyproject.toml/uv.lock pin Python 3.12 and the runtime/dev dependencies (FastAPI, SQLAlchemy 2, psycopg3, Alembic, pydantic-settings, procrastinate, ruff/pytest). krater/config.py adds a pydantic-settings Settings (env prefix KRATER_) with the Weave integration fields, and refuses stub Weave mode or a default secret key in production. krater/db.py adds the engine/session factory and the declarative Base with a naming convention for constraints. Empty krater/services and krater/weave packages scaffold the layout CLAUDE.md documents for later waves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- .gitignore | 31 + .python-version | 1 + krater/__init__.py | 0 krater/config.py | 47 ++ krater/db.py | 49 ++ krater/services/__init__.py | 0 krater/weave/__init__.py | 0 pyproject.toml | 43 ++ uv.lock | 1221 +++++++++++++++++++++++++++++++++++ 9 files changed, 1392 insertions(+) create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 krater/__init__.py create mode 100644 krater/config.py create mode 100644 krater/db.py create mode 100644 krater/services/__init__.py create mode 100644 krater/weave/__init__.py create mode 100644 pyproject.toml create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92a7532 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ + +# Virtual environments +.venv/ +venv/ + +# uv +# (uv.lock IS committed -- do not ignore it) + +# Environment / secrets +.env +.env.* +!.env.example + +# Testing / tooling +.pytest_cache/ +.ruff_cache/ +.coverage +htmlcov/ + +# Editors / OS +.vscode/ +.idea/ +.DS_Store + +# Logs +*.log diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/krater/__init__.py b/krater/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/krater/config.py b/krater/config.py new file mode 100644 index 0000000..178a057 --- /dev/null +++ b/krater/config.py @@ -0,0 +1,47 @@ +"""Application settings, loaded from environment variables prefixed ``KRATER_``.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import Literal + +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +#: Sentinel used as the default `secret_key`. Production must override it. +DEFAULT_SECRET_KEY = "insecure-dev-secret-change-me" + + +class Settings(BaseSettings): + """Krater's configuration. All fields read from `KRATER_` env vars (or a `.env` file).""" + + model_config = SettingsConfigDict(env_prefix="KRATER_", env_file=".env", extra="ignore") + + env: Literal["development", "test", "production"] = "development" + database_url: str = "postgresql+psycopg://root:root@localhost:5432/krater_dev" + secret_key: str = DEFAULT_SECRET_KEY + base_url: str = "http://localhost:8000" + + # Weave (Patchwork Labs identity provider) integration. See docs/weave-integration.md. + weave_mode: Literal["stub", "live"] = "stub" + weave_issuer: str = "" + weave_client_id: str = "" + weave_client_secret: str = "" + weave_api_base_url: str = "" + weave_service_key: str = "" + weave_stub_users_file: str = "" + + @model_validator(mode="after") + def _validate_production_safety(self) -> Settings: + if self.env == "production": + if self.weave_mode == "stub": + raise ValueError("KRATER_WEAVE_MODE cannot be 'stub' when KRATER_ENV=production") + if self.secret_key == DEFAULT_SECRET_KEY: + raise ValueError("KRATER_SECRET_KEY must be set to a non-default value when KRATER_ENV=production") + return self + + +@lru_cache +def get_settings() -> Settings: + """Return the process-wide `Settings` instance, built once and cached.""" + return Settings() diff --git a/krater/db.py b/krater/db.py new file mode 100644 index 0000000..b322f06 --- /dev/null +++ b/krater/db.py @@ -0,0 +1,49 @@ +"""Database engine, session factory and the declarative base shared by all models.""" + +from __future__ import annotations + +from collections.abc import Generator +from functools import lru_cache + +from sqlalchemy import Engine, MetaData, create_engine +from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker + +from krater.config import get_settings + +# Explicit names for every constraint type, so Alembic autogenerate produces stable, predictable +# names instead of Postgres/SQLAlchemy defaults that can drift between runs. +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(DeclarativeBase): + """Declarative base for every Krater ORM model.""" + + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + +@lru_cache +def get_engine() -> Engine: + """Return the process-wide SQLAlchemy engine, built once from settings and cached.""" + settings = get_settings() + return create_engine(settings.database_url, future=True) + + +@lru_cache +def get_sessionmaker() -> sessionmaker[Session]: + """Return the process-wide session factory, bound to `get_engine()`.""" + return sessionmaker(bind=get_engine(), autoflush=False, expire_on_commit=False, future=True) + + +def get_session() -> Generator[Session, None, None]: + """FastAPI dependency yielding a `Session`, closed after the request.""" + session = get_sessionmaker()() + try: + yield session + finally: + session.close() diff --git a/krater/services/__init__.py b/krater/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/krater/weave/__init__.py b/krater/weave/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a49fe49 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,43 @@ +[project] +name = "krater" +version = "0.1.0" +description = "Project Ganymede approval and compute allocation portal" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "sqlalchemy>=2.0", + "psycopg[binary]>=3.1", + "alembic>=1.13", + "pydantic-settings>=2.4", + "jinja2>=3.1", + "python-multipart>=0.0.9", + "itsdangerous>=2.2", + "authlib>=1.3", + "httpx>=0.27", + "procrastinate[psycopg]>=2.11", +] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "ruff>=0.6", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["krater"] + +[tool.ruff] +line-length = 120 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..f8cb470 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1221 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "alembic" +version = "1.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "authlib" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/bc1729d3cfdc214b4935f4e886e4dd443c3065fd8e1e66423fe84b490f81/authlib-1.8.0.tar.gz", hash = "sha256:f3ecd5f1da737262fb53bf1a4d95c4ea1ad9dd509316587a255c99ab1838a4f0", size = 177759, upload-time = "2026-08-30T12:12:34.833Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/c6/6f124bcfbbfb20fba22c939b4e43a06dccfc0e1ca20e5634ca573cb1e271/authlib-1.8.0-py2.py3-none-any.whl", hash = "sha256:88aebbd9af6757e14e912d5dc007ae1dc1f3e27e3b2152ce7c552ee2c3b3c121", size = 260804, upload-time = "2026-08-30T12:12:33.162Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "croniter" +version = "6.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" }, + { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" }, + { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" }, + { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" }, + { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" }, + { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" }, + { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" }, + { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" }, + { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" }, + { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" }, + { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "joserfc" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" }, +] + +[[package]] +name = "krater" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "authlib" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "procrastinate" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13" }, + { name = "authlib", specifier = ">=1.3" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "itsdangerous", specifier = ">=2.2" }, + { name = "jinja2", specifier = ">=3.1" }, + { name = "procrastinate", extras = ["psycopg"], specifier = ">=2.11" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, + { name = "pydantic-settings", specifier = ">=2.4" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "sqlalchemy", specifier = ">=2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.3" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "mako" +version = "1.4.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "procrastinate" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "attrs" }, + { name = "croniter" }, + { name = "packaging" }, + { name = "psycopg", extra = ["pool"] }, + { name = "python-dateutil" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/8a/ab3a9841b38b9349eb3cb871c47d0683ce4811669c9f0fb7b889343bea8f/procrastinate-3.10.0.tar.gz", hash = "sha256:3668da769088682abf91b8c760de467be1203218411872f1a26ac7515d4f980d", size = 91101, upload-time = "2026-09-23T21:22:57.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/51/c7ea18faef04260a1e67cb60c2c815ff0f2792c323c4f3a733478cbfc883/procrastinate-3.10.0-py3-none-any.whl", hash = "sha256:949ad25b4377c30e03a9d0e053d15dfa47ef02b3f12ae8a7f3254686183c152c", size = 155244, upload-time = "2026-09-23T21:22:55.873Z" }, +] + +[[package]] +name = "psycopg" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/26/3ea4ca5eaea1c0debcdf7ee7c1613fbe721dc27a03c461c0817ffd8a0601/psycopg-3.3.6.tar.gz", hash = "sha256:c081f2250df751a943036e42db6df4571c66cd0aabe8291a7a506512b12007d2", size = 168171, upload-time = "2026-09-18T13:22:55.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/de/748bd7609c71cae5d737f0ba9192f19329f70180ecda8fff3cac02c5abe3/psycopg-3.3.6-py3-none-any.whl", hash = "sha256:a1db9f7148b06a28606767efaca51fa6f9398c5c0a3810519be69d7000bdb631", size = 215490, upload-time = "2026-09-18T13:15:29.374Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] +pool = [ + { name = "psycopg-pool" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/01/2cdd1824e58b4467ee0b9498664cd28c42d8794db6b1e35b6bcb834f0044/psycopg_binary-3.3.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3f84dab25e0385692ee13274c68678377e0b1a70ab9d14e56264cbf61f60c62d", size = 4707086, upload-time = "2026-09-18T13:18:05.138Z" }, + { url = "https://files.pythonhosted.org/packages/f6/76/de9948ac06895261c84d5b9fbe283d8f3c5bc9f070691b8d9eaa1b51e322/psycopg_binary-3.3.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:612382ac3ed13651c7fa44b5fee9fbf7baaa2ddbc6f500391672682c5f1df9e0", size = 4769607, upload-time = "2026-09-18T13:18:12.83Z" }, + { url = "https://files.pythonhosted.org/packages/76/a9/72436c9915ee4905964689e7f0e182ce7767cc0a0390b3ce703be8177625/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:366db6e97e66b37211475f20c4c1324a2dc0dd825e46d4e87f9d599304d276f9", size = 5554134, upload-time = "2026-09-18T13:18:21.175Z" }, + { url = "https://files.pythonhosted.org/packages/0a/42/948bb3d2617795093512613fd96ba380e922992c7908fbc073858147d196/psycopg_binary-3.3.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1679a1cb93fbe5a6d1fd58d82cbddcc6fcb8c61446ba7cae6eb2a7b19bc585de", size = 5235723, upload-time = "2026-09-18T13:18:27.071Z" }, + { url = "https://files.pythonhosted.org/packages/99/47/93e823ff1b0088400703410939c9bda3e63ed9c850b3ee088e8769f4c10b/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d40450659401600e6d043ff586c89a71a69f33cbb8bcdba6cdb2569beecdbe", size = 6833587, upload-time = "2026-09-18T13:18:33.794Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/ecc69c847795aa704041a9f5667a6b0938a088cf1853636d762a6938e493/psycopg_binary-3.3.6-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a5165300324efd5a772c48a88ab3a928513ab3979fca76553e62ee815f7b2b9c", size = 5070013, upload-time = "2026-09-18T13:18:39.628Z" }, + { url = "https://files.pythonhosted.org/packages/92/36/6126f0dac21713dcae91404f2a76da18598a6252339a8c669c46370d43b2/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d636338c8f21b0df2f84657b00bc34f9313f826ef93f1155bc743607e4a0c5eb", size = 4597367, upload-time = "2026-09-18T13:18:45.023Z" }, + { url = "https://files.pythonhosted.org/packages/4d/29/7ecfc04243b46c89ffd49924e9c5634ea904ef96c7d0f37e4073623584c1/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a4ee3bdd5468a725f2a4d9aab8a74b6d0279f768c8b5d3aeb102c5307ff3d59c", size = 4275419, upload-time = "2026-09-18T13:18:49.299Z" }, + { url = "https://files.pythonhosted.org/packages/6e/90/2f46d2e0de79706ac170df0a3637fe63c4498fc04f131f6049520b78b806/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:289aadd6a00e151203c081f708348ec89f1e483c9b510ef4ac3981f847f01f79", size = 4007358, upload-time = "2026-09-18T13:18:53.944Z" }, + { url = "https://files.pythonhosted.org/packages/03/48/6744e91291b751a8cf12d63d719977974bb94c84ceba913e7ddb2e478e51/psycopg_binary-3.3.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f21d057f3e5f5491067e5b292498073b73847d48799b099803fef100775fcc52", size = 4320156, upload-time = "2026-09-18T13:18:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9b/94ff7fce53a64d5b286e2ec454e0a025cf3d6e6b4a9189bef16aa5de98b2/psycopg_binary-3.3.6-cp312-cp312-win_amd64.whl", hash = "sha256:e23a66a763fbe83fcc210bc77c27e5a5ea380ebf091c06f34d8561b695e5a40f", size = 3658864, upload-time = "2026-09-18T13:19:06.503Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c3/c072584b69ad44a747b448cfc9766fecb8aae56e372a017e2ef668790057/psycopg_binary-3.3.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ad8f35e67cc16d1fad1fa8c88972dc9b3a3141ea67897399904edab96a301b6", size = 4712284, upload-time = "2026-09-18T13:19:13.451Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b9/4283b785339e8e2318d03048994b093d650ea6289fabaa806b765dc0d449/psycopg_binary-3.3.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:373704aea331d3f3e3402c125a1543f5875e2986ebb54f97d1647942161f803f", size = 4772031, upload-time = "2026-09-18T13:19:18.524Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/7a1321d359246769fff1affffbd0132785a28f7f63c18524c15a502398f4/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b82491019b884d62318b5f30706c3d7e6d4e5a6cb7eabcb3edc0c1b0fdaceae9", size = 5556392, upload-time = "2026-09-18T13:19:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/de/b0/c6f8a0585a5dacbea74e130bcfc66629390e8f5bbc79d2a8e806e8952150/psycopg_binary-3.3.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cec5ea900390897d0b46130f60bc2883bf19c314f9044235217c8be88b0ef269", size = 5237855, upload-time = "2026-09-18T13:19:31.257Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fc/c3a7a8bbef7e945ec584ac61d460a612363ea398511cd0e220242b1d69f1/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98c02090d88f2ebc0ec1e8da538f77d225ce0fffecf372aa39262e62a1b054ef", size = 6833856, upload-time = "2026-09-18T13:19:43.622Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f2/8e80b921db728ebb68fc105bd7c4277f908210ad755bd6481d5ea7add740/psycopg_binary-3.3.6-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ee2c4728c691245e24501fcd7a97b5b381236b9985bc445bba88cdce7d1b5784", size = 5070730, upload-time = "2026-09-18T13:19:49.968Z" }, + { url = "https://files.pythonhosted.org/packages/54/6a/5b313e0c5348244f0e973aff3258bf86766656256d5ece8d541a53e35b4a/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f19cc87343eaa55255e76b31259a570072ac95d6ae82c92dd34b97691f5e49dc", size = 4598089, upload-time = "2026-09-18T13:19:56.426Z" }, + { url = "https://files.pythonhosted.org/packages/32/e9/db7f76ec24bf6699e92bf604e5c4bae10664a681a8999ef42aa0faf0f2c6/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdccb3a0e184b03e9baa673b15a809cf36c339c85dbda0ebc25a698846dfbee8", size = 4278481, upload-time = "2026-09-18T13:20:04.681Z" }, + { url = "https://files.pythonhosted.org/packages/61/83/72c67013656f4d6b547caabffb193e91d57e63f90eefdcc6d045c400e97d/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9892188bb15e5803beb51afe8a25add6b56be391a53058e8bca03b74e1e6bf22", size = 4009229, upload-time = "2026-09-18T13:20:11.905Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/5e4500df2c999eb0faed8b184e6958b834172128274f06167a5deef4c19c/psycopg_binary-3.3.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3af90f92769d8cc10f94515ee7a0aef36ea85ca733a0ce22858f6e0953f41138", size = 4321467, upload-time = "2026-09-18T13:20:17.949Z" }, + { url = "https://files.pythonhosted.org/packages/55/7f/e350e1cf498ba2565c3f87b12f429d2012eb86b76c2b3845a19ee5fbb4d6/psycopg_binary-3.3.6-cp313-cp313-win_amd64.whl", hash = "sha256:0ebfad5d131de9f892ae9e70cc7616207768b6714b66a52d4612b8ceaf78b372", size = 3658179, upload-time = "2026-09-18T13:20:22.691Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/60711317c284a442511644ea7185b56ebe627606d6741e732cd16108c47b/psycopg_binary-3.3.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b3f75dee0f9afafabe4edc52c4842f1e1878ed2069bd05b22d6fe961e97e4dba", size = 4720512, upload-time = "2026-09-18T13:20:29.278Z" }, + { url = "https://files.pythonhosted.org/packages/63/da/28befc84454cbc6374550de7746f591f8fe1b6165c1fce249652cc8291c4/psycopg_binary-3.3.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5927b7ba63153cd8e9862987290a2b783a5c590daf2a4ef981700cc3569166d4", size = 4782318, upload-time = "2026-09-18T13:20:35.401Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8a/0d21c2c833cdc0d4244c77e858e0ed37fa2abec2623be4fd686f617109ce/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0bf08b749cc144f33b44a91b78e3f71c60eb07963746a0df5a100b36ce3d7475", size = 5567460, upload-time = "2026-09-18T13:20:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/7692d0d4e656b6cc9868d8acc2e3b42f17a0db4a625400a6d093cb0533a1/psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:31cd942c23f613276b81a6e6598cefa12960058b0f46e1e874b540c793f6aca5", size = 5246902, upload-time = "2026-09-18T13:20:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c1/b8a1f18fb1b7558a17f57f7cb3fc8bc93189feea2958925950b3acb15743/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4690cf67738f0e0e49a32aeec99bf0e4595cc2b4f1af984a4345394b1dcff91a", size = 6847192, upload-time = "2026-09-18T13:20:56.874Z" }, + { url = "https://files.pythonhosted.org/packages/a5/76/404f33519167c65cca88ec4998776f1dbebccc301ee977f0e62c47fb0826/psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ad1c785e784cfd87e8436c6b7702f2d321fc39601bbaf29bc63a41a867091638", size = 5079573, upload-time = "2026-09-18T13:21:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d9/79e8fbc8f37262a415f3550f0bcc5f98037442bf3d12ef6cbae2056655ae/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:79a2a1c3449f6c3409427078ed1cec10de79f3023cb5f2504f0597d350ad46c7", size = 4613633, upload-time = "2026-09-18T13:21:10.664Z" }, + { url = "https://files.pythonhosted.org/packages/d4/47/96225db74be7d2ce04b3a58678b53cda610225055edf5faa775c9f501d8b/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:86147cb5d140341c3363fb5bacce31f8d5543902a46699d3c536b101bbceaf9e", size = 4293375, upload-time = "2026-09-18T13:21:16.027Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d2/18e9c779a5efd565250329adaf529ecc2b8b2ed5be5cb0f6ccee208cbfd9/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:7308c93cf0b19bbaf8e6ff0a6ad50d3c442385739245fe15a8d593bf841734a6", size = 4019883, upload-time = "2026-09-18T13:21:21.587Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/0cc654afc6c2cda982767f5679d3646b30b1ec86545bdaa9402202d6776c/psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:05a83ac9fd52b9bca7cb5ab04b3691163170bd16f53defa27216ea3aa07ee781", size = 4332607, upload-time = "2026-09-18T13:21:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3e/0a753a74fbd7aef120f286c016e09d3cc3f1daf7688f4a145d27281260b2/psycopg_binary-3.3.6-cp314-cp314-win_amd64.whl", hash = "sha256:1fbd30e537dab22cafdf080608f10148fe2a5f3a61294ddb5113caac8a623840", size = 3755671, upload-time = "2026-09-18T13:21:33.855Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b1/a372b9c02aea50148e71c9853e19efca8fa5ae2010a8e27243b9b8f790c0/psycopg_binary-3.3.6-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:bf8c8481d026b85dd70c5fa7dde85b2333aed0b32a2602bcd38a900cbd78a49c", size = 4719571, upload-time = "2026-09-18T13:21:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/65/7c/811e3828c6b82e2f10c6c9cdd963cfc66f3e024026e5a69ac18530bad984/psycopg_binary-3.3.6-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:b599defe9190b17e9907c8b4d114c181e702c87efcd1b8a0ad40971cdcc4634a", size = 4781230, upload-time = "2026-09-18T13:21:49.516Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/9a784eed813ea9e97c294af3ead63d02b7b203502c66380336c50065e441/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:b8ece331509f7a975b90501f41e83ad905e4141753fedf3f2711b2bc70a8efbc", size = 5566111, upload-time = "2026-09-18T13:21:58.089Z" }, + { url = "https://files.pythonhosted.org/packages/68/16/47194e002007c27337b11e49bf459c4b19727463f9aff2e1a90917bcc806/psycopg_binary-3.3.6-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c61617eaae0112ca154da87ffb99b73af2c74067acac28dfb9a4455b019dff2e", size = 5249963, upload-time = "2026-09-18T13:22:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/53/84/5dcf9f310b11f0675cd860c6b2c70f58ce61798a3ee3f6f962b53fa358ca/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c6d19cb4999d03231e8730a5f66c8f5068bc3b532677eb39dab0f600bff3e312", size = 6847925, upload-time = "2026-09-18T13:22:13.088Z" }, + { url = "https://files.pythonhosted.org/packages/f3/06/1957a06dc22963c418c27b284929579de84f29c37ad1abe6dc6ee9e8cf25/psycopg_binary-3.3.6-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8cbb54454dbf1bbf2ff08dd7693e8d94ac94b1a20f70f4b3b813d52ecb5cbc1", size = 5087720, upload-time = "2026-09-18T13:22:17.959Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/ac07d042bae99b57bf123bb473632f29af544008094da0ffd285ab8011e2/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:dc75da5a20951049f7b773145f998f69d181adad9c58a0ff36e0cf1d73c10e10", size = 4613412, upload-time = "2026-09-18T13:22:26.719Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/019156fbeafcefb4cccc9d109de4699493bceb8313c7545c8349e089dfbc/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:955e3dd94da361e052d2e49acf591017158dc8f8ed2c8a42c2e3943403c39dc2", size = 4292618, upload-time = "2026-09-18T13:22:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/5d/0f/62113dc6b1df65983a1f2fc816c04b1edfa22f2ae9d4abee74ed267f4a96/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:c7753871eb57e6a5f4646f6168590c6653073dea5e9e720b201c8875332df4c8", size = 4027121, upload-time = "2026-09-18T13:22:38.334Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/cf0cbd1ea5a7d8167fe2c6953efde19101f7b193bd61a23e6d622ad6854c/psycopg_binary-3.3.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:303732e798fe6729f8e12021b9c96107df8e95ecec4dd487c67b98ec2a59435e", size = 4336388, upload-time = "2026-09-18T13:22:45.576Z" }, + { url = "https://files.pythonhosted.org/packages/98/33/e2a5b36edf8aa422f6fa4b894756eb33dc93b36df5f65121280bb8b929c4/psycopg_binary-3.3.6-cp315-cp315-win_amd64.whl", hash = "sha256:2f122603f36050937982abf9668d8bc4769a79f7c93a65013b1c49f1cab7b56b", size = 3756154, upload-time = "2026-09-18T13:22:51.283Z" }, +] + +[[package]] +name = "psycopg-pool" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/bf/c935ca98e73fe8ce65b87ef08a280c0c1e85295d569228c15e87d8fdfaf1/ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161", size = 4948764, upload-time = "2026-09-24T20:37:49.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/26/df51322b52ee1ada7eff2d071ea09d11d5c2d1dcc9f02594f5785c4d1635/ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96", size = 10082922, upload-time = "2026-09-24T20:37:13.045Z" }, + { url = "https://files.pythonhosted.org/packages/a5/27/7bf51f5a7aa375e9f339280a303aab44ca75dc1525f1cdc5991761685b0f/ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67", size = 10236360, upload-time = "2026-09-24T20:37:16.049Z" }, + { url = "https://files.pythonhosted.org/packages/b6/63/09659283f92f02dff45809da194a70da2f688d87c55d8875c4fae3536072/ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6", size = 9892940, upload-time = "2026-09-24T20:37:17.957Z" }, + { url = "https://files.pythonhosted.org/packages/24/58/98de1b72ec172f5f8f1731236fe21585b3998bf7dfe9fcc44ae9ba626012/ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c", size = 10032114, upload-time = "2026-09-24T20:37:19.942Z" }, + { url = "https://files.pythonhosted.org/packages/c8/7d/f1e17c54ab59d4bad1dce8ee3e22a7a1d0ef4745240decacdcf3832b5bb2/ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d", size = 9910227, upload-time = "2026-09-24T20:37:21.872Z" }, + { url = "https://files.pythonhosted.org/packages/34/19/436f647a65075bbd3bab2668b3bdaa5120559b294694018cdcefabbbf30b/ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120", size = 10547484, upload-time = "2026-09-24T20:37:24.229Z" }, + { url = "https://files.pythonhosted.org/packages/03/59/38430a6bc2f6d8095447ac39625cf8b6e9344a47e6c26225c8ba1bff3ffb/ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129", size = 11412367, upload-time = "2026-09-24T20:37:26.307Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bd/bbb6d7fc7f208c8b8c50dd5dc8206e4cfdb1e7a8fb852606a3adf370c880/ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c", size = 10869787, upload-time = "2026-09-24T20:37:28.35Z" }, + { url = "https://files.pythonhosted.org/packages/bc/b8/9c543074918061abbefc3bd139bee22de35abedb00dde0f2d27288838962/ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728", size = 10406494, upload-time = "2026-09-24T20:37:30.624Z" }, + { url = "https://files.pythonhosted.org/packages/35/7a/5a8851bd146e7ccf8fd4b003f6c75c11f8fbdb0e60673b45097986b6bf41/ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452", size = 10590083, upload-time = "2026-09-24T20:37:32.439Z" }, + { url = "https://files.pythonhosted.org/packages/87/f0/4c3467188f23f806960b46fa76575a7cd0514c9ba90562650b71efc96980/ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e", size = 10119151, upload-time = "2026-09-24T20:37:34.581Z" }, + { url = "https://files.pythonhosted.org/packages/15/34/5a4def5adea572ce6aea0bb64f21f928ee317b80e0db747d7979b01d7261/ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7", size = 9911544, upload-time = "2026-09-24T20:37:36.796Z" }, + { url = "https://files.pythonhosted.org/packages/62/5d/d15ebea7499eef9373318c0ee6ca127832927c6529731f6d48e18dce7ca9/ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee", size = 10269884, upload-time = "2026-09-24T20:37:38.857Z" }, + { url = "https://files.pythonhosted.org/packages/d1/56/c5d3cd119ded7a3c7aba0e961b69cb3df701c91662c98ad694467d060ce1/ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588", size = 10749366, upload-time = "2026-09-24T20:37:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/ac/fe/734ec7527029ac757ecf821f143c9f3fcf69149c21f53a044a899b430f5a/ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385", size = 10152355, upload-time = "2026-09-24T20:37:43.025Z" }, + { url = "https://files.pythonhosted.org/packages/14/21/26e4643629b3ebb44f0a06f9c9a53058d63d989415f63a9a3c28e2ee7f22/ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b", size = 10592965, upload-time = "2026-09-24T20:37:44.944Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/5fb1a39dbb5ae314d5f59bc7348a63c1d5c20f3cd83914c4b5cb0be31d2d/ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284", size = 10458649, upload-time = "2026-09-24T20:37:46.882Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/cb/7c68da82239ffae9378eca076afe14c310e17a4f01e3d315112f266e26c7/sqlalchemy-2.1.1.tar.gz", hash = "sha256:fcf3cbb33bb23bad75d2150157c21804618745731e9931ab27e61879a9f6123b", size = 10503203, upload-time = "2026-09-25T14:59:00.117Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/44/950ac8e093784fdd5fd4fc385d110e0cf1d1d2bae781dc7be659bf3e7af3/sqlalchemy-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1abf1acecd4d0fc34d4ab7e9cda9e849ce95b647e1a5f76db7d2a5a0bea4e1e5", size = 2458475, upload-time = "2026-09-25T15:17:05.018Z" }, + { url = "https://files.pythonhosted.org/packages/20/13/ade5c1da6221e865a57ee45132a3c61528bb244d7b22ddbbddd589989754/sqlalchemy-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61333b2f806f64a08a1edf0b0e4271905f73c1f7e525c23da7a491f74c109835", size = 4653012, upload-time = "2026-09-25T15:25:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/b2/95/37b6846549a76c69beca71974905446130d78bddd88a4414b5b88c88af16/sqlalchemy-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0fad3dc37851b096ef72538b18152a5c1ae9c8b2013cd89f51eca83fc6eba36b", size = 4701803, upload-time = "2026-09-25T15:25:55.644Z" }, + { url = "https://files.pythonhosted.org/packages/57/88/abdc1ce5daa0af77c5d27183625ab7bb1155de41067fd77db4fb1cdc9616/sqlalchemy-2.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a3126c1203cdfc0f00884f9e8dbe95307b813f883ee9d9691983a563ebc96f8", size = 4351745, upload-time = "2026-09-25T15:31:43.337Z" }, + { url = "https://files.pythonhosted.org/packages/7b/28/4375244fc658c95033593429bc6dfc61df96beb9b64c8bf57821fdebf85d/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f90e517b2eb9c4c40fcc978c8c242789fa6cb0064c501fd8c1515145ed5fe23d", size = 4576898, upload-time = "2026-09-25T15:25:49.478Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ce/0aa3c294150a7a018e61af42462cf299022ad71b4b94384be36fff6ec567/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:054a2c2b6cbe3ca7cb88aba9c5e2ecdbfc43656bea22326a41d09e5398c86ec4", size = 4346156, upload-time = "2026-09-25T15:31:45.624Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/cf00fdbcdd8f1933aa4b33d499218a8d470ec9fd935a2f33ec55dcef4fdc/sqlalchemy-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ea049f3e2702967414bc79f58a484dab191359267726607e6c1579a09ef93788", size = 4650545, upload-time = "2026-09-25T15:25:57.819Z" }, + { url = "https://files.pythonhosted.org/packages/48/9b/8b4a17884e2b286d5f89e3b93bc2be928c6d86d7bfeadf9ae4459d64c244/sqlalchemy-2.1.1-cp312-cp312-win32.whl", hash = "sha256:842a5c00ca9719cc26fba6f401f70e4bac19dc8cc4dc36350803a03954d6aea2", size = 2369166, upload-time = "2026-09-25T15:36:05.081Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/c91c3cf7f0e65ae7ad9ebc87ec4cc68f6d163fddaa36736621a00dac5905/sqlalchemy-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:0220e0d1d1ad2391a03cd6713ca5d795419033cc40baa1233f9ad454dffa25e6", size = 2421474, upload-time = "2026-09-25T15:36:07.069Z" }, + { url = "https://files.pythonhosted.org/packages/e3/65/fc72cd55397792553b72a8c43e14531baa436d08f832486d2cff9e806f36/sqlalchemy-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af189bd861bd31a84f0ba244d6db0c8aeae9e56448c9001bf02ee6a93758b39e", size = 2381060, upload-time = "2026-09-25T15:11:45.018Z" }, + { url = "https://files.pythonhosted.org/packages/cc/54/83400a33b70e02ef03b877e9d35c451124b4e74846a02e8a8a42d66b3dc9/sqlalchemy-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b04365ffe57cdeaf4d0f75630332d857dde3f91336bc4bdec76c810473c9c35e", size = 2452431, upload-time = "2026-09-25T15:12:48.551Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ab/15a4bda565d148aae53f8df650a5e3c908735460b97ce37a89f637a87d49/sqlalchemy-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dee7886f4725c304641929a38d1ebe163f9fb45a5c9363f34bbb85bfdcb084c5", size = 4577496, upload-time = "2026-09-25T15:18:56.457Z" }, + { url = "https://files.pythonhosted.org/packages/b0/98/35dd80a408ae2d880f92631eaaff7ae1b1004a3cd8f070f95d9c534380ee/sqlalchemy-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abd893deac6b0765f51d87129c5631cc5bc8f784f8bf22a9d3903695c764bccc", size = 4628607, upload-time = "2026-09-25T15:19:51.653Z" }, + { url = "https://files.pythonhosted.org/packages/47/61/177fad5305fbd3ca2b528361f8723d7804995eeb458bdf33eba5547eb747/sqlalchemy-2.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c60fb6793cf60b2733012352f0e1861c38b24727d0e37a1da1f3974eb79f0ed", size = 4295693, upload-time = "2026-09-25T16:17:51.491Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/ec0273d82a196180f75084cfd7bc5ad5ae43724d0b8e4addf5dd97f1a0a5/sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a74c46cd93e34b912adafab98aa71c862518451a019d97f9c2f37392fc8eea3f", size = 4502443, upload-time = "2026-09-25T15:18:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/4b/91/36ff623fa5f28c5b96cec0f31c5c76c66ab9362d21f1d97df14261b5ec3e/sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:eef01acb10295adfac8a960f716687052fe16f14a3f5955b56c24eefa9df7851", size = 4297102, upload-time = "2026-09-25T16:17:54.314Z" }, + { url = "https://files.pythonhosted.org/packages/71/58/a5a62bb8e2aa32c202dad2930efe7d7775b98f6cce7424dc7e6b5f357500/sqlalchemy-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ed18c0747eec4a0d8d9abe4c89d96ec0eda97721ab7d3a31d0c1ebcbf38cd2e", size = 4589847, upload-time = "2026-09-25T15:19:53.839Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d4/8e80c47eea173dcfb09ed73d255ae2f5f52b6c053a335639fed9000b0cfd/sqlalchemy-2.1.1-cp313-cp313-win32.whl", hash = "sha256:389e7098e9ae15094f222496de98b0fd211c06b896c73c6e46aa9ca5b6fcd590", size = 2366456, upload-time = "2026-09-25T15:20:28.824Z" }, + { url = "https://files.pythonhosted.org/packages/07/ec/29eb6ce8f821d36df50ebe7bd05fb6f04b61aeed78ccb6cf830f413b34d0/sqlalchemy-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cf88881d593f527860fa7c63a50095496a5fa15632b1fee58e3114353f7fbc22", size = 2416469, upload-time = "2026-09-25T15:20:30.607Z" }, + { url = "https://files.pythonhosted.org/packages/69/ff/ab43fb0042119bd7ac7347d2df1391f37515b2e27cc49bf29bf799651878/sqlalchemy-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:28b882509319789d52966d70164ccfc5bc1aaffc1c7f98a79bbccec13346ebd7", size = 2377438, upload-time = "2026-09-25T15:25:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/19/6e/3739dc61a0bd20db049f1b220cf380d55cc6c6e6e80a9cab382b3fe2d826/sqlalchemy-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:597e1edf9cbe6ce63974813000c02c7a44da3d21f1da47f07174732ba188e8ae", size = 2455544, upload-time = "2026-09-25T15:12:50.464Z" }, + { url = "https://files.pythonhosted.org/packages/51/9b/29879ca69033f466da33486d6fb238ce1f4465e15529959e5bd3a47a8f8d/sqlalchemy-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e37b1fe2e080bf47f3b693dfbec9aa39d3c3d52bfac3b4fef290a449570bcf3f", size = 4571544, upload-time = "2026-09-25T15:19:00.881Z" }, + { url = "https://files.pythonhosted.org/packages/41/0f/6db5347662a9ed83509a5236e64afc15285947ed4dd71e618912bde83445/sqlalchemy-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18148691d761ef20a92cfc7dced305834e2c6ab4f4f3648f66f5d4028e234089", size = 4601803, upload-time = "2026-09-25T15:19:55.751Z" }, + { url = "https://files.pythonhosted.org/packages/77/e4/073eecd01e8a1bb5b90638c15f06b86c897518ef96d70118a70ab37bb673/sqlalchemy-2.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44dff425e7d88b04497b4317478a6af2a694327a087e6f1670a4266180838269", size = 4295583, upload-time = "2026-09-25T16:17:56.955Z" }, + { url = "https://files.pythonhosted.org/packages/25/71/c4e261d50297457c2496bb39b03f2a2ea0578a298defe4edc5ef514d4423/sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6a261e2244fa88e6eded95a078550c0584da55db22d49b0b2db8c075788282fb", size = 4501686, upload-time = "2026-09-25T15:19:02.804Z" }, + { url = "https://files.pythonhosted.org/packages/7c/85/9ec520ba12027bfdbcaa3e6633e977d1984dca5739ab73bc6c9f16cb4900/sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4789593e8979d7c946da1985a560200b6ef6c3dc6dcc5442985d62c6f2908875", size = 4296847, upload-time = "2026-09-25T16:17:59.584Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/877c7f19fe976dffbffc579148d842bb99665e520af83d28b287a162f843/sqlalchemy-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1477f2f1f531697753a729dd8f3e4e98f7019086f001fdc4d50350c929871c8c", size = 4560888, upload-time = "2026-09-25T15:19:57.887Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f3/feb8b0803a6a2d095f4330cdc598aacef46cfb1ab6e83f4b396c6f5b89cb/sqlalchemy-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1ef7bc38d140b3cf08cc6e8823b8e76da2e188b5594997e2c16586c9778af472", size = 2373288, upload-time = "2026-09-25T15:20:32.159Z" }, + { url = "https://files.pythonhosted.org/packages/be/b6/eef7d11752b5b52ad3fecfe03fabf7324e676bf1db5a22788cbcb3ba69c6/sqlalchemy-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:73b0f7cb41a3df79dc0427377a9e2a099eb15995a327120d651f21309faa662a", size = 2423714, upload-time = "2026-09-25T15:20:34.012Z" }, + { url = "https://files.pythonhosted.org/packages/22/a0/6ce78eaac3a3197847a1d184a24633869cc343b0885f9207aa605e916bf5/sqlalchemy-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:a0421f9eedad616b04a097ae3f155c5a0a31380364369861fd6e0f46074c3fce", size = 2388489, upload-time = "2026-09-25T15:25:20.78Z" }, + { url = "https://files.pythonhosted.org/packages/b1/56/db8f82c576dbe4f52db82e0e430debca0a6398fd0bf6a45bde11e7feb126/sqlalchemy-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2f0bb0502de28baed425278d4bd98658d8256419fd9ad6ca964138710f933aa0", size = 2494990, upload-time = "2026-09-25T15:27:27.737Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e0/cbadd9f851fa376c619f97adb4abd4b0c9d727841e988c4283dc45007c1f/sqlalchemy-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e20233c7d000c006a03f3734d8e7a6e63aa878fcd3482c1f412c864a0992e5a0", size = 4899710, upload-time = "2026-09-25T15:28:30.224Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5f/8cb1a5c751161cdac50355b2c91617e2a872c237922a54b47ce014f11dd3/sqlalchemy-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b0660ded5f9c090f9bf49fba78d52843ff3ca8c10f71624811f42f11655762d", size = 4810110, upload-time = "2026-09-25T15:49:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4f/a4329f5a006b6cd2e8836b160d179c522c0cd7edfd494971a6d5a504f33e/sqlalchemy-2.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:63c0291bf0f721543db3719c71f2d5ad6649f12abbcbc8324d42805dac112cbc", size = 4490328, upload-time = "2026-09-25T17:35:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/7bfd8136b8038bc1ffad70f283bd51aa93e43808ea522dac5a99be7b26b3/sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:092a743f28f3a660321e9b093144b6a5b16f08dbb9b2f52466b47a2a128475c2", size = 4757854, upload-time = "2026-09-25T15:28:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/a25d5f0e8493fa8ad2334a7100479302cd1928f788c6200faa952b47ee27/sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fdef70d59afce463bedef54580ba6a832f9f2313c1d0054e15cbb4ff46f37a97", size = 4490016, upload-time = "2026-09-25T17:35:05.664Z" }, + { url = "https://files.pythonhosted.org/packages/20/74/075ae28f89ba586a2229dc746aa8b4e5b0b2595f228c96bacadf66940bf4/sqlalchemy-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f94929be35def6580cf161f20c71930d9364d62f7040b6aed02b13fd3ca09e6", size = 4748267, upload-time = "2026-09-25T15:49:05.376Z" }, + { url = "https://files.pythonhosted.org/packages/57/fd/744a9e6d2788d1d4a077ed711d06cf503762ee8a614f887e284f0869beba/sqlalchemy-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:dabf6a73f2cc1fa2155b29f2a8a85afd82d8613262e6054cc3c932f5dee5d474", size = 2431659, upload-time = "2026-09-25T15:25:37.802Z" }, + { url = "https://files.pythonhosted.org/packages/c7/11/f4a73cb472cf1401ba3f47b02eed7983a0545f0347582afd469d5da2f324/sqlalchemy-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:3353333f8c25fc5e0661f54630238a17b0d6600305e224400cd4642cce4af34f", size = 2499109, upload-time = "2026-09-25T15:25:39.706Z" }, + { url = "https://files.pythonhosted.org/packages/ee/54/21ceb7c4ad51255be2333aa4fb32c973d35fa28b26f9895c6d66767aff4d/sqlalchemy-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7f15820c84b2377bcf02cf754232690ac2ad90d425978bf1e5ba30cc04f94d55", size = 2417171, upload-time = "2026-09-25T15:20:58.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/b4/bd32e5cc1d641871ca95c0d7bd06c9d7e03d47bcab100f0c27fd2e6c8c74/sqlalchemy-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:481d926922d9bff632955ed6f07e2b87066b0d1069eb67cbc8c35e27f7fe5e1e", size = 2455487, upload-time = "2026-09-25T15:07:27.083Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2c/87f7754471eff7ad3186243a835ebd609f10a46d73d0c62a40188d8ace70/sqlalchemy-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2a036e3c8b183f9c31df7611e03a4bd160e055915772bb8c13ec382da8f3571", size = 4575174, upload-time = "2026-09-25T15:24:16.573Z" }, + { url = "https://files.pythonhosted.org/packages/c5/82/c6684ed8aea294894b26b8b832b5ad42dc448e385d35d8d265f6c24309bd/sqlalchemy-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1f36ca818146b0e844dbde6dced65805d4ee06ce6282aa46a807f9a6619ede", size = 4610877, upload-time = "2026-09-25T15:35:07.336Z" }, + { url = "https://files.pythonhosted.org/packages/e8/14/ce3422645f694cc59eadc267009c245c401f15e03b342a9342cb05517b99/sqlalchemy-2.1.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:583038541d9246dbb05fc4ab73038dc61820983c12fa5e32a3b0d903e1ba7bba", size = 4312066, upload-time = "2026-09-25T15:28:55.17Z" }, + { url = "https://files.pythonhosted.org/packages/51/58/0888d935b0f9e2a61dfd28872cdda833be63239a4f21ac49f22a79fbb848/sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5274d07ae1dc5dc0e452ec24bdc97cf76ba189cab3f0c1c0386a1523240f775", size = 4505127, upload-time = "2026-09-25T15:24:18.447Z" }, + { url = "https://files.pythonhosted.org/packages/95/4b/c54b6cd56a1d064bef1dd1e202aa54d6b9baac4f3ecb8f42c179907d5e99/sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:f2c1c27a41d1a26db380b3bad45f822d979c9608108c32ee613a418ec454ecbf", size = 4312019, upload-time = "2026-09-25T15:28:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/db/b0/a42e854cc34c9fd47b36f3df15b4036f778b5032bc3206b1be31d35476f9/sqlalchemy-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3ac9781474e4b6c3e184487643d47408666be794d5782165a817da63f981f89a", size = 4571994, upload-time = "2026-09-25T15:35:10.02Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/34b2b42bdc5c5e4bd2d65406ac02cea51ad4bb6fed258223f6501a5ee902/sqlalchemy-2.1.1-cp315-cp315-win32.whl", hash = "sha256:eb2095d39baecbf8b78ea202d3d21178cc8d73e52c3b450c82feb200d5b0d1a9", size = 2373014, upload-time = "2026-09-25T15:19:07.42Z" }, + { url = "https://files.pythonhosted.org/packages/5c/91/441d75e7a94f3b5cf92b655282f5405925403daeba543d2cdfbcc6742923/sqlalchemy-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:ee446c07db92e94af30e1390ef7d65c1172756403f743fb7d8b4fc5f377cd427", size = 2423755, upload-time = "2026-09-25T15:19:09.005Z" }, + { url = "https://files.pythonhosted.org/packages/52/1e/414a98da13eec0e6b984a0659c7cdcc835be8e5cb73ed13cf134aefff9e8/sqlalchemy-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:57ae70935cd382cd41d84757e802687f7c5e7411aecf2b962b7bdb6ea74e088c", size = 2388191, upload-time = "2026-09-25T15:29:56.041Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d6/750d7aeb670f4954704206a5a0d20b07833549080d2e5cb1503890da568a/sqlalchemy-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:558d6d7d6eec0e54ed212320a587bb7f30553550396226d8488803c93757f17b", size = 2491192, upload-time = "2026-09-25T15:27:29.424Z" }, + { url = "https://files.pythonhosted.org/packages/e3/07/8e5c0691325466bbca0768406b4d5c2b6585d9684e25a4894bdc4df9c745/sqlalchemy-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1986faa95fd7b206e5bdd1af7f4ba9ac10f1166225fb2eca0fbca04e3851f7d", size = 4875769, upload-time = "2026-09-25T15:28:35.083Z" }, + { url = "https://files.pythonhosted.org/packages/29/2f/4deede5e238c5d539422e8f6cd692925a604a37b7c518bbcc89ac49ca801/sqlalchemy-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87d45175e8ba4a1ee027ad9307e6a4bf8a242469415f12afc9477def739474b0", size = 4826021, upload-time = "2026-09-25T15:49:07.847Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/b620b9ccafbdefd83cda6fd479e82d7f5dd0baec0fcae8c9e2b1296091f1/sqlalchemy-2.1.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d846615b89702ef5d0809e701ff5f3816b88dd02670c777adc456f6d81ef3ef", size = 4476462, upload-time = "2026-09-25T17:35:08.43Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/e6829e519e73365c180568b64b5d2f79350daf6eb468e74cdc45b2bef018/sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9324681cf8d8e9c7f6e507fea6a8ac2215fee253340d4399645216a08b936660", size = 4733363, upload-time = "2026-09-25T15:28:37.973Z" }, + { url = "https://files.pythonhosted.org/packages/af/78/9315b9732ce85aae954a4609f9e7d45975a8cf3a4133f2f8bda7fb7bdca0/sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:88cb0a27ee9571fe339d7809a40b14ff2387ea05d02629a390a03c87cc981277", size = 4475138, upload-time = "2026-09-25T17:35:10.766Z" }, + { url = "https://files.pythonhosted.org/packages/99/f0/50f86f633f6ddb76d4a81bba1d0b382acfe465063699bc19900daeefdf05/sqlalchemy-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:359e2411cf4713b4b323b3c2558a07663a19ab82a5d7b23c601e1be5432aac0c", size = 4752931, upload-time = "2026-09-25T15:49:10.069Z" }, + { url = "https://files.pythonhosted.org/packages/60/0c/e5a498c55b4f73b06bd9d541dff00b9736942bade23203719f1ba2c90774/sqlalchemy-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:d111318e32b4452d85b491d2ba3f39f6be2736ff858da616f0e6627bb3810145", size = 2428440, upload-time = "2026-09-25T15:25:41.517Z" }, + { url = "https://files.pythonhosted.org/packages/99/0b/df1c33f575ef39074dc682db2a33a1c5f259d48ca392b95a695f43553a29/sqlalchemy-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b357598df0676accd1ccfb6bbc5db53dbfb499052d2d1677adf4ce14a7180664", size = 2494146, upload-time = "2026-09-25T15:25:43.453Z" }, + { url = "https://files.pythonhosted.org/packages/43/e1/f3d25c2ca66ed394b0fc9786af9857c448bc9ac68e59c3d2283245e9dc40/sqlalchemy-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:b76d8fddf080c53f2afdea6e7149721e7b980086a2f17a9b0bdb42571a92c285", size = 2412985, upload-time = "2026-09-25T15:20:59.747Z" }, + { url = "https://files.pythonhosted.org/packages/c5/7a/7d1879d78c086737acee5f3250cbff9d285e5bd7cb6bc2c730f24e8d400e/sqlalchemy-2.1.1-py3-none-any.whl", hash = "sha256:4357c1a222e141662251a59d3702f9b124294941171dfa6bd570513e9f4905ee", size = 2047471, upload-time = "2026-09-25T15:20:37.797Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/31/3d74fa778a63b98b7374323befcc0be5ab3bd94afd4096a0124e7379152c/tzdata-2026.4.tar.gz", hash = "sha256:f1b8bd365d8d210c55353f4d7f8d6d8561c0ba50d704b700d195a9424bba0d79", size = 199350, upload-time = "2026-09-12T12:56:03.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bc/8737e8d54cf51106118039b83f485a4783112fab49ea9d044b234978a46e/tzdata-2026.4-py2.py3-none-any.whl", hash = "sha256:c2169a8b0a7a5e9674da5a135ccdfb2b3e671b333ed9fed17b41f73c34476e81", size = 347494, upload-time = "2026-09-12T12:56:01.67Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/a122743607939429ab6a7272672f49303aa02bbd34c3f06676765a3861de/watchfiles-1.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:e0204f90677b7fb8d3a824279a71a01166bea9f6e53923b8056592d87b33b0b6", size = 395978, upload-time = "2026-09-21T09:08:32.747Z" }, + { url = "https://files.pythonhosted.org/packages/3e/9d/0ee1b0ea42acfe72afa2484f0222e2386dd09992e9034a81e63cc3cdab0a/watchfiles-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7c1125e99f84934a92996ae343501285617373acd66f16b59fd5654b27ad8d80", size = 387997, upload-time = "2026-09-21T09:08:33.953Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f2/4319c6b683e8bd28465dfc53c750617a2a07f072eb2e3ab0182ccf812229/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a85e243c87109d9f48a2e0d10d6abdee8768cc114848b32281ec83d7f81d86ef", size = 448067, upload-time = "2026-09-21T09:08:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/b7/71/9f37ac192a51a24de3a2d7fe9aac1e99b2b2668300674ff7a9f85d95beb1/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dd538c71766c59c732e2c5cedc39323c99c11897b99f93eac773438e54af206a", size = 452881, upload-time = "2026-09-21T09:08:36.238Z" }, + { url = "https://files.pythonhosted.org/packages/34/07/1224667c32a4a96876ddd642da69733cbfe6a15897db0865aec5a882cebc/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da213822ec9082b62cadf3008e07a8c690ec7a3c89f6dc5e55a8cf07b56a1b26", size = 484802, upload-time = "2026-09-21T09:08:37.717Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4d/7f29cd87e1f826df86fc0418d0b8f370ca0311c29c618406dc492aad9951/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4593392e87669836670f87d62fc754059eaeb0e6157af44060a46558cebe5a3", size = 567522, upload-time = "2026-09-21T09:08:38.94Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ac/3aa1f84abd4dbbfbae99a817dd931f77b439261c911189cad4c30fdfd470/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26b81bc515a0f03f66a69a6fea86eea54c8eca4173c46dd7222569f79dd2f977", size = 461997, upload-time = "2026-09-21T09:08:40.142Z" }, + { url = "https://files.pythonhosted.org/packages/82/1c/27c66764d5b0dbb70a36770a32eca217eba739c28b24e8583a035087a3d7/watchfiles-1.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca39661934749df580d89f3dde266f2af7c6968f4c21c72ce2dc387d299e6aa7", size = 451856, upload-time = "2026-09-21T09:08:41.305Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/a45827084d953c1d0dd3d75abf85dcac26a0f5d115ad2fe0b7e9e931573b/watchfiles-1.3.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:04c5500faa725d69a99b0f63750e80795e455bb951849e9c98a811b4324997c4", size = 459889, upload-time = "2026-09-21T09:08:42.58Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/07740d23ce2bb23419bb24055c435a9abe5fe03efdaea1ba0720b831aab6/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec3cd4bb181a7b6329134204c2277c7144ab5f62cce9c5335d6f901ee7ce8d6a", size = 624482, upload-time = "2026-09-21T09:08:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/91/46/9540f8123eaec86df989f26d7704cad0448d9fb7271763a4f8f9fe23617d/watchfiles-1.3.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:e5871d4a7f788a9f64fb06f5793389d29d171e5c32d37c3ebfd520b017cc7694", size = 653724, upload-time = "2026-09-21T09:08:45.257Z" }, + { url = "https://files.pythonhosted.org/packages/25/9d/b9deb52f1e4528ffe44338c5ebab95c32ed79203082da661de50288e130a/watchfiles-1.3.0-cp314-cp314t-win32.whl", hash = "sha256:81c989d7267bfb7b676cd32c83bec97914e396870983cbf493e29415037bff5e", size = 270863, upload-time = "2026-09-21T09:08:46.518Z" }, + { url = "https://files.pythonhosted.org/packages/59/59/8b1881afccbafda06248be4185e28034dd73c80c85e4da24f2cb0c1321eb/watchfiles-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d754e049006e81a9be49dc78ba7802081cee426e968cbc63ee19e164fa2d0e39", size = 286879, upload-time = "2026-09-21T09:08:47.874Z" }, + { url = "https://files.pythonhosted.org/packages/46/dd/41c12ae9745d5d72f709333287679d71cbba68ad7678fed872207bba94f4/watchfiles-1.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5b631db08fd3032db57ec6a92d1777cd60f88803287e4e941a4858c53aab1e0", size = 276924, upload-time = "2026-09-21T09:08:48.976Z" }, +] + +[[package]] +name = "websockets" +version = "17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" }, + { url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e7/df821761772beaa48c211ee0e234930b35c1473778470773823f56d3911b/websockets-17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87f0d5e77548b0c40c8464cdb6108792e7e53f487c6400028a4ec28a8afbe5ab", size = 214959, upload-time = "2026-08-26T14:55:47.885Z" }, + { url = "https://files.pythonhosted.org/packages/3e/92/c3fb72f11764812fc648bf3838d224972427b348e8b3989d9e0a9df87da3/websockets-17.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:882af300d2c6a092b93767d5de03c7bb56dfb06314140c8e872d3f48e09f7b74", size = 224278, upload-time = "2026-08-26T14:55:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/fb/05/9f82d090c8d2d861604147ef6dfb938a90b039f9358d5193f1df62558593/websockets-17.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c863507ada5805517ca6dff1c524dcd42942efe6304dacf06700878398d21a6", size = 224557, upload-time = "2026-08-26T14:55:50.348Z" }, + { url = "https://files.pythonhosted.org/packages/8a/50/5cbf677b865290fe36819ff00615826e7edc1df38786f770123ff39a933d/websockets-17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d41ef69d5416fbc1d98cf96c37be6192d10fd101c3e0f8b3ddc36e09432b3c08", size = 225791, upload-time = "2026-08-26T14:55:51.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1c/eb8a032285243381b09a221ae384c972d5000453ad136add4d1595cec798/websockets-17.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5aefe78e6a3077fe22b5e64b04666a85a3eb8b934d40e8595a693adcbceb6f11", size = 228574, upload-time = "2026-08-26T14:55:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/69/85/413736251cb3ac04ce84cbd90e893d9a36a9698d4820b323aff3aa187e50/websockets-17.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f64e001bb7fa89b9f32cfa600bf8e9ac8ca26759d9b92ae01453ee303d9cd7b4", size = 226428, upload-time = "2026-08-26T14:55:54.263Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2b/a08bcc7fa1ca81a10f84ba32b6e6edd73a913f4b0c2640eed1fd626efacd/websockets-17.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:677014a073bcb1fbaa7e21144786864f16c08f856d66834f611eceb9006cbab8", size = 225184, upload-time = "2026-08-26T14:55:55.943Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8a/3bd2d0cf6b148c8c866d5d9fdcde30c04bfd81fdfac86813e69377eb4448/websockets-17.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0de501b7f2db11e83739ac20e2d33d46da4604b829f506c24be80e7def069391", size = 222430, upload-time = "2026-08-26T14:55:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c9/8e891ae342668735eabbbc669895e15195e4b45f24a4beeb58af76f414c7/websockets-17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f62114a54117e4948a1e414e89521f7fe1e3c2f83f2a571a06a4fc6718b0900a", size = 225227, upload-time = "2026-08-26T14:55:58.375Z" }, + { url = "https://files.pythonhosted.org/packages/e1/6f/c816f332dca11425e9bda7c07f7573eb5c5f8a735849d02b0d81e8ee20fa/websockets-17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eec113a5b41d124ef42ff56b0d74a6da3fd986400038eab9e58ee42a4024e837", size = 223831, upload-time = "2026-08-26T14:55:59.664Z" }, + { url = "https://files.pythonhosted.org/packages/53/67/5e91d5308ce24fc1ec74f56536c12f4888bad45ff5ea50f3180f8c518c57/websockets-17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5f051f8030a51815dc00e24bd2e5f1435af095c1cc111d747ac6e2a3620d7641", size = 224600, upload-time = "2026-08-26T14:56:00.873Z" }, + { url = "https://files.pythonhosted.org/packages/bb/96/faa298ecf2570d35b0eb37caddf4992178d907e108ed74bfffb6bc092c29/websockets-17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:655a8e28010f09fd6fa317e857afab3af7647f33e41dee88fa421e92086d1090", size = 225707, upload-time = "2026-08-26T14:56:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/0b/12/5710d2482ca5061c1eec5eb46f6313837c760d4115b1795c85b6c08be4e3/websockets-17.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:dc2b79afc074d2f3e64b26539350f697fe1b85ea1c49ea24eb588f247b053ce1", size = 223263, upload-time = "2026-08-26T14:56:03.092Z" }, + { url = "https://files.pythonhosted.org/packages/27/47/0c30f4eebfd1d93fae779d268f678d48847fb98516f5200849574eee8820/websockets-17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e4bd7eacb87d8cf3ed70d6392c770a0d92441f05d7d2a3efafb5bc171d5e3067", size = 224244, upload-time = "2026-08-26T14:56:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/41/33/46c256195a1255079ae23d1b1267b2e1843dc5f46a67f973cdf2a3523dff/websockets-17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ccbf3f4a9890d50b3a08ee04029fde30a03bfdeffaa19977628bf17251764e60", size = 224520, upload-time = "2026-08-26T14:56:05.521Z" }, + { url = "https://files.pythonhosted.org/packages/06/9a/aef0792731df4352e5f417369b532b3325fe434765ca90c193f594ae1e67/websockets-17.1-cp312-cp312-win32.whl", hash = "sha256:7e724f843fa6a0614aece65a7c73e51d0f4412ca41dccac13c3caf98e69536bb", size = 217485, upload-time = "2026-08-26T14:56:06.715Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/493ecfdaf32898e5ea24dc900e33e5e317f9662d5d9ab2d44b2e111b4e1c/websockets-17.1-cp312-cp312-win_amd64.whl", hash = "sha256:617243e19a0992095956f406ee9cd3bc4ba92862d83cb1d83bb59ce574412bec", size = 217786, upload-time = "2026-08-26T14:56:08.055Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/91954e2f7876f74ce1213e9b92c65a63b559cc4b942a931ebeb351cd9932/websockets-17.1-cp312-cp312-win_arm64.whl", hash = "sha256:9f4a08ff7cb68c27b18e09223cc6304e01d0f82d5a240d251266dfd2e6e44729", size = 217711, upload-time = "2026-08-26T14:56:09.267Z" }, + { url = "https://files.pythonhosted.org/packages/1d/31/5f6450a7879f4f063ef08897cc385ea3ce3f1fe17f08b11e3fd959abdf27/websockets-17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a0162a6372110a5601cb5c9fd826635cedf69f3e110c545dd19774e040b970e", size = 217006, upload-time = "2026-08-26T14:56:10.509Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2a/c1b006fc861695d2aa4e35327b842015ce1d98cf8f99241829b3d6460bfc/websockets-17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:829dba1bc049779de9b332088c1a6a9858e96bd67e50b6b644a95e02b67836bc", size = 214690, upload-time = "2026-08-26T14:56:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/66e5b7d01445e0eeb1d4ab419c30315f2c90cf7a8a8cd4ecc47f894dba54/websockets-17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd8f47dbf2e8adb15c847215f83436de3fdb120b51fdae0fbbdf69fd97a3ad80", size = 214947, upload-time = "2026-08-26T14:56:12.923Z" }, + { url = "https://files.pythonhosted.org/packages/07/ce/033cafe2d2538562efa876b9149a2c7a0f7787870a4b1bb6e28adc9ceb6b/websockets-17.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9f4c0377a83e163a303514fdfab501dbe379bdc13e5b9312a91d112658b29dce", size = 224329, upload-time = "2026-08-26T14:56:14.212Z" }, + { url = "https://files.pythonhosted.org/packages/34/c7/e1c2e8a67f6cc0aa43abe0046fb3b7a020980649e6a843751dc7ce9eb170/websockets-17.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c3241d684a76eaaef8b2dc789afde4343cd3aad55ea81e4e8ab3605b529bae51", size = 224611, upload-time = "2026-08-26T14:56:15.702Z" }, + { url = "https://files.pythonhosted.org/packages/be/de/07c6d48eb3d2069709410c851e7de10ab83d752c4bd09862899627c2729b/websockets-17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5f5c7a893507d0e83a80b88aefd6522f7e882cd53f9722c6f23f5a020c9557c", size = 225848, upload-time = "2026-08-26T14:56:16.962Z" }, + { url = "https://files.pythonhosted.org/packages/f3/dd/3c68572d20509648cc2fb6f50ccf3deeb4b87270f2c8966e99476e278ea3/websockets-17.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00bf34b64501e3477e81fc281532ff3cbf4da26633c10b63979d5085d46602d3", size = 227290, upload-time = "2026-08-26T14:56:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4a/8f6651c8a22093539c9215af0c5bbf217b87b382c99d2112039b92d593c2/websockets-17.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ce0305b702b20d1e1d60a9aaace6bc89970e1753565543f310d549eab22c2435", size = 226476, upload-time = "2026-08-26T14:56:19.459Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f6fc33cea86b1127fd1297b18c107e81580ab55a73a39f9a934441ef321f/websockets-17.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29176d8b429cfa0fa443c473878d37a5c06cfd0cb36b71ba4314accc71e05906", size = 225233, upload-time = "2026-08-26T14:56:20.939Z" }, + { url = "https://files.pythonhosted.org/packages/cb/83/65edaf05f7c9b1dea82f4d252fdc37706a84571646f06119a27b0a16fe19/websockets-17.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3709a1ab30b4b922027d22f68d2b61a0656a91680ac894a537624e6be7dd7f7c", size = 222488, upload-time = "2026-08-26T14:56:22.208Z" }, + { url = "https://files.pythonhosted.org/packages/07/42/d1169c2f7f1f0032b0d4b0c00f0711a070cd7c735de37bfeb876bc0f9606/websockets-17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43bd0c1ceb924d67f5c1a5254d8361dd9d94246e6331a726064dfa2917880780", size = 225295, upload-time = "2026-08-26T14:56:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f4/64e2a386c3899b917c2933225c9b47887874229d159797f3bf1a11c20d51/websockets-17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1fce0f43e0d41422e0b2cad6561e1970df22f212f4c7e884967df7cf591b031c", size = 223891, upload-time = "2026-08-26T14:56:24.647Z" }, + { url = "https://files.pythonhosted.org/packages/26/b3/dfb5c482f7e310a3432fdbb045ddfe6d34114680e89a233d4ff900a32961/websockets-17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4031152769179ab8dcdeafc7b0e58052a49117560a28671700b47b2c7b717aad", size = 224661, upload-time = "2026-08-26T14:56:26.027Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cf/94865130a336029f46412adc127c4fbe380f46172b90ce251369e35c4302/websockets-17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a06f3b5085176763182449559e20391d7ce616a8972a9f7a33deda87ea6d4f3c", size = 225766, upload-time = "2026-08-26T14:56:27.455Z" }, + { url = "https://files.pythonhosted.org/packages/96/34/eb8c658f86dfe562ed49a887a27424bfe9e618c26ea6f865b093d075d3a6/websockets-17.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:77b37cceca17291897c3c73bd30a7c7c7909593554b5da574ec852af83c1742a", size = 223323, upload-time = "2026-08-26T14:56:28.807Z" }, + { url = "https://files.pythonhosted.org/packages/1b/7e/2629609652ece5ca0c7ac235927dd4511b08131e3a5d53439b798fddf002/websockets-17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d8e83333385cac6030a5167fd18bf96cc6c58b914c308e683f05b0cf94bc8dd0", size = 224276, upload-time = "2026-08-26T14:56:29.991Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6b/8525737fe840b38e5f40956c198fb586a4fac1e07144d41a5b949b989cf8/websockets-17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:073c5c3f7e127041fa9d34a9e29ceefee8c3cafbd267ed2927318f425144380d", size = 224558, upload-time = "2026-08-26T14:56:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/74/ab/3a958c6cbcf74b118f601c20a80ac8bd5e8dfec0bcf7345116feaeefb121/websockets-17.1-cp313-cp313-win32.whl", hash = "sha256:2afb58c7ba48b329d56769f8dfd89f394efe587b65ef806bae810a484d6d3608", size = 217475, upload-time = "2026-08-26T14:56:32.431Z" }, + { url = "https://files.pythonhosted.org/packages/22/36/fb521f0f2994c25509651f169efe5582dddd8713d57a0757ba87859372ef/websockets-17.1-cp313-cp313-win_amd64.whl", hash = "sha256:0340bbef6bfbe16da888b3983d666a4db4954ac3253c38f13bc7aba0c7db5a2f", size = 217784, upload-time = "2026-08-26T14:56:33.608Z" }, + { url = "https://files.pythonhosted.org/packages/68/92/9b8419584681a12a7534b746dfb2737c466efe2455483e2fbf8b941a04ec/websockets-17.1-cp313-cp313-win_arm64.whl", hash = "sha256:7a72efa3bf4fa3a6669a54420a472ad056da3973d827f10e3a536da463f926c2", size = 217715, upload-time = "2026-08-26T14:56:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/90/0d/500cf5daea09d4669dff3a7d67159094a0bd6c4ef130381404f6edd3eb5f/websockets-17.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0c9982938980e086da59f70d05f9418cd143401a601a0faac10fa48f7bb1cd3e", size = 217048, upload-time = "2026-08-26T14:56:36.03Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/5b12c6168aa269cffbfd24d177cd492b130120403a418c7e89462e27b4ac/websockets-17.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:57b39dc8541cf7ed3f639da82bf7451060483967f9e733da1f8173e4095f0642", size = 214737, upload-time = "2026-08-26T14:56:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/0c/36/e453e5106e4e2416f008ac222837c2f1637a063b08008afcd1088889b631/websockets-17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:96abdecbaae746851b87c3a36cb4a661df93ca3d92f114270f79228bf1d00de6", size = 214955, upload-time = "2026-08-26T14:56:38.71Z" }, + { url = "https://files.pythonhosted.org/packages/dd/30/0204bb86176db02cdfc678ce65ed808a66fab87d250ce61a8790800a60b0/websockets-17.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9fc873e239c5abeb150bc24dbd1a7af23a9254526383ce0a077f5e20adbeb19", size = 224331, upload-time = "2026-08-26T14:56:39.924Z" }, + { url = "https://files.pythonhosted.org/packages/46/c8/d8372256e00c4e3cab1115c45075d1eeedb642a3f2b42bd70c4deae03f06/websockets-17.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6f42912fa9eb4cb7c7ec9fde9b3332ba339eb8a8811981043d4029599f3d950b", size = 224685, upload-time = "2026-08-26T14:56:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/12/7d/650355b8f67f908ff99603351d4458d1a0b787d627950a47c38db7e25308/websockets-17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f98bf378d7a5be047a044a1a27c987a8f355e10e3b5754617dbe756248cbc5ce", size = 225927, upload-time = "2026-08-26T14:56:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/34/6c/a9ffa5b903579eed76017870f055d75ecc73988d9d0c9b65a92ba0bf2a27/websockets-17.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d334d11398086bb5559606cb42d51c013ea7c061c7db701521392373d3c087f5", size = 227300, upload-time = "2026-08-26T14:56:43.538Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/4551c2269066af7481ee44605a0813770961615b5b5da3e87a8f5cb859ea/websockets-17.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c27336b1a0ac56569493e858497870347854372395f50483725f8cdacc5a45c", size = 226533, upload-time = "2026-08-26T14:56:44.669Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/237a99233e5c445759a613831b3a92e91905afc064dc3bd0ad33c35fd1e2/websockets-17.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67258b00302a5aaf0b267771c7014b13429abd7ea17eebc4c55bd935ff101555", size = 225280, upload-time = "2026-08-26T14:56:45.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b5/e9407a91613d1d1cd932414143a1012096b26674a782fc55a0bd23217ee4/websockets-17.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:455ffeea0879d313205df1e745e5883e1feb7f31ecd26be882f5f0babd3db04f", size = 222540, upload-time = "2026-08-26T14:56:47.053Z" }, + { url = "https://files.pythonhosted.org/packages/db/d2/db76628db0577b783205d9779f64d8e373416b04c62d1546be4b75dc8540/websockets-17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7233eaf441a345a5943a929fd4b5ea3278f11aed35a9ed0f3106b8cb3ca846a", size = 225354, upload-time = "2026-08-26T14:56:48.32Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/2174181c067b89a74ae18e2650c2ac29959f4b796afe876ab3f4d30d642c/websockets-17.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c65da239a5ad553619804c1f9d65c1a0b3005381c6158ee14da2c7444cbd0c78", size = 223867, upload-time = "2026-08-26T14:56:49.579Z" }, + { url = "https://files.pythonhosted.org/packages/df/75/274decb9a8253561b5be3261e02a6676fc8ecdf31e95b722e53d5bfb8fd2/websockets-17.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fa1ffa08c81a4f809cdab6129f8e55bee4650b9d6d3461019dda73aacd146b6", size = 224652, upload-time = "2026-08-26T14:56:50.885Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e6/49824f1fb4db7656d2f7492b1d8be16147b759d909490e32f4776843ee64/websockets-17.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:406b8107943a43ef4649b1e0cb0cdc052bbf08fe1c8905a623c4af9586e5cebb", size = 225822, upload-time = "2026-08-26T14:56:52.356Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/5dc43838c0b02a95f42c47a0de33c5ddd7767a9feeb4d0d8777ac1cfefe4/websockets-17.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:4e8ffcb486c8490a34a4cef5e4409d8da5a1cb1681e5bf7d786ce5e84aa8540d", size = 223379, upload-time = "2026-08-26T14:56:53.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/62/585637cf06d6b321232f79c55dc14d65518d12cf87c94c44f5864068810e/websockets-17.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fb88076df585b69c5761c387c0081aa87d7b9eb1b205a6535ca4777e25650d81", size = 224330, upload-time = "2026-08-26T14:56:55.184Z" }, + { url = "https://files.pythonhosted.org/packages/de/68/c3b234a6a1366b6ab5bbfaa4434a1b946e1dc4e8ddd6824bfd93a8835b7f/websockets-17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5d4724255fb8398acd9e583b97eb2279cec20e0bd0f9a94bf75f6056ef9f13da", size = 224622, upload-time = "2026-08-26T14:56:56.393Z" }, + { url = "https://files.pythonhosted.org/packages/6a/d4/84cf3d1376f5d8207f55f43c1c818babd6b89447f5dcd01f18a6d5526796/websockets-17.1-cp314-cp314-win32.whl", hash = "sha256:be3f0129c5654517b2abf07dcb75bb1d9479759a4ccfb569e8293579e9fc029a", size = 217036, upload-time = "2026-08-26T14:56:57.652Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0f/9e7ac63c5d7cb642952200814f584318e65146df008b7d375d5d9c6b2c97/websockets-17.1-cp314-cp314-win_amd64.whl", hash = "sha256:2a4dc6ef83f4559e0d05f313a375cb38f63c986096a9da99fe94fdd779d313e5", size = 217382, upload-time = "2026-08-26T14:56:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/1ae6b91f7f3ac05f5c9f14a72dc2181c115ff370bcd8a7f10f02c174adfd/websockets-17.1-cp314-cp314-win_arm64.whl", hash = "sha256:46c0331c9eaaf73a559f3a9e388466be0df96eb83d40f06f1ca6ab6613b35c82", size = 217268, upload-time = "2026-08-26T14:57:00.654Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f0/f65644d0e0b2b90918a8c41503841cc4072a58f2bf76c09bc36e751fc0dd/websockets-17.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d411ea5ca18ac1b12c0c94be88b60c18ca641ac43bcdfdf1c9f79d46cdbe1603", size = 217379, upload-time = "2026-08-26T14:57:02.181Z" }, + { url = "https://files.pythonhosted.org/packages/ff/35/4c46d1f620ac1a30f92b6eae78ee40a772a93f568647ca7ccdc5ea283cf8/websockets-17.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:07fa3e7c30e2c577928d359b56bf872a3e0cbcc15553eaa0907c1ee86344b56f", size = 214911, upload-time = "2026-08-26T14:57:03.478Z" }, + { url = "https://files.pythonhosted.org/packages/04/6e/4587e8406d7c1188e97b9cf466c081e93399380d447f885bfce81626cd37/websockets-17.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6de9acef07e3a78e9567fcd26c29011a4da8f050b13004bbf880a0fd82a6eea5", size = 215115, upload-time = "2026-08-26T14:57:04.692Z" }, + { url = "https://files.pythonhosted.org/packages/ec/06/1381c8fff525041025909eb80ace32489194a00ba22a0a8d428030afcc84/websockets-17.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ea0ed9373b880115911d9d39634bccc95b8ce590c9c42e8589f5cacc3ef3cee2", size = 224696, upload-time = "2026-08-26T14:57:05.899Z" }, + { url = "https://files.pythonhosted.org/packages/36/9d/9034e867dc85340be058619751742b895f722326e83100d110063461ca07/websockets-17.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50903d335bfda026c2fa11dd9aed09d8cbee0c451e3a85122a9acb041b7dc69b", size = 224975, upload-time = "2026-08-26T14:57:07.262Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/ed03aa3cae748ebf6397e5d44028f433f746bad09dc568ff754fda3a3c9b/websockets-17.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a74531ce81af587f906ab42f194032388fcff8fc7938402e5917c9147a39441", size = 226151, upload-time = "2026-08-26T14:57:08.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c9/cc1964a096d16f3b73cb1ee5f14f277f5a3bcac07c6e8f9a1dcded99f4c8/websockets-17.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8fbf28e639544503b7d1c96452a5e5e043e4108d89b1f3fa02910603622d19db", size = 228292, upload-time = "2026-08-26T14:57:09.846Z" }, + { url = "https://files.pythonhosted.org/packages/1a/26/46da6dd0363c2db2e4876fd59a40fd40c1943a82d7018d0a33afbce47d52/websockets-17.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f612dc57f00c07cf4aa2673f7cbceabd654ad2457b7e639f061b794d6e11f9fd", size = 226722, upload-time = "2026-08-26T14:57:11.118Z" }, + { url = "https://files.pythonhosted.org/packages/78/98/ecd8f5e1c5d0e54c08ebc5c66852271112166db68107cb0e17ca1bf25009/websockets-17.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c7ac77401227212dc6e849182feee50d57cf456ec6329ffd6979c94bb136c5c", size = 225451, upload-time = "2026-08-26T14:57:12.601Z" }, + { url = "https://files.pythonhosted.org/packages/65/4d/da8d2760db53e17aae763738b6ba834b1fcf16813d3632f3edb6951e1ec8/websockets-17.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32a2a68d989d6e5b74a9d5095415c51189ebae29fceb7cf2b64a1c0318a81256", size = 223003, upload-time = "2026-08-26T14:57:13.875Z" }, + { url = "https://files.pythonhosted.org/packages/a4/40/ea401c141a79c5b1d0021a0dab9d0df2051c108f1620fbb39a6e7c714c3b/websockets-17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:aec00f018d34c67500ff0438dc314b40277be4a1b983cbacbf53ccf7db63e257", size = 225704, upload-time = "2026-08-26T14:57:15.091Z" }, + { url = "https://files.pythonhosted.org/packages/e1/8e/07ab3f44215d89840d5385fdcaaab1fed8caeffa67c6899e15062957c12c/websockets-17.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:0014eaff8ad5b3b43feda2279f9d34bf2eaae040720b9fbbb55944b10f40b14d", size = 224192, upload-time = "2026-08-26T14:57:16.3Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/ccf1af0a23e5748d4e22292a377d78d15cf294d7e707bbb11a8990ae6bd5/websockets-17.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:db9d7ee47f3ba531e278be539af39e2c7c7d28fb94897b6cd1120d63b0ef5922", size = 225082, upload-time = "2026-08-26T14:57:17.531Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/e32200f99ce282e728d2929f2c429db353cf3282db7d0eba99eb32c9fec1/websockets-17.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ff3e2ba7a9f0a110b0555452e9b5a03a34e11662544e01beea15f144b48ba7b7", size = 226101, upload-time = "2026-08-26T14:57:18.802Z" }, + { url = "https://files.pythonhosted.org/packages/28/3d/e7a6e9777b29433620167c98f3caaff0d6b08b1239a273ef7f7fd1393349/websockets-17.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6da17fc94bd270f5987b10bee113461ac36a36a98b0481ddcc98056e5a90001a", size = 223794, upload-time = "2026-08-26T14:57:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/48/05/ac569090726dedd6656f3ee28b0c02dfb1ba76e898dceaccc2987a237cef/websockets-17.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:e8dc3fa6d6b7ead3f9de57895f41b116a28787548e066365d9d90f7356bcaad2", size = 224567, upload-time = "2026-08-26T14:57:21.634Z" }, + { url = "https://files.pythonhosted.org/packages/14/50/4ef62941111db6b31193f4fabbb65f845a5177579040cb8fe0d774d25034/websockets-17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b65d5fe48219dc2d5e158de9e6514e75600f379cc7e37108d35f31764c155566", size = 224993, upload-time = "2026-08-26T14:57:22.86Z" }, + { url = "https://files.pythonhosted.org/packages/28/42/2b95ada4ea19bf3a2072b68669ce4f4afb212690b727d31640576287fd68/websockets-17.1-cp314-cp314t-win32.whl", hash = "sha256:2cce251f3e2469b99b6802b55435bcdd07123b41870f54c87b336183af9d7e68", size = 217168, upload-time = "2026-08-26T14:57:24.466Z" }, + { url = "https://files.pythonhosted.org/packages/32/0a/67d5ee08dd8060a37d612fd40a625b5376ad19ae48fe1c8ad428c278b817/websockets-17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f6c38cdcaf98a911d7acc25577f2f9e710f3a2fc2bde1563556784320196b51", size = 217508, upload-time = "2026-08-26T14:57:25.983Z" }, + { url = "https://files.pythonhosted.org/packages/76/a3/822005d0c674451d2411027b878cdc128a2b7ea5a30d337d9e279da22eba/websockets-17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d1e2f5fa2b6d01f0d85b4f223fea7ed1d504be282a02a81bd2be4817ef7a2f03", size = 217425, upload-time = "2026-08-26T14:57:27.324Z" }, + { url = "https://files.pythonhosted.org/packages/de/d5/99a6c6a1eb5d5ae9f45f59a3c97f4e3b21f310eb404a547fb3e7d2fc054c/websockets-17.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:88381602e379165b66244b2ebc29f9b23ea0851fbe63ae157f91ca324f072d6f", size = 216970, upload-time = "2026-08-26T14:57:28.575Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0e/1e7f6e833728193958d3ed3d67b5d57c3c7cfa948abf94d4bc553257c954/websockets-17.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:88bc5138e53903a85c354e59df7ba73ce306f7b09724cef74dba121e60a88ce2", size = 214699, upload-time = "2026-08-26T14:57:29.862Z" }, + { url = "https://files.pythonhosted.org/packages/07/00/95d39549f86e34425a0412bcbe61708dd1fc46af654e2134a6c4389102ad/websockets-17.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:3546ef55b3a074494106508bc6505c73825970d2d9505f7bf53882b3e88b0d1e", size = 214927, upload-time = "2026-08-26T14:57:31.148Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/b442415fc4f7f9943b0fc8e8eebaa13923ca73361e167c439ba634eecbd9/websockets-17.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9ae55d24241fc055f22aea3ac924559069848bd0ad4ea065fdd72d2194685fe8", size = 224373, upload-time = "2026-08-26T14:57:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/a8/dd/b83537aae4cf61615b9d8b2dbb235c0030ba85457a6d934798273814600f/websockets-17.1-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7b349265fad6244013eecd99df8d83c12bf3013943e431f4fadd5bffc37db42", size = 224801, upload-time = "2026-08-26T14:57:34.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/83/5ab0abed58454909e8dbab45086ac68ee4556d7a8ada26735addc909b903/websockets-17.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc5789e5ea182b77a38881383ada5347202a6c66f4857d054e075290e80b604b", size = 225967, upload-time = "2026-08-26T14:57:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/4b/26/e2412f2b998a8c1dfc00c0709ff6ee0c634dd0b0b4f92bdfe9667876b71c/websockets-17.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ce13c7d233239e739600a57d4a73c1192ad8259e655a4d55aa1a454242bc809d", size = 227664, upload-time = "2026-08-26T14:57:36.493Z" }, + { url = "https://files.pythonhosted.org/packages/ec/25/0dd4495df3c0e02f6db705312ba85ab9b2dd42257dc23eb0da10066e4844/websockets-17.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1036189bd34b0bc1b10a4679321e2c7968af317efe6e8e4c1c5141c4254fb5bb", size = 226447, upload-time = "2026-08-26T14:57:37.781Z" }, + { url = "https://files.pythonhosted.org/packages/be/67/6df3f63ffc48f08126ed0cd2fd2a41092967c3e364f8ec100deae90b6d77/websockets-17.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e78fd4b7b2c5086a38671c9c882c1e643385eccea360b5b1fda4a105e590087e", size = 225343, upload-time = "2026-08-26T14:57:39.133Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8d/a8479bbb09ff054907d141123d8f52fb6ae5ac39c6dbe39e6a02a8408309/websockets-17.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46e7a10bf04318c7b0c0273791925ae5e1cbe4a11e34aa934d2ef27862058a80", size = 222748, upload-time = "2026-08-26T14:57:40.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/fb/4c3d2a3269cde3f3087916de9c3d9fc5d7196b46846d8c3a9ae59ad0a884/websockets-17.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:33e45c7ea38428e740a7f233555d71df0b875cef7fc080acebc9654475e35335", size = 225453, upload-time = "2026-08-26T14:57:41.859Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1c/6467b401d19408f34e1c7389c222c2c7e1dfdf08c551190269b5eabc726c/websockets-17.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6e63c01803be425ff062b7f7fc201a74def1d49fc94a2410dd17375df75936e9", size = 224112, upload-time = "2026-08-26T14:57:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5f/744e032ac80e11039a7447657ebabb46e9b5c2dbcec83be571335212932f/websockets-17.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:722ec21717eec6477bce582147a28acdfe034e604239466a6a95daedb863e774", size = 224646, upload-time = "2026-08-26T14:57:44.871Z" }, + { url = "https://files.pythonhosted.org/packages/9f/47/bcb9128d9afc4d0934d9192e2a24897ca2f7a63df2654904915349c6c46d/websockets-17.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:e74e41f0ad12ff1e8983e349daef79d37cc8280c743ce9d134d6c74c18dab5d6", size = 225797, upload-time = "2026-08-26T14:57:46.338Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/b058047b7cf565e1105b10ef6b6b24a6ebe3575678c7dc75a645334705a7/websockets-17.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:12fe8984a32dbfd084e0603f1a8d740c0180cb85b3174585c54a80d2455a8394", size = 223605, upload-time = "2026-08-26T14:57:48.175Z" }, + { url = "https://files.pythonhosted.org/packages/b9/69/fc1555bff884de363f1bf9eebf2836dbeb29fa7e4f957debb7bbcf43abba/websockets-17.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:01dcb47deebc40b38fd4a493b9b9f4d0a704b7bec6f35e4d34085b329abce71a", size = 224508, upload-time = "2026-08-26T14:57:49.407Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/648d4e68621688b19093b06f7b497d520952e68cdea1c1b54371fe9491de/websockets-17.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f4c45ee2512d3757b5e6c67c5a34e435143f2ecb7df3324f9fd888688c45c0f4", size = 224767, upload-time = "2026-08-26T14:57:50.799Z" }, + { url = "https://files.pythonhosted.org/packages/58/93/f8342b55864f71df13eb8e9ef7dce691b87a87f04f75bb8a1385b3336e7c/websockets-17.1-cp315-cp315-win32.whl", hash = "sha256:0f4f50dfe2cc810fc4e2de979b35e83bf8bb4bccdc6fe472d93762ea7b1d5927", size = 217003, upload-time = "2026-08-26T14:57:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f0/7b5fdb774c245e0b6217009e2a24d2105c1a64923949f33be41aa7959302/websockets-17.1-cp315-cp315-win_amd64.whl", hash = "sha256:4af784f3e436f65b355c117c6497320f2b5cf6a559295cb1c4c7338e335d45cc", size = 217300, upload-time = "2026-08-26T14:57:53.492Z" }, + { url = "https://files.pythonhosted.org/packages/76/33/1fe6ed1b5087516115ca451b2c240314b010647071f8fc3bd78a21e4dddb/websockets-17.1-cp315-cp315-win_arm64.whl", hash = "sha256:d58159af7835fde09c462394293c0d7aaf8fb4557d8f8e5699f5e722ccae013d", size = 217214, upload-time = "2026-08-26T14:57:54.88Z" }, + { url = "https://files.pythonhosted.org/packages/94/ca/ed02e75996a266d76c5fcb5dd9b930db4cf2b388ca5fa3d2a72086f81568/websockets-17.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1a5cf4e7bbe3ca499e6a289206cb4fcb7444b09919e129bd517f57d5fa192c13", size = 217282, upload-time = "2026-08-26T14:57:56.108Z" }, + { url = "https://files.pythonhosted.org/packages/bd/7d/d536f5bc89ea5b52fd1c1727c59fabafee6bc41f5ce92c3bd2f83047908c/websockets-17.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:416b4bc8789a1865a3ff643ec4ee073a5f52402d0dbeafd27b1798d5dd6b6a51", size = 214863, upload-time = "2026-08-26T14:57:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/37/37/944cf17bad668e9be1247e6314f88a48b9faf7c250e383410db8b38af0b9/websockets-17.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:259f45358c76d3b18489e3e80636cdbe807e05ecf1b10fdf1a779106d23d0c8e", size = 215073, upload-time = "2026-08-26T14:57:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/74/bf/3267966cc1bbc2b8fa62fd329651b0af502df1f5d1c0eed027ff339d6aa8/websockets-17.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d9d01e8ede41fea4f5a847dad9d628355f74905f437a5b6856d67aa66d193800", size = 225229, upload-time = "2026-08-26T14:58:00.235Z" }, + { url = "https://files.pythonhosted.org/packages/7f/d8/85ea722f483510abb39fc71aafb4465d17cf9051a275ab036874ff3c300c/websockets-17.1-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7b35181a14cbfcae163b4de545d22abfd07d06c2c41ca69cfcd99251d6888ab", size = 225500, upload-time = "2026-08-26T14:58:01.994Z" }, + { url = "https://files.pythonhosted.org/packages/50/ce/64c7d00005bd0d15ecb5c5fcb7fb2597b6b92ddd16c4fa6bbc3d2835ad63/websockets-17.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a8e768a048c2220697477ce2e67e4345dc9f693d0ee6af53945b5e30227c6a7", size = 226829, upload-time = "2026-08-26T14:58:03.327Z" }, + { url = "https://files.pythonhosted.org/packages/b4/dc/096c67940fb957e667ca3c542818150434eb0388c6fdc90b3a502f3c3e96/websockets-17.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:880069d21cc33a558dcf180924a546d1ecf8ada5be3e4e70acee87019d706a24", size = 228457, upload-time = "2026-08-26T14:58:04.78Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/f2331b6b7ccc67589891da354fa46a5cb79e95f83b9fd0e734d77f1f2140/websockets-17.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cec1bb8f22abccc8d20f8ca63df9be41600c26c190f4b97ee86c675fd4a863a6", size = 227265, upload-time = "2026-08-26T14:58:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/47/a5/fb1642302f8ec77ca922203074f155a9831a5128ad75e725059a476d1227/websockets-17.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f3a1d577e081667dda7f8e5b4796e6e32f9713c93e2a3d930669519840a3c623", size = 226143, upload-time = "2026-08-26T14:58:07.464Z" }, + { url = "https://files.pythonhosted.org/packages/d7/41/7133fcfb63f5562750b269d6a845c689dde6a2c6407286da395beea19ddd/websockets-17.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc053f9e95a76213c5eb7ed95779f7daf0d2bf0e4e03073629ebfa43a033f151", size = 223501, upload-time = "2026-08-26T14:58:08.766Z" }, + { url = "https://files.pythonhosted.org/packages/64/b1/82b36bfabc79ff2d383a1fc043cee6a13f794ef4f6bf1b4810ad6988cf6f/websockets-17.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:bb0efe019480a1c93e168ce96479273aaebd672fc8c350d5eed1e507ababb1b8", size = 226330, upload-time = "2026-08-26T14:58:09.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/7d/5b511b9bf6e9ad331e6ff902fcbcc71c3794d10ef3b5efe80ccb8f0a7861/websockets-17.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:615746b12b26a3fd4077bc6fbeb277a1c192a45dd57b531d07ad9ed5c52a9a7a", size = 224980, upload-time = "2026-08-26T14:58:11.303Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/aed08f25301f8eef23be903ff9319fcf35630ca2bdec9d226f7d804dd5b3/websockets-17.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:1a20136d61f9ca3a31493732762661fafc2c20e8861930214e21afc6a8a692a2", size = 225478, upload-time = "2026-08-26T14:58:12.543Z" }, + { url = "https://files.pythonhosted.org/packages/3e/47/0d63d4168536b4682c9d19b7399443b1176f25dbb68878374fa716670230/websockets-17.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:2786cbd273ab69c22612db8a41229ddf2c158060b17b5928884bf388d07887f3", size = 226588, upload-time = "2026-08-26T14:58:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/844bd0b6386fc81ed6a55f4b6dd26f01c6987eda205afa10175ea12b2164/websockets-17.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b1c323fc3be1dc3f87f6c59458cb7d9e13dcbbf971d6c3f3e2bbaf58d3bfcdfe", size = 224336, upload-time = "2026-08-26T14:58:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/96/18/03709c84bc88ec4dcea68d4be4ccd07d611073dec111203a5bf45af8809d/websockets-17.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12c8e2b25df59755954a04dfa09c990b96691025aaf7eafd19ed6da24b09c18d", size = 225197, upload-time = "2026-08-26T14:58:17.141Z" }, + { url = "https://files.pythonhosted.org/packages/27/cf/0d1c694b6466c89e875b85b32b51312c472cf6708eee91914866f5087dde/websockets-17.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f58f58b4b29bbea2a3635e2c56eff4a3adab011fe383802a9e542e31b97085fc", size = 225493, upload-time = "2026-08-26T14:58:18.521Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" }, + { url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" }, + { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, +] From d6387374e39d890beba06679cb96847b70be1373 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:14:23 +0000 Subject: [PATCH 007/124] Add SQLAlchemy models for the Krater data model One module per aggregate under krater/models/, matching docs/SPEC.md's Data model section: User, Project, ProjectRevision, Review, BudgetEntry, SpendSnapshot, ApprovalPolicy, AuditEvent (GalleryEntry stays a query, not a table). UUID primary keys, integer-cents money columns, timezone-aware server-defaulted timestamps via shared mixins, and Postgres ARRAY/JSONB columns where SPEC calls for them. Every closed-set field from SPEC is a Python enum stored via a shared pg_enum() helper (sa.Enum(..., native_enum=False, create_constraint=True, validate_strings=True, values_callable=...)), so adding a value later is a plain migration, not a Postgres ALTER TYPE, and the stored strings match the enum members' .value (not their .name). AuditEvent.action stays a plain string since SPEC gives it as an open, "e.g." list. Project.current_revision_id/approved_revision_id and ProjectRevision.project_id form a genuine circular FK between the two tables; the two FKs on Project use use_alter=True and their relationships use post_update=True. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/models/__init__.py | 39 +++++++++++++++ krater/models/approval_policy.py | 24 +++++++++ krater/models/audit_event.py | 39 +++++++++++++++ krater/models/budget_entry.py | 43 ++++++++++++++++ krater/models/enums.py | 83 +++++++++++++++++++++++++++++++ krater/models/mixins.py | 35 +++++++++++++ krater/models/project.py | 69 +++++++++++++++++++++++++ krater/models/project_revision.py | 57 +++++++++++++++++++++ krater/models/review.py | 39 +++++++++++++++ krater/models/spend_snapshot.py | 33 ++++++++++++ krater/models/user.py | 41 +++++++++++++++ 11 files changed, 502 insertions(+) create mode 100644 krater/models/__init__.py create mode 100644 krater/models/approval_policy.py create mode 100644 krater/models/audit_event.py create mode 100644 krater/models/budget_entry.py create mode 100644 krater/models/enums.py create mode 100644 krater/models/mixins.py create mode 100644 krater/models/project.py create mode 100644 krater/models/project_revision.py create mode 100644 krater/models/review.py create mode 100644 krater/models/spend_snapshot.py create mode 100644 krater/models/user.py diff --git a/krater/models/__init__.py b/krater/models/__init__.py new file mode 100644 index 0000000..cf60b66 --- /dev/null +++ b/krater/models/__init__.py @@ -0,0 +1,39 @@ +"""Every Krater ORM model and enum, re-exported for convenient importing (and for Alembic).""" + +from krater.models.approval_policy import ApprovalPolicy +from krater.models.audit_event import AuditEvent +from krater.models.budget_entry import BudgetEntry +from krater.models.enums import ( + ApprovalStage, + BudgetEntryKind, + ProjectStatus, + ReviewDecision, + ReviewSource, + RevisionKind, + RevisionOutcome, + SpendSource, +) +from krater.models.project import Project +from krater.models.project_revision import ProjectRevision +from krater.models.review import Review +from krater.models.spend_snapshot import SpendSnapshot +from krater.models.user import User + +__all__ = [ + "ApprovalPolicy", + "ApprovalStage", + "AuditEvent", + "BudgetEntry", + "BudgetEntryKind", + "Project", + "ProjectRevision", + "ProjectStatus", + "Review", + "ReviewDecision", + "ReviewSource", + "RevisionKind", + "RevisionOutcome", + "SpendSnapshot", + "SpendSource", + "User", +] diff --git a/krater/models/approval_policy.py b/krater/models/approval_policy.py new file mode 100644 index 0000000..fee2983 --- /dev/null +++ b/krater/models/approval_policy.py @@ -0,0 +1,24 @@ +"""ApprovalPolicy: configuration, not code. What a review stage requires to be considered passed.""" + +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.orm import Mapped, mapped_column + +from krater.db import Base +from krater.models.enums import ApprovalStage, pg_enum +from krater.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + + +class ApprovalPolicy(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "approval_policies" + + stage: Mapped[ApprovalStage] = mapped_column(pg_enum(ApprovalStage, name="approval_stage"), nullable=False) + # Nullable: a policy with no `min_budget_cents` floor applies regardless of requested budget size. + min_budget_cents: Mapped[int | None] = mapped_column(sa.Integer, nullable=True) + min_approvals: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=1, server_default="1") + # e.g. "ganymede:reviewer:senior"; nullable when any reviewer satisfies the policy. + required_group: Mapped[str | None] = mapped_column(sa.String(255), nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/krater/models/audit_event.py b/krater/models/audit_event.py new file mode 100644 index 0000000..b306d00 --- /dev/null +++ b/krater/models/audit_event.py @@ -0,0 +1,39 @@ +"""AuditEvent: an immutable record of an admin override -- who, what, when and why.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from krater.db import Base +from krater.models.mixins import CreatedAtMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from krater.models.project import Project + from krater.models.user import User + + +class AuditEvent(UUIDPrimaryKeyMixin, CreatedAtMixin, Base): + __tablename__ = "audit_events" + + actor_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True + ) + # Open-ended (e.g. "admin_approve", "budget_adjust", "launch_blocked") per SPEC.md -- not an enum, + # since new admin actions shouldn't need a migration to be logged. + action: Mapped[str] = mapped_column(sa.String(64), nullable=False, index=True) + project_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("projects.id"), nullable=True, index=True + ) + payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") + reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True) + + actor: Mapped[User] = relationship(back_populates="audit_events") + project: Mapped[Project | None] = relationship() + + def __repr__(self) -> str: + return f"" diff --git a/krater/models/budget_entry.py b/krater/models/budget_entry.py new file mode 100644 index 0000000..2f480f8 --- /dev/null +++ b/krater/models/budget_entry.py @@ -0,0 +1,43 @@ +"""BudgetEntry: an append-only ledger row. A project's budget ceiling is the sum of its entries.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from krater.db import Base +from krater.models.enums import BudgetEntryKind, pg_enum +from krater.models.mixins import CreatedAtMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from krater.models.project import Project + from krater.models.project_revision import ProjectRevision + from krater.models.user import User + + +class BudgetEntry(UUIDPrimaryKeyMixin, CreatedAtMixin, Base): + __tablename__ = "budget_entries" + + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("projects.id"), nullable=False, index=True + ) + kind: Mapped[BudgetEntryKind] = mapped_column(pg_enum(BudgetEntryKind, name="budget_entry_kind"), nullable=False) + amount_cents: Mapped[int] = mapped_column(sa.Integer, nullable=False) + actor_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True + ) + reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True) + revision_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("project_revisions.id"), nullable=True, index=True + ) + + project: Mapped[Project] = relationship(back_populates="budget_entries") + actor: Mapped[User] = relationship(back_populates="budget_entries") + revision: Mapped[ProjectRevision | None] = relationship() + + def __repr__(self) -> str: + return f"" diff --git a/krater/models/enums.py b/krater/models/enums.py new file mode 100644 index 0000000..96397b7 --- /dev/null +++ b/krater/models/enums.py @@ -0,0 +1,83 @@ +"""Enum types for Krater's data model, plus the `sa.Enum(...)` factory they're all stored with. + +Every enum here is a closed set per `docs/SPEC.md`. They're stored as `sa.Enum(..., native_enum=False, +create_constraint=True, validate_strings=True)`: a `VARCHAR` column with a `CHECK` constraint rather than a +native Postgres enum type, so adding a value later is a migration that adjusts the constraint, never a +Postgres `ALTER TYPE ... ADD VALUE` (which can't safely run inside a transaction on older Postgres). + +`AuditEvent.action` is deliberately NOT an enum here: SPEC.md gives it as an open, "e.g." list of action +names, so it's stored as a plain string. +""" + +from __future__ import annotations + +import enum + +import sqlalchemy as sa + + +def pg_enum(enum_cls: type[enum.Enum], *, name: str) -> sa.Enum: + """Build the `sa.Enum` column type Krater uses for every enum-backed column. + + `values_callable` makes the column (and its CHECK constraint) store the enum members' `.value` + (e.g. `"pending_review"`) rather than SQLAlchemy's default of their `.name` (`"PENDING_REVIEW"`). + """ + return sa.Enum( + enum_cls, + name=name, + native_enum=False, + create_constraint=True, + validate_strings=True, + length=64, + values_callable=lambda obj: [member.value for member in obj], + ) + + +class ProjectStatus(enum.StrEnum): + DRAFT = "draft" + PENDING_REVIEW = "pending_review" + CHANGES_REQUESTED = "changes_requested" + APPROVED = "approved" + PENDING_COMPLETION_REVIEW = "pending_completion_review" + COMPLETION_CHANGES_REQUESTED = "completion_changes_requested" + COMPLETED = "completed" + WITHDRAWN = "withdrawn" + + +class RevisionKind(enum.StrEnum): + PROPOSAL = "proposal" + AMENDMENT = "amendment" + COMPLETION = "completion" + + +class RevisionOutcome(enum.StrEnum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + SUPERSEDED = "superseded" + + +class ReviewDecision(enum.StrEnum): + APPROVE = "approve" + REJECT = "reject" + + +class ReviewSource(enum.StrEnum): + SLACK = "slack" + WEB = "web" + + +class BudgetEntryKind(enum.StrEnum): + INITIAL_APPROVAL = "initial_approval" + AMENDMENT = "amendment" + ADMIN_ADJUSTMENT = "admin_adjustment" + RECLAIM = "reclaim" + + +class SpendSource(enum.StrEnum): + SKYPILOT_COST_REPORT = "skypilot_cost_report" + + +class ApprovalStage(enum.StrEnum): + PROPOSAL = "proposal" + COMPLETION = "completion" diff --git a/krater/models/mixins.py b/krater/models/mixins.py new file mode 100644 index 0000000..5d837f4 --- /dev/null +++ b/krater/models/mixins.py @@ -0,0 +1,35 @@ +"""Small mixins shared by every model: UUID primary keys and server-defaulted timestamps.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + + +class UUIDPrimaryKeyMixin: + """A `uuid.uuid4`-generated primary key, safe to expose in URLs.""" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + +class CreatedAtMixin: + """A timezone-aware `created_at`, defaulted on the server so it's set even on bulk inserts.""" + + created_at: Mapped[datetime] = mapped_column( + sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now() + ) + + +class TimestampMixin(CreatedAtMixin): + """`created_at` plus a `updated_at` that the server refreshes on every update.""" + + updated_at: Mapped[datetime] = mapped_column( + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.func.now(), + onupdate=sa.func.now(), + ) diff --git a/krater/models/project.py b/krater/models/project.py new file mode 100644 index 0000000..6a54c69 --- /dev/null +++ b/krater/models/project.py @@ -0,0 +1,69 @@ +"""Project: a proposal, its lifecycle status, and pointers back at its revisions.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from krater.db import Base +from krater.models.enums import ProjectStatus, pg_enum +from krater.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from krater.models.budget_entry import BudgetEntry + from krater.models.project_revision import ProjectRevision + from krater.models.spend_snapshot import SpendSnapshot + from krater.models.user import User + + +class Project(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "projects" + + title: Mapped[str] = mapped_column(sa.String(255), nullable=False) + submitter_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True + ) + status: Mapped[ProjectStatus] = mapped_column( + pg_enum(ProjectStatus, name="project_status"), + nullable=False, + default=ProjectStatus.DRAFT, + server_default=ProjectStatus.DRAFT.value, + ) + # `current_revision_id`/`approved_revision_id` point at `project_revisions`, which itself has a + # non-nullable FK back to `projects`. That's a genuine circular dependency between the two tables, + # so these two are added via ALTER TABLE (`use_alter=True`) after both tables exist, and the + # matching relationships below use `post_update=True` so the ORM sets them with a separate UPDATE + # after both rows are inserted rather than during the INSERT itself. + current_revision_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + sa.ForeignKey("project_revisions.id", use_alter=True), + nullable=True, + ) + approved_revision_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + sa.ForeignKey("project_revisions.id", use_alter=True), + nullable=True, + ) + repo_url: Mapped[str | None] = mapped_column(sa.String(2048), nullable=True) + slack_channel_id: Mapped[str | None] = mapped_column(sa.String(64), nullable=True) + skypilot_workspace: Mapped[str | None] = mapped_column(sa.String(255), nullable=True) + + submitter: Mapped[User] = relationship(foreign_keys=[submitter_id], back_populates="projects") + current_revision: Mapped[ProjectRevision | None] = relationship( + foreign_keys=[current_revision_id], post_update=True + ) + approved_revision: Mapped[ProjectRevision | None] = relationship( + foreign_keys=[approved_revision_id], post_update=True + ) + revisions: Mapped[list[ProjectRevision]] = relationship( + foreign_keys="ProjectRevision.project_id", back_populates="project" + ) + budget_entries: Mapped[list[BudgetEntry]] = relationship(back_populates="project") + spend_snapshots: Mapped[list[SpendSnapshot]] = relationship(back_populates="project") + + def __repr__(self) -> str: + return f"" diff --git a/krater/models/project_revision.py b/krater/models/project_revision.py new file mode 100644 index 0000000..36a6d7a --- /dev/null +++ b/krater/models/project_revision.py @@ -0,0 +1,57 @@ +"""ProjectRevision: an immutable snapshot of a project as submitted for review.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ARRAY, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from krater.db import Base +from krater.models.enums import RevisionKind, RevisionOutcome, pg_enum +from krater.models.mixins import CreatedAtMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from krater.models.project import Project + from krater.models.review import Review + + +class ProjectRevision(UUIDPrimaryKeyMixin, CreatedAtMixin, Base): + __tablename__ = "project_revisions" + __table_args__ = (sa.UniqueConstraint("project_id", "number", name="uq_project_revisions_project_id_number"),) + + project_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("projects.id"), nullable=False, index=True + ) + number: Mapped[int] = mapped_column(sa.Integer, nullable=False) + kind: Mapped[RevisionKind] = mapped_column(pg_enum(RevisionKind, name="revision_kind"), nullable=False) + write_up: Mapped[str] = mapped_column(sa.Text, nullable=False) + budget_requested_cents: Mapped[int] = mapped_column(sa.Integer, nullable=False) + + # Completion-only fields; left empty/null for `proposal` and `amendment` revisions. + demo_url: Mapped[str | None] = mapped_column(sa.String(2048), nullable=True) + screenshot_keys: Mapped[list[str]] = mapped_column( + ARRAY(sa.String), nullable=False, default=list, server_default="{}" + ) + credited_builder_ids: Mapped[list[uuid.UUID]] = mapped_column( + ARRAY(UUID(as_uuid=True)), nullable=False, default=list, server_default="{}" + ) + tags: Mapped[list[str]] = mapped_column(ARRAY(sa.String), nullable=False, default=list, server_default="{}") + + # Null until the revision is actually submitted for review (vs. a draft edited in place). + submitted_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True), nullable=True) + outcome: Mapped[RevisionOutcome] = mapped_column( + pg_enum(RevisionOutcome, name="revision_outcome"), + nullable=False, + default=RevisionOutcome.PENDING, + server_default=RevisionOutcome.PENDING.value, + ) + + project: Mapped[Project] = relationship(foreign_keys=[project_id], back_populates="revisions") + reviews: Mapped[list[Review]] = relationship(back_populates="revision") + + def __repr__(self) -> str: + return f"" diff --git a/krater/models/review.py b/krater/models/review.py new file mode 100644 index 0000000..d319b63 --- /dev/null +++ b/krater/models/review.py @@ -0,0 +1,39 @@ +"""Review: a single reviewer's decision on a project revision.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from krater.db import Base +from krater.models.enums import ReviewDecision, ReviewSource, pg_enum +from krater.models.mixins import CreatedAtMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from krater.models.project_revision import ProjectRevision + from krater.models.user import User + + +class Review(UUIDPrimaryKeyMixin, CreatedAtMixin, Base): + __tablename__ = "reviews" + + revision_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("project_revisions.id"), nullable=False, index=True + ) + reviewer_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True + ) + decision: Mapped[ReviewDecision] = mapped_column(pg_enum(ReviewDecision, name="review_decision"), nullable=False) + # Required on reject; enforced by the review service, not a DB constraint. + reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True) + source: Mapped[ReviewSource] = mapped_column(pg_enum(ReviewSource, name="review_source"), nullable=False) + + revision: Mapped[ProjectRevision] = relationship(back_populates="reviews") + reviewer: Mapped[User] = relationship(back_populates="reviews") + + def __repr__(self) -> str: + return f"" diff --git a/krater/models/spend_snapshot.py b/krater/models/spend_snapshot.py new file mode 100644 index 0000000..06e7566 --- /dev/null +++ b/krater/models/spend_snapshot.py @@ -0,0 +1,33 @@ +"""SpendSnapshot: an estimated-spend reading written by the SkyPilot spend reconciler.""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from krater.db import Base +from krater.models.enums import SpendSource, pg_enum +from krater.models.mixins import UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from krater.models.project import Project + + +class SpendSnapshot(UUIDPrimaryKeyMixin, Base): + __tablename__ = "spend_snapshots" + __table_args__ = (sa.Index("ix_spend_snapshots_project_id_taken_at", "project_id", "taken_at"),) + + project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), sa.ForeignKey("projects.id"), nullable=False) + estimated_spend_cents: Mapped[int] = mapped_column(sa.Integer, nullable=False) + source: Mapped[SpendSource] = mapped_column(pg_enum(SpendSource, name="spend_source"), nullable=False) + taken_at: Mapped[datetime] = mapped_column(sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()) + + project: Mapped[Project] = relationship(back_populates="spend_snapshots") + + def __repr__(self) -> str: + return f"" diff --git a/krater/models/user.py b/krater/models/user.py new file mode 100644 index 0000000..1aeec1d --- /dev/null +++ b/krater/models/user.py @@ -0,0 +1,41 @@ +"""User: a cache of the Weave identity. Holds no role/authorization data (see CLAUDE.md).""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from krater.db import Base +from krater.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin + +if TYPE_CHECKING: + from krater.models.audit_event import AuditEvent + from krater.models.budget_entry import BudgetEntry + from krater.models.project import Project + from krater.models.review import Review + + +class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): + __tablename__ = "users" + + weave_sub: Mapped[str] = mapped_column(sa.String(64), unique=True, nullable=False) + display_name: Mapped[str] = mapped_column(sa.String(255), nullable=False) + email: Mapped[str] = mapped_column(sa.String(255), nullable=False) + slack_user_id: Mapped[str | None] = mapped_column(sa.String(64), nullable=True) + # Last-seen `groups` claim from Weave. Display only -- authorization always re-checks via WeaveClient. + groups_cached: Mapped[list[str]] = mapped_column( + ARRAY(sa.String), nullable=False, default=list, server_default="{}" + ) + last_login_at: Mapped[datetime | None] = mapped_column(sa.DateTime(timezone=True), nullable=True) + + projects: Mapped[list[Project]] = relationship(foreign_keys="Project.submitter_id", back_populates="submitter") + reviews: Mapped[list[Review]] = relationship(back_populates="reviewer") + budget_entries: Mapped[list[BudgetEntry]] = relationship(back_populates="actor") + audit_events: Mapped[list[AuditEvent]] = relationship(back_populates="actor") + + def __repr__(self) -> str: + return f"" From 64e16b45b16045acd18ba543563bb18721403d70 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:14:35 +0000 Subject: [PATCH 008/124] Add Alembic, configured from Settings, plus the initial migrations env.py takes its database URL from krater.config.get_settings() (KRATER_DATABASE_URL), never from alembic.ini, and wires target_metadata to krater.db.Base.metadata after importing krater.models so every table is registered. Two hand-reviewed migrations: - 59b55982f8a6: all eight Krater tables. Started from `alembic revision --autogenerate` and then fixed by hand: autogenerate had rendered a redundant explicit CheckConstraint for every enum column on top of the one each sa.Enum(create_constraint=True) already attaches, which made `upgrade()` fail with DuplicateObject; and it had rendered the circular projects <-> project_revisions foreign keys inline inside create_table('projects', ...), which fails because project_revisions doesn't exist yet at that point. The circular FKs are now added via op.create_foreign_key(...) once both tables exist, and dropped again before either table in downgrade(). - 67a1656dcb6d: procrastinate's own schema, applied via procrastinate.schema.SchemaManager.get_schema() (the documented approach for projects that manage migrations with a tool other than procrastinate's own CLI) so `alembic upgrade head` is the only migration path anyone needs to run. downgrade() drops procrastinate's tables/functions/types explicitly, since it doesn't ship a "drop everything" helper. Verified: `alembic upgrade head`, `downgrade base` and `upgrade head` again all succeed against krater_dev, and a post-upgrade `alembic check` reports no further autogenerate diff. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- alembic.ini | 148 +++++++++ alembic/README | 1 + alembic/env.py | 74 +++++ alembic/script.py.mako | 28 ++ .../versions/59b55982f8a6_initial_schema.py | 287 ++++++++++++++++++ .../67a1656dcb6d_procrastinate_schema.py | 77 +++++ 6 files changed, 615 insertions(+) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/59b55982f8a6_initial_schema.py create mode 100644 alembic/versions/67a1656dcb6d_procrastinate_schema.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..78b4d8f --- /dev/null +++ b/alembic.ini @@ -0,0 +1,148 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s +# Or organize into date-based subdirectories (requires recursive_version_locations = true) +# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. Deliberately left unset -- env.py sets it from krater.config.get_settings() +# (KRATER_DATABASE_URL), so there's no URL to keep in sync with the app's own settings. +sqlalchemy.url = + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..7471e1b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,74 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config, pool + +# Import every model module so they're registered on `Base.metadata` before autogenerate runs. +import krater.models # noqa: F401 +from alembic import context +from krater.config import get_settings +from krater.db import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Always take the database URL from Krater's own settings (KRATER_DATABASE_URL), never from a +# hard-coded value in alembic.ini, so migrations run against whatever the app is configured for. +config.set_main_option("sqlalchemy.url", get_settings().database_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/59b55982f8a6_initial_schema.py b/alembic/versions/59b55982f8a6_initial_schema.py new file mode 100644 index 0000000..dae8d56 --- /dev/null +++ b/alembic/versions/59b55982f8a6_initial_schema.py @@ -0,0 +1,287 @@ +"""initial schema + +Revision ID: 59b55982f8a6 +Revises: +Create Date: 2026-09-26 19:04:14.972174 + +Hand-reviewed after `alembic revision --autogenerate`. Two things needed fixing beyond what +autogenerate produced: + +* Each `sa.Enum(..., create_constraint=True)` column already attaches its own CHECK constraint when + the table is built (that's what `create_constraint=True` does); autogenerate additionally rendered + an explicit `sa.CheckConstraint(...)` for the same check, twice, which duplicated the constraint and + made `upgrade()` fail outright (`DuplicateObject`). The redundant explicit constraints are removed + here; the column type still creates the one real constraint. +* `projects.current_revision_id` / `approved_revision_id` and `project_revisions.project_id` form a + genuine circular FK dependency between the two tables. Autogenerate rendered both `use_alter=True` + foreign keys inline inside `create_table('projects', ...)`, which fails because `project_revisions` + doesn't exist yet at that point in the script. They're added below via `op.create_foreign_key(...)` + once both tables exist, and dropped again (via `op.drop_constraint`) before either table is dropped + in `downgrade()`. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "59b55982f8a6" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_table( + "approval_policies", + sa.Column( + "stage", + sa.Enum( + "proposal", "completion", name="approval_stage", native_enum=False, create_constraint=True, length=64 + ), + nullable=False, + ), + sa.Column("min_budget_cents", sa.Integer(), nullable=True), + sa.Column("min_approvals", sa.Integer(), server_default="1", nullable=False), + sa.Column("required_group", sa.String(length=255), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_approval_policies")), + ) + op.create_table( + "users", + sa.Column("weave_sub", sa.String(length=64), nullable=False), + sa.Column("display_name", sa.String(length=255), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("slack_user_id", sa.String(length=64), nullable=True), + sa.Column("groups_cached", postgresql.ARRAY(sa.String()), server_default="{}", nullable=False), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), + sa.UniqueConstraint("weave_sub", name=op.f("uq_users_weave_sub")), + ) + op.create_table( + "projects", + sa.Column("title", sa.String(length=255), nullable=False), + sa.Column("submitter_id", sa.UUID(), nullable=False), + sa.Column( + "status", + sa.Enum( + "draft", + "pending_review", + "changes_requested", + "approved", + "pending_completion_review", + "completion_changes_requested", + "completed", + "withdrawn", + name="project_status", + native_enum=False, + create_constraint=True, + length=64, + ), + server_default="draft", + nullable=False, + ), + # FKs to project_revisions.id are added below, once that table exists (circular dependency). + sa.Column("current_revision_id", sa.UUID(), nullable=True), + sa.Column("approved_revision_id", sa.UUID(), nullable=True), + sa.Column("repo_url", sa.String(length=2048), nullable=True), + sa.Column("slack_channel_id", sa.String(length=64), nullable=True), + sa.Column("skypilot_workspace", sa.String(length=255), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["submitter_id"], ["users.id"], name=op.f("fk_projects_submitter_id_users")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_projects")), + ) + op.create_index(op.f("ix_projects_submitter_id"), "projects", ["submitter_id"], unique=False) + op.create_table( + "audit_events", + sa.Column("actor_id", sa.UUID(), nullable=False), + sa.Column("action", sa.String(length=64), nullable=False), + sa.Column("project_id", sa.UUID(), nullable=True), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), server_default="{}", nullable=False), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["actor_id"], ["users.id"], name=op.f("fk_audit_events_actor_id_users")), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], name=op.f("fk_audit_events_project_id_projects")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_events")), + ) + op.create_index(op.f("ix_audit_events_action"), "audit_events", ["action"], unique=False) + op.create_index(op.f("ix_audit_events_actor_id"), "audit_events", ["actor_id"], unique=False) + op.create_index(op.f("ix_audit_events_project_id"), "audit_events", ["project_id"], unique=False) + op.create_table( + "project_revisions", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("number", sa.Integer(), nullable=False), + sa.Column( + "kind", + sa.Enum( + "proposal", + "amendment", + "completion", + name="revision_kind", + native_enum=False, + create_constraint=True, + length=64, + ), + nullable=False, + ), + sa.Column("write_up", sa.Text(), nullable=False), + sa.Column("budget_requested_cents", sa.Integer(), nullable=False), + sa.Column("demo_url", sa.String(length=2048), nullable=True), + sa.Column("screenshot_keys", postgresql.ARRAY(sa.String()), server_default="{}", nullable=False), + sa.Column("credited_builder_ids", postgresql.ARRAY(sa.UUID()), server_default="{}", nullable=False), + sa.Column("tags", postgresql.ARRAY(sa.String()), server_default="{}", nullable=False), + sa.Column("submitted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column( + "outcome", + sa.Enum( + "pending", + "approved", + "rejected", + "superseded", + name="revision_outcome", + native_enum=False, + create_constraint=True, + length=64, + ), + server_default="pending", + nullable=False, + ), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], name=op.f("fk_project_revisions_project_id_projects")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_project_revisions")), + sa.UniqueConstraint("project_id", "number", name="uq_project_revisions_project_id_number"), + ) + op.create_index(op.f("ix_project_revisions_project_id"), "project_revisions", ["project_id"], unique=False) + + # Both tables now exist: add the circular FKs from `projects` to `project_revisions` via ALTER TABLE. + op.create_foreign_key( + op.f("fk_projects_current_revision_id_project_revisions"), + "projects", + "project_revisions", + ["current_revision_id"], + ["id"], + ) + op.create_foreign_key( + op.f("fk_projects_approved_revision_id_project_revisions"), + "projects", + "project_revisions", + ["approved_revision_id"], + ["id"], + ) + + op.create_table( + "spend_snapshots", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column("estimated_spend_cents", sa.Integer(), nullable=False), + sa.Column( + "source", + sa.Enum("skypilot_cost_report", name="spend_source", native_enum=False, create_constraint=True, length=64), + nullable=False, + ), + sa.Column("taken_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("id", sa.UUID(), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], name=op.f("fk_spend_snapshots_project_id_projects")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_spend_snapshots")), + ) + op.create_index( + "ix_spend_snapshots_project_id_taken_at", "spend_snapshots", ["project_id", "taken_at"], unique=False + ) + op.create_table( + "budget_entries", + sa.Column("project_id", sa.UUID(), nullable=False), + sa.Column( + "kind", + sa.Enum( + "initial_approval", + "amendment", + "admin_adjustment", + "reclaim", + name="budget_entry_kind", + native_enum=False, + create_constraint=True, + length=64, + ), + nullable=False, + ), + sa.Column("amount_cents", sa.Integer(), nullable=False), + sa.Column("actor_id", sa.UUID(), nullable=False), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("revision_id", sa.UUID(), nullable=True), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["actor_id"], ["users.id"], name=op.f("fk_budget_entries_actor_id_users")), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], name=op.f("fk_budget_entries_project_id_projects")), + sa.ForeignKeyConstraint( + ["revision_id"], ["project_revisions.id"], name=op.f("fk_budget_entries_revision_id_project_revisions") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_budget_entries")), + ) + op.create_index(op.f("ix_budget_entries_actor_id"), "budget_entries", ["actor_id"], unique=False) + op.create_index(op.f("ix_budget_entries_project_id"), "budget_entries", ["project_id"], unique=False) + op.create_index(op.f("ix_budget_entries_revision_id"), "budget_entries", ["revision_id"], unique=False) + op.create_table( + "reviews", + sa.Column("revision_id", sa.UUID(), nullable=False), + sa.Column("reviewer_id", sa.UUID(), nullable=False), + sa.Column( + "decision", + sa.Enum("approve", "reject", name="review_decision", native_enum=False, create_constraint=True, length=64), + nullable=False, + ), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column( + "source", + sa.Enum("slack", "web", name="review_source", native_enum=False, create_constraint=True, length=64), + nullable=False, + ), + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["reviewer_id"], ["users.id"], name=op.f("fk_reviews_reviewer_id_users")), + sa.ForeignKeyConstraint( + ["revision_id"], ["project_revisions.id"], name=op.f("fk_reviews_revision_id_project_revisions") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_reviews")), + ) + op.create_index(op.f("ix_reviews_reviewer_id"), "reviews", ["reviewer_id"], unique=False) + op.create_index(op.f("ix_reviews_revision_id"), "reviews", ["revision_id"], unique=False) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_index(op.f("ix_reviews_revision_id"), table_name="reviews") + op.drop_index(op.f("ix_reviews_reviewer_id"), table_name="reviews") + op.drop_table("reviews") + op.drop_index(op.f("ix_budget_entries_revision_id"), table_name="budget_entries") + op.drop_index(op.f("ix_budget_entries_project_id"), table_name="budget_entries") + op.drop_index(op.f("ix_budget_entries_actor_id"), table_name="budget_entries") + op.drop_table("budget_entries") + op.drop_index("ix_spend_snapshots_project_id_taken_at", table_name="spend_snapshots") + op.drop_table("spend_snapshots") + + # Drop the circular FKs before either table goes away. + op.drop_constraint(op.f("fk_projects_approved_revision_id_project_revisions"), "projects", type_="foreignkey") + op.drop_constraint(op.f("fk_projects_current_revision_id_project_revisions"), "projects", type_="foreignkey") + + op.drop_index(op.f("ix_project_revisions_project_id"), table_name="project_revisions") + op.drop_table("project_revisions") + op.drop_index(op.f("ix_audit_events_project_id"), table_name="audit_events") + op.drop_index(op.f("ix_audit_events_actor_id"), table_name="audit_events") + op.drop_index(op.f("ix_audit_events_action"), table_name="audit_events") + op.drop_table("audit_events") + op.drop_index(op.f("ix_projects_submitter_id"), table_name="projects") + op.drop_table("projects") + op.drop_table("users") + op.drop_table("approval_policies") diff --git a/alembic/versions/67a1656dcb6d_procrastinate_schema.py b/alembic/versions/67a1656dcb6d_procrastinate_schema.py new file mode 100644 index 0000000..e415c21 --- /dev/null +++ b/alembic/versions/67a1656dcb6d_procrastinate_schema.py @@ -0,0 +1,77 @@ +"""procrastinate schema + +Revision ID: 67a1656dcb6d +Revises: 59b55982f8a6 +Create Date: 2026-09-26 19:06:29.266229 + +procrastinate ships its schema as raw SQL (`procrastinate.sql.schema.sql`) and normally applies it +itself (`procrastinate schema --apply`, or a job store's `apply_schema()`). We use Alembic as the only +migration path instead, so this migration pulls that same SQL from the installed `procrastinate` +package via `procrastinate.schema.SchemaManager.get_schema()` -- the approach procrastinate's own docs +point to for projects that manage migrations with a different tool -- and executes it here. Bump this +migration (or add a new one) if `procrastinate` is upgraded to a version with schema changes. + +`downgrade()` can't reuse a procrastinate helper the same way (it doesn't ship a "drop everything" +function), so it drops procrastinate's tables, functions and types explicitly. Functions are dropped +by matching `procrastinate_%` in `pg_proc` rather than by listing exact signatures, since those change +between procrastinate versions. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from procrastinate.schema import SchemaManager + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "67a1656dcb6d" +down_revision: str | Sequence[str] | None = "59b55982f8a6" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_DROP_FUNCTIONS_SQL = """ +DO $$ +DECLARE + proc RECORD; +BEGIN + FOR proc IN + SELECT p.oid::regprocedure AS signature + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname LIKE 'procrastinate\\_%' ESCAPE '\\' + LOOP + EXECUTE 'DROP FUNCTION IF EXISTS ' || proc.signature || ' CASCADE'; + END LOOP; +END; +$$; +""" + +_TABLES = ( + "procrastinate_events", + "procrastinate_periodic_defers", + "procrastinate_jobs", + "procrastinate_workers", +) + +_TYPES = ( + "procrastinate_job_status", + "procrastinate_job_event_type", + "procrastinate_job_to_defer_v1", +) + + +def upgrade() -> None: + """Upgrade schema.""" + # `op.execute` wraps a plain string in `sa.text(...)` itself; checked that this SQL has no bare + # `:name`-style substrings that `text()` would otherwise mistake for bind parameters. + op.execute(SchemaManager.get_schema()) + + +def downgrade() -> None: + """Downgrade schema.""" + for table in _TABLES: + op.execute(sa.text(f"DROP TABLE IF EXISTS {table} CASCADE")) + op.execute(sa.text(_DROP_FUNCTIONS_SQL)) + for type_name in _TYPES: + op.execute(sa.text(f"DROP TYPE IF EXISTS {type_name} CASCADE")) From a5443a9323e1d9a2e23a13cd71c689a9d6e19a3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:14:40 +0000 Subject: [PATCH 009/124] Add the procrastinate worker app and heartbeat task krater/worker/app.py builds a procrastinate App on the same Postgres database (PsycopgConnector, converted from Krater's SQLAlchemy-style KRATER_DATABASE_URL to a plain libpq conninfo string) and registers one periodic task, heartbeat, that logs once a minute. Run with: `uv run procrastinate --app=krater.worker.app.app worker`. Verified: ran the worker for a few seconds against krater_dev -- it connects, registers itself, imports the task module, and the periodic scheduler picks up heartbeat -- then stopped it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/worker/__init__.py | 0 krater/worker/app.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 krater/worker/__init__.py create mode 100644 krater/worker/app.py diff --git a/krater/worker/__init__.py b/krater/worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/krater/worker/app.py b/krater/worker/app.py new file mode 100644 index 0000000..3fe2c68 --- /dev/null +++ b/krater/worker/app.py @@ -0,0 +1,31 @@ +"""The procrastinate App: background jobs and periodic tasks, backed by the same Postgres database. + +Run with: `uv run procrastinate --app=krater.worker.app.app worker` +""" + +from __future__ import annotations + +import logging + +import procrastinate + +from krater.config import get_settings + +logger = logging.getLogger(__name__) + + +def _psycopg_conninfo(database_url: str) -> str: + """Turn a SQLAlchemy-style URL (`postgresql+psycopg://...`) into a plain libpq conninfo string.""" + return database_url.replace("postgresql+psycopg://", "postgresql://", 1) + + +app = procrastinate.App( + connector=procrastinate.PsycopgConnector(conninfo=_psycopg_conninfo(get_settings().database_url)), +) + + +@app.periodic(cron="* * * * *") +@app.task(name="heartbeat") +def heartbeat(timestamp: int) -> None: + """Log once a minute, so it's easy to see the worker and its scheduler are alive.""" + logger.info("krater worker heartbeat", extra={"timestamp": timestamp}) From 14d7fc52709f0e2d4dcc081d6054f1db83008991 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:14:50 +0000 Subject: [PATCH 010/124] Add the FastAPI web app: create_app(), base layout, home and healthz krater/web/app.py's create_app() factory wires SessionMiddleware (keyed on settings.secret_key), mounts /static, and includes the pages router: GET / renders a placeholder home page and GET /healthz runs SELECT 1 through the get_session dependency before reporting {"status": "ok"}. Jinja2 templates live in krater/web/templates/ with a base.html layout (header with "Krater" and a placeholder nav, centered content column) styled by hand-written CSS in krater/web/static/ using the Patchwork palette from CLAUDE.md, no framework or CDN, and no horizontal scroll at phone width. Verified live: ran uvicorn in the background against krater_dev, curled / and /healthz, then stopped it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/web/__init__.py | 0 krater/web/app.py | 31 ++++++++ krater/web/routers/__init__.py | 0 krater/web/routers/pages.py | 26 +++++++ krater/web/static/css/main.css | 127 +++++++++++++++++++++++++++++++++ krater/web/templates.py | 11 +++ krater/web/templates/base.html | 23 ++++++ krater/web/templates/home.html | 14 ++++ 8 files changed, 232 insertions(+) create mode 100644 krater/web/__init__.py create mode 100644 krater/web/app.py create mode 100644 krater/web/routers/__init__.py create mode 100644 krater/web/routers/pages.py create mode 100644 krater/web/static/css/main.css create mode 100644 krater/web/templates.py create mode 100644 krater/web/templates/base.html create mode 100644 krater/web/templates/home.html diff --git a/krater/web/__init__.py b/krater/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/krater/web/app.py b/krater/web/app.py new file mode 100644 index 0000000..349140f --- /dev/null +++ b/krater/web/app.py @@ -0,0 +1,31 @@ +"""The FastAPI application factory: `create_app()`. + +Run with: `uv run uvicorn krater.web.app:create_app --factory --reload` +""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware +from starlette.staticfiles import StaticFiles + +from krater.config import get_settings +from krater.web.routers import pages + +STATIC_DIR = Path(__file__).parent / "static" + + +def create_app() -> FastAPI: + settings = get_settings() + + app = FastAPI(title="Krater") + + app.add_middleware(SessionMiddleware, secret_key=settings.secret_key) + + app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + app.include_router(pages.router) + + return app diff --git a/krater/web/routers/__init__.py b/krater/web/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/krater/web/routers/pages.py b/krater/web/routers/pages.py new file mode 100644 index 0000000..9826c4f --- /dev/null +++ b/krater/web/routers/pages.py @@ -0,0 +1,26 @@ +"""Publicly reachable pages: the placeholder home page and the health check.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse +from sqlalchemy import text +from sqlalchemy.orm import Session + +from krater.db import get_session +from krater.web.templates import templates + +router = APIRouter() + + +@router.get("/", response_class=HTMLResponse) +def home(request: Request) -> HTMLResponse: + return templates.TemplateResponse(request, "home.html") + + +@router.get("/healthz") +def healthz(session: Annotated[Session, Depends(get_session)]) -> dict[str, str]: + session.execute(text("SELECT 1")) + return {"status": "ok"} diff --git a/krater/web/static/css/main.css b/krater/web/static/css/main.css new file mode 100644 index 0000000..ea4ae51 --- /dev/null +++ b/krater/web/static/css/main.css @@ -0,0 +1,127 @@ +/* Krater base styles. Hand-written, no framework. Brand palette from CLAUDE.md. */ + +:root { + --color-ink: #2a2050; + --color-grape: #6c3ec1; + --color-deep-purple: #4b006e; + --color-lavender: #c7b7e2; + --color-quilt: #f7f5fb; + --color-white: #ffffff; + + --color-bg: var(--color-quilt); + --color-surface: var(--color-white); + --color-text: var(--color-ink); + --color-text-muted: #5b5570; + --color-accent: var(--color-grape); + --color-accent-strong: var(--color-deep-purple); + --color-border: var(--color-lavender); + + --content-max-width: 40rem; + --gutter: 1rem; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background-color: var(--color-bg); + color: var(--color-text); + font-family: + "Segoe UI", + system-ui, + -apple-system, + Roboto, + Helvetica, + Arial, + sans-serif; + line-height: 1.5; +} + +a { + color: var(--color-accent-strong); +} + +a:hover { + color: var(--color-accent); +} + +.site-header { + background-color: var(--color-accent-strong); + color: var(--color-white); +} + +.site-header__inner { + max-width: 60rem; + margin: 0 auto; + padding: 0.75rem var(--gutter); + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.site-header__brand { + color: var(--color-white); + font-size: 1.25rem; + font-weight: 700; + text-decoration: none; + letter-spacing: 0.02em; +} + +.site-nav { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.site-nav a { + color: var(--color-lavender); + text-decoration: none; + font-size: 0.95rem; +} + +.site-nav a:hover { + color: var(--color-white); +} + +.content { + max-width: var(--content-max-width); + margin: 0 auto; + padding: 2rem var(--gutter); +} + +.card { + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 0.75rem; + padding: 1.5rem; +} + +.card h1 { + margin-top: 0; + color: var(--color-accent-strong); +} + +.muted { + color: var(--color-text-muted); + font-size: 0.9rem; +} + +@media (max-width: 30rem) { + .content { + padding: 1.25rem var(--gutter); + } + + .card { + padding: 1rem; + border-radius: 0.5rem; + } +} diff --git a/krater/web/templates.py b/krater/web/templates.py new file mode 100644 index 0000000..33d3e52 --- /dev/null +++ b/krater/web/templates.py @@ -0,0 +1,11 @@ +"""The single Jinja2Templates instance the web app renders from.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.templating import Jinja2Templates + +TEMPLATES_DIR = Path(__file__).parent / "templates" + +templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) diff --git a/krater/web/templates/base.html b/krater/web/templates/base.html new file mode 100644 index 0000000..178afc3 --- /dev/null +++ b/krater/web/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {% block title %}Krater{% endblock %} + + + + +
{% block content %}{% endblock %}
+ + diff --git a/krater/web/templates/home.html b/krater/web/templates/home.html new file mode 100644 index 0000000..39bea52 --- /dev/null +++ b/krater/web/templates/home.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Krater{% endblock %} + +{% block content %} +
+

Krater

+

+ The Project Ganymede portal: proposal review, compute budget allocation, and a public gallery of + completed projects. +

+

This page is a placeholder while the rest of the portal is built.

+
+{% endblock %} From ef66041056a9952946c32857c4de51bc80066d73 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:15:00 +0000 Subject: [PATCH 011/124] Add the test suite: transactional fixtures, healthz/home and a model smoke test tests/conftest.py forces KRATER_DATABASE_URL to KRATER_TEST_DATABASE_URL (default postgresql+psycopg://root:root@localhost:5432/krater_test) before anything under krater is imported, runs `alembic upgrade head` once per session via the engine fixture, and gives each test a db_session on a SAVEPOINT (begin_nested, restarted on every "after_transaction_end") so code under test can commit freely while the outer transaction still rolls back the test's writes at teardown. The client fixture is a FastAPI TestClient with get_session overridden to that db_session. tests/web/test_pages.py covers GET /healthz and GET /. tests/models/test_smoke.py creates a user, a project, two revisions (one superseded, one approved) and a budget entry, then reads them all back -- including relationships -- through a fresh query. Verified: `uv run pytest` passes (3 passed), and running it twice in a row leaves krater_test's `users` table empty both times, confirming the rollback. Empty tests/services and tests/worker packages scaffold future waves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- tests/__init__.py | 0 tests/conftest.py | 83 +++++++++++++++++++++++++++++++++++++ tests/models/__init__.py | 0 tests/models/test_smoke.py | 85 ++++++++++++++++++++++++++++++++++++++ tests/services/__init__.py | 0 tests/web/__init__.py | 0 tests/web/test_pages.py | 18 ++++++++ tests/worker/__init__.py | 0 8 files changed, 186 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/models/__init__.py create mode 100644 tests/models/test_smoke.py create mode 100644 tests/services/__init__.py create mode 100644 tests/web/__init__.py create mode 100644 tests/web/test_pages.py create mode 100644 tests/worker/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..494d114 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,83 @@ +"""Shared test fixtures. + +Sets `KRATER_DATABASE_URL` to the test database *before* anything under `krater` is imported, so every +module-level `get_settings()`/`get_engine()` singleton (app code, Alembic's `env.py`) targets it too. +""" + +from __future__ import annotations + +import os + +os.environ["KRATER_ENV"] = "test" +os.environ["KRATER_DATABASE_URL"] = os.environ.get( + "KRATER_TEST_DATABASE_URL", "postgresql+psycopg://root:root@localhost:5432/krater_test" +) + +from collections.abc import Generator # noqa: E402 +from pathlib import Path # noqa: E402 + +import pytest # noqa: E402 +from alembic.config import Config # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 +from sqlalchemy import event # noqa: E402 +from sqlalchemy.engine import Engine # noqa: E402 +from sqlalchemy.orm import Session # noqa: E402 + +from alembic import command # noqa: E402 +from krater.db import get_engine, get_session # noqa: E402 +from krater.web.app import create_app # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +@pytest.fixture(scope="session") +def engine() -> Generator[Engine, None, None]: + """The test database's engine, migrated to `head` once for the whole test session.""" + alembic_cfg = Config(str(REPO_ROOT / "alembic.ini")) + command.upgrade(alembic_cfg, "head") + + eng = get_engine() + yield eng + eng.dispose() + + +@pytest.fixture +def db_session(engine: Engine) -> Generator[Session, None, None]: + """A `Session` bound to a connection-level transaction, rolled back after the test. + + Runs on a SAVEPOINT (`begin_nested`) so code under test can call `session.commit()` (or start its + own nested transactions) without ending the outer transaction: a listener re-opens the SAVEPOINT + every time one ends, and only the outer transaction rollback at teardown actually discards the + test's writes. + """ + connection = engine.connect() + outer_transaction = connection.begin() + session = Session(bind=connection) + + nested = connection.begin_nested() + + @event.listens_for(session, "after_transaction_end") + def _restart_savepoint(sess: Session, transaction: object) -> None: + nonlocal nested + if not nested.is_active: + nested = connection.begin_nested() + + try: + yield session + finally: + event.remove(session, "after_transaction_end", _restart_savepoint) + session.close() + outer_transaction.rollback() + connection.close() + + +@pytest.fixture +def client(db_session: Session) -> Generator[TestClient, None, None]: + """A FastAPI `TestClient` with `get_session` overridden to the per-test transactional session.""" + app = create_app() + app.dependency_overrides[get_session] = lambda: db_session + + with TestClient(app) as test_client: + yield test_client + + app.dependency_overrides.clear() diff --git a/tests/models/__init__.py b/tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/models/test_smoke.py b/tests/models/test_smoke.py new file mode 100644 index 0000000..a69886d --- /dev/null +++ b/tests/models/test_smoke.py @@ -0,0 +1,85 @@ +"""A smoke test for the ORM layer: create a user, a project, two revisions and a budget entry, then +read them all back through a fresh query, including relationships.""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from krater.models import ( + BudgetEntry, + BudgetEntryKind, + Project, + ProjectRevision, + ProjectStatus, + RevisionKind, + RevisionOutcome, + User, +) + + +def test_create_project_with_revisions_and_budget_entry(db_session: Session) -> None: + user = User(weave_sub="PWL0000TEST", display_name="Ada Lovelace", email="ada@example.com") + db_session.add(user) + db_session.flush() + + project = Project(title="Analytical Engine", submitter_id=user.id, status=ProjectStatus.DRAFT) + db_session.add(project) + db_session.flush() + + revision_1 = ProjectRevision( + project_id=project.id, + number=1, + kind=RevisionKind.PROPOSAL, + write_up="An engine for general computation.", + budget_requested_cents=100_000, + outcome=RevisionOutcome.SUPERSEDED, + ) + revision_2 = ProjectRevision( + project_id=project.id, + number=2, + kind=RevisionKind.PROPOSAL, + write_up="An engine for general computation, revised.", + budget_requested_cents=150_000, + outcome=RevisionOutcome.APPROVED, + ) + db_session.add_all([revision_1, revision_2]) + db_session.flush() + + # Only revision 2's approval counts: revision 1 was superseded by the resubmission. + project.current_revision_id = revision_2.id + project.approved_revision_id = revision_2.id + project.status = ProjectStatus.APPROVED + + budget_entry = BudgetEntry( + project_id=project.id, + kind=BudgetEntryKind.INITIAL_APPROVAL, + amount_cents=150_000, + actor_id=user.id, + revision_id=revision_2.id, + ) + db_session.add(budget_entry) + db_session.commit() + db_session.expire_all() + + fetched_project = db_session.get(Project, project.id) + assert fetched_project is not None + assert fetched_project.title == "Analytical Engine" + assert fetched_project.status is ProjectStatus.APPROVED + assert fetched_project.current_revision_id == revision_2.id + assert fetched_project.approved_revision_id == revision_2.id + assert fetched_project.submitter.email == "ada@example.com" + + revisions = ( + db_session.query(ProjectRevision).filter_by(project_id=project.id).order_by(ProjectRevision.number).all() + ) + assert [r.number for r in revisions] == [1, 2] + assert revisions[0].outcome is RevisionOutcome.SUPERSEDED + assert revisions[1].outcome is RevisionOutcome.APPROVED + assert revisions[1].budget_requested_cents == 150_000 + + fetched_entry = db_session.get(BudgetEntry, budget_entry.id) + assert fetched_entry is not None + assert fetched_entry.amount_cents == 150_000 + assert fetched_entry.kind is BudgetEntryKind.INITIAL_APPROVAL + assert fetched_entry.actor.weave_sub == "PWL0000TEST" + assert fetched_entry.revision_id == revision_2.id diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/__init__.py b/tests/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/web/test_pages.py b/tests/web/test_pages.py new file mode 100644 index 0000000..b9ea538 --- /dev/null +++ b/tests/web/test_pages.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_healthz_runs_a_query_and_reports_ok(client: TestClient) -> None: + response = client.get("/healthz") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_home_page_renders(client: TestClient) -> None: + response = client.get("/") + + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "Krater" in response.text diff --git a/tests/worker/__init__.py b/tests/worker/__init__.py new file mode 100644 index 0000000..e69de29 From c64d1c79f9814603944e27e7f9ed09d7e37d79ef Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:15:12 +0000 Subject: [PATCH 012/124] Add Dockerfile, docker-compose, CI workflow and a Development README section Dockerfile: python:3.12-slim, uv copied from Astral's official image (no architecture pinned, so it builds for amd64 and arm64 under buildx), a non-root krater user, and a venv-first layer for build caching. docker-compose.yml: db (postgres:16, healthcheck, named volume), migrate (one-shot `alembic upgrade head`, depends on db's healthcheck), and portal/worker (depend on migrate completing successfully), all reading KRATER_/POSTGRES_ env vars from .env. .env.example documents every variable, including the docker-compose-only POSTGRES_*/PORT ones. .github/workflows/ci.yml: on push/PR, uv sync, ruff check, ruff format --check, and pytest against a postgres:16 service container. README.md gets a Development section (uv setup, db creation, running the app/ worker, lint/format/test, docker compose), keeping the existing doc links. Note: docs/weave-integration.md has a small pre-existing, uncommitted whitespace-only diff (comment spacing in a code sample) that predates this work and is unrelated to it; left as-is rather than folded into this change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- .env.example | 23 +++++++++++++++++ .github/workflows/ci.yml | 54 ++++++++++++++++++++++++++++++++++++++++ Dockerfile | 37 +++++++++++++++++++++++++++ README.md | 32 ++++++++++++++++++++++++ docker-compose.yml | 42 +++++++++++++++++++++++++++++++ 5 files changed, 188 insertions(+) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fc2c34a --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Copy to `.env` and adjust. See krater/config.py for the full list of settings. + +KRATER_ENV=development +# In docker-compose, the db host is the `db` service; for `bin/dev` locally, use localhost. +KRATER_DATABASE_URL=postgresql+psycopg://krater:krater@db:5432/krater +KRATER_SECRET_KEY=change-me-in-production +KRATER_BASE_URL=http://localhost:8000 + +# Weave (Patchwork Labs identity provider) integration. See docs/weave-integration.md. +# `stub` replaces Weave with a local fixture of fake users; required outside of production. +KRATER_WEAVE_MODE=stub +KRATER_WEAVE_ISSUER= +KRATER_WEAVE_CLIENT_ID= +KRATER_WEAVE_CLIENT_SECRET= +KRATER_WEAVE_API_BASE_URL= +KRATER_WEAVE_SERVICE_KEY= +KRATER_WEAVE_STUB_USERS_FILE= + +# docker-compose only: the `db` service's own credentials. +POSTGRES_USER=krater +POSTGRES_PASSWORD=krater +POSTGRES_DB=krater +PORT=8000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77c5bf1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: root + POSTGRES_PASSWORD: root + POSTGRES_DB: krater_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U root" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + KRATER_ENV: test + KRATER_DATABASE_URL: postgresql+psycopg://root:root@localhost:5432/krater_test + KRATER_TEST_DATABASE_URL: postgresql+psycopg://root:root@localhost:5432/krater_test + KRATER_SECRET_KEY: ci-test-secret + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + version: "0.8.17" + enable-cache: true + + - name: Set up Python + run: uv python install + + - name: Install dependencies + run: uv sync --frozen + + - name: Lint (ruff check) + run: uv run ruff check . + + - name: Format check (ruff format) + run: uv run ruff format --check . + + - name: Run tests + run: uv run pytest diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d90f18f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +# +# Builds for both amd64 and arm64 (e.g. `docker buildx build --platform linux/amd64,linux/arm64 .`): +# both `python:3.12-slim` and the `uv` image below publish multi-arch manifests, and nothing here +# pins an architecture. +FROM python:3.12-slim + +# Copy the `uv` and `uvx` binaries from Astral's official image rather than installing via pip. +COPY --from=ghcr.io/astral-sh/uv:0.8.17 /uv /uvx /usr/local/bin/ + +ENV UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + UV_PYTHON_DOWNLOADS=never \ + PYTHONUNBUFFERED=1 \ + PATH="/app/.venv/bin:$PATH" + +WORKDIR /app + +# Install dependencies before copying the rest of the source, so `docker build` caches this layer as +# long as pyproject.toml / uv.lock haven't changed. +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-install-project --no-dev + +COPY . . +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +RUN groupadd --system krater \ + && useradd --system --gid krater --create-home --home-dir /home/krater krater \ + && chown -R krater:krater /app +USER krater + +EXPOSE 8000 + +# The portal is the default; docker-compose.yml overrides `command` for `migrate` and `worker`. +CMD ["uvicorn", "krater.web.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 957ef05..c74f862 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,35 @@ gallery of completed projects. - [Spec](docs/SPEC.md): product spec, data model, workflow, deployment, open questions - [Weave integration](docs/weave-integration.md): sign-in, roles, and the Weave changes Krater depends on - [SkyPilot integration](docs/skypilot-integration.md): workspaces, the admin-policy launch gate, and the spend reconciler + +## Development + +Stack, layout and conventions are documented in [CLAUDE.md](CLAUDE.md). Quick start: + +```bash +# Once: install Python 3.12 and the project's dependencies. +uv python install 3.12 +uv sync + +# Once: create the local databases (adjust to your Postgres superuser). +createdb krater_dev +createdb krater_test + +# Copy and adjust environment variables (KRATER_DATABASE_URL etc.). +cp .env.example .env + +# Apply migrations, then run the web app and worker (in separate terminals). +uv run alembic upgrade head +uv run uvicorn krater.web.app:create_app --factory --reload +uv run procrastinate --app=krater.worker.app.app worker + +# Lint, format and test. +uv run ruff check . && uv run ruff format --check . +uv run pytest +``` + +`KRATER_TEST_DATABASE_URL` (default `postgresql+psycopg://root:root@localhost:5432/krater_test`) points +tests at a separate database; see `tests/conftest.py`. + +The full stack (Postgres, a one-shot migration, the portal and the worker) also runs under +`docker compose up --build`. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b532cc --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: ${POSTGRES_USER:-krater} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-krater} + POSTGRES_DB: ${POSTGRES_DB:-krater} + volumes: + - krater_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-krater} -d ${POSTGRES_DB:-krater}"] + interval: 5s + timeout: 5s + retries: 10 + + migrate: + build: . + env_file: .env + command: ["alembic", "upgrade", "head"] + depends_on: + db: + condition: service_healthy + + portal: + build: . + env_file: .env + ports: + - "${PORT:-8000}:8000" + depends_on: + migrate: + condition: service_completed_successfully + + worker: + build: . + env_file: .env + command: ["procrastinate", "--app=krater.worker.app.app", "worker"] + depends_on: + migrate: + condition: service_completed_successfully + +volumes: + krater_db_data: From 1b642aa9faf22ed08f008ced5140910172d6bbb9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:16:37 +0000 Subject: [PATCH 013/124] Exclude procrastinate tables from autogenerate; add shared Actor type alembic check reported that the next autogenerated migration would drop procrastinate's tables, since they come from its SQL rather than our models. Filter them out with include_name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- alembic/env.py | 14 +++++++++++++- docs/weave-integration.md | 6 +++--- krater/services/actor.py | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 krater/services/actor.py diff --git a/alembic/env.py b/alembic/env.py index 7471e1b..87fd17e 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -24,6 +24,17 @@ target_metadata = Base.metadata +def include_name(name, type_, parent_names) -> bool: + """Keep autogenerate away from procrastinate's schema. + + procrastinate's tables are created by their own migration from its SQL, not from our models, so without this + every autogenerated migration would try to drop them. + """ + if type_ == "table": + return not (name or "").startswith("procrastinate_") + return True + + def run_migrations_offline() -> None: """Run migrations in 'offline' mode. @@ -40,6 +51,7 @@ def run_migrations_offline() -> None: context.configure( url=url, target_metadata=target_metadata, + include_name=include_name, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) @@ -62,7 +74,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure(connection=connection, target_metadata=target_metadata) + context.configure(connection=connection, target_metadata=target_metadata, include_name=include_name) with context.begin_transaction(): context.run_migrations() diff --git a/docs/weave-integration.md b/docs/weave-integration.md index ddee906..141d300 100644 --- a/docs/weave-integration.md +++ b/docs/weave-integration.md @@ -100,7 +100,7 @@ Register Krater in Weave at `/admin/oauth_applications`: class WeaveClient(Protocol): # OIDC def authorization_url(self, state: str, code_verifier: str) -> str: ... - def exchange_code(self, code: str, code_verifier: str) -> WeaveIdentity: ... # verifies id_token via JWKS + def exchange_code(self, code: str, code_verifier: str) -> WeaveIdentity: ... # verifies id_token via JWKS # Directory (service key) def get_user(self, sub: str) -> WeaveUser | None: ... @@ -111,12 +111,12 @@ class WeaveClient(Protocol): ```python @dataclass(frozen=True) class WeaveUser: - sub: str # PWL…; Krater's external ID + sub: str # PWL…; Krater's external ID name: str email: str slack_id: str | None groups: frozenset[str] - active: bool # status == "active" + active: bool # status == "active" ``` Rules: diff --git a/krater/services/actor.py b/krater/services/actor.py new file mode 100644 index 0000000..c374179 --- /dev/null +++ b/krater/services/actor.py @@ -0,0 +1,36 @@ +"""Who is performing an action, and what Weave says they may do. + +Built by the web layer (or Slack handlers) from a fresh WeaveClient lookup, then passed explicitly into services. Services +authorize against `groups` here and never read `User.groups_cached`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from krater.models import User + +GROUP_MEMBER = "ganymede:member" +GROUP_REVIEWER = "ganymede:reviewer" +GROUP_ADMIN = "ganymede:admin" + + +@dataclass(frozen=True) +class Actor: + user: User + groups: frozenset[str] + + def in_group(self, group: str) -> bool: + return group in self.groups + + @property + def is_member(self) -> bool: + return GROUP_MEMBER in self.groups + + @property + def is_reviewer(self) -> bool: + return GROUP_REVIEWER in self.groups + + @property + def is_admin(self) -> bool: + return GROUP_ADMIN in self.groups From afc34bb90e038855988484c5e392cb23b535cb3c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:16:50 +0000 Subject: [PATCH 014/124] Fix line length in actor.py Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/services/actor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/krater/services/actor.py b/krater/services/actor.py index c374179..3c52e1b 100644 --- a/krater/services/actor.py +++ b/krater/services/actor.py @@ -1,7 +1,7 @@ """Who is performing an action, and what Weave says they may do. -Built by the web layer (or Slack handlers) from a fresh WeaveClient lookup, then passed explicitly into services. Services -authorize against `groups` here and never read `User.groups_cached`. +Built by the web layer (or Slack handlers) from a fresh WeaveClient lookup, then passed explicitly into services. +Services authorize against `groups` here and never read `User.groups_cached`. """ from __future__ import annotations From 453211d4cff69c48604a9d9b1cb8b2e1a567e2ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:31:56 +0000 Subject: [PATCH 015/124] Add the WeaveClient protocol, identity/user types, errors and a TTL cache krater/weave/types.py: WeaveIdentity (from a verified id_token) and WeaveUser (from the directory API), per docs/weave-integration.md. krater/weave/client.py: the WeaveClient Protocol every adapter implements (authorization_url, exchange_code, get_user, get_user_by_slack_id, list_users_in_group). krater/weave/errors.py: WeaveAuthError (bad state/code/id_token) vs. WeaveUnavailableError (Weave unreachable or erroring), so callers can branch on which one it was instead of on Weave's own exceptions or status codes. krater/weave/cache.py: a small in-process TTLCache, used by LiveWeaveClient's directory lookups (next commit) to absorb bursts of repeat calls. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/weave/cache.py | 31 +++++++++++++++++++++++++++++++ krater/weave/client.py | 38 ++++++++++++++++++++++++++++++++++++++ krater/weave/errors.py | 20 ++++++++++++++++++++ krater/weave/types.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 krater/weave/cache.py create mode 100644 krater/weave/client.py create mode 100644 krater/weave/errors.py create mode 100644 krater/weave/types.py diff --git a/krater/weave/cache.py b/krater/weave/cache.py new file mode 100644 index 0000000..a987f2f --- /dev/null +++ b/krater/weave/cache.py @@ -0,0 +1,31 @@ +"""A tiny in-process TTL cache, used to absorb bursts of directory lookups (e.g. Slack button clicks).""" + +from __future__ import annotations + +import time + +#: Sentinel distinguishing "not cached" from a cached `None` (a directory miss is worth caching too). +MISSING = object() + + +class TTLCache[K, V]: + """A `dict`-like cache where each entry expires `ttl_seconds` after it was set. Not thread-safe; + fine for the single-worker-per-request model this app runs under.""" + + def __init__(self, ttl_seconds: float) -> None: + self._ttl_seconds = ttl_seconds + self._entries: dict[K, tuple[float, V]] = {} + + def get(self, key: K) -> V | object: + """The cached value for `key`, or `MISSING` if absent or expired.""" + entry = self._entries.get(key) + if entry is None: + return MISSING + expires_at, value = entry + if time.monotonic() >= expires_at: + del self._entries[key] + return MISSING + return value + + def set(self, key: K, value: V) -> None: + self._entries[key] = (time.monotonic() + self._ttl_seconds, value) diff --git a/krater/weave/client.py b/krater/weave/client.py new file mode 100644 index 0000000..b191330 --- /dev/null +++ b/krater/weave/client.py @@ -0,0 +1,38 @@ +"""The `WeaveClient` protocol every adapter (live or stub) implements. + +Nothing outside `krater.weave` should know Weave's URLs, scopes or claim shapes -- go through this +interface. See `docs/weave-integration.md` for the contract. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from krater.weave.types import WeaveIdentity, WeaveUser + + +@runtime_checkable +class WeaveClient(Protocol): + def authorization_url(self, *, state: str, nonce: str, code_verifier: str, redirect_uri: str) -> str: + """The URL to send the browser to, to start sign-in (PKCE S256 challenge included).""" + ... + + def exchange_code(self, *, code: str, code_verifier: str, redirect_uri: str, nonce: str) -> WeaveIdentity: + """Exchange an authorization code for a verified identity. + + Raises `WeaveAuthError` if the code, PKCE verifier or resulting id_token don't check out, and + `WeaveUnavailableError` if Weave couldn't be reached. + """ + ... + + def get_user(self, sub: str) -> WeaveUser | None: + """Look up a user by their `sub` (Weave's `p_id`). `None` if unknown.""" + ... + + def get_user_by_slack_id(self, slack_id: str) -> WeaveUser | None: + """Look up a user by their Slack user ID. `None` if unknown or unlinked.""" + ... + + def list_users_in_group(self, group: str) -> list[WeaveUser]: + """Every user in a Weave group, e.g. `ganymede:reviewer`.""" + ... diff --git a/krater/weave/errors.py b/krater/weave/errors.py new file mode 100644 index 0000000..6ee5f96 --- /dev/null +++ b/krater/weave/errors.py @@ -0,0 +1,20 @@ +"""Exception types raised by `krater.weave`. + +Callers (routers, deps) branch on these two, not on HTTP status codes or Weave's own exceptions. +""" + +from __future__ import annotations + + +class WeaveError(Exception): + """Base class for every error raised by `krater.weave`.""" + + +class WeaveAuthError(WeaveError): + """Sign-in failed: a bad state/PKCE pairing, a code that didn't exchange, or an id_token that didn't + verify (signature, issuer, audience, expiry or nonce).""" + + +class WeaveUnavailableError(WeaveError): + """Weave couldn't be reached, or answered with an error we can't attribute to the caller (a 5xx, a + malformed response, a network timeout).""" diff --git a/krater/weave/types.py b/krater/weave/types.py new file mode 100644 index 0000000..4bcfbbc --- /dev/null +++ b/krater/weave/types.py @@ -0,0 +1,33 @@ +"""Data carried out of the Weave adapter. + +`WeaveIdentity` comes from a freshly verified id_token, at sign-in time. `WeaveUser` comes from the +directory API, and is what Weave says about a user *right now* -- see `docs/weave-integration.md`. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class WeaveIdentity: + """A verified identity, built from an id_token's claims. `groups` is empty, never omitted.""" + + sub: str + name: str + email: str + email_verified: bool + slack_id: str | None + groups: frozenset[str] + + +@dataclass(frozen=True) +class WeaveUser: + """A directory lookup result. `active` is Weave's `status == "active"`.""" + + sub: str + name: str + email: str + slack_id: str | None + groups: frozenset[str] + active: bool From efbd1804c90cefc2b142b72c3aaf20a7a0aca248 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:32:06 +0000 Subject: [PATCH 016/124] Add LiveWeaveClient and StubWeaveClient, plus the get_weave_client() factory LiveWeaveClient (krater/weave/live.py): OIDC against a real Weave. - Discovery (/.well-known/openid-configuration) and its JWKS are each fetched once per process and cached on the instance. - authorization_url builds a PKCE S256 challenge from the verifier and requests `openid profile email groups slack`. - exchange_code posts to the token endpoint with the client secret and PKCE verifier, then verifies the id_token with joserfc: RS256 signature against the JWKS, plus iss/aud/exp/nonce via JWTClaimsRegistry. Any failure there, or a 4xx from the token endpoint, raises WeaveAuthError; a 5xx or network error raises WeaveUnavailableError. - get_user/get_user_by_slack_id/list_users_in_group call the directory API with the X-Api-Key header; 404 becomes None. get_user and get_user_by_slack_id are cached for 60s via the new TTLCache. - The httpx.Client is injectable, so tests can pass one built on httpx.MockTransport instead of hitting the network. StubWeaveClient (krater/weave/stub.py): no network calls. Loads krater/weave/stub_users.json (or `weave_stub_users_file`) into memory; exchange_code treats the authorization code as the chosen user's `sub` directly, and authorization_url points at the local /auth/stub picker. The bundled fixture has 7 users: two plain members, two reviewers, an admin, a non-member, and one inactive member. krater/weave/__init__.py: get_weave_client(), an lru_cache'd FastAPI dependency that picks Live vs. Stub from settings.weave_mode, overridable in tests. Re-exports the public types/errors/client so `from krater.weave import ...` is the only import other code needs. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/weave/__init__.py | 41 ++++++++ krater/weave/live.py | 195 +++++++++++++++++++++++++++++++++++ krater/weave/stub.py | 80 ++++++++++++++ krater/weave/stub_users.json | 65 ++++++++++++ 4 files changed, 381 insertions(+) create mode 100644 krater/weave/live.py create mode 100644 krater/weave/stub.py create mode 100644 krater/weave/stub_users.json diff --git a/krater/weave/__init__.py b/krater/weave/__init__.py index e69de29..9bf754f 100644 --- a/krater/weave/__init__.py +++ b/krater/weave/__init__.py @@ -0,0 +1,41 @@ +"""`krater.weave`: the only place that talks to Weave. + +Nothing outside this package should import `httpx` for Weave or know its URLs, scopes or claim shapes -- +go through `WeaveClient` (via `get_weave_client`, a FastAPI dependency that tests can override). See +`docs/weave-integration.md` for the contract. +""" + +from __future__ import annotations + +from functools import lru_cache + +from krater.config import get_settings +from krater.weave.client import WeaveClient +from krater.weave.errors import WeaveAuthError, WeaveError, WeaveUnavailableError +from krater.weave.live import LiveWeaveClient +from krater.weave.stub import StubWeaveClient +from krater.weave.types import WeaveIdentity, WeaveUser + +__all__ = [ + "LiveWeaveClient", + "StubWeaveClient", + "WeaveAuthError", + "WeaveClient", + "WeaveError", + "WeaveIdentity", + "WeaveUnavailableError", + "WeaveUser", + "get_weave_client", +] + + +@lru_cache +def get_weave_client() -> WeaveClient: + """The process-wide `WeaveClient`, chosen by `settings.weave_mode`. + + A FastAPI dependency; override it in tests with `app.dependency_overrides[get_weave_client]`. + """ + settings = get_settings() + if settings.weave_mode == "stub": + return StubWeaveClient(settings.weave_stub_users_file or None) + return LiveWeaveClient(settings) diff --git a/krater/weave/live.py b/krater/weave/live.py new file mode 100644 index 0000000..51c36f9 --- /dev/null +++ b/krater/weave/live.py @@ -0,0 +1,195 @@ +"""`LiveWeaveClient`: OIDC sign-in and the directory API against a real Weave. + +Discovery and the JWKS are fetched once per process and cached. id_tokens are RS256, verified against +that JWKS with `joserfc` (signature, `iss`, `aud`, `exp`, `nonce`); PKCE is S256, as Weave requires. +Directory responses (`get_user` / `get_user_by_slack_id`) are cached for a short TTL to absorb bursts of +lookups (e.g. Slack button clicks). See `docs/weave-integration.md` for the full contract. +""" + +from __future__ import annotations + +import base64 +import hashlib +from urllib.parse import urlencode + +import httpx +from joserfc import jwt +from joserfc.errors import JoseError +from joserfc.jwk import KeySet +from joserfc.jwt import JWTClaimsRegistry + +from krater.config import Settings +from krater.weave.cache import MISSING, TTLCache +from krater.weave.errors import WeaveAuthError, WeaveUnavailableError +from krater.weave.types import WeaveIdentity, WeaveUser + +#: How long a directory lookup (`get_user` / `get_user_by_slack_id`) is trusted before re-fetching. +DIRECTORY_CACHE_TTL_SECONDS = 60.0 + +SCOPES = "openid profile email groups slack" + + +def _pkce_code_challenge_s256(code_verifier: str) -> str: + digest = hashlib.sha256(code_verifier.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + +class LiveWeaveClient: + """A `WeaveClient` backed by a real Weave over HTTP. `http_client` is injectable for tests + (`httpx.MockTransport`); production code leaves it out and gets a real `httpx.Client`.""" + + def __init__(self, settings: Settings, *, http_client: httpx.Client | None = None) -> None: + self._settings = settings + self._http = http_client if http_client is not None else httpx.Client(timeout=10.0) + self._discovery_doc: dict | None = None + self._jwk_set: KeySet | None = None + self._directory_cache: TTLCache[tuple[str, str], WeaveUser | None] = TTLCache(DIRECTORY_CACHE_TTL_SECONDS) + + # -- OIDC ------------------------------------------------------------------------------------ + + def authorization_url(self, *, state: str, nonce: str, code_verifier: str, redirect_uri: str) -> str: + endpoint = self._discovery()["authorization_endpoint"] + params = { + "response_type": "code", + "client_id": self._settings.weave_client_id, + "redirect_uri": redirect_uri, + "scope": SCOPES, + "state": state, + "nonce": nonce, + "code_challenge": _pkce_code_challenge_s256(code_verifier), + "code_challenge_method": "S256", + } + return f"{endpoint}?{urlencode(params)}" + + def exchange_code(self, *, code: str, code_verifier: str, redirect_uri: str, nonce: str) -> WeaveIdentity: + token_endpoint = self._discovery()["token_endpoint"] + try: + response = self._http.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self._settings.weave_client_id, + "client_secret": self._settings.weave_client_secret, + "code_verifier": code_verifier, + }, + ) + except httpx.HTTPError as exc: + raise WeaveUnavailableError("could not reach Weave's token endpoint") from exc + + if response.status_code >= 500: + raise WeaveUnavailableError(f"Weave's token endpoint returned {response.status_code}") + if response.status_code != 200: + raise WeaveAuthError(f"token exchange failed: {response.status_code} {response.text}") + + id_token = response.json().get("id_token") + if not id_token: + raise WeaveAuthError("token response had no id_token") + + return self._verify_id_token(id_token, nonce=nonce) + + def _discovery(self) -> dict: + if self._discovery_doc is None: + self._discovery_doc = self._get_json(f"{self._settings.weave_issuer}/.well-known/openid-configuration") + return self._discovery_doc + + def _jwks(self) -> KeySet: + if self._jwk_set is None: + jwks_uri = self._discovery()["jwks_uri"] + self._jwk_set = KeySet.import_key_set(self._get_json(jwks_uri)) + return self._jwk_set + + def _get_json(self, url: str) -> dict: + try: + response = self._http.get(url) + except httpx.HTTPError as exc: + raise WeaveUnavailableError(f"could not reach Weave at {url}") from exc + if response.status_code != 200: + raise WeaveUnavailableError(f"Weave returned {response.status_code} for {url}") + return response.json() + + def _verify_id_token(self, id_token: str, *, nonce: str) -> WeaveIdentity: + try: + token = jwt.decode(id_token, self._jwks(), algorithms=["RS256"]) + except JoseError as exc: + raise WeaveAuthError(f"id_token failed signature/decoding checks: {exc}") from exc + + registry = JWTClaimsRegistry( + iss={"essential": True, "value": self._settings.weave_issuer}, + aud={"essential": True, "value": self._settings.weave_client_id}, + exp={"essential": True}, + nonce={"essential": True, "value": nonce}, + ) + try: + registry.validate(token.claims) + except JoseError as exc: + raise WeaveAuthError(f"id_token claims failed validation: {exc}") from exc + + claims = token.claims + return WeaveIdentity( + sub=claims["sub"], + name=claims.get("name", ""), + email=claims.get("email", ""), + email_verified=bool(claims.get("email_verified", False)), + slack_id=claims.get("slack_id"), + groups=frozenset(claims.get("groups", [])), + ) + + # -- Directory --------------------------------------------------------------------------------- + + def get_user(self, sub: str) -> WeaveUser | None: + return self._cached_directory_lookup(("sub", sub), f"{self._settings.weave_api_base_url}/api/v1/users/{sub}") + + def get_user_by_slack_id(self, slack_id: str) -> WeaveUser | None: + return self._cached_directory_lookup( + ("slack_id", slack_id), f"{self._settings.weave_api_base_url}/api/v1/users/by_slack_id/{slack_id}" + ) + + def list_users_in_group(self, group: str) -> list[WeaveUser]: + url = f"{self._settings.weave_api_base_url}/api/v1/users" + response = self._directory_get(url, params={"group": group}) + if response.status_code == 404: + return [] + self._raise_unless_ok(response) + return [self._parse_user(entry) for entry in response.json()["users"]] + + def _cached_directory_lookup(self, cache_key: tuple[str, str], url: str) -> WeaveUser | None: + cached = self._directory_cache.get(cache_key) + if cached is not MISSING: + return cached + + response = self._directory_get(url) + if response.status_code == 404: + user: WeaveUser | None = None + else: + self._raise_unless_ok(response) + user = self._parse_user(response.json()["user"]) + + self._directory_cache.set(cache_key, user) + return user + + def _directory_get(self, url: str, *, params: dict[str, str] | None = None) -> httpx.Response: + try: + return self._http.get(url, params=params, headers={"X-Api-Key": self._settings.weave_service_key}) + except httpx.HTTPError as exc: + raise WeaveUnavailableError(f"could not reach Weave's directory at {url}") from exc + + @staticmethod + def _raise_unless_ok(response: httpx.Response) -> None: + if response.status_code != 200: + raise WeaveUnavailableError(f"Weave's directory returned {response.status_code}") + + @staticmethod + def _parse_user(data: dict) -> WeaveUser: + return WeaveUser( + sub=data["sub"], + name=data["name"], + email=data["email"], + slack_id=data.get("slack_id"), + groups=frozenset(data.get("groups", [])), + active=bool(data.get("active", False)), + ) + + +__all__ = ["LiveWeaveClient"] diff --git a/krater/weave/stub.py b/krater/weave/stub.py new file mode 100644 index 0000000..16b0fe3 --- /dev/null +++ b/krater/weave/stub.py @@ -0,0 +1,80 @@ +"""`StubWeaveClient`: a fake Weave for `KRATER_WEAVE_MODE=stub` (development and tests). + +Makes no network calls. Users come from a JSON fixture (`weave_stub_users_file`, or the bundled +`stub_users.json` when that setting is blank). `authorization_url` points at the local `/auth/stub` +picker page; `exchange_code` treats the authorization code as the chosen user's `sub` directly. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from urllib.parse import urlencode + +from krater.weave.errors import WeaveAuthError +from krater.weave.types import WeaveIdentity, WeaveUser + +#: The bundled fixture, used whenever `settings.weave_stub_users_file` is blank. +DEFAULT_STUB_USERS_FILE = Path(__file__).parent / "stub_users.json" + + +class StubWeaveClient: + """A `WeaveClient` backed by a local JSON fixture of fake users. See `DEFAULT_STUB_USERS_FILE` for + the expected shape: one object per user, with the same fields as a directory `WeaveUser`, plus + `email_verified` (used for the identity built at "sign-in").""" + + def __init__(self, stub_users_file: str | Path | None = None) -> None: + path = Path(stub_users_file) if stub_users_file else DEFAULT_STUB_USERS_FILE + raw_users = json.loads(path.read_text()) + + self._users_by_sub: dict[str, WeaveUser] = {} + self._email_verified_by_sub: dict[str, bool] = {} + for entry in raw_users: + user = WeaveUser( + sub=entry["sub"], + name=entry["name"], + email=entry["email"], + slack_id=entry.get("slack_id"), + groups=frozenset(entry.get("groups", [])), + active=entry.get("active", True), + ) + self._users_by_sub[user.sub] = user + self._email_verified_by_sub[user.sub] = entry.get("email_verified", True) + + def authorization_url(self, *, state: str, nonce: str, code_verifier: str, redirect_uri: str) -> str: + del code_verifier # no real PKCE exchange happens in stub mode + params = urlencode({"state": state, "nonce": nonce, "redirect_uri": redirect_uri}) + return f"/auth/stub?{params}" + + def exchange_code(self, *, code: str, code_verifier: str, redirect_uri: str, nonce: str) -> WeaveIdentity: + del code_verifier, redirect_uri, nonce # the stub trusts the code (a fixture `sub`) directly + user = self._users_by_sub.get(code) + if user is None: + raise WeaveAuthError(f"no stub user with sub {code!r}") + return WeaveIdentity( + sub=user.sub, + name=user.name, + email=user.email, + email_verified=self._email_verified_by_sub.get(user.sub, True), + slack_id=user.slack_id, + groups=user.groups, + ) + + def get_user(self, sub: str) -> WeaveUser | None: + return self._users_by_sub.get(sub) + + def get_user_by_slack_id(self, slack_id: str) -> WeaveUser | None: + for user in self._users_by_sub.values(): + if user.slack_id == slack_id: + return user + return None + + def list_users_in_group(self, group: str) -> list[WeaveUser]: + return [user for user in self._users_by_sub.values() if group in user.groups] + + def list_all_users(self) -> list[WeaveUser]: + """Every fixture user, in file order. Used by the `/auth/stub` picker page.""" + return list(self._users_by_sub.values()) + + +__all__ = ["DEFAULT_STUB_USERS_FILE", "StubWeaveClient"] diff --git a/krater/weave/stub_users.json b/krater/weave/stub_users.json new file mode 100644 index 0000000..d780368 --- /dev/null +++ b/krater/weave/stub_users.json @@ -0,0 +1,65 @@ +[ + { + "sub": "PWLMEMBERONE", + "name": "Mia Member", + "email": "mia@example.com", + "email_verified": true, + "slack_id": "U0001MEMBER", + "groups": ["ganymede:member"], + "active": true + }, + { + "sub": "PWLMEMBERTWO", + "name": "Max Member", + "email": "max@example.com", + "email_verified": true, + "slack_id": null, + "groups": ["ganymede:member"], + "active": true + }, + { + "sub": "PWLREVIEWERONE", + "name": "Rae Reviewer", + "email": "rae@example.com", + "email_verified": true, + "slack_id": "U0002REVIEW", + "groups": ["ganymede:member", "ganymede:reviewer"], + "active": true + }, + { + "sub": "PWLREVIEWERTWO", + "name": "Remy Reviewer", + "email": "remy@example.com", + "email_verified": true, + "slack_id": "U0003REVIEW", + "groups": ["ganymede:member", "ganymede:reviewer"], + "active": true + }, + { + "sub": "PWLADMINONE", + "name": "Ana Admin", + "email": "ana@example.com", + "email_verified": true, + "slack_id": "U0004ADMIN", + "groups": ["ganymede:member", "ganymede:reviewer", "ganymede:admin"], + "active": true + }, + { + "sub": "PWLNONMEMBER", + "name": "Nico Nonmember", + "email": "nico@example.com", + "email_verified": true, + "slack_id": null, + "groups": [], + "active": true + }, + { + "sub": "PWLINACTIVEONE", + "name": "Ivy Inactive", + "email": "ivy@example.com", + "email_verified": true, + "slack_id": null, + "groups": ["ganymede:member"], + "active": false + } +] From 7fd8c28879ef9ecc0e76bce2dcbe5e82022d83ed Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:32:10 +0000 Subject: [PATCH 017/124] Add upsert_user_from_identity Creates or updates a User by weave_sub, refreshing display_name, email, slack_user_id, groups_cached (sorted, for stable diffs) and last_login_at from a verified WeaveIdentity. Flushes but doesn't commit -- the router calling it (krater/web/routers/auth.py, next commit) owns the transaction. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/services/users.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 krater/services/users.py diff --git a/krater/services/users.py b/krater/services/users.py new file mode 100644 index 0000000..1a3ad14 --- /dev/null +++ b/krater/services/users.py @@ -0,0 +1,35 @@ +"""Turning a verified Weave identity into (or onto) a `User` row.""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from krater.models import User +from krater.weave import WeaveIdentity + + +def upsert_user_from_identity(session: Session, identity: WeaveIdentity) -> User: + """Create or update the `User` row for `identity`, by `weave_sub`. + + Refreshes `display_name`, `email`, `slack_user_id`, `groups_cached` and `last_login_at` from the + identity. Flushes but does not commit -- the caller (a router, inside a request) commits. + """ + user = session.execute(select(User).where(User.weave_sub == identity.sub)).scalar_one_or_none() + if user is None: + user = User(weave_sub=identity.sub) + session.add(user) + + user.display_name = identity.name + user.email = identity.email + user.slack_user_id = identity.slack_id + user.groups_cached = sorted(identity.groups) + user.last_login_at = datetime.now(UTC) + + session.flush() + return user + + +__all__ = ["upsert_user_from_identity"] From d02ac2f18fa4522dd3599ecc6cf96cec4ee114eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:32:24 +0000 Subject: [PATCH 018/124] Add Weave sign-in: /login, /auth/callback, /logout, /auth/stub, and deps krater/web/csrf.py: a per-session CSRF token (get_or_create_csrf_token), a csrf_token() Jinja global bound to the current request via jinja2.pass_context, and verify_csrf_token, a dependency that 403s a POST missing or mismatching the csrf_token form field. Wired into krater/web/templates.py so every template can call {{ csrf_token() }}. krater/web/deps.py: current_user (optional), require_user (redirects signed-out HTML requests to /login?next=..., open-redirect-safe since next is always this same app's own path), session_actor (cached groups, display only) and fresh_actor (a live WeaveClient.get_user() call; 403s if the user is inactive or no longer ganymede:member), plus require_reviewer/require_admin built on it. Docstrings say which to use when; later waves should reach for fresh_actor (or stricter) for anything state-changing. krater/web/routers/auth.py: - GET /login stores a fresh state/nonce/PKCE verifier and a validated `next` in the session, then redirects to authorization_url. - GET /auth/callback checks state, exchanges the code, 403s with a friendly page for a non-member, otherwise upserts the user, commits, clears and re-seeds the session (session fixation) with the user id/sub/display name, and redirects to `next`. - POST /logout (CSRF-protected) clears the session. - GET /auth/stub (stub mode only, 404s under `live`) lists the fixture users to click, linking to the callback with code=. base.html now shows the signed-in user's name and a sign-out button, or a "Sign in" link otherwise; new templates/auth/{not_a_member,stub_picker}.html and a few additive CSS rules follow the existing hand-written styles. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/web/app.py | 3 +- krater/web/csrf.py | 62 +++++++++ krater/web/deps.py | 101 +++++++++++++++ krater/web/routers/auth.py | 135 ++++++++++++++++++++ krater/web/static/css/main.css | 41 ++++++ krater/web/templates.py | 3 + krater/web/templates/auth/not_a_member.html | 14 ++ krater/web/templates/auth/stub_picker.html | 21 +++ krater/web/templates/base.html | 11 +- 9 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 krater/web/csrf.py create mode 100644 krater/web/deps.py create mode 100644 krater/web/routers/auth.py create mode 100644 krater/web/templates/auth/not_a_member.html create mode 100644 krater/web/templates/auth/stub_picker.html diff --git a/krater/web/app.py b/krater/web/app.py index 349140f..31e811b 100644 --- a/krater/web/app.py +++ b/krater/web/app.py @@ -12,7 +12,7 @@ from starlette.staticfiles import StaticFiles from krater.config import get_settings -from krater.web.routers import pages +from krater.web.routers import auth, pages STATIC_DIR = Path(__file__).parent / "static" @@ -27,5 +27,6 @@ def create_app() -> FastAPI: app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") app.include_router(pages.router) + app.include_router(auth.router) return app diff --git a/krater/web/csrf.py b/krater/web/csrf.py new file mode 100644 index 0000000..2ca34d7 --- /dev/null +++ b/krater/web/csrf.py @@ -0,0 +1,62 @@ +"""CSRF protection for session-authenticated form posts. + +Every state-changing form includes a hidden `csrf_token` field, filled in via the `csrf_token()` Jinja +global (registered onto `templates` by `register_csrf_template_global`). `verify_csrf_token` is a FastAPI +dependency that checks the posted `csrf_token` form field against the one stored in the session; add it +to any POST route with `dependencies=[Depends(verify_csrf_token)]`. +""" + +from __future__ import annotations + +import secrets +from typing import Annotated + +import jinja2 +from fastapi import Form, HTTPException, Request, status +from fastapi.templating import Jinja2Templates + +#: The session key the token is stored under. +CSRF_SESSION_KEY = "csrf_token" + +#: The form field name every CSRF-protected form must post the token under. +CSRF_FORM_FIELD = "csrf_token" + + +def get_or_create_csrf_token(request: Request) -> str: + """This session's CSRF token, generating and storing one on first use.""" + token = request.session.get(CSRF_SESSION_KEY) + if not token: + token = secrets.token_urlsafe(32) + request.session[CSRF_SESSION_KEY] = token + return token + + +def verify_csrf_token(request: Request, csrf_token: Annotated[str | None, Form()] = None) -> None: + """Raise 403 unless the posted `csrf_token` form field matches this session's token. + + `csrf_token` is optional at the FastAPI level (rather than required) so that a request missing it + entirely fails with a clear 403, the same as a mismatched one, instead of a generic 422. + """ + expected = request.session.get(CSRF_SESSION_KEY) + if not expected or not csrf_token or not secrets.compare_digest(csrf_token, expected): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="invalid or missing CSRF token") + + +@jinja2.pass_context +def _csrf_token_template_global(context: dict) -> str: + request: Request = context["request"] + return get_or_create_csrf_token(request) + + +def register_csrf_template_global(templates: Jinja2Templates) -> None: + """Wire `{{ csrf_token() }}` into `templates`, bound to the current request via the Jinja context.""" + templates.env.globals["csrf_token"] = _csrf_token_template_global + + +__all__ = [ + "CSRF_FORM_FIELD", + "CSRF_SESSION_KEY", + "get_or_create_csrf_token", + "register_csrf_template_global", + "verify_csrf_token", +] diff --git a/krater/web/deps.py b/krater/web/deps.py new file mode 100644 index 0000000..658d102 --- /dev/null +++ b/krater/web/deps.py @@ -0,0 +1,101 @@ +"""Auth dependencies for HTML routes: who's signed in, and what Weave currently says they may do. + +- `current_user`: the signed-in `User`, or `None`. For pages that render differently either way (e.g. the + header's "sign in" link vs. the user's name) without requiring sign-in. +- `require_user`: the signed-in `User`, or a redirect to `/login?next=`. For any page that + needs *a* signed-in user but doesn't itself gate on group membership. +- `session_actor`: an `Actor` built from the session's cached `groups_cached`. **Display and navigation + only** -- e.g. deciding whether to show a "Review" link. Never use it to authorize an action: it can be + stale by up to a session's lifetime. +- `fresh_actor`: an `Actor` built from a live `WeaveClient.get_user()` call. Refuses (403) if the user is + inactive or no longer `ganymede:member`. **Every state-changing action must use this, or one of the two + below, instead of `session_actor`.** +- `require_reviewer` / `require_admin`: `fresh_actor`, plus a 403 unless the actor is a reviewer/admin. +""" + +from __future__ import annotations + +import uuid +from typing import Annotated +from urllib.parse import quote + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from krater.db import get_session +from krater.models import User +from krater.services.actor import GROUP_MEMBER, Actor +from krater.weave import WeaveClient, get_weave_client + +SESSION_USER_ID_KEY = "user_id" + + +def current_user(request: Request, db_session: Annotated[Session, Depends(get_session)]) -> User | None: + """The signed-in `User`, or `None` if there isn't one (no session, or it points at a deleted user).""" + raw_user_id = request.session.get(SESSION_USER_ID_KEY) + if not raw_user_id: + return None + try: + user_id = uuid.UUID(raw_user_id) + except ValueError: + return None + return db_session.get(User, user_id) + + +def require_user(request: Request, user: Annotated[User | None, Depends(current_user)]) -> User: + """The signed-in `User`. Redirects (303) to `/login?next=` if signed out.""" + if user is not None: + return user + + next_path = request.url.path + if request.url.query: + next_path = f"{next_path}?{request.url.query}" + raise HTTPException( + status_code=status.HTTP_303_SEE_OTHER, + headers={"Location": f"/login?next={quote(next_path, safe='')}"}, + ) + + +def session_actor(user: Annotated[User, Depends(require_user)]) -> Actor: + """An `Actor` from the session's cached groups. Display/navigation only -- see the module docstring.""" + return Actor(user=user, groups=frozenset(user.groups_cached)) + + +def fresh_actor( + user: Annotated[User, Depends(require_user)], + weave_client: Annotated[WeaveClient, Depends(get_weave_client)], +) -> Actor: + """An `Actor` built from a live Weave lookup. Refuses (403) if Weave no longer lists the user as an + active Ganymede member. Use this (or `require_reviewer`/`require_admin`) for state-changing actions. + """ + weave_user = weave_client.get_user(user.weave_sub) + if weave_user is None or not weave_user.active or GROUP_MEMBER not in weave_user.groups: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Weave no longer lists you as an active Ganymede member", + ) + return Actor(user=user, groups=weave_user.groups) + + +def require_reviewer(actor: Annotated[Actor, Depends(fresh_actor)]) -> Actor: + """`fresh_actor`, plus a 403 unless the actor is a Ganymede reviewer.""" + if not actor.is_reviewer: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="reviewer access required") + return actor + + +def require_admin(actor: Annotated[Actor, Depends(fresh_actor)]) -> Actor: + """`fresh_actor`, plus a 403 unless the actor is a Ganymede admin.""" + if not actor.is_admin: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin access required") + return actor + + +__all__ = [ + "current_user", + "fresh_actor", + "require_admin", + "require_reviewer", + "require_user", + "session_actor", +] diff --git a/krater/web/routers/auth.py b/krater/web/routers/auth.py new file mode 100644 index 0000000..53156f5 --- /dev/null +++ b/krater/web/routers/auth.py @@ -0,0 +1,135 @@ +"""Sign-in with Weave: `/login`, `/auth/callback`, `/logout`, and (stub mode only) `/auth/stub`. + +See `docs/weave-integration.md` for the OIDC contract this implements against. +""" + +from __future__ import annotations + +import secrets +from typing import Annotated +from urllib.parse import urlparse + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from krater.config import get_settings +from krater.db import get_session +from krater.services.actor import GROUP_MEMBER +from krater.services.users import upsert_user_from_identity +from krater.weave import WeaveAuthError, WeaveClient, WeaveUnavailableError, get_weave_client +from krater.weave.stub import StubWeaveClient +from krater.web.csrf import verify_csrf_token +from krater.web.deps import SESSION_USER_ID_KEY +from krater.web.templates import templates + +router = APIRouter() + +# Session keys used only for the duration of a single OIDC round trip; cleared (along with everything +# else) once sign-in succeeds, to guard against session fixation. +SESSION_STATE_KEY = "oauth_state" +SESSION_NONCE_KEY = "oauth_nonce" +SESSION_VERIFIER_KEY = "oauth_code_verifier" +SESSION_NEXT_KEY = "oauth_next" + + +def _redirect_uri() -> str: + return f"{get_settings().base_url}/auth/callback" + + +def _safe_next_path(path: str | None) -> str: + """A local path to send the browser to after sign-in, or `/` if `path` isn't one. + + Rejects anything that isn't an absolute path on this host: no scheme, no netloc, no `//` (which + browsers treat as protocol-relative) -- otherwise `next` would be an open redirect. + """ + if not path or not path.startswith("/") or path.startswith("//"): + return "/" + parsed = urlparse(path) + if parsed.scheme or parsed.netloc: + return "/" + return path + + +@router.get("/login") +def login( + request: Request, + weave_client: Annotated[WeaveClient, Depends(get_weave_client)], + next: str | None = None, +) -> RedirectResponse: + state = secrets.token_urlsafe(24) + nonce = secrets.token_urlsafe(24) + code_verifier = secrets.token_urlsafe(64) + + request.session[SESSION_STATE_KEY] = state + request.session[SESSION_NONCE_KEY] = nonce + request.session[SESSION_VERIFIER_KEY] = code_verifier + request.session[SESSION_NEXT_KEY] = _safe_next_path(next) + + url = weave_client.authorization_url( + state=state, nonce=nonce, code_verifier=code_verifier, redirect_uri=_redirect_uri() + ) + return RedirectResponse(url, status_code=status.HTTP_302_FOUND) + + +@router.get("/auth/callback") +def auth_callback( + request: Request, + code: str, + state: str, + db_session: Annotated[Session, Depends(get_session)], + weave_client: Annotated[WeaveClient, Depends(get_weave_client)], +): + expected_state = request.session.get(SESSION_STATE_KEY) + if not expected_state or not secrets.compare_digest(state, expected_state): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid or expired state") + + nonce = request.session.get(SESSION_NONCE_KEY, "") + code_verifier = request.session.get(SESSION_VERIFIER_KEY, "") + next_path = _safe_next_path(request.session.get(SESSION_NEXT_KEY)) + + try: + identity = weave_client.exchange_code( + code=code, code_verifier=code_verifier, redirect_uri=_redirect_uri(), nonce=nonce + ) + except WeaveAuthError as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="sign-in with Weave failed") from exc + except WeaveUnavailableError as exc: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Weave is unavailable") from exc + + if GROUP_MEMBER not in identity.groups: + return templates.TemplateResponse(request, "auth/not_a_member.html", status_code=status.HTTP_403_FORBIDDEN) + + user = upsert_user_from_identity(db_session, identity) + db_session.commit() + + # Session fixation: throw away everything the pre-login session held (state/nonce/verifier included) + # before writing an authenticated identity into it. + request.session.clear() + request.session[SESSION_USER_ID_KEY] = str(user.id) + request.session["weave_sub"] = user.weave_sub + request.session["display_name"] = user.display_name + + return RedirectResponse(next_path, status_code=status.HTTP_302_FOUND) + + +@router.post("/logout", dependencies=[Depends(verify_csrf_token)]) +def logout(request: Request) -> RedirectResponse: + request.session.clear() + return RedirectResponse("/", status_code=status.HTTP_302_FOUND) + + +@router.get("/auth/stub") +def stub_picker(request: Request, weave_client: Annotated[WeaveClient, Depends(get_weave_client)]): + if get_settings().weave_mode == "live" or not isinstance(weave_client, StubWeaveClient): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + state = request.query_params.get("state", "") + return templates.TemplateResponse( + request, + "auth/stub_picker.html", + {"users": weave_client.list_all_users(), "state": state}, + ) + + +__all__ = ["router"] diff --git a/krater/web/static/css/main.css b/krater/web/static/css/main.css index ea4ae51..c732e66 100644 --- a/krater/web/static/css/main.css +++ b/krater/web/static/css/main.css @@ -92,6 +92,47 @@ a:hover { color: var(--color-white); } +.site-nav__user { + color: var(--color-white); + font-size: 0.95rem; +} + +.site-nav__logout { + display: inline-flex; + margin: 0; + padding: 0; +} + +.link-button { + background: none; + border: none; + margin: 0; + padding: 0; + font: inherit; + color: var(--color-lavender); + text-decoration: none; + cursor: pointer; +} + +.link-button:hover { + color: var(--color-white); +} + +.stub-user-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.stub-user-list li { + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: 0.5rem; +} + .content { max-width: var(--content-max-width); margin: 0 auto; diff --git a/krater/web/templates.py b/krater/web/templates.py index 33d3e52..b80fb8a 100644 --- a/krater/web/templates.py +++ b/krater/web/templates.py @@ -6,6 +6,9 @@ from fastapi.templating import Jinja2Templates +from krater.web.csrf import register_csrf_template_global + TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) +register_csrf_template_global(templates) diff --git a/krater/web/templates/auth/not_a_member.html b/krater/web/templates/auth/not_a_member.html new file mode 100644 index 0000000..5dc669f --- /dev/null +++ b/krater/web/templates/auth/not_a_member.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} + +{% block title %}Not a Ganymede member{% endblock %} + +{% block content %} +
+

You're not a Ganymede member yet

+

+ Weave signed you in, but Krater is only open to Project Ganymede members. Ask a Ganymede admin to add + you to the ganymede:member group in Weave, then sign in again. +

+

Try again

+
+{% endblock %} diff --git a/krater/web/templates/auth/stub_picker.html b/krater/web/templates/auth/stub_picker.html new file mode 100644 index 0000000..045b43f --- /dev/null +++ b/krater/web/templates/auth/stub_picker.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Stub sign-in{% endblock %} + +{% block content %} +
+

Stub sign-in

+

Development only ({{ "KRATER_WEAVE_MODE=stub" }}). Pick a fixture user to sign in as.

+
    + {% for user in users %} +
  • + {{ user.name }} +
    + {{ user.sub }} · groups: {{ user.groups | sort | join(', ') if user.groups else 'none' }} + {% if not user.active %} · inactive{% endif %} +
    +
  • + {% endfor %} +
+
+{% endblock %} diff --git a/krater/web/templates/base.html b/krater/web/templates/base.html index 178afc3..1a21b60 100644 --- a/krater/web/templates/base.html +++ b/krater/web/templates/base.html @@ -14,7 +14,16 @@ Home Projects Gallery - Sign in + {% set signed_in_name = request.session.get('display_name') %} + {% if signed_in_name %} + {{ signed_in_name }} + + {% else %} + Sign in + {% endif %} From 2ca37ae70471a25ae2d4dc27cf309c39e741a349 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:32:35 +0000 Subject: [PATCH 019/124] Add tests for the Weave client, upsert and the sign-in flow tests/weave/test_live_client.py: LiveWeaveClient against a fake Weave built on httpx.MockTransport, with a real RSA key generated in the test. Covers a good token; wrong aud/iss/nonce, an expired token and a bad signature each failing with WeaveAuthError; a rejected code (WeaveAuthError) vs. a 5xx (WeaveUnavailableError); discovery/JWKS fetched only once; directory parsing including a missing/null slack_id and a 404; the X-Api-Key header; and the directory cache hit. tests/weave/test_stub_client.py: the bundled fixture covers every role, exchange_code/get_user/get_user_by_slack_id/list_users_in_group behave, and an unknown code raises WeaveAuthError. tests/weave/test_upsert.py: upsert_user_from_identity creates a User, then updates the same row (by weave_sub) on a second call. tests/web/test_auth.py: the full stub flow (login -> stub pick -> callback -> signed-in page showing the name -> logout) via TestClient, plus the reject cases: bad/missing state, a non-member (403, no User row created), a CSRF-less logout (403, still signed in), an absolute-URL and a protocol-relative `next` (both fall back to "/"), and /auth/stub 404ing once weave_mode is "live". tests/web/test_deps.py: fresh_actor built from a fake WeaveClient picks up a group removed after login and rejects an inactive or unknown-to-Weave user; require_admin needs the admin group specifically; and one true TestClient-driven check that require_user's FastAPI dependency chain actually redirects to /login?next=... (not just that the plain-Python-call version does). `uv run ruff check . && uv run ruff format --check .` clean; `uv run pytest` passes: 47 passed (3 pre-existing + 44 new). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- tests/weave/__init__.py | 0 tests/weave/test_live_client.py | 341 ++++++++++++++++++++++++++++++++ tests/weave/test_stub_client.py | 73 +++++++ tests/weave/test_upsert.py | 57 ++++++ tests/web/test_auth.py | 130 ++++++++++++ tests/web/test_deps.py | 174 ++++++++++++++++ 6 files changed, 775 insertions(+) create mode 100644 tests/weave/__init__.py create mode 100644 tests/weave/test_live_client.py create mode 100644 tests/weave/test_stub_client.py create mode 100644 tests/weave/test_upsert.py create mode 100644 tests/web/test_auth.py create mode 100644 tests/web/test_deps.py diff --git a/tests/weave/__init__.py b/tests/weave/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/weave/test_live_client.py b/tests/weave/test_live_client.py new file mode 100644 index 0000000..bc6fc56 --- /dev/null +++ b/tests/weave/test_live_client.py @@ -0,0 +1,341 @@ +"""`LiveWeaveClient` against a fake Weave (`httpx.MockTransport`): discovery, JWKS, PKCE, id_token +validation (signature, `iss`, `aud`, `exp`, `nonce`), and the directory API. +""" + +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest +from joserfc import jwt +from joserfc.jwk import RSAKey + +from krater.config import Settings +from krater.weave.errors import WeaveAuthError, WeaveUnavailableError +from krater.weave.live import LiveWeaveClient + +ISSUER = "https://weave.test" +CLIENT_ID = "krater-client" +CLIENT_SECRET = "krater-secret" +API_BASE = "https://weave.test" +SERVICE_KEY = "svc-key-123" +KID = "test-key-1" +REDIRECT_URI = "https://krater.test/auth/callback" +GOOD_NONCE = "expected-nonce" + + +def _settings() -> Settings: + return Settings( + weave_issuer=ISSUER, + weave_client_id=CLIENT_ID, + weave_client_secret=CLIENT_SECRET, + weave_api_base_url=API_BASE, + weave_service_key=SERVICE_KEY, + ) + + +def _id_token(key: RSAKey, **claim_overrides: Any) -> str: + now = int(time.time()) + claims = { + "iss": ISSUER, + "aud": CLIENT_ID, + "sub": "PWLLIVE0001", + "name": "Lee Live", + "email": "lee@example.com", + "email_verified": True, + "groups": ["ganymede:member"], + "nonce": GOOD_NONCE, + "iat": now, + "exp": now + 300, + } + claims.update(claim_overrides) + return jwt.encode({"alg": "RS256", "kid": KID}, claims, key) + + +class FakeWeave: + """A minimal fake of Weave's OIDC + directory endpoints, driven by an `httpx.MockTransport`.""" + + def __init__(self, signing_key: RSAKey) -> None: + self.signing_key = signing_key + self.requests: list[httpx.Request] = [] + self.id_token: str | None = None + self.token_status = 200 + self.directory_users: dict[str, dict] = {} + self.directory_by_slack: dict[str, dict] = {} + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + + if path == "/.well-known/openid-configuration": + return httpx.Response( + 200, + json={ + "issuer": ISSUER, + "authorization_endpoint": f"{ISSUER}/oauth/authorize", + "token_endpoint": f"{ISSUER}/oauth/token", + "jwks_uri": f"{ISSUER}/oauth/discovery/keys", + }, + ) + if path == "/oauth/discovery/keys": + return httpx.Response(200, json={"keys": [self.signing_key.as_dict(private=False)]}) + if path == "/oauth/token": + if self.token_status != 200: + return httpx.Response(self.token_status, json={"error": "invalid_grant"}) + return httpx.Response(200, json={"access_token": "at", "token_type": "Bearer", "id_token": self.id_token}) + if path.startswith("/api/v1/users/by_slack_id/"): + slack_id = path.rsplit("/", 1)[-1] + user = self.directory_by_slack.get(slack_id) + return ( + httpx.Response(404, json={"error": "not_found"}) + if user is None + else httpx.Response(200, json={"user": user}) + ) + if path == "/api/v1/users": + group = request.url.params.get("group") + matches = [u for u in self.directory_users.values() if group in u.get("groups", [])] + return httpx.Response(200, json={"users": matches}) + if path.startswith("/api/v1/users/"): + sub = path.rsplit("/", 1)[-1] + user = self.directory_users.get(sub) + return ( + httpx.Response(404, json={"error": "not_found"}) + if user is None + else httpx.Response(200, json={"user": user}) + ) + return httpx.Response(404, json={"error": "not_found"}) + + +@pytest.fixture(scope="module") +def rsa_key() -> RSAKey: + return RSAKey.generate_key(2048, parameters={"kid": KID}, private=True) + + +@pytest.fixture +def fake_weave(rsa_key: RSAKey) -> FakeWeave: + return FakeWeave(rsa_key) + + +@pytest.fixture +def live_client(fake_weave: FakeWeave) -> LiveWeaveClient: + http_client = httpx.Client(transport=httpx.MockTransport(fake_weave.handler)) + return LiveWeaveClient(_settings(), http_client=http_client) + + +def test_authorization_url_uses_s256_pkce_and_the_full_scope_set(live_client: LiveWeaveClient) -> None: + url = live_client.authorization_url(state="s1", nonce="n1", code_verifier="a" * 64, redirect_uri=REDIRECT_URI) + parsed = urlparse(url) + params = parse_qs(parsed.query) + + assert url.startswith(f"{ISSUER}/oauth/authorize?") + assert params["response_type"] == ["code"] + assert params["client_id"] == [CLIENT_ID] + assert params["redirect_uri"] == [REDIRECT_URI] + assert params["scope"] == ["openid profile email groups slack"] + assert params["state"] == ["s1"] + assert params["nonce"] == ["n1"] + assert params["code_challenge_method"] == ["S256"] + assert params["code_challenge"][0] # a non-empty S256 challenge was derived from the verifier + + +def test_exchange_code_accepts_a_good_token( + fake_weave: FakeWeave, live_client: LiveWeaveClient, rsa_key: RSAKey +) -> None: + fake_weave.id_token = _id_token(rsa_key) + + identity = live_client.exchange_code(code="c1", code_verifier="v", redirect_uri=REDIRECT_URI, nonce=GOOD_NONCE) + + assert identity.sub == "PWLLIVE0001" + assert identity.name == "Lee Live" + assert identity.email == "lee@example.com" + assert identity.email_verified is True + assert identity.slack_id is None + assert identity.groups == frozenset({"ganymede:member"}) + + +def test_exchange_code_sends_the_client_secret_and_code_verifier( + fake_weave: FakeWeave, live_client: LiveWeaveClient, rsa_key: RSAKey +) -> None: + fake_weave.id_token = _id_token(rsa_key) + + live_client.exchange_code(code="c1", code_verifier="verifier-value", redirect_uri=REDIRECT_URI, nonce=GOOD_NONCE) + + token_request = next(r for r in fake_weave.requests if r.url.path == "/oauth/token") + body = token_request.read().decode() + assert "code_verifier=verifier-value" in body + assert f"client_secret={CLIENT_SECRET}" in body + + +@pytest.mark.parametrize( + "override", + [ + {"aud": "someone-elses-client"}, + {"iss": "https://not-weave.test"}, + {"nonce": "wrong-nonce"}, + {"exp": int(time.time()) - 60, "iat": int(time.time()) - 120}, + ], + ids=["wrong-aud", "wrong-iss", "wrong-nonce", "expired"], +) +def test_exchange_code_rejects_bad_claims( + fake_weave: FakeWeave, live_client: LiveWeaveClient, rsa_key: RSAKey, override: dict +) -> None: + fake_weave.id_token = _id_token(rsa_key, **override) + + with pytest.raises(WeaveAuthError): + live_client.exchange_code(code="c1", code_verifier="v", redirect_uri=REDIRECT_URI, nonce=GOOD_NONCE) + + +def test_exchange_code_rejects_a_bad_signature(fake_weave: FakeWeave, live_client: LiveWeaveClient) -> None: + # Signed by a different key than the one Weave's JWKS publishes, but with the same `kid` -- a real + # forgery attempt would look exactly like this. + forged_key = RSAKey.generate_key(2048, parameters={"kid": KID}, private=True) + fake_weave.id_token = _id_token(forged_key) + + with pytest.raises(WeaveAuthError): + live_client.exchange_code(code="c1", code_verifier="v", redirect_uri=REDIRECT_URI, nonce=GOOD_NONCE) + + +def test_exchange_code_raises_weave_auth_error_when_the_code_is_rejected( + fake_weave: FakeWeave, live_client: LiveWeaveClient +) -> None: + fake_weave.token_status = 400 + + with pytest.raises(WeaveAuthError): + live_client.exchange_code(code="bad-code", code_verifier="v", redirect_uri=REDIRECT_URI, nonce="n") + + +def test_exchange_code_raises_weave_unavailable_on_a_5xx(fake_weave: FakeWeave, live_client: LiveWeaveClient) -> None: + fake_weave.token_status = 500 + + with pytest.raises(WeaveUnavailableError): + live_client.exchange_code(code="c1", code_verifier="v", redirect_uri=REDIRECT_URI, nonce="n") + + +def test_discovery_and_jwks_are_each_fetched_only_once( + fake_weave: FakeWeave, live_client: LiveWeaveClient, rsa_key: RSAKey +) -> None: + fake_weave.id_token = _id_token(rsa_key) + + live_client.exchange_code(code="c1", code_verifier="v", redirect_uri=REDIRECT_URI, nonce=GOOD_NONCE) + live_client.authorization_url(state="s", nonce="n", code_verifier="v", redirect_uri=REDIRECT_URI) + + discovery_hits = [r for r in fake_weave.requests if r.url.path == "/.well-known/openid-configuration"] + jwks_hits = [r for r in fake_weave.requests if r.url.path == "/oauth/discovery/keys"] + assert len(discovery_hits) == 1 + assert len(jwks_hits) == 1 + + +def test_get_user_parses_groups_and_slack_id(fake_weave: FakeWeave, live_client: LiveWeaveClient) -> None: + fake_weave.directory_users["PWLDIR0001"] = { + "sub": "PWLDIR0001", + "name": "Dee Directory", + "email": "dee@example.com", + "slack_id": "U9999", + "groups": ["ganymede:member", "ganymede:reviewer"], + "active": True, + } + + user = live_client.get_user("PWLDIR0001") + + assert user is not None + assert user.name == "Dee Directory" + assert user.slack_id == "U9999" + assert user.groups == frozenset({"ganymede:member", "ganymede:reviewer"}) + assert user.active is True + + +def test_get_user_treats_absent_or_null_slack_id_as_none(fake_weave: FakeWeave, live_client: LiveWeaveClient) -> None: + fake_weave.directory_users["PWLDIR0002"] = { + "sub": "PWLDIR0002", + "name": "Null Slack", + "email": "nullslack@example.com", + "slack_id": None, + "groups": [], + "active": True, + } + fake_weave.directory_users["PWLDIR0003"] = { + "sub": "PWLDIR0003", + "name": "No Slack Key", + "email": "noslackkey@example.com", + "groups": [], + "active": True, + } + + assert live_client.get_user("PWLDIR0002").slack_id is None # type: ignore[union-attr] + assert live_client.get_user("PWLDIR0003").slack_id is None # type: ignore[union-attr] + + +def test_get_user_returns_none_on_404(live_client: LiveWeaveClient) -> None: + assert live_client.get_user("no-such-sub") is None + + +def test_get_user_by_slack_id_parses_and_404s(fake_weave: FakeWeave, live_client: LiveWeaveClient) -> None: + fake_weave.directory_by_slack["U555"] = { + "sub": "PWLSLACK", + "name": "Slack User", + "email": "slack@example.com", + "slack_id": "U555", + "groups": [], + "active": True, + } + + found = live_client.get_user_by_slack_id("U555") + assert found is not None + assert found.sub == "PWLSLACK" + assert live_client.get_user_by_slack_id("no-such-slack-id") is None + + +def test_list_users_in_group_filters_by_group(fake_weave: FakeWeave, live_client: LiveWeaveClient) -> None: + fake_weave.directory_users["PWLG1"] = { + "sub": "PWLG1", + "name": "G1", + "email": "g1@example.com", + "groups": ["ganymede:reviewer"], + "active": True, + } + fake_weave.directory_users["PWLG2"] = { + "sub": "PWLG2", + "name": "G2", + "email": "g2@example.com", + "groups": ["ganymede:member"], + "active": True, + } + + reviewers = live_client.list_users_in_group("ganymede:reviewer") + + assert [u.sub for u in reviewers] == ["PWLG1"] + + +def test_get_user_sends_the_api_key_header(fake_weave: FakeWeave, live_client: LiveWeaveClient) -> None: + fake_weave.directory_users["PWLKEY"] = { + "sub": "PWLKEY", + "name": "Key Check", + "email": "key@example.com", + "groups": [], + "active": True, + } + + live_client.get_user("PWLKEY") + + directory_request = next(r for r in fake_weave.requests if r.url.path == "/api/v1/users/PWLKEY") + assert directory_request.headers["X-Api-Key"] == SERVICE_KEY + + +def test_get_user_result_is_cached_for_the_ttl(fake_weave: FakeWeave, live_client: LiveWeaveClient) -> None: + fake_weave.directory_users["PWLCACHE"] = { + "sub": "PWLCACHE", + "name": "Cache Me", + "email": "cache@example.com", + "groups": [], + "active": True, + } + + first = live_client.get_user("PWLCACHE") + second = live_client.get_user("PWLCACHE") + + directory_hits = [r for r in fake_weave.requests if r.url.path == "/api/v1/users/PWLCACHE"] + assert len(directory_hits) == 1 + assert first == second diff --git a/tests/weave/test_stub_client.py b/tests/weave/test_stub_client.py new file mode 100644 index 0000000..b8d6873 --- /dev/null +++ b/tests/weave/test_stub_client.py @@ -0,0 +1,73 @@ +"""`StubWeaveClient`: the bundled fixture, and the exchange/lookup behavior other tests rely on.""" + +from __future__ import annotations + +import pytest + +from krater.services.actor import GROUP_ADMIN, GROUP_MEMBER, GROUP_REVIEWER +from krater.weave.errors import WeaveAuthError +from krater.weave.stub import StubWeaveClient + + +@pytest.fixture +def stub_client() -> StubWeaveClient: + return StubWeaveClient() + + +def test_bundled_fixture_covers_every_role(stub_client: StubWeaveClient) -> None: + users = stub_client.list_all_users() + assert len(users) >= 6 + + members = [u for u in users if GROUP_MEMBER in u.groups] + reviewers = [u for u in users if GROUP_REVIEWER in u.groups] + admins = [u for u in users if GROUP_ADMIN in u.groups] + non_members = [u for u in users if not u.groups] + inactive = [u for u in users if not u.active] + + assert len(members) >= 4 # plain members + reviewers + admin all carry ganymede:member + assert len(reviewers) >= 2 + assert len(admins) >= 1 + assert len(non_members) >= 1 + assert len(inactive) >= 1 + + +def test_exchange_code_treats_the_code_as_a_sub(stub_client: StubWeaveClient) -> None: + member = next(u for u in stub_client.list_all_users() if u.groups == frozenset({GROUP_MEMBER})) + + identity = stub_client.exchange_code( + code=member.sub, code_verifier="unused", redirect_uri="http://testserver/auth/callback", nonce="unused" + ) + + assert identity.sub == member.sub + assert identity.name == member.name + assert identity.email == member.email + assert identity.groups == member.groups + + +def test_exchange_code_rejects_an_unknown_code(stub_client: StubWeaveClient) -> None: + with pytest.raises(WeaveAuthError): + stub_client.exchange_code( + code="not-a-real-sub", code_verifier="v", redirect_uri="http://testserver/auth/callback", nonce="n" + ) + + +def test_get_user_returns_none_for_an_unknown_sub(stub_client: StubWeaveClient) -> None: + assert stub_client.get_user("does-not-exist") is None + + +def test_get_user_by_slack_id_finds_a_linked_user_and_misses_otherwise(stub_client: StubWeaveClient) -> None: + linked = next(u for u in stub_client.list_all_users() if u.slack_id is not None) + + assert stub_client.get_user_by_slack_id(linked.slack_id).sub == linked.sub # type: ignore[union-attr] + assert stub_client.get_user_by_slack_id("no-such-slack-id") is None + + +def test_list_users_in_group_filters_correctly(stub_client: StubWeaveClient) -> None: + reviewers = stub_client.list_users_in_group(GROUP_REVIEWER) + assert reviewers + assert all(GROUP_REVIEWER in u.groups for u in reviewers) + + +def test_inactive_fixture_user_is_flagged(stub_client: StubWeaveClient) -> None: + inactive_user = next(u for u in stub_client.list_all_users() if not u.active) + assert stub_client.get_user(inactive_user.sub).active is False # type: ignore[union-attr] diff --git a/tests/weave/test_upsert.py b/tests/weave/test_upsert.py new file mode 100644 index 0000000..c78e67d --- /dev/null +++ b/tests/weave/test_upsert.py @@ -0,0 +1,57 @@ +"""`upsert_user_from_identity`: creates a `User` on first sign-in, updates it on the next.""" + +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from krater.models import User +from krater.services.users import upsert_user_from_identity +from krater.weave.types import WeaveIdentity + + +def test_upsert_creates_then_updates_the_same_user(db_session: Session) -> None: + first_identity = WeaveIdentity( + sub="PWLUPSERT001", + name="Original Name", + email="original@example.com", + email_verified=True, + slack_id=None, + groups=frozenset({"ganymede:member"}), + ) + + created = upsert_user_from_identity(db_session, first_identity) + db_session.commit() + + assert created.weave_sub == "PWLUPSERT001" + assert created.display_name == "Original Name" + assert created.email == "original@example.com" + assert created.slack_user_id is None + assert created.groups_cached == ["ganymede:member"] + assert created.last_login_at is not None + first_login_at = created.last_login_at + + only_row = db_session.execute(select(User).where(User.weave_sub == "PWLUPSERT001")).scalars().all() + assert len(only_row) == 1 + + second_identity = WeaveIdentity( + sub="PWLUPSERT001", + name="Updated Name", + email="updated@example.com", + email_verified=True, + slack_id="U12345", + groups=frozenset({"ganymede:member", "ganymede:reviewer"}), + ) + + updated = upsert_user_from_identity(db_session, second_identity) + db_session.commit() + + assert updated.id == created.id + assert updated.display_name == "Updated Name" + assert updated.email == "updated@example.com" + assert updated.slack_user_id == "U12345" + assert updated.groups_cached == ["ganymede:member", "ganymede:reviewer"] + assert updated.last_login_at >= first_login_at + + rows = db_session.execute(select(User).where(User.weave_sub == "PWLUPSERT001")).scalars().all() + assert len(rows) == 1 diff --git a/tests/web/test_auth.py b/tests/web/test_auth.py new file mode 100644 index 0000000..7d9d2d6 --- /dev/null +++ b/tests/web/test_auth.py @@ -0,0 +1,130 @@ +"""The stub sign-in flow end to end, and everything that should reject it.""" + +from __future__ import annotations + +from urllib.parse import parse_qs, urlparse + +from fastapi.testclient import TestClient +from sqlalchemy import select +from sqlalchemy.orm import Session + +from krater.models import User + +# Fixture subs from krater/weave/stub_users.json. +MEMBER_SUB = "PWLMEMBERONE" +NON_MEMBER_SUB = "PWLNONMEMBER" + + +def _start_login(client: TestClient, next: str | None = None) -> str: + """GET /login, and return the `state` value it put in the stub-redirect URL.""" + params = {"next": next} if next else {} + response = client.get("/login", params=params, follow_redirects=False) + assert response.status_code == 302 + location = response.headers["location"] + assert location.startswith("/auth/stub?") + return parse_qs(urlparse(location).query)["state"][0] + + +def test_full_stub_sign_in_flow(client: TestClient, db_session: Session) -> None: + # 1. /login redirects to the stub picker. + state = _start_login(client) + + # 2. The stub picker lists fixture users to click. + picker = client.get("/auth/stub", params={"state": state}) + assert picker.status_code == 200 + assert "Mia Member" in picker.text + + # 3. Picking a user posts/links to the callback with code=. + callback = client.get("/auth/callback", params={"code": MEMBER_SUB, "state": state}, follow_redirects=False) + assert callback.status_code == 302 + assert callback.headers["location"] == "/" + + user = db_session.execute(select(User).where(User.weave_sub == MEMBER_SUB)).scalar_one() + assert user.display_name == "Mia Member" + assert user.groups_cached == ["ganymede:member"] + assert user.last_login_at is not None + + # 4. The signed-in page shows the user's name and a sign-out control. + home = client.get("/") + assert home.status_code == 200 + assert "Mia Member" in home.text + assert "Sign out" in home.text + + # 5. Logging out (with a valid CSRF token, picked up from the page) clears the session. + csrf_token = home.text.split('name="csrf_token" value="')[1].split('"')[0] + logout = client.post("/logout", data={"csrf_token": csrf_token}, follow_redirects=False) + assert logout.status_code == 302 + + after_logout = client.get("/") + assert "Sign out" not in after_logout.text + assert "Sign in" in after_logout.text + + +def test_login_next_round_trips_to_the_final_redirect(client: TestClient) -> None: + state = _start_login(client, next="/somewhere/safe") + + callback = client.get("/auth/callback", params={"code": MEMBER_SUB, "state": state}, follow_redirects=False) + + assert callback.headers["location"] == "/somewhere/safe" + + +def test_callback_rejects_a_missing_state(client: TestClient) -> None: + _start_login(client) + + response = client.get("/auth/callback", params={"code": MEMBER_SUB, "state": "not-the-real-state"}) + + assert response.status_code == 400 + + +def test_callback_rejects_when_no_login_was_started(client: TestClient) -> None: + response = client.get("/auth/callback", params={"code": MEMBER_SUB, "state": "anything"}) + + assert response.status_code == 400 + + +def test_callback_rejects_a_non_member(client: TestClient, db_session: Session) -> None: + state = _start_login(client) + + response = client.get("/auth/callback", params={"code": NON_MEMBER_SUB, "state": state}) + + assert response.status_code == 403 + assert "Ganymede" in response.text + assert db_session.execute(select(User).where(User.weave_sub == NON_MEMBER_SUB)).scalar_one_or_none() is None + + +def test_logout_without_csrf_token_is_rejected(client: TestClient) -> None: + state = _start_login(client) + client.get("/auth/callback", params={"code": MEMBER_SUB, "state": state}, follow_redirects=False) + + response = client.post("/logout") + + assert response.status_code == 403 + # The session survives: still signed in. + assert "Mia Member" in client.get("/").text + + +def test_login_rejects_an_open_redirect_in_next(client: TestClient) -> None: + state = _start_login(client, next="https://evil.example/steal") + + callback = client.get("/auth/callback", params={"code": MEMBER_SUB, "state": state}, follow_redirects=False) + + # Falls back to "/" instead of honoring the attacker-controlled absolute URL. + assert callback.headers["location"] == "/" + + +def test_login_rejects_a_protocol_relative_next(client: TestClient) -> None: + state = _start_login(client, next="//evil.example/steal") + + callback = client.get("/auth/callback", params={"code": MEMBER_SUB, "state": state}, follow_redirects=False) + + assert callback.headers["location"] == "/" + + +def test_stub_routes_404_in_live_mode(client: TestClient, monkeypatch) -> None: + from krater.config import get_settings + + monkeypatch.setattr(get_settings(), "weave_mode", "live") + + response = client.get("/auth/stub") + + assert response.status_code == 404 diff --git a/tests/web/test_deps.py b/tests/web/test_deps.py new file mode 100644 index 0000000..0e2cb07 --- /dev/null +++ b/tests/web/test_deps.py @@ -0,0 +1,174 @@ +"""`fresh_actor` must re-check Weave rather than trust the session's cached groups, and `require_user` +must actually redirect signed-out requests to `/login`. +""" + +from __future__ import annotations + +from typing import Annotated + +import pytest +from fastapi import APIRouter, Depends, HTTPException +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from krater.db import get_session +from krater.models import User +from krater.services.actor import GROUP_ADMIN, GROUP_MEMBER, GROUP_REVIEWER, Actor +from krater.weave.types import WeaveUser +from krater.web.app import create_app +from krater.web.deps import current_user, fresh_actor, require_admin, require_reviewer, require_user + + +class _FakeWeaveClient: + """Answers `get_user` with whatever `WeaveUser` (or `None`) the test hands it.""" + + def __init__(self, user: WeaveUser | None) -> None: + self._user = user + + def get_user(self, sub: str) -> WeaveUser | None: + return self._user + + +def _make_request(session_data: dict) -> object: + class _FakeRequest: + session = session_data + + return _FakeRequest() + + +def _make_user(db_session: Session, *, groups_cached: list[str]) -> User: + user = User( + weave_sub="PWLDEPSTEST", + display_name="Dep Test", + email="dep@example.com", + groups_cached=groups_cached, + ) + db_session.add(user) + db_session.flush() + return user + + +def test_current_user_is_none_without_a_session(db_session: Session) -> None: + assert current_user(_make_request({}), db_session) is None + + +def test_current_user_looks_up_the_session_user_id(db_session: Session) -> None: + user = _make_user(db_session, groups_cached=[GROUP_MEMBER]) + + found = current_user(_make_request({"user_id": str(user.id)}), db_session) + + assert found is not None + assert found.id == user.id + + +def test_current_user_ignores_a_corrupt_session_value(db_session: Session) -> None: + assert current_user(_make_request({"user_id": "not-a-uuid"}), db_session) is None + + +def test_fresh_actor_uses_live_groups_not_the_session_cache(db_session: Session) -> None: + # Signed in as a reviewer, but Weave now says the reviewer group was removed. + user = _make_user(db_session, groups_cached=[GROUP_MEMBER, GROUP_REVIEWER]) + live_user = WeaveUser( + sub=user.weave_sub, + name=user.display_name, + email=user.email, + slack_id=None, + groups=frozenset({GROUP_MEMBER}), + active=True, + ) + + actor = fresh_actor(user, _FakeWeaveClient(live_user)) + + assert isinstance(actor, Actor) + assert actor.is_member + assert not actor.is_reviewer + with pytest.raises(HTTPException) as exc_info: + require_reviewer(actor) + assert exc_info.value.status_code == 403 + + +def test_fresh_actor_rejects_an_inactive_user(db_session: Session) -> None: + user = _make_user(db_session, groups_cached=[GROUP_MEMBER]) + live_user = WeaveUser( + sub=user.weave_sub, + name=user.display_name, + email=user.email, + slack_id=None, + groups=frozenset({GROUP_MEMBER}), + active=False, + ) + + with pytest.raises(HTTPException) as exc_info: + fresh_actor(user, _FakeWeaveClient(live_user)) + assert exc_info.value.status_code == 403 + + +def test_fresh_actor_rejects_a_user_no_longer_a_member(db_session: Session) -> None: + user = _make_user(db_session, groups_cached=[GROUP_MEMBER]) + live_user = WeaveUser( + sub=user.weave_sub, + name=user.display_name, + email=user.email, + slack_id=None, + groups=frozenset(), + active=True, + ) + + with pytest.raises(HTTPException): + fresh_actor(user, _FakeWeaveClient(live_user)) + + +def test_fresh_actor_rejects_a_user_unknown_to_weave(db_session: Session) -> None: + user = _make_user(db_session, groups_cached=[GROUP_MEMBER]) + + with pytest.raises(HTTPException): + fresh_actor(user, _FakeWeaveClient(None)) + + +def test_require_admin_needs_the_admin_group(db_session: Session) -> None: + user = _make_user(db_session, groups_cached=[GROUP_MEMBER, GROUP_REVIEWER]) + live_user = WeaveUser( + sub=user.weave_sub, + name=user.display_name, + email=user.email, + slack_id=None, + groups=frozenset({GROUP_MEMBER, GROUP_REVIEWER}), + active=True, + ) + actor = fresh_actor(user, _FakeWeaveClient(live_user)) + + with pytest.raises(HTTPException) as exc_info: + require_admin(actor) + assert exc_info.value.status_code == 403 + + admin_live_user = WeaveUser( + sub=user.weave_sub, + name=user.display_name, + email=user.email, + slack_id=None, + groups=frozenset({GROUP_MEMBER, GROUP_REVIEWER, GROUP_ADMIN}), + active=True, + ) + admin_actor = fresh_actor(user, _FakeWeaveClient(admin_live_user)) + assert require_admin(admin_actor) is admin_actor + + +def test_require_user_redirects_to_login_with_next_when_signed_out(db_session: Session) -> None: + """An end-to-end check (through real FastAPI dependency resolution) that `require_user` actually + produces the redirect, not just that the plain-Python-call behavior above is right.""" + app = create_app() + app.dependency_overrides[get_session] = lambda: db_session + + probe_router = APIRouter() + + @probe_router.get("/__test/require-user") + def _probe(user: Annotated[User, Depends(require_user)]) -> dict: + return {"id": str(user.id)} + + app.include_router(probe_router) + + with TestClient(app, follow_redirects=False) as test_client: + response = test_client.get("/__test/require-user?foo=bar") + + assert response.status_code == 303 + assert response.headers["location"] == "/login?next=%2F__test%2Frequire-user%3Ffoo%3Dbar" From 97df6ddbdc20c566fff667d2a2401c7517ebee3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:33:30 +0000 Subject: [PATCH 020/124] Add reviewer_groups snapshot column to reviews ApprovalPolicy.required_group must be checked against the groups a reviewer held at review time, not their current (possibly since-changed) groups. Snapshot Weave groups onto Review.reviewer_groups so approval_policy can check membership without re-deriving history. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- ...add_reviewer_groups_snapshot_to_reviews.py | 36 +++++++++++++++++++ krater/models/review.py | 7 +++- 2 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 alembic/versions/ddcdc402852d_add_reviewer_groups_snapshot_to_reviews.py diff --git a/alembic/versions/ddcdc402852d_add_reviewer_groups_snapshot_to_reviews.py b/alembic/versions/ddcdc402852d_add_reviewer_groups_snapshot_to_reviews.py new file mode 100644 index 0000000..cb75569 --- /dev/null +++ b/alembic/versions/ddcdc402852d_add_reviewer_groups_snapshot_to_reviews.py @@ -0,0 +1,36 @@ +"""add reviewer_groups snapshot to reviews + +Revision ID: ddcdc402852d +Revises: 67a1656dcb6d +Create Date: 2026-09-26 19:19:22.841148 + +Snapshots the reviewer's Weave groups onto the `Review` row at the moment they review, so +`ApprovalPolicy.required_group` can be checked against the groups the reviewer actually held at +review time rather than their current (possibly since-changed) groups. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "ddcdc402852d" +down_revision: str | Sequence[str] | None = "67a1656dcb6d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.add_column( + "reviews", + sa.Column("reviewer_groups", postgresql.ARRAY(sa.String()), server_default="{}", nullable=False), + ) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_column("reviews", "reviewer_groups") diff --git a/krater/models/review.py b/krater/models/review.py index d319b63..0bfc519 100644 --- a/krater/models/review.py +++ b/krater/models/review.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.dialects.postgresql import ARRAY, UUID from sqlalchemy.orm import Mapped, mapped_column, relationship from krater.db import Base @@ -31,6 +31,11 @@ class Review(UUIDPrimaryKeyMixin, CreatedAtMixin, Base): # Required on reject; enforced by the review service, not a DB constraint. reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True) source: Mapped[ReviewSource] = mapped_column(pg_enum(ReviewSource, name="review_source"), nullable=False) + # Snapshot of the reviewer's Weave groups *at review time*, so a later change to their groups (or to + # an ApprovalPolicy's `required_group`) can't retroactively change whether a past review counts. + reviewer_groups: Mapped[list[str]] = mapped_column( + ARRAY(sa.String), nullable=False, default=list, server_default="{}" + ) revision: Mapped[ProjectRevision] = relationship(back_populates="reviews") reviewer: Mapped[User] = relationship(back_populates="reviews") From c144785667e2035c2d3612df16a42f3bcbfc3cca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:33:36 +0000 Subject: [PATCH 021/124] Add domain errors, audit, budget, and approval-policy services - errors.py: DomainError base plus NotAllowed, InvalidState, ValidationFailed (field-keyed), NotFound. - audit.py: record() writes an AuditEvent for admin overrides. - budget.py: ceiling_cents/latest_spend_cents/remaining_cents plus the internal add_entry() every ledger write goes through. - approval_policy.py: stage_for(), applicable_policies(), is_satisfied() and an explain() helper for the UI, per the stacking/budget-tier/ required-group rules in docs/SPEC.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/services/approval_policy.py | 101 ++++ krater/services/audit.py | 34 ++ krater/services/budget.py | 68 +++ krater/services/errors.py | 36 ++ krater/services/projects.py | 738 +++++++++++++++++++++++++++++ 5 files changed, 977 insertions(+) create mode 100644 krater/services/approval_policy.py create mode 100644 krater/services/audit.py create mode 100644 krater/services/budget.py create mode 100644 krater/services/errors.py create mode 100644 krater/services/projects.py diff --git a/krater/services/approval_policy.py b/krater/services/approval_policy.py new file mode 100644 index 0000000..45b7493 --- /dev/null +++ b/krater/services/approval_policy.py @@ -0,0 +1,101 @@ +"""ApprovalPolicyService: whether a revision's reviews meet the configured `ApprovalPolicy`. + +Given a revision, this module finds the `ApprovalPolicy` rows that apply to it (by stage and budget +size) and decides whether the approvals recorded so far satisfy every one of them. Multi-approval and +reviewer-tier policies are then just new `ApprovalPolicy` rows -- no code change needed. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from krater.models import ApprovalPolicy, ApprovalStage, ProjectRevision, Review, ReviewDecision, RevisionKind + + +def stage_for(revision: ProjectRevision) -> ApprovalStage: + """The `ApprovalStage` a revision is reviewed under. + + `proposal` and `amendment` revisions both use `ApprovalStage.PROPOSAL`; `completion` revisions use + `ApprovalStage.COMPLETION`. + """ + if revision.kind == RevisionKind.COMPLETION: + return ApprovalStage.COMPLETION + return ApprovalStage.PROPOSAL + + +def applicable_policies(session: Session, revision: ProjectRevision) -> list[ApprovalPolicy]: + """The `ApprovalPolicy` rows that apply to `revision`. + + A row applies when its `stage` matches and its `min_budget_cents` is either NULL or no greater + than the revision's `budget_requested_cents`. **Every** applicable row must be satisfied (see + `is_satisfied`) -- rows aren't alternatives, they stack. + """ + stage = stage_for(revision) + stmt = sa.select(ApprovalPolicy).where( + ApprovalPolicy.stage == stage, + sa.or_( + ApprovalPolicy.min_budget_cents.is_(None), + ApprovalPolicy.min_budget_cents <= revision.budget_requested_cents, + ), + ) + return list(session.scalars(stmt).all()) + + +def _approving_review_count(session: Session, revision: ProjectRevision, policy: ApprovalPolicy | None) -> int: + """How many approving reviews on `revision` count toward `policy`. + + A review counts when the reviewer was in `policy.required_group` *at review time* (checked + against `Review.reviewer_groups`, a snapshot -- never the reviewer's current groups), or when + `policy` has no `required_group` (or is `None`, for the no-policy-rows default). + """ + stmt = ( + sa.select(sa.func.count()) + .select_from(Review) + .where(Review.revision_id == revision.id, Review.decision == ReviewDecision.APPROVE) + ) + if policy is not None and policy.required_group is not None: + stmt = stmt.where(policy.required_group == sa.any_(Review.reviewer_groups)) + return int(session.scalar(stmt) or 0) + + +def is_satisfied(session: Session, revision: ProjectRevision) -> bool: + """Whether every `ApprovalPolicy` row applicable to `revision` is currently satisfied. + + A row is satisfied when the number of qualifying approving reviews (see `_approving_review_count`) + is at least its `min_approvals`. With no applicable rows at all, the default policy is 1 approval + from any reviewer. + """ + policies = applicable_policies(session, revision) + if not policies: + return _approving_review_count(session, revision, None) >= 1 + return all(_approving_review_count(session, revision, policy) >= policy.min_approvals for policy in policies) + + +def explain(session: Session, revision: ProjectRevision) -> str: + """A short, human-readable explanation of what's still needed to approve `revision`, for the UI. + + E.g. "Needs 1 more approval." or "Needs 2 more approvals from ganymede:reviewer:senior." Returns a + "policy satisfied" message once `is_satisfied` would return `True`. + """ + policies = applicable_policies(session, revision) + if not policies: + remaining = max(0, 1 - _approving_review_count(session, revision, None)) + if remaining == 0: + return "Policy satisfied." + return f"Needs {remaining} more approval{'s' if remaining != 1 else ''}." + + unmet: list[str] = [] + for policy in policies: + remaining = max(0, policy.min_approvals - _approving_review_count(session, revision, policy)) + if remaining == 0: + continue + plural = "s" if remaining != 1 else "" + if policy.required_group: + unmet.append(f"{remaining} more approval{plural} from {policy.required_group}") + else: + unmet.append(f"{remaining} more approval{plural}") + + if not unmet: + return "Policy satisfied." + return "Needs " + "; ".join(unmet) + "." diff --git a/krater/services/audit.py b/krater/services/audit.py new file mode 100644 index 0000000..18af1ec --- /dev/null +++ b/krater/services/audit.py @@ -0,0 +1,34 @@ +"""Audit logging: one immutable `AuditEvent` row per admin override (and other notable actions).""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from krater.models import AuditEvent, Project +from krater.services.actor import Actor + + +def record( + session: Session, + actor: Actor, + action: str, + *, + project: Project | None = None, + payload: dict | None = None, + reason: str | None = None, +) -> AuditEvent: + """Write an `AuditEvent` for `action`, performed by `actor`, and return it. + + `project` is optional (some actions aren't project-scoped). `payload` is a free-form JSON-able + dict of extra context (e.g. amounts, decision, revision id). Flushes, doesn't commit. + """ + event = AuditEvent( + actor_id=actor.user.id, + action=action, + project_id=project.id if project is not None else None, + payload=payload or {}, + reason=reason, + ) + session.add(event) + session.flush() + return event diff --git a/krater/services/budget.py b/krater/services/budget.py new file mode 100644 index 0000000..ca3e236 --- /dev/null +++ b/krater/services/budget.py @@ -0,0 +1,68 @@ +"""Budget ledger arithmetic. + +A project's budget ceiling is never stored directly: it's the sum of its append-only `BudgetEntry` +rows. Spend is the latest `SpendSnapshot` estimate (or 0 if none has been taken yet). Nothing here +ever updates or deletes a ledger row. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from krater.models import BudgetEntry, BudgetEntryKind, Project, ProjectRevision, SpendSnapshot +from krater.services.actor import Actor + + +def ceiling_cents(session: Session, project: Project) -> int: + """The project's current budget ceiling: the sum of all its `BudgetEntry.amount_cents`.""" + total = session.scalar( + sa.select(sa.func.coalesce(sa.func.sum(BudgetEntry.amount_cents), 0)).where( + BudgetEntry.project_id == project.id + ) + ) + return int(total or 0) + + +def latest_spend_cents(session: Session, project: Project) -> int: + """The project's most recent estimated spend, or 0 if no `SpendSnapshot` has been taken yet.""" + latest = session.scalar( + sa.select(SpendSnapshot.estimated_spend_cents) + .where(SpendSnapshot.project_id == project.id) + .order_by(SpendSnapshot.taken_at.desc()) + .limit(1) + ) + return int(latest or 0) + + +def remaining_cents(session: Session, project: Project) -> int: + """Ceiling minus latest spend. Can be negative if spend has overrun the ceiling.""" + return ceiling_cents(session, project) - latest_spend_cents(session, project) + + +def add_entry( + session: Session, + *, + project: Project, + kind: BudgetEntryKind, + amount_cents: int, + actor: Actor, + reason: str | None = None, + revision: ProjectRevision | None = None, +) -> BudgetEntry: + """Append a `BudgetEntry` to the project's ledger and return it. Internal: flushes, doesn't commit. + + `amount_cents` is signed (negative for reclaims and budget-decreasing amendments/adjustments). + Used by `projects.py`'s approval/admin/reclaim effects; not meant to be called directly by routers. + """ + entry = BudgetEntry( + project_id=project.id, + kind=kind, + amount_cents=amount_cents, + actor_id=actor.user.id, + reason=reason, + revision_id=revision.id if revision is not None else None, + ) + session.add(entry) + session.flush() + return entry diff --git a/krater/services/errors.py b/krater/services/errors.py new file mode 100644 index 0000000..f69a212 --- /dev/null +++ b/krater/services/errors.py @@ -0,0 +1,36 @@ +"""Domain errors raised by `krater.services`. + +Services never raise anything else for expected failure modes (bad input, wrong state, missing +authorization, missing record) -- routers catch these and turn them into HTTP responses. Anything +else escaping a service is a bug. +""" + +from __future__ import annotations + + +class DomainError(Exception): + """Base class for every error raised by a Krater service.""" + + +class NotAllowed(DomainError): + """The actor isn't authorized to perform this action.""" + + +class InvalidState(DomainError): + """The action doesn't make sense given the current status/outcome of the project or revision.""" + + +class ValidationFailed(DomainError): + """The input itself is invalid, independent of authorization or state. + + Carries a mapping of field name -> message so callers (routers, forms) can attach errors to the + right field instead of showing one flat string. + """ + + def __init__(self, errors: dict[str, str]) -> None: + self.errors = errors + super().__init__("; ".join(f"{field}: {message}" for field, message in errors.items())) + + +class NotFound(DomainError): + """The requested record doesn't exist (or isn't visible to this actor).""" diff --git a/krater/services/projects.py b/krater/services/projects.py new file mode 100644 index 0000000..7e8e926 --- /dev/null +++ b/krater/services/projects.py @@ -0,0 +1,738 @@ +"""The project lifecycle: drafts, submission, amendments, completion, review decisions, admin +overrides, withdrawal, and read models for the UI. + +See `docs/SPEC.md` ("Proposal & review workflow", "Completion flow & public gallery", "Budget +handling", "Admin overrides") for the rules this module implements. + +Revision lifecycle, in brief: a revision with `submitted_at is None` is a *draft*, edited in place. +`Project.current_revision_id` always points at the newest revision (draft or submitted). Submitting a +draft freezes it (`submitted_at` set) and puts it up for review; its `outcome` stays `pending` until a +review decision (or an admin override) resolves it to `approved`/`rejected`. A project has at most one +draft revision at a time. + +When a revision is **rejected**, this module immediately opens the next draft revision for the +submitter (a copy of the rejected one) as part of the rejection effect -- except for a rejected +*amendment*, where nothing else changes and the project simply keeps its current approved ceiling and +scope (see `docs/SPEC.md` "Amend"). To try again, the submitter calls `start_amendment` again. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from datetime import UTC, datetime + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from krater.models import ( + ApprovalStage, + BudgetEntry, + BudgetEntryKind, + Project, + ProjectRevision, + ProjectStatus, + Review, + ReviewDecision, + ReviewSource, + RevisionKind, + RevisionOutcome, +) +from krater.services import approval_policy, audit, budget +from krater.services.actor import Actor +from krater.services.errors import InvalidState, NotAllowed, NotFound, ValidationFailed + +_TERMINAL_STATUSES = (ProjectStatus.COMPLETED, ProjectStatus.WITHDRAWN) + + +# -------------------------------------------------------------------------------------------------- +# Create and edit +# -------------------------------------------------------------------------------------------------- + + +def create_project( + session: Session, + actor: Actor, + *, + title: str = "", + write_up: str = "", + budget_requested_cents: int = 0, + repo_url: str | None = None, +) -> Project: + """Start a new project as a draft, owned by `actor`. Ganymede members only. + + Creates the `Project` (status `draft`) and its revision 1 (`kind=proposal`, unsubmitted, i.e. a + draft). Fields may be left at their empty defaults and filled in later via `update_draft`; nothing + is validated until `submit`. + """ + if not actor.is_member: + raise NotAllowed("Only Ganymede members may create a project.") + + project = Project(title=title, submitter_id=actor.user.id, status=ProjectStatus.DRAFT, repo_url=repo_url) + session.add(project) + session.flush() + + revision = ProjectRevision( + project_id=project.id, + number=1, + kind=RevisionKind.PROPOSAL, + write_up=write_up, + budget_requested_cents=budget_requested_cents, + submitted_at=None, + outcome=RevisionOutcome.PENDING, + ) + session.add(revision) + session.flush() + + project.current_revision = revision + session.flush() + return project + + +def update_draft( + session: Session, + actor: Actor, + *, + project: Project, + title: str | None = None, + repo_url: str | None = None, + write_up: str | None = None, + budget_requested_cents: int | None = None, + demo_url: str | None = None, + screenshot_keys: list[str] | None = None, + credited_builder_ids: list[uuid.UUID] | None = None, + tags: list[str] | None = None, +) -> Project: + """Edit the project's current draft revision in place. Submitter only. + + `title`/`repo_url` live on `Project`; every other field lives on the draft revision. Only fields + passed (non-`None`) are changed. Raises `InvalidState` if there's no draft to edit right now (the + current revision has already been submitted -- call `start_amendment`/`start_completion` first, or + wait for a review decision to open the next draft). + """ + if actor.user.id != project.submitter_id: + raise NotAllowed("Only the submitter may edit this project.") + + draft = project.current_revision + if draft is None or draft.submitted_at is not None: + raise InvalidState("This project has no draft to edit right now.") + + if title is not None: + project.title = title + if repo_url is not None: + project.repo_url = repo_url + if write_up is not None: + draft.write_up = write_up + if budget_requested_cents is not None: + draft.budget_requested_cents = budget_requested_cents + if demo_url is not None: + draft.demo_url = demo_url + if screenshot_keys is not None: + draft.screenshot_keys = list(screenshot_keys) + if credited_builder_ids is not None: + draft.credited_builder_ids = list(credited_builder_ids) + if tags is not None: + draft.tags = list(tags) + + session.flush() + return project + + +# -------------------------------------------------------------------------------------------------- +# Submit +# -------------------------------------------------------------------------------------------------- + + +def submit(session: Session, actor: Actor, *, project: Project) -> Project: + """Submit the project's draft `proposal`/`amendment` revision for review. Submitter only. + + Validates a non-empty `Project.title`, a non-empty write-up, and `budget_requested_cents > 0` + (`ValidationFailed` otherwise). Freezes the draft (`submitted_at` set) and marks any other revision + of this project still `pending` as `superseded` -- only the just-submitted revision's reviews count + from here on. A `proposal` draft must come from status `draft`/`changes_requested`, and moves the + project to `pending_review`. An `amendment` draft must come from status `approved`, and leaves the + project `approved` (it's already reviewable while the approval stands). `InvalidState` otherwise. + + Use `submit_completion` for a completion revision -- calling this on one raises `InvalidState`. + """ + if actor.user.id != project.submitter_id: + raise NotAllowed("Only the submitter may submit this project.") + + draft = project.current_revision + if draft is None or draft.submitted_at is not None: + raise InvalidState("This project has no draft to submit.") + if draft.kind is RevisionKind.COMPLETION: + raise InvalidState("Use submit_completion to submit a completion revision.") + if draft.kind is RevisionKind.AMENDMENT: + if project.status is not ProjectStatus.APPROVED: + raise InvalidState(f"Cannot submit an amendment from status {project.status.value!r}.") + elif project.status not in (ProjectStatus.DRAFT, ProjectStatus.CHANGES_REQUESTED): + raise InvalidState(f"Cannot submit a proposal from status {project.status.value!r}.") + + errors: dict[str, str] = {} + if not project.title.strip(): + errors["title"] = "Title is required." + if not draft.write_up.strip(): + errors["write_up"] = "Write-up is required." + if draft.budget_requested_cents <= 0: + errors["budget_requested_cents"] = "Requested budget must be greater than zero." + if errors: + raise ValidationFailed(errors) + + draft.submitted_at = datetime.now(UTC) + _supersede_other_pending_revisions(session, project, keep=draft) + + if draft.kind is RevisionKind.PROPOSAL: + project.status = ProjectStatus.PENDING_REVIEW + # `amendment`: the project stays `approved` while the amendment is reviewed. + + session.flush() + return project + + +def _supersede_other_pending_revisions(session: Session, project: Project, *, keep: ProjectRevision) -> None: + """Mark every revision of `project` other than `keep` that's still `pending` as `superseded`. + + In the normal flow this is a no-op (older revisions are already `rejected`/`approved` by the time a + new one is submitted), but it's the safety net `docs/SPEC.md` calls for: "a new revision marks + earlier pending ones as superseded", so only the current revision's reviews are ever counted. + """ + stmt = sa.select(ProjectRevision).where( + ProjectRevision.project_id == project.id, + ProjectRevision.id != keep.id, + ProjectRevision.outcome == RevisionOutcome.PENDING, + ) + for revision in session.scalars(stmt): + revision.outcome = RevisionOutcome.SUPERSEDED + session.flush() + + +def _next_revision_number(session: Session, project: Project) -> int: + current_max = session.scalar( + sa.select(sa.func.max(ProjectRevision.number)).where(ProjectRevision.project_id == project.id) + ) + return int(current_max or 0) + 1 + + +# -------------------------------------------------------------------------------------------------- +# Amendments +# -------------------------------------------------------------------------------------------------- + + +def start_amendment(session: Session, actor: Actor, *, project: Project) -> ProjectRevision: + """Open a new draft `amendment` revision on an `approved` project. Submitter only. + + The draft is copied from the project's approved revision (`write_up`, `budget_requested_cents`), + ready for the submitter to edit via `update_draft`. Raises `InvalidState` if the project isn't + `approved`, or if it already has an unsubmitted draft in progress. + """ + if actor.user.id != project.submitter_id: + raise NotAllowed("Only the submitter may amend this project.") + if project.status is not ProjectStatus.APPROVED: + raise InvalidState("Amendments can only be started on an approved project.") + + _ensure_no_draft_in_progress(project) + + approved = project.approved_revision + if approved is None: + raise InvalidState("This project has no approved revision to amend.") + + draft = ProjectRevision( + project_id=project.id, + number=_next_revision_number(session, project), + kind=RevisionKind.AMENDMENT, + write_up=approved.write_up, + budget_requested_cents=approved.budget_requested_cents, + submitted_at=None, + outcome=RevisionOutcome.PENDING, + ) + session.add(draft) + session.flush() + project.current_revision = draft + session.flush() + return draft + + +def _ensure_no_draft_in_progress(project: Project) -> None: + current = project.current_revision + if current is not None and current.submitted_at is None: + raise InvalidState("This project already has a draft in progress.") + + +# -------------------------------------------------------------------------------------------------- +# Completion +# -------------------------------------------------------------------------------------------------- + + +def start_completion(session: Session, actor: Actor, *, project: Project) -> ProjectRevision: + """Open a new draft `completion` revision on an `approved` project. Submitter only. + + `write_up`/`budget_requested_cents` start out copied from the approved revision; the + completion-only fields (`demo_url`, `screenshot_keys`, `credited_builder_ids`, `tags`) start empty + for the submitter to fill in via `update_draft` before calling `submit_completion`. Raises + `InvalidState` if the project isn't `approved`, or already has an unsubmitted draft in progress. + """ + if actor.user.id != project.submitter_id: + raise NotAllowed("Only the submitter may submit this project for completion.") + if project.status is not ProjectStatus.APPROVED: + raise InvalidState("Completion can only be started on an approved project.") + + _ensure_no_draft_in_progress(project) + + approved = project.approved_revision + if approved is None: + raise InvalidState("This project has no approved revision to complete.") + + draft = ProjectRevision( + project_id=project.id, + number=_next_revision_number(session, project), + kind=RevisionKind.COMPLETION, + write_up=approved.write_up, + budget_requested_cents=approved.budget_requested_cents, + submitted_at=None, + outcome=RevisionOutcome.PENDING, + ) + session.add(draft) + session.flush() + project.current_revision = draft + session.flush() + return draft + + +def submit_completion(session: Session, actor: Actor, *, project: Project) -> Project: + """Submit the project's draft completion revision for review. Submitter only. + + Validates a non-empty write-up (`ValidationFailed` otherwise). Freezes the draft and moves the + project to `pending_completion_review`. Valid from `approved` (first completion attempt) or + `completion_changes_requested` (after a completion reject reopened a draft). + """ + if actor.user.id != project.submitter_id: + raise NotAllowed("Only the submitter may submit this project's completion.") + + draft = project.current_revision + if draft is None or draft.submitted_at is not None or draft.kind is not RevisionKind.COMPLETION: + raise InvalidState("This project has no completion draft to submit.") + if project.status not in (ProjectStatus.APPROVED, ProjectStatus.COMPLETION_CHANGES_REQUESTED): + raise InvalidState(f"Cannot submit completion from status {project.status.value!r}.") + + errors: dict[str, str] = {} + if not draft.write_up.strip(): + errors["write_up"] = "Write-up is required." + if errors: + raise ValidationFailed(errors) + + draft.submitted_at = datetime.now(UTC) + _supersede_other_pending_revisions(session, project, keep=draft) + project.status = ProjectStatus.PENDING_COMPLETION_REVIEW + + session.flush() + return project + + +# -------------------------------------------------------------------------------------------------- +# Reviews +# -------------------------------------------------------------------------------------------------- + + +def record_review( + session: Session, + actor: Actor, + *, + revision: ProjectRevision, + decision: ReviewDecision, + reason: str | None = None, + source: ReviewSource, +) -> Review: + """Record a reviewer's decision on `revision`, then apply its effect if the decision resolves it. + + Reviewers only, and only on the project's current, submitted, still-`pending` revision. Rejects + self-review: `actor` may not be the project's submitter or a credited builder on the revision. One + review per reviewer per revision. `reason` is required when `decision` is `reject`. + + Snapshots `actor.groups` onto `Review.reviewer_groups`. A `reject` rejects the revision immediately + (see module docstring for what that opens up next); an `approve` applies the revision's approval + effect only once `approval_policy.is_satisfied` says the policy is now met -- otherwise the revision + just stays `pending` with one more recorded approval. + """ + project = revision.project + + if not actor.is_reviewer: + raise NotAllowed("Only reviewers may record a review decision.") + if actor.user.id == project.submitter_id: + raise NotAllowed("You cannot review your own submission.") + if actor.user.id in revision.credited_builder_ids: + raise NotAllowed("You cannot review a project that credits you as a builder.") + + if project.current_revision_id != revision.id: + raise InvalidState("Only the project's current revision can be reviewed.") + if revision.submitted_at is None: + raise InvalidState("Cannot review a draft revision.") + if revision.outcome is not RevisionOutcome.PENDING: + raise InvalidState("This revision has already been decided.") + + already_reviewed = session.scalar( + sa.select(Review.id).where(Review.revision_id == revision.id, Review.reviewer_id == actor.user.id) + ) + if already_reviewed is not None: + raise InvalidState("You have already reviewed this revision.") + + if decision is ReviewDecision.REJECT and not (reason and reason.strip()): + raise ValidationFailed({"reason": "A reason is required to reject."}) + + review = Review( + revision_id=revision.id, + reviewer_id=actor.user.id, + decision=decision, + reason=reason, + source=source, + reviewer_groups=sorted(actor.groups), + ) + session.add(review) + session.flush() + + if decision is ReviewDecision.REJECT: + _apply_reject(session, revision) + elif approval_policy.is_satisfied(session, revision): + _apply_approve(session, actor, revision) + + return review + + +def _apply_reject(session: Session, revision: ProjectRevision) -> None: + """Apply the effect of a revision being rejected (by a reviewer or an admin override).""" + revision.outcome = RevisionOutcome.REJECTED + project = revision.project + stage = approval_policy.stage_for(revision) + + if stage is ApprovalStage.COMPLETION: + project.status = ProjectStatus.COMPLETION_CHANGES_REQUESTED + _open_next_draft(session, revision) + elif revision.kind is RevisionKind.AMENDMENT: + pass # "the project stays approved... if it's rejected, nothing changes" (SPEC.md "Amend"). + else: + project.status = ProjectStatus.CHANGES_REQUESTED + _open_next_draft(session, revision) + + session.flush() + + +def _apply_approve(session: Session, actor: Actor, revision: ProjectRevision) -> None: + """Apply the effect of a revision being approved (by policy satisfaction or an admin override).""" + revision.outcome = RevisionOutcome.APPROVED + project = revision.project + stage = approval_policy.stage_for(revision) + + if stage is ApprovalStage.COMPLETION: + project.status = ProjectStatus.COMPLETED + unspent = budget.remaining_cents(session, project) + if unspent > 0: + budget.add_entry( + session, + project=project, + kind=BudgetEntryKind.RECLAIM, + amount_cents=-unspent, + actor=actor, + reason="Unspent budget reclaimed on completion.", + revision=revision, + ) + elif revision.kind is RevisionKind.AMENDMENT: + delta = revision.budget_requested_cents - budget.ceiling_cents(session, project) + project.approved_revision_id = revision.id + budget.add_entry( + session, + project=project, + kind=BudgetEntryKind.AMENDMENT, + amount_cents=delta, + actor=actor, + revision=revision, + ) + # Status stays `approved`; it never left. + else: # proposal + project.status = ProjectStatus.APPROVED + project.approved_revision_id = revision.id + budget.add_entry( + session, + project=project, + kind=BudgetEntryKind.INITIAL_APPROVAL, + amount_cents=revision.budget_requested_cents, + actor=actor, + revision=revision, + ) + + session.flush() + + +def _open_next_draft(session: Session, rejected: ProjectRevision) -> ProjectRevision: + """Open the next draft revision after `rejected`, copied from it, as the project's current one.""" + project = rejected.project + draft = ProjectRevision( + project_id=project.id, + number=_next_revision_number(session, project), + kind=rejected.kind, + write_up=rejected.write_up, + budget_requested_cents=rejected.budget_requested_cents, + demo_url=rejected.demo_url, + screenshot_keys=list(rejected.screenshot_keys), + credited_builder_ids=list(rejected.credited_builder_ids), + tags=list(rejected.tags), + submitted_at=None, + outcome=RevisionOutcome.PENDING, + ) + session.add(draft) + session.flush() + project.current_revision = draft + session.flush() + return draft + + +# -------------------------------------------------------------------------------------------------- +# Admin actions +# -------------------------------------------------------------------------------------------------- + + +def admin_decide( + session: Session, + actor: Actor, + *, + revision: ProjectRevision, + decision: ReviewDecision, + reason: str, +) -> ProjectRevision: + """Admin override: decide `revision` directly, bypassing `ApprovalPolicy` and self-review rules. + + Admin only. `reason` is required regardless of `decision`. Applies the same approve/reject effect + as a policy-satisfying reviewer decision would (see `record_review`), and writes an `AuditEvent`. + Still requires `revision` to be the project's current, submitted, still-`pending` revision. + """ + if not actor.is_admin: + raise NotAllowed("Only an admin may override a review decision.") + if not (reason and reason.strip()): + raise ValidationFailed({"reason": "A reason is required."}) + + project = revision.project + if project.current_revision_id != revision.id: + raise InvalidState("Only the project's current revision can be decided.") + if revision.submitted_at is None: + raise InvalidState("Cannot decide a draft revision.") + if revision.outcome is not RevisionOutcome.PENDING: + raise InvalidState("This revision has already been decided.") + + if decision is ReviewDecision.REJECT: + _apply_reject(session, revision) + action = "admin_reject" + else: + _apply_approve(session, actor, revision) + action = "admin_approve" + + audit.record( + session, + actor, + action, + project=project, + payload={"revision_id": str(revision.id), "decision": decision.value}, + reason=reason, + ) + return revision + + +def admin_adjust_budget( + session: Session, + actor: Actor, + *, + project: Project, + amount_cents: int, + reason: str, +) -> BudgetEntry: + """Admin override: adjust a project's budget ceiling by a signed amount. Admin only. + + Only on an `approved` or `pending_completion_review` project. `reason` is required. Raises + `ValidationFailed` if `amount_cents` would take the ceiling below zero. Writes an `AuditEvent`. + """ + if not actor.is_admin: + raise NotAllowed("Only an admin may adjust a project's budget.") + if not (reason and reason.strip()): + raise ValidationFailed({"reason": "A reason is required."}) + if project.status not in (ProjectStatus.APPROVED, ProjectStatus.PENDING_COMPLETION_REVIEW): + raise InvalidState("Budget can only be adjusted on an approved or pending-completion project.") + + if budget.ceiling_cents(session, project) + amount_cents < 0: + raise ValidationFailed({"amount_cents": "This would take the project's budget ceiling below zero."}) + + entry = budget.add_entry( + session, + project=project, + kind=BudgetEntryKind.ADMIN_ADJUSTMENT, + amount_cents=amount_cents, + actor=actor, + reason=reason, + ) + audit.record( + session, actor, "admin_adjust_budget", project=project, payload={"amount_cents": amount_cents}, reason=reason + ) + return entry + + +def reclaim_budget( + session: Session, + actor: Actor, + *, + project: Project, + amount_cents: int, + reason: str, +) -> BudgetEntry: + """Admin override: reclaim unspent budget from a project's ceiling. Admin only. + + `amount_cents` is the positive amount to reclaim; it's recorded as a negative ledger entry. + `reason` is required. Raises `ValidationFailed` if `amount_cents` isn't positive, or would take the + ceiling below zero. Writes an `AuditEvent`. + """ + if not actor.is_admin: + raise NotAllowed("Only an admin may reclaim budget.") + if not (reason and reason.strip()): + raise ValidationFailed({"reason": "A reason is required."}) + if amount_cents <= 0: + raise ValidationFailed({"amount_cents": "The amount to reclaim must be greater than zero."}) + if budget.ceiling_cents(session, project) - amount_cents < 0: + raise ValidationFailed({"amount_cents": "Cannot reclaim more than the project's current budget ceiling."}) + + entry = budget.add_entry( + session, project=project, kind=BudgetEntryKind.RECLAIM, amount_cents=-amount_cents, actor=actor, reason=reason + ) + audit.record( + session, actor, "admin_reclaim_budget", project=project, payload={"amount_cents": amount_cents}, reason=reason + ) + return entry + + +# -------------------------------------------------------------------------------------------------- +# Withdraw +# -------------------------------------------------------------------------------------------------- + + +def withdraw(session: Session, actor: Actor, *, project: Project, reason: str | None = None) -> Project: + """Withdraw `project`, by its submitter or an admin, from any non-terminal state. + + Reclaims whatever budget remains (`ceiling - latest spend`, if positive) as a negative ledger + entry. `reason` is required when an admin withdraws someone else's project (and an `AuditEvent` is + written in that case); optional for a submitter withdrawing their own project. Raises + `InvalidState` if the project is already `completed` or `withdrawn`. + """ + is_self = actor.user.id == project.submitter_id + if not is_self and not actor.is_admin: + raise NotAllowed("Only the submitter or an admin may withdraw this project.") + if project.status in _TERMINAL_STATUSES: + raise InvalidState(f"Cannot withdraw a project that is already {project.status.value!r}.") + if not is_self and not (reason and reason.strip()): + raise ValidationFailed({"reason": "A reason is required for an admin withdrawal."}) + + remaining = budget.remaining_cents(session, project) + if remaining > 0: + budget.add_entry( + session, + project=project, + kind=BudgetEntryKind.RECLAIM, + amount_cents=-remaining, + actor=actor, + reason=reason or "Reclaimed on withdrawal.", + ) + + project.status = ProjectStatus.WITHDRAWN + + if not is_self: + audit.record( + session, + actor, + "admin_withdraw", + project=project, + payload={"reclaimed_cents": max(remaining, 0)}, + reason=reason, + ) + + session.flush() + return project + + +# -------------------------------------------------------------------------------------------------- +# Queries +# -------------------------------------------------------------------------------------------------- + + +def get_project(session: Session, *, project_id: uuid.UUID) -> Project: + """Fetch a project by id, or raise `NotFound`. + + This is a plain lookup: it doesn't itself decide who may *see* the result (SPEC.md doesn't define a + single per-project view-visibility rule beyond the public gallery of `completed` projects) -- that's + left to the caller/router. + """ + project = session.get(Project, project_id) + if project is None: + raise NotFound(f"No project with id {project_id}.") + return project + + +def list_projects_for_user(session: Session, *, user_id: uuid.UUID) -> list[Project]: + """All projects submitted by `user_id`, newest first.""" + stmt = sa.select(Project).where(Project.submitter_id == user_id).order_by(Project.created_at.desc()) + return list(session.scalars(stmt).all()) + + +def review_queue(session: Session, actor: Actor) -> list[ProjectRevision]: + """The submitted, still-`pending` current revisions `actor` may review right now. + + Reviewers only. Excludes projects `actor` submitted, revisions crediting `actor` as a builder, and + revisions `actor` has already reviewed (they couldn't record another decision on it anyway). + """ + if not actor.is_reviewer: + raise NotAllowed("Only reviewers have a review queue.") + + already_reviewed = sa.exists().where(Review.revision_id == ProjectRevision.id, Review.reviewer_id == actor.user.id) + stmt = ( + sa.select(ProjectRevision) + .join(Project, Project.current_revision_id == ProjectRevision.id) + .where( + ProjectRevision.submitted_at.is_not(None), + ProjectRevision.outcome == RevisionOutcome.PENDING, + Project.submitter_id != actor.user.id, + ~(actor.user.id == sa.any_(ProjectRevision.credited_builder_ids)), + ~already_reviewed, + ) + .order_by(ProjectRevision.submitted_at) + ) + return list(session.scalars(stmt).all()) + + +@dataclass(frozen=True) +class ProjectSummary: + """A read model for a project's page: status, revisions, budget, and review-policy explanation.""" + + project: Project + status: ProjectStatus + current_revision: ProjectRevision | None + approved_revision: ProjectRevision | None + ceiling_cents: int + spend_cents: int + remaining_cents: int + #: `None` unless the current revision is submitted and still awaiting a review decision. + policy_explanation: str | None + + +def project_summary(session: Session, *, project: Project) -> ProjectSummary: + """Build a `ProjectSummary` for `project`, for display in the UI.""" + current = project.current_revision + ceiling = budget.ceiling_cents(session, project) + spend = budget.latest_spend_cents(session, project) + + policy_explanation = None + if current is not None and current.submitted_at is not None and current.outcome is RevisionOutcome.PENDING: + policy_explanation = approval_policy.explain(session, current) + + return ProjectSummary( + project=project, + status=project.status, + current_revision=current, + approved_revision=project.approved_revision, + ceiling_cents=ceiling, + spend_cents=spend, + remaining_cents=ceiling - spend, + policy_explanation=policy_explanation, + ) From a6f560b72143ae612745b9a2947f1820816f9e1c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:33:38 +0000 Subject: [PATCH 022/124] Reject backslashes and control characters in the post-login next path Browsers normalize /\evil.example to //evil.example and strip tabs and newlines, so those slipped past the // check and made next an open redirect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/web/routers/auth.py | 5 ++++- tests/web/test_auth.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/krater/web/routers/auth.py b/krater/web/routers/auth.py index 53156f5..ced101f 100644 --- a/krater/web/routers/auth.py +++ b/krater/web/routers/auth.py @@ -41,10 +41,13 @@ def _safe_next_path(path: str | None) -> str: """A local path to send the browser to after sign-in, or `/` if `path` isn't one. Rejects anything that isn't an absolute path on this host: no scheme, no netloc, no `//` (which - browsers treat as protocol-relative) -- otherwise `next` would be an open redirect. + browsers treat as protocol-relative) -- otherwise `next` would be an open redirect. Backslashes and control + characters are rejected too: browsers normalize `/\\evil.example` to `//evil.example`, and strip tabs/newlines. """ if not path or not path.startswith("/") or path.startswith("//"): return "/" + if "\\" in path or any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in path): + return "/" parsed = urlparse(path) if parsed.scheme or parsed.netloc: return "/" diff --git a/tests/web/test_auth.py b/tests/web/test_auth.py index 7d9d2d6..3a103a5 100644 --- a/tests/web/test_auth.py +++ b/tests/web/test_auth.py @@ -4,6 +4,7 @@ from urllib.parse import parse_qs, urlparse +import pytest from fastapi.testclient import TestClient from sqlalchemy import select from sqlalchemy.orm import Session @@ -120,6 +121,15 @@ def test_login_rejects_a_protocol_relative_next(client: TestClient) -> None: assert callback.headers["location"] == "/" +@pytest.mark.parametrize("next_path", ["/\\evil.example/steal", "/\t/evil.example", "/%0a/ok\n//evil.example"]) +def test_login_rejects_next_paths_browsers_turn_into_other_hosts(client: TestClient, next_path: str) -> None: + state = _start_login(client, next=next_path) + + callback = client.get("/auth/callback", params={"code": MEMBER_SUB, "state": state}, follow_redirects=False) + + assert callback.headers["location"] == "/" + + def test_stub_routes_404_in_live_mode(client: TestClient, monkeypatch) -> None: from krater.config import get_settings From 8c34061f2a4a88f9d54747c1ae6edfe64068aa35 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:33:54 +0000 Subject: [PATCH 023/124] Add tests for the project workflow, budget, and approval-policy services Covers the full happy path (proposal -> approve -> amend, including a budget decrease -> approve -> completion -> approve/reclaim), resubmit after reject with old reviews not counting, self-review (submitter and credited builder) and double-review blocks, non-reviewer rejection, multi-approval/required-group/budget-tier ApprovalPolicy rows, admin overrides with audit and budget-floor rules, withdraw/reclaim, and invalid-transition/validation errors across projects.py. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- tests/services/conftest.py | 59 +++++ tests/services/test_admin.py | 231 ++++++++++++++++++++ tests/services/test_approval_policy.py | 170 ++++++++++++++ tests/services/test_budget.py | 68 ++++++ tests/services/test_reviews.py | 202 +++++++++++++++++ tests/services/test_validation_and_state.py | 206 +++++++++++++++++ tests/services/test_workflow_happy_path.py | 119 ++++++++++ 7 files changed, 1055 insertions(+) create mode 100644 tests/services/conftest.py create mode 100644 tests/services/test_admin.py create mode 100644 tests/services/test_approval_policy.py create mode 100644 tests/services/test_budget.py create mode 100644 tests/services/test_reviews.py create mode 100644 tests/services/test_validation_and_state.py create mode 100644 tests/services/test_workflow_happy_path.py diff --git a/tests/services/conftest.py b/tests/services/conftest.py new file mode 100644 index 0000000..521a937 --- /dev/null +++ b/tests/services/conftest.py @@ -0,0 +1,59 @@ +"""Shared fixtures for `tests/services`: quick user/actor factories.""" + +from __future__ import annotations + +import itertools + +import pytest +from sqlalchemy.orm import Session + +from krater.models import User +from krater.services.actor import GROUP_ADMIN, GROUP_MEMBER, GROUP_REVIEWER, Actor + +_counter = itertools.count() + + +@pytest.fixture +def make_user(db_session: Session): + """Factory: create and flush a `User` with a unique `weave_sub`/email.""" + + def _make_user(*, display_name: str = "Test User", email: str | None = None) -> User: + n = next(_counter) + user = User( + weave_sub=f"PWLTEST{n:06d}", + display_name=display_name, + email=email or f"user{n}@example.com", + ) + db_session.add(user) + db_session.flush() + return user + + return _make_user + + +@pytest.fixture +def make_actor(make_user): + """Factory: build an `Actor` with the given groups, backed by a fresh (or supplied) `User`.""" + + def _make_actor(*, groups: frozenset[str] = frozenset(), user: User | None = None, **user_kwargs) -> Actor: + return Actor(user=user or make_user(**user_kwargs), groups=groups) + + return _make_actor + + +@pytest.fixture +def member(make_actor) -> Actor: + """A plain Ganymede member: can submit, but not review or admin-override.""" + return make_actor(groups=frozenset({GROUP_MEMBER})) + + +@pytest.fixture +def reviewer(make_actor) -> Actor: + """A Ganymede reviewer (and member).""" + return make_actor(groups=frozenset({GROUP_MEMBER, GROUP_REVIEWER})) + + +@pytest.fixture +def admin(make_actor) -> Actor: + """A Ganymede admin (and member).""" + return make_actor(groups=frozenset({GROUP_MEMBER, GROUP_ADMIN})) diff --git a/tests/services/test_admin.py b/tests/services/test_admin.py new file mode 100644 index 0000000..88ab2d6 --- /dev/null +++ b/tests/services/test_admin.py @@ -0,0 +1,231 @@ +"""Admin overrides: bypassing policy/self-review, budget adjustments, reclaims, audit, and withdraw.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.orm import Session + +from krater.models import ( + ApprovalPolicy, + ApprovalStage, + AuditEvent, + BudgetEntryKind, + ProjectStatus, + ReviewDecision, + RevisionOutcome, + SpendSnapshot, + SpendSource, +) +from krater.services import budget, projects +from krater.services.actor import GROUP_ADMIN, GROUP_MEMBER, Actor +from krater.services.errors import InvalidState, NotAllowed, ValidationFailed + + +def _submitted_project(db_session: Session, member: Actor, *, budget_requested_cents: int = 10_000): + project = projects.create_project( + db_session, member, title="Admin Test", write_up="Write-up.", budget_requested_cents=budget_requested_cents + ) + return projects.submit(db_session, member, project=project) + + +def _approved_project(db_session: Session, member: Actor, admin: Actor, *, budget_requested_cents: int = 10_000): + project = _submitted_project(db_session, member, budget_requested_cents=budget_requested_cents) + projects.admin_decide( + db_session, admin, revision=project.current_revision, decision=ReviewDecision.APPROVE, reason="ok" + ) + db_session.refresh(project) + return project + + +def test_admin_decide_bypasses_policy_and_approves_directly(db_session: Session, member: Actor, admin: Actor) -> None: + db_session.add(ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=5)) + db_session.flush() + project = _submitted_project(db_session, member) + + revision = projects.admin_decide( + db_session, + admin, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + reason="Admin override: looks fine.", + ) + + assert revision.outcome is RevisionOutcome.APPROVED + db_session.refresh(project) + assert project.status is ProjectStatus.APPROVED + assert project.approved_revision_id == revision.id + + event = db_session.query(AuditEvent).filter_by(project_id=project.id, action="admin_approve").one() + assert event.reason == "Admin override: looks fine." + assert event.actor_id == admin.user.id + + +def test_admin_decide_bypasses_self_review(db_session: Session, member: Actor) -> None: + """An admin who is also the submitter can still admin_decide their own project.""" + submitter_admin = Actor(user=member.user, groups=frozenset({GROUP_MEMBER, GROUP_ADMIN})) + project = _submitted_project(db_session, submitter_admin) + + revision = projects.admin_decide( + db_session, + submitter_admin, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + reason="Self-approved by admin.", + ) + assert revision.outcome is RevisionOutcome.APPROVED + + +def test_admin_decide_reject_opens_next_draft_and_audits(db_session: Session, member: Actor, admin: Actor) -> None: + project = _submitted_project(db_session, member) + revision = project.current_revision + + projects.admin_decide(db_session, admin, revision=revision, decision=ReviewDecision.REJECT, reason="Not yet.") + + db_session.refresh(project) + assert project.status is ProjectStatus.CHANGES_REQUESTED + assert revision.outcome is RevisionOutcome.REJECTED + assert project.current_revision.id != revision.id + + event = db_session.query(AuditEvent).filter_by(project_id=project.id, action="admin_reject").one() + assert event.reason == "Not yet." + + +def test_admin_decide_requires_a_reason(db_session: Session, member: Actor, admin: Actor) -> None: + project = _submitted_project(db_session, member) + + with pytest.raises(ValidationFailed): + projects.admin_decide( + db_session, admin, revision=project.current_revision, decision=ReviewDecision.APPROVE, reason="" + ) + + +def test_admin_decide_requires_admin(db_session: Session, member: Actor, reviewer: Actor) -> None: + project = _submitted_project(db_session, member) + + with pytest.raises(NotAllowed): + projects.admin_decide( + db_session, reviewer, revision=project.current_revision, decision=ReviewDecision.APPROVE, reason="x" + ) + + +def test_admin_adjust_budget_up_and_down(db_session: Session, member: Actor, admin: Actor) -> None: + project = _approved_project(db_session, member, admin, budget_requested_cents=10_000) + + projects.admin_adjust_budget(db_session, admin, project=project, amount_cents=5_000, reason="Extra credit.") + assert budget.ceiling_cents(db_session, project) == 15_000 + + projects.admin_adjust_budget(db_session, admin, project=project, amount_cents=-3_000, reason="Trim.") + assert budget.ceiling_cents(db_session, project) == 12_000 + + events = db_session.query(AuditEvent).filter_by(project_id=project.id, action="admin_adjust_budget").all() + assert len(events) == 2 + + +def test_admin_adjust_budget_cannot_take_ceiling_below_zero(db_session: Session, member: Actor, admin: Actor) -> None: + project = _approved_project(db_session, member, admin, budget_requested_cents=1_000) + + with pytest.raises(ValidationFailed): + projects.admin_adjust_budget(db_session, admin, project=project, amount_cents=-2_000, reason="Too much.") + + assert budget.ceiling_cents(db_session, project) == 1_000 + + +def test_admin_adjust_budget_only_on_approved_or_pending_completion( + db_session: Session, member: Actor, admin: Actor +) -> None: + project = _submitted_project(db_session, member) # status: pending_review + + with pytest.raises(InvalidState): + projects.admin_adjust_budget(db_session, admin, project=project, amount_cents=100, reason="x") + + +def test_admin_adjust_budget_requires_reason(db_session: Session, member: Actor, admin: Actor) -> None: + project = _approved_project(db_session, member, admin) + + with pytest.raises(ValidationFailed): + projects.admin_adjust_budget(db_session, admin, project=project, amount_cents=100, reason=" ") + + +def test_admin_adjust_budget_requires_admin(db_session: Session, member: Actor, admin: Actor, reviewer: Actor) -> None: + project = _approved_project(db_session, member, admin) + + with pytest.raises(NotAllowed): + projects.admin_adjust_budget(db_session, reviewer, project=project, amount_cents=100, reason="x") + + +def test_reclaim_budget_reduces_ceiling(db_session: Session, member: Actor, admin: Actor) -> None: + project = _approved_project(db_session, member, admin, budget_requested_cents=10_000) + + entry = projects.reclaim_budget(db_session, admin, project=project, amount_cents=4_000, reason="Stalled.") + + assert entry.kind is BudgetEntryKind.RECLAIM + assert entry.amount_cents == -4_000 + assert budget.ceiling_cents(db_session, project) == 6_000 + + event = db_session.query(AuditEvent).filter_by(project_id=project.id, action="admin_reclaim_budget").one() + assert event.reason == "Stalled." + + +def test_reclaim_budget_amount_must_be_positive(db_session: Session, member: Actor, admin: Actor) -> None: + project = _approved_project(db_session, member, admin, budget_requested_cents=10_000) + + with pytest.raises(ValidationFailed): + projects.reclaim_budget(db_session, admin, project=project, amount_cents=0, reason="x") + + +def test_reclaim_budget_cannot_exceed_ceiling(db_session: Session, member: Actor, admin: Actor) -> None: + project = _approved_project(db_session, member, admin, budget_requested_cents=1_000) + + with pytest.raises(ValidationFailed): + projects.reclaim_budget(db_session, admin, project=project, amount_cents=5_000, reason="x") + + +def test_withdraw_by_submitter_reclaims_remaining_budget_without_audit( + db_session: Session, member: Actor, admin: Actor +) -> None: + project = _approved_project(db_session, member, admin, budget_requested_cents=10_000) + db_session.add( + SpendSnapshot(project_id=project.id, estimated_spend_cents=2_000, source=SpendSource.SKYPILOT_COST_REPORT) + ) + db_session.flush() + + projects.withdraw(db_session, member, project=project) + + db_session.refresh(project) + assert project.status is ProjectStatus.WITHDRAWN + assert budget.ceiling_cents(db_session, project) == 2_000 + assert budget.remaining_cents(db_session, project) == 0 + # No withdrawal-related audit event: only the (unrelated) admin_approve from `_approved_project`. + assert db_session.query(AuditEvent).filter_by(project_id=project.id, action="admin_withdraw").count() == 0 + + +def test_withdraw_by_admin_writes_audit_and_requires_reason(db_session: Session, member: Actor, admin: Actor) -> None: + project = _approved_project(db_session, member, admin, budget_requested_cents=5_000) + + with pytest.raises(ValidationFailed): + projects.withdraw(db_session, admin, project=project, reason="") + + projects.withdraw(db_session, admin, project=project, reason="Inactive for 6 months.") + db_session.refresh(project) + assert project.status is ProjectStatus.WITHDRAWN + event = db_session.query(AuditEvent).filter_by(project_id=project.id, action="admin_withdraw").one() + assert event.reason == "Inactive for 6 months." + assert event.payload["reclaimed_cents"] == 5_000 + + +def test_withdraw_requires_submitter_or_admin( + db_session: Session, member: Actor, admin: Actor, reviewer: Actor +) -> None: + project = _approved_project(db_session, member, admin) + + with pytest.raises(NotAllowed): + projects.withdraw(db_session, reviewer, project=project, reason="Not yours.") + + +def test_withdraw_from_a_terminal_state_is_invalid(db_session: Session, member: Actor, admin: Actor) -> None: + project = _approved_project(db_session, member, admin) + projects.withdraw(db_session, member, project=project) + db_session.refresh(project) + + with pytest.raises(InvalidState): + projects.withdraw(db_session, member, project=project) diff --git a/tests/services/test_approval_policy.py b/tests/services/test_approval_policy.py new file mode 100644 index 0000000..e1cce79 --- /dev/null +++ b/tests/services/test_approval_policy.py @@ -0,0 +1,170 @@ +"""Tests for `krater.services.approval_policy`.""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from krater.models import ( + ApprovalPolicy, + ApprovalStage, + Project, + ProjectRevision, + ProjectStatus, + Review, + ReviewDecision, + ReviewSource, + RevisionKind, + RevisionOutcome, +) +from krater.services import approval_policy +from krater.services.actor import Actor + + +def _make_revision( + db_session: Session, + submitter: Actor, + *, + kind: RevisionKind = RevisionKind.PROPOSAL, + budget_requested_cents: int = 1_000, +) -> ProjectRevision: + project = Project(title="Policy Project", submitter_id=submitter.user.id, status=ProjectStatus.PENDING_REVIEW) + db_session.add(project) + db_session.flush() + revision = ProjectRevision( + project_id=project.id, + number=1, + kind=kind, + write_up="write-up", + budget_requested_cents=budget_requested_cents, + submitted_at=None, + outcome=RevisionOutcome.PENDING, + ) + db_session.add(revision) + db_session.flush() + project.current_revision_id = revision.id + db_session.flush() + return revision + + +def _approve(db_session: Session, revision: ProjectRevision, reviewer: Actor, *, groups: frozenset[str]) -> None: + db_session.add( + Review( + revision_id=revision.id, + reviewer_id=reviewer.user.id, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + reviewer_groups=sorted(groups), + ) + ) + db_session.flush() + + +def test_stage_for_proposal_and_amendment_is_proposal_stage(db_session: Session, member: Actor) -> None: + proposal = _make_revision(db_session, member, kind=RevisionKind.PROPOSAL) + amendment = _make_revision(db_session, member, kind=RevisionKind.AMENDMENT) + + assert approval_policy.stage_for(proposal) is ApprovalStage.PROPOSAL + assert approval_policy.stage_for(amendment) is ApprovalStage.PROPOSAL + + +def test_stage_for_completion_is_completion_stage(db_session: Session, member: Actor) -> None: + completion = _make_revision(db_session, member, kind=RevisionKind.COMPLETION) + + assert approval_policy.stage_for(completion) is ApprovalStage.COMPLETION + + +def test_no_policy_rows_defaults_to_one_approval_from_any_reviewer( + db_session: Session, member: Actor, reviewer: Actor +) -> None: + revision = _make_revision(db_session, member) + + assert approval_policy.applicable_policies(db_session, revision) == [] + assert approval_policy.is_satisfied(db_session, revision) is False + + _approve(db_session, revision, reviewer, groups=reviewer.groups) + + assert approval_policy.is_satisfied(db_session, revision) is True + + +def test_multi_approval_policy_requires_min_approvals(db_session: Session, member: Actor, make_actor) -> None: + db_session.add(ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=2)) + db_session.flush() + revision = _make_revision(db_session, member) + + reviewer_a = make_actor(groups=frozenset({"ganymede:member", "ganymede:reviewer"})) + reviewer_b = make_actor(groups=frozenset({"ganymede:member", "ganymede:reviewer"})) + + _approve(db_session, revision, reviewer_a, groups=reviewer_a.groups) + assert approval_policy.is_satisfied(db_session, revision) is False + + _approve(db_session, revision, reviewer_b, groups=reviewer_b.groups) + assert approval_policy.is_satisfied(db_session, revision) is True + + +def test_required_group_policy_only_counts_reviewers_in_that_group_at_review_time( + db_session: Session, member: Actor, make_actor +) -> None: + db_session.add( + ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=1, required_group="ganymede:reviewer:senior") + ) + db_session.flush() + revision = _make_revision(db_session, member) + + plain_reviewer = make_actor(groups=frozenset({"ganymede:member", "ganymede:reviewer"})) + senior_reviewer = make_actor(groups=frozenset({"ganymede:member", "ganymede:reviewer", "ganymede:reviewer:senior"})) + + _approve(db_session, revision, plain_reviewer, groups=plain_reviewer.groups) + assert approval_policy.is_satisfied(db_session, revision) is False + + _approve(db_session, revision, senior_reviewer, groups=senior_reviewer.groups) + assert approval_policy.is_satisfied(db_session, revision) is True + + +def test_required_group_uses_snapshot_not_current_groups(db_session: Session, member: Actor, reviewer: Actor) -> None: + """A reviewer's *current* groups changing later must not retroactively affect a past review.""" + db_session.add( + ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=1, required_group="ganymede:reviewer:senior") + ) + db_session.flush() + revision = _make_revision(db_session, member) + + # Reviewed without the senior group; the policy shouldn't count it even if `reviewer.groups` is + # (hypothetically) senior now -- what matters is what was snapshotted onto the Review row. + _approve(db_session, revision, reviewer, groups=frozenset({"ganymede:member", "ganymede:reviewer"})) + + assert approval_policy.is_satisfied(db_session, revision) is False + + +def test_budget_tier_policy_only_applies_above_its_floor(db_session: Session, member: Actor) -> None: + db_session.add(ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=1)) + db_session.add(ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_budget_cents=100_000, min_approvals=2)) + db_session.flush() + + small = _make_revision(db_session, member, budget_requested_cents=1_000) + large = _make_revision(db_session, member, budget_requested_cents=200_000) + + small_policies = approval_policy.applicable_policies(db_session, small) + large_policies = approval_policy.applicable_policies(db_session, large) + + assert len(small_policies) == 1 + assert len(large_policies) == 2 + + +def test_explain_reports_remaining_approvals(db_session: Session, member: Actor, reviewer: Actor) -> None: + revision = _make_revision(db_session, member) + + assert approval_policy.explain(db_session, revision) == "Needs 1 more approval." + + _approve(db_session, revision, reviewer, groups=reviewer.groups) + + assert approval_policy.explain(db_session, revision) == "Policy satisfied." + + +def test_explain_names_the_required_group(db_session: Session, member: Actor) -> None: + db_session.add( + ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=1, required_group="ganymede:reviewer:senior") + ) + db_session.flush() + revision = _make_revision(db_session, member) + + assert "ganymede:reviewer:senior" in approval_policy.explain(db_session, revision) diff --git a/tests/services/test_budget.py b/tests/services/test_budget.py new file mode 100644 index 0000000..6ebacb3 --- /dev/null +++ b/tests/services/test_budget.py @@ -0,0 +1,68 @@ +"""Tests for `krater.services.budget`: ledger arithmetic.""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from krater.models import BudgetEntryKind, Project, ProjectStatus, SpendSnapshot, SpendSource +from krater.services import budget +from krater.services.actor import Actor + + +def _make_project(db_session: Session, member: Actor) -> Project: + project = Project(title="Budget Project", submitter_id=member.user.id, status=ProjectStatus.APPROVED) + db_session.add(project) + db_session.flush() + return project + + +def test_ceiling_cents_is_zero_with_no_entries(db_session: Session, member: Actor) -> None: + project = _make_project(db_session, member) + + assert budget.ceiling_cents(db_session, project) == 0 + + +def test_ceiling_cents_sums_signed_entries(db_session: Session, member: Actor) -> None: + project = _make_project(db_session, member) + + budget.add_entry( + db_session, project=project, kind=BudgetEntryKind.INITIAL_APPROVAL, amount_cents=10_000, actor=member + ) + budget.add_entry(db_session, project=project, kind=BudgetEntryKind.AMENDMENT, amount_cents=-2_000, actor=member) + budget.add_entry(db_session, project=project, kind=BudgetEntryKind.ADMIN_ADJUSTMENT, amount_cents=500, actor=member) + + assert budget.ceiling_cents(db_session, project) == 8_500 + + +def test_latest_spend_cents_is_zero_with_no_snapshots(db_session: Session, member: Actor) -> None: + project = _make_project(db_session, member) + + assert budget.latest_spend_cents(db_session, project) == 0 + + +def test_latest_spend_cents_returns_the_most_recent_snapshot(db_session: Session, member: Actor) -> None: + project = _make_project(db_session, member) + + db_session.add( + SpendSnapshot(project_id=project.id, estimated_spend_cents=100, source=SpendSource.SKYPILOT_COST_REPORT) + ) + db_session.flush() + db_session.add( + SpendSnapshot(project_id=project.id, estimated_spend_cents=300, source=SpendSource.SKYPILOT_COST_REPORT) + ) + db_session.flush() + + assert budget.latest_spend_cents(db_session, project) == 300 + + +def test_remaining_cents_is_ceiling_minus_spend(db_session: Session, member: Actor) -> None: + project = _make_project(db_session, member) + budget.add_entry( + db_session, project=project, kind=BudgetEntryKind.INITIAL_APPROVAL, amount_cents=10_000, actor=member + ) + db_session.add( + SpendSnapshot(project_id=project.id, estimated_spend_cents=4_000, source=SpendSource.SKYPILOT_COST_REPORT) + ) + db_session.flush() + + assert budget.remaining_cents(db_session, project) == 6_000 diff --git a/tests/services/test_reviews.py b/tests/services/test_reviews.py new file mode 100644 index 0000000..e54ac28 --- /dev/null +++ b/tests/services/test_reviews.py @@ -0,0 +1,202 @@ +"""Review recording: self-review, double review, non-reviewer, and reject/resubmit.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.orm import Session + +from krater.models import ApprovalPolicy, ApprovalStage, ProjectStatus, ReviewDecision, ReviewSource, RevisionOutcome +from krater.services import projects +from krater.services.actor import Actor +from krater.services.errors import InvalidState, NotAllowed, ValidationFailed + + +def _submitted_project(db_session: Session, member: Actor, *, budget_requested_cents: int = 10_000): + project = projects.create_project( + db_session, + member, + title="Test Project", + write_up="Write-up.", + budget_requested_cents=budget_requested_cents, + ) + project = projects.submit(db_session, member, project=project) + return project + + +def test_non_reviewer_cannot_record_a_review(db_session: Session, member: Actor, make_actor) -> None: + project = _submitted_project(db_session, member) + plain_member = make_actor(groups=frozenset({"ganymede:member"})) + + with pytest.raises(NotAllowed): + projects.record_review( + db_session, + plain_member, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + + +def test_submitter_cannot_review_their_own_submission(db_session: Session, make_actor) -> None: + submitter_and_reviewer = make_actor(groups=frozenset({"ganymede:member", "ganymede:reviewer"})) + project = _submitted_project(db_session, submitter_and_reviewer) + + with pytest.raises(NotAllowed): + projects.record_review( + db_session, + submitter_and_reviewer, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + + +def test_credited_builder_cannot_review(db_session: Session, member: Actor, make_actor) -> None: + builder = make_actor(groups=frozenset({"ganymede:member", "ganymede:reviewer"})) + project = _submitted_project(db_session, member) + project.current_revision.credited_builder_ids = [builder.user.id] + db_session.flush() + + with pytest.raises(NotAllowed): + projects.record_review( + db_session, + builder, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + + +def test_double_review_by_the_same_reviewer_is_blocked(db_session: Session, member: Actor, reviewer: Actor) -> None: + project = _submitted_project(db_session, member) + # First approval alone would satisfy the default policy and close out the revision, so use a + # multi-approval policy to keep it open for a second decision attempt. + db_session.add(ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=2)) + db_session.flush() + + projects.record_review( + db_session, + reviewer, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + + with pytest.raises(InvalidState): + projects.record_review( + db_session, + reviewer, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + + +def test_reject_requires_a_reason(db_session: Session, member: Actor, reviewer: Actor) -> None: + project = _submitted_project(db_session, member) + + with pytest.raises(ValidationFailed): + projects.record_review( + db_session, + reviewer, + revision=project.current_revision, + decision=ReviewDecision.REJECT, + source=ReviewSource.WEB, + ) + + +def test_a_single_reject_rejects_the_revision_immediately_and_opens_the_next_draft( + db_session: Session, member: Actor, reviewer: Actor +) -> None: + project = _submitted_project(db_session, member, budget_requested_cents=10_000) + rejected_revision = project.current_revision + + projects.record_review( + db_session, + reviewer, + revision=rejected_revision, + decision=ReviewDecision.REJECT, + reason="Needs more detail.", + source=ReviewSource.WEB, + ) + + db_session.refresh(project) + assert project.status is ProjectStatus.CHANGES_REQUESTED + assert rejected_revision.outcome is RevisionOutcome.REJECTED + + new_draft = project.current_revision + assert new_draft.id != rejected_revision.id + assert new_draft.number == rejected_revision.number + 1 + assert new_draft.submitted_at is None + assert new_draft.budget_requested_cents == 10_000 + + +def test_resubmit_after_reject_does_not_count_old_reviews(db_session: Session, member: Actor, make_actor) -> None: + """A reviewer's rejection of an earlier revision must not carry over to (or block approval of) the + resubmitted one -- only the current revision's own reviews count toward the policy.""" + reviewer_a = make_actor(groups=frozenset({"ganymede:member", "ganymede:reviewer"})) + reviewer_b = make_actor(groups=frozenset({"ganymede:member", "ganymede:reviewer"})) + + project = _submitted_project(db_session, member) + first_revision = project.current_revision + + projects.record_review( + db_session, + reviewer_a, + revision=first_revision, + decision=ReviewDecision.REJECT, + reason="Not ready.", + source=ReviewSource.WEB, + ) + db_session.refresh(project) + assert project.status is ProjectStatus.CHANGES_REQUESTED + + projects.update_draft(db_session, member, project=project, write_up="Revised write-up.") + project = projects.submit(db_session, member, project=project) + second_revision = project.current_revision + assert second_revision.id != first_revision.id + + # The default policy (1 approval, any reviewer) is satisfied by reviewer_b's single approval, even + # though reviewer_a already rejected the *first* revision. + projects.record_review( + db_session, reviewer_b, revision=second_revision, decision=ReviewDecision.APPROVE, source=ReviewSource.WEB + ) + db_session.refresh(project) + assert project.status is ProjectStatus.APPROVED + assert project.approved_revision_id == second_revision.id + + +def test_cannot_review_a_non_current_revision(db_session: Session, member: Actor, reviewer: Actor) -> None: + project = _submitted_project(db_session, member) + old_revision = project.current_revision + + projects.record_review( + db_session, + reviewer, + revision=old_revision, + decision=ReviewDecision.REJECT, + reason="Needs work.", + source=ReviewSource.WEB, + ) + + with pytest.raises(InvalidState): + projects.record_review( + db_session, + reviewer, + revision=old_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + + +def test_cannot_review_a_draft_revision(db_session: Session, member: Actor, reviewer: Actor) -> None: + project = projects.create_project(db_session, member, title="T", write_up="W", budget_requested_cents=1_000) + + with pytest.raises(InvalidState): + projects.record_review( + db_session, + reviewer, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) diff --git a/tests/services/test_validation_and_state.py b/tests/services/test_validation_and_state.py new file mode 100644 index 0000000..a3f1650 --- /dev/null +++ b/tests/services/test_validation_and_state.py @@ -0,0 +1,206 @@ +"""Invalid transitions, input validation, and the read-model queries.""" + +from __future__ import annotations + +import uuid + +import pytest +from sqlalchemy.orm import Session + +from krater.models import ProjectStatus, ReviewDecision, ReviewSource, RevisionKind +from krater.services import projects +from krater.services.actor import Actor +from krater.services.errors import InvalidState, NotAllowed, NotFound, ValidationFailed + + +def test_create_project_requires_membership(db_session: Session, make_actor) -> None: + non_member = make_actor(groups=frozenset()) + + with pytest.raises(NotAllowed): + projects.create_project(db_session, non_member, title="x", write_up="y", budget_requested_cents=100) + + +def test_update_draft_requires_submitter(db_session: Session, member: Actor, make_actor) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + someone_else = make_actor(groups=frozenset({"ganymede:member"})) + + with pytest.raises(NotAllowed): + projects.update_draft(db_session, someone_else, project=project, title="hijacked") + + +def test_update_draft_fails_when_no_draft_exists(db_session: Session, member: Actor, reviewer: Actor) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + project = projects.submit(db_session, member, project=project) + + with pytest.raises(InvalidState): + projects.update_draft(db_session, member, project=project, title="too late") + + +def test_submit_validates_required_fields(db_session: Session, member: Actor) -> None: + project = projects.create_project(db_session, member) # blank title/write-up, budget 0 + + with pytest.raises(ValidationFailed) as excinfo: + projects.submit(db_session, member, project=project) + + assert set(excinfo.value.errors) == {"title", "write_up", "budget_requested_cents"} + + +def test_submit_fails_without_a_draft(db_session: Session, member: Actor, reviewer: Actor) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + project = projects.submit(db_session, member, project=project) + + with pytest.raises(InvalidState): + projects.submit(db_session, member, project=project) + + +def test_submit_on_a_completion_draft_directs_to_submit_completion( + db_session: Session, member: Actor, admin: Actor +) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + project = projects.submit(db_session, member, project=project) + projects.admin_decide( + db_session, admin, revision=project.current_revision, decision=ReviewDecision.APPROVE, reason="ok" + ) + db_session.refresh(project) + projects.start_completion(db_session, member, project=project) + + with pytest.raises(InvalidState): + projects.submit(db_session, member, project=project) + + +def test_start_amendment_requires_an_approved_project(db_session: Session, member: Actor) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + + with pytest.raises(InvalidState): + projects.start_amendment(db_session, member, project=project) + + +def test_start_completion_requires_an_approved_project(db_session: Session, member: Actor) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + + with pytest.raises(InvalidState): + projects.start_completion(db_session, member, project=project) + + +def test_start_amendment_fails_with_a_draft_already_in_progress( + db_session: Session, member: Actor, admin: Actor +) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + project = projects.submit(db_session, member, project=project) + projects.admin_decide( + db_session, admin, revision=project.current_revision, decision=ReviewDecision.APPROVE, reason="ok" + ) + db_session.refresh(project) + projects.start_amendment(db_session, member, project=project) + + with pytest.raises(InvalidState): + projects.start_amendment(db_session, member, project=project) + + +def test_submit_completion_requires_a_completion_draft(db_session: Session, member: Actor, admin: Actor) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + project = projects.submit(db_session, member, project=project) + projects.admin_decide( + db_session, admin, revision=project.current_revision, decision=ReviewDecision.APPROVE, reason="ok" + ) + db_session.refresh(project) + + with pytest.raises(InvalidState): + projects.submit_completion(db_session, member, project=project) + + +def test_get_project_not_found_raises(db_session: Session) -> None: + with pytest.raises(NotFound): + projects.get_project(db_session, project_id=uuid.uuid4()) + + +def test_get_project_returns_the_project(db_session: Session, member: Actor) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + + found = projects.get_project(db_session, project_id=project.id) + + assert found.id == project.id + + +def test_list_projects_for_user(db_session: Session, member: Actor, make_actor) -> None: + other = make_actor(groups=frozenset({"ganymede:member"})) + mine = projects.create_project(db_session, member, title="mine", write_up="y", budget_requested_cents=100) + projects.create_project(db_session, other, title="theirs", write_up="y", budget_requested_cents=100) + + result = projects.list_projects_for_user(db_session, user_id=member.user.id) + + assert [p.id for p in result] == [mine.id] + + +def test_review_queue_requires_reviewer(db_session: Session, member: Actor) -> None: + with pytest.raises(NotAllowed): + projects.review_queue(db_session, member) + + +def test_review_queue_excludes_own_projects_credited_builders_and_already_reviewed( + db_session: Session, member: Actor, reviewer: Actor, make_actor +) -> None: + other_member = make_actor(groups=frozenset({"ganymede:member"})) + + # A submission by someone else: should show up. + visible = projects.create_project( + db_session, other_member, title="visible", write_up="y", budget_requested_cents=100 + ) + visible = projects.submit(db_session, other_member, project=visible) + + # reviewer's own submission: excluded. + own = projects.create_project(db_session, reviewer, title="own", write_up="y", budget_requested_cents=100) + projects.submit(db_session, reviewer, project=own) + + # A submission crediting the reviewer as a builder: excluded. + credited = projects.create_project( + db_session, other_member, title="credited", write_up="y", budget_requested_cents=100 + ) + credited.current_revision.credited_builder_ids = [reviewer.user.id] + db_session.flush() + credited = projects.submit(db_session, other_member, project=credited) + + queue_before = {r.id for r in projects.review_queue(db_session, reviewer)} + assert visible.current_revision.id in queue_before + assert own.current_revision.id not in queue_before + assert credited.current_revision.id not in queue_before + + # Once reviewed, it drops out of the queue too. + projects.record_review( + db_session, + reviewer, + revision=visible.current_revision, + decision=ReviewDecision.REJECT, + reason="not ready", + source=ReviewSource.WEB, + ) + queue_after = {r.id for r in projects.review_queue(db_session, reviewer)} + assert visible.current_revision.id not in queue_after # rejected -> no longer pending anyway + + +def test_project_summary_reports_policy_explanation_while_pending( + db_session: Session, member: Actor, reviewer: Actor +) -> None: + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=1_000) + project = projects.submit(db_session, member, project=project) + + summary = projects.project_summary(db_session, project=project) + assert summary.status is ProjectStatus.PENDING_REVIEW + assert summary.policy_explanation == "Needs 1 more approval." + assert summary.ceiling_cents == 0 + assert summary.remaining_cents == 0 + + projects.record_review( + db_session, + reviewer, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + db_session.refresh(project) + summary = projects.project_summary(db_session, project=project) + assert summary.status is ProjectStatus.APPROVED + assert summary.policy_explanation is None + assert summary.ceiling_cents == 1_000 + assert summary.current_revision.kind is RevisionKind.PROPOSAL + assert summary.approved_revision is not None diff --git a/tests/services/test_workflow_happy_path.py b/tests/services/test_workflow_happy_path.py new file mode 100644 index 0000000..7326d8b --- /dev/null +++ b/tests/services/test_workflow_happy_path.py @@ -0,0 +1,119 @@ +"""The full happy path: proposal -> approve -> amend (decrease) -> approve -> completion -> approve +(reclaim). One end-to-end test per SPEC's "at minimum" list, exercising the budget ledger throughout. +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from krater.models import ( + BudgetEntryKind, + ProjectStatus, + ReviewDecision, + ReviewSource, + RevisionKind, + RevisionOutcome, + SpendSnapshot, + SpendSource, +) +from krater.services import budget, projects +from krater.services.actor import Actor + + +def test_happy_path_proposal_amendment_and_completion(db_session: Session, member: Actor, reviewer: Actor) -> None: + # --- Draft & submit the proposal -------------------------------------------------------- + project = projects.create_project(db_session, member, title="", write_up="", budget_requested_cents=0) + assert project.status is ProjectStatus.DRAFT + assert project.current_revision.number == 1 + assert project.current_revision.kind is RevisionKind.PROPOSAL + assert project.current_revision.submitted_at is None + + projects.update_draft( + db_session, + member, + project=project, + title="Ganymede Rover", + repo_url="https://github.com/patchworklabs/rover", + write_up="A rover for Ganymede.", + budget_requested_cents=100_000, + ) + + project = projects.submit(db_session, member, project=project) + assert project.status is ProjectStatus.PENDING_REVIEW + proposal_revision = project.current_revision + assert proposal_revision.submitted_at is not None + assert proposal_revision.outcome is RevisionOutcome.PENDING + + # --- Review & approve the proposal (default policy: 1 approval from any reviewer) ------- + review = projects.record_review( + db_session, + reviewer, + revision=proposal_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + assert review.reviewer_groups == sorted(reviewer.groups) + + db_session.refresh(project) + assert project.status is ProjectStatus.APPROVED + assert project.approved_revision_id == proposal_revision.id + assert budget.ceiling_cents(db_session, project) == 100_000 + + entries = {e.kind for e in project.budget_entries} + assert entries == {BudgetEntryKind.INITIAL_APPROVAL} + + # --- Amend: decrease the budget ----------------------------------------------------------- + amendment = projects.start_amendment(db_session, member, project=project) + assert amendment.kind is RevisionKind.AMENDMENT + assert amendment.budget_requested_cents == 100_000 # copied from the approved revision + + projects.update_draft(db_session, member, project=project, budget_requested_cents=60_000) + project = projects.submit(db_session, member, project=project) + # Amendments leave the project approved while under review. + assert project.status is ProjectStatus.APPROVED + assert amendment.submitted_at is not None + + projects.record_review( + db_session, reviewer, revision=amendment, decision=ReviewDecision.APPROVE, source=ReviewSource.WEB + ) + db_session.refresh(project) + assert project.status is ProjectStatus.APPROVED + assert project.approved_revision_id == amendment.id + # Ceiling decreased by 40,000: a BudgetEntry(amendment, -40_000) was written. + assert budget.ceiling_cents(db_session, project) == 60_000 + amendment_entry = next(e for e in project.budget_entries if e.kind is BudgetEntryKind.AMENDMENT) + assert amendment_entry.amount_cents == -40_000 + + # --- Completion, with some spend to reclaim ----------------------------------------------- + completion = projects.start_completion(db_session, member, project=project) + assert completion.kind is RevisionKind.COMPLETION + + projects.update_draft( + db_session, + member, + project=project, + demo_url="https://example.com/demo", + tags=["rover", "ganymede"], + ) + project = projects.submit_completion(db_session, member, project=project) + assert project.status is ProjectStatus.PENDING_COMPLETION_REVIEW + assert completion.submitted_at is not None + + db_session.add( + SpendSnapshot(project_id=project.id, estimated_spend_cents=35_000, source=SpendSource.SKYPILOT_COST_REPORT) + ) + db_session.flush() + + projects.record_review( + db_session, reviewer, revision=completion, decision=ReviewDecision.APPROVE, source=ReviewSource.WEB + ) + db_session.refresh(project) + assert project.status is ProjectStatus.COMPLETED + assert completion.outcome is RevisionOutcome.APPROVED + + # Ceiling was 60,000, spend was 35,000: 25,000 unspent should have been reclaimed, leaving the + # ceiling equal to spend (remaining == 0). + assert budget.ceiling_cents(db_session, project) == 35_000 + assert budget.remaining_cents(db_session, project) == 0 + reclaim_entry = next(e for e in project.budget_entries if e.kind is BudgetEntryKind.RECLAIM) + assert reclaim_entry.amount_cents == -25_000 From 162ef2e52d9bb6822be32f11918dd7d20197f762 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:43:29 +0000 Subject: [PATCH 024/124] Prevent concurrent double-reviews with a DB constraint Two concurrent approvals from the same reviewer (a double-click, or a retried Slack action) could both pass record_review's "already reviewed" SELECT before either had inserted its row. Add a unique constraint on reviews(revision_id, reviewer_id), and have record_review turn the resulting IntegrityError into the same InvalidState the pre-check normally raises, via a savepoint around just the insert. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- ...77378_add_unique_constraint_on_reviews_.py | 31 ++++++++++ krater/models/review.py | 5 ++ krater/services/projects.py | 30 +++++++-- tests/services/test_reviews.py | 61 ++++++++++++++++++- 4 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/153093277378_add_unique_constraint_on_reviews_.py diff --git a/alembic/versions/153093277378_add_unique_constraint_on_reviews_.py b/alembic/versions/153093277378_add_unique_constraint_on_reviews_.py new file mode 100644 index 0000000..145130b --- /dev/null +++ b/alembic/versions/153093277378_add_unique_constraint_on_reviews_.py @@ -0,0 +1,31 @@ +"""add unique constraint on reviews revision_id reviewer_id + +Revision ID: 153093277378 +Revises: ddcdc402852d +Create Date: 2026-09-26 19:37:31.411192 + +`record_review`'s "already reviewed" check is a SELECT followed by an INSERT, so two concurrent +approvals from the same reviewer (a double-click, or a retried Slack action) could both pass the +check before either has inserted its row. This constraint is what actually prevents the duplicate; +`record_review` turns the resulting `IntegrityError` into a friendly `InvalidState`. +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "153093277378" +down_revision: str | Sequence[str] | None = "ddcdc402852d" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.create_unique_constraint("uq_reviews_revision_id_reviewer_id", "reviews", ["revision_id", "reviewer_id"]) + + +def downgrade() -> None: + """Downgrade schema.""" + op.drop_constraint("uq_reviews_revision_id_reviewer_id", "reviews", type_="unique") diff --git a/krater/models/review.py b/krater/models/review.py index 0bfc519..7ed624d 100644 --- a/krater/models/review.py +++ b/krater/models/review.py @@ -20,6 +20,11 @@ class Review(UUIDPrimaryKeyMixin, CreatedAtMixin, Base): __tablename__ = "reviews" + # One review per reviewer per revision. Also enforced in `record_review` (via a pre-check, for a + # clean error message in the common case), but the constraint is what actually prevents two + # concurrent requests (a double-click, a retried Slack action) from both passing that check and + # inserting a second row. + __table_args__ = (sa.UniqueConstraint("revision_id", "reviewer_id", name="uq_reviews_revision_id_reviewer_id"),) revision_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), sa.ForeignKey("project_revisions.id"), nullable=False, index=True diff --git a/krater/services/projects.py b/krater/services/projects.py index 7e8e926..611bd30 100644 --- a/krater/services/projects.py +++ b/krater/services/projects.py @@ -23,6 +23,7 @@ from datetime import UTC, datetime import sqlalchemy as sa +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from krater.models import ( @@ -334,6 +335,19 @@ def submit_completion(session: Session, actor: Actor, *, project: Project) -> Pr # -------------------------------------------------------------------------------------------------- +def _has_existing_review(session: Session, *, revision_id: uuid.UUID, reviewer_id: uuid.UUID) -> bool: + """Whether `reviewer_id` already has a `Review` recorded against `revision_id`. + + A plain SELECT, split out so it's easy to simulate the race the `reviews` unique constraint (and + the `IntegrityError` handling in `record_review`) guards against: two concurrent requests that both + call this and both see `False` before either has inserted its row. + """ + return ( + session.scalar(sa.select(Review.id).where(Review.revision_id == revision_id, Review.reviewer_id == reviewer_id)) + is not None + ) + + def record_review( session: Session, actor: Actor, @@ -370,10 +384,7 @@ def record_review( if revision.outcome is not RevisionOutcome.PENDING: raise InvalidState("This revision has already been decided.") - already_reviewed = session.scalar( - sa.select(Review.id).where(Review.revision_id == revision.id, Review.reviewer_id == actor.user.id) - ) - if already_reviewed is not None: + if _has_existing_review(session, revision_id=revision.id, reviewer_id=actor.user.id): raise InvalidState("You have already reviewed this revision.") if decision is ReviewDecision.REJECT and not (reason and reason.strip()): @@ -388,7 +399,16 @@ def record_review( reviewer_groups=sorted(actor.groups), ) session.add(review) - session.flush() + try: + # A SAVEPOINT around just the insert: two concurrent requests (a double-click, a retried Slack + # action) can both pass the `_has_existing_review` check above before either has inserted its + # row. The `reviews` unique constraint is what actually stops the second one; this turns the + # resulting `IntegrityError` into the same friendly `InvalidState` the pre-check normally gives, + # without aborting the whole transaction the caller is relying on to commit. + with session.begin_nested(): + session.flush() + except IntegrityError as exc: + raise InvalidState("You have already reviewed this revision.") from exc if decision is ReviewDecision.REJECT: _apply_reject(session, revision) diff --git a/tests/services/test_reviews.py b/tests/services/test_reviews.py index e54ac28..d870084 100644 --- a/tests/services/test_reviews.py +++ b/tests/services/test_reviews.py @@ -3,9 +3,19 @@ from __future__ import annotations import pytest +import sqlalchemy as sa from sqlalchemy.orm import Session -from krater.models import ApprovalPolicy, ApprovalStage, ProjectStatus, ReviewDecision, ReviewSource, RevisionOutcome +from krater.models import ( + ApprovalPolicy, + ApprovalStage, + Project, + ProjectStatus, + Review, + ReviewDecision, + ReviewSource, + RevisionOutcome, +) from krater.services import projects from krater.services.actor import Actor from krater.services.errors import InvalidState, NotAllowed, ValidationFailed @@ -92,6 +102,55 @@ def test_double_review_by_the_same_reviewer_is_blocked(db_session: Session, memb ) +def test_concurrent_double_review_is_blocked_by_the_db_constraint( + db_session: Session, member: Actor, reviewer: Actor, monkeypatch: pytest.MonkeyPatch +) -> None: + """Simulates two concurrent requests that both pass the `_has_existing_review` pre-check before + either has inserted its row (e.g. a double-click, or a retried Slack action): the `reviews` unique + constraint is what actually stops the second insert, and `record_review` must turn the resulting + `IntegrityError` into the same `InvalidState` the pre-check normally raises. + + Mirrors how this plays out for real, across two separate requests/sessions: the first review is + committed (as the router would on success) before the "concurrent" second attempt comes in, and a + plain `session.rollback()` (as the router would do on an `InvalidState`) is enough to leave the + session usable again -- it only discards the second attempt's own (already-failed) work. + """ + # A multi-approval policy keeps the revision `pending` after the first approval, so there's still a + # decision to (attempt to) record when the "concurrent" second request comes in. + db_session.add(ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=2)) + db_session.flush() + project = _submitted_project(db_session, member) + + projects.record_review( + db_session, + reviewer, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + db_session.commit() + + monkeypatch.setattr(projects, "_has_existing_review", lambda *args, **kwargs: False) + with pytest.raises(InvalidState): + projects.record_review( + db_session, + reviewer, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + db_session.rollback() + + # The first (committed) review survived, and the session is usable again for a fresh query. + reloaded = db_session.get(Project, project.id) + assert reloaded is not None + assert reloaded.status is ProjectStatus.PENDING_REVIEW + review_count = db_session.scalar( + sa.select(sa.func.count()).select_from(Review).where(Review.revision_id == project.current_revision_id) + ) + assert review_count == 1 + + def test_reject_requires_a_reason(db_session: Session, member: Actor, reviewer: Actor) -> None: project = _submitted_project(db_session, member) From 0df332e2d4dc14ed45a7e212c349dc58a56ff54b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:03:15 +0000 Subject: [PATCH 025/124] Build the web UI: proposals, review, admin overrides, and the gallery Adds the full server-rendered UI on top of the existing services and auth: - Home (`/`): signed-out intro + gallery link; signed-in "My projects" with status badges, "New proposal", and shortcuts to the review queue (with a count) and admin for those roles. - `/projects/new`, `/projects/{id}`, `/projects/{id}/edit`: draft creation and editing (dollars entered by hand, converted to cents), the project detail page (budget, policy explanation, escaped write-up with line breaks, revision history with reviews), and every contextual action form (submit, amend, complete, withdraw, review, admin override), each hidden unless the viewer may actually use it. The service remains the real enforcement. - `/reviews` and `/admin`: the review queue and the admin overview (projects by status, the approval-policy table). - `/gallery`, `/gallery/{id}`: the public gallery of completed projects, no auth. - Domain errors map per spec: `NotAllowed` -> 403, `NotFound` -> 404 (so a project's existence never leaks to a viewer who isn't its submitter, a reviewer, or an admin), `ValidationFailed` -> the form re-rendered with field errors (422), `InvalidState` -> a flash message and a redirect back to the project. - New helpers: `web/money.py` (dollars <-> cents, tested), `web/forms.py` (tags and credited-builder-email parsing), `web/flash.py` (session-backed flash messages), `web/textfmt.py` (escape-then-`
` write-up rendering). - Tests: a `login_as` fixture and stub-user constants in tests/conftest.py, per-route happy-path and authz tests (a non-member can't create, a member can't view another's project, a non-reviewer/self-submitter can't approve, a non-admin can't adjust budget, POSTs without CSRF fail), and one HTTP-only end-to-end test through the whole workflow into the gallery. Verified manually against the running app in stub mode (create, submit, review, amend, complete, gallery) in addition to the automated tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/web/app.py | 18 +- krater/web/flash.py | 43 ++ krater/web/forms.py | 59 +++ krater/web/money.py | 61 +++ krater/web/routers/admin.py | 48 ++ krater/web/routers/gallery.py | 62 +++ krater/web/routers/pages.py | 33 +- krater/web/routers/projects.py | 610 ++++++++++++++++++++++ krater/web/routers/reviews.py | 29 + krater/web/static/css/main.css | 312 +++++++++++ krater/web/templates.py | 6 + krater/web/templates/_macros.html | 7 + krater/web/templates/admin/index.html | 66 +++ krater/web/templates/base.html | 15 +- krater/web/templates/errors/403.html | 11 + krater/web/templates/errors/404.html | 11 + krater/web/templates/gallery/detail.html | 38 ++ krater/web/templates/gallery/index.html | 28 + krater/web/templates/home.html | 40 +- krater/web/templates/projects/detail.html | 207 ++++++++ krater/web/templates/projects/edit.html | 98 ++++ krater/web/templates/projects/new.html | 49 ++ krater/web/templates/reviews/queue.html | 21 + krater/web/textfmt.py | 19 + tests/conftest.py | 39 +- tests/web/conftest.py | 98 ++++ tests/web/test_admin_page.py | 49 ++ tests/web/test_gallery.py | 87 +++ tests/web/test_money.py | 58 ++ tests/web/test_pages.py | 39 +- tests/web/test_projects.py | 435 +++++++++++++++ tests/web/test_reviews_page.py | 34 ++ tests/web/test_workflow_e2e.py | 160 ++++++ 33 files changed, 2879 insertions(+), 11 deletions(-) create mode 100644 krater/web/flash.py create mode 100644 krater/web/forms.py create mode 100644 krater/web/money.py create mode 100644 krater/web/routers/admin.py create mode 100644 krater/web/routers/gallery.py create mode 100644 krater/web/routers/projects.py create mode 100644 krater/web/routers/reviews.py create mode 100644 krater/web/templates/_macros.html create mode 100644 krater/web/templates/admin/index.html create mode 100644 krater/web/templates/errors/403.html create mode 100644 krater/web/templates/errors/404.html create mode 100644 krater/web/templates/gallery/detail.html create mode 100644 krater/web/templates/gallery/index.html create mode 100644 krater/web/templates/projects/detail.html create mode 100644 krater/web/templates/projects/edit.html create mode 100644 krater/web/templates/projects/new.html create mode 100644 krater/web/templates/reviews/queue.html create mode 100644 krater/web/textfmt.py create mode 100644 tests/web/conftest.py create mode 100644 tests/web/test_admin_page.py create mode 100644 tests/web/test_gallery.py create mode 100644 tests/web/test_money.py create mode 100644 tests/web/test_projects.py create mode 100644 tests/web/test_reviews_page.py create mode 100644 tests/web/test_workflow_e2e.py diff --git a/krater/web/app.py b/krater/web/app.py index 31e811b..2152114 100644 --- a/krater/web/app.py +++ b/krater/web/app.py @@ -7,12 +7,14 @@ from pathlib import Path -from fastapi import FastAPI +from fastapi import FastAPI, Request from starlette.middleware.sessions import SessionMiddleware from starlette.staticfiles import StaticFiles from krater.config import get_settings -from krater.web.routers import auth, pages +from krater.services.errors import NotAllowed, NotFound +from krater.web.routers import admin, auth, gallery, pages, projects, reviews +from krater.web.templates import templates STATIC_DIR = Path(__file__).parent / "static" @@ -28,5 +30,17 @@ def create_app() -> FastAPI: app.include_router(pages.router) app.include_router(auth.router) + app.include_router(projects.router) + app.include_router(reviews.router) + app.include_router(admin.router) + app.include_router(gallery.router) + + @app.exception_handler(NotAllowed) + def _handle_not_allowed(request: Request, exc: NotAllowed): + return templates.TemplateResponse(request, "errors/403.html", status_code=403) + + @app.exception_handler(NotFound) + def _handle_not_found(request: Request, exc: NotFound): + return templates.TemplateResponse(request, "errors/404.html", status_code=404) return app diff --git a/krater/web/flash.py b/krater/web/flash.py new file mode 100644 index 0000000..866263e --- /dev/null +++ b/krater/web/flash.py @@ -0,0 +1,43 @@ +"""Flash messages: a one-time message stashed in the session and shown on the next page render. + +Used for `InvalidState` ("that doesn't make sense right now") and for confirming an action succeeded, +since state-changing routes redirect (POST/redirect/GET) rather than rendering directly. +""" + +from __future__ import annotations + +from typing import Literal + +import jinja2 +from fastapi import Request +from fastapi.templating import Jinja2Templates + +_SESSION_KEY = "_flashes" + +Category = Literal["success", "error", "info"] + + +def flash(request: Request, message: str, category: Category = "info") -> None: + """Queue `message` to be shown once, on the next page this session renders.""" + messages = request.session.get(_SESSION_KEY, []) + messages.append({"message": message, "category": category}) + request.session[_SESSION_KEY] = messages + + +def pop_flashes(request: Request) -> list[dict[str, str]]: + """Return and clear every queued flash message for this session.""" + return request.session.pop(_SESSION_KEY, []) + + +@jinja2.pass_context +def _get_flashes_template_global(context: dict) -> list[dict[str, str]]: + request: Request = context["request"] + return pop_flashes(request) + + +def register_flash_template_global(templates: Jinja2Templates) -> None: + """Wire `{{ get_flashes() }}` into `templates`, bound to the current request.""" + templates.env.globals["get_flashes"] = _get_flashes_template_global + + +__all__ = ["flash", "pop_flashes", "register_flash_template_global"] diff --git a/krater/web/forms.py b/krater/web/forms.py new file mode 100644 index 0000000..95ec8a5 --- /dev/null +++ b/krater/web/forms.py @@ -0,0 +1,59 @@ +"""Small parsing helpers for form fields that aren't a plain string: comma-separated tags, and +comma-separated emails of existing Krater users (credited builders).""" + +from __future__ import annotations + +import uuid + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from krater.models import User + + +def parse_tags(raw: str) -> list[str]: + """Split a comma-separated tags field into a clean list: trimmed, empties dropped.""" + return [tag.strip() for tag in raw.split(",") if tag.strip()] + + +class UnknownEmails(ValueError): + """Raised by `parse_credited_builder_emails` when one or more emails aren't a Krater user's.""" + + def __init__(self, emails: list[str]) -> None: + self.emails = emails + super().__init__("Unknown email(s): " + ", ".join(emails)) + + +def parse_credited_builder_emails(session: Session, raw: str) -> list[uuid.UUID]: + """Parse a comma-separated list of emails into the `User.id`s of existing Krater users. + + Raises `UnknownEmails` (naming every email that doesn't match a user) rather than silently + dropping them -- the caller turns that into a field error. + """ + emails = [email.strip() for email in raw.split(",") if email.strip()] + if not emails: + return [] + + users_by_email = { + user.email.lower(): user + for user in session.scalars( + sa.select(User).where(sa.func.lower(User.email).in_([email.lower() for email in emails])) + ) + } + + unknown = [email for email in emails if email.lower() not in users_by_email] + if unknown: + raise UnknownEmails(unknown) + + # Preserve input order, de-duplicated. + seen: set[uuid.UUID] = set() + ids: list[uuid.UUID] = [] + for email in emails: + user_id = users_by_email[email.lower()].id + if user_id not in seen: + seen.add(user_id) + ids.append(user_id) + return ids + + +__all__ = ["UnknownEmails", "parse_credited_builder_emails", "parse_tags"] diff --git a/krater/web/money.py b/krater/web/money.py new file mode 100644 index 0000000..656e222 --- /dev/null +++ b/krater/web/money.py @@ -0,0 +1,61 @@ +"""Dollars <-> integer cents conversion. + +Money is stored as integer cents everywhere in the domain (see CLAUDE.md), but people type and read +dollars. This is the only place that parses a human-typed dollar string (a budget form field) into +cents, or formats cents back into a dollar string for display. +""" + +from __future__ import annotations + +import re +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation + +#: After stripping '$'/',' , this is what's left of a valid amount: an optional sign, digits, and an +#: optional 1-2 digit decimal part. +_VALID_AMOUNT = re.compile(r"^-?\d+(\.\d{1,2})?$") + + +class InvalidDollarAmount(ValueError): + """Raised by `parse_dollars` when the input string isn't a valid dollar amount.""" + + +def parse_dollars(raw: str, *, allow_negative: bool = False) -> int: + """Parse a human-entered dollar string (e.g. `"1,234.50"`) into integer cents. + + Accepts an optional leading `$`, thousands separators (`,`), and up to two decimal places. + Raises `InvalidDollarAmount` (with a message fit to show next to the field) for anything else, + including a negative amount when `allow_negative` is `False`, or a zero/blank amount. + """ + text = raw.strip().replace("$", "").replace(",", "") + if not text: + raise InvalidDollarAmount("Enter an amount.") + if not _VALID_AMOUNT.match(text): + raise InvalidDollarAmount("Enter a valid dollar amount, like 1,234.50.") + + try: + value = Decimal(text) + except InvalidOperation as exc: + raise InvalidDollarAmount("Enter a valid dollar amount, like 1,234.50.") from exc + + if not allow_negative and value < 0: + raise InvalidDollarAmount("Enter a positive amount.") + + return int((value * 100).to_integral_value(rounding=ROUND_HALF_UP)) + + +def format_cents(cents: int) -> str: + """Format integer cents as a dollar string, e.g. `-4050 -> "-$40.50"`, `100000 -> "$1,000.00"`.""" + sign = "-" if cents < 0 else "" + whole, remainder = divmod(abs(cents), 100) + return f"{sign}${whole:,}.{remainder:02d}" + + +def cents_to_input(cents: int) -> str: + """Format integer cents as a plain decimal string for prefilling a form field, e.g. `123450 -> + "1234.50"` (no `$`, no thousands separator, so it round-trips through `parse_dollars`).""" + sign = "-" if cents < 0 else "" + whole, remainder = divmod(abs(cents), 100) + return f"{sign}{whole}.{remainder:02d}" + + +__all__ = ["InvalidDollarAmount", "cents_to_input", "format_cents", "parse_dollars"] diff --git a/krater/web/routers/admin.py b/krater/web/routers/admin.py new file mode 100644 index 0000000..417763e --- /dev/null +++ b/krater/web/routers/admin.py @@ -0,0 +1,48 @@ +"""The admin overview: `/admin`, admins only -- every project grouped by status, plus a read-only +table of `ApprovalPolicy` rows.""" + +from __future__ import annotations + +from typing import Annotated + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from krater.db import get_session +from krater.models import ApprovalPolicy, Project, ProjectStatus +from krater.services import projects as project_service +from krater.services.actor import Actor +from krater.web.deps import require_admin +from krater.web.templates import templates + +router = APIRouter() + + +@router.get("/admin") +def admin_overview( + request: Request, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(require_admin)], +): + del actor # `require_admin` is the gate; nothing here is scoped to who's asking + + all_projects = list(db_session.scalars(sa.select(Project).order_by(Project.created_at.desc()))) + summaries = [project_service.project_summary(db_session, project=project) for project in all_projects] + + by_status: dict[ProjectStatus, list] = {status: [] for status in ProjectStatus} + for summary in summaries: + by_status[summary.status].append(summary) + + policies = list( + db_session.scalars(sa.select(ApprovalPolicy).order_by(ApprovalPolicy.stage, ApprovalPolicy.min_budget_cents)) + ) + + return templates.TemplateResponse( + request, + "admin/index.html", + {"by_status": by_status, "policies": policies, "statuses": list(ProjectStatus)}, + ) + + +__all__ = ["router"] diff --git a/krater/web/routers/gallery.py b/krater/web/routers/gallery.py new file mode 100644 index 0000000..fbed489 --- /dev/null +++ b/krater/web/routers/gallery.py @@ -0,0 +1,62 @@ +"""The public gallery of completed projects: `/gallery`, `/gallery/{id}`. No auth -- anything not +`completed` 404s, so an unfinished project's details never leak here.""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from krater.db import get_session +from krater.models import Project, ProjectStatus, User +from krater.services import budget +from krater.services import projects as project_service +from krater.services.errors import NotFound +from krater.web.templates import templates + +router = APIRouter() + + +@router.get("/gallery") +def gallery_index(request: Request, db_session: Annotated[Session, Depends(get_session)]): + completed = list( + db_session.scalars( + sa.select(Project).where(Project.status == ProjectStatus.COMPLETED).order_by(Project.updated_at.desc()) + ) + ) + entries = [ + { + "project": project, + "revision": project.current_revision, + "spend_cents": budget.latest_spend_cents(db_session, project), + } + for project in completed + ] + return templates.TemplateResponse(request, "gallery/index.html", {"entries": entries}) + + +@router.get("/gallery/{project_id}") +def gallery_detail(request: Request, project_id: uuid.UUID, db_session: Annotated[Session, Depends(get_session)]): + project = project_service.get_project(db_session, project_id=project_id) + if project.status is not ProjectStatus.COMPLETED: + raise NotFound(f"No completed project with id {project_id}.") + + # A completed project's `current_revision` is its approved completion revision: approving a + # completion moves the project straight to `completed` without opening another draft. + revision = project.current_revision + builders = [] + if revision is not None and revision.credited_builder_ids: + builders = list(db_session.scalars(sa.select(User).where(User.id.in_(revision.credited_builder_ids)))) + + spend_cents = budget.latest_spend_cents(db_session, project) + return templates.TemplateResponse( + request, + "gallery/detail.html", + {"project": project, "revision": revision, "builders": builders, "spend_cents": spend_cents}, + ) + + +__all__ = ["router"] diff --git a/krater/web/routers/pages.py b/krater/web/routers/pages.py index 9826c4f..8ea072d 100644 --- a/krater/web/routers/pages.py +++ b/krater/web/routers/pages.py @@ -1,4 +1,4 @@ -"""Publicly reachable pages: the placeholder home page and the health check.""" +"""Publicly reachable pages: the home page and the health check.""" from __future__ import annotations @@ -10,14 +10,41 @@ from sqlalchemy.orm import Session from krater.db import get_session +from krater.models import User +from krater.services import projects as project_service +from krater.services.actor import Actor +from krater.web.deps import current_user from krater.web.templates import templates router = APIRouter() @router.get("/", response_class=HTMLResponse) -def home(request: Request) -> HTMLResponse: - return templates.TemplateResponse(request, "home.html") +def home( + request: Request, + db_session: Annotated[Session, Depends(get_session)], + user: Annotated[User | None, Depends(current_user)], +) -> HTMLResponse: + if user is None: + return templates.TemplateResponse(request, "home.html", {"signed_in": False}) + + # Display/navigation only (which links to show, and a rough count) -- every action those links + # lead to re-checks live Weave groups via `fresh_actor` before it does anything. + actor = Actor(user=user, groups=frozenset(user.groups_cached)) + my_projects = project_service.list_projects_for_user(db_session, user_id=user.id) + review_queue_count = len(project_service.review_queue(db_session, actor)) if actor.is_reviewer else 0 + + return templates.TemplateResponse( + request, + "home.html", + { + "signed_in": True, + "my_projects": my_projects, + "is_reviewer": actor.is_reviewer, + "is_admin": actor.is_admin, + "review_queue_count": review_queue_count, + }, + ) @router.get("/healthz") diff --git a/krater/web/routers/projects.py b/krater/web/routers/projects.py new file mode 100644 index 0000000..2ccbecf --- /dev/null +++ b/krater/web/routers/projects.py @@ -0,0 +1,610 @@ +"""Project pages: create, view, edit the draft, and every contextual action form on the detail page. + +Routers are thin: every rule (who may do what, from what state) lives in `krater.services.projects`. +This module's job is parsing form input (including the dollars -> cents and email -> user-id +conversions), calling the service, and turning its domain errors into the right response -- see +`docs/SPEC.md` "Error handling" and CLAUDE.md. +""" + +from __future__ import annotations + +import uuid +from typing import Annotated + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import RedirectResponse +from sqlalchemy.orm import Session + +from krater.db import get_session +from krater.models import ( + Project, + ProjectRevision, + ProjectStatus, + ReviewDecision, + ReviewSource, + RevisionKind, + RevisionOutcome, + User, +) +from krater.services import projects as project_service +from krater.services.actor import Actor +from krater.services.errors import InvalidState, NotAllowed, NotFound, ValidationFailed +from krater.web.csrf import verify_csrf_token +from krater.web.deps import fresh_actor +from krater.web.flash import flash +from krater.web.forms import UnknownEmails, parse_credited_builder_emails, parse_tags +from krater.web.money import InvalidDollarAmount, cents_to_input, parse_dollars +from krater.web.templates import templates + +router = APIRouter() + +_TERMINAL_STATUSES = (ProjectStatus.COMPLETED, ProjectStatus.WITHDRAWN) + + +# -------------------------------------------------------------------------------------------------- +# Visibility: the submitter, reviewers and admins only -- anyone else gets a 404, not a 403, so a +# project's existence doesn't leak to members who have no relationship to it. +# -------------------------------------------------------------------------------------------------- + + +def _can_view(actor: Actor, project: Project) -> bool: + return actor.user.id == project.submitter_id or actor.is_reviewer or actor.is_admin + + +def _get_visible_project(session: Session, actor: Actor, project_id: uuid.UUID) -> Project: + project = project_service.get_project(session, project_id=project_id) + if not _can_view(actor, project): + raise NotFound(f"No project with id {project_id}.") + return project + + +def _redirect_to_project(project_id: uuid.UUID) -> RedirectResponse: + return RedirectResponse(f"/projects/{project_id}", status_code=303) + + +def _invalid_state_redirect( + db_session: Session, request: Request, project_id: uuid.UUID, exc: InvalidState +) -> RedirectResponse: + db_session.rollback() + flash(request, str(exc), "error") + return _redirect_to_project(project_id) + + +def _success_redirect(db_session: Session, request: Request, project_id: uuid.UUID, message: str) -> RedirectResponse: + db_session.commit() + flash(request, message, "success") + return _redirect_to_project(project_id) + + +# -------------------------------------------------------------------------------------------------- +# New project +# -------------------------------------------------------------------------------------------------- + + +def _new_project_values(*, title: str = "", repo_url: str = "", write_up: str = "", budget_requested: str = "") -> dict: + return {"title": title, "repo_url": repo_url, "write_up": write_up, "budget_requested": budget_requested} + + +@router.get("/projects/new") +def new_project_form(request: Request, actor: Annotated[Actor, Depends(fresh_actor)]): + del actor # membership itself is enforced by `fresh_actor`; the service checks it again + return templates.TemplateResponse(request, "projects/new.html", {"errors": {}, "values": _new_project_values()}) + + +@router.post("/projects/new", dependencies=[Depends(verify_csrf_token)]) +def create_project( + request: Request, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + title: Annotated[str, Form()] = "", + repo_url: Annotated[str, Form()] = "", + write_up: Annotated[str, Form()] = "", + budget_requested: Annotated[str, Form()] = "", +): + errors: dict[str, str] = {} + budget_cents = 0 + raw_budget = budget_requested.strip() + if raw_budget: + try: + budget_cents = parse_dollars(raw_budget) + except InvalidDollarAmount as exc: + errors["budget_requested_cents"] = str(exc) + + if errors: + values = _new_project_values( + title=title, repo_url=repo_url, write_up=write_up, budget_requested=budget_requested + ) + return templates.TemplateResponse( + request, "projects/new.html", {"errors": errors, "values": values}, status_code=422 + ) + + project = project_service.create_project( + db_session, + actor, + title=title, + write_up=write_up, + budget_requested_cents=budget_cents, + repo_url=repo_url or None, + ) + db_session.commit() + flash(request, "Draft created.", "success") + return RedirectResponse(f"/projects/{project.id}", status_code=303) + + +# -------------------------------------------------------------------------------------------------- +# Detail page: read model + which action forms this viewer may use +# -------------------------------------------------------------------------------------------------- + + +def _build_detail_context( + session: Session, + project: Project, + actor: Actor, + *, + errors: dict[str, str] | None = None, + error_form: str | None = None, + posted: dict[str, str] | None = None, +) -> dict: + summary = project_service.project_summary(session, project=project) + revisions = sorted(project.revisions, key=lambda revision: revision.number, reverse=True) + + reviewer_ids = {review.reviewer_id for revision in revisions for review in revision.reviews} + builder_ids: set[uuid.UUID] = set() + for revision in revisions: + builder_ids.update(revision.credited_builder_ids) + user_ids = reviewer_ids | builder_ids + users_by_id = ( + {user.id: user for user in session.scalars(sa.select(User).where(User.id.in_(user_ids)))} if user_ids else {} + ) + + is_submitter = actor.user.id == project.submitter_id + current = project.current_revision + has_draft = current is not None and current.submitted_at is None + current_is_pending = ( + current is not None and current.submitted_at is not None and current.outcome is RevisionOutcome.PENDING + ) + is_credited_builder = current is not None and actor.user.id in current.credited_builder_ids + + can_review = actor.is_reviewer and not is_submitter and not is_credited_builder and current_is_pending + + return { + "project": project, + "summary": summary, + "revisions": revisions, + "users_by_id": users_by_id, + "is_submitter": is_submitter, + "is_admin": actor.is_admin, + "has_draft": has_draft, + "can_edit_draft": is_submitter and has_draft, + "can_submit": is_submitter and has_draft and current.kind is not RevisionKind.COMPLETION, + "can_submit_completion": is_submitter and has_draft and current.kind is RevisionKind.COMPLETION, + "can_start_amendment": is_submitter and project.status is ProjectStatus.APPROVED and not has_draft, + "can_start_completion": is_submitter and project.status is ProjectStatus.APPROVED and not has_draft, + "can_withdraw": is_submitter and project.status not in _TERMINAL_STATUSES, + "can_review": can_review, + "can_admin_decide": actor.is_admin and current_is_pending, + "can_admin_adjust_budget": actor.is_admin + and project.status in (ProjectStatus.APPROVED, ProjectStatus.PENDING_COMPLETION_REVIEW), + "can_admin_reclaim": actor.is_admin and summary.ceiling_cents > 0, + "can_admin_withdraw": actor.is_admin and project.status not in _TERMINAL_STATUSES, + "errors": errors or {}, + "error_form": error_form, + "posted": posted or {}, + } + + +@router.get("/projects/{project_id}") +def project_detail( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], +): + project = _get_visible_project(db_session, actor, project_id) + context = _build_detail_context(db_session, project, actor) + return templates.TemplateResponse(request, "projects/detail.html", context) + + +# -------------------------------------------------------------------------------------------------- +# Edit the draft +# -------------------------------------------------------------------------------------------------- + + +def _edit_form_values(session: Session, project: Project, draft: ProjectRevision) -> dict: + builder_emails = "" + if draft.credited_builder_ids: + users = session.scalars(sa.select(User).where(User.id.in_(draft.credited_builder_ids))) + builder_emails = ", ".join(sorted(user.email for user in users)) + return { + "title": project.title, + "repo_url": project.repo_url or "", + "write_up": draft.write_up, + "budget_requested": cents_to_input(draft.budget_requested_cents), + "demo_url": draft.demo_url or "", + "tags": ", ".join(draft.tags), + "credited_builder_emails": builder_emails, + } + + +def _require_editable_draft(project: Project) -> ProjectRevision | None: + """The project's current draft, or `None` if there's nothing to edit right now.""" + draft = project.current_revision + if draft is None or draft.submitted_at is not None: + return None + return draft + + +@router.get("/projects/{project_id}/edit") +def edit_draft_form( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], +): + project = _get_visible_project(db_session, actor, project_id) + if actor.user.id != project.submitter_id: + raise NotAllowed("Only the submitter may edit this project.") + + draft = _require_editable_draft(project) + if draft is None: + flash(request, "This project has no draft to edit right now.", "error") + return _redirect_to_project(project_id) + + values = _edit_form_values(db_session, project, draft) + return templates.TemplateResponse( + request, "projects/edit.html", {"project": project, "draft": draft, "errors": {}, "values": values} + ) + + +@router.post("/projects/{project_id}/edit", dependencies=[Depends(verify_csrf_token)]) +def update_draft( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + title: Annotated[str, Form()] = "", + repo_url: Annotated[str, Form()] = "", + write_up: Annotated[str, Form()] = "", + budget_requested: Annotated[str, Form()] = "", + demo_url: Annotated[str, Form()] = "", + tags: Annotated[str, Form()] = "", + credited_builder_emails: Annotated[str, Form()] = "", +): + project = _get_visible_project(db_session, actor, project_id) + if actor.user.id != project.submitter_id: + raise NotAllowed("Only the submitter may edit this project.") + + draft = _require_editable_draft(project) + if draft is None: + flash(request, "This project has no draft to edit right now.", "error") + return _redirect_to_project(project_id) + + errors: dict[str, str] = {} + budget_cents = draft.budget_requested_cents + raw_budget = budget_requested.strip() + if raw_budget: + try: + budget_cents = parse_dollars(raw_budget) + except InvalidDollarAmount as exc: + errors["budget_requested_cents"] = str(exc) + + builder_ids = list(draft.credited_builder_ids) + if draft.kind is RevisionKind.COMPLETION: + try: + builder_ids = parse_credited_builder_emails(db_session, credited_builder_emails) + except UnknownEmails as exc: + errors["credited_builder_emails"] = f"Unknown email(s): {', '.join(exc.emails)}" + + if errors: + values = { + "title": title, + "repo_url": repo_url, + "write_up": write_up, + "budget_requested": budget_requested, + "demo_url": demo_url, + "tags": tags, + "credited_builder_emails": credited_builder_emails, + } + return templates.TemplateResponse( + request, + "projects/edit.html", + {"project": project, "draft": draft, "errors": errors, "values": values}, + status_code=422, + ) + + update_kwargs: dict = { + "title": title, + "repo_url": repo_url or None, + "write_up": write_up, + "budget_requested_cents": budget_cents, + } + if draft.kind is RevisionKind.COMPLETION: + update_kwargs.update(demo_url=demo_url or None, tags=parse_tags(tags), credited_builder_ids=builder_ids) + + project_service.update_draft(db_session, actor, project=project, **update_kwargs) + db_session.commit() + flash(request, "Draft saved.", "success") + return RedirectResponse(f"/projects/{project_id}/edit", status_code=303) + + +# -------------------------------------------------------------------------------------------------- +# Submitter actions +# -------------------------------------------------------------------------------------------------- + + +@router.post("/projects/{project_id}/submit", dependencies=[Depends(verify_csrf_token)]) +def submit_project( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], +): + project = _get_visible_project(db_session, actor, project_id) + try: + project_service.submit(db_session, actor, project=project) + except ValidationFailed as exc: + db_session.rollback() + draft = project.current_revision + values = _edit_form_values(db_session, project, draft) if draft is not None else {} + return templates.TemplateResponse( + request, + "projects/edit.html", + {"project": project, "draft": draft, "errors": exc.errors, "values": values}, + status_code=422, + ) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + return _success_redirect(db_session, request, project_id, "Project submitted for review.") + + +@router.post("/projects/{project_id}/submit-completion", dependencies=[Depends(verify_csrf_token)]) +def submit_completion( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], +): + project = _get_visible_project(db_session, actor, project_id) + try: + project_service.submit_completion(db_session, actor, project=project) + except ValidationFailed as exc: + db_session.rollback() + draft = project.current_revision + values = _edit_form_values(db_session, project, draft) if draft is not None else {} + return templates.TemplateResponse( + request, + "projects/edit.html", + {"project": project, "draft": draft, "errors": exc.errors, "values": values}, + status_code=422, + ) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + return _success_redirect(db_session, request, project_id, "Completion submitted for review.") + + +@router.post("/projects/{project_id}/amend", dependencies=[Depends(verify_csrf_token)]) +def start_amendment( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], +): + project = _get_visible_project(db_session, actor, project_id) + try: + project_service.start_amendment(db_session, actor, project=project) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + db_session.commit() + return RedirectResponse(f"/projects/{project_id}/edit", status_code=303) + + +@router.post("/projects/{project_id}/complete", dependencies=[Depends(verify_csrf_token)]) +def start_completion( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], +): + project = _get_visible_project(db_session, actor, project_id) + try: + project_service.start_completion(db_session, actor, project=project) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + db_session.commit() + return RedirectResponse(f"/projects/{project_id}/edit", status_code=303) + + +@router.post("/projects/{project_id}/withdraw", dependencies=[Depends(verify_csrf_token)]) +def withdraw_project( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + reason: Annotated[str, Form()] = "", +): + project = _get_visible_project(db_session, actor, project_id) + try: + project_service.withdraw(db_session, actor, project=project, reason=reason or None) + except ValidationFailed as exc: + db_session.rollback() + context = _build_detail_context(db_session, project, actor, errors=exc.errors, error_form="withdraw") + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + return _success_redirect(db_session, request, project_id, "Project withdrawn.") + + +# -------------------------------------------------------------------------------------------------- +# Reviewer action (project-scoped; the review *queue* lives in krater/web/routers/reviews.py) +# -------------------------------------------------------------------------------------------------- + + +@router.post("/projects/{project_id}/review", dependencies=[Depends(verify_csrf_token)]) +def record_review( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + decision: Annotated[str, Form()], + reason: Annotated[str, Form()] = "", +): + project = _get_visible_project(db_session, actor, project_id) + try: + decision_enum = ReviewDecision(decision) + except ValueError: + db_session.rollback() + context = _build_detail_context( + db_session, project, actor, errors={"decision": "Unknown decision."}, error_form="review" + ) + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + + revision = project.current_revision + try: + project_service.record_review( + db_session, actor, revision=revision, decision=decision_enum, reason=reason or None, source=ReviewSource.WEB + ) + except ValidationFailed as exc: + db_session.rollback() + context = _build_detail_context( + db_session, project, actor, errors=exc.errors, error_form="review", posted={"reason": reason} + ) + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + return _success_redirect(db_session, request, project_id, "Review recorded.") + + +# -------------------------------------------------------------------------------------------------- +# Admin actions +# -------------------------------------------------------------------------------------------------- + + +@router.post("/projects/{project_id}/admin-decide", dependencies=[Depends(verify_csrf_token)]) +def admin_decide( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + decision: Annotated[str, Form()], + reason: Annotated[str, Form()] = "", +): + project = _get_visible_project(db_session, actor, project_id) + try: + decision_enum = ReviewDecision(decision) + except ValueError: + db_session.rollback() + context = _build_detail_context( + db_session, project, actor, errors={"decision": "Unknown decision."}, error_form="admin_decide" + ) + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + + revision = project.current_revision + try: + project_service.admin_decide(db_session, actor, revision=revision, decision=decision_enum, reason=reason) + except ValidationFailed as exc: + db_session.rollback() + context = _build_detail_context( + db_session, project, actor, errors=exc.errors, error_form="admin_decide", posted={"reason": reason} + ) + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + return _success_redirect(db_session, request, project_id, "Decision recorded.") + + +@router.post("/projects/{project_id}/admin-budget", dependencies=[Depends(verify_csrf_token)]) +def admin_adjust_budget( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + amount: Annotated[str, Form()] = "", + reason: Annotated[str, Form()] = "", +): + project = _get_visible_project(db_session, actor, project_id) + errors: dict[str, str] = {} + try: + amount_cents = parse_dollars(amount, allow_negative=True) if amount.strip() else 0 + except InvalidDollarAmount as exc: + amount_cents = 0 + errors["amount_cents"] = str(exc) + + if errors: + context = _build_detail_context( + db_session, + project, + actor, + errors=errors, + error_form="admin_budget", + posted={"amount": amount, "reason": reason}, + ) + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + + try: + project_service.admin_adjust_budget( + db_session, actor, project=project, amount_cents=amount_cents, reason=reason + ) + except ValidationFailed as exc: + db_session.rollback() + context = _build_detail_context( + db_session, + project, + actor, + errors=exc.errors, + error_form="admin_budget", + posted={"amount": amount, "reason": reason}, + ) + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + return _success_redirect(db_session, request, project_id, "Budget adjusted.") + + +@router.post("/projects/{project_id}/admin-reclaim", dependencies=[Depends(verify_csrf_token)]) +def admin_reclaim_budget( + request: Request, + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + amount: Annotated[str, Form()] = "", + reason: Annotated[str, Form()] = "", +): + project = _get_visible_project(db_session, actor, project_id) + errors: dict[str, str] = {} + try: + amount_cents = parse_dollars(amount) if amount.strip() else 0 + except InvalidDollarAmount as exc: + amount_cents = 0 + errors["amount_cents"] = str(exc) + + if errors: + context = _build_detail_context( + db_session, + project, + actor, + errors=errors, + error_form="admin_reclaim", + posted={"amount": amount, "reason": reason}, + ) + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + + try: + project_service.reclaim_budget(db_session, actor, project=project, amount_cents=amount_cents, reason=reason) + except ValidationFailed as exc: + db_session.rollback() + context = _build_detail_context( + db_session, + project, + actor, + errors=exc.errors, + error_form="admin_reclaim", + posted={"amount": amount, "reason": reason}, + ) + return templates.TemplateResponse(request, "projects/detail.html", context, status_code=422) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + return _success_redirect(db_session, request, project_id, "Budget reclaimed.") + + +__all__ = ["router"] diff --git a/krater/web/routers/reviews.py b/krater/web/routers/reviews.py new file mode 100644 index 0000000..f620e5d --- /dev/null +++ b/krater/web/routers/reviews.py @@ -0,0 +1,29 @@ +"""The review queue: `/reviews`, reviewers only.""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from krater.db import get_session +from krater.services import projects as project_service +from krater.services.actor import Actor +from krater.web.deps import require_reviewer +from krater.web.templates import templates + +router = APIRouter() + + +@router.get("/reviews") +def review_queue( + request: Request, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(require_reviewer)], +): + queue = project_service.review_queue(db_session, actor) + return templates.TemplateResponse(request, "reviews/queue.html", {"queue": queue}) + + +__all__ = ["router"] diff --git a/krater/web/static/css/main.css b/krater/web/static/css/main.css index c732e66..0f715ef 100644 --- a/krater/web/static/css/main.css +++ b/krater/web/static/css/main.css @@ -156,6 +156,313 @@ a:hover { font-size: 0.9rem; } +.content > .card + .card, +.content > .flash-list + .card { + margin-top: 1.5rem; +} + +.page-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.page-header h1, +.page-header h3 { + margin: 0; +} + +/* Flash messages */ + +.flash-list { + margin-bottom: 1.5rem; +} + +.flash { + margin: 0 0 0.5rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + border: 1px solid transparent; +} + +.flash--success { + background-color: #e6f4ea; + border-color: #b7ddc3; + color: #1e4620; +} + +.flash--error { + background-color: #fbe9e7; + border-color: #f0b8b0; + color: #611a15; +} + +.flash--info { + background-color: var(--color-quilt); + border-color: var(--color-border); + color: var(--color-text); +} + +/* Buttons */ + +.button { + display: inline-block; + padding: 0.55rem 1rem; + border-radius: 0.5rem; + border: 1px solid var(--color-accent-strong); + background-color: var(--color-white); + color: var(--color-accent-strong); + font: inherit; + font-weight: 600; + text-decoration: none; + cursor: pointer; + line-height: 1.2; +} + +.button:hover { + background-color: var(--color-quilt); + color: var(--color-accent-strong); +} + +.button:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +.button--primary { + background-color: var(--color-accent-strong); + border-color: var(--color-accent-strong); + color: var(--color-white); +} + +.button--primary:hover { + background-color: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-white); +} + +.button--danger { + border-color: #a33636; + color: #a33636; +} + +.button--danger:hover { + background-color: #fbe9e7; + color: #a33636; +} + +/* Forms */ + +.form { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.form--inline { + display: inline-flex; + margin: 0; +} + +.action-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.field label { + font-weight: 600; +} + +.field input[type="text"], +.field input[type="url"], +.field textarea { + font: inherit; + padding: 0.5rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 0.4rem; + background-color: var(--color-white); + color: var(--color-text); +} + +.field input:focus-visible, +.field textarea:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 1px; +} + +.field-hint { + margin: 0; + font-size: 0.85rem; + color: var(--color-text-muted); +} + +.field-error { + margin: 0; + font-size: 0.85rem; + color: #a33636; + font-weight: 600; +} + +/* Status badges */ + +.badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 700; + background-color: var(--color-lavender); + color: var(--color-ink); + white-space: nowrap; +} + +.badge--approved, +.badge--completed, +.badge--decision-approve, +.badge--outcome-approved { + background-color: #cdeeda; + color: #1e4620; +} + +.badge--changes_requested, +.badge--completion_changes_requested, +.badge--decision-reject, +.badge--outcome-rejected { + background-color: #f6d9c8; + color: #7a3a10; +} + +.badge--withdrawn, +.badge--outcome-superseded { + background-color: #e3e1e8; + color: var(--color-text-muted); +} + +/* Lists */ + +.project-list, +.link-list, +.gallery-list, +.review-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.project-list__item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 0; + border-bottom: 1px solid var(--color-border); +} + +.gallery-list { + gap: 1.25rem; +} + +.gallery-list__item h2 { + margin: 0 0 0.25rem; + font-size: 1.1rem; +} + +.tag-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0.25rem 0; +} + +.tag { + background-color: var(--color-quilt); + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.1rem 0.6rem; + font-size: 0.8rem; +} + +/* Budget summary */ + +.budget-summary { + display: flex; + flex-wrap: wrap; + gap: 1.5rem; + margin: 1rem 0; +} + +.budget-summary div { + display: flex; + flex-direction: column; +} + +.budget-summary dt { + font-size: 0.8rem; + color: var(--color-text-muted); +} + +.budget-summary dd { + margin: 0; + font-size: 1.25rem; + font-weight: 700; + color: var(--color-accent-strong); +} + +.policy-note { + padding: 0.6rem 0.9rem; + background-color: var(--color-quilt); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + font-size: 0.9rem; +} + +.write-up { + white-space: normal; + line-height: 1.6; +} + +.revision { + padding: 1rem 0; + border-top: 1px solid var(--color-border); +} + +.revision:first-child { + border-top: none; + padding-top: 0; +} + +/* Tables */ + +.table { + width: 100%; + border-collapse: collapse; + margin: 0.5rem 0 1.5rem; + font-size: 0.9rem; +} + +.table th, +.table td { + text-align: left; + padding: 0.5rem 0.6rem; + border-bottom: 1px solid var(--color-border); +} + @media (max-width: 30rem) { .content { padding: 1.25rem var(--gutter); @@ -165,4 +472,9 @@ a:hover { padding: 1rem; border-radius: 0.5rem; } + + .table { + display: block; + overflow-x: auto; + } } diff --git a/krater/web/templates.py b/krater/web/templates.py index b80fb8a..45e89e8 100644 --- a/krater/web/templates.py +++ b/krater/web/templates.py @@ -7,8 +7,14 @@ from fastapi.templating import Jinja2Templates from krater.web.csrf import register_csrf_template_global +from krater.web.flash import register_flash_template_global +from krater.web.money import format_cents +from krater.web.textfmt import nl2br TEMPLATES_DIR = Path(__file__).parent / "templates" templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) register_csrf_template_global(templates) +register_flash_template_global(templates) +templates.env.filters["dollars"] = format_cents +templates.env.filters["nl2br"] = nl2br diff --git a/krater/web/templates/_macros.html b/krater/web/templates/_macros.html new file mode 100644 index 0000000..d87e89a --- /dev/null +++ b/krater/web/templates/_macros.html @@ -0,0 +1,7 @@ +{% macro status_badge(status) %} +{{ status.value.replace('_', ' ') | capitalize }} +{%- endmacro %} + +{% macro decision_badge(decision) %} +{{ decision.value | capitalize }} +{%- endmacro %} diff --git a/krater/web/templates/admin/index.html b/krater/web/templates/admin/index.html new file mode 100644 index 0000000..b8ed96d --- /dev/null +++ b/krater/web/templates/admin/index.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% import "_macros.html" as macros %} + +{% block title %}Admin{% endblock %} + +{% block content %} +
+

All projects

+ {% for status in statuses %} + {% set group = by_status[status] %} + {% if group %} +

{{ macros.status_badge(status) }} ({{ group | length }})

+ + + + + + + + + + + {% for summary in group %} + + + + + + + {% endfor %} + +
ProjectCeilingSpendRemaining
{{ summary.project.title or "Untitled proposal" }}{{ summary.ceiling_cents | dollars }}{{ summary.spend_cents | dollars }}{{ summary.remaining_cents | dollars }}
+ {% endif %} + {% endfor %} +
+ +
+

Approval policy

+ {% if policies %} + + + + + + + + + + + {% for policy in policies %} + + + + + + + {% endfor %} + +
StageMin budgetMin approvalsRequired group
{{ policy.stage.value | capitalize }}{{ policy.min_budget_cents | dollars if policy.min_budget_cents is not none else "Any" }}{{ policy.min_approvals }}{{ policy.required_group or "Any reviewer" }}
+ {% else %} +

+ No custom policies configured. The default applies to every stage: 1 approval, from any reviewer. +

+ {% endif %} +
+{% endblock %} diff --git a/krater/web/templates/base.html b/krater/web/templates/base.html index 1a21b60..45797c2 100644 --- a/krater/web/templates/base.html +++ b/krater/web/templates/base.html @@ -12,8 +12,7 @@ Krater -
{% block content %}{% endblock %}
+
+ {% set flashes = get_flashes() %} + {% if flashes %} +
+ {% for item in flashes %} +

{{ item.message }}

+ {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
diff --git a/krater/web/templates/errors/403.html b/krater/web/templates/errors/403.html new file mode 100644 index 0000000..cc9d717 --- /dev/null +++ b/krater/web/templates/errors/403.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Not allowed{% endblock %} + +{% block content %} +
+

Not allowed

+

You don't have permission to do that.

+

Back to Krater

+
+{% endblock %} diff --git a/krater/web/templates/errors/404.html b/krater/web/templates/errors/404.html new file mode 100644 index 0000000..8062f4a --- /dev/null +++ b/krater/web/templates/errors/404.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} + +{% block title %}Not found{% endblock %} + +{% block content %} +
+

Not found

+

There's nothing here.

+

Back to Krater

+
+{% endblock %} diff --git a/krater/web/templates/gallery/detail.html b/krater/web/templates/gallery/detail.html new file mode 100644 index 0000000..be952f5 --- /dev/null +++ b/krater/web/templates/gallery/detail.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block title %}{{ project.title or "Untitled project" }}{% endblock %} + +{% block content %} +
+

{{ project.title or "Untitled project" }}

+ + {% if revision and revision.tags %} +

+ {% for tag in revision.tags %}{{ tag }}{% endfor %} +

+ {% endif %} + +
+ {% if revision and revision.demo_url %} + View demo + {% endif %} + {% if project.repo_url %} + View repo + {% endif %} +
+ + {% if revision %} +

Write-up

+

{{ revision.write_up | nl2br }}

+ {% endif %} + + {% if builders %} +

Credited builders

+ + {% endif %} + +

≈ {{ spend_cents | dollars }} estimated compute spend

+
+{% endblock %} diff --git a/krater/web/templates/gallery/index.html b/krater/web/templates/gallery/index.html new file mode 100644 index 0000000..80b49fe --- /dev/null +++ b/krater/web/templates/gallery/index.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block title %}Gallery{% endblock %} + +{% block content %} +
+

Gallery

+

Completed Project Ganymede projects.

+ + {% if entries %} + + {% else %} +

No completed projects yet.

+ {% endif %} +
+{% endblock %} diff --git a/krater/web/templates/home.html b/krater/web/templates/home.html index 39bea52..1ece4b1 100644 --- a/krater/web/templates/home.html +++ b/krater/web/templates/home.html @@ -1,14 +1,52 @@ {% extends "base.html" %} +{% import "_macros.html" as macros %} {% block title %}Krater{% endblock %} {% block content %} +{% if not signed_in %}

Krater

The Project Ganymede portal: proposal review, compute budget allocation, and a public gallery of completed projects.

-

This page is a placeholder while the rest of the portal is built.

+

Sign in with Weave

+

Browse the public gallery

+{% else %} +
+ + + {% if my_projects %} + + {% else %} +

You haven't started a proposal yet.

+ {% endif %} +
+ +{% if is_reviewer or is_admin %} +
+

Shortcuts

+ +
+{% endif %} +{% endif %} {% endblock %} diff --git a/krater/web/templates/projects/detail.html b/krater/web/templates/projects/detail.html new file mode 100644 index 0000000..c978343 --- /dev/null +++ b/krater/web/templates/projects/detail.html @@ -0,0 +1,207 @@ +{% extends "base.html" %} +{% import "_macros.html" as macros %} + +{% block title %}{{ project.title or "Untitled proposal" }}{% endblock %} + +{% block content %} +
+ + {% if project.repo_url %} +

{{ project.repo_url }}

+ {% endif %} + +
+
Ceiling
{{ summary.ceiling_cents | dollars }}
+
Estimated spend
{{ summary.spend_cents | dollars }}
+
Remaining
{{ summary.remaining_cents | dollars }}
+
+ + {% if summary.policy_explanation %} +

{{ summary.policy_explanation }}

+ {% endif %} + + {% if summary.current_revision %} +

{{ "Draft" if not summary.current_revision.submitted_at else "Write-up" }}

+

{{ summary.current_revision.write_up | nl2br }}

+ {% endif %} +
+ +{% if is_submitter and (can_edit_draft or can_submit or can_submit_completion or can_start_amendment or can_start_completion or can_withdraw) %} +
+

Your actions

+
+ {% if can_edit_draft %} + Edit draft + {% endif %} + {% if can_submit %} +
+ + +
+ {% endif %} + {% if can_submit_completion %} +
+ + +
+ {% endif %} + {% if can_start_amendment %} +
+ + +
+ {% endif %} + {% if can_start_completion %} +
+ + +
+ {% endif %} +
+ {% if can_withdraw %} +
+ + +
+ {% endif %} +
+{% endif %} + +{% if can_review %} +
+

Review this proposal

+
+
+ + + +
+
+
+ + +
+ + + {% if error_form == "review" and errors.reason %}

{{ errors.reason }}

{% endif %} +
+ +
+
+{% endif %} + +{% if is_admin %} +
+

Admin

+ + {% if can_admin_decide %} +
+ +
+ + + {% if error_form == "admin_decide" and errors.reason %}

{{ errors.reason }}

{% endif %} +
+
+ + +
+
+ {% endif %} + + {% if can_admin_adjust_budget %} +
+ +
+ + + {% if error_form == "admin_budget" and errors.amount_cents %}

{{ errors.amount_cents }}

{% endif %} +
+
+ + + {% if error_form == "admin_budget" and errors.reason %}

{{ errors.reason }}

{% endif %} +
+ +
+ {% endif %} + + {% if can_admin_reclaim %} +
+ +
+ + + {% if error_form == "admin_reclaim" and errors.amount_cents %}

{{ errors.amount_cents }}

{% endif %} +
+
+ + + {% if error_form == "admin_reclaim" and errors.reason %}

{{ errors.reason }}

{% endif %} +
+ +
+ {% endif %} + + {% if can_admin_withdraw %} +
+ +
+ + + {% if error_form == "withdraw" and errors.reason %}

{{ errors.reason }}

{% endif %} +
+ +
+ {% endif %} + +

Admin overview

+
+{% endif %} + +
+

Revision history

+ {% for revision in revisions %} +
+ +

+ {% if revision.submitted_at %}Submitted {{ revision.submitted_at.strftime("%Y-%m-%d %H:%M UTC") }}{% else %}Draft, not yet submitted{% endif %} + · Budget requested: {{ revision.budget_requested_cents | dollars }} +

+ {% if revision.reviews %} +
    + {% for review in revision.reviews %} +
  • + {{ users_by_id[review.reviewer_id].display_name if review.reviewer_id in users_by_id else "Unknown reviewer" }} + {{ macros.decision_badge(review.decision) }} + {% if review.reason %} — {{ review.reason }}{% endif %} +
  • + {% endfor %} +
+ {% else %} +

No reviews yet.

+ {% endif %} +
+ {% endfor %} +
+{% endblock %} diff --git a/krater/web/templates/projects/edit.html b/krater/web/templates/projects/edit.html new file mode 100644 index 0000000..a18b53f --- /dev/null +++ b/krater/web/templates/projects/edit.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} + +{% block title %}Edit draft{% endblock %} + +{% block content %} +
+ +

+ {% if draft.kind.value == "completion" %} + Completion write-up for “{{ project.title }}”. + {% elif draft.kind.value == "amendment" %} + Amendment to “{{ project.title }}”. + {% else %} + Proposal draft. + {% endif %} +

+ +
+ + +
+ + + {% if errors.title %}

{{ errors.title }}

{% endif %} +
+ +
+ + +
+ +
+ + + {% if errors.write_up %}

{{ errors.write_up }}

{% endif %} +
+ +
+ + + {% if errors.budget_requested_cents %}

{{ errors.budget_requested_cents }}

{% endif %} +
+ + {% if draft.kind.value == "completion" %} +
+ + +
+ +
+ + +

Comma-separated.

+
+ +
+ + +

Comma-separated emails of existing Krater users.

+ {% if errors.credited_builder_emails %}

{{ errors.credited_builder_emails }}

{% endif %} +
+ +
+

Screenshots are coming later — upload storage isn't set up yet.

+
+ {% endif %} + + +
+ +
+ + +
+
+{% endblock %} diff --git a/krater/web/templates/projects/new.html b/krater/web/templates/projects/new.html new file mode 100644 index 0000000..4c59b60 --- /dev/null +++ b/krater/web/templates/projects/new.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}New proposal{% endblock %} + +{% block content %} +
+

New proposal

+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +

Dollars, e.g. 1,234.50.

+ {% if errors.budget_requested_cents %} +

{{ errors.budget_requested_cents }}

+ {% endif %} +
+ +

You can keep editing this as a draft before you submit it for review.

+ + +
+
+{% endblock %} diff --git a/krater/web/templates/reviews/queue.html b/krater/web/templates/reviews/queue.html new file mode 100644 index 0000000..e91d88b --- /dev/null +++ b/krater/web/templates/reviews/queue.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Review queue{% endblock %} + +{% block content %} +
+

Review queue

+ {% if queue %} + + {% else %} +

Nothing waiting on your review.

+ {% endif %} +
+{% endblock %} diff --git a/krater/web/textfmt.py b/krater/web/textfmt.py new file mode 100644 index 0000000..df3c115 --- /dev/null +++ b/krater/web/textfmt.py @@ -0,0 +1,19 @@ +"""Small text-formatting helpers for templates.""" + +from __future__ import annotations + +from markupsafe import Markup, escape + + +def nl2br(value: str) -> Markup: + """Escape `value`, then turn its line breaks into `
` tags. + + Used for the write-up: "escaped plain text with line breaks" (no raw HTML, no markdown). Escaping + happens first, so nothing in `value` itself can inject markup -- only the `
` this function adds + is ever unescaped. + """ + escaped = escape(value) + return Markup("
\n").join(escaped.split("\n")) + + +__all__ = ["nl2br"] diff --git a/tests/conftest.py b/tests/conftest.py index 494d114..21ebfcb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,20 +15,30 @@ from collections.abc import Generator # noqa: E402 from pathlib import Path # noqa: E402 +from urllib.parse import parse_qs, urlparse # noqa: E402 import pytest # noqa: E402 from alembic.config import Config # noqa: E402 from fastapi.testclient import TestClient # noqa: E402 -from sqlalchemy import event # noqa: E402 +from sqlalchemy import event, select # noqa: E402 from sqlalchemy.engine import Engine # noqa: E402 from sqlalchemy.orm import Session # noqa: E402 from alembic import command # noqa: E402 from krater.db import get_engine, get_session # noqa: E402 +from krater.models import User # noqa: E402 from krater.web.app import create_app # noqa: E402 REPO_ROOT = Path(__file__).resolve().parent.parent +# Fixture users from krater/weave/stub_users.json, named for what they're good for in tests. +MEMBER_SUB = "PWLMEMBERONE" +OTHER_MEMBER_SUB = "PWLMEMBERTWO" +REVIEWER_SUB = "PWLREVIEWERONE" +OTHER_REVIEWER_SUB = "PWLREVIEWERTWO" +ADMIN_SUB = "PWLADMINONE" +NON_MEMBER_SUB = "PWLNONMEMBER" + @pytest.fixture(scope="session") def engine() -> Generator[Engine, None, None]: @@ -81,3 +91,30 @@ def client(db_session: Session) -> Generator[TestClient, None, None]: yield test_client app.dependency_overrides.clear() + + +@pytest.fixture +def login_as(client: TestClient, db_session: Session): + """Factory: sign `client` in as the stub user with the given `weave_sub`, via the real stub flow + (the same `/login` -> `/auth/stub` -> `/auth/callback` round trip `tests/web/test_auth.py` + exercises), and return the resulting `User` row. + """ + + def _login_as(weave_sub: str) -> User: + login_response = client.get("/login", follow_redirects=False) + assert login_response.status_code == 302 + state = parse_qs(urlparse(login_response.headers["location"]).query)["state"][0] + + callback_response = client.get( + "/auth/callback", params={"code": weave_sub, "state": state}, follow_redirects=False + ) + assert callback_response.status_code == 302, callback_response.text + + return db_session.execute(select(User).where(User.weave_sub == weave_sub)).scalar_one() + + return _login_as + + +def get_csrf_token(html: str) -> str: + """Pull the `csrf_token` hidden field's value out of a rendered page, for posting a form back.""" + return html.split('name="csrf_token" value="')[1].split('"')[0] diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 0000000..b378f00 --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,98 @@ +"""Shared fixtures for tests/web: quick, direct-to-service project setup for a signed-in stub user. + +HTTP-level tests exercise routing, auth wiring, CSRF and error-mapping -- the service layer's own +rules already have thorough unit tests in tests/services. So these fixtures skip the HTTP round trip +for *setup* (e.g. getting a project into `pending_review`) and call the service directly, using an +`Actor` built the same way `fresh_actor` would from the signed-in `User`'s real stub groups. +""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from krater.models import Project, ReviewDecision, ReviewSource, User +from krater.services import projects as project_service +from krater.services.actor import Actor +from krater.weave import get_weave_client +from krater.weave.types import WeaveUser + + +def actor_for(user: User) -> Actor: + """An `Actor` for `user`, using their real (stub) groups -- not the session's cached copy.""" + return Actor(user=user, groups=frozenset(user.groups_cached)) + + +@pytest.fixture +def create_project(db_session: Session) -> Callable[..., Project]: + """Factory: create a draft project for `user`, via the service (bypassing the HTTP form).""" + + def _create(user: User, **kwargs) -> Project: + kwargs.setdefault("title", "Test Project") + kwargs.setdefault("write_up", "A write-up.") + kwargs.setdefault("budget_requested_cents", 10_000) + return project_service.create_project(db_session, actor_for(user), **kwargs) + + return _create + + +@pytest.fixture +def submitted_project(db_session: Session, create_project: Callable[..., Project]) -> Callable[..., Project]: + """Factory: create and submit a project for `user`, landing it in `pending_review`.""" + + def _submit(user: User, **kwargs) -> Project: + project = create_project(user, **kwargs) + return project_service.submit(db_session, actor_for(user), project=project) + + return _submit + + +@pytest.fixture +def approved_project(db_session: Session, submitted_project: Callable[..., Project]) -> Callable[..., Project]: + """Factory: create, submit and get `reviewer` to approve a project for `user`.""" + + def _approve(user: User, reviewer: User, **kwargs) -> Project: + project = submitted_project(user, **kwargs) + project_service.record_review( + db_session, + actor_for(reviewer), + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + db_session.refresh(project) + return project + + return _approve + + +class _FakeWeaveClient: + """A `WeaveClient` that answers `get_user` with a fixed `WeaveUser`, regardless of `sub`.""" + + def __init__(self, user: WeaveUser) -> None: + self._user = user + + def get_user(self, sub: str) -> WeaveUser: + del sub + return self._user + + +@pytest.fixture +def revoke_membership(client: TestClient) -> Callable[[User], None]: + """Make `fresh_actor` see `user` as no longer a Ganymede member on their *next* request, by + overriding `get_weave_client` -- simulates their Weave groups changing after they signed in + (Krater's own `/login` already refuses a non-member outright, so this is the only way an + already-signed-in session can end up "not a member" for `fresh_actor` to catch). + """ + + def _revoke(user: User) -> None: + revoked = WeaveUser( + sub=user.weave_sub, name=user.display_name, email=user.email, slack_id=None, groups=frozenset(), active=True + ) + client.app.dependency_overrides[get_weave_client] = lambda: _FakeWeaveClient(revoked) + + yield _revoke + client.app.dependency_overrides.pop(get_weave_client, None) diff --git a/tests/web/test_admin_page.py b/tests/web/test_admin_page.py new file mode 100644 index 0000000..4264cf2 --- /dev/null +++ b/tests/web/test_admin_page.py @@ -0,0 +1,49 @@ +"""HTTP-level tests for the admin overview page, /admin.""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from krater.models import ApprovalPolicy, ApprovalStage +from tests.conftest import ADMIN_SUB, MEMBER_SUB, REVIEWER_SUB + + +def test_admin_page_requires_sign_in(client: TestClient) -> None: + response = client.get("/admin", follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"].startswith("/login") + + +def test_non_admin_gets_403(client: TestClient, login_as) -> None: + login_as(REVIEWER_SUB) + + response = client.get("/admin") + + assert response.status_code == 403 + + +def test_admin_sees_projects_grouped_by_status_and_the_default_policy_note( + client: TestClient, login_as, submitted_project +) -> None: + member = login_as(MEMBER_SUB) + submitted_project(member, title="Pending One") + + login_as(ADMIN_SUB) + response = client.get("/admin") + + assert response.status_code == 200 + assert "Pending One" in response.text + assert "No custom policies configured" in response.text + + +def test_admin_page_lists_configured_policies(client: TestClient, login_as, db_session) -> None: + db_session.add(ApprovalPolicy(stage=ApprovalStage.PROPOSAL, min_approvals=2)) + db_session.flush() + + login_as(ADMIN_SUB) + response = client.get("/admin") + + assert response.status_code == 200 + assert "No custom policies configured" not in response.text + assert "Proposal" in response.text diff --git a/tests/web/test_gallery.py b/tests/web/test_gallery.py new file mode 100644 index 0000000..f6c34ec --- /dev/null +++ b/tests/web/test_gallery.py @@ -0,0 +1,87 @@ +"""HTTP-level tests for the public gallery: /gallery, /gallery/{id}. No auth required.""" + +from __future__ import annotations + +import uuid + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from krater.models import ReviewDecision, ReviewSource +from krater.services import projects as project_service +from tests.conftest import MEMBER_SUB, REVIEWER_SUB +from tests.web.conftest import actor_for + + +def _completed_project(db_session: Session, approved_project, member, reviewer): + project = approved_project(member, reviewer, title="Gallery Project") + project_service.start_completion(db_session, actor_for(member), project=project) + project_service.update_draft( + db_session, + actor_for(member), + project=project, + demo_url="https://example.com/demo", + tags=["ganymede", "rover"], + ) + project_service.submit_completion(db_session, actor_for(member), project=project) + project_service.record_review( + db_session, + actor_for(reviewer), + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + db_session.refresh(project) + return project + + +def test_gallery_index_is_public(client: TestClient) -> None: + response = client.get("/gallery") + + assert response.status_code == 200 + + +def test_gallery_index_shows_only_completed_projects( + client: TestClient, login_as, approved_project, db_session: Session +) -> None: + member = login_as(MEMBER_SUB) + reviewer = login_as(REVIEWER_SUB) + # Approved, but not yet completed -- should NOT show up in the gallery. + approved_project(member, reviewer, title="Not Done Yet") + + response = client.get("/gallery") + + assert response.status_code == 200 + assert "Not Done Yet" not in response.text + + +def test_gallery_detail_shows_a_completed_project( + client: TestClient, login_as, approved_project, db_session: Session +) -> None: + member = login_as(MEMBER_SUB) + reviewer = login_as(REVIEWER_SUB) + project = _completed_project(db_session, approved_project, member, reviewer) + + index_response = client.get("/gallery") + assert "Gallery Project" in index_response.text + + detail_response = client.get(f"/gallery/{project.id}") + assert detail_response.status_code == 200 + assert "Gallery Project" in detail_response.text + assert "ganymede" in detail_response.text + assert "example.com/demo" in detail_response.text + + +def test_gallery_detail_404s_for_a_non_completed_project(client: TestClient, login_as, submitted_project) -> None: + member = login_as(MEMBER_SUB) + project = submitted_project(member) + + response = client.get(f"/gallery/{project.id}") + + assert response.status_code == 404 + + +def test_gallery_detail_404s_for_an_unknown_project(client: TestClient) -> None: + response = client.get(f"/gallery/{uuid.uuid4()}") + + assert response.status_code == 404 diff --git a/tests/web/test_money.py b/tests/web/test_money.py new file mode 100644 index 0000000..259a850 --- /dev/null +++ b/tests/web/test_money.py @@ -0,0 +1,58 @@ +"""Tests for the dollars <-> cents helpers.""" + +from __future__ import annotations + +import pytest + +from krater.web.money import InvalidDollarAmount, format_cents, parse_dollars + + +@pytest.mark.parametrize( + ("raw", "expected_cents"), + [ + ("1,234.50", 123_450), + ("1234.50", 123_450), + ("$1,234.50", 123_450), + ("100", 10_000), + ("0.01", 1), + ("0.1", 10), + ("1,000,000", 100_000_000), + ], +) +def test_parse_dollars_accepts_common_formats(raw: str, expected_cents: int) -> None: + assert parse_dollars(raw) == expected_cents + + +@pytest.mark.parametrize("raw", ["", " ", "abc", "1.234", "$", "--5", "5.", "."]) +def test_parse_dollars_rejects_invalid_input(raw: str) -> None: + with pytest.raises(InvalidDollarAmount): + parse_dollars(raw) + + +def test_parse_dollars_rejects_negative_by_default() -> None: + with pytest.raises(InvalidDollarAmount): + parse_dollars("-5.00") + + +def test_parse_dollars_allows_negative_when_requested() -> None: + assert parse_dollars("-5.00", allow_negative=True) == -500 + assert parse_dollars("-1,234.56", allow_negative=True) == -123_456 + + +def test_parse_dollars_allows_zero() -> None: + assert parse_dollars("0") == 0 + assert parse_dollars("0.00") == 0 + + +@pytest.mark.parametrize( + ("cents", "expected"), + [ + (0, "$0.00"), + (1, "$0.01"), + (123_450, "$1,234.50"), + (-4050, "-$40.50"), + (100_000_000, "$1,000,000.00"), + ], +) +def test_format_cents(cents: int, expected: str) -> None: + assert format_cents(cents) == expected diff --git a/tests/web/test_pages.py b/tests/web/test_pages.py index b9ea538..ee6b3c4 100644 --- a/tests/web/test_pages.py +++ b/tests/web/test_pages.py @@ -2,6 +2,8 @@ from fastapi.testclient import TestClient +from tests.conftest import ADMIN_SUB, MEMBER_SUB, REVIEWER_SUB + def test_healthz_runs_a_query_and_reports_ok(client: TestClient) -> None: response = client.get("/healthz") @@ -10,9 +12,44 @@ def test_healthz_runs_a_query_and_reports_ok(client: TestClient) -> None: assert response.json() == {"status": "ok"} -def test_home_page_renders(client: TestClient) -> None: +def test_home_page_renders_signed_out(client: TestClient) -> None: response = client.get("/") assert response.status_code == 200 assert "text/html" in response.headers["content-type"] assert "Krater" in response.text + assert "Sign in with Weave" in response.text + assert "/gallery" in response.text + + +def test_home_page_signed_in_shows_my_projects_and_new_proposal(client: TestClient, login_as, create_project) -> None: + member = login_as(MEMBER_SUB) + create_project(member, title="My Draft") + + response = client.get("/") + + assert response.status_code == 200 + assert "My Draft" in response.text + assert "New proposal" in response.text + assert "Review queue" not in response.text + assert ">Admin<" not in response.text + + +def test_home_page_shows_review_queue_link_with_count_for_reviewers( + client: TestClient, login_as, submitted_project +) -> None: + member = login_as(MEMBER_SUB) + submitted_project(member) + + login_as(REVIEWER_SUB) + response = client.get("/") + + assert "Review queue (1)" in response.text + + +def test_home_page_shows_admin_link_for_admins(client: TestClient, login_as) -> None: + login_as(ADMIN_SUB) + + response = client.get("/") + + assert ">Admin<" in response.text diff --git a/tests/web/test_projects.py b/tests/web/test_projects.py new file mode 100644 index 0000000..363b0cb --- /dev/null +++ b/tests/web/test_projects.py @@ -0,0 +1,435 @@ +"""HTTP-level tests for /projects/*: creation, viewing, editing, and the contextual action forms. + +The service layer's rules are already covered in tests/services; these focus on routing, auth wiring, +CSRF, and how domain errors map to responses. +""" + +from __future__ import annotations + +import uuid + +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from krater.models import ProjectStatus +from krater.services import projects as project_service +from tests.conftest import ADMIN_SUB, MEMBER_SUB, OTHER_MEMBER_SUB, REVIEWER_SUB, get_csrf_token + +# -------------------------------------------------------------------------------------------------- +# /projects/new +# -------------------------------------------------------------------------------------------------- + + +def test_get_new_project_form_requires_sign_in(client: TestClient) -> None: + response = client.get("/projects/new", follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"].startswith("/login") + + +def test_a_member_who_lost_ganymede_membership_cannot_create(client: TestClient, login_as, revoke_membership) -> None: + """Krater's `/login` already refuses a non-member outright, so the case this route must guard + against is a *previously* signed-in member whose live Weave groups no longer include + `ganymede:member` -- exactly what `fresh_actor` re-checks on every request.""" + member = login_as(MEMBER_SUB) + revoke_membership(member) + + response = client.get("/projects/new") + + assert response.status_code == 403 + + +def test_member_can_create_a_project(client: TestClient, login_as, db_session: Session) -> None: + login_as(MEMBER_SUB) + form = client.get("/projects/new") + csrf = get_csrf_token(form.text) + + response = client.post( + "/projects/new", + data={ + "csrf_token": csrf, + "title": "Rover", + "repo_url": "https://github.com/x/rover", + "write_up": "A rover.", + "budget_requested": "1,234.50", + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + project_id = uuid.UUID(response.headers["location"].removeprefix("/projects/")) + project = project_service.get_project(db_session, project_id=project_id) + assert project.title == "Rover" + assert project.current_revision.budget_requested_cents == 123_450 + assert project.status is ProjectStatus.DRAFT + + +def test_create_project_rejects_an_invalid_budget(client: TestClient, login_as) -> None: + login_as(MEMBER_SUB) + form = client.get("/projects/new") + csrf = get_csrf_token(form.text) + + response = client.post( + "/projects/new", + data={"csrf_token": csrf, "title": "X", "write_up": "Y", "budget_requested": "not-a-number"}, + ) + + assert response.status_code == 422 + assert "valid dollar amount" in response.text + + +def test_create_project_without_csrf_fails(client: TestClient, login_as) -> None: + login_as(MEMBER_SUB) + + response = client.post("/projects/new", data={"title": "X", "write_up": "Y", "budget_requested": "10"}) + + assert response.status_code == 403 + + +# -------------------------------------------------------------------------------------------------- +# /projects/{id}: visibility +# -------------------------------------------------------------------------------------------------- + + +def test_submitter_can_view_their_own_project(client: TestClient, login_as, create_project) -> None: + member = login_as(MEMBER_SUB) + project = create_project(member, title="Mine") + + response = client.get(f"/projects/{project.id}") + + assert response.status_code == 200 + assert "Mine" in response.text + + +def test_member_cannot_view_another_members_project(client: TestClient, login_as, create_project) -> None: + other = login_as(OTHER_MEMBER_SUB) + other_project = create_project(other, title="Theirs") + + login_as(MEMBER_SUB) + response = client.get(f"/projects/{other_project.id}") + + assert response.status_code == 404 + + +def test_reviewer_can_view_any_project(client: TestClient, login_as, create_project) -> None: + member = login_as(MEMBER_SUB) + project = create_project(member, title="Reviewable") + + login_as(REVIEWER_SUB) + response = client.get(f"/projects/{project.id}") + + assert response.status_code == 200 + assert "Reviewable" in response.text + + +def test_admin_can_view_any_project(client: TestClient, login_as, create_project) -> None: + member = login_as(MEMBER_SUB) + project = create_project(member, title="Adminable") + + login_as(ADMIN_SUB) + response = client.get(f"/projects/{project.id}") + + assert response.status_code == 200 + + +def test_unknown_project_is_404(client: TestClient, login_as) -> None: + login_as(MEMBER_SUB) + + response = client.get(f"/projects/{uuid.uuid4()}") + + assert response.status_code == 404 + + +# -------------------------------------------------------------------------------------------------- +# /projects/{id}/edit +# -------------------------------------------------------------------------------------------------- + + +def test_submitter_can_edit_and_save_their_draft( + client: TestClient, login_as, create_project, db_session: Session +) -> None: + member = login_as(MEMBER_SUB) + project = create_project(member, title="Original") + + edit_form = client.get(f"/projects/{project.id}/edit") + assert edit_form.status_code == 200 + csrf = get_csrf_token(edit_form.text) + + response = client.post( + f"/projects/{project.id}/edit", + data={ + "csrf_token": csrf, + "title": "Updated", + "repo_url": "", + "write_up": "Updated write-up.", + "budget_requested": "500", + }, + follow_redirects=False, + ) + + assert response.status_code == 303 + db_session.refresh(project) + assert project.title == "Updated" + assert project.current_revision.budget_requested_cents == 50_000 + + +def test_reviewer_cannot_edit_someone_elses_draft(client: TestClient, login_as, create_project) -> None: + member = login_as(MEMBER_SUB) + project = create_project(member) + + login_as(REVIEWER_SUB) + response = client.get(f"/projects/{project.id}/edit") + + assert response.status_code == 403 + + +def test_edit_rejects_an_unknown_credited_builder_email(client: TestClient, login_as, approved_project) -> None: + member = login_as(MEMBER_SUB) + reviewer = login_as(REVIEWER_SUB) + project = approved_project(member, reviewer) + login_as(MEMBER_SUB) + + completion_start = client.post( + f"/projects/{project.id}/complete", + data={"csrf_token": get_csrf_token(client.get(f"/projects/{project.id}").text)}, + follow_redirects=False, + ) + assert completion_start.status_code == 303 + + edit_form = client.get(f"/projects/{project.id}/edit") + csrf = get_csrf_token(edit_form.text) + response = client.post( + f"/projects/{project.id}/edit", + data={ + "csrf_token": csrf, + "title": project.title, + "write_up": "Final write-up.", + "budget_requested": "100", + "demo_url": "", + "tags": "", + "credited_builder_emails": "nobody-like-this@example.com", + }, + ) + + assert response.status_code == 422 + assert "Unknown email" in response.text + + +# -------------------------------------------------------------------------------------------------- +# submit / amend / complete / withdraw +# -------------------------------------------------------------------------------------------------- + + +def test_submit_happy_path(client: TestClient, login_as, create_project, db_session: Session) -> None: + member = login_as(MEMBER_SUB) + project = create_project(member, title="T", write_up="W", budget_requested_cents=100) + + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + response = client.post(f"/projects/{project.id}/submit", data={"csrf_token": csrf}, follow_redirects=False) + + assert response.status_code == 303 + db_session.refresh(project) + assert project.status is ProjectStatus.PENDING_REVIEW + + +def test_submit_with_missing_fields_rerenders_the_edit_form(client: TestClient, login_as, create_project) -> None: + member = login_as(MEMBER_SUB) + project = create_project(member, title="", write_up="", budget_requested_cents=0) + + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + response = client.post(f"/projects/{project.id}/submit", data={"csrf_token": csrf}) + + assert response.status_code == 422 + assert "required" in response.text.lower() + + +def test_submit_without_csrf_fails(client: TestClient, login_as, create_project) -> None: + member = login_as(MEMBER_SUB) + project = create_project(member) + + response = client.post(f"/projects/{project.id}/submit", data={}) + + assert response.status_code == 403 + + +def test_amend_then_complete_then_withdraw_are_hidden_and_gated_correctly( + client: TestClient, login_as, approved_project, db_session: Session +) -> None: + member = login_as(MEMBER_SUB) + reviewer = login_as(REVIEWER_SUB) + project = approved_project(member, reviewer) + + login_as(MEMBER_SUB) + detail = client.get(f"/projects/{project.id}") + assert "Start amendment" in detail.text + assert "Start completion" in detail.text + + csrf = get_csrf_token(detail.text) + amend_response = client.post(f"/projects/{project.id}/amend", data={"csrf_token": csrf}, follow_redirects=False) + assert amend_response.status_code == 303 + assert amend_response.headers["location"] == f"/projects/{project.id}/edit" + + # A second amend attempt while a draft is already in progress is an InvalidState -> flash+redirect. + second_attempt = client.post(f"/projects/{project.id}/amend", data={"csrf_token": csrf}, follow_redirects=False) + assert second_attempt.status_code == 303 + assert second_attempt.headers["location"] == f"/projects/{project.id}" + followed = client.get(second_attempt.headers["location"]) + assert "already has a draft" in followed.text + + withdraw_csrf = get_csrf_token(client.get(f"/projects/{project.id}").text) + withdraw_response = client.post( + f"/projects/{project.id}/withdraw", data={"csrf_token": withdraw_csrf}, follow_redirects=False + ) + assert withdraw_response.status_code == 303 + db_session.refresh(project) + assert project.status is ProjectStatus.WITHDRAWN + + +# -------------------------------------------------------------------------------------------------- +# Reviewer action: /projects/{id}/review +# -------------------------------------------------------------------------------------------------- + + +def test_reviewer_can_approve(client: TestClient, login_as, submitted_project, db_session: Session) -> None: + member = login_as(MEMBER_SUB) + project = submitted_project(member) + + login_as(REVIEWER_SUB) + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + response = client.post( + f"/projects/{project.id}/review", data={"csrf_token": csrf, "decision": "approve"}, follow_redirects=False + ) + + assert response.status_code == 303 + db_session.refresh(project) + assert project.status is ProjectStatus.APPROVED + + +def test_reject_without_a_reason_rerenders_with_a_field_error(client: TestClient, login_as, submitted_project) -> None: + member = login_as(MEMBER_SUB) + project = submitted_project(member) + + login_as(REVIEWER_SUB) + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + response = client.post(f"/projects/{project.id}/review", data={"csrf_token": csrf, "decision": "reject"}) + + assert response.status_code == 422 + assert "reason" in response.text.lower() + + +def test_non_reviewer_cannot_record_a_review(client: TestClient, login_as, submitted_project) -> None: + """The submitter here is a plain member (not a reviewer at all).""" + member = login_as(MEMBER_SUB) + project = submitted_project(member) + + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + response = client.post(f"/projects/{project.id}/review", data={"csrf_token": csrf, "decision": "approve"}) + + assert response.status_code == 403 + + +def test_submitter_who_is_a_reviewer_cannot_approve_their_own_project( + client: TestClient, login_as, submitted_project +) -> None: + """`REVIEWER_SUB` is both a member and a reviewer -- submitting their own project must still be + blocked by the self-review rule specifically (not just the "not a reviewer" rule).""" + reviewer = login_as(REVIEWER_SUB) + project = submitted_project(reviewer) + + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + response = client.post(f"/projects/{project.id}/review", data={"csrf_token": csrf, "decision": "approve"}) + + assert response.status_code == 403 + + +def test_review_without_csrf_fails(client: TestClient, login_as, submitted_project) -> None: + member = login_as(MEMBER_SUB) + project = submitted_project(member) + + login_as(REVIEWER_SUB) + response = client.post(f"/projects/{project.id}/review", data={"decision": "approve"}) + + assert response.status_code == 403 + + +# -------------------------------------------------------------------------------------------------- +# Admin actions +# -------------------------------------------------------------------------------------------------- + + +def test_admin_can_decide_and_adjust_budget( + client: TestClient, login_as, submitted_project, db_session: Session +) -> None: + member = login_as(MEMBER_SUB) + project = submitted_project(member) + + login_as(ADMIN_SUB) + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + decide_response = client.post( + f"/projects/{project.id}/admin-decide", + data={"csrf_token": csrf, "decision": "approve", "reason": "Looks good."}, + follow_redirects=False, + ) + assert decide_response.status_code == 303 + db_session.refresh(project) + assert project.status is ProjectStatus.APPROVED + + budget_csrf = get_csrf_token(client.get(f"/projects/{project.id}").text) + budget_response = client.post( + f"/projects/{project.id}/admin-budget", + data={"csrf_token": budget_csrf, "amount": "50.00", "reason": "Bonus credit."}, + follow_redirects=False, + ) + assert budget_response.status_code == 303 + from krater.services import budget as budget_service + + assert budget_service.ceiling_cents(db_session, project) == 15_000 + + +def test_non_admin_cannot_adjust_budget(client: TestClient, login_as, approved_project) -> None: + member = login_as(MEMBER_SUB) + reviewer = login_as(REVIEWER_SUB) + project = approved_project(member, reviewer) + + login_as(REVIEWER_SUB) + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + response = client.post( + f"/projects/{project.id}/admin-budget", data={"csrf_token": csrf, "amount": "50.00", "reason": "x"} + ) + + assert response.status_code == 403 + + +def test_admin_budget_without_csrf_fails(client: TestClient, login_as, approved_project) -> None: + member = login_as(MEMBER_SUB) + reviewer = login_as(REVIEWER_SUB) + project = approved_project(member, reviewer) + + login_as(ADMIN_SUB) + response = client.post(f"/projects/{project.id}/admin-budget", data={"amount": "50.00", "reason": "x"}) + + assert response.status_code == 403 + + +def test_admin_reclaim_requires_a_reason(client: TestClient, login_as, approved_project) -> None: + member = login_as(MEMBER_SUB) + reviewer = login_as(REVIEWER_SUB) + project = approved_project(member, reviewer) + + login_as(ADMIN_SUB) + page = client.get(f"/projects/{project.id}") + csrf = get_csrf_token(page.text) + response = client.post( + f"/projects/{project.id}/admin-reclaim", data={"csrf_token": csrf, "amount": "10.00", "reason": ""} + ) + + assert response.status_code == 422 + assert "reason" in response.text.lower() diff --git a/tests/web/test_reviews_page.py b/tests/web/test_reviews_page.py new file mode 100644 index 0000000..2e8fb0e --- /dev/null +++ b/tests/web/test_reviews_page.py @@ -0,0 +1,34 @@ +"""HTTP-level tests for the review queue page, /reviews.""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import MEMBER_SUB, REVIEWER_SUB + + +def test_reviews_page_requires_sign_in(client: TestClient) -> None: + response = client.get("/reviews", follow_redirects=False) + + assert response.status_code == 303 + assert response.headers["location"].startswith("/login") + + +def test_non_reviewer_gets_403(client: TestClient, login_as) -> None: + login_as(MEMBER_SUB) + + response = client.get("/reviews") + + assert response.status_code == 403 + + +def test_reviewer_sees_their_queue(client: TestClient, login_as, submitted_project) -> None: + member = login_as(MEMBER_SUB) + project = submitted_project(member, title="Needs review") + + login_as(REVIEWER_SUB) + response = client.get("/reviews") + + assert response.status_code == 200 + assert "Needs review" in response.text + assert f"/projects/{project.id}" in response.text diff --git a/tests/web/test_workflow_e2e.py b/tests/web/test_workflow_e2e.py new file mode 100644 index 0000000..101d2fd --- /dev/null +++ b/tests/web/test_workflow_e2e.py @@ -0,0 +1,160 @@ +"""One end-to-end test driving the whole workflow through HTTP only (no direct service calls): +member creates and submits -> reviewer approves -> the ceiling shows -> amendment -> completion -> +reviewer approves -> the project appears in the gallery. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import MEMBER_SUB, REVIEWER_SUB, get_csrf_token + + +def test_full_workflow_through_http(client: TestClient, login_as) -> None: + # --- Member creates a draft proposal -------------------------------------------------------- + login_as(MEMBER_SUB) + new_form = client.get("/projects/new") + csrf = get_csrf_token(new_form.text) + + create_response = client.post( + "/projects/new", + data={ + "csrf_token": csrf, + "title": "Ganymede Rover", + "repo_url": "https://github.com/patchworklabs/rover", + "write_up": "A rover for Ganymede.\nSecond line.", + "budget_requested": "1,000.00", + }, + follow_redirects=False, + ) + assert create_response.status_code == 303 + project_url = create_response.headers["location"] + + # --- Submit it for review -------------------------------------------------------------------- + detail = client.get(project_url) + assert "Ganymede Rover" in detail.text + csrf = get_csrf_token(detail.text) + submit_response = client.post(f"{project_url}/submit", data={"csrf_token": csrf}, follow_redirects=False) + assert submit_response.status_code == 303 + + pending_page = client.get(project_url) + assert "Pending review" in pending_page.text + + # --- Reviewer approves it --------------------------------------------------------------------- + login_as(REVIEWER_SUB) + review_page = client.get(project_url) + assert "Needs 1 more approval." in review_page.text + csrf = get_csrf_token(review_page.text) + approve_response = client.post( + f"{project_url}/review", data={"csrf_token": csrf, "decision": "approve"}, follow_redirects=False + ) + assert approve_response.status_code == 303 + + # --- The ceiling now shows ------------------------------------------------------------------- + approved_page = client.get(project_url) + assert "Approved" in approved_page.text + assert "$1,000.00" in approved_page.text + + # --- Submitter amends the budget down ----------------------------------------------------- + login_as(MEMBER_SUB) + amend_page = client.get(project_url) + assert "Start amendment" in amend_page.text + csrf = get_csrf_token(amend_page.text) + amend_start = client.post(f"{project_url}/amend", data={"csrf_token": csrf}, follow_redirects=False) + assert amend_start.status_code == 303 + assert amend_start.headers["location"] == f"{project_url}/edit" + + edit_page = client.get(amend_start.headers["location"]) + csrf = get_csrf_token(edit_page.text) + amend_save = client.post( + f"{project_url}/edit", + data={ + "csrf_token": csrf, + "title": "Ganymede Rover", + "repo_url": "https://github.com/patchworklabs/rover", + "write_up": "A rover for Ganymede.", + "budget_requested": "600.00", + }, + follow_redirects=False, + ) + assert amend_save.status_code == 303 + + submit_amend_page = client.get(project_url) + csrf = get_csrf_token(submit_amend_page.text) + submit_amend = client.post(f"{project_url}/submit", data={"csrf_token": csrf}, follow_redirects=False) + assert submit_amend.status_code == 303 + + # Amendments leave the project approved while it's reviewed. + still_approved_page = client.get(project_url) + assert "Approved" in still_approved_page.text + + login_as(REVIEWER_SUB) + review_amend_page = client.get(project_url) + csrf = get_csrf_token(review_amend_page.text) + approve_amend = client.post( + f"{project_url}/review", data={"csrf_token": csrf, "decision": "approve"}, follow_redirects=False + ) + assert approve_amend.status_code == 303 + + after_amend_page = client.get(project_url) + assert "$600.00" in after_amend_page.text + + # --- Submitter starts and submits completion -------------------------------------------------- + login_as(MEMBER_SUB) + detail_page = client.get(project_url) + csrf = get_csrf_token(detail_page.text) + complete_start = client.post(f"{project_url}/complete", data={"csrf_token": csrf}, follow_redirects=False) + assert complete_start.status_code == 303 + + completion_edit_page = client.get(complete_start.headers["location"]) + assert "coming later" in completion_edit_page.text # screenshots note + csrf = get_csrf_token(completion_edit_page.text) + completion_save = client.post( + f"{project_url}/edit", + data={ + "csrf_token": csrf, + "title": "Ganymede Rover", + "repo_url": "https://github.com/patchworklabs/rover", + "write_up": "Final write-up for the rover.", + "budget_requested": "600.00", + "demo_url": "https://example.com/rover-demo", + "tags": "rover, ganymede", + "credited_builder_emails": "", + }, + follow_redirects=False, + ) + assert completion_save.status_code == 303 + + submit_completion_page = client.get(project_url) + csrf = get_csrf_token(submit_completion_page.text) + submit_completion_response = client.post( + f"{project_url}/submit-completion", data={"csrf_token": csrf}, follow_redirects=False + ) + assert submit_completion_response.status_code == 303 + + pending_completion_page = client.get(project_url) + assert "Pending completion review" in pending_completion_page.text + + # --- Reviewer approves the completion ---------------------------------------------------------- + login_as(REVIEWER_SUB) + completion_review_page = client.get(project_url) + csrf = get_csrf_token(completion_review_page.text) + approve_completion = client.post( + f"{project_url}/review", data={"csrf_token": csrf, "decision": "approve"}, follow_redirects=False + ) + assert approve_completion.status_code == 303 + + completed_page = client.get(project_url) + assert "Completed" in completed_page.text + + # --- The project now appears in the public gallery, with no auth -------------------------------- + anonymous_client = TestClient(client.app) + gallery_index = anonymous_client.get("/gallery") + assert gallery_index.status_code == 200 + assert "Ganymede Rover" in gallery_index.text + + gallery_detail = anonymous_client.get(project_url.replace("/projects/", "/gallery/")) + assert gallery_detail.status_code == 200 + assert "Final write-up for the rover." in gallery_detail.text + assert "example.com/rover-demo" in gallery_detail.text + assert "rover" in gallery_detail.text From 47fd93ee83a7c719cbbb8da3386b74ba893524c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 20:04:50 +0000 Subject: [PATCH 026/124] Only accept http(s) repo and demo links They're rendered as hrefs, including on the public gallery, so a javascript: or data: URL would have run script for every visitor who clicked it. Validate in the service layer, which every entry point uses. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/services/projects.py | 29 ++++++++++++++++-- tests/services/test_validation_and_state.py | 33 +++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/krater/services/projects.py b/krater/services/projects.py index 611bd30..c1f28b9 100644 --- a/krater/services/projects.py +++ b/krater/services/projects.py @@ -21,6 +21,7 @@ import uuid from dataclasses import dataclass from datetime import UTC, datetime +from urllib.parse import urlparse import sqlalchemy as sa from sqlalchemy.exc import IntegrityError @@ -51,6 +52,29 @@ # -------------------------------------------------------------------------------------------------- +MAX_URL_LENGTH = 2048 + + +def _clean_url(field: str, value: str | None) -> str | None: + """Validate a user-supplied link that will be rendered as an `href`, including on the public gallery. + + Only absolute http(s) URLs are allowed: anything else (`javascript:`, `data:`, relative paths) would let a + submitter put script in front of every gallery visitor. Blank means "no link". + """ + if value is None or not value.strip(): + return None + value = value.strip() + parsed = urlparse(value) + if ( + parsed.scheme not in ("http", "https") + or not parsed.netloc + or len(value) > MAX_URL_LENGTH + or any(ch.isspace() or ord(ch) < 0x20 or ord(ch) == 0x7F for ch in value) + ): + raise ValidationFailed({field: "Enter a full http:// or https:// URL."}) + return value + + def create_project( session: Session, actor: Actor, @@ -69,6 +93,7 @@ def create_project( if not actor.is_member: raise NotAllowed("Only Ganymede members may create a project.") + repo_url = _clean_url("repo_url", repo_url) project = Project(title=title, submitter_id=actor.user.id, status=ProjectStatus.DRAFT, repo_url=repo_url) session.add(project) session.flush() @@ -121,13 +146,13 @@ def update_draft( if title is not None: project.title = title if repo_url is not None: - project.repo_url = repo_url + project.repo_url = _clean_url("repo_url", repo_url) if write_up is not None: draft.write_up = write_up if budget_requested_cents is not None: draft.budget_requested_cents = budget_requested_cents if demo_url is not None: - draft.demo_url = demo_url + draft.demo_url = _clean_url("demo_url", demo_url) if screenshot_keys is not None: draft.screenshot_keys = list(screenshot_keys) if credited_builder_ids is not None: diff --git a/tests/services/test_validation_and_state.py b/tests/services/test_validation_and_state.py index a3f1650..3875bda 100644 --- a/tests/services/test_validation_and_state.py +++ b/tests/services/test_validation_and_state.py @@ -204,3 +204,36 @@ def test_project_summary_reports_policy_explanation_while_pending( assert summary.ceiling_cents == 1_000 assert summary.current_revision.kind is RevisionKind.PROPOSAL assert summary.approved_revision is not None + + +@pytest.mark.parametrize( + "url", + [ + "javascript:alert(1)", + "JavaScript:alert(1)", + "data:text/html,", + "/relative/path", + "ftp://example.com/x", + "https://", + "https://example.com/a b", + ], +) +def test_links_must_be_http_urls(db_session: Session, member: Actor, url: str) -> None: + with pytest.raises(ValidationFailed) as exc: + projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100, repo_url=url) + assert "repo_url" in exc.value.errors + + project = projects.create_project(db_session, member, title="x", write_up="y", budget_requested_cents=100) + with pytest.raises(ValidationFailed) as exc: + projects.update_draft(db_session, member, project=project, demo_url=url) + assert "demo_url" in exc.value.errors + + +def test_links_accept_http_urls_and_blank_clears(db_session: Session, member: Actor) -> None: + project = projects.create_project( + db_session, member, title="x", write_up="y", budget_requested_cents=100, repo_url=" https://github.com/o/r " + ) + assert project.repo_url == "https://github.com/o/r" + + projects.update_draft(db_session, member, project=project, repo_url="") + assert project.repo_url is None From 90913657d9eecc422ffb75691ed17129a48cb72d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:06:36 +0000 Subject: [PATCH 027/124] Add SkyPilot integration spike findings Hands-on spike against a real local SkyPilot 0.13.0 API server to verify the assumptions in docs/skypilot-integration.md before building on them: admin-policy wire format (client- AND server-side calls, double-JSON encoding, workspace often absent from the payload), workspace CRUD payload shapes and allowed_users semantics, service-account/oauth2-proxy auth, cost_report row shape, and teardown API shapes. Several findings contradict the design doc's assumptions (default role is admin not user; the policy endpoint must be reachable from client machines, not just the SkyPilot container) and are flagged for follow-up. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docs/dev/skypilot-spike.md | 367 ++++++++++++++++++ ...admin_policy_mutated_response_accepted.txt | 1 + .../admin_policy_raw_wire_body_example.txt | 1 + ...admin_policy_raw_wire_response_example.txt | 1 + .../admin_policy_reject_cli_output.txt | 3 + ...policy_request_launch_before_mutation.json | 15 + ...min_policy_request_launch_client_side.json | 15 + ...min_policy_request_launch_server_side.json | 15 + ...admin_policy_request_rejected_example.json | 15 + ...n_policy_request_validate_server_side.json | 15 + .../admin_policy_server_down_cli_output.txt | 3 + .../cost_report_request_response.json | 30 ++ ...service_account_token_create_response.json | 13 + ...aces_batch_add_users_success_response.json | 4 + ...tch_add_users_user_not_found_response.json | 5 + .../skypilot/workspaces_create_request.json | 7 + ...isable_all_clouds_except_vast_request.json | 16 + .../workspaces_list_as_member_response.json | 9 + ...workspaces_list_as_nonmember_response.json | 7 + ...s_update_forbidden_nonmember_response.json | 9 + 20 files changed, 551 insertions(+) create mode 100644 docs/dev/skypilot-spike.md create mode 100644 tests/fixtures/skypilot/admin_policy_mutated_response_accepted.txt create mode 100644 tests/fixtures/skypilot/admin_policy_raw_wire_body_example.txt create mode 100644 tests/fixtures/skypilot/admin_policy_raw_wire_response_example.txt create mode 100644 tests/fixtures/skypilot/admin_policy_reject_cli_output.txt create mode 100644 tests/fixtures/skypilot/admin_policy_request_launch_before_mutation.json create mode 100644 tests/fixtures/skypilot/admin_policy_request_launch_client_side.json create mode 100644 tests/fixtures/skypilot/admin_policy_request_launch_server_side.json create mode 100644 tests/fixtures/skypilot/admin_policy_request_rejected_example.json create mode 100644 tests/fixtures/skypilot/admin_policy_request_validate_server_side.json create mode 100644 tests/fixtures/skypilot/admin_policy_server_down_cli_output.txt create mode 100644 tests/fixtures/skypilot/cost_report_request_response.json create mode 100644 tests/fixtures/skypilot/service_account_token_create_response.json create mode 100644 tests/fixtures/skypilot/workspaces_batch_add_users_success_response.json create mode 100644 tests/fixtures/skypilot/workspaces_batch_add_users_user_not_found_response.json create mode 100644 tests/fixtures/skypilot/workspaces_create_request.json create mode 100644 tests/fixtures/skypilot/workspaces_disable_all_clouds_except_vast_request.json create mode 100644 tests/fixtures/skypilot/workspaces_list_as_member_response.json create mode 100644 tests/fixtures/skypilot/workspaces_list_as_nonmember_response.json create mode 100644 tests/fixtures/skypilot/workspaces_update_forbidden_nonmember_response.json diff --git a/docs/dev/skypilot-spike.md b/docs/dev/skypilot-spike.md new file mode 100644 index 0000000..074389c --- /dev/null +++ b/docs/dev/skypilot-spike.md @@ -0,0 +1,367 @@ +# SkyPilot integration spike + +Hands-on spike against a real, local SkyPilot API server, run to pin down facts +`docs/skypilot-integration.md` depends on. This is evidence, not a design doc — see +`docs/skypilot-integration.md` for the design itself, which this spike was used to check. + +**Tested version:** `skypilot==0.13.0` (`pip install "skypilot[vast]"`), installed 2026-09-26 via `uv` +into an isolated venv outside this repo. Python 3.12. Local API server (`sky.server.server`, +SQLite backing store — no Postgres wired up for the spike). No real Vast API key; all launches used +`--dryrun`. `sky check vast` reports "enabled" off a placeholder key in +`~/.config/vastai/vast_api_key` without making a network call. + +A shallow clone of `skypilot-org/skypilot` (dated 2026-09-25, i.e. **ahead of the 0.13.0 release**) +was used for reading source. Where the checkout and the installed 0.13.0 wheel disagree, this doc +says so explicitly — see Surprise #2, the biggest one. + +All captured request/response bodies live in `tests/fixtures/skypilot/`. + +## Confirmed / Not confirmed / Surprises + +**Confirmed (observed directly):** +- The admin-policy endpoint is called **both client-side (from the machine running `sky launch`) and + server-side** — two (or three, see below) HTTP POSTs per `sky launch`, from two different network + locations. See Surprise #1. +- `RestfulAdminPolicy` **double-JSON-encodes** the body in both directions. See Surprise #3 — this is + the single most load-bearing fact for building the endpoint. +- `request_name` values actually observed: `launch`, `validate`. (`jobs.launch`, `jobs.launch_controller`, + `exec` exist in the `AdminPolicyRequestName` enum but weren't triggered live — no `--dryrun` support + for `sky exec` / `sky jobs launch` in this version; see Not-confirmed.) +- The active workspace is **not reliably present** in the policy payload's `skypilot_config` — see + Surprise #2. +- User identity is present server-side (`user: {id, name, ...}` YAML), absent (empty string) + client-side. +- A mutated response (autodown + `max_hourly_cost`) is accepted and changes real launch behavior: + capping `max_hourly_cost` below the only available offer's price made `sky launch` fail with "No + resource satisfying ... max_cost=$0.5/hr", proving the mutation was parsed and enforced, not just + echoed. +- An HTTP 400 response blocks the launch and shows our message verbatim to the user. +- A policy server that's down (or unreachable) fails the launch closed, with `RestfulPolicyError` + raised **client-side**, before ever reaching the API server. +- Workspace `create`, `update`, `delete`, `batch_add_users`, `batch_remove_users`, `get` all work + against the plain local server with **no restart**, over plain REST, returning async request IDs + polled via `GET /api/get?request_id=...`. +- `create`/`update` (config-based) accept **arbitrary, never-logged-in emails** in `allowed_users`. + `batch_add_users`/`batch_remove_users` require the user to already exist **and take the internal + user_id, not the email** — see Surprise #4. +- There is **no per-workspace cloud allowlist**. Restricting a workspace to Vast only means setting + `disabled: true` on every other cloud individually; a workspace-level `allowed_clouds` key fails + schema validation ("did you mean `allowed_users`?"). +- Service-account token creation works with Basic Auth once `ENABLE_BASIC_AUTH=true` and + `ENABLE_SERVICE_ACCOUNTS=true` are both set — two separate flags. The created bearer token + (`sky_...`) works for subsequent calls once service accounts are enabled. +- **Default new-user role is `admin`, not `user`** — see Surprise #5. This is a real, unpatched + discrepancy from the design doc, not a testing artifact. +- A simulated oauth2-proxy correctly identifies two different SSO emails as two different SkyPilot + users (distinct hashed ids), and a non-member is `403 Forbidden` on a private workspace's mutating + endpoints and simply doesn't see it in `GET /workspaces`. +- `cost_report` returns `[]` cleanly with no clusters; reading `global_user_state.get_clusters_from_history` + confirms the actual row shape includes `workspace` and `user_name` (see Surprise #6 for why the + public docstring is misleading here). +- Most admin-plane endpoints (`workspaces/*`, `cost_report`, `status`, `down`, `jobs/queue`, + `jobs/cancel`) are **async**: `POST` returns `200` with a `null` body and an + `x-skypilot-request-id` header immediately; the real result is fetched via + `GET /api/get?request_id=...` and must be polled until `status` is `SUCCEEDED`/`FAILED`. A few + endpoints (`users/role`, `users/service-account-tokens` CRUD, `GET /users`) are plain synchronous + FastAPI handlers that return data directly despite also carrying the request-id header. +- The installed venv for `skypilot[vast]` is **453MB** and pulls in pandas, numpy, a second copy of + SQLAlchemy, two Postgres drivers (`asyncpg` *and* `psycopg2-binary`), grpc, and even a PDF library + (`borb`, 36MB) — see the SDK-vs-REST recommendation. + +**Not confirmed (would need more than this spike allows):** +- `sky exec` and `sky jobs launch` triggering the admin policy in practice: neither has a `--dryrun` + flag in 0.13.0 CLI or SDK, and `sky exec` needs an already-running cluster we can't create without + a real Vast rental. `exec`, `jobs.launch`, `jobs.launch_controller` are defined in the + `AdminPolicyRequestName` enum (from code) but not observed on the wire. +- Whether SkyPilot resource *labels* propagate to Vast (design doc's "labelled with the project ID, + if resource labels work on Vast" line) — not tested; would need a real instance. +- Full end-to-end member access: real oauth2-proxy + Weave OIDC + `sky api login` browser flow. We + simulated the oauth2-proxy *contract* (the `GET /oauth2/auth` call SkyPilot itself makes) with a + 10-line stub, which is enough to confirm the header contract and RBAC enforcement, but not a real + OIDC round trip. +- Whether autodown/`max_hourly_cost` are honored by an actual Vast rental (only confirmed that the + *catalog/optimizer* stage respects the cap; never got to real provisioning). +- `cost_report` drift against real Vast billing (needs real runs, explicitly out of scope for this + spike per the no-real-launches constraint). +- Whether SkyPilot resource labels / per-cluster tagging survive on Vast specifically. + +**Surprises:** +1. **The admin policy is called from the *client's own machine*, not just from the API server.** + `sky/client/sdk.py` invokes `admin_policy_utils.apply(..., at_client_side=True)` before the launch + request is even sent to the server — this runs a real outbound HTTP POST from wherever `sky launch` + is typed, e.g. a Ganymede member's laptop. **This breaks the design doc's security model**, which + assumes `admin_policy: http://portal:8000/internal/skypilot/policy` is reachable only on SkyPilot's + own internal Docker network. If it's Docker-internal-only, every member's `sky launch` will fail + client-side with `RestfulPolicyError` (connection refused) before the request even reaches the + server-side check. **The policy URL needs to be reachable from wherever members run the CLI**, not + just from the SkyPilot container. (A real `sky launch` in our test hit the endpoint 2–3 times: once + client-side as `launch`, once server-side as `validate`, once server-side as `launch` — the endpoint + must be idempotent/cheap for all of these, not just one call per launch.) +2. **In the released 0.13.0, `skypilot_config.active_workspace` is often simply absent from the policy + payload.** The design doc says the endpoint "reads `skypilot_config.active_workspace`" — true in + the *unreleased* master checkout (which added `skypilot_config.resolved_config()`, always filling in + `active_workspace`), but in 0.13.0 the function used is `skypilot_config.to_dict()`, which — per its + own docstring — omits the workspace unless it was **explicitly** set (e.g. `sky launch -w default`, + or `active_workspace:` in the client's local config). A plain `sky launch` with no `-w` flag sends a + `skypilot_config` YAML with **no workspace key at all**. Worse, even when `-w default` was passed, + the `validate` request (one of the 2–3 calls per launch) still omitted it — only the `launch` + request_name carried it. **Krater's policy endpoint cannot assume `active_workspace` is present**; + it must have a defined fallback (reject, or resolve some other way) for when the key is missing. + This is squarely a "check when SkyPilot ships a release with `resolved_config()`" item. +3. **The wire format double-JSON-encodes in both directions.** `RestfulAdminPolicy.validate_and_mutate` + does `requests.post(url, json=user_request.encode())`, and `UserRequest.encode()` already returns a + JSON string (`pydantic.model_dump_json()`). Passing a `str` as `json=` to `requests` serializes it + *again*, so the raw HTTP body Krater's endpoint receives is a **JSON string containing escaped + JSON**, e.g. `"{\"task\": \"...\", ...}"` — not a bare JSON object. Symmetrically, SkyPilot calls + `MutatedUserRequest.decode(response.json(), ...)`, and `decode()` requires a `str` — so Krater's + response body must *also* be a JSON-encoded string of the object, or SkyPilot fails with a Pydantic + `json_type` validation error before ever looking at the content. First attempt against a naively-JSON + endpoint failed with exactly this error; see `tests/fixtures/skypilot/admin_policy_raw_wire_body_example.txt` + / `_response_example.txt` for the exact bytes. +4. **`allowed_users` semantics differ by endpoint.** `workspaces/create` and `workspaces/update` accept + raw strings (emails, in Krater's case) in `allowed_users`, including ones for users who have never + logged in — SkyPilot logs a warning and grants access automatically on first login. But + `workspaces/batch_add_users` / `batch_remove_users` take `user_ids`, and these must be the + **internal DB hash id** (as shown by `GET /users`), not the email — passing an email produces + `"User does not exist"` even for a user Krater knows exists in Weave, if they haven't signed + into SkyPilot yet. Krater's provisioner should prefer `create`/`update` (which tolerate emails that + haven't logged in) over `batch_add_users` for pre-provisioning a team before they've ever used + SkyPilot. +5. **New users default to SkyPilot role `admin`, not `user`.** `sky.users.rbac.get_default_role()` + defaults to `RoleName.ADMIN` when `rbac.default_role` isn't set in the server config. We reproduced + this live: two brand-new SSO logins both landed as `admin` (full visibility into every workspace) + until we added + ```yaml + rbac: + default_role: user + ``` + to the server's `~/.sky/config.yaml` — after which a third brand-new login correctly got `user`, with + **no server restart needed** (the seeding path reloads config on each new-user login). **The design + doc's assumption ("SkyPilot's default role for new users is `user`") is wrong for the out-of-the-box + server; Krater's SkyPilot server config MUST explicitly set `rbac.default_role: user`**, or every new + Ganymede member becomes a SkyPilot admin on first sign-in. +6. **Raw REST calls that omit `env_vars` are silently attributed to whatever process happens to parse + the JSON — not to a real authenticated identity.** `payloads.RequestBody`'s Pydantic `__init__` + fills in a default `env_vars` (`SKYPILOT_USER_ID`/`SKYPILOT_USER`) from `os.environ` of **whichever + process constructs the model** — which, for a raw `curl` POST with no `env_vars` field, is the + **server process itself** (since FastAPI/Pydantic parses the body server-side). Every unauthenticated + `curl` we sent in this spike (no bearer token, no basic auth) against the plain local server was + silently attributed to the server's own local identity ("root", admin). This is presumably fine for + `sky api start`'s intended "trusted localhost" use case, but it means **an unauthenticated REST call + is not "denied" the way you'd expect — it's silently accepted as the server's own admin identity.** + Krater's client must always send a real bearer token (once service accounts are enabled) and the + server must have auth genuinely turned on; never rely on "no credentials supplied" failing safe. +7. Minor: `sky cost_report()`'s own docstring in `sky/core.py` lists the returned fields and **omits + `workspace`, `user_name`, `user_hash`, `status`, `priority`, `last_event`, `node_names`** — all of + which are actually present (confirmed by reading `global_user_state.get_clusters_from_history`, + which is what actually builds the row). Don't code against the docstring; code against the actual + dict. +8. Minor: creating a service-account token over Basic Auth **only works from a non-loopback client**. + `BasicAuthMiddleware` bypasses itself entirely for requests whose peer IP is loopback (127.0.0.1), + leaving `request.state.auth_user = None` regardless of any `Authorization` header supplied — and + `POST /users/service-account-tokens` explicitly requires `request.state.auth_user is not None`. Not + an issue for Krater in production (the worker calls a remote server, not itself), but a trap when + testing an API server on the same host you're calling from. +9. Minor: the local git checkout of `skypilot-org/skypilot` used for reading source (dated one day + before this spike) is **ahead of the installed 0.13.0 release** — `sky/server/auth/db_lookup.py` + and `skypilot_config.resolved_config()` exist in the checkout but not in 0.13.0. Several design-doc + statements (workspace resolution, the oauth2-proxy user-upsert path) read as true against the + checkout but need re-verification against whatever version Krater actually deploys. + +## 1. RESTful admin policy wire format + +**Setup.** A ~150-line stdlib `http.server` (`tests/fixtures/skypilot`'s captures came from it) bound +to `127.0.0.1:9911/policy`, configured via +```yaml +admin_policy: http://127.0.0.1:9911/policy?token=abc123token +``` +in the client's `~/.sky/config.yaml` (this is also what the API server reads, since they shared a +`$HOME` in this local spike — see Surprise #1 for why this matters in production, where client and +server are different machines with different configs). + +**Token arrives intact.** Confirmed: `query_token_received: "abc123token"` on every captured request — +a query-string token survives the round trip fine. This is the only auth mechanism available to +`RestfulAdminPolicy` (it sends no headers), so it's the right choice for Krater's endpoint. + +**Triggering it.** +- `sky launch --dryrun -y -c task.yaml` (task requesting `accelerators: A100:1`, `infra: vast`) + → 3 POSTs: `launch` (client-side), `validate` (server-side), `launch` (server-side). Full dryrun run + completes and prints the chosen Vast offer. +- `sky jobs launch --dryrun ...` — **not possible**: `sky jobs launch` has no `--dryrun` flag in this + version (CLI: `Error: No such option: --dryrun`; SDK: `sky.jobs.client.sdk.launch()` has no `dryrun` + parameter either). Not exercised. +- `sky exec --dryrun ...` — **not possible**: same, no `--dryrun` flag, and `exec` additionally needs an + existing cluster we don't have without a real launch. Not exercised. + +**Request bodies.** `request_name` values seen: `launch`, `validate`. Workspace: usually **absent** +from `skypilot_config` (see Surprise #2); when present, it's a top-level `active_workspace: ` key +inside the YAML string, itself embedded in the JSON. User identity: present only on server-side calls, +as a YAML string `id: \n\nname: \n\nuser_type: \n\npreferred_workspace: null\n` +under the `user` key — empty string client-side. See +`tests/fixtures/skypilot/admin_policy_request_*.json` for full examples (already YAML-decoded for +readability; `admin_policy_raw_wire_body_example.txt` has the actual double-encoded bytes as they +appear on the wire). + +**Mutation.** Returning +```json +{"task": "resources:\n ...\n autostop:\n idle_minutes: 30\n down: true\n max_hourly_cost: 2.0\n...", "skypilot_config": "..."} +``` +(double-JSON-encoded per Surprise #3) — note `autostop` and `max_hourly_cost` are **`resources`-level +task fields**, not task-top-level (`autostop:` at the task's top level is rejected with "Found +unsupported field 'autostop'"). Accepted: `sky launch --dryrun` completed with the resources table still +showing the same Vast offer, and separately, capping `max_hourly_cost: 0.5` (below the $0.93/hr offer) +correctly made the launch fail with "No resource satisfying Vast(...) on Vast" / "max_cost=$0.5/hr may +be too restrictive" — proof the mutated resources constraint round-tripped into the optimizer, not just +echoed back untouched. + +**Rejection.** HTTP 400 with a plain-text body: +``` +sky.exceptions.UserRequestRejectedByPolicy: User request is rejected by admin policy +http://127.0.0.1:9911/policy?token=abc123token: Rejected by Krater policy spike (seq=12): +project budget exceeded (simulated). +``` +Our message text is shown to the user verbatim after the "rejected by admin policy ..." preamble. + +**Client vs. server; policy server down.** Both — see Surprise #1. With the policy server killed, +`sky launch --dryrun` fails immediately (client-side, before any server round trip) with: +``` +[sky.exceptions.RestfulPolicyError] Failed to call admin policy URL http://127.0.0.1:9911/policy?token=abc123token: +HTTPConnectionPool(...): Failed to establish a new connection: [Errno 111] Connection refused +``` +This is the fail-closed behavior the design doc wants, but it means **every client machine**, not just +the API server, needs to be able to reach (or fail predictably against) the policy URL. + +## 2. Workspaces via the API + +All confirmed via raw REST against `http://127.0.0.1:46580` (no SDK module exists for workspaces in +0.13.0 — `sky.workspaces` has no `client/sdk.py`). Endpoints: `GET /workspaces`, `POST /workspaces/create`, +`/update`, `/delete`, `/batch_add_users`, `/batch_remove_users` — all under `POST`, all scheduled async +(`schedule_request_async`), returning `200` + `null` body + `x-skypilot-request-id` header immediately. +Poll with `GET /api/get?request_id=` until `"status": "SUCCEEDED"` (or `"FAILED"`, with a pickled +Python traceback in `error`). + +Payload shapes (`RequestBody` base fields — `env_vars`, `entrypoint`, etc. — omitted below for clarity): +```jsonc +// POST /workspaces/create, /workspaces/update +{"workspace_name": "ganymede-", "config": {"private": true, "allowed_users": ["a@x.com", "b@x.com"]}} +// POST /workspaces/delete +{"workspace_name": "ganymede-"} +// POST /workspaces/batch_add_users, /workspaces/batch_remove_users +{"workspace_names": ["ganymede-"], "user_ids": [""]} +``` +Full create/update/delete/batch lifecycle exercised with **no server restart** at any point. + +`allowed_users` via `create`/`update` accepts **any string**, including emails that have never signed +in — SkyPilot logs `"...does not match any existing user record; skipping for now. Access will be +granted automatically once this user logs in."` and grants it retroactively. `batch_add_users`/ +`batch_remove_users` require `user_ids` to already resolve to a real user record (see Surprise #4); +`GET /users` is how you find the internal id for an email. + +Disabling all clouds but Vast in a workspace: no allowlist exists; deny-list every other cloud: +```json +{"private": true, "allowed_users": [...], "aws": {"disabled": true}, "gcp": {"disabled": true}, "...(all 21 others)...": {"disabled": true}} +``` +Confirmed effective: `sky check --workspace ` after this update reports only `Vast [compute]` +enabled, with a note listing all the disabled clouds by name. + +## 3. Auth and service accounts + +- **Auth off (default local server):** creating a service-account token (`POST + /users/service-account-tokens`) returns **401 "Authentication required"** — this endpoint checks + `request.state.auth_user is not None` directly rather than falling back to a default identity the + way the async-scheduled endpoints do (see Surprise #6). So: **no**, you cannot create a token with + auth fully off, even though plenty of *other* admin actions (creating workspaces, etc.) silently + succeed as an implicit "root" identity in that mode. +- **With Basic Auth on** (`ENABLE_BASIC_AUTH=true`): still 401 if the request looks like it came from + loopback (see Surprise #8) — needs a non-loopback client (in a real deployment, Krater's worker + calling a remote/containerized SkyPilot server naturally qualifies). Once past that, `-u + user:password` against `POST /users/service-account-tokens` succeeds and returns a `sky_...` bearer + token (JWT-shaped, `sky_` prefix). Presenting that bearer token then **additionally** requires + `ENABLE_SERVICE_ACCOUNTS=true` on the server or you get 401 "Service account authentication + disabled" — two independent flags. +- **`SKYPILOT_AUTH_OAUTH2_PROXY_ENABLED` / `_BASE_URL`:** confirmed from `sky/server/auth/oauth2_proxy.py` + and reproduced live. The API server calls `GET {base_url}/oauth2/auth` with `X-Forwarded-Uri`, `Host`, + and the original request's cookies (not arbitrary custom headers — a first attempt using a custom + header instead of a cookie was silently ignored). A `202` with `X-Auth-Request-Email: ` header + authenticates the request as that email; `401` triggers a redirect to `{base_url}/oauth2/start`. + **Loopback requests bypass this middleware entirely too** — testing from the same host as the server + requires spoofing `X-Forwarded-For` to defeat the loopback check, or the auth flow silently never + triggers. +- **Two-user simulation:** stood up a ~50-line stub implementing that one `GET /oauth2/auth` contract, + had it trust a test-only cookie for "which email is this," and confirmed two different emails map to + two different SkyPilot user records with distinct hashed ids (`0bd95ce5` / `d6ce4225` for two test + emails). Non-member blocked: after creating a private workspace with `allowed_users` set to only one + of the two users' internal ids, the non-member's `POST /workspaces/update` against that workspace + returned `{"detail": "Forbidden"}`, and `GET /workspaces` for the non-member simply omitted it + (showing only `default`), while the member's listing included it. +- `sky.users.rbac.get_default_role()` — see Surprise #5. + +## 4. cost_report + +`POST /cost_report` (async; body `{"days": 30}` + base fields) returned `[]` (no clusters ever +launched — dryrun launches don't create billing/history rows). Reading +`sky.global_user_state.get_clusters_from_history` (what actually builds each row, feeding +`sky.core.cost_report`) confirms the row shape includes (not just the docstring's list — see +Surprise #7): `name`, `launched_at`, `duration`, `num_nodes`, `resources`, `priority`, +`priority_class`, `cluster_hash`, `usage_intervals`, `status`, `user_hash`, **`user_name`**, +**`workspace`**, `last_event`, `node_names`, plus `total_cost` (computed in `sky.core.cost_report` +from `resources` + `duration`). `workspace` comes from `cluster_history.workspace`, falling back to +the live `cluster.workspace` if the history row predates that column. `exclude_managed_clusters=True` +is the documented way to drop clusters "launched by a controller (managed jobs and services)" — i.e. +managed-job worker clusters normally **do** show up per-workspace by default; only the **jobs +controller cluster itself** (the singleton queue/orchestrator) is a separate, unattributed cluster — +matching the design doc's claim, though not verified against a live managed job (no `--dryrun` +support; out of scope per the no-real-launches constraint). Whether an admin sees all users' clusters: +not directly tested (no other users' real clusters existed to check against), but the query has no +per-caller filter beyond RBAC/workspace visibility, so an admin (unrestricted workspace access) should +see everything — consistent with the design doc, "(from code)". + +## 5. Teardown APIs + +- **List clusters filtered by workspace:** `POST /status` (`StatusBody`: `cluster_names`, `refresh`, + `all_users`, `summary_response`, ... — **no `workspace` field**). Workspace scoping instead comes + from the request's *active workspace* context (the same `override_skypilot_config: {"active_workspace": + "..."}` / `-w` mechanism used for launches), which the server resolves per-request in + `override_request_env_and_config` before your handler ever runs. Async. +- **List managed jobs filtered by workspace:** `POST /jobs/queue` / `/jobs/queue/v2` (`refresh`, + `skip_finished`, `all_users`, `job_ids` — again no explicit `workspace` field; same active-workspace + mechanism). Async. +- **`down` a cluster:** `POST /down`, body `StopOrDownBody {cluster_name, purge, graceful, + graceful_timeout}`. Async. +- **Cancel managed jobs:** `POST /jobs/cancel`, body `JobsCancelBody {name, job_ids, all, all_users, + pool, graceful, graceful_timeout}`. Async. + +All four are scheduled through the same `executor.schedule_request_async` pattern as the workspace +endpoints — `200` + `null` + `x-skypilot-request-id`, then poll `GET /api/get?request_id=...`. + +## 6. SDK vs REST + +**Recommendation: plain REST via `httpx`, not the `skypilot` Python package**, for everything Krater +calls *outbound* (workspace CRUD, `cost_report`, `status`/`down`/`jobs/queue`/`jobs/cancel`). Reasons, +in order of weight: + +1. **Dependency weight and version collisions.** The `skypilot[vast]` venv is **453MB**. It bundles a + second, independently-pinned copy of SQLAlchemy (33MB) and **two different Postgres drivers** + (`asyncpg` and `psycopg2-binary`, ~24MB combined) — both of which risk colliding with Krater's own + pinned `SQLAlchemy 2.0` + `psycopg` (v3) stack per `CLAUDE.md`. It also pulls pandas (49MB), numpy + (60MB combined with its native libs), grpc (18MB), and — oddly for a client library — a PDF + generation package, `borb` (36MB), plus `fontTools` (23MB). None of this is needed to POST a JSON + body and poll a request id. +2. **No SDK exists for half of what Krater needs anyway.** `sky.workspaces` has no `client/sdk.py` at + all in 0.13.0 — workspace CRUD is REST-only regardless of which approach Krater picks. +3. **The auth story is a single bearer header.** Once service accounts are enabled server-side, it's + `Authorization: Bearer sky_...` — trivial with `httpx`, nothing SDK-specific needed. +4. **The async pattern is simple and now fully documented above**: POST, read + `x-skypilot-request-id`, poll `GET /api/get?request_id=...` for `status`. No SDK-specific + client state required. +5. **The one place that genuinely wants SkyPilot's own (de)serialization logic is the admin-policy + *receiver*** (Krater is the server here, not the client) — decoding `UserRequest` and encoding + `MutatedUserRequest` per Surprise #3. But that logic is now fully reverse-engineered (double + JSON-encode/decode of a small YAML-in-JSON envelope) and is roughly 15 lines of plain `json` + + `yaml` — not worth a 453MB dependency (which, per point 1, transitively imports FastAPI, uvicorn, + and SkyPilot's own multiprocessing job-queue machinery just to construct a `Task` object). **Krater + should reimplement the tiny envelope format natively** (this spike's fixtures document the exact + shape) rather than `import sky`. + +If a future need arises for something genuinely SDK-only (there wasn't one found in this spike), +re-evaluate then rather than paying the dependency cost up front. diff --git a/tests/fixtures/skypilot/admin_policy_mutated_response_accepted.txt b/tests/fixtures/skypilot/admin_policy_mutated_response_accepted.txt new file mode 100644 index 0000000..bae8bd9 --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_mutated_response_accepted.txt @@ -0,0 +1 @@ +"{\"task\": \"resources:\\n infra: vast\\n accelerators:\\n A100: 1\\n disk_size: 256\\n max_hourly_cost: 2.0\\n autostop:\\n idle_minutes: 30\\n down: true\\n\\nnum_nodes: 1\\n\\nrun: 'echo hello from krater spike\\n\\n '\\n\\nfile_mounts: {}\\n\\nvolumes: {}\\n\\n_user_specified_yaml: \\\"resources:\\\\n cloud: vast\\\\n accelerators: A100:1\\\\n\\\\nrun: 'echo\\\\\\n \\\\ hello from krater spike\\\\n\\\\n '\\\\n\\\"\\n\", \"skypilot_config\": \"admin_policy: http://127.0.0.1:9911/policy?token=abc123token\\n\\nworkspaces:\\n ganymede-priv-test:\\n private: true\\n allowed_users:\\n - 54400d50\\n ganymede-priv-test2:\\n private: true\\n allowed_users:\\n - 54400d50\\n\\nrbac:\\n default_role: user\\n\"}" \ No newline at end of file diff --git a/tests/fixtures/skypilot/admin_policy_raw_wire_body_example.txt b/tests/fixtures/skypilot/admin_policy_raw_wire_body_example.txt new file mode 100644 index 0000000..cbe2f9b --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_raw_wire_body_example.txt @@ -0,0 +1 @@ +"{\"task\":\"resources:\\n infra: vast\\n accelerators:\\n A100: 1\\n disk_size: 256\\n\\nnum_nodes: 1\\n\\nrun: 'echo hello from krater spike\\n\\n '\\n\\nfile_mounts: {}\\n\\nvolumes: {}\\n\\n_user_specified_yaml: \\\"resources:\\\\n cloud: vast\\\\n accelerators: A100:1\\\\n\\\\nrun: 'echo\\\\\\n \\\\ hello from krater spike\\\\n\\\\n '\\\\n\\\"\\n\",\"skypilot_config\":\"admin_policy: http://127.0.0.1:9911/policy?token=abc123token\\n\\nworkspaces:\\n ganymede-priv-test:\\n private: true\\n allowed_users:\\n - 54400d50\\n ganymede-priv-test2:\\n private: true\\n allowed_users:\\n - 54400d50\\n\\nrbac:\\n default_role: user\\n\",\"request_name\":\"launch\",\"request_options\":{\"cluster_name\":\"spike-fixture-echo\",\"idle_minutes_to_autostop\":null,\"down\":false,\"dryrun\":true},\"at_client_side\":true,\"user\":\"\",\"client_api_version\":null,\"client_version\":null}" \ No newline at end of file diff --git a/tests/fixtures/skypilot/admin_policy_raw_wire_response_example.txt b/tests/fixtures/skypilot/admin_policy_raw_wire_response_example.txt new file mode 100644 index 0000000..ed44667 --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_raw_wire_response_example.txt @@ -0,0 +1 @@ +"{\"task\": \"resources:\\n infra: vast\\n accelerators:\\n A100: 1\\n disk_size: 256\\n\\nnum_nodes: 1\\n\\nrun: 'echo hello from krater spike\\n\\n '\\n\\nfile_mounts: {}\\n\\nvolumes: {}\\n\\n_user_specified_yaml: \\\"resources:\\\\n cloud: vast\\\\n accelerators: A100:1\\\\n\\\\nrun: 'echo\\\\\\n \\\\ hello from krater spike\\\\n\\\\n '\\\\n\\\"\\n\", \"skypilot_config\": \"admin_policy: http://127.0.0.1:9911/policy?token=abc123token\\n\\nworkspaces:\\n ganymede-priv-test:\\n private: true\\n allowed_users:\\n - 54400d50\\n ganymede-priv-test2:\\n private: true\\n allowed_users:\\n - 54400d50\\n\\nrbac:\\n default_role: user\\n\"}" \ No newline at end of file diff --git a/tests/fixtures/skypilot/admin_policy_reject_cli_output.txt b/tests/fixtures/skypilot/admin_policy_reject_cli_output.txt new file mode 100644 index 0000000..2027e9f --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_reject_cli_output.txt @@ -0,0 +1,3 @@ +YAML to run: task.yaml +Applying client admin policy: RestfulAdminPolicy +sky.exceptions.UserRequestRejectedByPolicy: User request is rejected by admin policy http://127.0.0.1:9911/policy?token=abc123token: Rejected by Krater policy spike (seq=12): project budget exceeded (simulated). diff --git a/tests/fixtures/skypilot/admin_policy_request_launch_before_mutation.json b/tests/fixtures/skypilot/admin_policy_request_launch_before_mutation.json new file mode 100644 index 0000000..7b5aaba --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_request_launch_before_mutation.json @@ -0,0 +1,15 @@ +{ + "task": "resources:\n infra: vast\n accelerators:\n A100: 1\n disk_size: 256\n\nnum_nodes: 1\n\nrun: 'echo hello from krater spike\n\n '\n\nfile_mounts: {}\n\nvolumes: {}\n\n_user_specified_yaml: \"resources:\\n cloud: vast\\n accelerators: A100:1\\n\\nrun: 'echo\\\n \\ hello from krater spike\\n\\n '\\n\"\n", + "skypilot_config": "admin_policy: http://127.0.0.1:9911/policy?token=abc123token\n\nworkspaces:\n ganymede-priv-test:\n private: true\n allowed_users:\n - 54400d50\n ganymede-priv-test2:\n private: true\n allowed_users:\n - 54400d50\n\nrbac:\n default_role: user\n", + "request_name": "launch", + "request_options": { + "cluster_name": "spike-fixture-mutate2", + "idle_minutes_to_autostop": null, + "down": false, + "dryrun": true + }, + "at_client_side": true, + "user": "", + "client_api_version": null, + "client_version": null +} \ No newline at end of file diff --git a/tests/fixtures/skypilot/admin_policy_request_launch_client_side.json b/tests/fixtures/skypilot/admin_policy_request_launch_client_side.json new file mode 100644 index 0000000..fa58f22 --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_request_launch_client_side.json @@ -0,0 +1,15 @@ +{ + "task": "resources:\n infra: vast\n accelerators:\n A100: 1\n disk_size: 256\n\nnum_nodes: 1\n\nrun: 'echo hello from krater spike\n\n '\n\nfile_mounts: {}\n\nvolumes: {}\n\n_user_specified_yaml: \"resources:\\n cloud: vast\\n accelerators: A100:1\\n\\nrun: 'echo\\\n \\ hello from krater spike\\n\\n '\\n\"\n", + "skypilot_config": "admin_policy: http://127.0.0.1:9911/policy?token=abc123token\n\nworkspaces:\n ganymede-priv-test:\n private: true\n allowed_users:\n - 54400d50\n ganymede-priv-test2:\n private: true\n allowed_users:\n - 54400d50\n\nrbac:\n default_role: user\n", + "request_name": "launch", + "request_options": { + "cluster_name": "spike-fixture-echo", + "idle_minutes_to_autostop": null, + "down": false, + "dryrun": true + }, + "at_client_side": true, + "user": "", + "client_api_version": null, + "client_version": null +} \ No newline at end of file diff --git a/tests/fixtures/skypilot/admin_policy_request_launch_server_side.json b/tests/fixtures/skypilot/admin_policy_request_launch_server_side.json new file mode 100644 index 0000000..af737d7 --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_request_launch_server_side.json @@ -0,0 +1,15 @@ +{ + "task": "resources:\n infra: vast\n accelerators:\n A100: 1\n disk_size: 256\n\nnum_nodes: 1\n\nrun: 'echo hello from krater spike\n\n '\n\nfile_mounts: {}\n\nvolumes: {}\n\n_user_specified_yaml: \"resources:\\n cloud: vast\\n accelerators: A100:1\\n\\nrun: 'echo\\\n \\ hello from krater spike\\n\\n '\\n\"\n", + "skypilot_config": "admin_policy: http://127.0.0.1:9911/policy?token=abc123token\n\nworkspaces:\n ganymede-priv-test:\n private: true\n allowed_users:\n - 54400d50\n ganymede-priv-test2:\n private: true\n allowed_users:\n - 54400d50\n\nrbac:\n default_role: user\n", + "request_name": "launch", + "request_options": { + "cluster_name": "spike-fixture-echo", + "idle_minutes_to_autostop": null, + "down": false, + "dryrun": true + }, + "at_client_side": false, + "user": "id: fcd1e5cf\n\nname: root\n\nuser_type: system\n\npreferred_workspace: null\n", + "client_api_version": null, + "client_version": "unknown" +} \ No newline at end of file diff --git a/tests/fixtures/skypilot/admin_policy_request_rejected_example.json b/tests/fixtures/skypilot/admin_policy_request_rejected_example.json new file mode 100644 index 0000000..ce483a4 --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_request_rejected_example.json @@ -0,0 +1,15 @@ +{ + "task": "resources:\n infra: vast\n accelerators:\n A100: 1\n disk_size: 256\n\nnum_nodes: 1\n\nrun: 'echo hello from krater spike\n\n '\n\nfile_mounts: {}\n\nvolumes: {}\n\n_user_specified_yaml: \"resources:\\n cloud: vast\\n accelerators: A100:1\\n\\nrun: 'echo\\\n \\ hello from krater spike\\n\\n '\\n\"\n", + "skypilot_config": "admin_policy: http://127.0.0.1:9911/policy?token=abc123token\n\nworkspaces:\n ganymede-priv-test:\n private: true\n allowed_users:\n - 54400d50\n ganymede-priv-test2:\n private: true\n allowed_users:\n - 54400d50\n\nrbac:\n default_role: user\n", + "request_name": "launch", + "request_options": { + "cluster_name": "spike-fixture-reject", + "idle_minutes_to_autostop": null, + "down": false, + "dryrun": true + }, + "at_client_side": true, + "user": "", + "client_api_version": null, + "client_version": null +} \ No newline at end of file diff --git a/tests/fixtures/skypilot/admin_policy_request_validate_server_side.json b/tests/fixtures/skypilot/admin_policy_request_validate_server_side.json new file mode 100644 index 0000000..ffa5a9c --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_request_validate_server_side.json @@ -0,0 +1,15 @@ +{ + "task": "resources:\n infra: vast\n accelerators:\n A100: 1\n disk_size: 256\n\nnum_nodes: 1\n\nrun: 'echo hello from krater spike\n\n '\n\nfile_mounts: {}\n\nvolumes: {}\n\n_user_specified_yaml: \"resources:\\n cloud: vast\\n accelerators: A100:1\\n\\nrun: 'echo\\\n \\ hello from krater spike\\n\\n '\\n\"\n", + "skypilot_config": "admin_policy: http://127.0.0.1:9911/policy?token=abc123token\n\nworkspaces: {}\n\nrbac:\n default_role: user\n", + "request_name": "validate", + "request_options": { + "cluster_name": "spike-fixture-echo", + "idle_minutes_to_autostop": null, + "down": false, + "dryrun": true + }, + "at_client_side": false, + "user": "id: fcd1e5cf\n\nname: root\n\nuser_type: null\n\npreferred_workspace: null\n", + "client_api_version": 56, + "client_version": "0.13.0" +} \ No newline at end of file diff --git a/tests/fixtures/skypilot/admin_policy_server_down_cli_output.txt b/tests/fixtures/skypilot/admin_policy_server_down_cli_output.txt new file mode 100644 index 0000000..335119e --- /dev/null +++ b/tests/fixtures/skypilot/admin_policy_server_down_cli_output.txt @@ -0,0 +1,3 @@ +YAML to run: task.yaml +Applying client admin policy: RestfulAdminPolicy +sky.exceptions.UserRequestRejectedByPolicy: User request rejected by policy RestfulAdminPolicy(policy_url=http://127.0.0.1:9911/policy?token=abc123token): [sky.exceptions.RestfulPolicyError] Failed to call admin policy URL http://127.0.0.1:9911/policy?token=abc123token: HTTPConnectionPool(host='127.0.0.1', port=9911): Max retries exceeded with url: /policy?token=abc123token (Caused by NewConnectionError("HTTPConnection(host='127.0.0.1', port=9911): Failed to establish a new connection: [Errno 111] Connection refused")) diff --git a/tests/fixtures/skypilot/cost_report_request_response.json b/tests/fixtures/skypilot/cost_report_request_response.json new file mode 100644 index 0000000..3b4f402 --- /dev/null +++ b/tests/fixtures/skypilot/cost_report_request_response.json @@ -0,0 +1,30 @@ +{ + "_note": "POST /cost_report is async: this captures the request body we sent and the polled GET /api/get?request_id=... result. No clusters were ever launched (dryrun-only spike), so return_value is legitimately empty. Full request/response envelope fields (entrypoint, request_body) are omitted here since they're SkyPilot-internal pickled blobs, not useful as a fixture -- see docs/dev/skypilot-spike.md section 4 for the actual row shape, confirmed by reading sky.global_user_state.get_clusters_from_history.", + "request": { + "days": 30 + }, + "poll_result": { + "status": "SUCCEEDED", + "return_value": "[]", + "error": "null" + }, + "row_shape_from_code": { + "name": "str: cluster name", + "launched_at": "int: last launch timestamp", + "duration": "int: total seconds up", + "num_nodes": "int", + "resources": "pickled sky.Resources", + "priority": "int | null", + "priority_class": "str | null", + "cluster_hash": "str", + "usage_intervals": "list[[int, int|null]]", + "status": "sky.status_lib.ClusterStatus | null", + "user_hash": "str (falls back to the CALLING user's hash if the row itself has none)", + "user_name": "str | null", + "workspace": "str (cluster_history.workspace, falling back to the live cluster.workspace)", + "last_event": "dict | null", + "node_names": "str", + "total_cost": "float, computed as resources.get_cost(duration) * num_nodes" + }, + "source": "sky.core.cost_report (docstring is stale/incomplete -- omits workspace/user_name/etc, see Surprise #7) + sky.global_user_state.get_clusters_from_history (actual row builder)" +} diff --git a/tests/fixtures/skypilot/service_account_token_create_response.json b/tests/fixtures/skypilot/service_account_token_create_response.json new file mode 100644 index 0000000..55a39c0 --- /dev/null +++ b/tests/fixtures/skypilot/service_account_token_create_response.json @@ -0,0 +1,13 @@ +{ + "note": "Response from POST /users/service-account-tokens as a Basic-Auth-authenticated user, from a non-loopback client (loopback requests bypass BasicAuthMiddleware entirely and this endpoint 401s with auth_user=None). Requires ENABLE_BASIC_AUTH=true and ENABLE_SERVICE_ACCOUNTS=true on the server.", + "response": { + "token_id": "XP_Tz1VrJ3rNZYtd", + "token_name": "krater-admin-spike2", + "token": "sky_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpIjoic2t5IiwidCI6MTc5MDQ1NjAxNywidSI6InNhLTdlMWFmY2VjMTM5MDAzNjMiLCJrIjoiWFBfVHoxVnJKM3JOWll0ZCIsInkiOiJzYSJ9.AhMIl3AmIUMNlRjDMbXSlrUSjFWXIjFJmDR-_tm1uLg", + "expires_at": null, + "service_account_user_id": "sa-7e1afcec13900363", + "creator_user_id": "c160f8cc", + "message": "Please save this token - it will not be shown again!" + }, + "bearer_usage": "Authorization: Bearer sky_eyJ... (also requires ENABLE_SERVICE_ACCOUNTS=true on the server or you get 401 'Service account authentication disabled')" +} diff --git a/tests/fixtures/skypilot/workspaces_batch_add_users_success_response.json b/tests/fixtures/skypilot/workspaces_batch_add_users_success_response.json new file mode 100644 index 0000000..6533b2c --- /dev/null +++ b/tests/fixtures/skypilot/workspaces_batch_add_users_success_response.json @@ -0,0 +1,4 @@ +{ + "request": {"workspace_names": ["ganymede-spike"], "user_ids": ["c160f8cc"]}, + "response.return_value": {"succeeded": ["ganymede-spike"], "failed": []} +} diff --git a/tests/fixtures/skypilot/workspaces_batch_add_users_user_not_found_response.json b/tests/fixtures/skypilot/workspaces_batch_add_users_user_not_found_response.json new file mode 100644 index 0000000..36b1f6e --- /dev/null +++ b/tests/fixtures/skypilot/workspaces_batch_add_users_user_not_found_response.json @@ -0,0 +1,5 @@ +{ + "note": "batch_add_users_to_workspaces requires the INTERNAL user_id (the DB hash id from GET /users), not an email/username. Passing an email that hasn't been resolved to an existing user record fails synchronously inside the async request with this per-workspace error shape.", + "request": {"workspace_names": ["ganymede-spike"], "user_ids": ["alice@example.com"]}, + "response.return_value": {"succeeded": [], "failed": [{"workspace_name": "-", "error": "User alice@example.com does not exist"}]} +} diff --git a/tests/fixtures/skypilot/workspaces_create_request.json b/tests/fixtures/skypilot/workspaces_create_request.json new file mode 100644 index 0000000..9d72bc4 --- /dev/null +++ b/tests/fixtures/skypilot/workspaces_create_request.json @@ -0,0 +1,7 @@ +{ + "workspace_name": "ganymede-priv-test", + "config": { + "private": true, + "allowed_users": ["54400d50"] + } +} diff --git a/tests/fixtures/skypilot/workspaces_disable_all_clouds_except_vast_request.json b/tests/fixtures/skypilot/workspaces_disable_all_clouds_except_vast_request.json new file mode 100644 index 0000000..402eb20 --- /dev/null +++ b/tests/fixtures/skypilot/workspaces_disable_all_clouds_except_vast_request.json @@ -0,0 +1,16 @@ +{ + "workspace_name": "ganymede-spike", + "config": { + "private": true, + "allowed_users": ["alice@example.com", "bob@example.com"], + "aws": {"disabled": true}, "azure": {"disabled": true}, "cudo": {"disabled": true}, + "do": {"disabled": true}, "fluidstack": {"disabled": true}, "gcp": {"disabled": true}, + "hyperbolic": {"disabled": true}, "ibm": {"disabled": true}, "kubernetes": {"disabled": true}, + "lambda": {"disabled": true}, "mithril": {"disabled": true}, "nebius": {"disabled": true}, + "oci": {"disabled": true}, "paperspace": {"disabled": true}, "primeintellect": {"disabled": true}, + "runpod": {"disabled": true}, "scp": {"disabled": true}, "seeweb": {"disabled": true}, + "shadeform": {"disabled": true}, "ssh": {"disabled": true}, "vsphere": {"disabled": true}, + "yotta": {"disabled": true} + }, + "note": "There is no per-workspace allowlist (e.g. 'allowed_clouds') -- attempting that key fails schema validation with \"did you mean 'allowed_users'?\". You must deny-list every other cloud individually via .disabled=true. Confirmed effective via `sky check --workspace `." +} diff --git a/tests/fixtures/skypilot/workspaces_list_as_member_response.json b/tests/fixtures/skypilot/workspaces_list_as_member_response.json new file mode 100644 index 0000000..09f7ec5 --- /dev/null +++ b/tests/fixtures/skypilot/workspaces_list_as_member_response.json @@ -0,0 +1,9 @@ +{ + "_note": "GET /workspaces as frank@ganymede.example (id 54400d50), who IS in allowed_users of both private workspaces below. Simulated via a stub oauth2-proxy (see docs/dev/skypilot-spike.md section 3) with a non-loopback X-Forwarded-For header. Async: GET returned x-skypilot-request-id, polled via GET /api/get?request_id=...", + "poll_result_status": "SUCCEEDED", + "return_value": { + "ganymede-priv-test": {"private": true, "allowed_users": ["54400d50"]}, + "ganymede-priv-test2": {"private": true, "allowed_users": ["54400d50"]}, + "default": {} + } +} diff --git a/tests/fixtures/skypilot/workspaces_list_as_nonmember_response.json b/tests/fixtures/skypilot/workspaces_list_as_nonmember_response.json new file mode 100644 index 0000000..9699ea8 --- /dev/null +++ b/tests/fixtures/skypilot/workspaces_list_as_nonmember_response.json @@ -0,0 +1,7 @@ +{ + "_note": "GET /workspaces as grace@ganymede.example (id eea72432), who is NOT in allowed_users of either private workspace. Same request as workspaces_list_as_member_response.json, different simulated identity. She simply doesn't see the private workspaces at all -- no error, they're absent from the listing.", + "poll_result_status": "SUCCEEDED", + "return_value": { + "default": {} + } +} diff --git a/tests/fixtures/skypilot/workspaces_update_forbidden_nonmember_response.json b/tests/fixtures/skypilot/workspaces_update_forbidden_nonmember_response.json new file mode 100644 index 0000000..9f24f04 --- /dev/null +++ b/tests/fixtures/skypilot/workspaces_update_forbidden_nonmember_response.json @@ -0,0 +1,9 @@ +{ + "_note": "POST /workspaces/update as grace@ganymede.example (non-member) against the private workspace 'ganymede-priv-test', which only frank@ganymede.example (allowed_users) belongs to. This is rejected SYNCHRONOUSLY at the HTTP layer -- no x-skypilot-request-id is even issued, unlike a normal (authorized) call to this same endpoint.", + "request": { + "workspace_name": "ganymede-priv-test", + "config": {"private": true, "allowed_users": ["54400d50", "eea72432"]} + }, + "http_status": 403, + "http_body": {"detail": "Forbidden"} +} From 84e5195e9d53676991ef8370f7ade5af1f5fa77a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:07:51 +0000 Subject: [PATCH 028/124] Add temporary SeaweedFS storage container for screenshots S3-compatible storage in docker-compose (plus a one-shot bucket-init service) until a long-term provider is chosen. SeaweedFS rather than MinIO, whose community edition stopped publishing images in 2025. Credentials come from the env; the S3 gateway config is written at container start so no secret lives in the repo. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- .env.example | 10 ++++++++++ docker-compose.yml | 39 +++++++++++++++++++++++++++++++++++++++ docs/SPEC.md | 6 ++++-- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index fc2c34a..1f7d1a3 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,16 @@ KRATER_WEAVE_API_BASE_URL= KRATER_WEAVE_SERVICE_KEY= KRATER_WEAVE_STUB_USERS_FILE= +# S3-compatible object storage for gallery screenshots (not wired into the app yet). +# docker-compose runs a temporary SeaweedFS container as `storage`. The app talks to it on the internal endpoint; +# browsers get presigned URLs on the public one. Change the secret for anything beyond local dev. +KRATER_S3_ENDPOINT_URL=http://storage:8333 +KRATER_S3_PUBLIC_ENDPOINT_URL=http://localhost:8333 +KRATER_S3_REGION=us-east-1 +KRATER_S3_BUCKET=krater-screenshots +KRATER_S3_ACCESS_KEY_ID=krater +KRATER_S3_SECRET_ACCESS_KEY=krater-dev-secret + # docker-compose only: the `db` service's own credentials. POSTGRES_USER=krater POSTGRES_PASSWORD=krater diff --git a/docker-compose.yml b/docker-compose.yml index 4b532cc..2d9c23e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,5 +38,44 @@ services: migrate: condition: service_completed_successfully + # Temporary self-hosted S3-compatible storage (gallery screenshots) until a real provider is chosen; see + # docs/SPEC.md "Open questions". SeaweedFS rather than MinIO: MinIO stopped publishing community images in 2025. + storage: + image: chrislusf/seaweedfs:${SEAWEEDFS_VERSION:-latest} + environment: + S3_ACCESS_KEY: ${KRATER_S3_ACCESS_KEY_ID:-krater} + S3_SECRET_KEY: ${KRATER_S3_SECRET_ACCESS_KEY:-krater-dev-secret} + # The S3 gateway reads its credentials from a JSON file; write it from the env so no secret lives in the repo. + entrypoint: ["/bin/sh", "-c"] + command: + - >- + printf '{"identities":[{"name":"krater","credentials":[{"accessKey":"%s","secretKey":"%s"}],"actions":["Admin","Read","List","Tagging","Write"]}]}' + "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" > /tmp/s3.json && + exec weed server -dir=/data -master.volumeSizeLimitMB=1024 -s3 -s3.port=8333 -s3.config=/tmp/s3.json + ports: + - "${S3_PORT:-8333}:8333" + volumes: + - krater_storage_data:/data + + # One-shot: create the bucket. Safe to re-run. + storage-init: + image: amazon/aws-cli:${AWS_CLI_VERSION:-latest} + environment: + AWS_ACCESS_KEY_ID: ${KRATER_S3_ACCESS_KEY_ID:-krater} + AWS_SECRET_ACCESS_KEY: ${KRATER_S3_SECRET_ACCESS_KEY:-krater-dev-secret} + AWS_DEFAULT_REGION: ${KRATER_S3_REGION:-us-east-1} + BUCKET: ${KRATER_S3_BUCKET:-krater-screenshots} + entrypoint: ["/bin/sh", "-c"] + command: + - >- + for i in $$(seq 1 30); do + aws --endpoint-url http://storage:8333 s3api head-bucket --bucket "$$BUCKET" 2>/dev/null && exit 0; + aws --endpoint-url http://storage:8333 s3 mb "s3://$$BUCKET" 2>/dev/null && exit 0; + sleep 2; + done; echo "storage not reachable" >&2; exit 1 + depends_on: + - storage + volumes: krater_db_data: + krater_storage_data: diff --git a/docs/SPEC.md b/docs/SPEC.md index c221e41..5ed6348 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -236,6 +236,7 @@ arm64; SkyPilot publishes arm64 images, so that works. | `worker` | Same image, runs background jobs: Slack work, the spend reconciler, reviewer-channel sync | | `db` | Postgres | | `skypilot` | SkyPilot API server with the Vast credentials; out of scope except for its configuration | +| `storage` | Temporary self-hosted S3-compatible storage (SeaweedFS) for gallery screenshots, until a provider is chosen | | `auth-proxy` | oauth2-proxy with Weave as the OIDC issuer; members sign in to the SkyPilot CLI and dashboard through it | Networking: @@ -248,8 +249,9 @@ Networking: ## Open questions -1. **Screenshot storage provider.** S3-compatible for now; pick a provider (self-hosted MinIO next to the portal, or - R2/B2/S3). A reminder is set to circle back. +1. **Screenshot storage provider.** Temporarily a self-hosted SeaweedFS container (`storage` in docker-compose). + MinIO was ruled out because its community edition stopped publishing images in 2025. Still to pick a long-term + provider (e.g. R2/B2/S3, or keep SeaweedFS with backups). A reminder is set to circle back. 2. **How members use SkyPilot:** decided. One private SkyPilot workspace per project, and members sign in to SkyPilot with Weave (oauth2-proxy, limited to `ganymede:member`). See [skypilot-integration.md §0](skypilot-integration.md#0-member-access-one-workspace-per-project-sign-in-with-weave). From f85e876f68cc374fc078dd59c89a3de2e3c45fb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:10:02 +0000 Subject: [PATCH 029/124] Add SkyPilot/S3 settings; stop spend snapshots tying within a transaction Settings for the SkyPilot integration and S3 storage, with production guards (no fake SkyPilot, a long policy token). SpendSnapshot.taken_at relied on Postgres now(), which is frozen per transaction, so snapshots written together tied and latest_spend_cents picked one arbitrarily (the budget test was flaky for this reason). Stamp it in Python. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- .env.example | 6 ++++++ krater/config.py | 25 +++++++++++++++++++++++++ krater/models/spend_snapshot.py | 8 ++++++-- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 1f7d1a3..eec8725 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,12 @@ KRATER_WEAVE_API_BASE_URL= KRATER_WEAVE_SERVICE_KEY= KRATER_WEAVE_STUB_USERS_FILE= +# SkyPilot integration. See docs/skypilot-integration.md. `fake` = in-memory SkyPilot for local dev. +KRATER_SKYPILOT_MODE=fake +KRATER_SKYPILOT_API_URL=http://skypilot:46580 +KRATER_SKYPILOT_SERVICE_TOKEN= +KRATER_SKYPILOT_POLICY_TOKEN= + # S3-compatible object storage for gallery screenshots (not wired into the app yet). # docker-compose runs a temporary SeaweedFS container as `storage`. The app talks to it on the internal endpoint; # browsers get presigned URLs on the public one. Change the secret for anything beyond local dev. diff --git a/krater/config.py b/krater/config.py index 178a057..cae6f0b 100644 --- a/krater/config.py +++ b/krater/config.py @@ -31,11 +31,36 @@ class Settings(BaseSettings): weave_service_key: str = "" weave_stub_users_file: str = "" + # SkyPilot integration. See docs/skypilot-integration.md and docs/dev/skypilot-spike.md. + # `fake` uses an in-memory SkyPilot for dev and tests; `live` talks to a real API server over REST. + skypilot_mode: Literal["fake", "live"] = "fake" + skypilot_api_url: str = "" + skypilot_service_token: str = "" # admin service-account bearer token Krater uses for REST calls + # Shared secret carried in the admin-policy URL's query string (RestfulAdminPolicy can't send headers). Not a + # strong secret: SkyPilot clients fetch the URL too, so the endpoint must be safe for any member to call. + skypilot_policy_token: str = "" + skypilot_reconcile_interval_minutes: int = 5 + skypilot_budget_warn_percent: int = 80 + skypilot_autodown_idle_minutes: int = 30 + skypilot_max_hourly_cost_cents: int = 500 # per-instance cap forced onto every launch + + # S3-compatible object storage (gallery screenshots). docker-compose runs a temporary SeaweedFS as `storage`. + s3_endpoint_url: str = "" + s3_public_endpoint_url: str = "" + s3_region: str = "us-east-1" + s3_bucket: str = "krater-screenshots" + s3_access_key_id: str = "" + s3_secret_access_key: str = "" + @model_validator(mode="after") def _validate_production_safety(self) -> Settings: if self.env == "production": if self.weave_mode == "stub": raise ValueError("KRATER_WEAVE_MODE cannot be 'stub' when KRATER_ENV=production") + if self.skypilot_mode == "fake": + raise ValueError("KRATER_SKYPILOT_MODE cannot be 'fake' when KRATER_ENV=production") + if self.skypilot_mode == "live" and len(self.skypilot_policy_token) < 32: + raise ValueError("KRATER_SKYPILOT_POLICY_TOKEN must be at least 32 characters in production") if self.secret_key == DEFAULT_SECRET_KEY: raise ValueError("KRATER_SECRET_KEY must be set to a non-default value when KRATER_ENV=production") return self diff --git a/krater/models/spend_snapshot.py b/krater/models/spend_snapshot.py index 06e7566..d55d5d1 100644 --- a/krater/models/spend_snapshot.py +++ b/krater/models/spend_snapshot.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid -from datetime import datetime +from datetime import UTC, datetime from typing import TYPE_CHECKING import sqlalchemy as sa @@ -25,7 +25,11 @@ class SpendSnapshot(UUIDPrimaryKeyMixin, Base): project_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), sa.ForeignKey("projects.id"), nullable=False) estimated_spend_cents: Mapped[int] = mapped_column(sa.Integer, nullable=False) source: Mapped[SpendSource] = mapped_column(pg_enum(SpendSource, name="spend_source"), nullable=False) - taken_at: Mapped[datetime] = mapped_column(sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()) + # Set in Python, not by the server default alone: Postgres `now()` is frozen for the whole transaction, so snapshots + # written together (as the reconciler does) would tie and "latest" would be arbitrary. + taken_at: Mapped[datetime] = mapped_column( + sa.DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), server_default=sa.func.now() + ) project: Mapped[Project] = relationship(back_populates="spend_snapshots") From 288316aaed55737fc6e9dbee81610eeea6033bf8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:25:16 +0000 Subject: [PATCH 030/124] Allow AuditEvent.actor_id to be null for system-actor events The SkyPilot reconciler runs as a periodic worker job with no human behind it, but every AuditEvent previously required a non-null actor_id. Make the column nullable and let audit.record() accept actor=None, so budget_warning, budget_teardown and workspace-teardown events can be attributed to "the system" instead of forcing a fake user. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- ...aa7_make_audit_events_actor_id_nullable.py | 32 +++++++++++++++++++ krater/models/audit_event.py | 9 ++++-- krater/services/audit.py | 10 +++--- 3 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 alembic/versions/cdd8685b4aa7_make_audit_events_actor_id_nullable.py diff --git a/alembic/versions/cdd8685b4aa7_make_audit_events_actor_id_nullable.py b/alembic/versions/cdd8685b4aa7_make_audit_events_actor_id_nullable.py new file mode 100644 index 0000000..1330d04 --- /dev/null +++ b/alembic/versions/cdd8685b4aa7_make_audit_events_actor_id_nullable.py @@ -0,0 +1,32 @@ +"""make audit_events.actor_id nullable + +Revision ID: cdd8685b4aa7 +Revises: 153093277378 +Create Date: 2026-09-26 21:14:38.215192 + +The SkyPilot reconciler (`krater.services.skypilot_sync`) writes `budget_warning`, `budget_teardown` +and workspace-teardown audit events as a system actor with no human `User` behind it -- there's no one +to attribute a periodic reconcile run to. Every human-triggered action still always supplies an actor. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "cdd8685b4aa7" +down_revision: str | Sequence[str] | None = "153093277378" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.alter_column("audit_events", "actor_id", existing_type=sa.UUID(), nullable=True) + + +def downgrade() -> None: + """Downgrade schema.""" + op.alter_column("audit_events", "actor_id", existing_type=sa.UUID(), nullable=False) diff --git a/krater/models/audit_event.py b/krater/models/audit_event.py index b306d00..b95b33c 100644 --- a/krater/models/audit_event.py +++ b/krater/models/audit_event.py @@ -20,8 +20,11 @@ class AuditEvent(UUIDPrimaryKeyMixin, CreatedAtMixin, Base): __tablename__ = "audit_events" - actor_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True + # Nullable: the SkyPilot reconciler (`krater.services.skypilot_sync`) writes audit events as a + # system actor with no `User` behind it (there's no human to attribute a periodic reconcile run + # to). Every human-triggered action still always supplies an actor. + actor_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=True, index=True ) # Open-ended (e.g. "admin_approve", "budget_adjust", "launch_blocked") per SPEC.md -- not an enum, # since new admin actions shouldn't need a migration to be logged. @@ -32,7 +35,7 @@ class AuditEvent(UUIDPrimaryKeyMixin, CreatedAtMixin, Base): payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict, server_default="{}") reason: Mapped[str | None] = mapped_column(sa.Text, nullable=True) - actor: Mapped[User] = relationship(back_populates="audit_events") + actor: Mapped[User | None] = relationship(back_populates="audit_events") project: Mapped[Project | None] = relationship() def __repr__(self) -> str: diff --git a/krater/services/audit.py b/krater/services/audit.py index 18af1ec..937fd6e 100644 --- a/krater/services/audit.py +++ b/krater/services/audit.py @@ -10,7 +10,7 @@ def record( session: Session, - actor: Actor, + actor: Actor | None, action: str, *, project: Project | None = None, @@ -19,11 +19,13 @@ def record( ) -> AuditEvent: """Write an `AuditEvent` for `action`, performed by `actor`, and return it. - `project` is optional (some actions aren't project-scoped). `payload` is a free-form JSON-able - dict of extra context (e.g. amounts, decision, revision id). Flushes, doesn't commit. + `actor` is `None` for events written by a system process with no human behind it (the SkyPilot + reconciler's `budget_warning`/`budget_teardown`/workspace-teardown events). `project` is optional + (some actions aren't project-scoped). `payload` is a free-form JSON-able dict of extra context + (e.g. amounts, decision, revision id). Flushes, doesn't commit. """ event = AuditEvent( - actor_id=actor.user.id, + actor_id=actor.user.id if actor is not None else None, action=action, project_id=project.id if project is not None else None, payload=payload or {}, From 283cd9bfcde2c1b95a07c4e4820be6b3e245461f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:25:28 +0000 Subject: [PATCH 031/124] Add SkyPilot client, sync reconciler, worker task and project-page UI krater/skypilot/: SkyPilotClient protocol plus result types (CostReportRow, ClusterInfo, ManagedJobInfo), LiveSkyPilotClient (plain REST via httpx, bearer token, async request-id polling with timeout/backoff, SkyPilotUnavailableError vs SkyPilotRequestFailedError), FakeSkyPilotClient (in-memory, with add_cluster/add_managed_job/set_cluster_cost test helpers), and get_skypilot_client() keyed on settings.skypilot_mode. Built as plain REST per docs/dev/skypilot-spike.md's recommendation, not the skypilot package. krater/services/skypilot_sync.py: framework-free, idempotent sync_workspaces/sync_spend/enforce_budgets/reconcile, plus current_budget_flag() for the UI. Workspaces are named `ganymede-<12 hex chars>`, restricted to Vast by denylisting every other cloud, with allowed_users kept equal to the submitter + credited builders on the latest revision. Budget warnings fire once per project (re-armed only by a ceiling raise); teardowns repeat only when a new cluster appears since the last one. reconcile() commits after each step so a SkyPilot outage in one doesn't lose the others' work. krater/worker/app.py: periodic task `skypilot_reconcile`, scheduled from skypilot_reconcile_interval_minutes. krater/skypilot/reconcile_once.py: a `python -m` CLI entry for manual runs. UI: the project detail page shows the SkyPilot workspace name with a sign-in/launch hint, and a warning banner when a budget_warning/teardown event is still current given today's ceiling and spend. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/services/skypilot_sync.py | 249 +++++++++++++++++ krater/skypilot/__init__.py | 43 +++ krater/skypilot/client.py | 66 +++++ krater/skypilot/errors.py | 23 ++ krater/skypilot/fake.py | 87 ++++++ krater/skypilot/live.py | 247 +++++++++++++++++ krater/skypilot/reconcile_once.py | 31 +++ krater/skypilot/types.py | 41 +++ krater/web/routers/projects.py | 9 + krater/web/static/css/main.css | 27 ++ krater/web/templates/projects/detail.html | 19 ++ krater/worker/app.py | 30 +++ tests/services/test_skypilot_sync.py | 308 ++++++++++++++++++++++ tests/skypilot/__init__.py | 0 tests/skypilot/test_live_client.py | 288 ++++++++++++++++++++ tests/worker/test_skypilot_reconcile.py | 36 +++ 16 files changed, 1504 insertions(+) create mode 100644 krater/services/skypilot_sync.py create mode 100644 krater/skypilot/__init__.py create mode 100644 krater/skypilot/client.py create mode 100644 krater/skypilot/errors.py create mode 100644 krater/skypilot/fake.py create mode 100644 krater/skypilot/live.py create mode 100644 krater/skypilot/reconcile_once.py create mode 100644 krater/skypilot/types.py create mode 100644 tests/services/test_skypilot_sync.py create mode 100644 tests/skypilot/__init__.py create mode 100644 tests/skypilot/test_live_client.py create mode 100644 tests/worker/test_skypilot_reconcile.py diff --git a/krater/services/skypilot_sync.py b/krater/services/skypilot_sync.py new file mode 100644 index 0000000..ff8f1d2 --- /dev/null +++ b/krater/services/skypilot_sync.py @@ -0,0 +1,249 @@ +"""SkyPilot provisioning, spend reconciliation and budget enforcement. + +Framework-free (no FastAPI, no procrastinate) so it can be unit-tested against `FakeSkyPilotClient` and +driven by the worker's periodic task or the `python -m krater.skypilot.reconcile_once` CLI alike. Every +function here is safe to call repeatedly: re-running `reconcile` after a partial failure (or just on its +normal schedule) should never double up work or double-write ledger/audit rows. + +The reconciler acts as a system process with no human behind it, so every `audit.record` call here +passes `actor=None` (see `AuditEvent.actor_id`, nullable for exactly this reason). + +See `docs/skypilot-integration.md` sections 1, 3 and 4, and `docs/dev/skypilot-spike.md` for the facts +this was built against (workspace naming, `allowed_users` semantics, the Vast-only cloud denylist, and +`cost_report`'s row shape). +""" + +from __future__ import annotations + +import logging +import uuid + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from krater.models import AuditEvent, Project, ProjectStatus, SpendSnapshot, SpendSource, User +from krater.services import audit, budget +from krater.skypilot.client import SkyPilotClient +from krater.skypilot.errors import SkyPilotError + +logger = logging.getLogger(__name__) + +#: Statuses whose project should have a live, up-to-date SkyPilot workspace. +_PROVISIONED_STATUSES = ( + ProjectStatus.APPROVED, + ProjectStatus.PENDING_COMPLETION_REVIEW, + ProjectStatus.COMPLETION_CHANGES_REQUESTED, +) +#: Statuses whose project's workspace (if any) should be torn down and released. +_TERMINAL_STATUSES = (ProjectStatus.COMPLETED, ProjectStatus.WITHDRAWN) + +#: `cost_report`'s window. Deliberately large (not the design doc's illustrative "5 minutes" cadence, +#: which is the *reconcile* interval, not this): a project can run for months, and spend is cumulative +#: since the workspace was created, so a short window (e.g. 30 days) would silently under-report a +#: long-lived project's total once its oldest clusters age out of it. +COST_REPORT_DAYS = 3650 + +AUDIT_WORKSPACE_TORN_DOWN = "skypilot_workspace_torn_down" +AUDIT_BUDGET_WARNING = "budget_warning" +AUDIT_BUDGET_TEARDOWN = "budget_teardown" + + +def workspace_name_for(project_id: uuid.UUID) -> str: + """The workspace name for a project: `ganymede-` plus the first 12 hex characters of its UUID. + + Lowercase hex + hyphens matches the only naming convention the spike's fixtures show in practice + (e.g. `ganymede-priv-test`); a full UUID would work too, but the shorter form is what this build was + asked for and is plenty unique for Krater's project volume. + """ + return f"ganymede-{project_id.hex[:12]}" + + +def _team_emails(session: Session, project: Project) -> list[str]: + """The submitter's email plus the emails of the credited builders on the project's latest revision + (`current_revision`: the newest revision, draft or submitted -- see `krater.services.projects`).""" + emails = {project.submitter.email} + revision = project.current_revision + if revision is not None and revision.credited_builder_ids: + builders = session.scalars(sa.select(User).where(User.id.in_(revision.credited_builder_ids))) + emails.update(user.email for user in builders) + return sorted(emails) + + +def _active_projects(session: Session) -> list[Project]: + return list(session.scalars(sa.select(Project).where(Project.status.in_(_PROVISIONED_STATUSES)))) + + +def sync_workspaces(session: Session, client: SkyPilotClient) -> None: + """Keep every active project's SkyPilot workspace in step, and tear down finished ones. + + - Every `approved`/`pending_completion_review`/`completion_changes_requested` project gets (or + keeps) a private, Vast-only workspace named `workspace_name_for(project.id)`, with + `allowed_users` kept equal to `_team_emails`. Always calls `create`/`update` (never skips), so a + team change is picked up on the very next reconcile -- cheap, and safe to re-run. + - Every `completed`/`withdrawn` project that still has a workspace gets its clusters downed, its + managed jobs cancelled, its workspace deleted, its `skypilot_workspace` column cleared, and a + `skypilot_workspace_torn_down` audit event. + """ + for project in _active_projects(session): + allowed_users = _team_emails(session, project) + if project.skypilot_workspace is None: + name = workspace_name_for(project.id) + client.create_workspace(name, allowed_users=allowed_users) + project.skypilot_workspace = name + else: + client.update_workspace(project.skypilot_workspace, allowed_users=allowed_users) + session.flush() + + stmt = sa.select(Project).where(Project.status.in_(_TERMINAL_STATUSES), Project.skypilot_workspace.is_not(None)) + for project in session.scalars(stmt): + name = project.skypilot_workspace + for cluster in client.list_clusters(name): + client.down_cluster(cluster.name) + client.cancel_managed_jobs(name) + client.delete_workspace(name) + project.skypilot_workspace = None + audit.record(session, None, AUDIT_WORKSPACE_TORN_DOWN, project=project, payload={"workspace": name}) + session.flush() + + +def sync_spend(session: Session, client: SkyPilotClient) -> None: + """Write a `SpendSnapshot` for each project whose SkyPilot-reported total spend has changed. + + One `cost_report` call, summed per workspace, then compared against each project's + `budget.latest_spend_cents`. Projects whose total is unchanged are skipped, so the table doesn't + grow every reconcile tick for an idle project. + """ + totals_by_workspace: dict[str, int] = {} + for row in client.cost_report(days=COST_REPORT_DAYS): + totals_by_workspace[row.workspace] = totals_by_workspace.get(row.workspace, 0) + row.total_cost_cents + + stmt = sa.select(Project).where(Project.skypilot_workspace.is_not(None)) + for project in session.scalars(stmt): + total_cents = totals_by_workspace.get(project.skypilot_workspace, 0) + if total_cents == budget.latest_spend_cents(session, project): + continue + session.add( + SpendSnapshot( + project_id=project.id, estimated_spend_cents=total_cents, source=SpendSource.SKYPILOT_COST_REPORT + ) + ) + session.flush() + + +def _latest_audit_event(session: Session, project: Project, action: str) -> AuditEvent | None: + stmt = ( + sa.select(AuditEvent) + .where(AuditEvent.project_id == project.id, AuditEvent.action == action) + .order_by(AuditEvent.created_at.desc()) + .limit(1) + ) + return session.scalars(stmt).first() + + +def _budget_percent(ceiling_cents: int, spend_cents: int) -> float: + if ceiling_cents <= 0: + return 100.0 if spend_cents > 0 else 0.0 + return (spend_cents / ceiling_cents) * 100.0 + + +def enforce_budgets(session: Session, client: SkyPilotClient, *, warn_percent: int) -> None: + """Warn once per project at `warn_percent` of the ceiling, and tear down (repeatably, but only + when new clusters have appeared) once spend reaches or passes 100%. + + - **Warning:** written once per project. Re-armed only if the ceiling is later raised (compared + against the ceiling recorded on the last warning) and the percentage is crossed again. + - **Teardown:** downs every current cluster and cancels every managed job in the project's + workspace, then records which clusters were torn down. A later run only repeats the teardown if + it finds a cluster that wasn't in that recorded set (i.e. one launched after the last teardown) -- + an idle-but-still-over-budget project isn't re-torn-down every tick. + """ + stmt = sa.select(Project).where(Project.status.in_(_PROVISIONED_STATUSES), Project.skypilot_workspace.is_not(None)) + for project in session.scalars(stmt): + workspace = project.skypilot_workspace + ceiling_cents = budget.ceiling_cents(session, project) + spend_cents = budget.latest_spend_cents(session, project) + percent = _budget_percent(ceiling_cents, spend_cents) + + if percent >= warn_percent: + last_warning = _latest_audit_event(session, project, AUDIT_BUDGET_WARNING) + already_warned_at_this_ceiling = last_warning is not None and ceiling_cents <= last_warning.payload.get( + "ceiling_cents", 0 + ) + if not already_warned_at_this_ceiling: + audit.record( + session, + None, + AUDIT_BUDGET_WARNING, + project=project, + payload={ + "ceiling_cents": ceiling_cents, + "spend_cents": spend_cents, + "percent": round(percent, 1), + }, + ) + session.flush() + + if percent >= 100.0: + clusters = client.list_clusters(workspace) + current_names = sorted(cluster.name for cluster in clusters) + last_teardown = _latest_audit_event(session, project, AUDIT_BUDGET_TEARDOWN) + previously_torn_down = set(last_teardown.payload.get("cluster_names", [])) if last_teardown else set() + new_clusters_appeared = bool(set(current_names) - previously_torn_down) + + if last_teardown is None or new_clusters_appeared: + for cluster in clusters: + client.down_cluster(cluster.name) + client.cancel_managed_jobs(workspace) + audit.record( + session, + None, + AUDIT_BUDGET_TEARDOWN, + project=project, + payload={ + "cluster_names": current_names, + "ceiling_cents": ceiling_cents, + "spend_cents": spend_cents, + }, + ) + session.flush() + + +def current_budget_flag(session: Session, project: Project, *, warn_percent: int) -> str | None: + """`"teardown"`, `"warning"`, or `None`: whether the project's spend *right now* still meets the + condition that last triggered a teardown or warning. Used for the project page's warning banner. + + Deliberately re-derived from the live ceiling/spend rather than "does a budget_warning/teardown + event exist", so raising the ceiling (which re-arms future warnings/teardowns) also immediately + clears a banner that no longer reflects reality. + """ + ceiling_cents = budget.ceiling_cents(session, project) + spend_cents = budget.latest_spend_cents(session, project) + if spend_cents >= ceiling_cents and _latest_audit_event(session, project, AUDIT_BUDGET_TEARDOWN) is not None: + return "teardown" + percent = _budget_percent(ceiling_cents, spend_cents) + if percent >= warn_percent and _latest_audit_event(session, project, AUDIT_BUDGET_WARNING) is not None: + return "warning" + return None + + +def reconcile(session: Session, client: SkyPilotClient, *, warn_percent: int) -> None: + """Run `sync_workspaces`, `sync_spend` and `enforce_budgets` in order, committing after each step. + + Committing per step means a SkyPilot outage partway through doesn't lose the other steps' work: if + one step raises `SkyPilotError`, it's logged and the next step still runs on the next scheduled + reconcile (this function itself doesn't retry within a single call, since the periodic task is + already the retry loop). + """ + steps = ( + ("sync_workspaces", lambda: sync_workspaces(session, client)), + ("sync_spend", lambda: sync_spend(session, client)), + ("enforce_budgets", lambda: enforce_budgets(session, client, warn_percent=warn_percent)), + ) + for name, step in steps: + try: + step() + except SkyPilotError: + logger.exception("krater.skypilot reconcile step %s failed; continuing", name) + session.rollback() + else: + session.commit() diff --git a/krater/skypilot/__init__.py b/krater/skypilot/__init__.py new file mode 100644 index 0000000..7c089a7 --- /dev/null +++ b/krater/skypilot/__init__.py @@ -0,0 +1,43 @@ +"""`krater.skypilot`: the only place that talks to SkyPilot. + +Nothing outside this package should import `httpx` for SkyPilot or know its URLs or wire shapes -- go +through `SkyPilotClient` (via `get_skypilot_client`). See `docs/skypilot-integration.md` for the design +and `docs/dev/skypilot-spike.md` for the verified facts it was checked against. +""" + +from __future__ import annotations + +from functools import lru_cache + +from krater.config import get_settings +from krater.skypilot.client import SkyPilotClient +from krater.skypilot.errors import SkyPilotError, SkyPilotRequestFailedError, SkyPilotUnavailableError +from krater.skypilot.fake import FakeSkyPilotClient +from krater.skypilot.live import LiveSkyPilotClient +from krater.skypilot.types import ClusterInfo, CostReportRow, ManagedJobInfo + +__all__ = [ + "ClusterInfo", + "CostReportRow", + "FakeSkyPilotClient", + "LiveSkyPilotClient", + "ManagedJobInfo", + "SkyPilotClient", + "SkyPilotError", + "SkyPilotRequestFailedError", + "SkyPilotUnavailableError", + "get_skypilot_client", +] + + +@lru_cache +def get_skypilot_client() -> SkyPilotClient: + """The process-wide `SkyPilotClient`, chosen by `settings.skypilot_mode`. + + Cached like `krater.weave.get_weave_client`; override in tests by passing a `FakeSkyPilotClient` + directly to the service functions instead of going through this factory. + """ + settings = get_settings() + if settings.skypilot_mode == "fake": + return FakeSkyPilotClient() + return LiveSkyPilotClient(settings) diff --git a/krater/skypilot/client.py b/krater/skypilot/client.py new file mode 100644 index 0000000..4e19f87 --- /dev/null +++ b/krater/skypilot/client.py @@ -0,0 +1,66 @@ +"""The `SkyPilotClient` protocol every adapter (live or fake) implements. + +Nothing outside `krater.skypilot` should know SkyPilot's URLs, wire shapes or async request-polling +mechanics -- go through this interface. See `docs/skypilot-integration.md` for the design and +`docs/dev/skypilot-spike.md` for the facts this was built and verified against. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from krater.skypilot.types import ClusterInfo, CostReportRow, ManagedJobInfo + + +@runtime_checkable +class SkyPilotClient(Protocol): + def create_workspace(self, name: str, *, allowed_users: list[str]) -> None: + """Create a new **private** workspace named `name`, restricted to Vast (every other cloud + `disabled: true`, per the spike -- there's no positive cloud allowlist), with `allowed_users` + set to exactly the given emails. + + Idempotent in intent: safe to call again with the same arguments (SkyPilot's `create` on an + existing workspace name behaves like an update in practice, but `update_workspace` is the + one to prefer for a workspace Krater already knows about -- see `sync_workspaces`). + """ + ... + + def update_workspace(self, name: str, *, allowed_users: list[str]) -> None: + """Replace `name`'s `allowed_users` with exactly the given emails (a full-list update, not an + add/remove -- per the spike, `batch_add_users`/`batch_remove_users` need internal user ids, + which Krater doesn't track, so membership is always reconciled via a full replace). Keeps the + workspace private and Vast-only. + """ + ... + + def delete_workspace(self, name: str) -> None: + """Delete the workspace. Safe to call on a workspace that's already gone.""" + ... + + def list_workspaces(self) -> list[str]: + """Every workspace name SkyPilot currently has.""" + ... + + def cost_report(self, days: int) -> list[CostReportRow]: + """Every cluster's cost estimate over the last `days` days, across every workspace (Krater's + service-account token is a SkyPilot admin, so this isn't scoped to "its own" clusters).""" + ... + + def list_clusters(self, workspace: str) -> list[ClusterInfo]: + """Every cluster currently in `workspace`.""" + ... + + def list_managed_jobs(self, workspace: str) -> list[ManagedJobInfo]: + """Every managed job currently in `workspace` (queued, running, or otherwise not yet + terminal -- SkyPilot's `/jobs/queue` without `skip_finished` also returns finished jobs, but + callers here only care about ones still worth cancelling).""" + ... + + def down_cluster(self, name: str) -> None: + """Tear the cluster down (`purge=True`, so a cluster SkyPilot can't cleanly reach is still + removed from its own bookkeeping rather than blocking budget enforcement).""" + ... + + def cancel_managed_jobs(self, workspace: str) -> None: + """Cancel every managed job in `workspace`.""" + ... diff --git a/krater/skypilot/errors.py b/krater/skypilot/errors.py new file mode 100644 index 0000000..e822b44 --- /dev/null +++ b/krater/skypilot/errors.py @@ -0,0 +1,23 @@ +"""Exception types raised by `krater.skypilot`. + +Callers branch on these two, mirroring `krater.weave.errors`: one for "couldn't even reach or complete +the round trip with SkyPilot" (network error, timeout, a request stuck non-terminal past our deadline), +one for "SkyPilot understood the request but it failed" (a `FAILED` polled request, a non-2xx response +with a body we can show). +""" + +from __future__ import annotations + + +class SkyPilotError(Exception): + """Base class for every error raised by `krater.skypilot`.""" + + +class SkyPilotUnavailableError(SkyPilotError): + """SkyPilot couldn't be reached at all: a network error, a timeout, or a request whose polled + status never reached SUCCEEDED/FAILED before our deadline.""" + + +class SkyPilotRequestFailedError(SkyPilotError): + """SkyPilot was reached and understood the request, but it failed. Carries the server's own + message (from the polled request's `error`, or the HTTP response body) so callers can show it.""" diff --git a/krater/skypilot/fake.py b/krater/skypilot/fake.py new file mode 100644 index 0000000..b8a2aca --- /dev/null +++ b/krater/skypilot/fake.py @@ -0,0 +1,87 @@ +"""`FakeSkyPilotClient`: an in-memory `SkyPilotClient` for `KRATER_SKYPILOT_MODE=fake` (development and +tests). Makes no network calls. Test helpers (`add_cluster`, `add_managed_job`) let a test set up +billing/queue state that a real SkyPilot server would otherwise report via `cost_report`/`status`/ +`jobs/queue`, without standing one up. +""" + +from __future__ import annotations + +import itertools + +from krater.skypilot.types import ClusterInfo, CostReportRow, ManagedJobInfo + + +class FakeSkyPilotClient: + """A `SkyPilotClient` backed by plain Python dicts. `workspaces` maps name -> sorted allowed_users, + for tests that want to assert on membership directly.""" + + def __init__(self) -> None: + self.workspaces: dict[str, list[str]] = {} + self._clusters: dict[str, ClusterInfo] = {} + self._cluster_costs_cents: dict[str, int] = {} + self._jobs: dict[int, ManagedJobInfo] = {} + self._job_id_seq = itertools.count(1) + # A monotonic counter, not `len(self._clusters)`: a cluster added after an earlier one was + # torn down (e.g. by `down_cluster`) must get a name that was never used before, or a caller + # tracking "which clusters have I already seen" (like the budget-teardown re-arm check in + # `krater.services.skypilot_sync.enforce_budgets`) would wrongly treat it as the same cluster. + self._cluster_name_seq = itertools.count(1) + + # -- SkyPilotClient protocol ----------------------------------------------------------------- + + def create_workspace(self, name: str, *, allowed_users: list[str]) -> None: + self.workspaces[name] = sorted(allowed_users) + + def update_workspace(self, name: str, *, allowed_users: list[str]) -> None: + self.workspaces[name] = sorted(allowed_users) + + def delete_workspace(self, name: str) -> None: + self.workspaces.pop(name, None) + + def list_workspaces(self) -> list[str]: + return list(self.workspaces.keys()) + + def cost_report(self, days: int) -> list[CostReportRow]: + del days # the fake has no notion of time; it just reports whatever's been added + return [ + CostReportRow( + workspace=cluster.workspace, cluster_name=cluster.name, total_cost_cents=self._cluster_costs_cents[name] + ) + for name, cluster in self._clusters.items() + ] + + def list_clusters(self, workspace: str) -> list[ClusterInfo]: + return [cluster for cluster in self._clusters.values() if cluster.workspace == workspace] + + def list_managed_jobs(self, workspace: str) -> list[ManagedJobInfo]: + return [job for job in self._jobs.values() if job.workspace == workspace] + + def down_cluster(self, name: str) -> None: + self._clusters.pop(name, None) + self._cluster_costs_cents.pop(name, None) + + def cancel_managed_jobs(self, workspace: str) -> None: + for job_id in [job.job_id for job in self._jobs.values() if job.workspace == workspace]: + del self._jobs[job_id] + + # -- Test helpers ------------------------------------------------------------------------------ + + def add_cluster(self, workspace: str, cost_cents: int, *, name: str | None = None, status: str = "UP") -> str: + """Add a cluster in `workspace` with the given cost estimate, returning its name.""" + name = name or f"{workspace}-cluster-{next(self._cluster_name_seq)}" + self._clusters[name] = ClusterInfo(name=name, workspace=workspace, status=status) + self._cluster_costs_cents[name] = cost_cents + return name + + def set_cluster_cost(self, name: str, cost_cents: int) -> None: + """Change an existing cluster's cost estimate (simulating time passing / the meter running).""" + self._cluster_costs_cents[name] = cost_cents + + def add_managed_job(self, workspace: str, *, name: str | None = None, status: str = "RUNNING") -> int: + """Add a managed job in `workspace`, returning its job id.""" + job_id = next(self._job_id_seq) + self._jobs[job_id] = ManagedJobInfo(job_id=job_id, name=name, workspace=workspace, status=status) + return job_id + + +__all__ = ["FakeSkyPilotClient"] diff --git a/krater/skypilot/live.py b/krater/skypilot/live.py new file mode 100644 index 0000000..e119c0a --- /dev/null +++ b/krater/skypilot/live.py @@ -0,0 +1,247 @@ +"""`LiveSkyPilotClient`: plain REST (via `httpx`) against a real SkyPilot API server. + +Deliberately **not** built on the `skypilot` Python package -- see `docs/dev/skypilot-spike.md` section +6 ("SDK vs REST") for why: no SDK exists for workspaces at all in 0.13.0, and the package pulls in a +453MB dependency tree (a second SQLAlchemy, two Postgres drivers, pandas, numpy, grpc...) that risks +colliding with Krater's own pinned stack, just to POST JSON and poll a request id. + +Auth is a single bearer header (`Authorization: Bearer `). Most admin-plane +endpoints are async: a `POST`/`GET` returns `200` with a `null` body and an `x-skypilot-request-id` +header immediately, and the real result comes from polling `GET /api/get?request_id=...` until +`status` is `SUCCEEDED` or `FAILED` (spike section 2, "Workspaces via the API"). A few calls fail +*synchronously* instead -- no request id is even issued -- e.g. a 403 on an unauthorized workspace +update (spike fixture `workspaces_update_forbidden_nonmember_response.json`); `_finish_async` handles +both shapes uniformly. +""" + +from __future__ import annotations + +import json +import time +from typing import Any + +import httpx + +from krater.config import Settings +from krater.skypilot.errors import SkyPilotRequestFailedError, SkyPilotUnavailableError +from krater.skypilot.types import ClusterInfo, CostReportRow, ManagedJobInfo + +#: How long to keep polling a request id before giving up and treating SkyPilot as unavailable. +DEFAULT_POLL_TIMEOUT_SECONDS = 30.0 +#: Poll backoff: start at this interval, double each miss, capped at `MAX_POLL_INTERVAL_SECONDS`. +DEFAULT_POLL_INTERVAL_SECONDS = 0.25 +MAX_POLL_INTERVAL_SECONDS = 2.0 +POLL_BACKOFF_FACTOR = 2.0 + +# There's no per-workspace cloud *allowlist* in SkyPilot (a workspace-level `allowed_clouds` key fails +# schema validation -- "did you mean `allowed_users`?", per the spike). Restricting a workspace to Vast +# means denying every other cloud individually. List taken verbatim from +# `tests/fixtures/skypilot/workspaces_disable_all_clouds_except_vast_request.json`. +_CLOUDS_TO_DISABLE = [ + "aws", "azure", "cudo", "do", "fluidstack", "gcp", "hyperbolic", "ibm", "kubernetes", "lambda", + "mithril", "nebius", "oci", "paperspace", "primeintellect", "runpod", "scp", "seeweb", "shadeform", + "ssh", "vsphere", "yotta", +] # fmt: skip + + +def _decode_return_value(raw: Any) -> Any: + """Decode a polled request's `return_value`. + + The spike's fixtures show this inconsistently: `cost_report`'s `return_value` is itself a + JSON-encoded *string* (`"[]"`), while the `workspaces` listing fixture shows a plain JSON object. + Handling both means: pass through anything that isn't a string, and `json.loads` anything that is + (falling back to the raw string if it doesn't parse, e.g. `null`/plain text). + """ + if not isinstance(raw, str): + return raw + try: + return json.loads(raw) + except (TypeError, ValueError): + return raw + + +class LiveSkyPilotClient: + """A `SkyPilotClient` backed by a real SkyPilot API server over HTTP. `http_client` is injectable + for tests (`httpx.MockTransport`); production code leaves it out and gets a real `httpx.Client`.""" + + def __init__( + self, + settings: Settings, + *, + http_client: httpx.Client | None = None, + poll_timeout_seconds: float = DEFAULT_POLL_TIMEOUT_SECONDS, + poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS, + ) -> None: + self._settings = settings + self._base_url = settings.skypilot_api_url.rstrip("/") + self._http = http_client if http_client is not None else httpx.Client(timeout=10.0) + self._poll_timeout_seconds = poll_timeout_seconds + self._poll_interval_seconds = poll_interval_seconds + + # -- Workspaces -------------------------------------------------------------------------------- + + def create_workspace(self, name: str, *, allowed_users: list[str]) -> None: + self._post_async( + "/workspaces/create", {"workspace_name": name, "config": self._vast_only_config(allowed_users)} + ) + + def update_workspace(self, name: str, *, allowed_users: list[str]) -> None: + self._post_async( + "/workspaces/update", {"workspace_name": name, "config": self._vast_only_config(allowed_users)} + ) + + def delete_workspace(self, name: str) -> None: + self._post_async("/workspaces/delete", {"workspace_name": name}) + + def list_workspaces(self) -> list[str]: + result = self._get_async("/workspaces") or {} + return list(result.keys()) + + @staticmethod + def _vast_only_config(allowed_users: list[str]) -> dict: + config: dict[str, Any] = {"private": True, "allowed_users": list(allowed_users)} + for cloud in _CLOUDS_TO_DISABLE: + config[cloud] = {"disabled": True} + return config + + # -- Spend --------------------------------------------------------------------------------------- + + def cost_report(self, days: int) -> list[CostReportRow]: + rows = self._post_async("/cost_report", {"days": days}) or [] + return [ + CostReportRow( + workspace=row.get("workspace") or "default", + cluster_name=row["name"], + # `total_cost` is a float dollar estimate (`resources.get_cost(duration) * num_nodes`, + # per the spike); see `CostReportRow`'s docstring for the rounding rationale. + total_cost_cents=round(float(row.get("total_cost", 0.0)) * 100), + ) + for row in rows + ] + + # -- Clusters and managed jobs -------------------------------------------------------------------- + + def list_clusters(self, workspace: str) -> list[ClusterInfo]: + rows = ( + self._post_async( + "/status", + { + "cluster_names": None, + "refresh": False, + "all_users": True, + # `/status` has no `workspace` field; scoping is via the request's active-workspace + # context (spike section 5). We also filter defensively below, since cost_report's row + # shape does carry an explicit `workspace` and the spike flags `active_workspace` as + # unreliable in at least one other context (the admin-policy payload). + "override_skypilot_config": {"active_workspace": workspace}, + }, + ) + or [] + ) + return [ + ClusterInfo(name=row["name"], workspace=row.get("workspace", workspace), status=row.get("status")) + for row in rows + if row.get("workspace", workspace) == workspace + ] + + def list_managed_jobs(self, workspace: str) -> list[ManagedJobInfo]: + rows = ( + self._post_async( + "/jobs/queue", + { + "refresh": False, + "skip_finished": True, + "all_users": True, + "override_skypilot_config": {"active_workspace": workspace}, + }, + ) + or [] + ) + return [ + ManagedJobInfo( + job_id=row["job_id"], + name=row.get("job_name") or row.get("name"), + workspace=row.get("workspace", workspace), + status=row.get("status"), + ) + for row in rows + if row.get("workspace", workspace) == workspace + ] + + def down_cluster(self, name: str) -> None: + # `purge=True`: a cluster SkyPilot can't cleanly reach (already gone on Vast, say) is still + # removed from SkyPilot's own bookkeeping rather than blocking budget enforcement. + self._post_async("/down", {"cluster_name": name, "purge": True, "graceful": False}) + + def cancel_managed_jobs(self, workspace: str) -> None: + self._post_async( + "/jobs/cancel", + {"all": True, "all_users": True, "override_skypilot_config": {"active_workspace": workspace}}, + ) + + # -- Transport: request + async request-id polling ------------------------------------------------ + + def _headers(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self._settings.skypilot_service_token}"} + + def _post_async(self, path: str, body: dict) -> Any: + return self._finish_async(self._request("POST", path, json=body)) + + def _get_async(self, path: str) -> Any: + return self._finish_async(self._request("GET", path)) + + def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response: + try: + return self._http.request(method, f"{self._base_url}{path}", headers=self._headers(), **kwargs) + except httpx.HTTPError as exc: + raise SkyPilotUnavailableError(f"could not reach SkyPilot at {path}: {exc}") from exc + + def _finish_async(self, response: httpx.Response) -> Any: + """Handle a request that may fail synchronously (no request id issued, e.g. a 403) or that + was scheduled and must be polled for its real result.""" + self._raise_for_status(response) + request_id = response.headers.get("x-skypilot-request-id") + if not request_id: + # A handful of endpoints (not used above, but the spike found some) answer directly. + return response.json() if response.content else None + return self._poll(request_id) + + def _poll(self, request_id: str) -> Any: + deadline = time.monotonic() + self._poll_timeout_seconds + interval = self._poll_interval_seconds + while True: + response = self._request("GET", "/api/get", params={"request_id": request_id}) + self._raise_for_status(response) + data = response.json() + status = data.get("status") + if status == "SUCCEEDED": + return _decode_return_value(data.get("return_value")) + if status == "FAILED": + raise SkyPilotRequestFailedError(str(data.get("error") or "SkyPilot request failed")) + if time.monotonic() >= deadline: + raise SkyPilotUnavailableError( + f"SkyPilot request {request_id} did not complete within {self._poll_timeout_seconds}s " + f"(last status: {status!r})" + ) + time.sleep(interval) + interval = min(interval * POLL_BACKOFF_FACTOR, MAX_POLL_INTERVAL_SECONDS) + + @staticmethod + def _raise_for_status(response: httpx.Response) -> None: + if response.status_code >= 500: + raise SkyPilotUnavailableError(f"SkyPilot returned {response.status_code} for {response.request.url}") + if response.status_code >= 400: + raise SkyPilotRequestFailedError(LiveSkyPilotClient._error_message(response)) + + @staticmethod + def _error_message(response: httpx.Response) -> str: + try: + body = response.json() + except ValueError: + return response.text or f"HTTP {response.status_code}" + if isinstance(body, dict): + return str(body.get("detail") or body.get("error") or body) + return str(body) + + +__all__ = ["LiveSkyPilotClient"] diff --git a/krater/skypilot/reconcile_once.py b/krater/skypilot/reconcile_once.py new file mode 100644 index 0000000..cc432a9 --- /dev/null +++ b/krater/skypilot/reconcile_once.py @@ -0,0 +1,31 @@ +"""Manual/debugging entry point: run one SkyPilot reconcile pass and exit. + + uv run python -m krater.skypilot.reconcile_once + +Does exactly what the worker's periodic `skypilot_reconcile` task does, on demand -- useful for testing +against a real SkyPilot server or checking a suspected budget/workspace drift without waiting for the +next scheduled tick. +""" + +from __future__ import annotations + +import logging + +from krater.config import get_settings +from krater.db import get_sessionmaker +from krater.services.skypilot_sync import reconcile +from krater.skypilot import get_skypilot_client + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + settings = get_settings() + session = get_sessionmaker()() + try: + reconcile(session, get_skypilot_client(), warn_percent=settings.skypilot_budget_warn_percent) + finally: + session.close() + + +if __name__ == "__main__": + main() diff --git a/krater/skypilot/types.py b/krater/skypilot/types.py new file mode 100644 index 0000000..94c0500 --- /dev/null +++ b/krater/skypilot/types.py @@ -0,0 +1,41 @@ +"""Data carried out of the SkyPilot adapter. See `docs/dev/skypilot-spike.md` for the wire shapes +these are built from.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class CostReportRow: + """One row of `cost_report`'s per-cluster estimate, as attributed to a workspace. + + `total_cost_cents` is `round(total_cost_dollars * 100)`: SkyPilot's own figure is a float dollar + estimate (`resources.get_cost(duration) * num_nodes`, per the spike), so converting to integer + cents always takes a last rounding step. Round-half-to-even (Python's `round`), applied once per + row before summing per workspace -- good enough for a spend *estimate* that the docs already flag + as approximate and that drifts from the real Vast bill regardless. + """ + + workspace: str + cluster_name: str + total_cost_cents: int + + +@dataclass(frozen=True) +class ClusterInfo: + """One cluster, as returned by `POST /status`.""" + + name: str + workspace: str + status: str | None + + +@dataclass(frozen=True) +class ManagedJobInfo: + """One managed job, as returned by `POST /jobs/queue`.""" + + job_id: int + name: str | None + workspace: str + status: str | None diff --git a/krater/web/routers/projects.py b/krater/web/routers/projects.py index 2ccbecf..786fe5a 100644 --- a/krater/web/routers/projects.py +++ b/krater/web/routers/projects.py @@ -16,6 +16,7 @@ from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session +from krater.config import get_settings from krater.db import get_session from krater.models import ( Project, @@ -30,6 +31,7 @@ from krater.services import projects as project_service from krater.services.actor import Actor from krater.services.errors import InvalidState, NotAllowed, NotFound, ValidationFailed +from krater.services.skypilot_sync import current_budget_flag from krater.web.csrf import verify_csrf_token from krater.web.deps import fresh_actor from krater.web.flash import flash @@ -168,9 +170,16 @@ def _build_detail_context( can_review = actor.is_reviewer and not is_submitter and not is_credited_builder and current_is_pending + skypilot_budget_flag = None + if project.skypilot_workspace is not None: + skypilot_budget_flag = current_budget_flag( + session, project, warn_percent=get_settings().skypilot_budget_warn_percent + ) + return { "project": project, "summary": summary, + "skypilot_budget_flag": skypilot_budget_flag, "revisions": revisions, "users_by_id": users_by_id, "is_submitter": is_submitter, diff --git a/krater/web/static/css/main.css b/krater/web/static/css/main.css index 0f715ef..458218d 100644 --- a/krater/web/static/css/main.css +++ b/krater/web/static/css/main.css @@ -437,6 +437,33 @@ a:hover { line-height: 1.6; } +/* SkyPilot workspace hint + budget banner */ + +.skypilot-hint { + padding: 0.6rem 0.9rem; + background-color: var(--color-quilt); + border: 1px solid var(--color-border); + border-radius: 0.5rem; + font-size: 0.9rem; +} + +.skypilot-hint code { + background-color: var(--color-white); + border: 1px solid var(--color-border); + border-radius: 0.25rem; + padding: 0.05rem 0.3rem; +} + +.skypilot-banner { + padding: 0.6rem 0.9rem; + border: 1px solid #a33636; + background-color: #fbe9e7; + color: #a33636; + border-radius: 0.5rem; + font-size: 0.9rem; + font-weight: 600; +} + .revision { padding: 1rem 0; border-top: 1px solid var(--color-border); diff --git a/krater/web/templates/projects/detail.html b/krater/web/templates/projects/detail.html index c978343..db9fea6 100644 --- a/krater/web/templates/projects/detail.html +++ b/krater/web/templates/projects/detail.html @@ -23,6 +23,25 @@

{{ project.title or "Untitled proposal" }}

{{ summary.policy_explanation }}

{% endif %} + {% if skypilot_budget_flag == "teardown" %} +

+ Budget reached: this project's SkyPilot clusters and managed jobs have been torn down. New + launches are blocked until the ceiling is raised. +

+ {% elif skypilot_budget_flag == "warning" %} +

+ Warning: this project is approaching its compute budget ceiling. +

+ {% endif %} + + {% if project.skypilot_workspace %} +

+ SkyPilot workspace: {{ project.skypilot_workspace }}. + Sign in with sky api login and launch with + sky launch -w {{ project.skypilot_workspace }} .... +

+ {% endif %} + {% if summary.current_revision %}

{{ "Draft" if not summary.current_revision.submitted_at else "Write-up" }}

{{ summary.current_revision.write_up | nl2br }}

diff --git a/krater/worker/app.py b/krater/worker/app.py index 3fe2c68..955d67f 100644 --- a/krater/worker/app.py +++ b/krater/worker/app.py @@ -10,6 +10,9 @@ import procrastinate from krater.config import get_settings +from krater.db import get_sessionmaker +from krater.services.skypilot_sync import reconcile +from krater.skypilot import SkyPilotError, get_skypilot_client logger = logging.getLogger(__name__) @@ -29,3 +32,30 @@ def _psycopg_conninfo(database_url: str) -> str: def heartbeat(timestamp: int) -> None: """Log once a minute, so it's easy to see the worker and its scheduler are alive.""" logger.info("krater worker heartbeat", extra={"timestamp": timestamp}) + + +def _skypilot_reconcile_cron() -> str: + """`*/N * * * *` from `skypilot_reconcile_interval_minutes` -- built at import time, so changing + the setting takes effect the next time the worker (and its scheduler) restarts.""" + return f"*/{get_settings().skypilot_reconcile_interval_minutes} * * * *" + + +@app.periodic(cron=_skypilot_reconcile_cron()) +@app.task(name="skypilot_reconcile") +def skypilot_reconcile(timestamp: int) -> None: + """Provision/tear down SkyPilot workspaces, take spend snapshots, and enforce budgets. + + Thin wrapper around `krater.services.skypilot_sync.reconcile`; see there for the actual logic. Also + runnable directly for manual runs/debugging via `python -m krater.skypilot.reconcile_once`. + """ + del timestamp + settings = get_settings() + session = get_sessionmaker()() + try: + reconcile(session, get_skypilot_client(), warn_percent=settings.skypilot_budget_warn_percent) + except SkyPilotError: + # `reconcile` already catches per-step SkyPilot errors and logs+continues; this is a last-resort + # net for anything that still escapes (e.g. a step raising before its own try/except is reached). + logger.exception("krater.skypilot_reconcile task failed") + finally: + session.close() diff --git a/tests/services/test_skypilot_sync.py b/tests/services/test_skypilot_sync.py new file mode 100644 index 0000000..12c45b7 --- /dev/null +++ b/tests/services/test_skypilot_sync.py @@ -0,0 +1,308 @@ +"""`krater.services.skypilot_sync` against `FakeSkyPilotClient`: workspace provisioning/teardown, +spend snapshots, and budget warning/teardown enforcement. +""" + +from __future__ import annotations + +import uuid + +import pytest +from sqlalchemy import select +from sqlalchemy.orm import Session + +from krater.models import AuditEvent, BudgetEntryKind, ProjectStatus, ReviewDecision, ReviewSource, SpendSnapshot +from krater.services import budget, projects +from krater.services.actor import Actor +from krater.services.skypilot_sync import ( + AUDIT_BUDGET_TEARDOWN, + AUDIT_BUDGET_WARNING, + AUDIT_WORKSPACE_TORN_DOWN, + current_budget_flag, + enforce_budgets, + reconcile, + sync_spend, + sync_workspaces, + workspace_name_for, +) +from krater.skypilot.errors import SkyPilotUnavailableError +from krater.skypilot.fake import FakeSkyPilotClient + +WARN_PERCENT = 80 + + +def _approve(session: Session, member: Actor, reviewer: Actor, *, budget_cents: int = 100_000) -> ProjectStatus: + project = projects.create_project(session, member, title="Rover", write_up="A rover.") + projects.update_draft(session, member, project=project, budget_requested_cents=budget_cents) + project = projects.submit(session, member, project=project) + projects.record_review( + session, reviewer, revision=project.current_revision, decision=ReviewDecision.APPROVE, source=ReviewSource.WEB + ) + session.refresh(project) + assert project.status is ProjectStatus.APPROVED + return project + + +@pytest.fixture +def client() -> FakeSkyPilotClient: + return FakeSkyPilotClient() + + +# -------------------------------------------------------------------------------------------------- +# sync_workspaces +# -------------------------------------------------------------------------------------------------- + + +def test_approved_project_gets_a_workspace(db_session: Session, member: Actor, reviewer: Actor, client) -> None: + project = _approve(db_session, member, reviewer) + + sync_workspaces(db_session, client) + + expected_name = workspace_name_for(project.id) + assert project.skypilot_workspace == expected_name + assert client.workspaces[expected_name] == [member.user.email] + + +def test_a_team_change_updates_allowed_users( + db_session: Session, member: Actor, reviewer: Actor, client, make_user +) -> None: + project = _approve(db_session, member, reviewer) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + + builder = make_user(email="builder@example.com") + amendment = projects.start_amendment(db_session, member, project=project) + projects.update_draft(db_session, member, project=project, credited_builder_ids=[builder.id]) + assert amendment.credited_builder_ids == [builder.id] + + sync_workspaces(db_session, client) + + assert client.workspaces[name] == sorted([member.user.email, builder.email]) + + +def test_a_completed_project_gets_torn_down(db_session: Session, member: Actor, reviewer: Actor, client) -> None: + project = _approve(db_session, member, reviewer) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + client.add_cluster(name, cost_cents=500) + client.add_managed_job(name) + + completion = projects.start_completion(db_session, member, project=project) + projects.submit_completion(db_session, member, project=project) + projects.record_review( + db_session, reviewer, revision=completion, decision=ReviewDecision.APPROVE, source=ReviewSource.WEB + ) + db_session.refresh(project) + assert project.status is ProjectStatus.COMPLETED + + sync_workspaces(db_session, client) + + assert project.skypilot_workspace is None + assert name not in client.workspaces + assert client.list_clusters(name) == [] + event = db_session.scalars( + select(AuditEvent).where(AuditEvent.action == AUDIT_WORKSPACE_TORN_DOWN, AuditEvent.project_id == project.id) + ).one() + assert event.actor_id is None + assert event.payload["workspace"] == name + + +def test_a_withdrawn_project_gets_torn_down_too(db_session: Session, member: Actor, reviewer: Actor, client) -> None: + project = _approve(db_session, member, reviewer) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + + projects.withdraw(db_session, member, project=project) + assert project.status is ProjectStatus.WITHDRAWN + + sync_workspaces(db_session, client) + + assert project.skypilot_workspace is None + assert name not in client.workspaces + + +# -------------------------------------------------------------------------------------------------- +# sync_spend +# -------------------------------------------------------------------------------------------------- + + +def test_spend_snapshots_are_written_only_on_change( + db_session: Session, member: Actor, reviewer: Actor, client +) -> None: + project = _approve(db_session, member, reviewer) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + cluster = client.add_cluster(name, cost_cents=1000) + + sync_spend(db_session, client) + assert budget.latest_spend_cents(db_session, project) == 1000 + + def snapshot_count() -> int: + return len(db_session.scalars(select(SpendSnapshot).where(SpendSnapshot.project_id == project.id)).all()) + + assert snapshot_count() == 1 + + # Unchanged spend: no new snapshot. + sync_spend(db_session, client) + assert snapshot_count() == 1 + + # Spend changes: a new snapshot is written. + client.set_cluster_cost(cluster, 1500) + sync_spend(db_session, client) + assert snapshot_count() == 2 + assert budget.latest_spend_cents(db_session, project) == 1500 + + +# -------------------------------------------------------------------------------------------------- +# enforce_budgets +# -------------------------------------------------------------------------------------------------- + + +def test_the_warning_fires_once(db_session: Session, member: Actor, reviewer: Actor, client) -> None: + project = _approve(db_session, member, reviewer, budget_cents=1000) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + client.add_cluster(name, cost_cents=850) # 85% >= 80% warn threshold + sync_spend(db_session, client) + + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) # re-run: should not duplicate + + warnings = db_session.scalars( + select(AuditEvent).where(AuditEvent.action == AUDIT_BUDGET_WARNING, AuditEvent.project_id == project.id) + ).all() + assert len(warnings) == 1 + assert warnings[0].actor_id is None + assert current_budget_flag(db_session, project, warn_percent=WARN_PERCENT) == "warning" + + +def test_teardown_at_100_percent_downs_clusters_and_cancels_jobs( + db_session: Session, member: Actor, reviewer: Actor, client +) -> None: + project = _approve(db_session, member, reviewer, budget_cents=1000) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + client.add_cluster(name, cost_cents=1200) # over budget + client.add_managed_job(name) + sync_spend(db_session, client) + + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) + + assert client.list_clusters(name) == [] + assert client.list_managed_jobs(name) == [] + teardown_event = db_session.scalars( + select(AuditEvent).where(AuditEvent.action == AUDIT_BUDGET_TEARDOWN, AuditEvent.project_id == project.id) + ).one() + assert teardown_event.actor_id is None + assert current_budget_flag(db_session, project, warn_percent=WARN_PERCENT) == "teardown" + + +def test_teardown_does_not_repeat_without_new_clusters( + db_session: Session, member: Actor, reviewer: Actor, client +) -> None: + project = _approve(db_session, member, reviewer, budget_cents=1000) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + client.add_cluster(name, cost_cents=1200) + sync_spend(db_session, client) + + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) + + events = db_session.scalars( + select(AuditEvent).where(AuditEvent.action == AUDIT_BUDGET_TEARDOWN, AuditEvent.project_id == project.id) + ).all() + assert len(events) == 1 + + +def test_teardown_repeats_when_a_new_cluster_appears( + db_session: Session, member: Actor, reviewer: Actor, client +) -> None: + project = _approve(db_session, member, reviewer, budget_cents=1000) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + client.add_cluster(name, cost_cents=1200) + sync_spend(db_session, client) + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) + + # A new cluster shows up after the first teardown (spend/ceiling is still >= 100%). + client.add_cluster(name, cost_cents=1200) + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) + + events = db_session.scalars( + select(AuditEvent).where(AuditEvent.action == AUDIT_BUDGET_TEARDOWN, AuditEvent.project_id == project.id) + ).all() + assert len(events) == 2 + assert client.list_clusters(name) == [] + + +def test_raising_the_ceiling_re_arms_the_warning(db_session: Session, member: Actor, reviewer: Actor, client) -> None: + project = _approve(db_session, member, reviewer, budget_cents=1000) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + cluster_name = client.add_cluster(name, cost_cents=850) + sync_spend(db_session, client) + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) + + warnings_before = db_session.scalars( + select(AuditEvent).where(AuditEvent.action == AUDIT_BUDGET_WARNING, AuditEvent.project_id == project.id) + ).all() + assert len(warnings_before) == 1 + + # Raise the ceiling, but not enough to drop below the warn threshold, then more spend accrues. + budget.add_entry( + db_session, project=project, kind=BudgetEntryKind.ADMIN_ADJUSTMENT, amount_cents=1000, actor=reviewer + ) + client.set_cluster_cost(cluster_name, 1700) # 85% of the new 2000 ceiling + sync_spend(db_session, client) + + enforce_budgets(db_session, client, warn_percent=WARN_PERCENT) + + warnings_after = db_session.scalars( + select(AuditEvent).where(AuditEvent.action == AUDIT_BUDGET_WARNING, AuditEvent.project_id == project.id) + ).all() + assert len(warnings_after) == 2 + + +# -------------------------------------------------------------------------------------------------- +# reconcile +# -------------------------------------------------------------------------------------------------- + + +class _OutageOnceClient(FakeSkyPilotClient): + """A `FakeSkyPilotClient` whose `cost_report` fails once, to test that an outage in one reconcile + step doesn't block the others.""" + + def __init__(self) -> None: + super().__init__() + self.cost_report_calls = 0 + + def cost_report(self, days: int): + self.cost_report_calls += 1 + if self.cost_report_calls == 1: + raise SkyPilotUnavailableError("simulated outage") + return super().cost_report(days) + + +def test_an_outage_in_one_step_does_not_block_the_others(db_session: Session, member: Actor, reviewer: Actor) -> None: + flaky_client = _OutageOnceClient() + project = _approve(db_session, member, reviewer) + + # sync_spend (step 2) raises once; sync_workspaces (step 1) and enforce_budgets (step 3) must still + # run and have their work committed. + reconcile(db_session, flaky_client, warn_percent=WARN_PERCENT) + + db_session.refresh(project) + assert project.skypilot_workspace == workspace_name_for(project.id) + assert flaky_client.cost_report_calls == 1 + + # A later reconcile succeeds and the spend step catches up. + flaky_client.add_cluster(project.skypilot_workspace, cost_cents=42) + reconcile(db_session, flaky_client, warn_percent=WARN_PERCENT) + assert budget.latest_spend_cents(db_session, project) == 42 + + +def test_workspace_name_for_is_deterministic_lowercase_hex() -> None: + project_id = uuid.uuid4() + name = workspace_name_for(project_id) + assert name == f"ganymede-{project_id.hex[:12]}" + assert name.islower() diff --git a/tests/skypilot/__init__.py b/tests/skypilot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/skypilot/test_live_client.py b/tests/skypilot/test_live_client.py new file mode 100644 index 0000000..c2c4f82 --- /dev/null +++ b/tests/skypilot/test_live_client.py @@ -0,0 +1,288 @@ +"""`LiveSkyPilotClient` against a fake SkyPilot API server (`httpx.MockTransport`): async request-id +polling, the FAILED/timeout/network-error error mapping, and cents conversion. Wire shapes taken from +`tests/fixtures/skypilot/` (see `docs/dev/skypilot-spike.md`).""" + +from __future__ import annotations + +import itertools +import json + +import httpx +import pytest + +from krater.config import Settings +from krater.skypilot.errors import SkyPilotRequestFailedError, SkyPilotUnavailableError +from krater.skypilot.live import LiveSkyPilotClient + +API_URL = "https://skypilot.test" +TOKEN = "sky_test_token" + +#: Paths handled by the generic async-schedule-then-poll machinery below. +_SCHEDULED_PATHS = { + "/workspaces/create", + "/workspaces/update", + "/workspaces/delete", + "/workspaces", + "/cost_report", + "/status", + "/jobs/queue", + "/down", + "/jobs/cancel", +} + + +def _settings() -> Settings: + return Settings(skypilot_mode="live", skypilot_api_url=API_URL, skypilot_service_token=TOKEN) + + +class FakeSkyPilotServer: + """A minimal fake of SkyPilot's REST surface, driven by an `httpx.MockTransport`. + + Every scheduled ("async") endpoint replies `200` + `null` body + an `x-skypilot-request-id` + header, exactly like the spike found; the *next* `GET /api/get` for that id returns the queued + poll response for that path (`self.poll_queue`), defaulting to an immediate `SUCCEEDED` with a + `null` return value. `self.sync_error`, when set, makes every scheduled endpoint fail + synchronously instead (no request id issued at all) -- the shape of the spike's + `workspaces_update_forbidden_nonmember_response.json` fixture. + """ + + def __init__(self) -> None: + self.requests: list[httpx.Request] = [] + self._request_id_seq = itertools.count(1) + #: path -> list of `{"status": ..., "return_value": ..., "error": ...}` dicts to hand back in + #: order, one per call to that path (repeats the last one once exhausted). + self.poll_queue: dict[str, list[dict]] = {} + self.sync_error: tuple[int, dict] | None = None + #: request_id -> (source path, the poll response fixed at schedule time). + self._pending: dict[str, tuple[str, dict]] = {} + + def handler(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path + + if path == "/api/get": + request_id = request.url.params["request_id"] + source_path, poll_response = self._pending[request_id] + return httpx.Response(200, json={"request_id": request_id, **poll_response}) + + if path not in _SCHEDULED_PATHS: + return httpx.Response(404, json={"error": "not_found"}) + + if self.sync_error is not None: + status, body = self.sync_error + return httpx.Response(status, json=body) + + queue = self.poll_queue.get(path, [{"status": "SUCCEEDED", "return_value": "null", "error": None}]) + poll_response = queue[0] if len(queue) == 1 else queue.pop(0) + request_id = f"req-{next(self._request_id_seq)}" + self._pending[request_id] = (path, poll_response) + return httpx.Response(200, headers={"x-skypilot-request-id": request_id}) + + +@pytest.fixture +def fake_server() -> FakeSkyPilotServer: + return FakeSkyPilotServer() + + +@pytest.fixture +def client(fake_server: FakeSkyPilotServer) -> LiveSkyPilotClient: + http_client = httpx.Client(transport=httpx.MockTransport(fake_server.handler)) + return LiveSkyPilotClient( + _settings(), http_client=http_client, poll_timeout_seconds=0.2, poll_interval_seconds=0.02 + ) + + +def test_create_workspace_sends_bearer_token_and_vast_only_config( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + client.create_workspace("ganymede-abc123", allowed_users=["a@x.com", "b@x.com"]) + + request = next(r for r in fake_server.requests if r.url.path == "/workspaces/create") + assert request.headers["Authorization"] == f"Bearer {TOKEN}" + body = json.loads(request.content) + assert body["workspace_name"] == "ganymede-abc123" + assert body["config"]["private"] is True + assert body["config"]["allowed_users"] == ["a@x.com", "b@x.com"] + # Every other cloud denied, per the spike's "no per-workspace allowlist" finding. + assert body["config"]["aws"] == {"disabled": True} + assert "vast" not in body["config"] + + +def test_update_workspace_replaces_allowed_users(fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient) -> None: + client.update_workspace("ganymede-abc123", allowed_users=["only-this-one@x.com"]) + + request = next(r for r in fake_server.requests if r.url.path == "/workspaces/update") + body = json.loads(request.content) + assert body["config"]["allowed_users"] == ["only-this-one@x.com"] + + +def test_delete_workspace(fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient) -> None: + client.delete_workspace("ganymede-abc123") + + request = next(r for r in fake_server.requests if r.url.path == "/workspaces/delete") + assert json.loads(request.content) == {"workspace_name": "ganymede-abc123"} + + +def test_list_workspaces_decodes_the_returned_mapping( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/workspaces"] = [ + { + "status": "SUCCEEDED", + "return_value": {"ganymede-priv-test": {"private": True, "allowed_users": ["54400d50"]}, "default": {}}, + "error": None, + } + ] + + names = client.list_workspaces() + + assert sorted(names) == ["default", "ganymede-priv-test"] + + +def test_cost_report_converts_dollars_to_cents_and_groups_by_workspace( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + # `return_value` is itself a JSON-encoded string in the real spike fixture + # (`tests/fixtures/skypilot/cost_report_request_response.json`), not a bare JSON array. + fake_server.poll_queue["/cost_report"] = [ + { + "status": "SUCCEEDED", + "return_value": json.dumps( + [ + {"name": "cluster-a", "workspace": "ganymede-abc123", "total_cost": 12.34}, + {"name": "cluster-b", "workspace": "ganymede-abc123", "total_cost": 0.5}, + {"name": "controller", "workspace": "default", "total_cost": 1.0}, + ] + ), + "error": None, + } + ] + + rows = client.cost_report(days=30) + + request = next(r for r in fake_server.requests if r.url.path == "/cost_report") + assert json.loads(request.content) == {"days": 30} + by_cluster = {row.cluster_name: (row.workspace, row.total_cost_cents) for row in rows} + assert by_cluster["cluster-a"] == ("ganymede-abc123", 1234) + assert by_cluster["cluster-b"] == ("ganymede-abc123", 50) + assert by_cluster["controller"] == ("default", 100) + + +def test_cost_report_handles_an_empty_report(fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient) -> None: + fake_server.poll_queue["/cost_report"] = [{"status": "SUCCEEDED", "return_value": "[]", "error": None}] + + assert client.cost_report(days=30) == [] + + +def test_list_clusters_filters_to_the_requested_workspace( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/status"] = [ + { + "status": "SUCCEEDED", + "return_value": json.dumps( + [ + {"name": "in-workspace", "workspace": "ganymede-abc123", "status": "UP"}, + {"name": "other-workspace", "workspace": "ganymede-other", "status": "UP"}, + ] + ), + "error": None, + } + ] + + clusters = client.list_clusters("ganymede-abc123") + + assert [c.name for c in clusters] == ["in-workspace"] + request = next(r for r in fake_server.requests if r.url.path == "/status") + body = json.loads(request.content) + assert body["override_skypilot_config"]["active_workspace"] == "ganymede-abc123" + + +def test_list_managed_jobs_filters_to_the_requested_workspace( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/jobs/queue"] = [ + { + "status": "SUCCEEDED", + "return_value": json.dumps( + [ + {"job_id": 1, "job_name": "train", "workspace": "ganymede-abc123", "status": "RUNNING"}, + {"job_id": 2, "job_name": "other", "workspace": "ganymede-other", "status": "RUNNING"}, + ] + ), + "error": None, + } + ] + + jobs = client.list_managed_jobs("ganymede-abc123") + + assert [(j.job_id, j.name) for j in jobs] == [(1, "train")] + + +def test_down_cluster_sends_purge(fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient) -> None: + client.down_cluster("some-cluster") + + request = next(r for r in fake_server.requests if r.url.path == "/down") + body = json.loads(request.content) + assert body["cluster_name"] == "some-cluster" + assert body["purge"] is True + + +def test_cancel_managed_jobs_scopes_to_the_workspace( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + client.cancel_managed_jobs("ganymede-abc123") + + request = next(r for r in fake_server.requests if r.url.path == "/jobs/cancel") + body = json.loads(request.content) + assert body["all"] is True + assert body["override_skypilot_config"]["active_workspace"] == "ganymede-abc123" + + +def test_a_failed_poll_raises_request_failed_with_the_servers_message( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/workspaces/create"] = [ + {"status": "FAILED", "return_value": None, "error": "boom: traceback"} + ] + + with pytest.raises(SkyPilotRequestFailedError, match="boom"): + client.create_workspace("ganymede-x", allowed_users=[]) + + +def test_a_synchronous_error_response_raises_request_failed_with_the_servers_message( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + # No x-skypilot-request-id at all -- exactly the shape of the spike's + # `workspaces_update_forbidden_nonmember_response.json` fixture (a 403 with a plain `detail`). + fake_server.sync_error = (403, {"detail": "Forbidden"}) + + with pytest.raises(SkyPilotRequestFailedError, match="Forbidden"): + client.update_workspace("ganymede-x", allowed_users=["nonmember@x.com"]) + + +def test_a_5xx_response_raises_unavailable(fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient) -> None: + fake_server.sync_error = (503, {"detail": "starting up"}) + + with pytest.raises(SkyPilotUnavailableError): + client.create_workspace("ganymede-x", allowed_users=[]) + + +def test_a_network_error_raises_unavailable() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("connection refused", request=request) + + http_client = httpx.Client(transport=httpx.MockTransport(handler)) + client = LiveSkyPilotClient(_settings(), http_client=http_client) + + with pytest.raises(SkyPilotUnavailableError): + client.create_workspace("ganymede-x", allowed_users=[]) + + +def test_a_request_that_never_completes_times_out_as_unavailable( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/workspaces/create"] = [{"status": "RUNNING", "return_value": None, "error": None}] + + with pytest.raises(SkyPilotUnavailableError, match="did not complete"): + client.create_workspace("ganymede-x", allowed_users=[]) diff --git a/tests/worker/test_skypilot_reconcile.py b/tests/worker/test_skypilot_reconcile.py new file mode 100644 index 0000000..b8711eb --- /dev/null +++ b/tests/worker/test_skypilot_reconcile.py @@ -0,0 +1,36 @@ +"""The `skypilot_reconcile` periodic task: registered on the procrastinate app, on the schedule +`skypilot_reconcile_interval_minutes` implies, and calling through to `skypilot_sync.reconcile`. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from krater.worker.app import app, skypilot_reconcile + + +def test_skypilot_reconcile_is_registered_as_a_periodic_task() -> None: + assert "skypilot_reconcile" in app.tasks + + periodic_task = app.periodic_registry.periodic_tasks[("skypilot_reconcile", "")] + # `KRATER_SKYPILOT_RECONCILE_INTERVAL_MINUTES` defaults to 5 (`krater/config.py`). + assert periodic_task.cron == "*/5 * * * *" + + +def test_skypilot_reconcile_calls_through_to_the_service_function() -> None: + with ( + patch("krater.worker.app.reconcile") as mock_reconcile, + patch("krater.worker.app.get_skypilot_client") as mock_get_client, + patch("krater.worker.app.get_sessionmaker") as mock_get_sessionmaker, + ): + fake_session = mock_get_sessionmaker.return_value.return_value + fake_client = mock_get_client.return_value + + skypilot_reconcile.func(timestamp=123) + + mock_reconcile.assert_called_once() + args, kwargs = mock_reconcile.call_args + assert args[0] is fake_session + assert args[1] is fake_client + assert kwargs["warn_percent"] == 80 # KRATER_SKYPILOT_BUDGET_WARN_PERCENT default + fake_session.close.assert_called_once() From 4b9c479d3d67d22806f2c52e9fce27681c5a8d08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:26:38 +0000 Subject: [PATCH 032/124] Add the SkyPilot admin-policy launch gate Implements the RestfulAdminPolicy wire envelope natively (double-JSON-encoded, YAML-in-JSON, per docs/dev/skypilot-spike.md) in krater/skypilot_policy/envelope.py, a framework-free decision function in krater/services/launch_policy.py (rejects on missing/unknown workspace, inactive project, or exhausted budget; forces autodown and caps max_hourly_cost on allow; never rejects `validate` calls, only mutates them, since the spike found those routinely omit the active workspace even for a real launch), and the route itself, POST /internal/skypilot/policy, which is public-reachable, side-effect-free (no DB writes, blocked launches only logged), and gated by a constant-time token check that 404s instead of 403ing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/services/launch_policy.py | 159 +++++++++++++++++ krater/skypilot_policy/__init__.py | 3 + krater/skypilot_policy/envelope.py | 175 ++++++++++++++++++ krater/web/app.py | 3 +- krater/web/routers/skypilot_policy.py | 91 ++++++++++ pyproject.toml | 1 + tests/services/test_launch_policy.py | 236 +++++++++++++++++++++++++ tests/skypilot_policy/__init__.py | 0 tests/skypilot_policy/test_envelope.py | 147 +++++++++++++++ tests/web/test_skypilot_policy.py | 144 +++++++++++++++ uv.lock | 2 + 11 files changed, 960 insertions(+), 1 deletion(-) create mode 100644 krater/services/launch_policy.py create mode 100644 krater/skypilot_policy/__init__.py create mode 100644 krater/skypilot_policy/envelope.py create mode 100644 krater/web/routers/skypilot_policy.py create mode 100644 tests/services/test_launch_policy.py create mode 100644 tests/skypilot_policy/__init__.py create mode 100644 tests/skypilot_policy/test_envelope.py create mode 100644 tests/web/test_skypilot_policy.py diff --git a/krater/services/launch_policy.py b/krater/services/launch_policy.py new file mode 100644 index 0000000..81dbea9 --- /dev/null +++ b/krater/services/launch_policy.py @@ -0,0 +1,159 @@ +"""The SkyPilot launch gate: a pure decision function for the admin-policy endpoint. + +Takes a decoded policy request, a `Session` and `Settings`, and returns an allow (optionally mutated) +or a reject with a message the member will see verbatim in their terminal. See +`docs/skypilot-integration.md` section 2 and `docs/dev/skypilot-spike.md` for the design and the wire +facts this enforces. Framework-free like every other service: no FastAPI, no HTTP status codes -- the +route (`krater.web.routers.skypilot_policy`) maps `Reject`/`Allow` onto 400/200. +""" + +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from krater.config import Settings +from krater.models import Project, ProjectStatus +from krater.services import budget +from krater.skypilot_policy.envelope import PolicyRequest + +#: `request_name` values that actually reserve/consume compute, and so are the only ones subject to +#: rejection (missing/unknown workspace, inactive project, exhausted budget). Everything else -- +#: currently just `validate`, SkyPilot's pre-flight schema check, run before the real `launch` call -- +#: still gets the same cost/autodown mutations applied (so a dry validation reflects what the real +#: launch would do), but is *never* rejected. +#: +#: This matters because of a spike finding (docs/dev/skypilot-spike.md, Surprise #2): a single +#: `sky launch` triggers 2-3 policy calls (`launch` client-side, `validate` server-side, `launch` +#: server-side), and the `validate` call routinely omits `skypilot_config.active_workspace` even when +#: the *actual* `launch` call moments later, from the same invocation, carries it correctly. Rejecting +#: `validate` on "no workspace" would therefore reject every real launch on its very first hop, before +#: the hop with the real workspace ever runs -- and rejecting it on budget would double-count nothing +#: (validate never provisions anything) while still risking that same false rejection. `jobs.launch`, +#: `jobs.launch_controller` and `exec` exist in SkyPilot's `AdminPolicyRequestName` enum but were never +#: observed on the wire in the spike; they're deliberately left out of this set (treated like +#: `validate`, advisory-only) until one is actually seen, rather than guessed at. +ENFORCED_REQUEST_NAMES = frozenset({"launch"}) + +#: Project statuses in which compute launches are allowed. Draft/pending/changes-requested projects +#: haven't been funded yet; completed/withdrawn ones no longer have live budget to spend. +_ACTIVE_STATUSES = frozenset( + {ProjectStatus.APPROVED, ProjectStatus.PENDING_COMPLETION_REVIEW, ProjectStatus.COMPLETION_CHANGES_REQUESTED} +) + +# SkyPilot 0.13.0's `skypilot_config.to_dict()` only fills in `active_workspace` when the user (or their +# local config) explicitly set one -- see docs/dev/skypilot-spike.md Surprise #2. `-w default` is a +# no-op: `default` isn't a Krater project workspace either, so it gets the same message as "nothing set". +_WORKSPACE_HELP = ( + "Target your project's workspace: run `sky launch -w ...` (the workspace " + "name is on your project's Krater page), or add `active_workspace: ` under your " + "`~/.sky/config.yaml`." +) + + +@dataclass(frozen=True) +class Allow: + """Allow the launch, with `task` mutated (autodown forced, cost capped) and `skypilot_config` as-is.""" + + task: dict[str, Any] + skypilot_config: dict[str, Any] + + +@dataclass(frozen=True) +class Reject: + """Reject the launch. `message` is shown to the member verbatim -- keep it clear and actionable.""" + + message: str + + +PolicyDecision = Allow | Reject + + +def _dollars(cents: int) -> str: + return f"${cents / 100:,.2f}" + + +def _resource_items(task: dict[str, Any]) -> list[dict[str, Any]]: + """`task["resources"]`, normalized to a list of the dicts to mutate in place. + + A SkyPilot task's `resources:` can be a single mapping or a list of candidate mappings (`any_of`/ + `ordered`); either way, the returned dicts are the same objects nested in `task`, so mutating them + mutates `task` too. + """ + resources = task.setdefault("resources", {}) + if isinstance(resources, list): + return [item for item in resources if isinstance(item, dict)] + if isinstance(resources, dict): + return [resources] + return [] + + +def _apply_mutations(task: dict[str, Any], settings: Settings) -> dict[str, Any]: + """Force autodown and cap `max_hourly_cost` on every resource candidate in `task`, in place.""" + cap_dollars = settings.skypilot_max_hourly_cost_cents / 100 + for resource in _resource_items(task): + existing_cost = resource.get("max_hourly_cost") + resource["max_hourly_cost"] = ( + min(existing_cost, cap_dollars) if isinstance(existing_cost, int | float) else cap_dollars + ) + + autostop = resource.get("autostop") + user_is_stricter = ( + isinstance(autostop, dict) + and autostop.get("down") is True + and isinstance(autostop.get("idle_minutes"), int | float) + and autostop["idle_minutes"] < settings.skypilot_autodown_idle_minutes + ) + if not user_is_stricter: + resource["autostop"] = {"idle_minutes": settings.skypilot_autodown_idle_minutes, "down": True} + return task + + +def decide(request: PolicyRequest, session: Session, settings: Settings) -> PolicyDecision: + """Decide whether to allow `request`'s launch. + + Only `request_name`s in `ENFORCED_REQUEST_NAMES` can be rejected -- see that constant's docstring. + Every request (enforced or not) that isn't rejected gets `task` mutated the same way: autodown + forced after `settings.skypilot_autodown_idle_minutes` (unless the user's own `autostop` is already + stricter), and every resource's `max_hourly_cost` capped at + `min(user's value, settings.skypilot_max_hourly_cost_cents / 100)`. `skypilot_config` is returned + unchanged: the mutation points here are `resources`-level task fields (see the spike), not config. + + Does at most three simple, indexed reads (the project lookup, plus `budget.remaining_cents`'s two + selects) and never writes -- this is called on the hot path of every `sky launch`. + """ + workspace = request.skypilot_config.get("active_workspace") + + if request.request_name in ENFORCED_REQUEST_NAMES: + if not workspace or workspace == "default": + return Reject(f"No Ganymede project workspace selected. {_WORKSPACE_HELP}") + + project = session.scalar(sa.select(Project).where(Project.skypilot_workspace == workspace)) + if project is None: + return Reject(f"Workspace '{workspace}' isn't a Ganymede project on Krater. {_WORKSPACE_HELP}") + + if project.status not in _ACTIVE_STATUSES: + return Reject( + f"Project '{project.title}' isn't active on Krater right now (status: {project.status.value}). " + "Compute launches are only allowed while a project is approved or in its completion review." + ) + + remaining = budget.remaining_cents(session, project) + if remaining <= 0: + ceiling = budget.ceiling_cents(session, project) + spend = budget.latest_spend_cents(session, project) + return Reject( + f"Project '{project.title}' has used its full compute budget " + f"({_dollars(spend)} of {_dollars(ceiling)}). Launches are blocked until the budget " + "changes -- ask a Ganymede admin to review it." + ) + + task = _apply_mutations(copy.deepcopy(request.task), settings) + return Allow(task=task, skypilot_config=request.skypilot_config) + + +__all__ = ["Allow", "ENFORCED_REQUEST_NAMES", "PolicyDecision", "Reject", "decide"] diff --git a/krater/skypilot_policy/__init__.py b/krater/skypilot_policy/__init__.py new file mode 100644 index 0000000..0b86ffa --- /dev/null +++ b/krater/skypilot_policy/__init__.py @@ -0,0 +1,3 @@ +"""SkyPilot's admin-policy wire envelope: decoding what SkyPilot sends and encoding what it expects +back. See `krater.skypilot_policy.envelope` and `docs/dev/skypilot-spike.md` section 1. +""" diff --git a/krater/skypilot_policy/envelope.py b/krater/skypilot_policy/envelope.py new file mode 100644 index 0000000..1528194 --- /dev/null +++ b/krater/skypilot_policy/envelope.py @@ -0,0 +1,175 @@ +"""Native (de)codec for the `RestfulAdminPolicy` wire envelope SkyPilot uses to call Krater's +launch-gate policy endpoint, and for the mutated response Krater sends back. + +See `docs/dev/skypilot-spike.md` section 1 ("RESTful admin policy wire format") for how this shape was +reverse-engineered, and its section 6 for why it's reimplemented natively here (~15 lines of `json` + +`yaml`) instead of depending on the 450MB `skypilot` package. + +**Wire format, both directions (Surprise #3):** a JSON string containing another, escaped JSON +document. `RestfulAdminPolicy.validate_and_mutate` does `requests.post(url, json=user_request.encode())` +where `UserRequest.encode()` already returns a JSON string (`model_dump_json()`) -- passing that `str` as +`json=` to `requests` serializes it a *second* time. Symmetrically, SkyPilot calls +`MutatedUserRequest.decode(response.json(), ...)`, which requires a `str`, so Krater's response body must +also be a JSON-encoded string of the object. + +Three fields of the *inner* document are themselves YAML text, not JSON, embedded as strings: `task`, +`skypilot_config`, and (request-side only, and only server-side -- see the spike's "User identity" +note) `user`. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any + +import yaml + + +class PolicyEnvelopeError(ValueError): + """The raw body isn't a well-formed `RestfulAdminPolicy` envelope.""" + + +@dataclass(frozen=True) +class PolicyUser: + """The `user:` YAML block, present only on server-side calls (empty client-side -- see the spike).""" + + id: str | None + name: str | None + user_type: str | None + preferred_workspace: str | None + + +@dataclass(frozen=True) +class PolicyRequest: + """A decoded `UserRequest`: one `RestfulAdminPolicy` call, with its YAML fields parsed.""" + + task: dict[str, Any] + skypilot_config: dict[str, Any] + request_name: str + request_options: dict[str, Any] + at_client_side: bool + user: PolicyUser | None + client_api_version: int | None + client_version: str | None + + +@dataclass(frozen=True) +class MutatedRequest: + """A decoded `MutatedUserRequest`: the shape of Krater's own allow response, for round-tripping.""" + + task: dict[str, Any] + skypilot_config: dict[str, Any] + + +def _double_decode(raw: bytes | str) -> dict[str, Any]: + """Undo the double-JSON-encoding common to both directions of this envelope.""" + try: + outer = json.loads(raw) + if not isinstance(outer, str): + raise PolicyEnvelopeError("expected a JSON-encoded string (the envelope is double-JSON-encoded)") + inner = json.loads(outer) + if not isinstance(inner, dict): + raise PolicyEnvelopeError("expected a JSON object inside the encoded string") + return inner + except json.JSONDecodeError as exc: + raise PolicyEnvelopeError(f"invalid JSON: {exc}") from exc + + +def _double_encode(obj: dict[str, Any]) -> str: + """The inverse of `_double_decode`: JSON-encode `obj`, then JSON-encode that string.""" + return json.dumps(json.dumps(obj)) + + +def _load_yaml(text: str | None) -> dict[str, Any]: + if not text or not text.strip(): + return {} + try: + loaded = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise PolicyEnvelopeError(f"invalid YAML: {exc}") from exc + if loaded is None: + return {} + if not isinstance(loaded, dict): + raise PolicyEnvelopeError("expected a YAML mapping") + return loaded + + +def _dump_yaml(data: dict[str, Any]) -> str: + return yaml.safe_dump(data, sort_keys=False, default_flow_style=False) + + +def decode_request(raw_body: bytes | str) -> PolicyRequest: + """Decode a raw HTTP request body from `RestfulAdminPolicy` into a `PolicyRequest`. + + Raises `PolicyEnvelopeError` (a `ValueError`) on anything malformed -- bad outer/inner JSON, bad + YAML in `task`/`skypilot_config`/`user`, or a missing `task`/`request_name` -- so callers (the + route) can turn it into a generic 400 without needing to know why decoding failed. + """ + inner = _double_decode(raw_body) + + try: + task_yaml = inner["task"] + request_name = inner["request_name"] + except KeyError as exc: + raise PolicyEnvelopeError(f"missing required field: {exc}") from exc + + if not isinstance(task_yaml, str): + raise PolicyEnvelopeError("'task' must be a YAML string") + + task = _load_yaml(task_yaml) + skypilot_config = _load_yaml(inner.get("skypilot_config")) + + user_yaml = inner.get("user") + user: PolicyUser | None = None + if isinstance(user_yaml, str) and user_yaml.strip(): + user_data = _load_yaml(user_yaml) + user = PolicyUser( + id=user_data.get("id"), + name=user_data.get("name"), + user_type=user_data.get("user_type"), + preferred_workspace=user_data.get("preferred_workspace"), + ) + + return PolicyRequest( + task=task, + skypilot_config=skypilot_config, + request_name=request_name, + request_options=inner.get("request_options") or {}, + at_client_side=bool(inner.get("at_client_side", False)), + user=user, + client_api_version=inner.get("client_api_version"), + client_version=inner.get("client_version"), + ) + + +def decode_response(raw_body: bytes | str) -> MutatedRequest: + """Decode a `MutatedUserRequest` wire body (Krater's own allow response, or a captured fixture of + one) back into native dicts. Not used by the route itself -- it only ever encodes -- but it's the + natural counterpart to `encode_allow` and is what makes the envelope round-trippable in tests. + """ + inner = _double_decode(raw_body) + try: + task_yaml = inner["task"] + except KeyError as exc: + raise PolicyEnvelopeError(f"missing required field: {exc}") from exc + return MutatedRequest(task=_load_yaml(task_yaml), skypilot_config=_load_yaml(inner.get("skypilot_config"))) + + +def encode_allow(task: dict[str, Any], skypilot_config: dict[str, Any]) -> str: + """Encode an allow decision's task/config back into the double-JSON-encoded wire format + `MutatedUserRequest.decode()` expects. Only these two fields are needed on the response side (see + `tests/fixtures/skypilot/admin_policy_mutated_response_accepted.txt`). + """ + return _double_encode({"task": _dump_yaml(task), "skypilot_config": _dump_yaml(skypilot_config)}) + + +__all__ = [ + "MutatedRequest", + "PolicyEnvelopeError", + "PolicyRequest", + "PolicyUser", + "decode_request", + "decode_response", + "encode_allow", +] diff --git a/krater/web/app.py b/krater/web/app.py index 2152114..181e178 100644 --- a/krater/web/app.py +++ b/krater/web/app.py @@ -13,7 +13,7 @@ from krater.config import get_settings from krater.services.errors import NotAllowed, NotFound -from krater.web.routers import admin, auth, gallery, pages, projects, reviews +from krater.web.routers import admin, auth, gallery, pages, projects, reviews, skypilot_policy from krater.web.templates import templates STATIC_DIR = Path(__file__).parent / "static" @@ -34,6 +34,7 @@ def create_app() -> FastAPI: app.include_router(reviews.router) app.include_router(admin.router) app.include_router(gallery.router) + app.include_router(skypilot_policy.router) @app.exception_handler(NotAllowed) def _handle_not_allowed(request: Request, exc: NotAllowed): diff --git a/krater/web/routers/skypilot_policy.py b/krater/web/routers/skypilot_policy.py new file mode 100644 index 0000000..7479fcc --- /dev/null +++ b/krater/web/routers/skypilot_policy.py @@ -0,0 +1,91 @@ +"""The SkyPilot admin-policy launch gate: `POST /internal/skypilot/policy?token=...`. + +SkyPilot's `RestfulAdminPolicy` calls this on every `sky launch`/`validate` -- and, per +`docs/dev/skypilot-spike.md` Surprise #1, from **both** the SkyPilot API server and members' own +machines running the `sky` CLI, 2-3 times per launch. That has real consequences for how this route is +built: + +- It must be reachable from wherever members run `sky launch`, not just the internal Docker network -- + see `KRATER_PUBLIC_URL` in `docker-compose.yml`. +- The token in its URL is therefore visible to every member, not a real secret. It only keeps the route + from existing to a scanner (a wrong/missing token gets a plain 404); it is not what makes the launch + gate safe. What makes it safe is that the route has **no side effects** -- it never writes to the + database (not even an audit row) and never calls back into SkyPilot -- so it's fine for literally + anyone to call, at any time, from anywhere. The client-side call it also has to answer is not itself + trusted to gate anything (a member could skip it); the server-side call SkyPilot always makes as well + is what actually blocks a rejected launch. +- It takes none of the usual session/CSRF/auth dependencies on purpose: no session cookie exists for a + bare `curl`/`sky` client to send, so nothing here can redirect to `/login`, and there is no form post + to protect with a CSRF token. +- It must stay fast (at most three simple indexed reads via `launch_policy.decide`) since a launch fails + closed if this endpoint doesn't answer. + +On reject, blocked launches are logged (not audited -- audit rows are for admin *actions*, and nothing +here writes) with enough context to investigate: `at_client_side`, the workspace, and the user. +""" + +from __future__ import annotations + +import logging +import secrets +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, Response, status +from sqlalchemy.orm import Session + +from krater.config import Settings, get_settings +from krater.db import get_session +from krater.services import launch_policy +from krater.skypilot_policy.envelope import PolicyEnvelopeError, decode_request, encode_allow + +router = APIRouter() +logger = logging.getLogger(__name__) + + +def _token_is_valid(settings: Settings, token: str | None) -> bool: + """Constant-time comparison against the configured token. An unset configured token never matches + (so a misconfigured, empty `KRATER_SKYPILOT_POLICY_TOKEN` fails closed rather than accepting any + or no token).""" + return ( + bool(settings.skypilot_policy_token) + and bool(token) + and secrets.compare_digest(token, settings.skypilot_policy_token) + ) + + +@router.post("/internal/skypilot/policy", include_in_schema=False) +async def skypilot_launch_policy( + request: Request, + db_session: Annotated[Session, Depends(get_session)], + settings: Annotated[Settings, Depends(get_settings)], + token: str | None = None, +) -> Response: + # Wrong or missing token: 404, not 403/401, so the route doesn't reveal its own existence. + if not _token_is_valid(settings, token): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not Found") + + raw_body = await request.body() + try: + policy_request = decode_request(raw_body) + except PolicyEnvelopeError: + return Response(content="Malformed admin-policy request.", status_code=400, media_type="text/plain") + + decision = launch_policy.decide(policy_request, db_session, settings) + + if isinstance(decision, launch_policy.Reject): + logger.warning( + "skypilot launch blocked: %s", + decision.message, + extra={ + "at_client_side": policy_request.at_client_side, + "workspace": policy_request.skypilot_config.get("active_workspace"), + "user": policy_request.user.name if policy_request.user else None, + }, + ) + return Response(content=decision.message, status_code=400, media_type="text/plain") + + body = encode_allow(decision.task, decision.skypilot_config) + return Response(content=body, status_code=200, media_type="application/json") + + +__all__ = ["router"] diff --git a/pyproject.toml b/pyproject.toml index a49fe49..099eda9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "authlib>=1.3", "httpx>=0.27", "procrastinate[psycopg]>=2.11", + "pyyaml>=6.0", ] [dependency-groups] diff --git a/tests/services/test_launch_policy.py b/tests/services/test_launch_policy.py new file mode 100644 index 0000000..36468eb --- /dev/null +++ b/tests/services/test_launch_policy.py @@ -0,0 +1,236 @@ +"""Tests for `krater.services.launch_policy`: the SkyPilot launch gate's decision function.""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from krater.config import Settings +from krater.models import BudgetEntryKind, Project, ProjectStatus, SpendSnapshot, SpendSource +from krater.services import budget, launch_policy +from krater.services.actor import Actor +from krater.skypilot_policy.envelope import PolicyRequest + +SETTINGS = Settings(skypilot_autodown_idle_minutes=30, skypilot_max_hourly_cost_cents=500) + + +def _request( + *, + task: dict | None = None, + workspace: str | None = "ganymede-test", + request_name: str = "launch", +) -> PolicyRequest: + skypilot_config: dict = {} + if workspace is not None: + skypilot_config["active_workspace"] = workspace + return PolicyRequest( + task=task if task is not None else {"resources": {"infra": "vast", "accelerators": {"A100": 1}}}, + skypilot_config=skypilot_config, + request_name=request_name, + request_options={"cluster_name": "test", "dryrun": True}, + at_client_side=True, + user=None, + client_api_version=None, + client_version=None, + ) + + +def _make_project( + db_session: Session, member: Actor, *, status: ProjectStatus, workspace: str = "ganymede-test" +) -> Project: + project = Project( + title="Launch Policy Project", submitter_id=member.user.id, status=status, skypilot_workspace=workspace + ) + db_session.add(project) + db_session.flush() + return project + + +# -------------------------------------------------------------------------------------------------- +# Rejections +# -------------------------------------------------------------------------------------------------- + + +def test_rejects_when_workspace_is_missing(db_session: Session) -> None: + decision = launch_policy.decide(_request(workspace=None), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Reject) + assert "workspace" in decision.message.lower() + assert "sky launch -w" in decision.message + + +def test_rejects_when_workspace_is_default(db_session: Session) -> None: + decision = launch_policy.decide(_request(workspace="default"), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Reject) + assert "sky launch -w" in decision.message + + +def test_rejects_when_workspace_is_not_a_krater_project(db_session: Session) -> None: + decision = launch_policy.decide(_request(workspace="ganymede-nonexistent"), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Reject) + assert "ganymede-nonexistent" in decision.message + + +def test_rejects_when_project_is_not_active(db_session: Session, member: Actor) -> None: + _make_project(db_session, member, status=ProjectStatus.COMPLETED) + + decision = launch_policy.decide(_request(), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Reject) + assert "completed" in decision.message.lower() + + +def test_rejects_when_project_is_still_in_review(db_session: Session, member: Actor) -> None: + _make_project(db_session, member, status=ProjectStatus.PENDING_REVIEW) + + decision = launch_policy.decide(_request(), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Reject) + + +def test_rejects_when_budget_is_exhausted(db_session: Session, member: Actor) -> None: + project = _make_project(db_session, member, status=ProjectStatus.APPROVED) + budget.add_entry( + db_session, project=project, kind=BudgetEntryKind.INITIAL_APPROVAL, amount_cents=1_000, actor=member + ) + db_session.add( + SpendSnapshot(project_id=project.id, estimated_spend_cents=1_000, source=SpendSource.SKYPILOT_COST_REPORT) + ) + db_session.flush() + + decision = launch_policy.decide(_request(), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Reject) + assert "$10.00" in decision.message + + +# -------------------------------------------------------------------------------------------------- +# Allow + mutations +# -------------------------------------------------------------------------------------------------- + + +def _approved_project(db_session: Session, member: Actor, ceiling_cents: int = 100_000) -> Project: + project = _make_project(db_session, member, status=ProjectStatus.APPROVED) + budget.add_entry( + db_session, project=project, kind=BudgetEntryKind.INITIAL_APPROVAL, amount_cents=ceiling_cents, actor=member + ) + return project + + +def test_allows_and_forces_autodown_when_user_specified_none(db_session: Session, member: Actor) -> None: + _approved_project(db_session, member) + + decision = launch_policy.decide(_request(), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + assert decision.task["resources"]["autostop"] == {"idle_minutes": 30, "down": True} + + +def test_allows_and_caps_max_hourly_cost_to_the_global_default(db_session: Session, member: Actor) -> None: + _approved_project(db_session, member) + task = {"resources": {"infra": "vast", "max_hourly_cost": 999.0}} + + decision = launch_policy.decide(_request(task=task), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + assert decision.task["resources"]["max_hourly_cost"] == 5.0 # 500 cents + + +def test_allows_and_keeps_the_users_lower_max_hourly_cost(db_session: Session, member: Actor) -> None: + _approved_project(db_session, member) + task = {"resources": {"infra": "vast", "max_hourly_cost": 1.5}} + + decision = launch_policy.decide(_request(task=task), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + assert decision.task["resources"]["max_hourly_cost"] == 1.5 + + +def test_allows_and_keeps_the_users_stricter_autostop(db_session: Session, member: Actor) -> None: + _approved_project(db_session, member) + task = {"resources": {"infra": "vast", "autostop": {"idle_minutes": 5, "down": True}}} + + decision = launch_policy.decide(_request(task=task), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + assert decision.task["resources"]["autostop"] == {"idle_minutes": 5, "down": True} + + +def test_allows_and_overrides_a_looser_user_autostop(db_session: Session, member: Actor) -> None: + _approved_project(db_session, member) + task = {"resources": {"infra": "vast", "autostop": {"idle_minutes": 120, "down": True}}} + + decision = launch_policy.decide(_request(task=task), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + assert decision.task["resources"]["autostop"] == {"idle_minutes": 30, "down": True} + + +def test_allows_and_overrides_autostop_with_down_false(db_session: Session, member: Actor) -> None: + """A user who asked for `down: false` (never autodown) isn't "stricter" -- Krater's cap still applies.""" + _approved_project(db_session, member) + task = {"resources": {"infra": "vast", "autostop": {"idle_minutes": 1, "down": False}}} + + decision = launch_policy.decide(_request(task=task), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + assert decision.task["resources"]["autostop"] == {"idle_minutes": 30, "down": True} + + +def test_pending_completion_review_and_completion_changes_requested_are_active( + db_session: Session, member: Actor +) -> None: + for status in (ProjectStatus.PENDING_COMPLETION_REVIEW, ProjectStatus.COMPLETION_CHANGES_REQUESTED): + project = _make_project(db_session, member, status=status, workspace=f"ganymede-{status.value}") + budget.add_entry( + db_session, project=project, kind=BudgetEntryKind.INITIAL_APPROVAL, amount_cents=1_000, actor=member + ) + + decision = launch_policy.decide(_request(workspace=f"ganymede-{status.value}"), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow), status + + +# -------------------------------------------------------------------------------------------------- +# `validate` (and other unenforced request names): mutations yes, rejection never +# -------------------------------------------------------------------------------------------------- + + +def test_validate_is_never_rejected_for_missing_workspace(db_session: Session) -> None: + decision = launch_policy.decide(_request(workspace=None, request_name="validate"), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + + +def test_validate_is_never_rejected_for_unknown_workspace(db_session: Session) -> None: + decision = launch_policy.decide( + _request(workspace="ganymede-nonexistent", request_name="validate"), db_session, SETTINGS + ) + + assert isinstance(decision, launch_policy.Allow) + + +def test_validate_is_never_rejected_for_exhausted_budget(db_session: Session, member: Actor) -> None: + project = _make_project(db_session, member, status=ProjectStatus.APPROVED) + budget.add_entry( + db_session, project=project, kind=BudgetEntryKind.INITIAL_APPROVAL, amount_cents=1_000, actor=member + ) + db_session.add( + SpendSnapshot(project_id=project.id, estimated_spend_cents=1_000, source=SpendSource.SKYPILOT_COST_REPORT) + ) + db_session.flush() + + decision = launch_policy.decide(_request(request_name="validate"), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + + +def test_validate_still_gets_the_same_mutations(db_session: Session) -> None: + task = {"resources": {"infra": "vast", "max_hourly_cost": 999.0}} + + decision = launch_policy.decide(_request(task=task, workspace=None, request_name="validate"), db_session, SETTINGS) + + assert isinstance(decision, launch_policy.Allow) + assert decision.task["resources"]["max_hourly_cost"] == 5.0 + assert decision.task["resources"]["autostop"] == {"idle_minutes": 30, "down": True} diff --git a/tests/skypilot_policy/__init__.py b/tests/skypilot_policy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/skypilot_policy/test_envelope.py b/tests/skypilot_policy/test_envelope.py new file mode 100644 index 0000000..e40476c --- /dev/null +++ b/tests/skypilot_policy/test_envelope.py @@ -0,0 +1,147 @@ +"""Envelope round-trips against the real captured fixtures in tests/fixtures/skypilot/.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from krater.skypilot_policy.envelope import PolicyEnvelopeError, decode_request, decode_response, encode_allow + +FIXTURES = Path(__file__).resolve().parent.parent / "fixtures" / "skypilot" + +REQUEST_FIXTURES = [ + "admin_policy_request_launch_before_mutation.json", + "admin_policy_request_launch_client_side.json", + "admin_policy_request_launch_server_side.json", + "admin_policy_request_rejected_example.json", + "admin_policy_request_validate_server_side.json", +] + + +def _wire_from_pretty_json(path: Path) -> str: + """The `admin_policy_request_*.json` fixtures are the envelope's *inner* JSON object, pretty-printed + for readability (see the fixtures' own note in docs/dev/skypilot-spike.md). Wrap it exactly the way + `RestfulAdminPolicy` would -- as a JSON string containing that same JSON text -- to get real wire bytes. + """ + return json.dumps(path.read_text()) + + +@pytest.mark.parametrize("fixture_name", REQUEST_FIXTURES) +def test_decode_request_parses_every_request_fixture(fixture_name: str) -> None: + path = FIXTURES / fixture_name + raw = _wire_from_pretty_json(path) + expected = json.loads(path.read_text()) + + decoded = decode_request(raw) + + assert decoded.request_name == expected["request_name"] + assert decoded.at_client_side == expected["at_client_side"] + assert decoded.client_api_version == expected.get("client_api_version") + assert decoded.client_version == expected.get("client_version") + assert decoded.request_options == expected["request_options"] + # `task`/`skypilot_config` are YAML strings in the fixture; decoding must parse them into dicts. + assert decoded.task["resources"]["accelerators"] == {"A100": 1} + assert isinstance(decoded.skypilot_config, dict) + + +def test_decode_request_parses_client_side_user_as_absent() -> None: + raw = _wire_from_pretty_json(FIXTURES / "admin_policy_request_launch_client_side.json") + + decoded = decode_request(raw) + + assert decoded.user is None + + +def test_decode_request_parses_server_side_user() -> None: + raw = _wire_from_pretty_json(FIXTURES / "admin_policy_request_launch_server_side.json") + + decoded = decode_request(raw) + + assert decoded.user is not None + assert decoded.user.id == "fcd1e5cf" + assert decoded.user.name == "root" + + +def test_decode_request_handles_missing_workspace_key() -> None: + """The `validate` fixture's `skypilot_config` has no `active_workspace` key at all (Surprise #2).""" + raw = _wire_from_pretty_json(FIXTURES / "admin_policy_request_validate_server_side.json") + + decoded = decode_request(raw) + + assert "active_workspace" not in decoded.skypilot_config + assert decoded.request_name == "validate" + + +def test_decode_request_on_real_raw_wire_bytes_matches_pretty_fixture() -> None: + raw_wire = (FIXTURES / "admin_policy_raw_wire_body_example.txt").read_text() + pretty = json.loads((FIXTURES / "admin_policy_request_launch_client_side.json").read_text()) + + decoded = decode_request(raw_wire) + + assert decoded.request_name == pretty["request_name"] + assert decoded.at_client_side is True + assert decoded.task["resources"]["infra"] == "vast" + + +def test_decode_response_on_real_raw_wire_bytes() -> None: + raw_wire = (FIXTURES / "admin_policy_raw_wire_response_example.txt").read_text() + + decoded = decode_response(raw_wire) + + assert decoded.task["resources"]["infra"] == "vast" + # This is the pre-mutation example: no autostop/max_hourly_cost yet. + assert "autostop" not in decoded.task["resources"] + assert "max_hourly_cost" not in decoded.task["resources"] + + +def test_decode_response_parses_the_accepted_mutated_fixture() -> None: + raw_wire = (FIXTURES / "admin_policy_mutated_response_accepted.txt").read_text() + + decoded = decode_response(raw_wire) + + assert decoded.task["resources"]["max_hourly_cost"] == 2.0 + assert decoded.task["resources"]["autostop"] == {"idle_minutes": 30, "down": True} + assert decoded.skypilot_config["rbac"] == {"default_role": "user"} + + +def test_encode_allow_round_trips_the_accepted_mutated_fixture() -> None: + raw_wire = (FIXTURES / "admin_policy_mutated_response_accepted.txt").read_text() + original = decode_response(raw_wire) + + re_encoded = encode_allow(original.task, original.skypilot_config) + round_tripped = decode_response(re_encoded) + + assert round_tripped == original + + +@pytest.mark.parametrize("fixture_name", REQUEST_FIXTURES) +def test_decode_request_round_trips_task_and_config_through_encode_allow(fixture_name: str) -> None: + """Every request fixture's task/config, once decoded, survives an encode + decode round trip + unchanged -- confirming `encode_allow` (the response side) is the true inverse of the YAML/JSON + layers `decode_request` (the request side) undoes.""" + raw = _wire_from_pretty_json(FIXTURES / fixture_name) + decoded = decode_request(raw) + + re_encoded = encode_allow(decoded.task, decoded.skypilot_config) + round_tripped = decode_response(re_encoded) + + assert round_tripped.task == decoded.task + assert round_tripped.skypilot_config == decoded.skypilot_config + + +@pytest.mark.parametrize( + "raw", + [ + b"not json at all", + json.dumps({"task": "resources: {}"}), # single-encoded, not double -- outer isn't a string + json.dumps(json.dumps("just a string, not an object")), + json.dumps(json.dumps({"request_name": "launch"})), # missing `task` + json.dumps(json.dumps({"task": "{", "request_name": "launch"})), # invalid YAML + json.dumps(json.dumps({"task": 123, "request_name": "launch"})), # task isn't a string + ], +) +def test_decode_request_rejects_malformed_bodies(raw: bytes | str) -> None: + with pytest.raises(PolicyEnvelopeError): + decode_request(raw) diff --git a/tests/web/test_skypilot_policy.py b/tests/web/test_skypilot_policy.py new file mode 100644 index 0000000..d3a981f --- /dev/null +++ b/tests/web/test_skypilot_policy.py @@ -0,0 +1,144 @@ +"""HTTP-level tests for POST /internal/skypilot/policy: the SkyPilot admin-policy launch gate. + +No sign-in involved: this endpoint is called by SkyPilot itself (and, per docs/dev/skypilot-spike.md, +by members' own machines), never by a browser with a Krater session. +""" + +from __future__ import annotations + +import json + +import sqlalchemy as sa +import yaml +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session + +from krater.config import get_settings +from krater.models import AuditEvent, BudgetEntryKind, Project, ProjectStatus +from krater.services import budget +from krater.skypilot_policy.envelope import decode_response +from tests.conftest import MEMBER_SUB +from tests.web.conftest import actor_for + +POLICY_URL = "/internal/skypilot/policy" +TOKEN = "test-policy-token-0123456789abcdef" + + +def _wire_body(*, task: dict, config: dict, request_name: str = "launch", at_client_side: bool = True) -> bytes: + inner = { + "task": yaml.safe_dump(task, sort_keys=False), + "skypilot_config": yaml.safe_dump(config, sort_keys=False), + "request_name": request_name, + "request_options": {"cluster_name": "test", "dryrun": True}, + "at_client_side": at_client_side, + "user": "", + "client_api_version": None, + "client_version": None, + } + return json.dumps(json.dumps(inner)).encode() + + +def _post(client: TestClient, *, token: str | None = TOKEN, body: bytes | None = None, **body_kwargs): + if body is None: + body = _wire_body(task=body_kwargs.pop("task", {"resources": {"infra": "vast"}}), **body_kwargs) + params = {} if token is None else {"token": token} + return client.post(POLICY_URL, params=params, content=body, headers={"content-type": "application/json"}) + + +def test_wrong_token_is_a_404(client: TestClient, monkeypatch) -> None: + monkeypatch.setattr(get_settings(), "skypilot_policy_token", TOKEN) + + response = _post(client, token="not-the-token", config={"active_workspace": "default"}) + + assert response.status_code == 404 + + +def test_missing_token_is_a_404(client: TestClient, monkeypatch) -> None: + monkeypatch.setattr(get_settings(), "skypilot_policy_token", TOKEN) + + response = _post(client, token=None, config={"active_workspace": "default"}) + + assert response.status_code == 404 + + +def test_unconfigured_token_always_404s(client: TestClient, monkeypatch) -> None: + """An empty configured token (misconfiguration) must never accept an empty/absent token either.""" + monkeypatch.setattr(get_settings(), "skypilot_policy_token", "") + + response = _post(client, token="", config={"active_workspace": "default"}) + + assert response.status_code == 404 + + +def test_malformed_body_is_a_400(client: TestClient, monkeypatch) -> None: + monkeypatch.setattr(get_settings(), "skypilot_policy_token", TOKEN) + + response = _post(client, body=b"not a valid envelope at all") + + assert response.status_code == 400 + assert response.text # a generic message, not a stack trace + + +def test_no_database_writes_happen(client: TestClient, monkeypatch, db_session: Session) -> None: + monkeypatch.setattr(get_settings(), "skypilot_policy_token", TOKEN) + + audit_before = db_session.scalar(sa.select(sa.func.count()).select_from(AuditEvent)) + project_before = db_session.scalar(sa.select(sa.func.count()).select_from(Project)) + + # One reject, one allow, one malformed -- none of them should touch the database. + _post(client, config={"active_workspace": "default"}) + _post(client, config={}) + _post(client, body=b"garbage") + + audit_after = db_session.scalar(sa.select(sa.func.count()).select_from(AuditEvent)) + project_after = db_session.scalar(sa.select(sa.func.count()).select_from(Project)) + + assert audit_after == audit_before + assert project_after == project_before + + +def test_reject_returns_400_with_the_message(client: TestClient, monkeypatch) -> None: + monkeypatch.setattr(get_settings(), "skypilot_policy_token", TOKEN) + + response = _post(client, config={"active_workspace": "default"}) + + assert response.status_code == 400 + assert "sky launch -w" in response.text + + +def test_allow_returns_a_body_shaped_like_the_accepted_fixture( + client: TestClient, monkeypatch, db_session: Session, login_as +) -> None: + monkeypatch.setattr(get_settings(), "skypilot_policy_token", TOKEN) + monkeypatch.setattr(get_settings(), "skypilot_autodown_idle_minutes", 30) + monkeypatch.setattr(get_settings(), "skypilot_max_hourly_cost_cents", 500) + + member = login_as(MEMBER_SUB) + project = Project( + title="Route Test Project", + submitter_id=member.id, + status=ProjectStatus.APPROVED, + skypilot_workspace="ganymede-route-test", + ) + db_session.add(project) + db_session.flush() + budget.add_entry( + db_session, + project=project, + kind=BudgetEntryKind.INITIAL_APPROVAL, + amount_cents=100_000, + actor=actor_for(member), + ) + db_session.flush() + + response = _post( + client, + task={"resources": {"infra": "vast", "accelerators": {"A100": 1}}}, + config={"active_workspace": "ganymede-route-test"}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("application/json") + decoded = decode_response(response.text) + assert decoded.task["resources"]["autostop"] == {"idle_minutes": 30, "down": True} + assert decoded.task["resources"]["max_hourly_cost"] == 5.0 diff --git a/uv.lock b/uv.lock index f8cb470..60df8e7 100644 --- a/uv.lock +++ b/uv.lock @@ -407,6 +407,7 @@ dependencies = [ { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, { name = "python-multipart" }, + { name = "pyyaml" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -429,6 +430,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "pyyaml", specifier = ">=6.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] From 13125e45cd1b885e397a84fdd42634cd924f6df3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:26:44 +0000 Subject: [PATCH 033/124] Add skypilot/auth-proxy compose services behind a profile New `skypilot` (SkyPilot API server, pinned to the version docs/dev/skypilot-spike.md was run against) and `auth-proxy` (oauth2-proxy fronting it with Weave OIDC) services, both behind the `skypilot` compose profile so plain `docker compose up` stays UI-only. The skypilot service's ~/.sky/config.yaml is generated from env at container start (admin_policy built from the new KRATER_PUBLIC_URL, since the spike found the policy endpoint is called from members' own machines too, not just server-side; rbac.default_role: user, since SkyPilot's own default is admin; every cloud disabled in the `default` workspace), matching the pattern the `storage` service already uses. .env.example documents every new variable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- .env.example | 39 ++++++++++++++- docker-compose.yml | 119 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index eec8725..4e2abd4 100644 --- a/.env.example +++ b/.env.example @@ -16,12 +16,49 @@ KRATER_WEAVE_API_BASE_URL= KRATER_WEAVE_SERVICE_KEY= KRATER_WEAVE_STUB_USERS_FILE= -# SkyPilot integration. See docs/skypilot-integration.md. `fake` = in-memory SkyPilot for local dev. +# SkyPilot integration. See docs/skypilot-integration.md, docs/dev/skypilot-spike.md and +# docs/dev/staging.md. `fake` = in-memory SkyPilot for local dev; switching to `live` also needs the +# `skypilot` compose profile (`docker compose --profile skypilot up`) -- see below. KRATER_SKYPILOT_MODE=fake KRATER_SKYPILOT_API_URL=http://skypilot:46580 +# A service-account bearer token (`sky_...`), minted once against the running server -- see +# docs/dev/staging.md's bootstrap steps. Never the KRATER_SKYPILOT_BASIC_AUTH_* credentials below. KRATER_SKYPILOT_SERVICE_TOKEN= +# Shared secret carried in the admin-policy URL's query string. Generate with, e.g., `openssl rand -hex 32`. +# Not a strong secret by itself -- see krater/config.py's comment -- but still don't commit a real one. KRATER_SKYPILOT_POLICY_TOKEN= +# -- `skypilot` compose profile only (docker compose --profile skypilot up) ------------------------- +# This portal's own URL as reachable from wherever members run `sky launch` -- NOT an internal Docker +# hostname. The SkyPilot server's generated admin_policy config uses this (see docs/dev/skypilot-spike.md +# Surprise #1: the policy endpoint is also called client-side, from members' own machines). +KRATER_PUBLIC_URL=http://localhost:8000 +# Basic-auth credentials for the SkyPilot API server, used only once to bootstrap the service-account +# token above (ENABLE_BASIC_AUTH + ENABLE_SERVICE_ACCOUNTS both required -- see the spike). Not used by +# member sign-in, which goes through `auth-proxy` instead. +KRATER_SKYPILOT_BASIC_AUTH_USER=admin +KRATER_SKYPILOT_BASIC_AUTH_PASSWORD= +# Local path to a file holding your Vast.ai API key (one line, never committed), mounted read-only into +# the `skypilot` container. See docs/dev/staging.md for how to get a (small-credit!) key. +KRATER_SKYPILOT_VAST_KEY_FILE= +# Which host/port the `skypilot` API server listens on for host access (rarely needed directly; members +# go through `auth-proxy` instead). +SKYPILOT_PORT=46580 + +# oauth2-proxy (`auth-proxy` service), fronting `skypilot` with Weave sign-in. See +# docs/skypilot-integration.md section 0. Uses KRATER_WEAVE_ISSUER above (same Weave instance as Krater +# itself), but a SEPARATE Weave OAuth app/client -- register it as its own confidential app. +KRATER_SKYPILOT_AUTH_CLIENT_ID= +KRATER_SKYPILOT_AUTH_CLIENT_SECRET= +# 32 random bytes, base64/urlsafe-encoded, e.g.: +# python -c "import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())" +KRATER_SKYPILOT_AUTH_COOKIE_SECRET= +# This proxy's own public URL (what a member's browser/`sky api login` hits), used to build its OIDC +# redirect_url. Set `false` for KRATER_SKYPILOT_AUTH_COOKIE_SECURE only over plain http (local dev). +KRATER_SKYPILOT_PUBLIC_URL=http://localhost:46580 +KRATER_SKYPILOT_AUTH_COOKIE_SECURE=true +SKYPILOT_AUTH_PROXY_PORT=8080 + # S3-compatible object storage for gallery screenshots (not wired into the app yet). # docker-compose runs a temporary SeaweedFS container as `storage`. The app talks to it on the internal endpoint; # browsers get presigned URLs on the public one. Change the secret for anything beyond local dev. diff --git a/docker-compose.yml b/docker-compose.yml index 2d9c23e..f5f9d97 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -76,6 +76,125 @@ services: depends_on: - storage + # -- SkyPilot compute (see docs/skypilot-integration.md, docs/dev/skypilot-spike.md) -------------- + # Both behind the `skypilot` profile: `docker compose up` (no --profile) stays UI-only, no Weave or + # Vast credentials required. `docker compose --profile skypilot up` brings these two up too. + + # The SkyPilot API server. Image/version pinned to what docs/dev/skypilot-spike.md was actually run + # against -- bump both together and re-check the spike's findings before changing it. + # + # The server config (~/.sky/config.yaml) is generated from env at container start, the same way + # `storage`'s identities file is, so nothing secret (the policy token) is committed. NOTE: the exact + # `sky api start` invocation below is our best reading of SkyPilot's own server docs -- it could not + # be exercised against a real container in this environment (no Docker here); verify it against a + # real `docker compose --profile skypilot up` before relying on it, per docs/dev/staging.md. + skypilot: + image: berkeleyskypilot/skypilot:0.13.0 + profiles: ["skypilot"] + env_file: .env + environment: + KRATER_PUBLIC_URL: ${KRATER_PUBLIC_URL:-http://localhost:8000} + KRATER_SKYPILOT_POLICY_TOKEN: ${KRATER_SKYPILOT_POLICY_TOKEN} + # Member SSO: the API server delegates auth decisions to `auth-proxy` over the internal network. + # See docs/dev/skypilot-spike.md section 3 for the GET {base_url}/oauth2/auth contract this uses. + SKYPILOT_AUTH_OAUTH2_PROXY_ENABLED: "true" + SKYPILOT_AUTH_OAUTH2_PROXY_BASE_URL: "http://auth-proxy:4180" + # Krater's admin REST calls use a service-account bearer token instead (see + # KRATER_SKYPILOT_SERVICE_TOKEN); both flags are required to mint and accept one (spike Surprise + # and section 3). ENABLE_BASIC_AUTH is only needed for the one-time bootstrap that creates that + # token (docs/dev/staging.md) -- it doesn't affect member sign-in, which goes through auth-proxy. + ENABLE_BASIC_AUTH: "true" + ENABLE_SERVICE_ACCOUNTS: "true" + SKYPILOT_BASIC_AUTH_USER: ${KRATER_SKYPILOT_BASIC_AUTH_USER:-admin} + # No safe default here on purpose -- set a real value in .env before using --profile skypilot. + SKYPILOT_BASIC_AUTH_PASSWORD: ${KRATER_SKYPILOT_BASIC_AUTH_PASSWORD:-changeme-set-in-dot-env} + entrypoint: ["/bin/sh", "-c"] + command: | + set -eu + mkdir -p /root/.sky /root/.config/vastai + cat > /root/.sky/config.yaml < Date: Sat, 26 Sep 2026 21:26:53 +0000 Subject: [PATCH 034/124] Correct skypilot-integration.md against spike findings, add staging runbook Fixes docs/skypilot-integration.md wherever docs/dev/skypilot-spike.md contradicted the original design: the admin-policy endpoint is called client-side too and so must be public and side-effect-free (no audit writes, not just no Slack post); active_workspace is often absent, especially on `validate` calls; SkyPilot's real default role is admin, not user; the oauth2-proxy contract is an auth_request-style callback, not a plain reverse proxy, and service-account tokens need two separate flags; and allowed_users should be kept in sync via full-list create/update, not incremental batch calls (which need internal user ids and fail for anyone who hasn't logged in yet). Adds docs/dev/staging.md: a from-scratch runbook for the maintainer's own Windows machine (Docker Desktop + WSL2, a local Weave on branch Krater-Integration, a small-credit Vast account, and a test script covering the full budget-enforcement loop from project approval through 100% teardown). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docs/dev/staging.md | 223 +++++++++++++++++++++++++++++++++++ docs/skypilot-integration.md | 151 +++++++++++++++++------- 2 files changed, 331 insertions(+), 43 deletions(-) create mode 100644 docs/dev/staging.md diff --git a/docs/dev/staging.md b/docs/dev/staging.md new file mode 100644 index 0000000..62bcc44 --- /dev/null +++ b/docs/dev/staging.md @@ -0,0 +1,223 @@ +# Staging runbook (maintainer's Windows machine) + +A from-scratch setup for running the full stack -- Krater, a local Weave, SkyPilot, oauth2-proxy -- on one Windows +machine, plus a test script that exercises budget enforcement end to end against a real (tiny) Vast rental. Read +`docs/skypilot-integration.md` and `docs/dev/skypilot-spike.md` first; this doc assumes their design and facts. + +**Where things run:** Docker Desktop (with the WSL2 backend) hosts every container. The `sky` CLI, and anything that +needs to reach containers by `localhost`, runs **inside WSL2**, not in PowerShell -- SkyPilot's CLI is Linux/macOS-only +upstream, and Docker Desktop's WSL2 integration makes `localhost` resolve the same way from both sides anyway, so +there's no reason to fight it from Windows directly. Commands below are labelled **PowerShell** or **WSL2 (bash)**. + +## 1. Prerequisites + +**PowerShell** (one-time): + +```powershell +# Docker Desktop, with WSL2 as its backend (Settings > General > "Use the WSL 2 based engine"). +winget install Docker.DockerDesktop +wsl --install -d Ubuntu +``` + +Then, in Docker Desktop's Settings > Resources > WSL Integration, enable integration for your Ubuntu distro. + +**WSL2 (bash)** (one-time): + +```bash +# Python via uv (Krater's own toolchain) and the sky CLI, in their own venvs so nothing collides. +curl -LsSf https://astral.sh/uv/install.sh | sh +pipx install "skypilot[vast]" # or: uv tool install "skypilot[vast]" +sky --version # confirm it matches the pinned image tag in docker-compose.yml (0.13.0) + +git clone https://github.com//Krater.git +cd Krater +``` + +## 2. A local Weave + +Weave provides sign-in for both Krater and the SkyPilot proxy. Run the `Krater-Integration` branch (see +`docs/weave-integration.md`) -- it's the one with the `groups` claim and the directory API Krater depends on. + +**WSL2 (bash):** + +```bash +git clone https://github.com/patchworklabsorg/weave.git ~/weave +cd ~/weave +git checkout Krater-Integration +bin/setup # installs gems, prepares the dev database, etc. -- see Weave's own README for prerequisites +bin/rails server -p 3000 +``` + +Leave that running (or use `bin/dev` if you also want Weave's asset watchers). Weave is now at +`http://localhost:3000`. + +### Register two OAuth apps + +At `http://localhost:3000/admin/oauth_applications`, create two **confidential** applications (see +`docs/weave-integration.md` "Registration" for the general shape): + +1. **Krater** itself: + - Redirect URI: `http://localhost:8000/auth/callback` + - Scopes: `openid profile email groups` + - Note the client id/secret for `KRATER_WEAVE_CLIENT_ID`/`KRATER_WEAVE_CLIENT_SECRET`. + +2. **SkyPilot proxy** (a *separate* app -- never reuse Krater's own credentials here, per + `docs/skypilot-integration.md` section 0): + - Redirect URI: `http://localhost:8080/oauth2/callback` (oauth2-proxy's default callback path, on the port + `auth-proxy` publishes -- see `.env`'s `SKYPILOT_AUTH_PROXY_PORT`). + - Scopes: `openid profile email groups` + - Note the client id/secret for `KRATER_SKYPILOT_AUTH_CLIENT_ID`/`KRATER_SKYPILOT_AUTH_CLIENT_SECRET`. + +### Issue a service key + +Also at `/admin/oauth_applications` (or wherever Weave's `Krater-Integration` branch puts service-key management -- +check that branch's `docs/OAUTH.md` if the path has moved), issue a service key scoped to **`directory:read`** only. +This is `KRATER_WEAVE_SERVICE_KEY`, used for Krater's directory lookups (`get_user`, group membership) -- it should +never be able to act as a user, only read the directory. + +Give your own Weave user the `ganymede:member` group (and `ganymede:admin` if you want to approve your own test +project) through whatever admin UI Weave's `Krater-Integration` branch adds for group management. + +## 3. A separate, small-credit Vast.ai account + +**Use a throwaway or clearly-separated Vast account, never the production one.** Create it at vast.ai, add the +smallest amount of credit that lets you launch anything (a few dollars covers a `datacenter_only` CPU instance for a +short test), and generate an API key under Account > API Keys. + +```bash +mkdir -p ~/.config/vastai +echo "" > ~/.config/vastai/vast_api_key +chmod 600 ~/.config/vastai/vast_api_key +``` + +You'll point `KRATER_SKYPILOT_VAST_KEY_FILE` at this file's path (from WSL2's filesystem, e.g. +`/home//.config/vastai/vast_api_key` -- Docker Desktop's WSL2 integration mounts this fine as a bind mount as +long as `docker compose` itself is also run from within WSL2). + +## 4. `.env` + +**WSL2 (bash)**, from the repo root: + +```bash +cp .env.example .env +``` + +Then edit `.env` (see the comments in `.env.example` for what each does): + +```ini +KRATER_WEAVE_MODE=live +KRATER_WEAVE_ISSUER=http://host.docker.internal:3000 +KRATER_WEAVE_CLIENT_ID= +KRATER_WEAVE_CLIENT_SECRET= +KRATER_WEAVE_API_BASE_URL=http://host.docker.internal:3000 +KRATER_WEAVE_SERVICE_KEY= + +KRATER_SKYPILOT_MODE=live +KRATER_PUBLIC_URL=http://localhost:8000 +KRATER_SKYPILOT_POLICY_TOKEN= +KRATER_SKYPILOT_BASIC_AUTH_USER=admin +KRATER_SKYPILOT_BASIC_AUTH_PASSWORD= +KRATER_SKYPILOT_VAST_KEY_FILE=/home//.config/vastai/vast_api_key + +KRATER_SKYPILOT_AUTH_CLIENT_ID= +KRATER_SKYPILOT_AUTH_CLIENT_SECRET= +KRATER_SKYPILOT_AUTH_COOKIE_SECRET= +KRATER_SKYPILOT_PUBLIC_URL=http://localhost:46580 +KRATER_SKYPILOT_AUTH_COOKIE_SECURE=false # plain http on localhost; set true (and use https) for anything else + +# Tiny, deliberately conservative for a test run -- see "Safety notes" below. +KRATER_SKYPILOT_AUTODOWN_IDLE_MINUTES=5 +KRATER_SKYPILOT_MAX_HOURLY_COST_CENTS=50 +``` + +`host.docker.internal` is how containers reach Weave running directly on the WSL2/Windows host; Docker Desktop wires +this up automatically. `KRATER_WEAVE_SERVICE_KEY` must be issued with `directory:read` (step 2). + +## 5. Bring the stack up + +**WSL2 (bash):** + +```bash +docker compose --profile skypilot up --build +``` + +This starts everything, including `skypilot` and `auth-proxy` (the `skypilot` profile), on top of the usual +`db`/`migrate`/`portal`/`worker`/`storage` services. + +### Bootstrap the SkyPilot service-account token + +One-time, once `skypilot` is up (needs `ENABLE_BASIC_AUTH` + `ENABLE_SERVICE_ACCOUNTS`, both already set by +`docker-compose.yml` -- see `docs/dev/skypilot-spike.md` section 3 for why both are required, and its Surprise #8 for +why this call must come from *outside* the container, not `docker compose exec`, since loopback requests bypass +Basic Auth): + +```bash +curl -u "$KRATER_SKYPILOT_BASIC_AUTH_USER:$KRATER_SKYPILOT_BASIC_AUTH_PASSWORD" \ + -X POST http://localhost:46580/users/service-account-tokens \ + -H 'Content-Type: application/json' \ + -d '{"token_name": "krater-admin"}' +``` + +Copy the returned `token` (starts `sky_...`) into `.env`'s `KRATER_SKYPILOT_SERVICE_TOKEN`, then +`docker compose --profile skypilot up -d portal worker` to pick it up. + +## 6. Test script + +1. **Create a project.** Sign in to Krater at `http://localhost:8000` with your Weave account, submit a small + proposal. +2. **Approve it** (as an admin -- via `/admin` or the review flow). This should provision a private SkyPilot + workspace named `ganymede-` and save it on the project. +3. **Confirm the workspace appears:** + ```bash + curl -H "Authorization: Bearer $KRATER_SKYPILOT_SERVICE_TOKEN" \ + -X POST http://localhost:46580/workspaces -d '{}' -H 'Content-Type: application/json' + # poll GET /api/get?request_id=... per docs/dev/skypilot-spike.md section 2 -- the workspace and + # your project's Weave email should be in the returned mapping. + ``` +4. **Sign in the `sky` CLI as a member:** + ```bash + sky api login -e http://localhost:8080 # the auth-proxy port, not 46580 directly + ``` + This opens a browser to Weave sign-in (via oauth2-proxy). Use the same Weave account as your project's submitter. +5. **Launch a tiny job** targeting your project's workspace explicitly (required -- see + `docs/skypilot-integration.md` section 2 for what happens if you don't): + ```bash + sky launch -w ganymede- -y -c staging-test --cpus 1 --infra vast \ + --down --idle-minutes-to-autostop 5 \ + -- "echo hello from staging && sleep 60" + ``` + Add `resources.vast.datacenter_only: true` in the task YAML (or `--vast-datacenter-only` if the CLI flag exists in + your installed version) for a more reliable host, per `docs/skypilot-integration.md`'s Vast.ai caveats. +6. **Watch spend accrue.** The reconciler runs every `KRATER_SKYPILOT_RECONCILE_INTERVAL_MINUTES` (default 5) and + writes a `SpendSnapshot`; the project page should show it climbing. +7. **Confirm the 80% warning** posts (Slack, or wherever the reconciler notifies in your build) once estimated spend + crosses `KRATER_SKYPILOT_BUDGET_WARN_PERCENT` (default 80) of the ceiling. +8. **Confirm 100% teardown:** once spend reaches the ceiling, the reconciler should tear down the workspace's + clusters/managed jobs, and a follow-up `sky launch -w ganymede- ...` should now be **rejected** by the + policy endpoint with a clear "budget exhausted" message (test this directly too: it's the fastest way to confirm + `krater/services/launch_policy.py`'s reject path against a real `sky launch`, not just its unit tests). + +## 7. Comparing spend against Vast billing + +SkyPilot's `cost_report` is a **catalog-price × uptime estimate**, not a bill (see +`docs/skypilot-integration.md`'s "What SkyPilot does and doesn't provide" table). After a test run: + +1. Note the `SpendSnapshot.estimated_spend_cents` Krater recorded for the project. +2. Check the actual charge in your Vast account's billing/instance history for the same time window. +3. The two won't match exactly -- SkyPilot prices from a cached catalog (`vast/vms.csv`) while the real rental comes + from live `search_offers`, and Vast can add disk charges SkyPilot doesn't price in. Note the delta as a percentage; + a handful of real runs is what `docs/skypilot-integration.md`'s open item (drift/safety-margin) needs before + picking a margin to build into ceilings. + +## Safety notes + +- **Tiny ceilings.** `KRATER_SKYPILOT_MAX_HOURLY_COST_CENTS` and every test project's budget should be small enough + that a mistake costs cents, not dollars -- this is what step 5's `50` cents/hour is for. +- **`datacenter_only`.** Prefer it for reliability (per the Vast.ai caveats above); it also tends to avoid the + cheapest, least predictable consumer-grade offers. +- **Autodown, always.** Never launch without `--down`/`idle_minutes_to_autostop` here -- the whole point of this + environment is to test that Krater's policy *forces* this even if you forget, but don't rely on that while you're + still bringing the stack up for the first time. +- **Never the production Vast key or a real Weave instance.** This runbook's entire point is a disposable, low-stakes + sandbox; keep it that way. Tear the stack down (`docker compose --profile skypilot down -v`) when you're done + testing, and rotate/delete the test Vast key afterward if you're not going to reuse this setup. diff --git a/docs/skypilot-integration.md b/docs/skypilot-integration.md index 76fe458..6d2830c 100644 --- a/docs/skypilot-integration.md +++ b/docs/skypilot-integration.md @@ -53,8 +53,11 @@ Checked against the SkyPilot docs (docs.skypilot.ai) and `skypilot-org/skypilot` - **Isolation:** each approved project gets its own **private** workspace. `allowed_users` is set to the project team's emails (the same `email` claim oauth2-proxy passes on). A member on two projects can use both workspaces and picks one per launch. Everyone else can't see the workspace at all. -- **Roles:** SkyPilot's default role for new users is `user`. Only Krater's service account (and SkyPilot operators) - get `admin`, since SkyPilot admins can see and edit every workspace. +- **Roles:** confirmed by spike (`docs/dev/skypilot-spike.md`, Surprise #5) that SkyPilot's *out-of-the-box* default + role for new users is **`admin`**, not `user` -- every new SSO login otherwise gets full visibility into every + workspace. Krater's SkyPilot server config (`docker-compose.yml`'s `skypilot` service) explicitly sets + `rbac.default_role: user`, which the spike confirmed takes effect on the next new-user login with no server + restart needed. Only Krater's service account (and SkyPilot operators) get `admin`. - **Offboarding:** when a project is completed or withdrawn, Krater removes the team from `allowed_users` before tearing the workspace down. Someone suspended in Weave can't sign in again. Existing SkyPilot sessions last until they expire, so keep the oauth2-proxy cookie lifetime short (e.g. 8h). @@ -79,6 +82,21 @@ Depends on Weave's `groups` claim (on Weave branch `Krater-Integration`, not mer `allowed_groups` and rely on private workspaces alone. Anyone with a Weave account could then sign in, but they'd see nothing. +**How the API server actually checks this** (confirmed by spike, `docs/dev/skypilot-spike.md` section 3): it isn't a +classic reverse-proxy setup where oauth2-proxy sits fully in front and SkyPilot never sees an unauthenticated request. +Instead, `SKYPILOT_AUTH_OAUTH2_PROXY_ENABLED`/`_BASE_URL` make the API server itself call `GET +{base_url}/oauth2/auth` (an nginx-`auth_request`-style check) with the original request's `Host`, `X-Forwarded-Uri` +and cookies -- a `202` with an `X-Auth-Request-Email` header authenticates the request as that email; `401` triggers +a redirect to `{base_url}/oauth2/start`. Custom headers aren't forwarded, only cookies, so the browser/CLI flow still +needs to actually reach `auth-proxy` (not just the API server) for the initial sign-in. + +Separately, Krater's own admin REST calls (workspace CRUD, `cost_report`, teardown) never go through oauth2-proxy at +all -- they use a service-account bearer token instead. Minting one requires **two independent server flags** +(`ENABLE_BASIC_AUTH=true` and `ENABLE_SERVICE_ACCOUNTS=true`; see `docs/dev/staging.md` for the one-time bootstrap), +and presenting the resulting token later still requires `ENABLE_SERVICE_ACCOUNTS=true` to be accepted. **Never rely on +"no credentials supplied" failing safe**: the spike found that with auth off, an unauthenticated REST call is silently +attributed to the server's own local admin identity, not rejected. + ### 1. Provisioning (on approval) When a project's proposal is approved, a worker job: @@ -89,6 +107,14 @@ When a project's proposal is approved, a worker job: 3. Keeps `allowed_users` in step with the team: the submitter plus credited builders, by their Weave email. Members sign in with Weave (see section 0); no per-project tokens are handed out. + **How, concretely** (spike, `docs/dev/skypilot-spike.md` Surprise #4): always resend the **full, current** + `allowed_users` list via `workspaces/update` (the same shape as `workspaces/create`), not an incremental + `batch_add_users`/`batch_remove_users` call. `create`/`update` accept raw email strings, including ones for + people who have never signed into SkyPilot -- access is granted automatically on their first login. + `batch_add_users`/`batch_remove_users` instead require the **internal SkyPilot user id** (from `GET /users`), not + the email, and fail outright for anyone who hasn't logged in yet -- exactly the case Krater needs to support when + pre-provisioning a newly-approved team. + When the project is completed or withdrawn: tear down its clusters and managed jobs, cut off its access, and keep the workspace until the cost history has been recorded, then delete it. @@ -96,38 +122,71 @@ Krater calls the API server with an **admin** service-account token, stored as a ### 2. Admin policy endpoint (every launch) -The SkyPilot server config points at Krater: +The SkyPilot server config points at Krater, with a token in the query string (`RestfulAdminPolicy` can't send +headers) and, critically, at Krater's **public** URL, not an internal Docker hostname: ```yaml -# SkyPilot API server config -admin_policy: http://portal:8000/internal/skypilot/policy +# SkyPilot API server config (docker-compose.yml's `skypilot` service generates this) +admin_policy: https:///internal/skypilot/policy?token= ``` -For each request, the endpoint decodes the body with `sky.admin_policy.UserRequest.decode(...)`, reads -`skypilot_config.active_workspace`, and maps it to a project. - -**Reject** (HTTP 400, with a message the user will see) if: -- the workspace doesn't belong to a Krater project, or the project isn't `approved` (for example, it's completed, - withdrawn or still in review); -- the project's latest estimated spend is at or above its ceiling; -- the request asks for something Vast doesn't support, such as multi-node. This is optional but gives friendlier errors. - -**Change** the request (return `MutatedUserRequest.encode()`) so that: -- the cluster autodowns after idling (e.g. `idle_minutes: 30`, `down: true`) unless the user asked for something - stricter; -- `max_hourly_cost` is capped at a per-project limit. The simple version is a global default; later it could be - something like 5% of the remaining budget; -- the cluster is labelled with the project ID, if resource labels work on Vast. Otherwise the workspace alone is enough - to attribute spend. - -Every rejection writes an `AuditEvent(launch_blocked)` and posts a short note in the project's Slack channel. - -**Security:** `RestfulAdminPolicy` sends **no auth header**. The endpoint must only be reachable on the internal Docker -network, never on the public router. Also check that requests come from the `skypilot` container's address. - -**Availability:** if the endpoint is down, every launch fails (SkyPilot raises `RestfulPolicyError`). That's the right -failure mode for a budget gate, but the endpoint must be cheap. It reads the latest `SpendSnapshot` from Postgres and -never calls SkyPilot back. +**This is a real design change from an earlier draft of this doc, forced by a spike finding** +(`docs/dev/skypilot-spike.md`, Surprise #1): `RestfulAdminPolicy` is called **both from the SkyPilot API server and +from the machine running `sky launch`** -- a member's own laptop -- 2-3 times per launch (`launch` client-side, +`validate` server-side, `launch` server-side). If the URL were internal-Docker-only as originally assumed, every +member's `sky launch` would fail closed on its very first (client-side) hop. That has three consequences: + +- The endpoint must be reachable from wherever members run `sky launch`/`sky api login`, not just from the + `skypilot` container -- see `KRATER_PUBLIC_URL` in `docker-compose.yml`/`.env.example`. +- **The token is not a real secret**: every member who runs `sky launch` can read it out of their own SkyPilot + client config. It only keeps the endpoint from existing to a scanner (wrong/missing token -> a plain 404); it does + not gate who may call it. +- The endpoint must therefore be **safe for literally anyone to call, at any time**: no state changes of any kind + (see "No side effects" below), and only the *server-side* call's decision has any real teeth -- a member skipping + or spoofing the client-side call can't get past the server-side one, which SkyPilot always makes regardless. + +The endpoint decodes the body natively (`krater/skypilot_policy/envelope.py`; see `docs/dev/skypilot-spike.md` +section 1 for the double-JSON-encoded, YAML-in-JSON wire format this reimplements -- no `import sky`, which would +pull in a 450MB dependency tree just to construct a `Task` object) and reads `skypilot_config.active_workspace` to +map the request to a project. + +**`active_workspace` is frequently absent, not just unset** (spike, Surprise #2): SkyPilot 0.13.0's +`skypilot_config.to_dict()` only fills it in when the user explicitly set one, and even then the `validate` call +(one of the 2-3 per launch) omits it while the real `launch` call, moments later, carries it correctly. Rejecting on +"no workspace" therefore only applies to `launch`-family request names; a `validate` (or any other request_name +Krater hasn't seen yet) is never rejected -- it still gets the same mutations below, so a dry validation reflects the +real launch's eventual shape, but a missing/absent workspace on that call alone can't be trusted as "no workspace +selected." See `krater/services/launch_policy.py`'s `ENFORCED_REQUEST_NAMES` docstring for the exact reasoning. + +**Reject** (HTTP 400, with a message the user will see verbatim in their terminal) an enforced (`launch`) request if: +- the workspace is missing or `default` (SkyPilot's own default, not tied to any Krater project) -- the message + tells the member how to target their project's workspace (`sky launch -w ...` or + `active_workspace:` in their local `~/.sky/config.yaml`); +- the workspace doesn't match any `Project.skypilot_workspace`; +- the project isn't in an active state (`approved`, `pending_completion_review`, `completion_changes_requested` -- + for example, it's completed, withdrawn or still in review); +- the project's remaining budget (ceiling minus latest estimated spend) is at or below zero, with a message showing + both figures. + +**Change** the request (return the encoded mutated request) on every non-rejected call, `validate` included, so that: +- the cluster autodowns after idling (`skypilot_autodown_idle_minutes`, `down: true`) unless the user's own + `autostop` is already at least as strict; +- every resource candidate's `max_hourly_cost` is capped at `min(the user's value, skypilot_max_hourly_cost_cents / + 100)`. The simple version is a global default; later it could be something like 5% of the remaining budget. + +Labelling the cluster with the project ID (for Vast attribution) wasn't verified in the spike (resource labels on +Vast specifically weren't tested) and isn't implemented yet -- the workspace alone attributes spend for now. + +**No side effects.** Per the public-reachability requirement above, this route does **no database writes at all, +not even an `AuditEvent`** -- audit rows are for admin actions, and nothing here is one. A blocked launch is logged +instead (with `at_client_side`, the workspace, and the user), for after-the-fact investigation, not as a durable +record. It also never calls back into SkyPilot. The route takes none of Krater's usual session/CSRF dependencies: +there's no session cookie for a bare `sky` client to send, and no form to protect. + +**Availability:** if the endpoint is down, every launch fails closed (SkyPilot raises `RestfulPolicyError`, +client-side, before ever reaching the server). That's the right failure mode for a budget gate, but the endpoint +must stay cheap -- at most a project lookup plus the two reads behind `budget.remaining_cents` -- since it's on the +hot path of every launch, called 2-3 times each. ### 3. Spend reconciler (periodic) @@ -147,16 +206,22 @@ When a project is completed or withdrawn, the reconciler takes a final snapshot gallery's "compute spent" (shown as an estimate), and the unspent remainder is written to the ledger as a `BudgetEntry(reclaim)`. -## Things to verify in a spike (before building on them) - -1. `cost_report` run with an admin service account returns **every** user's clusters, with `workspace` filled in, and - includes managed-job clusters. The jobs controller's own cost isn't attributed to any project. -2. The workspace create, update and add-users endpoints work with a service-account token on a plain Docker (non-Helm) - deployment, and don't need a server restart. -3. **Member access:** end to end on Docker Compose: oauth2-proxy + Weave sign-in, `sky api login` from the CLI, a - private workspace blocking a non-member, and a member launching on Vast in it. Also check that SkyPilot's user - identity is the email (so `allowed_users` entries match), and whether allowing users into a workspace needs them to - have signed in once first. -4. Whether autodown and `max_hourly_cost` changes made by the policy are respected for Vast launches and managed jobs. -5. How far `cost_report` drifts from actual Vast billing on a few real runs. That sets the safety margin (if any) to take - off the ceiling. +## What the spike confirmed, and what's still open + +`docs/dev/skypilot-spike.md` ran the items originally listed here against a real local 0.13.0 server. Confirmed (and +folded into this doc and the implementation above): workspace create/update/delete/batch-user endpoints work with no +server restart; `cost_report` rows include `workspace` and `user_name` (its own docstring is stale -- code against +`global_user_state.get_clusters_from_history`, not the docstring); the policy's mutations are genuinely enforced, not +just echoed (a too-low `max_hourly_cost` made a real dryrun launch fail); and member access (oauth2-proxy + a +simulated SSO identity + private-workspace isolation) works as designed, via `GET {base_url}/oauth2/auth` (see +section 0). + +**Still open** (see the spike's "Not confirmed" list for the full detail and why): +- `sky exec` / `sky jobs launch` triggering the admin policy -- neither had a `--dryrun` path to exercise in 0.13.0. +- Whether autodown/`max_hourly_cost` are honored by an actual Vast *rental* (only the catalog/optimizer stage was + confirmed; no real provisioning was in scope). +- Resource labels surviving on Vast, for per-cluster cost attribution. +- `cost_report` drift against real Vast billing -- needs a few real runs; sets whatever safety margin the ceiling + should carry, if any. +- A real OIDC round trip end-to-end (the spike stubbed oauth2-proxy's `GET /oauth2/auth` contract, not a full browser + flow against real Weave). `docs/dev/staging.md` walks through doing this for real. From d4ff524d191e9b53718dd86082f83d16de162c11 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:27:56 +0000 Subject: [PATCH 035/124] Note that the SkyPilot start command matches the official Helm chart Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docker-compose.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index f5f9d97..4fd2bba 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,10 +84,9 @@ services: # against -- bump both together and re-check the spike's findings before changing it. # # The server config (~/.sky/config.yaml) is generated from env at container start, the same way - # `storage`'s identities file is, so nothing secret (the policy token) is committed. NOTE: the exact - # `sky api start` invocation below is our best reading of SkyPilot's own server docs -- it could not - # be exercised against a real container in this environment (no Docker here); verify it against a - # real `docker compose --profile skypilot up` before relying on it, per docs/dev/staging.md. + # `storage`'s identities file is, so nothing secret (the policy token) is committed. The start command + # (`sky api start --deploy --foreground`) matches SkyPilot's official Helm chart; the container itself hasn't been + # run end to end yet, so check it on the first `docker compose --profile skypilot up` (docs/dev/staging.md). skypilot: image: berkeleyskypilot/skypilot:0.13.0 profiles: ["skypilot"] From 1738d4bc878baed7651d40011ed3853202d2ea16 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:29:35 +0000 Subject: [PATCH 036/124] Capture final spend before workspace teardown; reorder reconcile steps sync_workspaces now takes one final cost_report reading per workspace after downing its clusters and cancelling its jobs, but before deleting the workspace and clearing Project.skypilot_workspace: writes a SpendSnapshot if it changed and records it in the skypilot_workspace_torn_down audit payload. FakeSkyPilotClient's cost history now survives down_cluster (matching real cost_report reading cluster_history, not live status), so this and later cost_report calls still see torn-down clusters' final cost. reconcile now runs sync_spend -> enforce_budgets -> sync_workspaces, so active projects are measured and enforced against this pass's numbers before any same-pass teardown work. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- krater/services/skypilot_sync.py | 44 ++++++++++++++-- krater/skypilot/fake.py | 23 +++++---- tests/services/test_skypilot_sync.py | 77 +++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 15 deletions(-) diff --git a/krater/services/skypilot_sync.py b/krater/services/skypilot_sync.py index ff8f1d2..4d5e054 100644 --- a/krater/services/skypilot_sync.py +++ b/krater/services/skypilot_sync.py @@ -81,8 +81,11 @@ def sync_workspaces(session: Session, client: SkyPilotClient) -> None: `allowed_users` kept equal to `_team_emails`. Always calls `create`/`update` (never skips), so a team change is picked up on the very next reconcile -- cheap, and safe to re-run. - Every `completed`/`withdrawn` project that still has a workspace gets its clusters downed, its - managed jobs cancelled, its workspace deleted, its `skypilot_workspace` column cleared, and a - `skypilot_workspace_torn_down` audit event. + managed jobs cancelled, one final `cost_report` total recorded (as a `SpendSnapshot`, if it + changed, and in the `skypilot_workspace_torn_down` audit event's payload) *before* the workspace + is deleted and `skypilot_workspace` cleared -- once the workspace is gone, nothing can attribute + further `cost_report` rows back to this project (see `docs/skypilot-integration.md` section 4, + "Final spend"). """ for project in _active_projects(session): allowed_users = _team_emails(session, project) @@ -100,12 +103,36 @@ def sync_workspaces(session: Session, client: SkyPilotClient) -> None: for cluster in client.list_clusters(name): client.down_cluster(cluster.name) client.cancel_managed_jobs(name) + + final_spend_cents = _workspace_total_cents(client, name) + if final_spend_cents != budget.latest_spend_cents(session, project): + session.add( + SpendSnapshot( + project_id=project.id, + estimated_spend_cents=final_spend_cents, + source=SpendSource.SKYPILOT_COST_REPORT, + ) + ) + session.flush() + client.delete_workspace(name) project.skypilot_workspace = None - audit.record(session, None, AUDIT_WORKSPACE_TORN_DOWN, project=project, payload={"workspace": name}) + audit.record( + session, + None, + AUDIT_WORKSPACE_TORN_DOWN, + project=project, + payload={"workspace": name, "final_spend_cents": final_spend_cents}, + ) session.flush() +def _workspace_total_cents(client: SkyPilotClient, workspace: str) -> int: + """One `cost_report` call, summed to a single workspace's total -- used for the final-spend figure + captured just before a workspace is torn down.""" + return sum(row.total_cost_cents for row in client.cost_report(days=COST_REPORT_DAYS) if row.workspace == workspace) + + def sync_spend(session: Session, client: SkyPilotClient) -> None: """Write a `SpendSnapshot` for each project whose SkyPilot-reported total spend has changed. @@ -227,7 +254,14 @@ def current_budget_flag(session: Session, project: Project, *, warn_percent: int def reconcile(session: Session, client: SkyPilotClient, *, warn_percent: int) -> None: - """Run `sync_workspaces`, `sync_spend` and `enforce_budgets` in order, committing after each step. + """Run `sync_spend`, `enforce_budgets` and `sync_workspaces` in order, committing after each step. + + `sync_spend`/`enforce_budgets` before `sync_workspaces`: active projects get measured and their + budgets enforced against this pass's numbers before any teardown work happens in the same pass, so + a project that just went over budget is caught before, not after, whatever else this reconcile + tick does to it. `sync_workspaces` still takes its own final `cost_report` reading for a + completed/withdrawn project's teardown, since that project has already dropped out of + `sync_spend`'s and `enforce_budgets`' scope (they only cover active statuses). Committing per step means a SkyPilot outage partway through doesn't lose the other steps' work: if one step raises `SkyPilotError`, it's logged and the next step still runs on the next scheduled @@ -235,9 +269,9 @@ def reconcile(session: Session, client: SkyPilotClient, *, warn_percent: int) -> already the retry loop). """ steps = ( - ("sync_workspaces", lambda: sync_workspaces(session, client)), ("sync_spend", lambda: sync_spend(session, client)), ("enforce_budgets", lambda: enforce_budgets(session, client, warn_percent=warn_percent)), + ("sync_workspaces", lambda: sync_workspaces(session, client)), ) for name, step in steps: try: diff --git a/krater/skypilot/fake.py b/krater/skypilot/fake.py index b8a2aca..4daefb6 100644 --- a/krater/skypilot/fake.py +++ b/krater/skypilot/fake.py @@ -18,7 +18,13 @@ class FakeSkyPilotClient: def __init__(self) -> None: self.workspaces: dict[str, list[str]] = {} self._clusters: dict[str, ClusterInfo] = {} - self._cluster_costs_cents: dict[str, int] = {} + # Cost history, keyed by cluster name -- separate from `_clusters` (the *live* set) because + # real SkyPilot's `cost_report` reads `cluster_history`, which keeps a torn-down cluster's + # final cost around after `down` removes it from the live listing (`/status`). `down_cluster` + # below only pops `_clusters`, never this, so a workspace's last `cost_report` total still + # includes clusters just torn down in the same reconcile pass (see `sync_workspaces`'s + # final-spend-before-teardown step). + self._cost_history: dict[str, tuple[str, int]] = {} # name -> (workspace, cost_cents) self._jobs: dict[int, ManagedJobInfo] = {} self._job_id_seq = itertools.count(1) # A monotonic counter, not `len(self._clusters)`: a cluster added after an earlier one was @@ -44,10 +50,8 @@ def list_workspaces(self) -> list[str]: def cost_report(self, days: int) -> list[CostReportRow]: del days # the fake has no notion of time; it just reports whatever's been added return [ - CostReportRow( - workspace=cluster.workspace, cluster_name=cluster.name, total_cost_cents=self._cluster_costs_cents[name] - ) - for name, cluster in self._clusters.items() + CostReportRow(workspace=workspace, cluster_name=name, total_cost_cents=cost_cents) + for name, (workspace, cost_cents) in self._cost_history.items() ] def list_clusters(self, workspace: str) -> list[ClusterInfo]: @@ -58,7 +62,6 @@ def list_managed_jobs(self, workspace: str) -> list[ManagedJobInfo]: def down_cluster(self, name: str) -> None: self._clusters.pop(name, None) - self._cluster_costs_cents.pop(name, None) def cancel_managed_jobs(self, workspace: str) -> None: for job_id in [job.job_id for job in self._jobs.values() if job.workspace == workspace]: @@ -70,12 +73,14 @@ def add_cluster(self, workspace: str, cost_cents: int, *, name: str | None = Non """Add a cluster in `workspace` with the given cost estimate, returning its name.""" name = name or f"{workspace}-cluster-{next(self._cluster_name_seq)}" self._clusters[name] = ClusterInfo(name=name, workspace=workspace, status=status) - self._cluster_costs_cents[name] = cost_cents + self._cost_history[name] = (workspace, cost_cents) return name def set_cluster_cost(self, name: str, cost_cents: int) -> None: - """Change an existing cluster's cost estimate (simulating time passing / the meter running).""" - self._cluster_costs_cents[name] = cost_cents + """Change an existing (or already torn-down) cluster's cost estimate, simulating time passing + / the meter running.""" + workspace, _ = self._cost_history[name] + self._cost_history[name] = (workspace, cost_cents) def add_managed_job(self, workspace: str, *, name: str | None = None, status: str = "RUNNING") -> int: """Add a managed job in `workspace`, returning its job id.""" diff --git a/tests/services/test_skypilot_sync.py b/tests/services/test_skypilot_sync.py index 12c45b7..0f1755b 100644 --- a/tests/services/test_skypilot_sync.py +++ b/tests/services/test_skypilot_sync.py @@ -5,6 +5,7 @@ from __future__ import annotations import uuid +from unittest.mock import patch import pytest from sqlalchemy import select @@ -104,6 +105,45 @@ def test_a_completed_project_gets_torn_down(db_session: Session, member: Actor, ).one() assert event.actor_id is None assert event.payload["workspace"] == name + # No prior SpendSnapshot existed (sync_spend was never called), so the teardown's own final + # cost_report reading (the cluster's 500) is what gets recorded. + assert event.payload["final_spend_cents"] == 500 + assert budget.latest_spend_cents(db_session, project) == 500 + + +def test_a_completed_project_gets_a_final_spend_snapshot_before_teardown( + db_session: Session, member: Actor, reviewer: Actor, client +) -> None: + project = _approve(db_session, member, reviewer) + sync_workspaces(db_session, client) + name = project.skypilot_workspace + cluster_name = client.add_cluster(name, cost_cents=500) + sync_spend(db_session, client) + assert budget.latest_spend_cents(db_session, project) == 500 + + completion = projects.start_completion(db_session, member, project=project) + projects.submit_completion(db_session, member, project=project) + projects.record_review( + db_session, reviewer, revision=completion, decision=ReviewDecision.APPROVE, source=ReviewSource.WEB + ) + db_session.refresh(project) + assert project.status is ProjectStatus.COMPLETED + + # Spend grows a bit more between the last sync_spend and the teardown pass -- the teardown must + # capture this final figure itself, since the workspace (and the ability to attribute cost_report + # rows back to this project) is about to disappear. + client.set_cluster_cost(cluster_name, 700) + + sync_workspaces(db_session, client) + + assert project.skypilot_workspace is None + assert budget.latest_spend_cents(db_session, project) == 700 + snapshots = db_session.scalars(select(SpendSnapshot).where(SpendSnapshot.project_id == project.id)).all() + assert [s.estimated_spend_cents for s in snapshots] == [500, 700] + event = db_session.scalars( + select(AuditEvent).where(AuditEvent.action == AUDIT_WORKSPACE_TORN_DOWN, AuditEvent.project_id == project.id) + ).one() + assert event.payload["final_spend_cents"] == 700 def test_a_withdrawn_project_gets_torn_down_too(db_session: Session, member: Actor, reviewer: Actor, client) -> None: @@ -286,8 +326,12 @@ def cost_report(self, days: int): def test_an_outage_in_one_step_does_not_block_the_others(db_session: Session, member: Actor, reviewer: Actor) -> None: flaky_client = _OutageOnceClient() project = _approve(db_session, member, reviewer) + # Commit the test's own setup first: `reconcile` rolls back the *session* on a failed step (not + # just that step's own writes), and since sync_spend now runs first and fails immediately, nothing + # else must be left uncommitted on `db_session` for that rollback to catch. + db_session.commit() - # sync_spend (step 2) raises once; sync_workspaces (step 1) and enforce_budgets (step 3) must still + # sync_spend (step 1) raises once; enforce_budgets (step 2) and sync_workspaces (step 3) must still # run and have their work committed. reconcile(db_session, flaky_client, warn_percent=WARN_PERCENT) @@ -301,6 +345,37 @@ def test_an_outage_in_one_step_does_not_block_the_others(db_session: Session, me assert budget.latest_spend_cents(db_session, project) == 42 +def test_reconcile_runs_spend_and_enforcement_before_workspace_teardown( + db_session: Session, member: Actor, reviewer: Actor, client +) -> None: + """`sync_spend` and `enforce_budgets` must run (and see this pass's numbers) before + `sync_workspaces`'s teardown work, so an active project's budget is enforced against this pass's + reading rather than one left over from before a same-pass teardown.""" + call_order: list[str] = [] + real_sync_spend, real_enforce_budgets, real_sync_workspaces = sync_spend, enforce_budgets, sync_workspaces + + def spy_sync_spend(session, client): + call_order.append("sync_spend") + return real_sync_spend(session, client) + + def spy_enforce_budgets(session, client, *, warn_percent): + call_order.append("enforce_budgets") + return real_enforce_budgets(session, client, warn_percent=warn_percent) + + def spy_sync_workspaces(session, client): + call_order.append("sync_workspaces") + return real_sync_workspaces(session, client) + + with ( + patch("krater.services.skypilot_sync.sync_spend", spy_sync_spend), + patch("krater.services.skypilot_sync.enforce_budgets", spy_enforce_budgets), + patch("krater.services.skypilot_sync.sync_workspaces", spy_sync_workspaces), + ): + reconcile(db_session, client, warn_percent=WARN_PERCENT) + + assert call_order == ["sync_spend", "enforce_budgets", "sync_workspaces"] + + def test_workspace_name_for_is_deterministic_lowercase_hex() -> None: project_id = uuid.uuid4() name = workspace_name_for(project_id) From 4937356271fb65ae4a453a373f84e5aefe6b1f0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 21:39:03 +0000 Subject: [PATCH 037/124] Add a deselected-by-default pytest marker for live-service tests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 099eda9..a8b303a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,3 +42,7 @@ select = ["E", "F", "I", "UP", "B", "SIM"] [tool.pytest.ini_options] testpaths = ["tests"] +# `live` tests talk to real running services (a local Weave, a real SkyPilot API server). They're skipped by default; +# run them with `uv run pytest -m live` once those services are up (see docs/dev/). +addopts = "-m 'not live'" +markers = ["live: needs real external services running; deselected by default"] From d52b7c0f1f12cf27619dde6002f7958ecf26a582 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:12:42 +0000 Subject: [PATCH 038/124] Prove the SkyPilot integration against a real 0.13.0 server; fix 3 wire-format bugs Ran krater/skypilot/live.py's client contract and the admin-policy launch gate against a real, locally-run SkyPilot 0.13.0 API server (no Docker, dry runs only) and a real Krater process, using service-account tokens for both an admin and a non-admin "member" identity in place of a live oauth2-proxy/Weave SSO round trip. All of docs/skypilot-integration.md's contract held once three real bugs were fixed in LiveSkyPilotClient, none of them guessable from the Pydantic models alone: - list_clusters sent `refresh: false`; StatusBody.refresh is an enum ("NONE"/"AUTO"/"FORCE"), not a bool, and the server 422s on a bool. - list_managed_jobs/cancel_managed_jobs raised on ClusterNotUpError ("No in-progress managed jobs") whenever a workspace's managed-jobs controller doesn't exist yet -- the common case for nearly every fresh project -- which would have broken sync_workspaces's teardown and enforce_budgets's over-budget teardown for almost every real project. This failure also arrives as an unusual HTTP 500 with the poll-status dict nested under `detail`, which _poll now unwraps generically. - delete_workspace wasn't actually idempotent against a real server, despite the SkyPilotClient protocol promising callers it's safe to call twice. Added regression tests against a fake server for all three (tests/skypilot/test_live_client.py), documented the findings in docs/dev/skypilot-spike.md section 7, and added a repeatable live-contract check: tests/live/test_skypilot_live.py (@pytest.mark.live, skips cleanly without a running server/process) plus scripts/dev/skypilot_contract.sh, which stands up a real SkyPilot server and Krater process, mints the tokens, runs the live tests, and walks the real `sky` CLI through every launch-gate scenario (allowed with forced autodown + capped max_hourly_cost, no workspace, the default workspace, over budget, and a wrong policy token) as a real signed-in non-admin user. See docs/dev/skypilot-contract.md for how to run it and why the two authentication bootstrapping tricks it uses are necessary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docs/dev/skypilot-contract.md | 87 ++++++ docs/dev/skypilot-spike.md | 43 +++ krater/skypilot/live.py | 128 +++++++-- scripts/dev/_skypilot_contract_helper.py | 132 +++++++++ scripts/dev/skypilot_contract.sh | 338 +++++++++++++++++++++++ tests/live/__init__.py | 4 + tests/live/test_skypilot_live.py | 175 ++++++++++++ tests/skypilot/test_live_client.py | 98 +++++++ 8 files changed, 985 insertions(+), 20 deletions(-) create mode 100644 docs/dev/skypilot-contract.md create mode 100755 scripts/dev/_skypilot_contract_helper.py create mode 100755 scripts/dev/skypilot_contract.sh create mode 100644 tests/live/__init__.py create mode 100644 tests/live/test_skypilot_live.py diff --git a/docs/dev/skypilot-contract.md b/docs/dev/skypilot-contract.md new file mode 100644 index 0000000..2d0ceb0 --- /dev/null +++ b/docs/dev/skypilot-contract.md @@ -0,0 +1,87 @@ +# SkyPilot contract check + +A repeatable check that Krater's SkyPilot integration works against a **real SkyPilot 0.13.0 API +server** -- no Docker, no real Vast key, dry runs only. Read `docs/skypilot-integration.md` (the +design) and `docs/dev/skypilot-spike.md` (facts pinned down by hands-on testing, including section 7's +bugs this check exists to catch) first. + +## What it proves + +1. **The client contract** (`krater/skypilot/live.py`, `krater/services/skypilot_sync.py`): a private, + Vast-only workspace is created with the right `allowed_users`; a team change updates it; `cost_report`, + `list_clusters` and `list_managed_jobs` succeed and parse cleanly with no real clusters; completing or + withdrawing a project tears the workspace down. +2. **The launch gate** (`krater/services/launch_policy.py`, `krater/web/routers/skypilot_policy.py`), + exercised with the real `sky` CLI as a signed-in non-admin user: a dry-run launch targeting the + project's workspace is allowed, with the forced autodown and capped `max_hourly_cost` visible in the + mutated request; no workspace, the `default` workspace, an over-budget project, and a wrong policy + token are all rejected or fail closed, with Krater's own message shown verbatim. + +## Two pieces + +- **`tests/live/test_skypilot_live.py`** (`@pytest.mark.live`, deselected by default): `LiveSkyPilotClient`'s + full workspace lifecycle plus the reconciler's read calls, run against a real server -- the biggest + bug surface (see `docs/dev/skypilot-spike.md` section 7: `StatusBody.refresh`'s enum, `/jobs/queue`'s + `ClusterNotUpError`-as-500). Plus three fail-closed checks against a *really-running* Krater process + (not the in-process `TestClient`), since those need a real HTTP round trip: no workspace, `default` + workspace, and a wrong policy token. Skips cleanly (each test's `reason` says which env vars to set) + when nothing is running -- `SKYPILOT_LIVE_API_URL`/`SKYPILOT_LIVE_SERVICE_TOKEN` for the client tests, + `SKYPILOT_LIVE_KRATER_BASE_URL`/`SKYPILOT_LIVE_POLICY_TOKEN` for the process ones. +- **`scripts/dev/skypilot_contract.sh`**: starts a real SkyPilot API server and a real Krater process, + wires them together, runs the pytest file above, then (unless `--skip-launch-gate`) walks through + every launch-gate scenario with the real `sky` CLI, as a real signed-in non-admin service-account + user. Stops both servers on exit, success or failure. + +## Running it + +```bash +export SKYPILOT_VENV=/path/to/a/venv/with/skypilot[vast]==0.13.0 +export KRATER_DATABASE_URL=postgresql+psycopg://root:root@localhost:5432/krater_dev # must exist +scripts/dev/skypilot_contract.sh +``` + +`SKYPILOT_VENV` is deliberately not `uv sync`'d into Krater's own venv: `skypilot[vast]` is ~450MB and +pulls in a second SQLAlchemy, two Postgres drivers, pandas, and more (`docs/dev/skypilot-spike.md` +section 6) -- it's a separate tool the script shells out to, not a Krater dependency. Build it once +with `uv venv "$SKYPILOT_VENV" && uv pip install --python "$SKYPILOT_VENV/bin/python" 'skypilot[vast]==0.13.0'`. + +The script is otherwise self-contained: it picks a fresh temp `WORKDIR` (isolated `HOME`/`~/.sky` +config for both an "admin" and a "member" persona, so nothing touches your real `~/.sky`), generates a +random policy token, mints both service-account tokens, runs migrations, and cleans up its own +processes on exit (`KEEP_WORKDIR=1` keeps the temp dir and logs for debugging a failure). + +## Two bootstrapping tricks worth knowing about + +Both are dead ends without them, and neither is documented by SkyPilot itself -- see +`docs/dev/skypilot-spike.md` Surprises #5, #6 and #8 for the underlying facts. + +1. **Minting the first service-account token needs an admin user, which needs a service-account token.** + Broken by two unauthenticated-loopback quirks: `POST /users/create` has no auth check in its handler + at all, and `BasicAuthMiddleware` bypasses itself entirely for loopback peers -- so a plain `curl` from + `127.0.0.1` with no credentials can create a user with `role: admin` outright. `POST + /users/service-account-tokens` *does* require a real authenticated caller even from loopback, so that + one call has to go out over the host's own non-loopback address instead (`hostname -I`), using the + admin user's Basic Auth credentials. The resulting token is still seeded with `rbac.default_role` + (here, `user`) regardless of who created it -- promoting it to `admin` is one more unauthenticated + loopback call, to `POST /users/update`. +2. **The "signed-in non-admin member" is a second service-account token, not a real SSO login.** This + environment has neither Docker nor a running oauth2-proxy, so there's no way to do the real Weave-SSO + flow `docs/skypilot-integration.md` section 0 describes. A service-account token minted with the + default (`user`) role is a legitimate, distinct, non-admin SkyPilot identity, which is what the launch + gate actually cares about -- but it isn't an email, so it can't appear in Krater's own + `allowed_users` (built entirely from Weave emails, `krater/services/skypilot_sync.py`'s + `_team_emails`). The script grants this one test identity access to the demo project's workspace with + one extra, out-of-band `workspaces/batch_add_users` call (by the SA's internal id, not through + Krater), so the `sky` CLI can actually target it -- Krater's own provisioning contract is verified + separately, by inspecting `allowed_users` after a real `sync_workspaces` call, without needing that + grant at all. + +## Bugs this run found and fixed (in `krater/skypilot/live.py`) + +See `docs/dev/skypilot-spike.md` section 7 for the full detail on each. Summary: `list_clusters` sent +`refresh: false` where the real server wants the string `"NONE"`; `list_managed_jobs`/ +`cancel_managed_jobs` raised on a workspace that never had a managed job (`ClusterNotUpError`, delivered +as an HTTP 500 with the usual poll-status dict nested under `detail`) instead of treating it as empty; +and `delete_workspace` wasn't actually idempotent against a real server despite the `SkyPilotClient` +protocol promising callers it would be. All three now have regression tests against a fake server in +`tests/skypilot/test_live_client.py` and are exercised against the real thing here. diff --git a/docs/dev/skypilot-spike.md b/docs/dev/skypilot-spike.md index 074389c..6e0580f 100644 --- a/docs/dev/skypilot-spike.md +++ b/docs/dev/skypilot-spike.md @@ -365,3 +365,46 @@ in order of weight: If a future need arises for something genuinely SDK-only (there wasn't one found in this spike), re-evaluate then rather than paying the dependency cost up front. + +## 7. The contract run: three `LiveSkyPilotClient` bugs this spike's reasoning missed + +Follow-up, 2026-09-26, same day: `krater/skypilot/live.py` (built from this spike, before this section +existed) was run for real against a fresh 0.13.0 API server -- `sync_workspaces`/`reconcile` via +`python -m krater.skypilot.reconcile_once` against a real Postgres-backed project, and `sky launch +--dryrun` as a real signed-in non-admin service-account user targeting that project's workspace. See +`docs/dev/skypilot-contract.md` for the full repeatable check (`scripts/dev/skypilot_contract.sh`, +`tests/live/test_skypilot_live.py`). Provisioning, team updates, teardown, and every launch-gate +scenario (allowed with forced autodown + capped `max_hourly_cost`, missing/`default` workspace, +over-budget, wrong policy token) all worked as designed once these three were fixed -- none of them +were guessable from reading `sky`'s Pydantic models alone; they only showed up by actually calling the +real server: + +- **`StatusBody.refresh` is `StatusRefreshMode` (`"NONE"`/`"AUTO"`/`"FORCE"`), not a bool.** `POST + /status` with `"refresh": false` (the natural reading of "don't force a refresh") 422s: + `Input should be 'NONE', 'AUTO' or 'FORCE'`. `JobsQueueBody.refresh` really is a plain bool (checked + by reading `sky/server/requests/payloads.py` after this broke) -- the two endpoints disagree with + each other, so this isn't a pattern to extrapolate from elsewhere without checking. +- **`/jobs/queue` and `/jobs/cancel` raise `sky.exceptions.ClusterNotUpError("No in-progress managed + jobs.")` when the workspace's managed-jobs *controller* cluster doesn't exist yet** -- i.e. whenever + no managed job has ever been launched there, which is true of essentially every fresh Ganymede + workspace (most compute is a plain `sky launch`, not `sky jobs launch`). Worse, this particular + failure comes back from `GET /api/get` as **HTTP 500**, with the usual `status`/`error` polled-request + dict nested one level down under `detail` -- not the flat `200` body every other polled request + (`SUCCEEDED` or a "clean" `FAILED`) uses. A client that treats every 5xx from `/api/get` as "SkyPilot + is down" (the reasonable-sounding reading of the design doc's async-polling section) would make + `list_managed_jobs`/`cancel_managed_jobs` -- and therefore `sync_workspaces`'s teardown and + `enforce_budgets`'s over-budget teardown -- fail on almost every real project. `LiveSkyPilotClient` + now unwraps both response shapes and treats this specific error as "no managed jobs" (an empty list / + a no-op), matching what it actually means. +- **`POST /workspaces/delete` on a workspace that doesn't exist is a polled `FAILED` request** (`"Workspace + '' does not exist."`), not a no-op. The `SkyPilotClient` protocol's `delete_workspace` promises + callers it's "safe to call on a workspace that's already gone" -- `sync_workspaces` leans on that for + a reconcile pass that crashes between deleting a workspace and clearing `Project.skypilot_workspace` + (which would otherwise retry the same delete on the next tick, fail the same way, and get stuck + retrying forever, since `reconcile()` rolls back and re-tries a failed step on its next scheduled + run). Fixed the same way: swallow that one message, re-raise everything else. + +All three are now regression-tested against a fake server in `tests/skypilot/test_live_client.py` +(so a future contributor touching `live.py` gets caught without needing a real SkyPilot server) and +against the real one in `tests/live/test_skypilot_live.py` (so a future SkyPilot upgrade that changes +the wire format again is caught there first). diff --git a/krater/skypilot/live.py b/krater/skypilot/live.py index e119c0a..da1bc1b 100644 --- a/krater/skypilot/live.py +++ b/krater/skypilot/live.py @@ -44,6 +44,22 @@ ] # fmt: skip +#: Confirmed live against a real 0.13.0 server (not documented anywhere): `/jobs/queue` and +#: `/jobs/cancel` both raise `sky.exceptions.ClusterNotUpError("No in-progress managed jobs.")` when +#: the workspace's managed-jobs *controller* cluster doesn't exist yet -- i.e. no managed job has ever +#: been launched there. That's the common case for essentially every Ganymede workspace (most compute +#: is a plain `sky launch`, not `sky jobs launch`), so treating it as a real failure would make +#: `list_managed_jobs`/`cancel_managed_jobs` -- and therefore `sync_workspaces`'s teardown and +#: `enforce_budgets`'s budget-exceeded teardown -- fail on almost every project. It means exactly what +#: an empty managed-jobs queue means, so both methods below treat it as one. +_NO_JOBS_CONTROLLER_MARKERS = ("ClusterNotUpError", "No in-progress managed jobs") + + +def _is_no_jobs_controller_error(exc: SkyPilotRequestFailedError) -> bool: + message = str(exc) + return any(marker in message for marker in _NO_JOBS_CONTROLLER_MARKERS) + + def _decode_return_value(raw: Any) -> Any: """Decode a polled request's `return_value`. @@ -91,7 +107,19 @@ def update_workspace(self, name: str, *, allowed_users: list[str]) -> None: ) def delete_workspace(self, name: str) -> None: - self._post_async("/workspaces/delete", {"workspace_name": name}) + # Confirmed live against a real 0.13.0 server: deleting a workspace that doesn't exist is a + # polled `FAILED` request ("Workspace '' does not exist."), not a no-op -- unlike, say, + # `down_cluster`'s `purge=True`. The `SkyPilotClient` protocol promises callers this method is + # "safe to call on a workspace that's already gone" (`sync_workspaces` leans on that for a + # reconcile pass that crashes between deleting a workspace and clearing + # `Project.skypilot_workspace`, which would otherwise retry this same delete, and fail closed + # on it, forever), so swallow exactly that one message here instead of every caller re-deriving + # it. + try: + self._post_async("/workspaces/delete", {"workspace_name": name}) + except SkyPilotRequestFailedError as exc: + if "does not exist" not in str(exc): + raise def list_workspaces(self) -> list[str]: result = self._get_async("/workspaces") or {} @@ -127,7 +155,11 @@ def list_clusters(self, workspace: str) -> list[ClusterInfo]: "/status", { "cluster_names": None, - "refresh": False, + # `StatusBody.refresh` is `StatusRefreshMode` ("NONE"/"AUTO"/"FORCE"), not a bool -- + # a real 0.13.0 server 422s on `false` (confirmed live; see + # docs/dev/skypilot-spike.md). "NONE" matches this call's old (fake-client-only) + # intent of not forcing a live refresh against the cloud. + "refresh": "NONE", "all_users": True, # `/status` has no `workspace` field; scoping is via the request's active-workspace # context (spike section 5). We also filter defensively below, since cost_report's row @@ -145,18 +177,23 @@ def list_clusters(self, workspace: str) -> list[ClusterInfo]: ] def list_managed_jobs(self, workspace: str) -> list[ManagedJobInfo]: - rows = ( - self._post_async( - "/jobs/queue", - { - "refresh": False, - "skip_finished": True, - "all_users": True, - "override_skypilot_config": {"active_workspace": workspace}, - }, + try: + rows = ( + self._post_async( + "/jobs/queue", + { + "refresh": False, + "skip_finished": True, + "all_users": True, + "override_skypilot_config": {"active_workspace": workspace}, + }, + ) + or [] ) - or [] - ) + except SkyPilotRequestFailedError as exc: + if _is_no_jobs_controller_error(exc): + return [] + raise return [ ManagedJobInfo( job_id=row["job_id"], @@ -174,10 +211,14 @@ def down_cluster(self, name: str) -> None: self._post_async("/down", {"cluster_name": name, "purge": True, "graceful": False}) def cancel_managed_jobs(self, workspace: str) -> None: - self._post_async( - "/jobs/cancel", - {"all": True, "all_users": True, "override_skypilot_config": {"active_workspace": workspace}}, - ) + try: + self._post_async( + "/jobs/cancel", + {"all": True, "all_users": True, "override_skypilot_config": {"active_workspace": workspace}}, + ) + except SkyPilotRequestFailedError as exc: + if not _is_no_jobs_controller_error(exc): + raise # -- Transport: request + async request-id polling ------------------------------------------------ @@ -211,13 +252,18 @@ def _poll(self, request_id: str) -> Any: interval = self._poll_interval_seconds while True: response = self._request("GET", "/api/get", params={"request_id": request_id}) - self._raise_for_status(response) - data = response.json() + data = self._poll_response_data(response) + if data is None: + # Not the polled-request shape at all (either direction) -- a genuine transport/server + # problem `_raise_for_status` can describe; if it somehow doesn't raise, fall through + # to unavailable below rather than silently treating an unrecognized 2xx as success. + self._raise_for_status(response) + raise SkyPilotUnavailableError(f"SkyPilot returned an unrecognized response for request {request_id}") status = data.get("status") if status == "SUCCEEDED": return _decode_return_value(data.get("return_value")) if status == "FAILED": - raise SkyPilotRequestFailedError(str(data.get("error") or "SkyPilot request failed")) + raise SkyPilotRequestFailedError(self._poll_failure_message(data)) if time.monotonic() >= deadline: raise SkyPilotUnavailableError( f"SkyPilot request {request_id} did not complete within {self._poll_timeout_seconds}s " @@ -226,6 +272,48 @@ def _poll(self, request_id: str) -> Any: time.sleep(interval) interval = min(interval * POLL_BACKOFF_FACTOR, MAX_POLL_INTERVAL_SECONDS) + @staticmethod + def _poll_response_data(response: httpx.Response) -> dict[str, Any] | None: + """Return `/api/get`'s polled-request dict (the one with `status`/`return_value`/`error`), + regardless of which of the two shapes a real 0.13.0 server used for it. + + Confirmed live: a request that finishes normally (`SUCCEEDED` or a "clean" `FAILED`, e.g. our + own admin-policy rejecting something upstream) comes back as HTTP 200 with that dict at the + body's top level -- but a request that failed because the *handler itself* raised an + uncaught exception (e.g. `ClusterNotUpError` from `/jobs/queue`/`/jobs/cancel` when a + workspace's managed-jobs controller doesn't exist yet, see `_is_no_jobs_controller_error`) + comes back as HTTP **500**, with the identical dict nested one level down, under `detail`. + Callers must handle both or a routine "this failed" case (never mind our own no-jobs- + controller handling above it) gets misread as SkyPilot being unreachable. Returns `None` if + the body matches neither shape, so the caller can fall back to the generic transport-error path. + """ + try: + body = response.json() + except ValueError: + return None + if isinstance(body, dict) and "status" in body: + return body + detail = body.get("detail") if isinstance(body, dict) else None + if isinstance(detail, dict) and "status" in detail: + return detail + return None + + @staticmethod + def _poll_failure_message(data: dict[str, Any]) -> str: + """`data["error"]` for a FAILED polled request is itself a JSON-encoded string (a pickled + exception's `type`/`message`/... per the spike's fixtures) -- decode it for a readable + message, falling back to the raw value for anything that doesn't parse that way.""" + error = data.get("error") + if isinstance(error, str): + try: + parsed = json.loads(error) + except (TypeError, ValueError): + return error or "SkyPilot request failed" + if isinstance(parsed, dict): + return str(parsed.get("message") or parsed.get("type") or error) + return error + return str(error or "SkyPilot request failed") + @staticmethod def _raise_for_status(response: httpx.Response) -> None: if response.status_code >= 500: diff --git a/scripts/dev/_skypilot_contract_helper.py b/scripts/dev/_skypilot_contract_helper.py new file mode 100755 index 0000000..b5852af --- /dev/null +++ b/scripts/dev/_skypilot_contract_helper.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Small DB-mutation helpers for `scripts/dev/skypilot_contract.sh`'s launch-gate demo. + +Not a general-purpose tool -- just the handful of one-off project/budget mutations the shell script +needs against a running Krater's own database, done through `krater.services` (never raw SQL) so they +go through the same rules a real approval/withdrawal would. `KRATER_DATABASE_URL` must already point at +a migrated database (the shell script runs `alembic upgrade head` before calling this). + +Usage: + _skypilot_contract_helper.py create-project + _skypilot_contract_helper.py set-overbudget + _skypilot_contract_helper.py clear-spend + _skypilot_contract_helper.py withdraw-project +""" + +from __future__ import annotations + +import json +import sys +import uuid + +from krater.db import get_sessionmaker +from krater.models import Project, ReviewDecision, ReviewSource, SpendSnapshot, SpendSource, User +from krater.services import projects +from krater.services.actor import Actor +from krater.services.skypilot_sync import workspace_name_for + +# Same stub users `tests/conftest.py` and the rest of the suite use (krater/weave/stub_users.json). +SUBMITTER_EMAIL = "mia@example.com" +REVIEWER_EMAIL = "rae@example.com" + + +def _actor(session, *, email: str, name: str, groups: frozenset[str]) -> Actor: + user = session.query(User).filter_by(email=email).one_or_none() + if user is None: + user = User(weave_sub=f"CONTRACT-{email}", display_name=name, email=email) + session.add(user) + session.flush() + return Actor(user=user, groups=groups) + + +def create_project(budget_cents: int) -> None: + session = get_sessionmaker()() + try: + member = _actor(session, email=SUBMITTER_EMAIL, name="Mia Member", groups=frozenset({"ganymede:member"})) + reviewer = _actor( + session, + email=REVIEWER_EMAIL, + name="Rae Reviewer", + groups=frozenset({"ganymede:member", "ganymede:reviewer"}), + ) + project = projects.create_project( + session, member, title="SkyPilot Contract Check", write_up="Automated contract check." + ) + projects.update_draft(session, member, project=project, budget_requested_cents=budget_cents) + project = projects.submit(session, member, project=project) + projects.record_review( + session, + reviewer, + revision=project.current_revision, + decision=ReviewDecision.APPROVE, + source=ReviewSource.WEB, + ) + session.commit() + session.refresh(project) + print(json.dumps({"project_id": str(project.id), "workspace": workspace_name_for(project.id)})) + finally: + session.close() + + +def set_overbudget(project_id: str) -> None: + session = get_sessionmaker()() + try: + project = session.get(Project, uuid.UUID(project_id)) + if project is None: + raise SystemExit(f"no such project: {project_id}") + # A ceiling-matching spend, regardless of what the ceiling actually is -- simplest way to + # trip `budget.remaining_cents(...) <= 0` without needing to know the exact figure here. + # `SpendSource` has only one member today (real spend always comes from `cost_report`), but a + # manual test snapshot is exactly what it would report anyway once the workspace existed. + ceiling_cents = sum(entry.amount_cents for entry in project.budget_entries) + session.add( + SpendSnapshot( + project_id=project.id, estimated_spend_cents=ceiling_cents, source=SpendSource.SKYPILOT_COST_REPORT + ) + ) + session.commit() + finally: + session.close() + + +def clear_spend(project_id: str) -> None: + session = get_sessionmaker()() + try: + pid = uuid.UUID(project_id) + session.query(SpendSnapshot).filter_by(project_id=pid).delete() + session.commit() + finally: + session.close() + + +def withdraw_project(project_id: str) -> None: + session = get_sessionmaker()() + try: + project = session.get(Project, uuid.UUID(project_id)) + if project is None: + raise SystemExit(f"no such project: {project_id}") + actor = Actor(user=project.submitter, groups=frozenset({"ganymede:member"})) + projects.withdraw(session, actor, project=project) + session.commit() + finally: + session.close() + + +def main() -> None: + if len(sys.argv) < 2: + raise SystemExit(__doc__) + command, args = sys.argv[1], sys.argv[2:] + if command == "create-project": + create_project(int(args[0])) + elif command == "set-overbudget": + set_overbudget(args[0]) + elif command == "clear-spend": + clear_spend(args[0]) + elif command == "withdraw-project": + withdraw_project(args[0]) + else: + raise SystemExit(f"unknown command: {command}\n\n{__doc__}") + + +if __name__ == "__main__": + main() diff --git a/scripts/dev/skypilot_contract.sh b/scripts/dev/skypilot_contract.sh new file mode 100755 index 0000000..c34f7c2 --- /dev/null +++ b/scripts/dev/skypilot_contract.sh @@ -0,0 +1,338 @@ +#!/usr/bin/env bash +# Stand up a real, local SkyPilot 0.13.0 API server (no Docker, dry runs only) plus a real Krater +# process, wire them to each other exactly as docs/skypilot-integration.md describes, and run the +# contract check: `tests/live/test_skypilot_live.py` plus (unless --skip-launch-gate is passed) a live +# `sky launch --dryrun` walk through every launch-gate scenario from docs/skypilot-integration.md +# section 2, as a real signed-in non-admin user. +# +# See docs/dev/skypilot-contract.md for what this proves, why each step exists, and how to read the +# output. Stops both servers on exit (success, failure, or Ctrl-C) -- nothing here is meant to be left +# running. +# +# Usage: +# scripts/dev/skypilot_contract.sh [--skip-launch-gate] +# +# Required env: +# SKYPILOT_VENV Path to a venv with `skypilot[vast]==0.13.0` installed (`sky` on its bin/). +# +# Optional env (defaults shown): +# KRATER_DATABASE_URL postgresql+psycopg://root:root@localhost:5432/krater_dev +# SKY_API_PORT 46580 (SkyPilot's own default) +# KRATER_PORT 8202 +# WORKDIR a fresh `mktemp -d` (isolated HOME/config dirs live here; deleted on exit +# unless KEEP_WORKDIR=1) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +SKY_API_PORT="${SKY_API_PORT:-46580}" +KRATER_PORT="${KRATER_PORT:-8202}" +KRATER_DATABASE_URL="${KRATER_DATABASE_URL:-postgresql+psycopg://root:root@localhost:5432/krater_dev}" +WORKDIR="${WORKDIR:-$(mktemp -d -t skypilot-contract-XXXXXX)}" +SKIP_LAUNCH_GATE=0 +for arg in "$@"; do + case "$arg" in + --skip-launch-gate) SKIP_LAUNCH_GATE=1 ;; + *) echo "Unknown argument: $arg" >&2; exit 2 ;; + esac +done + +if [[ -z "${SKYPILOT_VENV:-}" ]]; then + echo "SKYPILOT_VENV must point at a venv with skypilot[vast]==0.13.0 installed." >&2 + echo " uv venv \"\$SKYPILOT_VENV\" && uv pip install --python \"\$SKYPILOT_VENV/bin/python\" 'skypilot[vast]==0.13.0'" >&2 + exit 2 +fi +SKY_BIN="$SKYPILOT_VENV/bin/sky" +if [[ ! -x "$SKY_BIN" ]]; then + echo "No 'sky' executable at $SKY_BIN -- is SKYPILOT_VENV set up? (see the message above)" >&2 + exit 2 +fi +SKY_VERSION="$("$SKY_BIN" --version 2>&1 | head -1)" +echo "Using $SKY_BIN ($SKY_VERSION)" +case "$SKY_VERSION" in + *0.13.0*) ;; + *) echo "WARNING: expected skypilot==0.13.0, got: $SKY_VERSION -- this contract was written and verified against 0.13.0 only." >&2 ;; +esac + +echo "Working directory: $WORKDIR" +ADMIN_HOME="$WORKDIR/admin_home" +MEMBER_HOME="$WORKDIR/member_home" +mkdir -p "$ADMIN_HOME/.sky" "$ADMIN_HOME/.config/vastai" "$MEMBER_HOME/.sky" "$MEMBER_HOME/.config/vastai" + +# Fake Vast key: SkyPilot's optimizer reads Vast's catalog/searches offers without ever needing a real +# key for a --dryrun launch (confirmed in docs/dev/skypilot-spike.md); this just satisfies `sky check`. +echo "fake-key-for-dryrun-only-not-real" | tee "$ADMIN_HOME/.config/vastai/vast_api_key" "$MEMBER_HOME/.config/vastai/vast_api_key" >/dev/null + +POLICY_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(32))')" + +# The server config (docs/skypilot-integration.md section 2): admin_policy at Krater's *public* URL +# (reachable from wherever `sky launch` runs, per the spike's Surprise #1 -- here that's just +# 127.0.0.1, since everything's local), rbac.default_role: user (Surprise #5: the out-of-the-box +# default is admin), and the `default` workspace with every cloud denied so it can never be used for +# real compute -- members must target their project's own Vast-only workspace. +cat > "$ADMIN_HOME/.sky/config.yaml" < "$MEMBER_HOME/.sky/config.yaml" </dev/null || true + # `sky api start` forks a handful of long-lived multiprocessing workers (its request-queue workers) + # that survive their parent being killed -- confirmed the hard way (a second run refused to bind + # because a first run's orphaned workers were still holding an internal queue port). `setsid` below + # makes $SKY_PID its own process group leader, so `-$SKY_PID` (the negated pid) signals that whole + # group, not just the one process. + [[ -n "$SKY_PID" ]] && kill -TERM -- "-$SKY_PID" 2>/dev/null || true + sleep 1 + [[ -n "$SKY_PID" ]] && kill -KILL -- "-$SKY_PID" 2>/dev/null || true + wait 2>/dev/null || true + if [[ "${KEEP_WORKDIR:-0}" != "1" ]]; then + rm -rf "$WORKDIR" + else + echo "Kept $WORKDIR (KEEP_WORKDIR=1). Logs: $SKY_LOG, $KRATER_LOG" + fi + exit "$status" +} +trap cleanup EXIT INT TERM + +echo "Starting SkyPilot API server on :${SKY_API_PORT}..." +HOME="$ADMIN_HOME" ENABLE_SERVICE_ACCOUNTS=true SKYPILOT_DISABLE_USAGE_COLLECTION=1 \ + setsid "$SKY_BIN" api start --host 0.0.0.0 --enable-basic-auth --foreground >"$SKY_LOG" 2>&1 & +SKY_PID=$! + +for _ in $(seq 1 60); do + curl -s -o /dev/null "http://127.0.0.1:${SKY_API_PORT}/api/health" && break + sleep 1 +done +curl -sf -o /dev/null "http://127.0.0.1:${SKY_API_PORT}/api/health" || { + echo "SkyPilot API server never came up; see $SKY_LOG" >&2 + exit 1 +} +echo "SkyPilot API server is up." + +# Bootstrap an admin user + its service-account token. Both `/users/create` (no auth check at all in +# its handler) and `/users/update` (skips the admin-role check when `request.state.auth_user is None`) +# are only reachable unauthenticated from loopback -- `BasicAuthMiddleware` bypasses itself entirely +# for loopback peers (docs/dev/skypilot-spike.md Surprise #8) -- while `/users/service-account-tokens` +# explicitly requires a real authenticated user even from loopback, so that one call goes out over the +# host's own non-loopback address instead. See docs/dev/skypilot-contract.md for the full reasoning. +ADMIN_USER="krater-admin" +ADMIN_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_urlsafe(18))')" +NON_LOOPBACK_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +if [[ -z "$NON_LOOPBACK_IP" ]]; then + echo "Could not determine a non-loopback IP for this host (needed for the service-account-token bootstrap; see Surprise #8 in docs/dev/skypilot-spike.md)." >&2 + exit 1 +fi + +curl -sf -X POST "http://127.0.0.1:${SKY_API_PORT}/users/create" \ + -H 'Content-Type: application/json' \ + -d "{\"username\": \"${ADMIN_USER}\", \"password\": \"${ADMIN_PASSWORD}\", \"role\": \"admin\"}" >/dev/null + +_create_token() { + local name="$1" + curl -sf -u "${ADMIN_USER}:${ADMIN_PASSWORD}" -X POST "http://${NON_LOOPBACK_IP}:${SKY_API_PORT}/users/service-account-tokens" \ + -H 'Content-Type: application/json' -d "{\"token_name\": \"${name}\", \"expires_in_days\": 0}" +} + +ADMIN_TOKEN_JSON="$(_create_token krater_admin_token)" +ADMIN_TOKEN="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['token'])" "$ADMIN_TOKEN_JSON")" +ADMIN_SA_ID="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['service_account_user_id'])" "$ADMIN_TOKEN_JSON")" +# Service-account tokens are always seeded with `rbac.default_role` (here, "user"), regardless of who +# created them -- promote this one, the same way (loopback, unauthenticated `/users/update`). +curl -sf -X POST "http://127.0.0.1:${SKY_API_PORT}/users/update" -H 'Content-Type: application/json' \ + -d "{\"user_id\": \"${ADMIN_SA_ID}\", \"role\": \"admin\"}" >/dev/null + +MEMBER_TOKEN_JSON="$(_create_token krater_member_token)" +MEMBER_TOKEN="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['token'])" "$MEMBER_TOKEN_JSON")" +MEMBER_SA_ID="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['service_account_user_id'])" "$MEMBER_TOKEN_JSON")" +echo "Admin service-account token minted (role: admin). Member service-account token minted (role: user, the rbac.default_role)." + +echo "Running migrations on $KRATER_DATABASE_URL..." +(cd "$REPO_ROOT" && KRATER_DATABASE_URL="$KRATER_DATABASE_URL" uv run alembic upgrade head) + +echo "Starting Krater on :${KRATER_PORT} (KRATER_SKYPILOT_MODE=live)..." +( + cd "$REPO_ROOT" + export KRATER_ENV=development + export KRATER_WEAVE_MODE=stub + export KRATER_DATABASE_URL + export KRATER_BASE_URL="http://127.0.0.1:${KRATER_PORT}" + export KRATER_SECRET_KEY="skypilot-contract-dev-secret-not-for-prod" + export KRATER_SKYPILOT_MODE=live + export KRATER_SKYPILOT_API_URL="http://127.0.0.1:${SKY_API_PORT}" + export KRATER_SKYPILOT_SERVICE_TOKEN="$ADMIN_TOKEN" + export KRATER_SKYPILOT_POLICY_TOKEN="$POLICY_TOKEN" + export KRATER_SKYPILOT_AUTODOWN_IDLE_MINUTES=5 + export KRATER_SKYPILOT_MAX_HOURLY_COST_CENTS=200 + exec uv run uvicorn krater.web.app:create_app --factory --port "$KRATER_PORT" +) >"$KRATER_LOG" 2>&1 & +KRATER_PID=$! + +for _ in $(seq 1 30); do + curl -s -o /dev/null "http://127.0.0.1:${KRATER_PORT}/" && break + sleep 1 +done +curl -sf -o /dev/null "http://127.0.0.1:${KRATER_PORT}/" || { + echo "Krater never came up; see $KRATER_LOG" >&2 + exit 1 +} +echo "Krater is up." + +echo +echo "=== Running tests/live/test_skypilot_live.py ===" +( + cd "$REPO_ROOT" + export SKYPILOT_LIVE_API_URL="http://127.0.0.1:${SKY_API_PORT}" + export SKYPILOT_LIVE_SERVICE_TOKEN="$ADMIN_TOKEN" + export SKYPILOT_LIVE_KRATER_BASE_URL="http://127.0.0.1:${KRATER_PORT}" + export SKYPILOT_LIVE_POLICY_TOKEN="$POLICY_TOKEN" + uv run pytest -m live tests/live/test_skypilot_live.py -v +) + +if [[ "$SKIP_LAUNCH_GATE" == "1" ]]; then + echo "Skipping the sky-CLI launch-gate demo (--skip-launch-gate)." + exit 0 +fi + +echo +echo "=== Launch-gate demo: a real, signed-in non-admin 'sky launch --dryrun' ===" +PROJECT_JSON="$(cd "$REPO_ROOT" && KRATER_DATABASE_URL="$KRATER_DATABASE_URL" uv run python "$SCRIPT_DIR/_skypilot_contract_helper.py" create-project 200000)" +PROJECT_ID="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['project_id'])" "$PROJECT_JSON")" +WORKSPACE="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['workspace'])" "$PROJECT_JSON")" +echo "Created approved project $PROJECT_ID (submitter mia@example.com), workspace $WORKSPACE" + +echo "Provisioning its workspace (python -m krater.skypilot.reconcile_once)..." +(cd "$REPO_ROOT" && \ + KRATER_ENV=development KRATER_WEAVE_MODE=stub KRATER_DATABASE_URL="$KRATER_DATABASE_URL" \ + KRATER_SKYPILOT_MODE=live KRATER_SKYPILOT_API_URL="http://127.0.0.1:${SKY_API_PORT}" \ + KRATER_SKYPILOT_SERVICE_TOKEN="$ADMIN_TOKEN" KRATER_SKYPILOT_POLICY_TOKEN="$POLICY_TOKEN" \ + uv run python -m krater.skypilot.reconcile_once) + +# The member's identity here is a service-account token, not a real Weave/oauth2-proxy SSO login (this +# environment has neither Docker nor a running oauth2-proxy) -- see docs/dev/skypilot-contract.md for +# why that's the right stand-in for "a signed-in non-admin user" here. Krater's own provisioning always +# grants access by *email* (`_team_emails`, docs/skypilot-integration.md section 1); grant this +# specific test identity access too, out of band, so it can actually target the workspace Krater just +# created for the real submitter's email. +curl -sf -X POST "http://127.0.0.1:${SKY_API_PORT}/workspaces/batch_add_users" \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \ + -d "{\"workspace_names\": [\"${WORKSPACE}\"], \"user_ids\": [\"${MEMBER_SA_ID}\"]}" >/dev/null +sleep 1 + +HOME="$MEMBER_HOME" SKYPILOT_SERVICE_ACCOUNT_TOKEN="$MEMBER_TOKEN" \ + "$SKY_BIN" api login -e "http://127.0.0.1:${SKY_API_PORT}" --token "$MEMBER_TOKEN" >/dev/null + +TASK_YAML="$WORKDIR/task.yaml" +cat > "$TASK_YAML" <<'EOF' +resources: + cloud: vast + accelerators: A100:1 + +run: | + echo hello from the krater skypilot contract check +EOF + +_launch() { + # Captured (not live-piped): `sky launch` renders a live-updating spinner with carriage returns and + # ANSI escapes even under `--dryrun`, which a `| tee | grep` pipeline reads unreliably (the match can + # land in a chunk `grep` never flushes past before the pipe closes). Capturing to a variable first + # and grepping that is robust to how the CLI chose to buffer/flush its own output. + HOME="$MEMBER_HOME" SKYPILOT_SERVICE_ACCOUNT_TOKEN="$MEMBER_TOKEN" NO_COLOR=1 \ + "$SKY_BIN" launch --dryrun -y -c "contract-$1" "${@:2}" "$TASK_YAML" 2>&1 || true +} + +_expect() { + local description="$1" needle="$2" output="$3" + echo "$output" + if grep -qF "$needle" <<<"$output"; then + echo "OK: $description" + else + echo "FAIL: expected to see '$needle' -- $description" >&2 + exit 1 + fi +} + +echo +echo "--- Scenario: allowed, targeting the project workspace (expect a chosen offer + Dryrun finished) ---" +OUT="$(_launch ok -w "$WORKSPACE")" +_expect "dry-run completed with a chosen resource" "Dryrun finished" "$OUT" + +echo +echo "--- Scenario: no workspace selected (expect: rejected, Krater's message) ---" +OUT="$(_launch noworkspace)" +_expect "rejected for no workspace selected" "No Ganymede project workspace selected" "$OUT" + +echo +echo "--- Scenario: the 'default' workspace (expect: rejected, same message) ---" +OUT="$(_launch default -w default)" +_expect "rejected for the default workspace" "No Ganymede project workspace selected" "$OUT" + +echo +echo "--- Scenario: over budget (expect: rejected, budget message) ---" +(cd "$REPO_ROOT" && KRATER_DATABASE_URL="$KRATER_DATABASE_URL" uv run python "$SCRIPT_DIR/_skypilot_contract_helper.py" set-overbudget "$PROJECT_ID") +OUT="$(_launch overbudget -w "$WORKSPACE")" +_expect "rejected for exhausted budget" "used its full compute budget" "$OUT" +(cd "$REPO_ROOT" && KRATER_DATABASE_URL="$KRATER_DATABASE_URL" uv run python "$SCRIPT_DIR/_skypilot_contract_helper.py" clear-spend "$PROJECT_ID") + +echo +echo "--- Scenario: wrong token in the policy URL (expect: fails closed, client-side) ---" +cp "$MEMBER_HOME/.sky/config.yaml" "$WORKDIR/member_config.yaml.bak" +echo "admin_policy: http://127.0.0.1:${KRATER_PORT}/internal/skypilot/policy?token=not-the-real-token" > "$MEMBER_HOME/.sky/config.yaml" +OUT="$(_launch wrongtoken -w "$WORKSPACE")" +cp "$WORKDIR/member_config.yaml.bak" "$MEMBER_HOME/.sky/config.yaml" +_expect "failed closed on a wrong policy token" "RestfulPolicyError" "$OUT" + +echo +echo "--- Withdrawing the project (expect: workspace torn down and deleted) ---" +(cd "$REPO_ROOT" && KRATER_DATABASE_URL="$KRATER_DATABASE_URL" uv run python "$SCRIPT_DIR/_skypilot_contract_helper.py" withdraw-project "$PROJECT_ID") +(cd "$REPO_ROOT" && \ + KRATER_ENV=development KRATER_WEAVE_MODE=stub KRATER_DATABASE_URL="$KRATER_DATABASE_URL" \ + KRATER_SKYPILOT_MODE=live KRATER_SKYPILOT_API_URL="http://127.0.0.1:${SKY_API_PORT}" \ + KRATER_SKYPILOT_SERVICE_TOKEN="$ADMIN_TOKEN" KRATER_SKYPILOT_POLICY_TOKEN="$POLICY_TOKEN" \ + uv run python -m krater.skypilot.reconcile_once) + +echo +echo "All launch-gate scenarios passed." diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 0000000..1747865 --- /dev/null +++ b/tests/live/__init__.py @@ -0,0 +1,4 @@ +"""Tests that need real external services running (`@pytest.mark.live`, deselected by default). + +Keep this file minimal -- more than one live-service test module lives directly under here. +""" diff --git a/tests/live/test_skypilot_live.py b/tests/live/test_skypilot_live.py new file mode 100644 index 0000000..9bfbbb9 --- /dev/null +++ b/tests/live/test_skypilot_live.py @@ -0,0 +1,175 @@ +"""End-to-end checks against a **real** SkyPilot 0.13.0 API server (and, for a couple of fail-closed +checks, a really-running Krater process) -- not the `httpx.MockTransport` fake in +`tests/skypilot/test_live_client.py`. Marked `@pytest.mark.live` (deselected by default; see +`pyproject.toml`'s `markers`). + +This is the regression net for the wire-format bugs the initial spike/build missed and this contract +run found (see `docs/dev/skypilot-spike.md` and `docs/dev/skypilot-contract.md`): `StatusBody.refresh` +being an enum, not a bool, and `/jobs/queue`/`/jobs/cancel` raising `ClusterNotUpError` (HTTP 500, not a +clean `FAILED` poll body) when a workspace's managed-jobs controller doesn't exist yet. Both are things +`httpx.MockTransport` can't catch on its own, because the fake has to already know the real shape to +fake it -- these tests exist so a future SkyPilot upgrade that changes the wire format again fails here +first, against the real thing, not in production. + +Run via `scripts/dev/skypilot_contract.sh`, which starts a real server, mints the tokens, and runs +`uv run pytest -m live tests/live/test_skypilot_live.py`. Running it directly needs the same env vars +that script sets -- see each skip message below, and `docs/dev/skypilot-contract.md`. +""" + +from __future__ import annotations + +import json +import os +import uuid + +import httpx +import pytest + +from krater.config import Settings +from krater.skypilot.live import LiveSkyPilotClient + +pytestmark = pytest.mark.live + +_API_URL = os.environ.get("SKYPILOT_LIVE_API_URL", "") +_SERVICE_TOKEN = os.environ.get("SKYPILOT_LIVE_SERVICE_TOKEN", "") +_KRATER_BASE_URL = os.environ.get("SKYPILOT_LIVE_KRATER_BASE_URL", "") +_POLICY_TOKEN = os.environ.get("SKYPILOT_LIVE_POLICY_TOKEN", "") + +_MISSING_CLIENT_ENV = not (_API_URL and _SERVICE_TOKEN) +_MISSING_POLICY_ENV = not (_KRATER_BASE_URL and _POLICY_TOKEN) + +skip_without_skypilot_server = pytest.mark.skipif( + _MISSING_CLIENT_ENV, + reason=( + "needs a real running SkyPilot API server: set SKYPILOT_LIVE_API_URL and " + "SKYPILOT_LIVE_SERVICE_TOKEN (an admin service-account bearer token) -- " + "see scripts/dev/skypilot_contract.sh" + ), +) +skip_without_krater_process = pytest.mark.skipif( + _MISSING_POLICY_ENV, + reason=( + "needs a really-running Krater process (not the in-process TestClient) with " + "KRATER_SKYPILOT_POLICY_TOKEN set: export SKYPILOT_LIVE_KRATER_BASE_URL and " + "SKYPILOT_LIVE_POLICY_TOKEN -- see scripts/dev/skypilot_contract.sh" + ), +) + + +@pytest.fixture +def live_client() -> LiveSkyPilotClient: + settings = Settings(skypilot_mode="live", skypilot_api_url=_API_URL, skypilot_service_token=_SERVICE_TOKEN) + return LiveSkyPilotClient(settings) + + +@pytest.fixture +def live_workspace(live_client: LiveSkyPilotClient): + """A workspace name unique to this test run, deleted afterwards even if the test fails.""" + name = f"ganymede-livetest-{uuid.uuid4().hex[:12]}" + yield name + live_client.delete_workspace(name) + + +# -------------------------------------------------------------------------------------------------- +# `LiveSkyPilotClient` contract: workspace lifecycle, allowed_users, Vast-only, and the read-side +# calls a project's reconciler makes -- all against the real server, over real REST. +# -------------------------------------------------------------------------------------------------- + + +@skip_without_skypilot_server +def test_workspace_lifecycle_and_reconciler_reads_against_a_real_server( + live_client: LiveSkyPilotClient, live_workspace: str +) -> None: + live_client.create_workspace(live_workspace, allowed_users=["a@example.com", "b@example.com"]) + + assert live_workspace in live_client.list_workspaces() + + # A team change is a full-list replace (per docs/skypilot-integration.md section 1), not an + # add/remove -- confirm it actually lands on the real server. + live_client.update_workspace(live_workspace, allowed_users=["only-this-one@example.com"]) + + # Every read a reconcile pass makes, with no clusters ever launched in this brand-new workspace. + # Each of these caught a real bug in this contract run (see the module docstring): `list_clusters` + # 422ed on `refresh: false`, and `list_managed_jobs` raised on a workspace with no jobs-controller + # cluster yet (the common case for a fresh workspace) instead of returning `[]`. + assert live_client.cost_report(days=3650) == [] + assert live_client.list_clusters(live_workspace) == [] + assert live_client.list_managed_jobs(live_workspace) == [] + + # Teardown calls `cancel_managed_jobs` unconditionally (docs/skypilot-integration.md section 1) -- + # must be a clean no-op here too, for the same "no jobs controller yet" reason. + live_client.cancel_managed_jobs(live_workspace) + + live_client.delete_workspace(live_workspace) + assert live_workspace not in live_client.list_workspaces() + + +# -------------------------------------------------------------------------------------------------- +# Fail-closed checks against a really-running Krater process (not the in-process `TestClient`): these +# specifically prove the *deployed* process's settings and routing work, which an in-process test +# (tests/web/test_skypilot_policy.py) can't -- it never leaves the test process. +# -------------------------------------------------------------------------------------------------- + + +def _wire_request(*, task: dict, config: dict, request_name: str = "launch") -> bytes: + """Encode a `RestfulAdminPolicy`-shaped request body: double-JSON-encoded, with `task`/ + `skypilot_config` as embedded YAML strings (see docs/dev/skypilot-spike.md section 1 and + krater/skypilot_policy/envelope.py). Avoids importing PyYAML here by keeping both fields + JSON-parseable -- YAML is a superset of JSON, so this round-trips through `yaml.safe_load` fine. + """ + inner = { + "task": json.dumps(task), + "skypilot_config": json.dumps(config), + "request_name": request_name, + "request_options": {}, + "at_client_side": True, + "user": "", + } + return json.dumps(json.dumps(inner)).encode() + + +@skip_without_krater_process +def test_real_process_rejects_a_launch_with_no_workspace_selected() -> None: + response = httpx.post( + f"{_KRATER_BASE_URL}/internal/skypilot/policy", + params={"token": _POLICY_TOKEN}, + content=_wire_request(task={"resources": {"infra": "vast"}}, config={}), + headers={"content-type": "application/json"}, + timeout=10.0, + ) + + assert response.status_code == 400 + assert "No Ganymede project workspace selected" in response.text + + +@skip_without_krater_process +def test_real_process_rejects_the_default_workspace() -> None: + response = httpx.post( + f"{_KRATER_BASE_URL}/internal/skypilot/policy", + params={"token": _POLICY_TOKEN}, + content=_wire_request(task={"resources": {"infra": "vast"}}, config={"active_workspace": "default"}), + headers={"content-type": "application/json"}, + timeout=10.0, + ) + + assert response.status_code == 400 + assert "No Ganymede project workspace selected" in response.text + + +@skip_without_krater_process +def test_real_process_404s_on_a_wrong_token() -> None: + # The route's own fail-closed contract (docs/skypilot-integration.md section 2): a wrong/missing + # token gets a plain 404, not a 401/403, so the endpoint doesn't reveal its own existence to a + # scanner. `sky launch`'s `RestfulAdminPolicy` turns any non-400 error status into a client-side + # `RestfulPolicyError`, failing the launch closed -- confirmed manually against the real `sky` CLI + # in this contract run (see docs/dev/skypilot-contract.md); this test covers the HTTP contract that + # behavior depends on, without needing the `sky` CLI itself. + response = httpx.post( + f"{_KRATER_BASE_URL}/internal/skypilot/policy", + params={"token": "not-the-real-token"}, + content=_wire_request(task={"resources": {"infra": "vast"}}, config={"active_workspace": "default"}), + headers={"content-type": "application/json"}, + timeout=10.0, + ) + + assert response.status_code == 404 diff --git a/tests/skypilot/test_live_client.py b/tests/skypilot/test_live_client.py index c2c4f82..c47555c 100644 --- a/tests/skypilot/test_live_client.py +++ b/tests/skypilot/test_live_client.py @@ -63,6 +63,14 @@ def handler(self, request: httpx.Request) -> httpx.Response: if path == "/api/get": request_id = request.url.params["request_id"] source_path, poll_response = self._pending[request_id] + del source_path + # A request whose *handler* raised (rather than one that completed and rejected + # something) comes back as HTTP 500 with the same `status`/`error` dict nested under + # `detail`, not the flat 200 body every other FAILED/SUCCEEDED poll uses -- confirmed + # live against a real 0.13.0 server (`/jobs/queue`, `/jobs/cancel` with no jobs + # controller yet; see `docs/dev/skypilot-spike.md`). + if poll_response.pop("_wrap_in_5xx_detail", False): + return httpx.Response(500, json={"detail": {"request_id": request_id, **poll_response}}) return httpx.Response(200, json={"request_id": request_id, **poll_response}) if path not in _SCHEDULED_PATHS: @@ -123,6 +131,30 @@ def test_delete_workspace(fake_server: FakeSkyPilotServer, client: LiveSkyPilotC assert json.loads(request.content) == {"workspace_name": "ganymede-abc123"} +def test_delete_workspace_is_a_no_op_when_already_gone( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + # Confirmed live against a real 0.13.0 server: deleting a workspace that doesn't exist is a polled + # `FAILED` request, not a no-op -- the `SkyPilotClient` protocol's "safe to call on a workspace + # that's already gone" promise (which `sync_workspaces` relies on) has to be implemented here. + fake_server.poll_queue["/workspaces/delete"] = [ + {"status": "FAILED", "return_value": None, "error": "Workspace 'ganymede-x' does not exist."} + ] + + client.delete_workspace("ganymede-x") # must not raise + + +def test_delete_workspace_still_raises_for_a_different_failure( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/workspaces/delete"] = [ + {"status": "FAILED", "return_value": None, "error": "permission denied"} + ] + + with pytest.raises(SkyPilotRequestFailedError, match="permission denied"): + client.delete_workspace("ganymede-x") + + def test_list_workspaces_decodes_the_returned_mapping( fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient ) -> None: @@ -196,6 +228,9 @@ def test_list_clusters_filters_to_the_requested_workspace( request = next(r for r in fake_server.requests if r.url.path == "/status") body = json.loads(request.content) assert body["override_skypilot_config"]["active_workspace"] == "ganymede-abc123" + # `StatusBody.refresh` is a `StatusRefreshMode` enum ("NONE"/"AUTO"/"FORCE"), not a bool -- a real + # 0.13.0 server 422s on `false` (confirmed live; see docs/dev/skypilot-spike.md). + assert body["refresh"] == "NONE" def test_list_managed_jobs_filters_to_the_requested_workspace( @@ -286,3 +321,66 @@ def test_a_request_that_never_completes_times_out_as_unavailable( with pytest.raises(SkyPilotUnavailableError, match="did not complete"): client.create_workspace("ganymede-x", allowed_users=[]) + + +# -------------------------------------------------------------------------------------------------- +# "No jobs controller yet" (`sky.exceptions.ClusterNotUpError`) -- confirmed live against a real +# 0.13.0 server: `/jobs/queue` and `/jobs/cancel` both raise this when a workspace's managed-jobs +# controller cluster doesn't exist yet (i.e. no managed job has ever been launched there), which is +# the common case for essentially every Ganymede workspace. See docs/dev/skypilot-spike.md. +# -------------------------------------------------------------------------------------------------- + +_NO_JOBS_CONTROLLER_POLL_RESPONSE = { + "status": "FAILED", + "return_value": "null", + "error": json.dumps({"type": "ClusterNotUpError", "message": "No in-progress managed jobs."}), + "_wrap_in_5xx_detail": True, +} + + +def test_list_managed_jobs_treats_no_jobs_controller_as_empty( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/jobs/queue"] = [dict(_NO_JOBS_CONTROLLER_POLL_RESPONSE)] + + assert client.list_managed_jobs("ganymede-abc123") == [] + + +def test_cancel_managed_jobs_treats_no_jobs_controller_as_a_no_op( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/jobs/cancel"] = [dict(_NO_JOBS_CONTROLLER_POLL_RESPONSE)] + + client.cancel_managed_jobs("ganymede-abc123") # must not raise + + +def test_list_managed_jobs_still_raises_for_a_different_5xx_wrapped_failure( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/jobs/queue"] = [ + { + "status": "FAILED", + "return_value": "null", + "error": json.dumps({"type": "RuntimeError", "message": "something else broke"}), + "_wrap_in_5xx_detail": True, + } + ] + + with pytest.raises(SkyPilotRequestFailedError, match="something else broke"): + client.list_managed_jobs("ganymede-abc123") + + +def test_cancel_managed_jobs_still_raises_for_a_different_5xx_wrapped_failure( + fake_server: FakeSkyPilotServer, client: LiveSkyPilotClient +) -> None: + fake_server.poll_queue["/jobs/cancel"] = [ + { + "status": "FAILED", + "return_value": "null", + "error": json.dumps({"type": "RuntimeError", "message": "something else broke"}), + "_wrap_in_5xx_detail": True, + } + ] + + with pytest.raises(SkyPilotRequestFailedError, match="something else broke"): + client.cancel_managed_jobs("ganymede-abc123") From 5269a0def431963f6a7f414ee00d34ac0e36895f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:19:43 +0000 Subject: [PATCH 039/124] test: add a repeatable live Weave e2e check Proves KRATER_WEAVE_MODE=live against a real running Weave: OIDC discovery/JWKS, the full Authorization Code + PKCE + magic-link sign-in flow (member, reviewer, non-member), the directory API (get_user, get_user_by_slack_id, list_users_in_group), and that a group removed in Weave is reflected by a fresh LiveWeaveClient without a new sign-in. - scripts/dev/weave_e2e_provision.rb: idempotent bin/rails-runner script that provisions Weave's dev DB (four users, groups, a confidential OAuth app, a directory:read service key). - scripts/dev/weave_e2e_setup.py: runs that script against a sibling Weave checkout and writes gitignored fixtures (.weave_e2e_fixture.json, .env.weave-e2e) for the test and for running Krater manually. - tests/live/test_weave_live.py: pytest.mark.live suite driving the OAuth dance with httpx (no browser dependency); skips cleanly, per-test or module-wide, when its env vars/fixture aren't set. - docs/dev/weave-e2e.md: how to run it, and the two real-browser-only Weave bugs (Turbo + CSP form-action blocking the OAuth redirect) the manual Playwright pass behind this asset found -- fixed on the Weave branch. No Krater application code changed: the live pass against real Weave surfaced no Krater-side bugs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- .gitignore | 3 + docs/dev/weave-e2e.md | 132 +++++++++++ scripts/dev/weave_e2e_provision.rb | 117 ++++++++++ scripts/dev/weave_e2e_setup.py | 124 ++++++++++ tests/live/__init__.py | 0 tests/live/test_weave_live.py | 352 +++++++++++++++++++++++++++++ 6 files changed, 728 insertions(+) create mode 100644 docs/dev/weave-e2e.md create mode 100644 scripts/dev/weave_e2e_provision.rb create mode 100644 scripts/dev/weave_e2e_setup.py create mode 100644 tests/live/__init__.py create mode 100644 tests/live/test_weave_live.py diff --git a/.gitignore b/.gitignore index 92a7532..58d6bc6 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ venv/ .env .env.* !.env.example +# Live-Weave e2e fixture (scripts/dev/weave_e2e_setup.py) -- carries a live OAuth client secret +# and service API key, regenerated on every run. See docs/dev/weave-e2e.md. +.weave_e2e_fixture.json # Testing / tooling .pytest_cache/ diff --git a/docs/dev/weave-e2e.md b/docs/dev/weave-e2e.md new file mode 100644 index 0000000..ba7a2af --- /dev/null +++ b/docs/dev/weave-e2e.md @@ -0,0 +1,132 @@ +# Running the live Weave e2e check + +This proves Krater's `KRATER_WEAVE_MODE=live` path (OIDC sign-in, the directory API) against a **real** +running Weave, not the stub. It needs a Weave checkout (`patchworklabsorg/weave`) alongside this repo, +with Ruby/Rails runnable and its dev Postgres database migrated. + +See `docs/weave-integration.md` for the contract this exercises, and +`tests/live/test_weave_live.py` for the check itself. + +## 1. Provision Weave + +```bash +uv run python scripts/dev/weave_e2e_setup.py --weave-dir ../weave +``` + +This runs `scripts/dev/weave_e2e_provision.rb` inside the Weave checkout (via `bin/rails runner`) to +idempotently create, in Weave's own database: + +- four users: `e2e-member@ganymede.test` (`ganymede:member`), `e2e-reviewer@ganymede.test` + (`ganymede:member` + `ganymede:reviewer`, with a `slack_id` set), `e2e-admin@ganymede.test` + (`ganymede:member` + `ganymede:admin`), and `e2e-nonmember@ganymede.test` (no groups); +- a confidential OAuth application ("Krater (e2e)") with redirect URI + `http://localhost:8201/auth/callback` and scopes `openid profile email groups slack` (**recreated** + every run, since its secret is hashed at rest and only readable right after creation); +- a Service ("Krater (e2e)") with a `directory:read` Service::Key (likewise recreated every run). + +It then writes two **gitignored** files in this repo's root: + +- `.weave_e2e_fixture.json` -- everything `tests/live/test_weave_live.py` reads: the OAuth client + id/secret, the service API key, and each user's email/`sub`/`slack_id`. +- `.env.weave-e2e` -- the `KRATER_WEAVE_*` settings pointing at that application/key, ready to `source`. + +Re-run this script whenever you need fresh users/credentials, or after restarting from a clean Weave +database. It's idempotent for the users and groups; the OAuth app and service key are always rotated. + +Options: `--weave-dir` (default `../weave`), `--krater-base-url` (default `http://localhost:8201`), +`--ruby-shims` (default `/opt/rbenv/shims`, prepended to `PATH` so `bundle`/`rails` resolve). + +## 2. Run Weave + +```bash +cd ../weave +bin/rails db:migrate # if you haven't already +bin/rails tailwindcss:build +bin/rails server -p 3000 -b 0.0.0.0 +``` + +Weave needs an OIDC signing key and a Lockbox master key in **encrypted credentials** +(`config/credentials/development.yml.enc` + `.key`), which have no `ENV` fallback outside test. If your +checkout doesn't have real ones (e.g. a fresh container with no master key), generate dev-only ones with +`bin/rails runner` and `ActiveSupport::EncryptedConfiguration` -- see that file's own comments in +`config/initializers/doorkeeper_openid_connect.rb` and `config/initializers/lockbox.rb`. **Never commit +a generated key or the resulting `.yml.enc` you can't otherwise reproduce.** + +Sign-in is by magic link. Rather than running Weave's mailer/Solid Queue worker just to read an email +back out of `letter_opener`, both `scripts/dev/weave_e2e_setup.py`'s fixture and +`tests/live/test_weave_live.py` mint a fresh, valid `User::MagicLink` token directly (`bin/rails runner +'User::MagicLink.issue!(user).token'`) and drive Weave's real confirmation endpoint +(`GET`/`POST /auth/magic_link/:token`) with it -- functionally identical to clicking the emailed link, +without needing the queue worker up. This is why the live pytest suite needs `WEAVE_REPO_DIR` (a Weave +checkout with `bundle`/`rails` runnable) for its sign-in tests specifically. + +## 3. Run Krater in live mode + +Do **not** copy `.env.weave-e2e`'s contents into this repo's `.env` -- pydantic-settings auto-loads +`.env`, so a live `KRATER_WEAVE_MODE` there would silently leak into `uv run pytest` too. Source it +directly instead: + +```bash +set -a; source .env.weave-e2e; set +a +export KRATER_DATABASE_URL=postgresql+psycopg://root:root@localhost:5432/krater_e2e_weave +export KRATER_SECRET_KEY=some-dev-secret +uv run alembic upgrade head +uv run uvicorn krater.web.app:create_app --factory --port 8201 +``` + +At this point you can sign in at `http://localhost:8201/login` as any of the four fixture users (there's +no password -- mint a magic link as above, or add a `/auth/stub`-style shortcut of your own for manual +poking) and drive the same flow this doc's automated check does. + +## 4. Run the check + +```bash +export KRATER_LIVE_BASE_URL=http://localhost:8201 # default; only needed if you changed the port +export KRATER_LIVE_DATABASE_URL=$KRATER_DATABASE_URL # so the test can read the users table +export WEAVE_REPO_DIR=../weave # default; needed for the sign-in tests +export WEAVE_DATABASE_URL=postgresql://root:root@localhost/patchwork-idp_development # needed for the group-removal test +uv run pytest -m live +``` + +Each variable's test(s) skip individually, with a clear reason, if it's unset -- `WEAVE_E2E_FIXTURE` (or +the default `.weave_e2e_fixture.json`) is the only one the whole module needs; without it the module +skips entirely. + +Magic-link tokens are single-use and expire in 15 minutes, so re-run step 1 before re-running the sign-in +tests if you've already consumed that run's tokens. + +### What "authorization uses fresh data" means here + +`LiveWeaveClient` caches directory responses (`get_user`, `get_user_by_slack_id`) for ~60 seconds per +process, to absorb bursts of lookups (docs/weave-integration.md). That means: + +- **In a running Krater**, removing a group in Weave takes up to 60 seconds (the cache TTL) to affect + Krater's next `fresh_actor`-gated action -- or takes effect immediately if you restart Krater (a fresh + process has an empty cache). Both are legitimate ways to observe it; restarting is far faster than + waiting out real wall-clock time. +- **`test_group_removal_is_reflected_by_a_fresh_client`** takes the "fresh process" route without + restarting anything: it edits Weave's `group_memberships` table directly (needs `WEAVE_DATABASE_URL`) + and asserts against a brand-new `LiveWeaveClient` instance, which has never cached that user and so + reflects the removal immediately -- exactly what Krater's own cache would show once it expires or the + process restarts. + +## What this doesn't cover + +This file's automated check drives the OAuth/PKCE/magic-link dance with `httpx`, not a browser. The +manual pass this was built from used a real Chromium (Playwright) and caught two real, browser-only +Weave bugs that a plain HTTP client can't see: + +1. The magic-link confirmation form (`app/views/auth/magic_link_login.html.erb`) submitted over Turbo + (`fetch`). When confirming resumes an OAuth flow that's already authorized, that fetch gets redirected + straight through to the client's (cross-origin) `redirect_uri`, which Weave's CSP `connect-src` + correctly blocks for `fetch` -- silently breaking sign-in for any returning user in a real browser. + Fixed by adding `turbo: false`, matching the (already `turbo: false`) `/oauth/authorize` forms. +2. Weave's CSP `form-action 'self'` is enforced by Chromium against every redirect a form submission + leads to, not just its immediate target -- so submitting the OAuth consent ("Authorize") form, which + deliberately ends in a redirect to the client's (cross-origin) `redirect_uri`, was blocked outright. + Fixed by widening `form-action` to also allow `https:` always, and `http:` in local/dev environments + (matching the existing `Rails.env.local?` http/https split used for the OIDC issuer's protocol). + +Both are fixed on the Weave branch used for this check. If you're re-running this against a Weave +checkout that predates that fix, sign-in will appear to hang or silently fail in a real browser (it still +works via `httpx`, which doesn't enforce CSP) -- check the browser console for CSP violation messages. diff --git a/scripts/dev/weave_e2e_provision.rb b/scripts/dev/weave_e2e_provision.rb new file mode 100644 index 0000000..9e5515e --- /dev/null +++ b/scripts/dev/weave_e2e_provision.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true +# +# Provisions a local Weave with everything the live Krater<->Weave e2e check +# needs: four users covering member/reviewer/admin/non-member, a slack_id on +# the reviewer, a confidential OAuth application for Krater, and a service key +# with directory:read. Idempotent (find_or_create_by! / upsert-by-email), so +# it can be re-run against the same dev database. +# +# This file lives in the Krater repo (it's Krater's e2e fixture, not a Weave +# behavior change) but runs inside a Weave checkout: +# +# cd /path/to/weave +# bundle exec rails runner /path/to/krater/scripts/dev/weave_e2e_provision.rb +# +# See scripts/dev/weave_e2e_setup.py for a wrapper that also writes the +# KRATER_WEAVE_* env vars this produces into a fixture file, and +# docs/dev/weave-e2e.md for the full workflow. +# +# Prints one JSON object to stdout with everything the Python side needs +# (client_id/secret, service API key, user emails/subs). Nothing else should +# be printed to stdout -- logs go to stderr. + +require "json" + +def log(msg) = warn("[weave_e2e_provision] #{msg}") + +GROUPS = { + "ganymede:member" => "May submit Project Ganymede proposals in Krater", + "ganymede:reviewer" => "May review Project Ganymede proposals and completion requests", + "ganymede:admin" => "May override Project Ganymede reviews and budgets" +}.freeze + +GROUPS.each { |name, description| Group.find_or_create_by!(name: name) { |g| g.description = description } } +group = GROUPS.keys.index_with { |name| Group.find_by!(name: name) } + +def upsert_user(email:, first_name:, last_name:, group_names:, groups:, slack_id: nil) + user = User.find_or_initialize_by(email: email) + user.first_name = first_name + user.last_name = last_name + user.slack_id = slack_id if slack_id + if user.new_record? + user.password = User.generate_secure_password + user.email_confirmed_at = Time.current + end + user.save! + granter = User.superadmin.first || user + user.replace_groups!(group_names.map { |n| groups.fetch(n) }, granted_by: granter) + user +end + +member = upsert_user( + email: "e2e-member@ganymede.test", first_name: "Mira", last_name: "Member", + group_names: ["ganymede:member"], groups: group +) +reviewer = upsert_user( + email: "e2e-reviewer@ganymede.test", first_name: "Rae", last_name: "Reviewer", + group_names: ["ganymede:member", "ganymede:reviewer"], slack_id: "U0E2EREVIEWER", + groups: group +) +admin = upsert_user( + email: "e2e-admin@ganymede.test", first_name: "Avi", last_name: "Admin", + group_names: ["ganymede:member", "ganymede:admin"], groups: group +) +non_member = upsert_user( + email: "e2e-nonmember@ganymede.test", first_name: "Nia", last_name: "Nonmember", + group_names: [], groups: group +) +log "users ready: #{[member, reviewer, admin, non_member].map(&:email).join(', ')}" + +# -- OAuth application for Krater -------------------------------------------------------------- + +redirect_uri = ENV.fetch("KRATER_REDIRECT_URI", "http://localhost:8201/auth/callback") +app_name = "Krater (e2e)" +# Secrets are hashed at rest (hash_application_secrets), so the plaintext is +# only ever available on the record returned by .create!. Recreate each run +# rather than updating, so this script always knows the current secret. +Doorkeeper::Application.where(name: app_name).destroy_all +app = Doorkeeper::Application.create!( + name: app_name, + redirect_uri: redirect_uri, + confidential: true, + scopes: "openid profile email groups slack" +) +log "oauth application ready: uid=#{app.uid}" + +# -- Service + service key for the directory API ----------------------------------------------- + +creator = User.superadmin.first || admin +service = Service.find_or_create_by!(name: "Krater (e2e)") { |s| s.created_by = creator; s.status = "active" } + +# Service keys can't be re-read once created (only the digest is stored), so +# revoke any previous e2e keys and mint a fresh one every run. +service.keys.where(name: "krater-e2e").usable.each(&:revoke!) +plaintext_key = Service::Key.generate_api_key +service_key = service.keys.create!( + name: "krater-e2e", + scopes: ["directory:read"], + created_by: creator, + api_key: plaintext_key +) +log "service key ready: id=#{service_key.id}" + +result = { + issuer: (ENV["OIDC_ISSUER"].presence || "http://localhost:3000"), + oauth_client_id: app.uid, + oauth_client_secret: app.plaintext_secret || app.secret, + service_key: plaintext_key, + redirect_uri: redirect_uri, + users: { + member: { email: member.email, sub: member.p_id }, + reviewer: { email: reviewer.email, sub: reviewer.p_id, slack_id: reviewer.slack_id }, + admin: { email: admin.email, sub: admin.p_id }, + non_member: { email: non_member.email, sub: non_member.p_id } + } +} + +puts JSON.generate(result) diff --git a/scripts/dev/weave_e2e_setup.py b/scripts/dev/weave_e2e_setup.py new file mode 100644 index 0000000..08f484f --- /dev/null +++ b/scripts/dev/weave_e2e_setup.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Provisions a local Weave for the live Krater<->Weave e2e check, and writes the fixtures +`tests/live/test_weave_live.py` (and a manually-run Krater in live mode) need. + +See `docs/dev/weave-e2e.md` for the full workflow. In short: + + uv run python scripts/dev/weave_e2e_setup.py + # then, in the Weave checkout: bin/rails server -p 3000 + # then, in this checkout: KRATER_WEAVE_MODE=live ... uv run uvicorn ... + # then: uv run pytest -m live + +This script only touches Weave through `bin/rails runner` (never its database directly) and only +touches Krater by writing the two fixture files below -- both gitignored, both regenerated (with fresh +secrets) on every run. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +PROVISION_SCRIPT = Path(__file__).resolve().parent / "weave_e2e_provision.rb" + +#: Where the raw provisioning result (client id/secret, service key, user subs) is written. +#: Read by tests/live/test_weave_live.py. Never commit this -- it's a live credential. +FIXTURE_PATH = REPO_ROOT / ".weave_e2e_fixture.json" + +#: A ready-to-source env file with the KRATER_WEAVE_* settings that point Krater at that fixture. +ENV_PATH = REPO_ROOT / ".env.weave-e2e" + +DEFAULT_KRATER_REDIRECT_URI = "http://localhost:8201/auth/callback" + + +def run_provisioning(weave_dir: Path, *, redirect_uri: str, ruby_path: str | None) -> dict: + env = dict(os.environ) + if ruby_path: + env["PATH"] = f"{ruby_path}:{env.get('PATH', '')}" + env.setdefault("DATABASE_URL", "postgres://root:root@localhost") + env["RAILS_ENV"] = env.get("RAILS_ENV", "development") + env["KRATER_REDIRECT_URI"] = redirect_uri + + result = subprocess.run( + ["bundle", "exec", "rails", "runner", str(PROVISION_SCRIPT)], + cwd=weave_dir, + env=env, + capture_output=True, + text=True, + ) + if result.returncode != 0: + print(result.stderr, file=sys.stderr) + raise SystemExit(f"weave_e2e_provision.rb failed (exit {result.returncode}) -- see stderr above") + print(result.stderr, file=sys.stderr) # the ruby script's own log lines + + try: + # The ruby script prints exactly one JSON object; if anything else leaked onto + # stdout (a gem warning, a stray puts), the JSON is still the last line. + return json.loads(result.stdout.strip().splitlines()[-1]) + except (json.JSONDecodeError, IndexError) as exc: + raise SystemExit(f"could not parse provisioning output as JSON:\n{result.stdout}") from exc + + +def write_fixtures(data: dict, *, krater_base_url: str) -> None: + FIXTURE_PATH.write_text(json.dumps(data, indent=2) + "\n") + + env_lines = [ + "# Generated by scripts/dev/weave_e2e_setup.py -- do not commit, regenerated on every run.", + "KRATER_ENV=development", + f"KRATER_BASE_URL={krater_base_url}", + "KRATER_WEAVE_MODE=live", + f"KRATER_WEAVE_ISSUER={data['issuer']}", + f"KRATER_WEAVE_CLIENT_ID={data['oauth_client_id']}", + f"KRATER_WEAVE_CLIENT_SECRET={data['oauth_client_secret']}", + f"KRATER_WEAVE_API_BASE_URL={data['issuer']}", + f"KRATER_WEAVE_SERVICE_KEY={data['service_key']}", + "", + ] + ENV_PATH.write_text("\n".join(env_lines)) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + "--weave-dir", + default=os.environ.get("WEAVE_REPO_DIR", str(REPO_ROOT.parent / "weave")), + help="Path to a Weave checkout with its dev database migrated (default: %(default)s, or $WEAVE_REPO_DIR)", + ) + parser.add_argument( + "--krater-base-url", + default=os.environ.get("KRATER_BASE_URL", "http://localhost:8201"), + help="Where Krater will run (default: %(default)s)", + ) + parser.add_argument( + "--ruby-shims", + default=os.environ.get("RBENV_SHIMS_DIR", "/opt/rbenv/shims"), + help="Prepended to PATH so `bundle`/`rails` resolve via rbenv (default: %(default)s)", + ) + args = parser.parse_args() + + weave_dir = Path(args.weave_dir).resolve() + if not (weave_dir / "bin" / "rails").exists(): + raise SystemExit(f"{weave_dir} doesn't look like a Weave checkout (no bin/rails)") + + redirect_uri = f"{args.krater_base_url.rstrip('/')}/auth/callback" + data = run_provisioning(weave_dir, redirect_uri=redirect_uri, ruby_path=args.ruby_shims) + write_fixtures(data, krater_base_url=args.krater_base_url) + + print(f"\nWrote {FIXTURE_PATH} and {ENV_PATH}.") + print("Users provisioned (all share Weave's dev magic-link/password sign-in):") + for role, user in data["users"].items(): + print(f" {role:10s} {user['email']} ({user['sub']})") + print("\nNext steps -- see docs/dev/weave-e2e.md:") + print(f" 1. In {weave_dir}: bin/rails server -p 3000 -b 0.0.0.0") + print(f" 2. In {REPO_ROOT}: set -a; source .env.weave-e2e; set +a; uv run alembic upgrade head && \\") + print(" uv run uvicorn krater.web.app:create_app --factory --port 8201") + print(" 3. uv run pytest -m live") + + +if __name__ == "__main__": + main() diff --git a/tests/live/__init__.py b/tests/live/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/live/test_weave_live.py b/tests/live/test_weave_live.py new file mode 100644 index 0000000..83dd9ea --- /dev/null +++ b/tests/live/test_weave_live.py @@ -0,0 +1,352 @@ +"""Live Krater <-> Weave integration check. + +Unlike the rest of the suite, this drives a **real** Weave (OIDC discovery/JWKS, the magic-link sign-in +flow, the `/oauth/authorize` consent screen, and the `/api/v1/users` directory API) and a **real** +running Krater in `KRATER_WEAVE_MODE=live`, over plain HTTP -- no mocks. See `docs/dev/weave-e2e.md` for +how to bring both up and provision the fixture users this file reads. + +It does the same OAuth Authorization Code + PKCE round trip a browser does (confirm a magic link, submit +the consent form, land back on Krater's `/auth/callback`), but drives it directly with `httpx` rather +than a browser: same redirects, same cookies, same real signed id_token, without a browser dependency in +the Python test suite. The interactive proof with an actual browser (Playwright/Chromium) that this asset +is derived from is described in that doc, alongside the exact bugs it caught that `httpx` alone could not +(Turbo intercepting the sign-in form, and `form-action` CSP blocking the OAuth redirect) -- both are +Weave view/CSP issues invisible to a plain HTTP client, which is why that manual pass still matters even +with this file in place. + +Every test here is marked `live` (deselected by default -- see `pyproject.toml`) and skips, individually +or at module scope, with a clear reason when what it needs isn't configured. Nothing here is required for +`uv run pytest`. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +from collections.abc import Iterator +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import httpx +import psycopg +import pytest + +from krater.config import Settings +from krater.weave.live import LiveWeaveClient + +pytestmark = pytest.mark.live + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + + +def _env_path(name: str, default: Path | None = None) -> Path | None: + raw = os.environ.get(name) + if raw: + return Path(raw) + return default + + +FIXTURE_PATH = _env_path("WEAVE_E2E_FIXTURE", REPO_ROOT / ".weave_e2e_fixture.json") +KRATER_BASE_URL = os.environ.get("KRATER_LIVE_BASE_URL", "http://localhost:8201") +KRATER_DATABASE_URL = os.environ.get("KRATER_LIVE_DATABASE_URL") or os.environ.get("KRATER_DATABASE_URL") +WEAVE_REPO_DIR = _env_path("WEAVE_REPO_DIR", REPO_ROOT.parent / "weave") +WEAVE_DATABASE_URL = os.environ.get("WEAVE_DATABASE_URL") +RBENV_SHIMS_DIR = os.environ.get("RBENV_SHIMS_DIR", "/opt/rbenv/shims") + + +def _load_fixture() -> dict[str, Any] | None: + if FIXTURE_PATH is None or not FIXTURE_PATH.exists(): + return None + return json.loads(FIXTURE_PATH.read_text()) + + +_FIXTURE = _load_fixture() + +if _FIXTURE is None: + pytest.skip( + "no live-Weave fixture found (set WEAVE_E2E_FIXTURE, or run " + "`uv run python scripts/dev/weave_e2e_setup.py` first -- see docs/dev/weave-e2e.md)", + allow_module_level=True, + ) + + +@pytest.fixture(scope="module") +def fixture() -> dict[str, Any]: + assert _FIXTURE is not None # module-level skip above guarantees this + return _FIXTURE + + +@pytest.fixture(scope="module") +def weave_settings(fixture: dict[str, Any]) -> Settings: + return Settings( + weave_mode="live", + weave_issuer=fixture["issuer"], + weave_client_id=fixture["oauth_client_id"], + weave_client_secret=fixture["oauth_client_secret"], + weave_api_base_url=fixture["issuer"], + weave_service_key=fixture["service_key"], + ) + + +@pytest.fixture +def weave_client(weave_settings: Settings) -> Iterator[LiveWeaveClient]: + client = LiveWeaveClient(weave_settings) + yield client + + +def _require_krater_db() -> str: + if not KRATER_DATABASE_URL: + pytest.skip("KRATER_LIVE_DATABASE_URL (or KRATER_DATABASE_URL) is not set") + return KRATER_DATABASE_URL + + +def _krater_user_row(email: str) -> dict[str, Any] | None: + dsn = _require_krater_db().replace("postgresql+psycopg://", "postgresql://") + with psycopg.connect(dsn) as conn, conn.cursor() as cur: + cur.execute( + "select weave_sub, display_name, groups_cached, slack_user_id from users where email = %s", + (email,), + ) + row = cur.fetchone() + if row is None: + return None + return { + "weave_sub": row[0], + "display_name": row[1], + "groups_cached": row[2], + "slack_user_id": row[3], + } + + +# -------------------------------------------------------------------------------------------------- +# Directory API, via a real LiveWeaveClient against the real running Weave. +# -------------------------------------------------------------------------------------------------- + + +def test_discovery_and_jwks_are_reachable(fixture: dict[str, Any]) -> None: + resp = httpx.get(f"{fixture['issuer']}/.well-known/openid-configuration", timeout=10) + resp.raise_for_status() + doc = resp.json() + assert doc["issuer"] == fixture["issuer"] + assert "RS256" in doc["id_token_signing_alg_values_supported"] + + jwks = httpx.get(doc["jwks_uri"], timeout=10) + jwks.raise_for_status() + assert jwks.json()["keys"] + + +def test_get_user_matches_fixture_for_each_role(fixture: dict[str, Any], weave_client: LiveWeaveClient) -> None: + member = weave_client.get_user(fixture["users"]["member"]["sub"]) + assert member is not None + assert member.active + assert member.groups == frozenset({"ganymede:member"}) + + reviewer = weave_client.get_user(fixture["users"]["reviewer"]["sub"]) + assert reviewer is not None + assert "ganymede:reviewer" in reviewer.groups + assert reviewer.slack_id == fixture["users"]["reviewer"]["slack_id"] + + admin = weave_client.get_user(fixture["users"]["admin"]["sub"]) + assert admin is not None + assert "ganymede:admin" in admin.groups + + non_member = weave_client.get_user(fixture["users"]["non_member"]["sub"]) + assert non_member is not None + assert "ganymede:member" not in non_member.groups + + +def test_get_user_unknown_sub_returns_none(weave_client: LiveWeaveClient) -> None: + assert weave_client.get_user("PWL0000000000000-does-not-exist") is None + + +def test_list_users_in_group_reviewer(fixture: dict[str, Any], weave_client: LiveWeaveClient) -> None: + reviewers = weave_client.list_users_in_group("ganymede:reviewer") + subs = {u.sub for u in reviewers} + assert fixture["users"]["reviewer"]["sub"] in subs + assert fixture["users"]["member"]["sub"] not in subs + + +def test_get_user_by_slack_id(fixture: dict[str, Any], weave_client: LiveWeaveClient) -> None: + user = weave_client.get_user_by_slack_id(fixture["users"]["reviewer"]["slack_id"]) + assert user is not None + assert user.sub == fixture["users"]["reviewer"]["sub"] + + +# -------------------------------------------------------------------------------------------------- +# The full OAuth Authorization Code + PKCE round trip, against real running Krater + Weave. +# -------------------------------------------------------------------------------------------------- + + +def _issue_magic_link(email: str) -> str: + if WEAVE_REPO_DIR is None or not (WEAVE_REPO_DIR / "bin" / "rails").exists(): + pytest.skip(f"WEAVE_REPO_DIR ({WEAVE_REPO_DIR}) is not a Weave checkout -- can't mint a magic link") + + env = dict(os.environ) + env["PATH"] = f"{RBENV_SHIMS_DIR}:{env.get('PATH', '')}" + env.setdefault("DATABASE_URL", "postgres://root:root@localhost") + env["RAILS_ENV"] = env.get("RAILS_ENV", "development") + ruby = f'u = User.find_by!(email: {email!r}); puts User::MagicLink.issue!(u, requested_ip: "127.0.0.1").token' + result = subprocess.run( + ["bundle", "exec", "rails", "runner", ruby], + cwd=WEAVE_REPO_DIR, + env=env, + capture_output=True, + text=True, + timeout=60, + ) + if result.returncode != 0: + pytest.fail(f"could not mint a magic link for {email}:\n{result.stderr}") + return result.stdout.strip().splitlines()[-1] + + +_AUTHENTICITY_TOKEN_RE = re.compile(r'name="authenticity_token"\s+value="([^"]*)"') +_HIDDEN_FIELD_RE = re.compile(r']*type="hidden"[^>]*name="([^"]+)"[^>]*value="([^"]*)"') + + +def _authorize_form_fields(html: str) -> dict[str, str]: + """The Doorkeeper consent screen's *first* form (POST -- "Authorize"; the second is the DELETE + "Deny" form). Both share hidden field names, so this has to stop at the first ``.""" + form_html = html.split("", 1)[0] + fields = dict(_HIDDEN_FIELD_RE.findall(form_html)) + token_match = _AUTHENTICITY_TOKEN_RE.search(form_html) + if token_match: + fields["authenticity_token"] = token_match.group(1) + fields["commit"] = "Authorize" + return fields + + +@dataclass +class SignInResult: + final_response: httpx.Response + + +def _sign_in(client: httpx.Client, fixture: dict[str, Any], email: str) -> SignInResult: + """Drives the real Authorization Code + PKCE flow: Krater `/login` -> Weave's magic-link + confirmation (using a freshly minted token in place of "the user clicked the emailed link") -> + the OAuth consent screen (submitted for real, when Weave shows one) -> back to Krater's + `/auth/callback`. Returns the final response Krater gave (a redirect on success, a 403 page for + a non-member). + """ + weave_base = fixture["issuer"] + + resp = client.get(f"{KRATER_BASE_URL}/login") + assert resp.status_code == 302, f"Krater /login didn't redirect into Weave: {resp.status_code}" + authorize_url = resp.headers["location"] + assert authorize_url.startswith(weave_base), authorize_url + + # This GET is what puts client_id / the return-to authorize URL into Weave's own session + # (AuthController#oauth_login) -- without it, confirming the magic link lands on Weave's + # dashboard instead of resuming the OAuth flow. + resp = client.get(authorize_url) + assert resp.status_code == 302 + assert resp.headers["location"].startswith(f"{weave_base}/oauth/login") + + token = _issue_magic_link(email) + resp = client.get(f"{weave_base}/auth/magic_link/{token}") + assert resp.status_code == 200, "magic link wasn't live (already used, expired, or unknown)" + csrf = _AUTHENTICITY_TOKEN_RE.search(resp.text) + assert csrf, "no CSRF token on the magic-link confirmation page" + + resp = client.post( + f"{weave_base}/auth/magic_link/{token}", + data={"authenticity_token": csrf.group(1)}, + ) + assert resp.status_code == 302, f"magic-link confirmation didn't redirect: {resp.status_code}" + next_url = resp.headers["location"] + + if next_url.startswith(f"{weave_base}/oauth/authorize"): + resp = client.get(next_url) + if resp.status_code == 200: + # First-time consent: submit the real "Authorize" form. + fields = _authorize_form_fields(resp.text) + resp = client.post(f"{weave_base}/oauth/authorize", data=fields) + assert resp.status_code == 302, f"OAuth authorize didn't redirect: {resp.status_code} {resp.text[:300]}" + next_url = resp.headers["location"] + + assert next_url.startswith(f"{KRATER_BASE_URL}/auth/callback"), next_url + final = client.get(next_url) + return SignInResult(final_response=final) + + +@pytest.fixture +def http_client() -> Iterator[httpx.Client]: + with httpx.Client(follow_redirects=False, timeout=15) as client: + yield client + + +def test_full_oidc_signin_persists_member(fixture: dict[str, Any], http_client: httpx.Client) -> None: + _require_krater_db() + email = fixture["users"]["member"]["email"] + result = _sign_in(http_client, fixture, email) + assert result.final_response.status_code == 302, "sign-in should end with Krater redirecting home" + + row = _krater_user_row(email) + assert row is not None, "Krater never created/updated the user row" + assert row["weave_sub"] == fixture["users"]["member"]["sub"] + assert "ganymede:member" in row["groups_cached"] + + +def test_full_oidc_signin_persists_reviewer_slack_id(fixture: dict[str, Any], http_client: httpx.Client) -> None: + _require_krater_db() + email = fixture["users"]["reviewer"]["email"] + result = _sign_in(http_client, fixture, email) + assert result.final_response.status_code == 302 + + row = _krater_user_row(email) + assert row is not None + assert row["slack_user_id"] == fixture["users"]["reviewer"]["slack_id"] + assert "ganymede:reviewer" in row["groups_cached"] + + +def test_oidc_signin_rejects_non_member(fixture: dict[str, Any], http_client: httpx.Client) -> None: + email = fixture["users"]["non_member"]["email"] + result = _sign_in(http_client, fixture, email) + assert result.final_response.status_code == 403 + + +# -------------------------------------------------------------------------------------------------- +# Group removal takes effect without a new sign-in (docs/weave-integration.md: "Authorization uses +# fresh data"). LiveWeaveClient caches directory responses for ~60s per docs/dev/weave-e2e.md to +# absorb bursts of lookups; rather than sleeping past that TTL, this constructs its own fresh +# `LiveWeaveClient` (an empty cache, same as Krater's process would have after the TTL, or after a +# restart) and asserts *that* reflects the change immediately, straight from Weave's Postgres. +# -------------------------------------------------------------------------------------------------- + + +def test_group_removal_is_reflected_by_a_fresh_client(fixture: dict[str, Any], weave_settings: Settings) -> None: + if not WEAVE_DATABASE_URL: + pytest.skip("WEAVE_DATABASE_URL is not set -- can't manipulate Weave's group_memberships directly") + + reviewer_sub = fixture["users"]["reviewer"]["sub"] + with psycopg.connect(WEAVE_DATABASE_URL) as conn, conn.cursor() as cur: + cur.execute("select id from users where p_id = %s", (reviewer_sub,)) + (user_id,) = cur.fetchone() + cur.execute("select id from groups where name = 'ganymede:reviewer'") + (group_id,) = cur.fetchone() + + cur.execute( + "delete from group_memberships where user_id = %s and group_id = %s returning id", + (user_id, group_id), + ) + deleted = cur.fetchall() + conn.commit() + + try: + fresh_client = LiveWeaveClient(weave_settings) # empty directory cache + user = fresh_client.get_user(reviewer_sub) + assert user is not None + assert "ganymede:reviewer" not in user.groups, "fresh lookup still shows the removed group" + finally: + # Restore it (granted_by NULL is fine for this fixture) so re-running the suite, or the + # interactive proof, without re-provisioning still has a working reviewer. + if deleted: + cur.execute( + "insert into group_memberships (user_id, group_id, created_at, updated_at) " + "values (%s, %s, now(), now()) on conflict do nothing", + (user_id, group_id), + ) + conn.commit() From f6c23cf9fb5dbab6cfc8ae5ee71a306203fc7558 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:22:50 +0000 Subject: [PATCH 040/124] docs: describe the narrowed Weave form-action fix Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docs/dev/weave-e2e.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/dev/weave-e2e.md b/docs/dev/weave-e2e.md index ba7a2af..37c8252 100644 --- a/docs/dev/weave-e2e.md +++ b/docs/dev/weave-e2e.md @@ -122,10 +122,12 @@ Weave bugs that a plain HTTP client can't see: correctly blocks for `fetch` -- silently breaking sign-in for any returning user in a real browser. Fixed by adding `turbo: false`, matching the (already `turbo: false`) `/oauth/authorize` forms. 2. Weave's CSP `form-action 'self'` is enforced by Chromium against every redirect a form submission - leads to, not just its immediate target -- so submitting the OAuth consent ("Authorize") form, which - deliberately ends in a redirect to the client's (cross-origin) `redirect_uri`, was blocked outright. - Fixed by widening `form-action` to also allow `https:` always, and `http:` in local/dev environments - (matching the existing `Rails.env.local?` http/https split used for the OIDC issuer's protocol). + leads to, not just its immediate target. Signing in while an OAuth authorization is pending ends in a + redirect to the client's (cross-origin) `redirect_uri` once consent already exists, so the sign-in forms + were blocked. Fixed narrowly: while an authorization is pending, Weave's sign-in pages add **only that + client's registered redirect origins** to `form-action` (the consent screen already did this). An + earlier fix that allowed all `https:` origins on every page was replaced, since it weakened the login + form's protection. Both are fixed on the Weave branch used for this check. If you're re-running this against a Weave checkout that predates that fix, sign-in will appear to hang or silently fail in a real browser (it still From 299a7557e4aaa8422f00dafcecd1b02ab8d90152 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 22:48:52 +0000 Subject: [PATCH 041/124] Add screenshot uploads for completed projects Adds krater/storage/ (an ObjectStore protocol with S3ObjectStore on boto3, an in-memory FakeObjectStore, and a fake|live factory mirroring krater/skypilot/'s structure), plus krater/services/screenshots.py enforcing the upload rules from docs/SPEC.md: submitter-only, completion-draft-only, at most 6 screenshots, PNG/JPEG/WebP under 5MB, server-generated keys, and a confirm step that re-validates against storage rather than trusting the presigned request. Wires up presign/confirm/delete routes and a small vanilla-JS upload widget on the completion draft's edit page (with a no-JS fallback), and shows screenshots via short-lived presigned GET URLs on the project detail and public gallery pages. Also adds docker-compose's storage-init bucket CORS setup and docs/dev/storage.md, and a tests/live/test_storage_live.py suite that starts a real SeaweedFS server to verify content-length-range enforcement, exact Content-Type policy enforcement, and bucket CORS -- all checked against the real thing rather than assumed from the S3 spec. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015etbcYs4SKqPxsnY7Pr2p6 --- docker-compose.yml | 13 +- docs/dev/staging.md | 3 +- docs/dev/storage.md | 89 +++++++ krater/config.py | 5 + krater/services/screenshots.py | 182 ++++++++++++++ krater/storage/__init__.py | 41 ++++ krater/storage/client.py | 48 ++++ krater/storage/errors.py | 20 ++ krater/storage/fake.py | 47 ++++ krater/storage/live.py | 106 ++++++++ krater/storage/types.py | 30 +++ krater/web/routers/gallery.py | 24 +- krater/web/routers/projects.py | 102 +++++++- krater/web/static/css/main.css | 58 +++++ krater/web/static/js/screenshot-upload.js | 144 +++++++++++ krater/web/templates/base.html | 1 + krater/web/templates/gallery/detail.html | 8 + krater/web/templates/gallery/index.html | 8 + krater/web/templates/projects/detail.html | 9 + krater/web/templates/projects/edit.html | 61 ++++- pyproject.toml | 1 + tests/live/test_storage_live.py | 281 ++++++++++++++++++++++ tests/services/test_screenshots.py | 266 ++++++++++++++++++++ tests/storage/__init__.py | 0 tests/storage/test_s3_object_store.py | 154 ++++++++++++ tests/web/test_gallery.py | 32 ++- tests/web/test_screenshots.py | 178 ++++++++++++++ tests/web/test_workflow_e2e.py | 2 +- uv.lock | 60 +++++ 29 files changed, 1950 insertions(+), 23 deletions(-) create mode 100644 docs/dev/storage.md create mode 100644 krater/services/screenshots.py create mode 100644 krater/storage/__init__.py create mode 100644 krater/storage/client.py create mode 100644 krater/storage/errors.py create mode 100644 krater/storage/fake.py create mode 100644 krater/storage/live.py create mode 100644 krater/storage/types.py create mode 100644 krater/web/static/js/screenshot-upload.js create mode 100644 tests/live/test_storage_live.py create mode 100644 tests/services/test_screenshots.py create mode 100644 tests/storage/__init__.py create mode 100644 tests/storage/test_s3_object_store.py create mode 100644 tests/web/test_screenshots.py diff --git a/docker-compose.yml b/docker-compose.yml index 4fd2bba..2e20ee5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,7 +57,10 @@ services: volumes: - krater_storage_data:/data - # One-shot: create the bucket. Safe to re-run. + # One-shot: create the bucket and set its CORS policy. Safe to re-run. The CORS rule is what lets a + # browser upload straight to `storage` (and load screenshot thumbnails from it) via the presigned + # URLs `krater.storage` hands out -- see docs/dev/storage.md, which also documents that this is + # verified against real SeaweedFS, not assumed from the S3 spec. storage-init: image: amazon/aws-cli:${AWS_CLI_VERSION:-latest} environment: @@ -69,10 +72,12 @@ services: command: - >- for i in $$(seq 1 30); do - aws --endpoint-url http://storage:8333 s3api head-bucket --bucket "$$BUCKET" 2>/dev/null && exit 0; - aws --endpoint-url http://storage:8333 s3 mb "s3://$$BUCKET" 2>/dev/null && exit 0; + aws --endpoint-url http://storage:8333 s3api head-bucket --bucket "$$BUCKET" 2>/dev/null && break; + aws --endpoint-url http://storage:8333 s3 mb "s3://$$BUCKET" 2>/dev/null && break; sleep 2; - done; echo "storage not reachable" >&2; exit 1 + done; + aws --endpoint-url http://storage:8333 s3api head-bucket --bucket "$$BUCKET" 2>/dev/null || { echo "storage not reachable" >&2; exit 1; }; + aws --endpoint-url http://storage:8333 s3api put-bucket-cors --bucket "$$BUCKET" --cors-configuration '{"CORSRules":[{"AllowedOrigins":["*"],"AllowedMethods":["GET","PUT","POST"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":3000}]}' depends_on: - storage diff --git a/docs/dev/staging.md b/docs/dev/staging.md index 62bcc44..cf3a18f 100644 --- a/docs/dev/staging.md +++ b/docs/dev/staging.md @@ -142,7 +142,8 @@ docker compose --profile skypilot up --build ``` This starts everything, including `skypilot` and `auth-proxy` (the `skypilot` profile), on top of the usual -`db`/`migrate`/`portal`/`worker`/`storage` services. +`db`/`migrate`/`portal`/`worker`/`storage` services. See `docs/dev/storage.md` for `storage`'s own env vars, +its CORS setup, and what's been verified against it. ### Bootstrap the SkyPilot service-account token diff --git a/docs/dev/storage.md b/docs/dev/storage.md new file mode 100644 index 0000000..9340e15 --- /dev/null +++ b/docs/dev/storage.md @@ -0,0 +1,89 @@ +# Object storage (screenshots) + +Covers the SeaweedFS container `docker-compose.yml` runs as `storage`, the env vars that configure +`krater.storage`, which endpoint is reachable from where, and the bucket CORS setup that lets a +browser upload straight to it. Read `docs/SPEC.md` ("Completion flow & public gallery") for the +feature this backs, and `krater/storage/client.py` for the `ObjectStore` interface itself. + +## The `storage` container + +`docker-compose.yml`'s `storage` service is `chrislusf/seaweedfs`, run as `weed server -s3 ...`: a +single-node master+volume+filer+S3-gateway process, with the S3 gateway on port 8333 (`${S3_PORT}`). +It's a **temporary** choice (see `docs/SPEC.md`'s "Open questions" #1) -- MinIO was ruled out because +its community edition stopped publishing images in 2025; a long-term provider is still to be picked. + +Credentials come from `KRATER_S3_ACCESS_KEY_ID`/`KRATER_S3_SECRET_ACCESS_KEY` (defaults: `krater` / +`krater-dev-secret` for local dev), written into a JSON identity file at container start the same way +the `skypilot` service generates its config -- so no secret is ever committed. + +`storage-init` is a one-shot `amazon/aws-cli` container that creates the bucket (retrying until +`storage` answers) and sets its CORS policy. Both are idempotent, safe to re-run on every +`docker compose up`. + +## Env vars + +| Var | Meaning | +| --- | --- | +| `KRATER_S3_MODE` | `fake` (default; in-memory, no network) or `live`. Like `KRATER_SKYPILOT_MODE`, the app refuses to start with `fake` when `KRATER_ENV=production`. | +| `KRATER_S3_ENDPOINT_URL` | The **internal** endpoint Krater's own process uses for server-side calls (`head`, `delete`): `http://storage:8333` inside Docker Compose, `http://localhost:8333` running the portal directly against `docker compose up storage`. | +| `KRATER_S3_PUBLIC_ENDPOINT_URL` | The endpoint a **browser** can reach, embedded in every presigned URL handed to it (direct uploads, thumbnails). In dev this is the same host as above (`http://localhost:8333`, since the browser and the portal are both on your machine); in a real deployment it has to be whatever public hostname/port the `storage` container is actually exposed on -- these two vars are *not* interchangeable, and a presigned URL built from the wrong one embeds a host the browser can't reach (see `krater/storage/live.py`'s docstring). | +| `KRATER_S3_BUCKET` | Default `krater-screenshots`. | +| `KRATER_S3_REGION` | Default `us-east-1` -- SeaweedFS doesn't care, but SigV4 needs some value. | +| `KRATER_S3_ACCESS_KEY_ID` / `KRATER_S3_SECRET_ACCESS_KEY` | Match whatever `storage`'s generated identity file was given. | + +## CORS: verified, not assumed + +A presigned upload and a thumbnail `` are both cross-origin browser requests once +`KRATER_S3_PUBLIC_ENDPOINT_URL` differs from the portal's own origin (any real deployment), so the +bucket needs a CORS policy. `storage-init` sets one with: + +```bash +aws --endpoint-url http://storage:8333 s3api put-bucket-cors --bucket "$BUCKET" --cors-configuration \ + '{"CORSRules":[{"AllowedOrigins":["*"],"AllowedMethods":["GET","PUT","POST"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":3000}]}' +``` + +**Checked against a real, local SeaweedFS server** (not assumed from the S3 API spec, since SeaweedFS +doesn't implement every S3 bucket-config API): `s3api put-bucket-cors` / `get-bucket-cors` round-trip +correctly, and — the part that actually matters — a live `OPTIONS` preflight and a real presigned-POST +upload both come back with the right `Access-Control-Allow-*` headers once the rule is set. This was +verified with the equivalent `boto3` calls (`put_bucket_cors`/`get_bucket_cors`, plus real HTTP +`OPTIONS`/`POST` requests) rather than the literal `aws` binary, which isn't installed in every +environment this was checked from -- `aws s3api ...` calls the identical REST API, so the result +carries over. See `tests/live/test_storage_live.py`, which pins this down as an automated (marked +`live`) regression test: `test_bucket_cors_preflight_allows_a_browser_origin` and the CORS setup in the +`bucket` fixture. + +If SeaweedFS is ever swapped for something else, re-verify this rather than assuming it: not every +S3-compatible server implements bucket-level CORS config at all (some only support a server-wide +`-allowedOrigins` flag, which SeaweedFS also has but which is coarser than a bucket policy and isn't +what this app relies on). + +## What else was verified against real SeaweedFS + +Also in `tests/live/test_storage_live.py`, and worth knowing before trusting `krater/storage/`'s design +elsewhere: + +- **`content-length-range` in a presigned POST policy is enforced.** An oversized upload gets a real + `400 EntityTooLarge` from SeaweedFS itself -- the server never even reaches Krater's process, let + alone its confirm step. +- **The policy's exact `Content-Type` match is enforced too**, but it's easy to misread what that + means: an S3 (and SeaweedFS) presigned POST's stored `Content-Type` metadata comes from the **posted + form field**, not from the uploaded file part's own multipart headers. Changing that form field away + from what was signed breaks the signature (`403 AccessDenied: Policy Condition failed`) -- but a + client is always free to send *whatever bytes* it wants under a form field it's still allowed to sign + correctly (e.g. non-image bytes labeled `image/png`). That gap is exactly why + `krater.services.screenshots.confirm_screenshot` re-checks the *stored* object's reported content type + via `ObjectStore.head` rather than trusting that the presigned request succeeded, and exactly why that + module's docstring is explicit about not decoding the bytes themselves either. + +## Running the live test locally + +`tests/live/test_storage_live.py` starts a real, throwaway `weed server -s3 ...` itself (same flags as +the `storage` service above, against a temp data dir and a random free port) and tears it down after -- +no Docker required. It's marked `@pytest.mark.live` (deselected by default; see `pyproject.toml`), and +skips cleanly without a `weed` binary configured: + +```bash +export STORAGE_LIVE_WEED_BIN=/path/to/weed # a SeaweedFS binary (e.g. from https://github.com/seaweedfs/seaweedfs/releases) +uv run pytest -m live tests/live/test_storage_live.py +``` diff --git a/krater/config.py b/krater/config.py index cae6f0b..bf2d424 100644 --- a/krater/config.py +++ b/krater/config.py @@ -45,6 +45,9 @@ class Settings(BaseSettings): skypilot_max_hourly_cost_cents: int = 500 # per-instance cap forced onto every launch # S3-compatible object storage (gallery screenshots). docker-compose runs a temporary SeaweedFS as `storage`. + # `fake` uses an in-memory store for dev and tests; `live` talks to a real S3-compatible bucket. See + # docs/dev/storage.md. + s3_mode: Literal["fake", "live"] = "fake" s3_endpoint_url: str = "" s3_public_endpoint_url: str = "" s3_region: str = "us-east-1" @@ -59,6 +62,8 @@ def _validate_production_safety(self) -> Settings: raise ValueError("KRATER_WEAVE_MODE cannot be 'stub' when KRATER_ENV=production") if self.skypilot_mode == "fake": raise ValueError("KRATER_SKYPILOT_MODE cannot be 'fake' when KRATER_ENV=production") + if self.s3_mode == "fake": + raise ValueError("KRATER_S3_MODE cannot be 'fake' when KRATER_ENV=production") if self.skypilot_mode == "live" and len(self.skypilot_policy_token) < 32: raise ValueError("KRATER_SKYPILOT_POLICY_TOKEN must be at least 32 characters in production") if self.secret_key == DEFAULT_SECRET_KEY: diff --git a/krater/services/screenshots.py b/krater/services/screenshots.py new file mode 100644 index 0000000..d5842a9 --- /dev/null +++ b/krater/services/screenshots.py @@ -0,0 +1,182 @@ +"""Screenshot uploads on a completion draft: presign, confirm, remove. + +See `docs/SPEC.md` ("Completion flow & public gallery": "Uploads use presigned URLs, and only objects +from approved completion revisions are served publicly"). The three-step flow this module implements: + +1. `presign_screenshot` -- the submitter asks for somewhere to upload. This picks the key (never the + client) and returns a presigned `POST` policy that pins the object's size and content type into the + signature, so storage itself rejects an oversized or mislabeled upload. +2. The browser uploads directly to storage, bypassing Krater's own process entirely. +3. `confirm_screenshot` -- the submitter tells Krater the upload finished. This is the only step that + actually changes `ProjectRevision.screenshot_keys`, and it re-checks the object via `ObjectStore.head` + before trusting it: the presigned policy is *storage's* enforcement, not Krater's, so a client that + skips step 1 (or a storage backend that doesn't enforce every policy condition -- see + `docs/dev/storage.md`) must not be able to sneak an oversized or wrong-type object into the gallery. + +No image is ever decoded (no Pillow, no magic-byte sniffing beyond what `head` reports). That's an +acceptable gap for now because: uploads require a signed-in member on their own completion draft, not an +anonymous/public endpoint; a mislabeled object is only ever rendered back as an ``, which just +fails to display rather than executing anything; and decoding untrusted image bytes server-side is its +own attack surface (image-parser CVEs) not worth adding for this. If screenshots ever accept broader or +more adversarial input, add a decode-and-reencode step before confirming. +""" + +from __future__ import annotations + +import contextlib +import uuid +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from krater.models import Project, ProjectRevision, RevisionKind +from krater.services.actor import Actor +from krater.services.errors import InvalidState, NotAllowed, ValidationFailed +from krater.storage.client import ObjectStore +from krater.storage.errors import StorageError +from krater.storage.types import PresignedPost + +#: PNG / JPEG / WebP only, per docs/SPEC.md's screenshot upload scope. Maps content type -> the +#: extension used in the generated key (cosmetic only; the type actually stored comes from `head`). +ALLOWED_CONTENT_TYPES: dict[str, str] = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", +} + +MAX_SCREENSHOTS = 6 +MAX_SCREENSHOT_BYTES = 5 * 1024 * 1024 + + +@dataclass(frozen=True) +class ScreenshotUpload: + """What `presign_screenshot` hands back: the server-chosen key, and the presigned POST to upload to.""" + + key: str + post: PresignedPost + + +def _require_completion_draft(actor: Actor, project: Project) -> ProjectRevision: + """The project's current, unsubmitted `completion` revision, or raise. Submitter only. + + Shared by all three operations: screenshots only ever belong to the *draft in progress*, never a + submitted/decided revision (which is immutable review history) or another kind of revision. + """ + if actor.user.id != project.submitter_id: + raise NotAllowed("Only the submitter may manage this project's screenshots.") + + draft = project.current_revision + if draft is None or draft.submitted_at is not None or draft.kind is not RevisionKind.COMPLETION: + raise InvalidState("Screenshots can only be added to a completion draft.") + return draft + + +def _key_prefix(project: Project, draft: ProjectRevision) -> str: + return f"projects/{project.id}/{draft.id}/" + + +def presign_screenshot( + session: Session, + actor: Actor, + *, + project: Project, + content_type: str, + store: ObjectStore, +) -> ScreenshotUpload: + """Issue a presigned upload for a new screenshot on the project's completion draft. + + Validates `content_type` and the current screenshot count (`ValidationFailed` otherwise -- the same + limit is re-checked in `confirm_screenshot`, since two presigns can be issued before either is + confirmed). The key is always server-generated + (`projects///.`), never taken from the caller. + """ + draft = _require_completion_draft(actor, project) + + if content_type not in ALLOWED_CONTENT_TYPES: + raise ValidationFailed({"screenshot": "Only PNG, JPEG or WebP screenshots are allowed."}) + if len(draft.screenshot_keys) >= MAX_SCREENSHOTS: + raise ValidationFailed({"screenshot": f"At most {MAX_SCREENSHOTS} screenshots are allowed."}) + + extension = ALLOWED_CONTENT_TYPES[content_type] + key = f"{_key_prefix(project, draft)}{uuid.uuid4()}.{extension}" + post = store.presign_upload(key, content_type=content_type, max_bytes=MAX_SCREENSHOT_BYTES) + return ScreenshotUpload(key=key, post=post) + + +def confirm_screenshot( + session: Session, + actor: Actor, + *, + project: Project, + key: str, + store: ObjectStore, +) -> ProjectRevision: + """Confirm a screenshot finished uploading to `key`, and append it to the draft. + + Re-validates everything server-side rather than trusting the browser's earlier presign request: + `key` must belong to this project's current draft (never an arbitrary, client-chosen key), the + screenshot limit must still hold, and the object must actually exist in storage with an allowed + content type and size at or under `MAX_SCREENSHOT_BYTES` (`ValidationFailed` if `head` finds + anything else, including nothing at all). A rejected object is best-effort deleted from storage so + it doesn't linger. + """ + draft = _require_completion_draft(actor, project) + + if not key.startswith(_key_prefix(project, draft)): + raise NotAllowed("That upload doesn't belong to this project's draft.") + if key in draft.screenshot_keys: + return draft # already confirmed; idempotent + if len(draft.screenshot_keys) >= MAX_SCREENSHOTS: + raise ValidationFailed({"screenshot": f"At most {MAX_SCREENSHOTS} screenshots are allowed."}) + + meta = store.head(key) + if meta is None: + raise ValidationFailed({"screenshot": "Upload not found. Try again."}) + if meta.content_type not in ALLOWED_CONTENT_TYPES or meta.size_bytes > MAX_SCREENSHOT_BYTES: + # Best-effort: the object is orphaned but harmless, and never gets linked to the draft either way. + with contextlib.suppress(StorageError): + store.delete(key) + raise ValidationFailed({"screenshot": "That upload isn't a valid PNG, JPEG or WebP under 5 MB."}) + + draft.screenshot_keys = [*draft.screenshot_keys, key] + session.flush() + return draft + + +def remove_screenshot( + session: Session, + actor: Actor, + *, + project: Project, + key: str, + store: ObjectStore, +) -> ProjectRevision: + """Remove `key` from the draft's screenshots, and best-effort delete it from storage. + + Raises `InvalidState` if `key` isn't currently one of the draft's screenshots (covers both a typo + and a double-submit of the remove form). + """ + draft = _require_completion_draft(actor, project) + + if key not in draft.screenshot_keys: + raise InvalidState("That screenshot isn't on this draft.") + + draft.screenshot_keys = [existing for existing in draft.screenshot_keys if existing != key] + session.flush() + + # Best-effort, per docs/SPEC.md/module docstring: the draft's own list is the source of truth. + with contextlib.suppress(StorageError): + store.delete(key) + + return draft + + +__all__ = [ + "ALLOWED_CONTENT_TYPES", + "MAX_SCREENSHOTS", + "MAX_SCREENSHOT_BYTES", + "ScreenshotUpload", + "confirm_screenshot", + "presign_screenshot", + "remove_screenshot", +] diff --git a/krater/storage/__init__.py b/krater/storage/__init__.py new file mode 100644 index 0000000..b573b9c --- /dev/null +++ b/krater/storage/__init__.py @@ -0,0 +1,41 @@ +"""`krater.storage`: the only place that talks to object storage (gallery screenshots). + +Nothing outside this package should import `boto3`/know the bucket's endpoints or credentials -- go +through `ObjectStore` (via `get_object_store`). See `docs/dev/storage.md` for the SeaweedFS container +this backs in dev, and `docs/SPEC.md` ("Completion flow & public gallery") for the feature. +""" + +from __future__ import annotations + +from functools import lru_cache + +from krater.config import get_settings +from krater.storage.client import ObjectStore +from krater.storage.errors import StorageError, StorageUnavailableError +from krater.storage.fake import FakeObjectStore +from krater.storage.live import S3ObjectStore +from krater.storage.types import ObjectMeta, PresignedPost + +__all__ = [ + "FakeObjectStore", + "ObjectMeta", + "ObjectStore", + "PresignedPost", + "S3ObjectStore", + "StorageError", + "StorageUnavailableError", + "get_object_store", +] + + +@lru_cache +def get_object_store() -> ObjectStore: + """The process-wide `ObjectStore`, chosen by `settings.s3_mode`. + + Cached like `krater.skypilot.get_skypilot_client`; override in tests by passing a `FakeObjectStore` + directly to the service functions instead of going through this factory. + """ + settings = get_settings() + if settings.s3_mode == "fake": + return FakeObjectStore() + return S3ObjectStore(settings) diff --git a/krater/storage/client.py b/krater/storage/client.py new file mode 100644 index 0000000..fb4bdc9 --- /dev/null +++ b/krater/storage/client.py @@ -0,0 +1,48 @@ +"""The `ObjectStore` protocol every adapter (live or fake) implements. + +Nothing outside `krater.storage` should know the storage backend's endpoints, credentials or wire +format -- go through this interface. See `docs/dev/storage.md` for the SeaweedFS container this backs +in dev, and `docs/SPEC.md` ("Completion flow & public gallery") for why screenshots live here at all. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from krater.storage.types import ObjectMeta, PresignedPost + +#: Default lifetime for a presigned upload/download URL. Short: these are handed to a browser and used +#: (or not) within the same page load. +DEFAULT_UPLOAD_EXPIRES_SECONDS = 300 +DEFAULT_DOWNLOAD_EXPIRES_SECONDS = 3600 + + +@runtime_checkable +class ObjectStore(Protocol): + def presign_upload( + self, key: str, *, content_type: str, max_bytes: int, expires: int = DEFAULT_UPLOAD_EXPIRES_SECONDS + ) -> PresignedPost: + """A presigned `POST` policy for uploading directly to `key`. + + The policy pins both the object's `Content-Type` (to exactly `content_type`) and its size (via + a `content-length-range` condition, `1..max_bytes`) into the signature, so storage itself + rejects an upload that doesn't match either -- the caller never has to stream the bytes through + itself just to enforce them. `key` must already be the server-generated key the caller intends + to store the object under; this protocol never chooses one. + """ + ... + + def presign_download(self, key: str, *, expires: int = DEFAULT_DOWNLOAD_EXPIRES_SECONDS) -> str: + """A short-lived, presigned `GET` URL for `key`. Doesn't check the object exists.""" + ... + + def head(self, key: str) -> ObjectMeta | None: + """`key`'s size and declared content type, or `None` if no such object exists.""" + ... + + def delete(self, key: str) -> None: + """Delete `key`. Safe to call on a key that's already gone (S3's own `DeleteObject` semantics).""" + ... + + +__all__ = ["DEFAULT_DOWNLOAD_EXPIRES_SECONDS", "DEFAULT_UPLOAD_EXPIRES_SECONDS", "ObjectStore"] diff --git a/krater/storage/errors.py b/krater/storage/errors.py new file mode 100644 index 0000000..4c767f9 --- /dev/null +++ b/krater/storage/errors.py @@ -0,0 +1,20 @@ +"""Exception types raised by `krater.storage`. + +Mirrors `krater.skypilot.errors`: one error type for "storage couldn't be reached or failed +unexpectedly". There's no "understood but rejected" counterpart here the way SkyPilot has one, because +the two calls that can fail this way (`head`, `delete`) don't have a meaningful business-rejection case +of their own -- a missing object is a normal `None`/no-op, not an error. +""" + +from __future__ import annotations + + +class StorageError(Exception): + """Base class for every error raised by `krater.storage`.""" + + +class StorageUnavailableError(StorageError): + """Object storage couldn't be reached, or returned an unexpected failure.""" + + +__all__ = ["StorageError", "StorageUnavailableError"] diff --git a/krater/storage/fake.py b/krater/storage/fake.py new file mode 100644 index 0000000..c5eb1ab --- /dev/null +++ b/krater/storage/fake.py @@ -0,0 +1,47 @@ +"""`FakeObjectStore`: an in-memory `ObjectStore` for `KRATER_S3_MODE=fake` (development and tests). + +Makes no network calls, and doesn't actually store bytes -- just enough bookkeeping (size + content +type per key) for `krater.services.screenshots` to exercise the real confirm/head/delete flow. A test +that wants to simulate "the browser successfully uploaded through the presigned POST" calls `put` +directly, mirroring how `FakeSkyPilotClient.add_cluster` simulates a fact a real server would otherwise +report. +""" + +from __future__ import annotations + +from krater.storage.types import ObjectMeta, PresignedPost + + +class FakeObjectStore: + def __init__(self) -> None: + self._objects: dict[str, ObjectMeta] = {} + + # -- ObjectStore protocol ------------------------------------------------------------------------ + + def presign_upload(self, key: str, *, content_type: str, max_bytes: int, expires: int = 300) -> PresignedPost: + del expires + # A fake URL, never actually POSTed to in tests -- callers that need to simulate a completed + # upload use `put` below instead. The fields still carry the real policy inputs so a test can + # assert on them if it wants to. + return PresignedPost( + url=f"fake://krater-storage/{key}", + fields={"key": key, "Content-Type": content_type, "x-fake-max-bytes": str(max_bytes)}, + ) + + def presign_download(self, key: str, *, expires: int = 3600) -> str: + return f"fake://krater-storage/{key}?expires={expires}" + + def head(self, key: str) -> ObjectMeta | None: + return self._objects.get(key) + + def delete(self, key: str) -> None: + self._objects.pop(key, None) + + # -- Test helpers ---------------------------------------------------------------------------------- + + def put(self, key: str, *, content_type: str, size_bytes: int) -> None: + """Simulate a browser having uploaded `key` through the presigned POST above.""" + self._objects[key] = ObjectMeta(size_bytes=size_bytes, content_type=content_type) + + +__all__ = ["FakeObjectStore"] diff --git a/krater/storage/live.py b/krater/storage/live.py new file mode 100644 index 0000000..b208a51 --- /dev/null +++ b/krater/storage/live.py @@ -0,0 +1,106 @@ +"""`S3ObjectStore`: boto3-backed `ObjectStore` for `KRATER_S3_MODE=live`, against any S3-compatible +object storage (SeaweedFS today; see `docs/dev/storage.md`). + +Two boto3 clients, deliberately kept separate: + +- `_internal`, signed against `s3_endpoint_url` (Docker's internal network in production, e.g. + `http://storage:8333`; `http://localhost:8333` in dev), for server-side calls (`head`, `delete`) that + never leave the backend. +- `_public`, signed against `s3_public_endpoint_url` (whatever a browser can actually resolve and + reach), for anything whose URL is handed to a browser: `presign_upload`, `presign_download`. A URL + presigned against the internal endpoint would embed a host the browser can't reach, and SigV4 signs + the host into the signature, so it can't just be string-replaced after the fact either -- the request + has to be signed against the right endpoint from the start. + +Path-style addressing throughout (`s3={"addressing_style": "path"}`): SeaweedFS's S3 gateway doesn't +support virtual-hosted-style bucket URLs. SigV4 (`signature_version="s3v4"`) throughout too. +""" + +from __future__ import annotations + +from typing import Any + +import boto3 +from botocore.client import Config as BotoConfig +from botocore.exceptions import ClientError + +from krater.config import Settings +from krater.storage.client import DEFAULT_DOWNLOAD_EXPIRES_SECONDS, DEFAULT_UPLOAD_EXPIRES_SECONDS +from krater.storage.errors import StorageUnavailableError +from krater.storage.types import ObjectMeta, PresignedPost + +_BOTO_CONFIG = BotoConfig(signature_version="s3v4", s3={"addressing_style": "path"}) + + +def _is_not_found(exc: ClientError) -> bool: + error = exc.response.get("Error", {}) + status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") + return error.get("Code") in ("404", "NoSuchKey", "NotFound") or status == 404 + + +class S3ObjectStore: + """An `ObjectStore` backed by a real S3-compatible bucket. `internal_client`/`public_client` are + injectable for tests (e.g. botocore's `Stubber`); production code leaves them out and gets real + boto3 clients built from `settings`.""" + + def __init__(self, settings: Settings, *, internal_client: Any = None, public_client: Any = None) -> None: + self._bucket = settings.s3_bucket + self._internal = ( + internal_client if internal_client is not None else self._build_client(settings, settings.s3_endpoint_url) + ) + self._public = ( + public_client + if public_client is not None + else self._build_client(settings, settings.s3_public_endpoint_url) + ) + + @staticmethod + def _build_client(settings: Settings, endpoint_url: str) -> Any: + return boto3.client( + "s3", + endpoint_url=endpoint_url, + region_name=settings.s3_region, + aws_access_key_id=settings.s3_access_key_id, + aws_secret_access_key=settings.s3_secret_access_key, + config=_BOTO_CONFIG, + ) + + # -- ObjectStore protocol ------------------------------------------------------------------------ + + def presign_upload( + self, key: str, *, content_type: str, max_bytes: int, expires: int = DEFAULT_UPLOAD_EXPIRES_SECONDS + ) -> PresignedPost: + post = self._public.generate_presigned_post( + Bucket=self._bucket, + Key=key, + Fields={"Content-Type": content_type}, + Conditions=[ + {"Content-Type": content_type}, + ["content-length-range", 1, max_bytes], + ], + ExpiresIn=expires, + ) + return PresignedPost(url=post["url"], fields=dict(post["fields"])) + + def presign_download(self, key: str, *, expires: int = DEFAULT_DOWNLOAD_EXPIRES_SECONDS) -> str: + return self._public.generate_presigned_url( + "get_object", Params={"Bucket": self._bucket, "Key": key}, ExpiresIn=expires + ) + + def head(self, key: str) -> ObjectMeta | None: + try: + response = self._internal.head_object(Bucket=self._bucket, Key=key) + except ClientError as exc: + if _is_not_found(exc): + return None + raise StorageUnavailableError(f"could not head {key!r}: {exc}") from exc + return ObjectMeta(size_bytes=int(response["ContentLength"]), content_type=response.get("ContentType")) + + def delete(self, key: str) -> None: + try: + self._internal.delete_object(Bucket=self._bucket, Key=key) + except ClientError as exc: + raise StorageUnavailableError(f"could not delete {key!r}: {exc}") from exc + + +__all__ = ["S3ObjectStore"] diff --git a/krater/storage/types.py b/krater/storage/types.py new file mode 100644 index 0000000..b1fc6b8 --- /dev/null +++ b/krater/storage/types.py @@ -0,0 +1,30 @@ +"""Data carried out of `krater.storage`. See `krater/storage/client.py` for how each is produced.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PresignedPost: + """A presigned S3 `POST` policy: the browser posts the file straight to `url`, with `fields` sent + as form fields alongside it (in any order, before the file field itself). + + Carries a `content-length-range` condition and an exact `Content-Type` match baked into the signed + policy, so storage itself enforces the size limit -- the server never has to stream the upload + through itself just to reject an oversized file. + """ + + url: str + fields: dict[str, str] + + +@dataclass(frozen=True) +class ObjectMeta: + """What `ObjectStore.head` reports about an object that exists.""" + + size_bytes: int + content_type: str | None + + +__all__ = ["ObjectMeta", "PresignedPost"] diff --git a/krater/web/routers/gallery.py b/krater/web/routers/gallery.py index fbed489..65474c9 100644 --- a/krater/web/routers/gallery.py +++ b/krater/web/routers/gallery.py @@ -11,15 +11,25 @@ from sqlalchemy.orm import Session from krater.db import get_session -from krater.models import Project, ProjectStatus, User +from krater.models import Project, ProjectRevision, ProjectStatus, User from krater.services import budget from krater.services import projects as project_service from krater.services.errors import NotFound +from krater.storage import ObjectStore, get_object_store from krater.web.templates import templates router = APIRouter() +def _screenshot_urls(store: ObjectStore, revision: ProjectRevision | None) -> list[str]: + """Short-lived presigned GET URLs for `revision`'s screenshots. Only ever called for a `completed` + project's revision, per docs/SPEC.md: "only objects from approved completion revisions are served + publicly".""" + if revision is None: + return [] + return [store.presign_download(key) for key in revision.screenshot_keys] + + @router.get("/gallery") def gallery_index(request: Request, db_session: Annotated[Session, Depends(get_session)]): completed = list( @@ -27,11 +37,14 @@ def gallery_index(request: Request, db_session: Annotated[Session, Depends(get_s sa.select(Project).where(Project.status == ProjectStatus.COMPLETED).order_by(Project.updated_at.desc()) ) ) + store = get_object_store() entries = [ { "project": project, "revision": project.current_revision, "spend_cents": budget.latest_spend_cents(db_session, project), + # Just the first, as a card thumbnail -- the full set shows on the entry's own detail page. + "thumbnail_url": next(iter(_screenshot_urls(store, project.current_revision)), None), } for project in completed ] @@ -52,10 +65,17 @@ def gallery_detail(request: Request, project_id: uuid.UUID, db_session: Annotate builders = list(db_session.scalars(sa.select(User).where(User.id.in_(revision.credited_builder_ids)))) spend_cents = budget.latest_spend_cents(db_session, project) + screenshot_urls = _screenshot_urls(get_object_store(), revision) return templates.TemplateResponse( request, "gallery/detail.html", - {"project": project, "revision": revision, "builders": builders, "spend_cents": spend_cents}, + { + "project": project, + "revision": revision, + "builders": builders, + "spend_cents": spend_cents, + "screenshot_urls": screenshot_urls, + }, ) diff --git a/krater/web/routers/projects.py b/krater/web/routers/projects.py index 786fe5a..3b11201 100644 --- a/krater/web/routers/projects.py +++ b/krater/web/routers/projects.py @@ -13,7 +13,7 @@ import sqlalchemy as sa from fastapi import APIRouter, Depends, Form, Request -from fastapi.responses import RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse from sqlalchemy.orm import Session from krater.config import get_settings @@ -29,9 +29,11 @@ User, ) from krater.services import projects as project_service +from krater.services import screenshots as screenshot_service from krater.services.actor import Actor from krater.services.errors import InvalidState, NotAllowed, NotFound, ValidationFailed from krater.services.skypilot_sync import current_budget_flag +from krater.storage import ObjectStore, get_object_store from krater.web.csrf import verify_csrf_token from krater.web.deps import fresh_actor from krater.web.flash import flash @@ -176,10 +178,13 @@ def _build_detail_context( session, project, warn_percent=get_settings().skypilot_budget_warn_percent ) + screenshots = _screenshot_entries(get_object_store(), current) if current is not None else [] + return { "project": project, "summary": summary, "skypilot_budget_flag": skypilot_budget_flag, + "screenshots": screenshots, "revisions": revisions, "users_by_id": users_by_id, "is_submitter": is_submitter, @@ -244,12 +249,18 @@ def _require_editable_draft(project: Project) -> ProjectRevision | None: return draft +def _screenshot_entries(store: ObjectStore, draft: ProjectRevision) -> list[dict[str, str]]: + """Short-lived presigned GET URLs for a draft's screenshots, for the edit page's thumbnails.""" + return [{"key": key, "url": store.presign_download(key)} for key in draft.screenshot_keys] + + @router.get("/projects/{project_id}/edit") def edit_draft_form( request: Request, project_id: uuid.UUID, db_session: Annotated[Session, Depends(get_session)], actor: Annotated[Actor, Depends(fresh_actor)], + store: Annotated[ObjectStore, Depends(get_object_store)], ): project = _get_visible_project(db_session, actor, project_id) if actor.user.id != project.submitter_id: @@ -261,9 +272,12 @@ def edit_draft_form( return _redirect_to_project(project_id) values = _edit_form_values(db_session, project, draft) - return templates.TemplateResponse( - request, "projects/edit.html", {"project": project, "draft": draft, "errors": {}, "values": values} - ) + context = {"project": project, "draft": draft, "errors": {}, "values": values} + if draft.kind is RevisionKind.COMPLETION: + context["screenshots"] = _screenshot_entries(store, draft) + context["max_screenshots"] = screenshot_service.MAX_SCREENSHOTS + context["max_screenshot_bytes"] = screenshot_service.MAX_SCREENSHOT_BYTES + return templates.TemplateResponse(request, "projects/edit.html", context) @router.post("/projects/{project_id}/edit", dependencies=[Depends(verify_csrf_token)]) @@ -272,6 +286,7 @@ def update_draft( project_id: uuid.UUID, db_session: Annotated[Session, Depends(get_session)], actor: Annotated[Actor, Depends(fresh_actor)], + store: Annotated[ObjectStore, Depends(get_object_store)], title: Annotated[str, Form()] = "", repo_url: Annotated[str, Form()] = "", write_up: Annotated[str, Form()] = "", @@ -315,12 +330,12 @@ def update_draft( "tags": tags, "credited_builder_emails": credited_builder_emails, } - return templates.TemplateResponse( - request, - "projects/edit.html", - {"project": project, "draft": draft, "errors": errors, "values": values}, - status_code=422, - ) + context = {"project": project, "draft": draft, "errors": errors, "values": values} + if draft.kind is RevisionKind.COMPLETION: + context["screenshots"] = _screenshot_entries(store, draft) + context["max_screenshots"] = screenshot_service.MAX_SCREENSHOTS + context["max_screenshot_bytes"] = screenshot_service.MAX_SCREENSHOT_BYTES + return templates.TemplateResponse(request, "projects/edit.html", context, status_code=422) update_kwargs: dict = { "title": title, @@ -616,4 +631,71 @@ def admin_reclaim_budget( return _success_redirect(db_session, request, project_id, "Budget reclaimed.") +# -------------------------------------------------------------------------------------------------- +# Screenshots (completion draft only): presign/confirm are called by the edit page's upload widget +# (krater/web/static/js/screenshot-upload.js) via `fetch`, and return JSON rather than a redirect. +# Remove is a plain CSRF-protected form post, so it works with JavaScript disabled. +# -------------------------------------------------------------------------------------------------- + + +@router.post("/projects/{project_id}/screenshots/presign", dependencies=[Depends(verify_csrf_token)]) +def presign_screenshot( + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + store: Annotated[ObjectStore, Depends(get_object_store)], + content_type: Annotated[str, Form()] = "", +): + project = _get_visible_project(db_session, actor, project_id) + try: + upload = screenshot_service.presign_screenshot( + db_session, actor, project=project, content_type=content_type, store=store + ) + except ValidationFailed as exc: + return JSONResponse({"errors": exc.errors}, status_code=422) + except InvalidState as exc: + return JSONResponse({"errors": {"screenshot": str(exc)}}, status_code=409) + return JSONResponse({"key": upload.key, "url": upload.post.url, "fields": upload.post.fields}) + + +@router.post("/projects/{project_id}/screenshots/confirm", dependencies=[Depends(verify_csrf_token)]) +def confirm_screenshot( + project_id: uuid.UUID, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + store: Annotated[ObjectStore, Depends(get_object_store)], + key: Annotated[str, Form()] = "", +): + project = _get_visible_project(db_session, actor, project_id) + try: + screenshot_service.confirm_screenshot(db_session, actor, project=project, key=key, store=store) + except ValidationFailed as exc: + db_session.rollback() + return JSONResponse({"errors": exc.errors}, status_code=422) + except InvalidState as exc: + db_session.rollback() + return JSONResponse({"errors": {"screenshot": str(exc)}}, status_code=409) + db_session.commit() + return JSONResponse({"key": key}) + + +@router.post("/projects/{project_id}/screenshots/{key:path}/delete", dependencies=[Depends(verify_csrf_token)]) +def delete_screenshot( + request: Request, + project_id: uuid.UUID, + key: str, + db_session: Annotated[Session, Depends(get_session)], + actor: Annotated[Actor, Depends(fresh_actor)], + store: Annotated[ObjectStore, Depends(get_object_store)], +): + project = _get_visible_project(db_session, actor, project_id) + try: + screenshot_service.remove_screenshot(db_session, actor, project=project, key=key, store=store) + except InvalidState as exc: + return _invalid_state_redirect(db_session, request, project_id, exc) + db_session.commit() + flash(request, "Screenshot removed.", "success") + return RedirectResponse(f"/projects/{project_id}/edit", status_code=303) + + __all__ = ["router"] diff --git a/krater/web/static/css/main.css b/krater/web/static/css/main.css index 458218d..59567e9 100644 --- a/krater/web/static/css/main.css +++ b/krater/web/static/css/main.css @@ -383,6 +383,18 @@ a:hover { font-size: 1.1rem; } +.gallery-list__thumb { + display: block; + width: 100%; + max-width: 16rem; + height: 9rem; + object-fit: cover; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + background-color: var(--color-quilt); + margin-bottom: 0.4rem; +} + .tag-list { display: flex; flex-wrap: wrap; @@ -474,6 +486,52 @@ a:hover { padding-top: 0; } +/* Screenshots (completion draft upload widget + gallery) */ + +.screenshot-list { + list-style: none; + margin: 0 0 0.75rem; + padding: 0; + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.screenshot-list__item { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + width: 9rem; +} + +.screenshot-list__thumb { + width: 100%; + height: 6rem; + object-fit: cover; + border: 1px solid var(--color-border); + border-radius: 0.4rem; + background-color: var(--color-quilt); +} + +.gallery-screenshots { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin: 0.75rem 0 1rem; + padding: 0; + list-style: none; +} + +.gallery-screenshots img { + width: 12rem; + height: 8rem; + object-fit: cover; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + background-color: var(--color-quilt); +} + /* Tables */ .table { diff --git a/krater/web/static/js/screenshot-upload.js b/krater/web/static/js/screenshot-upload.js new file mode 100644 index 0000000..b9e9837 --- /dev/null +++ b/krater/web/static/js/screenshot-upload.js @@ -0,0 +1,144 @@ +/* + * Screenshot upload widget for the completion-draft edit page. Vanilla JS, no build step, no CDN + * dependencies -- served straight from /static (see krater/web/app.py's StaticFiles mount). + * + * Flow per file, matching krater/services/screenshots.py: + * 1. POST (this app) -- returns a presigned S3 POST policy for a server-chosen key. + * 2. POST straight to that policy's `url` (object storage, a *different* origin from this app in + * production -- see docs/dev/storage.md's CORS section; this is why step 2 is a plain `fetch` + * with no credentials, not a same-origin form submit). + * 3. POST (this app) -- tells Krater the object exists, so it gets appended to the + * draft's screenshot_keys after a server-side re-check (content type, size). + * 4. Reload the page so the new thumbnail renders from the server-rendered list (also picks up a + * fresh presigned GET URL for every existing thumbnail, which are short-lived). + * + * There's no partial/AJAX re-render on purpose: this widget is a small enhancement over a page that + * already works without it (see the