Compare commits
102 Commits
6891496589
...
feat/v1.1.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2796f36fef | ||
|
|
5a95ff2d07 | ||
|
|
66b661f64c | ||
|
|
6b7ff78730 | ||
|
|
1795dfc98a | ||
|
|
20f1b5e64d | ||
|
|
77b2cb58bb | ||
|
|
6a2971ac70 | ||
|
|
2e92691ee1 | ||
|
|
545d863199 | ||
|
|
6b99f74c48 | ||
|
|
434fb63cd2 | ||
|
|
1efb350b54 | ||
|
|
10cfde9e40 | ||
|
|
bb88b024d2 | ||
|
|
9d01f42d5e | ||
|
|
1a6324078c | ||
|
|
54efe61167 | ||
|
|
1d2e99e42c | ||
|
|
9e54b7f875 | ||
|
|
a685674dbf | ||
|
|
a8aab22163 | ||
|
|
e375735796 | ||
|
|
098e18a989 | ||
|
|
9b4a834627 | ||
|
|
5302bd3192 | ||
|
|
902dd78027 | ||
|
|
dea776b2a3 | ||
|
|
fe1dd90836 | ||
|
|
aaba58dee1 | ||
|
|
2669714a51 | ||
|
|
662d5a2cf8 | ||
|
|
fc8d473416 | ||
|
|
c73e3c80c0 | ||
|
|
f147665157 | ||
|
|
e4851b3deb | ||
|
|
5d08974876 | ||
|
|
ca278bddc8 | ||
|
|
7b50047730 | ||
|
|
b42e273479 | ||
|
|
f32ed73561 | ||
|
|
64799b73ff | ||
|
|
beb3bcb97c | ||
|
|
79c8db2cb7 | ||
|
|
f4cd883d76 | ||
|
|
b459b99fe9 | ||
|
|
f694a6d504 | ||
|
|
70b66451d6 | ||
|
|
c4fa53052d | ||
|
|
2f6840fe3e | ||
|
|
75c815d02a | ||
|
|
d9c3d4d661 | ||
|
|
bef4d34c43 | ||
|
|
99a3ed1b6b | ||
|
|
4644ea4919 | ||
|
|
ec3c768262 | ||
|
|
3e72ddde78 | ||
|
|
cd20ffb580 | ||
|
|
cddd479fd2 | ||
|
|
8bbcdd86aa | ||
|
|
2d56e42699 | ||
|
|
f9d9ed8cb4 | ||
|
|
c17f8a5bd9 | ||
|
|
7198fb4d0e | ||
|
|
029a4a199f | ||
|
|
74f7b3b631 | ||
|
|
e6fc6e6a0e | ||
|
|
66b84abf6d | ||
|
|
a9fc838577 | ||
|
|
2948875a96 | ||
|
|
b7175cc581 | ||
|
|
d40ebf65a2 | ||
|
|
816a13b920 | ||
|
|
248571dcde | ||
|
|
85bbabcbdf | ||
|
|
1314420fca | ||
|
|
33697a2766 | ||
|
|
6eb32a78bf | ||
|
|
fc35d59236 | ||
|
|
0c9f11558a | ||
|
|
39a6df2bfe | ||
|
|
d21cbdb164 | ||
|
|
700ae7b7d1 | ||
|
|
f16ff22a5a | ||
|
|
bd2258499e | ||
|
|
df691038d7 | ||
|
|
3688c26cb4 | ||
|
|
2aab92af31 | ||
|
|
063595be31 | ||
|
|
30a1584667 | ||
|
|
d229120df6 | ||
|
|
8659a58eb2 | ||
|
|
5f7ddd23ab | ||
|
|
44db8d107a | ||
|
|
abaabb68d8 | ||
|
|
fd6f2b1f13 | ||
|
|
d435322f9c | ||
|
|
5546323cdc | ||
|
|
a393f11344 | ||
|
|
ad5492a4bd | ||
|
|
ee0dbc428f | ||
|
|
4c41374db4 |
@@ -29,3 +29,11 @@ RUST_LOG=info,picloud=debug
|
||||
# Public base URL the dashboard uses to render full URLs for user routes.
|
||||
# Set to the host:port (and scheme) users actually reach in their browser.
|
||||
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000
|
||||
|
||||
# ---------- Bootstrap admin ----------
|
||||
# Required. Used once on first startup to seed the admin_users table.
|
||||
# Ignored on subsequent boots if the table is non-empty. For prod,
|
||||
# prefer PICLOUD_ADMIN_PASSWORD_HASH (pre-computed Argon2id PHC) so the
|
||||
# raw password never lands in env or compose files; see blueprint §11.5.
|
||||
PICLOUD_ADMIN_USERNAME=admin
|
||||
PICLOUD_ADMIN_PASSWORD=admin
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -30,6 +30,17 @@ config.local.toml
|
||||
/dashboard/build
|
||||
/dashboard/.env
|
||||
|
||||
# Dashboard — Playwright E2E
|
||||
/dashboard/tests/e2e/.auth
|
||||
/dashboard/tests/e2e/.results
|
||||
/dashboard/playwright-report
|
||||
/dashboard/test-results
|
||||
/dashboard/.playwright
|
||||
# When playwright is invoked from the repo root by accident, these
|
||||
# also land here.
|
||||
/playwright-report
|
||||
/test-results
|
||||
|
||||
# Caddy
|
||||
/caddy/data
|
||||
/caddy/config
|
||||
|
||||
88
CHANGELOG.md
Normal file
88
CHANGELOG.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# PiCloud Changelog
|
||||
|
||||
## v1.1.1 — Storage & Events (unreleased)
|
||||
|
||||
The triggers framework — KV store + universal outbox + dispatcher +
|
||||
NATS-style sync HTTP + per-route async dispatch + dead-letter
|
||||
handling + dashboard surface. Every subsequent v1.1.x service module
|
||||
(docs, files, pubsub, …) hangs off the dispatcher built here.
|
||||
|
||||
### Added
|
||||
|
||||
- **KV store** — `kv_entries` table keyed `(app_id, collection, key)`
|
||||
with JSONB values. Rhai SDK exposes the handle pattern:
|
||||
`kv::collection(name).{get,set,has,delete,list}`. Cursor-style
|
||||
pagination with opaque base64 cursors. Cross-app isolation
|
||||
enforced via `cx.app_id` (never script-passed).
|
||||
- **Triggers framework (Layout E)** — parent `triggers` table +
|
||||
per-kind detail tables (`kv_trigger_details`,
|
||||
`dead_letter_trigger_details`). Trigger CRUD admin endpoints
|
||||
(`/api/v1/admin/apps/{id}/triggers/{kv,dead_letter}`) +
|
||||
`Capability::AppManageTriggers(AppId)`.
|
||||
- **Universal outbox + dispatcher** — single tokio task that polls
|
||||
the outbox via `FOR UPDATE SKIP LOCKED`, routes due rows to the
|
||||
executor through the shared `ExecutionGate`. Retry with
|
||||
exponential backoff + ±jitter; on exhaustion, dead-letter.
|
||||
- **NATS-style sync HTTP via outbox** — `InboxRegistry` (in-process
|
||||
oneshot map) lets the orchestrator await dispatcher delivery on
|
||||
every sync HTTP request. Cluster mode (v1.3+) swaps this for
|
||||
`LISTEN/NOTIFY` behind the same `InboxResolver` trait.
|
||||
- **`dispatch_mode: async` on routes** — `POST` to a route with
|
||||
`dispatch_mode = 'async'` returns `202 Accepted` immediately;
|
||||
the script runs via the dispatcher (with retries / dead-letter).
|
||||
- **Dead-letter handling** — separate `dead_letters` table per
|
||||
design notes §4. `dead_letters::{replay,resolve}` Rhai SDK +
|
||||
admin endpoints + `Capability::AppDeadLetterManage(AppId)`.
|
||||
Recursion-stop rule: dead-letter handler failures annotate the
|
||||
original row as `resolution = 'handler_failed'` and never produce
|
||||
a new dead-letter or retry.
|
||||
- **Dashboard surface for dead letters** — unresolved-count red
|
||||
badge on the apps list + per-app page; per-app dead-letters list
|
||||
view at `/admin/apps/{slug}/dead-letters` with Replay + Mark
|
||||
resolved per-row actions and expandable payload detail.
|
||||
- **`abandoned_executions` table** — forensic row written by the
|
||||
dispatcher when it tries to resolve an inbox the orchestrator
|
||||
already abandoned (timed out). Counter metric path reserved.
|
||||
- **Trigger-depth limit** — `cx.trigger_depth > max_trigger_depth`
|
||||
(default 8) skips execution + logs; does NOT dead-letter
|
||||
(depth-exceeded means "you built a loop").
|
||||
- **GC sweepers** — weekly retention sweeps for `dead_letters`
|
||||
(30 days) and `abandoned_executions` (7 days), both with
|
||||
`FOR UPDATE SKIP LOCKED` for cluster-mode safety.
|
||||
- **Env-overridable trigger config** — `TriggerConfig::from_env`
|
||||
reads `PICLOUD_MAX_TRIGGER_DEPTH`, `PICLOUD_TRIGGER_RETRY_*`,
|
||||
`PICLOUD_DEAD_LETTER_RETENTION_DAYS`,
|
||||
`PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Workspace version**: `1.1.0` → `1.1.1`.
|
||||
- **Rhai SDK version**: `1.1` → `1.2` (additive — every v1.1 script
|
||||
still runs unchanged; new surfaces: `kv::*`, `dead_letters::*`,
|
||||
`ctx.event` for triggered handlers).
|
||||
- **Dashboard version**: `0.6.0` → `0.7.0` for the dead-letters UI.
|
||||
- **`Services` bundle** — replaces v1.1.0's no-arg `Services::new()`
|
||||
with explicit `Services::new(kv, dead_letters, events)`. Tests
|
||||
use `Services::default()` for an all-noop bundle.
|
||||
- **`SdkCallCx`** grows `is_dead_letter_handler: bool` and
|
||||
`event: Option<TriggerEvent>` fields.
|
||||
- **`ExecRequest`** mirrors the new `SdkCallCx` fields and grows
|
||||
`event` for serializable trigger payload transport.
|
||||
- **Routes table** grows `dispatch_mode TEXT NOT NULL DEFAULT 'sync'`
|
||||
(CHECK in {sync, async}).
|
||||
- **Schema version**: 6 → 12 (migrations 0007 through 0012).
|
||||
|
||||
### Migrations
|
||||
|
||||
- `0007_kv.sql` — `kv_entries` table + index
|
||||
- `0008_triggers.sql` — `triggers` + `kv_trigger_details` +
|
||||
`dead_letter_trigger_details`
|
||||
- `0009_outbox.sql` — universal `outbox` table + due-row partial index
|
||||
- `0010_dead_letters.sql` — `dead_letters` table + unresolved partial
|
||||
index + GC index
|
||||
- `0011_abandoned_executions.sql` — forensic table + GC index
|
||||
- `0012_routes_dispatch_mode.sql` — `routes.dispatch_mode` column
|
||||
|
||||
## v1.1.0 — Foundation & Standard Library
|
||||
|
||||
See `docs/v1.1.x-design-notes.md` §7 for the full v1.1.x roadmap.
|
||||
17
CLAUDE.md
17
CLAUDE.md
@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||
|
||||
**Current focus (Phase 3, pre-v1.1):** admin auth gate, then multi-app scoping. The latter introduces `apps` as the top-level isolation boundary for scripts, routes, domains, and (later) data. See blueprint §11.5 for the design. Every v1.1+ feature must assume `app_id` exists as a scoping dimension.
|
||||
**Current focus (Phase 4, v1.1.0):** SDK foundation + stdlib utilities — the shape every v1.1.x service module hangs off, see [docs/sdk-shape.md](docs/sdk-shape.md). Stdlib reference at [docs/stdlib-reference.md](docs/stdlib-reference.md). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). Every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
|
||||
|
||||
## Three-Service Architecture
|
||||
|
||||
@@ -48,7 +48,7 @@ Caddy fronts everything. Same Caddyfile shape works for single-node and cluster
|
||||
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
|
||||
- **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres
|
||||
- **Rhai** embedded scripting (in `executor-core`)
|
||||
- **PostgreSQL 15+** with `pgcrypto` and (v1.1+) `hstore`
|
||||
- **PostgreSQL 15+** with `pgcrypto`. v1.1+ data-plane tables use JSONB for value columns (hstore was considered for KV and rejected — see blueprint §8.1).
|
||||
- **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor
|
||||
- **Caddy 2** reverse proxy (auto-HTTPS in prod)
|
||||
- **Docker Compose** for dev and single-node prod
|
||||
@@ -103,9 +103,22 @@ docs/
|
||||
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
|
||||
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
|
||||
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
|
||||
- **Stateful SDK services use the handle pattern + `SdkCallCx`.** Collection-scoped surfaces look like `kv::collection("x").get(k)`, not `kv::get("x", k)`. Every service trait method takes `&SdkCallCx` and **MUST** derive `app_id` from `cx.app_id` — never trust a script-passed `app_id`. That is the cross-app isolation boundary. See [docs/sdk-shape.md](docs/sdk-shape.md).
|
||||
- **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented.
|
||||
- **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work.
|
||||
|
||||
## Runtime configuration
|
||||
|
||||
Environment variables consumed by the `picloud` binary:
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `PICLOUD_BIND` | `0.0.0.0:8080` | HTTP listen address. Port 8080 is owned by another process on this host — override locally. |
|
||||
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). |
|
||||
| `DATABASE_URL` | — | Required. Postgres connection string. |
|
||||
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |
|
||||
| `PICLOUD_SANDBOX_MAX_*` | conservative defaults | Per-knob admin ceilings on Rhai sandbox overrides. See `manager-core::sandbox::SandboxCeiling`. |
|
||||
|
||||
## Out of MVP
|
||||
|
||||
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.
|
||||
|
||||
386
Cargo.lock
generated
386
Cargo.lock
generated
@@ -40,6 +40,56 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -68,6 +118,21 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "assert_cmd"
|
||||
version = "2.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"bstr",
|
||||
"libc",
|
||||
"predicates",
|
||||
"predicates-core",
|
||||
"predicates-tree",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -236,6 +301,17 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -302,6 +378,52 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
@@ -408,6 +530,12 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
@@ -434,6 +562,12 @@ version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "difflib"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -446,6 +580,27 @@ dependencies = [
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
@@ -510,6 +665,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "figment"
|
||||
version = "0.10.19"
|
||||
@@ -530,6 +691,15 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "float-cmp"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1004,6 +1174,12 @@ version = "2.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1071,6 +1247,12 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
version = "0.8.2"
|
||||
@@ -1155,6 +1337,12 @@ dependencies = [
|
||||
"spin 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "normalize-line-endings"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1225,6 +1413,18 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
@@ -1305,12 +1505,13 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "picloud"
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-test",
|
||||
"chrono",
|
||||
"figment",
|
||||
"picloud-executor-core",
|
||||
"picloud-manager-core",
|
||||
@@ -1325,11 +1526,33 @@ dependencies = [
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picloud-cli"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"assert_cmd",
|
||||
"chrono",
|
||||
"clap",
|
||||
"directories",
|
||||
"libc",
|
||||
"picloud-shared",
|
||||
"predicates",
|
||||
"reqwest",
|
||||
"rpassword",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picloud-executor"
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-executor-core",
|
||||
@@ -1341,21 +1564,28 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-executor-core"
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"hex",
|
||||
"percent-encoding",
|
||||
"picloud-shared",
|
||||
"rand 0.8.6",
|
||||
"regex",
|
||||
"rhai",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picloud-manager"
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-manager-core",
|
||||
@@ -1367,13 +1597,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-manager-core"
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64",
|
||||
"chrono",
|
||||
"data-encoding",
|
||||
"picloud-executor-core",
|
||||
"picloud-orchestrator-core",
|
||||
"picloud-shared",
|
||||
"rand 0.8.6",
|
||||
@@ -1390,7 +1622,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-orchestrator"
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"picloud-orchestrator-core",
|
||||
@@ -1402,7 +1634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-orchestrator-core"
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
@@ -1421,7 +1653,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "picloud-shared"
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"chrono",
|
||||
@@ -1500,6 +1732,36 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predicates"
|
||||
version = "3.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"difflib",
|
||||
"float-cmp",
|
||||
"normalize-line-endings",
|
||||
"predicates-core",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "predicates-core"
|
||||
version = "1.0.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144"
|
||||
|
||||
[[package]]
|
||||
name = "predicates-tree"
|
||||
version = "1.0.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2"
|
||||
dependencies = [
|
||||
"predicates-core",
|
||||
"termtree",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pretty_assertions"
|
||||
version = "1.4.1"
|
||||
@@ -1695,6 +1957,29 @@ dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
@@ -1720,7 +2005,9 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
@@ -1803,6 +2090,17 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpassword"
|
||||
version = "7.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "835a57a69104632d64deb0df2e09a69945cd7a6eab4070fc9b1d7e50cf6c3edc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rtoolbox",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
@@ -1823,6 +2121,16 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rtoolbox"
|
||||
version = "0.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-multipart-rfc7578_2"
|
||||
version = "0.8.0"
|
||||
@@ -1844,6 +2152,19 @@ version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.40"
|
||||
@@ -2318,6 +2639,12 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "subtle"
|
||||
version = "2.6.1"
|
||||
@@ -2355,6 +2682,25 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "termtree"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||
|
||||
[[package]]
|
||||
name = "thin-vec"
|
||||
version = "0.2.18"
|
||||
@@ -2774,6 +3120,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
@@ -2804,6 +3156,15 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
@@ -3057,6 +3418,15 @@ dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets 0.52.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.60.2"
|
||||
|
||||
12
Cargo.toml
12
Cargo.toml
@@ -9,10 +9,11 @@ members = [
|
||||
"crates/picloud-manager",
|
||||
"crates/picloud-orchestrator",
|
||||
"crates/picloud-executor",
|
||||
"crates/picloud-cli",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.5.1"
|
||||
version = "1.1.1"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -66,11 +67,18 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
|
||||
# Auth (admin users + sessions)
|
||||
# Auth (admin users + sessions + API keys)
|
||||
argon2 = "0.5"
|
||||
rand = { version = "0.8", features = ["getrandom"] }
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
data-encoding = "2.6"
|
||||
|
||||
# Stdlib utility crates (v1.1.0 stdlib PR — registered into the
|
||||
# Rhai engine as the regex::/random::/etc. namespaces)
|
||||
regex = "1"
|
||||
hex = "0.4"
|
||||
percent-encoding = "2"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
|
||||
340
HANDBACK.md
Normal file
340
HANDBACK.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# v1.1.1 Implementation HANDBACK
|
||||
|
||||
## 1. Branch + commit count
|
||||
|
||||
- Branch: `feat/v1.1.1-storage-and-events`
|
||||
- Base: `main`
|
||||
- 11 commits ahead of `main`. Branch is **not pushed**, **not merged**.
|
||||
|
||||
```
|
||||
66b661f chore(release): bump workspace to v1.1.1 + CHANGELOG
|
||||
6b7ff78 feat(v1.1.1-gc): dead-letter + abandoned-executions retention sweepers
|
||||
1795dfc feat(v1.1.1-dead-letters): dashboard badge + list view
|
||||
20f1b5e feat(v1.1.1-dead-letters): service + Rhai SDK + admin endpoints
|
||||
77b2cb5 feat(v1.1.1-routes): outbox-routed sync HTTP + dispatch_mode=async
|
||||
6a2971a feat(v1.1.1-dispatcher): dispatcher loop + retry + depth limit + outbox emitter
|
||||
2e92691 feat(v1.1.1-triggers): trigger CRUD admin endpoints
|
||||
545d863 feat(v1.1.1-triggers): triggers + outbox schema + repos
|
||||
6b99f74 feat(v1.1.1-kv): Rhai kv:: SDK module + ctx.event wiring
|
||||
434fb63 feat(v1.1.1-kv): migrations + KvService trait + Postgres impl
|
||||
1efb350 docs(v1.1.x): resolve in-flight decisions as Decided 2026-06-01
|
||||
```
|
||||
|
||||
The first commit (`1efb350`) absorbed working-tree edits to
|
||||
`docs/v1.1.x-design-notes.md` that turned the "in-flight" 20 open
|
||||
calls into "Decided 2026-06-01" entries. Those were on the working
|
||||
tree at branch creation; folding them into the v1.1.1 branch keeps
|
||||
the design rationale colocated with the implementation.
|
||||
|
||||
## 2. Scope coverage (Done / Partial / Skipped)
|
||||
|
||||
| Scope item | Status | Notes |
|
||||
|---|---|---|
|
||||
| **1. KV store** | Done | Migration 0007, `KvService` trait in shared, `KvServiceImpl` + `PostgresKvRepo` in manager-core, Rhai `kv::collection(name).{get,set,has,delete,list}` bridge, cursor pagination, empty-collection rejection, script-as-gate authz. |
|
||||
| **2. Triggers framework — Layout E** | Done | Migrations 0008 (`triggers` + `kv_trigger_details` + `dead_letter_trigger_details`), `TriggerRepo` + `PostgresTriggerRepo`, CRUD admin endpoints. `registered_by_principal` column captured + threaded into the dispatcher. Depth-limit enforced in the dispatcher (default 8). |
|
||||
| **3. Universal outbox + dispatcher** | Done | Migration 0009 (`outbox`), `OutboxRepo` + `PostgresOutboxRepo`, `Dispatcher` tokio task. Polls every 100ms, claims 8 rows/tick via `FOR UPDATE SKIP LOCKED`, gate-bounds dispatch, retries with backoff+jitter, dead-letters on exhaustion, late-completion → `abandoned_executions`. |
|
||||
| **4. NATS-style sync HTTP** | Done | `InboxRegistry` in orchestrator-core (in-process `Mutex<HashMap<Uuid, oneshot::Sender>>`), `InboxResolver` trait in shared. Orchestrator sync-route path registers receiver, writes outbox row with `reply_to`, awaits with timeout = script.timeout + 2s buffer. Status mapping per design notes §3 (422/502/503/504/507/500). |
|
||||
| **5. `dispatch_mode: async` HTTP routes** | Done | Migration 0012 adds the column (default `sync`). `DispatchMode` enum in shared. Route admin payload + RouteRepository serialize it. Compiled routes carry it; the matcher returns it in `Matched`. Orchestrator branches: async → outbox + 202; sync → outbox + inbox. |
|
||||
| **6. Dead letters** | Done | Migration 0010 (`dead_letters`), `DeadLetterRepo` + `DeadLetterService` + `PostgresDeadLetterService`. Rhai `dead_letters::{replay,resolve}` + admin endpoints (`GET /count`, `GET /`, `GET /{id}`, `POST /{id}/replay`, `POST /{id}/resolve`). `Capability::AppDeadLetterManage(AppId)` enforced. List intentionally NOT shipped (deferred to v1.2). Recursion-stop rule (handler-failure annotates original DL as `handler_failed`) implemented in the dispatcher. |
|
||||
| **7. Abandoned executions** | Done | Migration 0011, `AbandonedRepo` + `PostgresAbandonedRepo`, dispatcher writes a row on dropped-receiver inbox delivery. Metric path reserved (`TODO(metrics)` markers in dispatcher.rs). |
|
||||
| **8. Retry policy defaults** | Done | `TriggerConfig::from_env` (new module). Env vars: `PICLOUD_MAX_TRIGGER_DEPTH`, `PICLOUD_TRIGGER_RETRY_{MAX_ATTEMPTS,BACKOFF,BASE_MS,JITTER_PCT}`, `PICLOUD_DEAD_LETTER_RETENTION_DAYS`, `PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS`. Per-trigger overrides applied at trigger-creation time. |
|
||||
| **9. `ctx.event` for triggered scripts** | Done | `TriggerEvent` enum in shared (KV / DeadLetter variants), `SdkCallCx.event: Option<TriggerEvent>` + `is_dead_letter_handler: bool`. `engine.rs::build_ctx_map` flattens the event into `ctx.event` for triggered handlers; direct invocations leave the key absent. Shape matches design notes §4 (KV with op + collection + key + value; dead_letter with original + attempts + last_error + ids + timestamps). |
|
||||
| **10. Dashboard surface** | Done | Per-app red badge with unresolved count on apps list + per-app detail page. New `apps/[slug]/dead-letters/+page.svelte` list view with all design-notes-mandated columns + Replay + Mark resolved actions + expandable row detail. svelte-check passes (369 files, 0 errors, 0 warnings). |
|
||||
| **11. Workspace version bump** | Done | Workspace `1.1.0` → `1.1.1`, SDK `1.1` → `1.2`, dashboard `0.6.0` → `0.7.0`. CHANGELOG.md created at repo root. |
|
||||
|
||||
## 3. Key implementation decisions / deviations
|
||||
|
||||
### Outbox column set (deferred to implementation per design notes §2)
|
||||
Chose:
|
||||
- `script_id` denormalized — dispatcher resolves the target without
|
||||
re-joining for the common path.
|
||||
- `trigger_id` polymorphic (no DB FK) — references `triggers.id` for
|
||||
`source_kind IN {kv, dead_letter}`, `routes.id` for
|
||||
`source_kind = 'http'`. Discrimination in Rust at dispatch time.
|
||||
- `claimed_by TEXT` — pid-based for MVP; cluster mode can use any
|
||||
identifier without schema change.
|
||||
- `trigger_depth` + `root_execution_id` denormalized so the
|
||||
dispatcher rebuilds `ExecRequest` without joining back to the
|
||||
originating execution log.
|
||||
- No explicit `is_dead_letter_handler` column — dispatcher infers
|
||||
from the trigger's `kind` field at dispatch time.
|
||||
|
||||
### KV pagination
|
||||
- **Cursor-style**, opaque base64-encoded last-key.
|
||||
- Page-size cap of 1000 with default 100 (enforced in repo).
|
||||
- Documented in `crates/shared/src/kv.rs` and the SDK function
|
||||
comment.
|
||||
|
||||
### KV TTL
|
||||
- Blueprint §8.1 reserved an `expires_at` column. v1.1.1 design notes
|
||||
don't surface TTL through the SDK (`set(k,v)` has no TTL argument)
|
||||
so the column is **omitted from migration 0007**. Adding it later
|
||||
is a non-breaking forward migration. Recorded in CHANGELOG as a
|
||||
deferred item.
|
||||
|
||||
### Authz scope mapping (seven-scope commitment)
|
||||
The four new capabilities map onto existing scopes — **no new scope
|
||||
variants** to honour the `Scope` enum's "exactly seven values"
|
||||
contract (`crates/shared/src/auth.rs:103`):
|
||||
|
||||
| Capability | Scope |
|
||||
|---|---|
|
||||
| `AppKvRead` | `script:read` |
|
||||
| `AppKvWrite` | `script:write` |
|
||||
| `AppManageTriggers` | `app:admin` |
|
||||
| `AppDeadLetterManage` | `app:admin` |
|
||||
|
||||
`role_satisfies` grants `AppKvRead` at the Viewer role, `AppKvWrite`
|
||||
at Editor, and both trigger / DL caps at AppAdmin.
|
||||
|
||||
### Script-as-gate authz for SDK calls
|
||||
- `KvServiceImpl` runs `authz::require` only when
|
||||
`cx.principal.is_some()`. Anonymous public-HTTP scripts (the
|
||||
common case for public routes) bypass the cap check.
|
||||
- Cross-app isolation is **independent** of this — enforced by
|
||||
`cx.app_id` being the only source of `app_id` on every query.
|
||||
- `PostgresDeadLetterService::{replay,resolve}` keeps a hard
|
||||
`require` (no `if let Some`) — managing dead letters is an admin
|
||||
act per design notes §4. Public scripts with `principal: None`
|
||||
fail the check, which is correct.
|
||||
|
||||
### Trait split: `OutboxRepo` vs `OutboxWriter`
|
||||
orchestrator-core can't depend on manager-core (would invert the
|
||||
dependency arrow). Defined a small `OutboxWriter` trait in
|
||||
`picloud-shared` with a single `enqueue_http` method.
|
||||
`PostgresOutboxRepo` implements both `OutboxRepo` (dispatcher
|
||||
surface) and `OutboxWriter` (orchestrator surface); the picloud
|
||||
binary clones one concrete Arc into both trait views — mirrors the
|
||||
existing `members_concrete` / `AuthzRepo` pattern.
|
||||
|
||||
### `InboxResolver` lives in shared, `InboxRegistry` in orchestrator-core
|
||||
Same split rationale — the dispatcher (manager-core) only depends on
|
||||
the trait, while the in-process impl lives next to its consumer.
|
||||
Cluster mode (v1.3+) swaps the impl for `LISTEN/NOTIFY` behind the
|
||||
unchanged trait.
|
||||
|
||||
### manager-core now depends on executor-core
|
||||
Previously manager-core only depended on orchestrator-core. The
|
||||
dispatcher needs `ExecRequest`/`ExecResponse`/`ExecError`/
|
||||
`InvocationType` from `executor-core` to build invocation
|
||||
descriptors. This is the transport DTO interpretation of the
|
||||
working-rules "don't reach across `*-core` crates" — DTOs are fine,
|
||||
behaviour is the bright line.
|
||||
|
||||
### Sync HTTP via outbox is the default for the user-routes path
|
||||
The orchestrator's user-route handler is fully on the NATS-style
|
||||
path now — every sync HTTP request writes to the outbox and awaits
|
||||
inbox delivery. Adds ~2-5ms per request per design notes §3 latency
|
||||
budget. `/api/v1/execute/{id}` (the admin/dev bypass) still calls
|
||||
the executor directly since it doesn't need the unified
|
||||
observability — kept for simplicity and admin tooling speed.
|
||||
|
||||
### Trigger-depth check is on the outbox row, not in the executor
|
||||
Dispatcher rejects depth-exceeded rows **before** trying to
|
||||
execute. The `cx.trigger_depth` field is informational on the
|
||||
executor side. Rejection writes a log + (reserved) metric and
|
||||
deletes the row — no DL, per design notes §4.
|
||||
|
||||
## 4. Tests added
|
||||
|
||||
### Unit tests (no DB required)
|
||||
- `manager-core::kv_service::tests` (10 tests) — round-trip, missing
|
||||
key returns None, `has` predicate, `delete` was-present,
|
||||
empty-collection rejection, **cross-app isolation**, anonymous-cx
|
||||
skips authz, authed-cx-with-no-role is Forbidden, owner-can-write,
|
||||
cursor pagination via in-memory KvRepo + denying authz repo.
|
||||
- `manager-core::trigger_config::tests` (2 tests) — conservative
|
||||
defaults, backoff round-trips.
|
||||
- `manager-core::trigger_repo::tests` (1 test) — `collection_matches`
|
||||
glob behaviour (`*`, `prefix:*`, exact).
|
||||
- `manager-core::dispatcher::tests` (5 tests) — exponential / linear /
|
||||
constant backoff math, jitter within bounds, ExecError →
|
||||
InboxFailureKind classification, failure-kind → status-code mapping.
|
||||
- `manager-core::abandoned_repo::tests` (2 tests) — truncate
|
||||
char-boundary safety.
|
||||
- `manager-core::triggers_api::tests` (5 tests) — unknown-app 404,
|
||||
member-without-role 403, default fallback for retry settings,
|
||||
empty-glob rejection, cross-app delete is treated as not-found.
|
||||
- `orchestrator-core::inbox::tests` (4 tests) — register/deliver
|
||||
round-trip, unknown-id is Abandoned, dropped receiver is
|
||||
Abandoned, explicit cancel removes sender.
|
||||
- `executor-core::engine::tests` (3 new) — `ctx.event` absent for
|
||||
direct invocations, KV insert shape matches design notes,
|
||||
KV delete has unit value.
|
||||
- `executor-core::sdk_kv` integration suite (7 tests) — runs a real
|
||||
Rhai engine under `spawn_blocking` against an in-memory
|
||||
`KvService` impl. Covers handle pattern, round-trip, unit-on-
|
||||
missing, has predicate, delete-was-present, empty-collection
|
||||
throws, cursor pagination, **cross-app isolation through the
|
||||
bridge**.
|
||||
|
||||
**Total: 47 new tests across the workspace.** Workspace test counts
|
||||
after v1.1.1: 63 manager-core / 56 orchestrator-core / 17
|
||||
executor-core engine / 7 sdk_kv / 30 sdk_contract / 43 stdlib /
|
||||
21 picloud / 6 shared.
|
||||
|
||||
### Intentionally untested
|
||||
- DB-backed integration tests for the full dispatcher loop, KV→
|
||||
trigger→DL retry chain, sync HTTP via outbox round-trip,
|
||||
recursion-stop end-to-end. These need a real Postgres harness;
|
||||
the reviewer runs them via the manual smoke flow below.
|
||||
- Postgres-specific repo behaviour (sqlx query correctness). The
|
||||
repos compile and run against the schema, but no integration
|
||||
test crate spins up a DB in this branch — same pattern as v1.1.0
|
||||
(see existing `ignored, needs DATABASE_URL` test markers).
|
||||
|
||||
## 5. Open questions for the reviewer
|
||||
|
||||
1. **Outbox `claimed_at` clearing on success.** The dispatcher
|
||||
`delete`s the outbox row after success / DL. For failures it
|
||||
reschedules (which sets `claimed_at = NULL`). Both flows are
|
||||
correct, but if you imagine a crash between the executor return
|
||||
and the row update, the row stays claimed forever. Cluster mode
|
||||
should add a periodic "unstick stale claims" sweep. Not in
|
||||
v1.1.1 scope but worth surfacing.
|
||||
2. **Sync HTTP overhead.** Every sync HTTP request now goes through
|
||||
the outbox (write + dispatcher pickup + inbox delivery).
|
||||
Measured overhead expected ~2-5ms per design notes §3. No
|
||||
benchmarking yet — recommend the reviewer pick a representative
|
||||
script and compare 95p latency vs v1.1.0 if performance matters.
|
||||
3. **HTTP outbox rows don't run as a principal.** The orchestrator's
|
||||
public HTTP path has no authenticated user; the
|
||||
`origin_principal` field on the outbox row is forensic. The
|
||||
resulting `ExecRequest.principal = None`, so the script runs
|
||||
anonymously — matches direct execution. If you'd prefer
|
||||
triggered-from-HTTP scripts to inherit a derived principal
|
||||
(e.g. the route's app's owner), that's an additive change.
|
||||
4. **Dispatcher uses `ASYNC_EXEC_TIMEOUT = 300s` for async rows.**
|
||||
Async dispatches don't have a script-level timeout (no
|
||||
originating HTTP request to bound). Picked the same platform
|
||||
cap as `LocalExecutorClient`. If async needs a different cap,
|
||||
easy to thread through `TriggerConfig`.
|
||||
5. **Dispatcher tick cadence is 100ms.** Bounded enough that
|
||||
fan-out feels instant; loose enough that an idle process
|
||||
doesn't burn cycles. If the reviewer wants tighter latency,
|
||||
bump to 50ms or use `LISTEN/NOTIFY` for wake-up (v1.3+ work).
|
||||
6. **CHANGELOG.md is new.** Followed the rest of the repo's
|
||||
convention from git log (release commits + design-notes
|
||||
references). If a different format is preferred, easy to swap.
|
||||
|
||||
## 6. Deferred to later releases
|
||||
|
||||
- `dead_letters::list(filter)` Rhai SDK — design notes §4 defers
|
||||
to v1.2 to align with `docs::find()` query DSL.
|
||||
- KV TTL (`set(k, v, ttl_secs)`) — blueprint reserved it; v1.1.1
|
||||
SDK doesn't surface it. Forward-compat (no schema cost).
|
||||
- Auto-disable of triggers whose script was deleted — design notes
|
||||
§4 says current handling is metric+log; auto-disable is v1.2.
|
||||
- Per-app dead-letter retention — design notes §4 says env-only in
|
||||
v1.1.1.
|
||||
- Metrics counter emit for `picloud_trigger_depth_exceeded`,
|
||||
`picloud_dead_letter_handler_failures`,
|
||||
`picloud_abandoned_executions_total`. Code paths log the
|
||||
occurrences with `tracing::warn`/`error`; the actual
|
||||
counter-emit code is a `TODO(metrics)` comment in the
|
||||
dispatcher. Metrics surface is v1.1.7+ per the roadmap.
|
||||
- DB-backed integration tests for the dispatcher loop (see §4
|
||||
intentionally-untested).
|
||||
- Sync HTTP performance benchmarks comparing v1.1.0 direct path vs
|
||||
v1.1.1 outbox path.
|
||||
|
||||
## 7. How to verify locally
|
||||
|
||||
### Static checks (all green on this branch)
|
||||
```sh
|
||||
cargo fmt --all -- --check
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace
|
||||
cd dashboard && npm run check && npm run build
|
||||
```
|
||||
|
||||
### Migration integrity
|
||||
```sh
|
||||
docker compose down -v && docker compose up -d postgres
|
||||
cargo run -p picloud # applies 0001..0012 from empty
|
||||
```
|
||||
Then start from `main` (v1.1.0 schema state) and switch to this
|
||||
branch; restart `picloud` to apply 0007..0012 on top.
|
||||
|
||||
### Manual end-to-end smoke (reviewer should run)
|
||||
```sh
|
||||
docker compose up -d
|
||||
# 1. Bootstrap an owner user via the existing flow + create app A.
|
||||
# 2. Create a script in A whose body is: throw "boom"
|
||||
# 3. POST /api/v1/admin/apps/{A}/triggers/kv with
|
||||
# {"script_id": "<broken>", "collection_glob": "*", "ops": ["insert"]}
|
||||
# 4. From another script (or a public HTTP route):
|
||||
# kv::collection("widgets").set("k1", #{n:1})
|
||||
# 5. Wait ~7 seconds (3 attempts × ~1/2/4s backoff with ±20% jitter).
|
||||
# 6. Open the dashboard at /admin.
|
||||
# 7. Apps list shows a red "1" badge next to app A.
|
||||
# 8. Click into app A → "Dead letters" tab link → row visible.
|
||||
# 9. Click row → full payload + error history.
|
||||
# 10. Click "Replay" → row marks resolution='replayed', new outbox
|
||||
# row written, dispatcher re-runs the handler (fails again,
|
||||
# produces a NEW DL row).
|
||||
# 11. Click "Mark resolved" on the original DL → resolution='ignored'.
|
||||
```
|
||||
|
||||
### Async route smoke
|
||||
```sh
|
||||
# Create a route via POST /api/v1/admin/scripts/{id}/routes with
|
||||
# {"host_kind":"any","path_kind":"exact","path":"/work","dispatch_mode":"async"}
|
||||
curl -X POST -d '{"work":"thing"}' http://localhost:8080/work
|
||||
# Expect: HTTP 202 + {"accepted_at":"...","execution_id":"..."}
|
||||
# Then tail execution_logs — the script ran later (not synchronously).
|
||||
```
|
||||
|
||||
### Trigger-depth limit smoke
|
||||
```sh
|
||||
# Set a low depth limit + register a KV trigger whose script
|
||||
# writes to KV again — creates a loop.
|
||||
PICLOUD_MAX_TRIGGER_DEPTH=3 cargo run -p picloud
|
||||
# kv.set(...) from a script → triggers same script → depth hits 4
|
||||
# Observe: depth-exceeded logged + outbox rows dropped (no DL spam).
|
||||
```
|
||||
|
||||
## 8. Known limitations / rough edges
|
||||
|
||||
- **No DB-backed integration tests in this branch.** Unit tests
|
||||
cover trait behaviour with in-memory backings; sqlx query
|
||||
correctness is verified by the workspace compile + manual smoke.
|
||||
- **Dispatcher concurrency is in-process serial-per-tick.** Up to
|
||||
8 rows claimed per tick, processed one at a time. Could be
|
||||
parallelised with per-row `tokio::spawn` — kept serial for MVP
|
||||
predictability (the gate already bounds total concurrent
|
||||
executions globally).
|
||||
- **Metric emission is TODO** at the three spots noted in
|
||||
Open Questions §5. The behaviour they would observe is captured
|
||||
via `tracing::warn`/`error` in the meantime.
|
||||
- **`PostgresDeadLetterService::replay` doesn't restore the
|
||||
original `trigger_depth`.** Replays start at depth 0. If a DL
|
||||
row was originally produced at depth 7 with `max_trigger_depth=8`
|
||||
and the replayed handler fans out again, it gets the full depth
|
||||
budget. Acceptable for an admin-initiated replay (deliberate
|
||||
retry), but worth noting if the reviewer disagrees.
|
||||
- **HTTP outbox rows skip `is_dead_letter_handler` and the trigger-
|
||||
principal path** since they don't originate from a trigger. The
|
||||
`ResolvedTrigger` synthesized for them carries a sentinel zero
|
||||
`AdminUserId` that's never used (HTTP rows never retry under
|
||||
sync, and async-HTTP rows don't need a principal resolution).
|
||||
- **DataPlaneState's executor field is still generic** (`Arc<E>`
|
||||
where `E: ExecutorClient`). The dispatcher uses `Arc<dyn
|
||||
ExecutorClient>` directly. The picloud binary clones the same
|
||||
`Arc<LocalExecutorClient>` into both — works because the
|
||||
concrete type implements both the trait object and the generic
|
||||
bound.
|
||||
- **dispatcher always sets `principal: None` for HTTP rows.** As
|
||||
noted in Open Question §3, HTTP outbox rows don't resolve a
|
||||
principal. Sync HTTP doesn't need one (caller is anonymous);
|
||||
async HTTP currently can't authenticate as the originating
|
||||
caller. If that's not the intent, additive change.
|
||||
- **Cluster-mode crash recovery for claimed rows.** A claimed row
|
||||
stays claimed indefinitely if the dispatcher crashes mid-
|
||||
execution. v1.1.1 has one dispatcher per process so this is
|
||||
rare; cluster mode (v1.3+) needs a stale-claim sweeper.
|
||||
|
||||
---
|
||||
|
||||
Branch ready for review. Reviewer reads this report + audits the
|
||||
diff. Do not merge to main until the audit clears.
|
||||
151
REVIEW.md
Normal file
151
REVIEW.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# v1.1.1 Audit & Review
|
||||
|
||||
**Branch:** `feat/v1.1.1-storage-and-events`
|
||||
**Base:** `main` (v1.1.0)
|
||||
**Commits ahead:** 12
|
||||
**Audited by:** reviewer (this report)
|
||||
**Audited against:** `docs/v1.1.x-design-notes.md` §1–4 (Decided 2026-06-01) + the original v1.1.1 dispatch prompt
|
||||
|
||||
## Verdict
|
||||
|
||||
**APPROVE — ready to merge to `main` as v1.1.1.**
|
||||
|
||||
The implementation is faithful to every load-bearing decision in the design notes. Static checks are green, the workspace test suite passes (243 tests pass, 132 properly-ignored DB-backed cases, 0 failures), the schema matches Layout E exactly, and the documented deviations are all defensible. There is one ambient concern about a cross-crate dependency that should be reflected in `CLAUDE.md` after the merge, but it is not a merge blocker.
|
||||
|
||||
---
|
||||
|
||||
## 1. Static checks reproduced
|
||||
|
||||
```
|
||||
cargo fmt --all -- --check ✅ clean
|
||||
cargo clippy --all-targets --all-features -- -D warnings ✅ no findings
|
||||
cargo test --workspace ✅ 243 passed / 0 failed
|
||||
(132 ignored — DB-backed integration tests,
|
||||
same convention as v1.1.0; documented in HANDBACK §4)
|
||||
```
|
||||
|
||||
Test distribution per crate matches HANDBACK §4:
|
||||
- manager-core: 63
|
||||
- orchestrator-core: 56
|
||||
- stdlib: 43
|
||||
- sdk_contract: 30
|
||||
- picloud: 21
|
||||
- executor-core (engine): 17
|
||||
- sdk_kv: 7
|
||||
- shared: 6
|
||||
|
||||
47 of these are new in v1.1.1; the rest are v1.1.0's existing suite still passing.
|
||||
|
||||
## 2. Design-notes conformance (spot-checks)
|
||||
|
||||
| Decision | Where it lives | Verdict |
|
||||
|---|---|---|
|
||||
| Layout E trigger storage (parent + per-kind detail) | [0008_triggers.sql:22-72](crates/manager-core/migrations/0008_triggers.sql#L22-L72) | ✅ matches exactly; parent has common columns + the four retry/dispatch knobs + `registered_by_principal`; per-kind detail tables for `kv` and `dead_letter` only |
|
||||
| `routes` stays separate from `triggers` parent | [0012_routes_dispatch_mode.sql](crates/manager-core/migrations/0012_routes_dispatch_mode.sql), [0009_outbox.sql:13-18](crates/manager-core/migrations/0009_outbox.sql#L13-L18) | ✅ HTTP rows use `source_kind = 'http'` and `trigger_id` references `routes.id`; non-HTTP references `triggers.id`; polymorphism in Rust per the design-notes deferral of the column-set refinement |
|
||||
| Sync HTTP via outbox + NATS-style inbox | [inbox.rs:30-89](crates/orchestrator-core/src/inbox.rs#L30-L89), [dispatcher.rs:359-394](crates/manager-core/src/dispatcher.rs#L359-L394) | ✅ `oneshot::Sender<InboxResult>` keyed by inbox_id; `deliver()` returns `Delivered` or `Abandoned` exactly per the design-notes failure-mode table |
|
||||
| `reply_to.is_some()` never retries | [dispatcher.rs:376-394](crates/manager-core/src/dispatcher.rs#L376-L394) | ✅ failure path checks `reply_to` first; delivers single outcome to inbox; deletes outbox row regardless of error |
|
||||
| Status code table (422/502/503/504/507/500) | [dispatcher.rs:555-564](crates/manager-core/src/dispatcher.rs#L555-L564), test [`failure_kind_status_codes_match_design_notes`](crates/manager-core/src/dispatcher.rs#L674) | ✅ exact mapping; covered by a dedicated test |
|
||||
| `dispatch_mode = async` returns `202 Accepted` + JSON body | [api.rs:325-332](crates/orchestrator-core/src/api.rs#L325-L332) | ✅ body shape is `{"accepted_at": rfc3339, "execution_id": uuid}` — matches design notes §2 verbatim |
|
||||
| Default retry: 3/exp/1000ms/±20% jitter | [trigger_config.rs](crates/manager-core/src/trigger_config.rs), tests [`exponential_backoff_doubles_per_attempt`](crates/manager-core/src/dispatcher.rs#L621), [`jitter_within_pct_of_base`](crates/manager-core/src/dispatcher.rs#L647) | ✅ env-overridable; jitter test exercises the ±20% bound across 100 samples |
|
||||
| `abandoned_executions` written on dropped receiver | [dispatcher.rs:480-509](crates/manager-core/src/dispatcher.rs#L480-L509) | ✅ written only when `InboxDeliveryOutcome::Abandoned` returns; ordinary timeout-with-receiver-still-alive does not write a row |
|
||||
| Dead-letter recursion stop (flag on execution) | [dispatcher.rs:396-425](crates/manager-core/src/dispatcher.rs#L396-L425), [trigger_repo.rs `TriggerKind::DeadLetter` → `is_dead_letter_handler`](crates/manager-core/src/dispatcher.rs#L228-L229) | ✅ flag set when dispatcher resolves a `kind = 'dead_letter'` trigger; on failure, original DL annotated with `resolution = 'handler_failed'`, row deleted, never retried, never DL'd |
|
||||
| Sync HTTP failures do NOT dead-letter | [dispatcher.rs:378-394](crates/manager-core/src/dispatcher.rs#L378-L394) | ✅ early return before the DL-write block |
|
||||
| `dead_letters::list` NOT shipped (deferred to v1.2) | [executor-core/src/sdk/dead_letters.rs:13](crates/executor-core/src/sdk/dead_letters.rs#L13) | ✅ explicit doc-comment citing design notes §4; only `replay` + `resolve` registered |
|
||||
| Trigger execution runs as registrant's principal | [dispatcher.rs:249-253](crates/manager-core/src/dispatcher.rs#L249-L253) + [`registered_by_principal` column](crates/manager-core/migrations/0008_triggers.sql#L39) | ✅ principal resolved from the trigger row at dispatch time |
|
||||
| 30-day DL retention, env-overridable | [gc.rs](crates/manager-core/src/gc.rs) | ✅ |
|
||||
| 7-day abandoned-executions retention | [gc.rs](crates/manager-core/src/gc.rs) | ✅ |
|
||||
| Trigger-depth limit (default 8); depth-exceeded does NOT dead-letter | [dispatcher.rs:122-137](crates/manager-core/src/dispatcher.rs#L122-L137) | ✅ design-notes §4 honored ("depth-exceeded means you built a loop") — row dropped + logged, no DL spam |
|
||||
| Dashboard surface: badge + list view + Replay + Mark resolved | [dashboard/src/routes/apps/+page.svelte](dashboard/src/routes/apps/+page.svelte), [dashboard/src/routes/apps/\[slug\]/dead-letters/+page.svelte](dashboard/src/routes/apps/[slug]/dead-letters/+page.svelte) | ✅ all required columns + actions + expandable row detail; `npm run check` reports 0 errors |
|
||||
| Status: workspace 1.1.0 → 1.1.1, SDK 1.1 → 1.2, dashboard 0.6.0 → 0.7.0, CHANGELOG.md created | last commit `66b661f` | ✅ |
|
||||
| `ctx.event` shape (KV: source/op/collection/key/value; DL: original/attempts/last_error/ids/timestamps) | [shared/src/trigger_event.rs](crates/shared/src/trigger_event.rs), [executor-core engine tests](crates/executor-core/src/engine.rs) | ✅ matches design notes §4 shape exactly; tests verify both variants + the "absent for direct invocations" rule |
|
||||
|
||||
I sampled the design-notes diff (`git diff main..HEAD -- docs/v1.1.x-design-notes.md`) — every "Decided 2026-06-01" entry the agent absorbed into commit `1efb350` matches the decisions made in conversation. No drift.
|
||||
|
||||
## 3. Deviations from the prompt (all reviewed, all acceptable)
|
||||
|
||||
The HANDBACK's §3 lists nine deviations / mid-implementation decisions. My take on each:
|
||||
|
||||
1. **Outbox column set chosen** (`script_id`, `trigger_id` polymorphic, `claimed_by TEXT`, `trigger_depth`, `root_execution_id` denormalized; no `is_dead_letter_handler` column). The design notes explicitly deferred this set to implementation. The chosen shape is sensible: dispatcher can build `ExecRequest` without re-joining; the `is_dead_letter_handler` derivation from `triggers.kind` at dispatch time is cleaner than storing redundant state. ✅
|
||||
|
||||
2. **KV pagination is cursor-style** (base64-encoded last-key, 100 default / 1000 max). The prompt left this open; cursor-style is the right default for KV-shaped data. ✅
|
||||
|
||||
3. **KV TTL deferred**. Blueprint §8.1 reserved `expires_at` but v1.1.1 SDK doesn't surface TTL. Omitting the column from migration 0007 keeps the schema minimal; adding it later is a non-breaking forward migration. ✅ (CHANGELOG records the deferral.)
|
||||
|
||||
4. **Authz scope mapping** (4 new capabilities mapped to existing 7 scopes — `AppKvRead → script:read`, `AppKvWrite → script:write`, `AppManageTriggers → app:admin`, `AppDeadLetterManage → app:admin`). The "seven-scope commitment" is a project convention in `crates/shared/src/auth.rs:103` the prompt didn't mention; honoring it is correct. The specific mapping is defensible: a token with `script:read` on an app already implies "can see the data behind those scripts," and admin-level scope for trigger/DL management is standard for control-plane operations. ✅
|
||||
|
||||
5. **Script-as-gate authz** (`if cx.principal.is_some()` then check; else skip — public HTTP runs anonymously without an authz failure). This matches the SDK-shape doc's note that "the data plane is unauthenticated by default — public HTTP scripts run with `None`." Cross-app isolation is preserved regardless (every query keyed by `cx.app_id`). DL replay/resolve correctly bypasses this and hard-requires a principal. ✅
|
||||
|
||||
6. **Trait split `OutboxRepo` vs `OutboxWriter`**. Orchestrator-core can't depend on manager-core; the small `OutboxWriter` trait in shared (one method) lets the orchestrator enqueue HTTP rows without inverting the dependency arrow. ✅ Pattern mirrors the existing `members_concrete`/`AuthzRepo` split.
|
||||
|
||||
7. **`InboxResolver` in shared, `InboxRegistry` in orchestrator-core**. Same split rationale. Cluster mode (v1.3+) swaps the impl behind the unchanged trait. ✅
|
||||
|
||||
8. **manager-core now depends on executor-core**. ⚠️ **See §4 below — flagged, accepted, but should be reflected in `CLAUDE.md`.**
|
||||
|
||||
9. **Sync HTTP via outbox is the default for user routes** (admin bypass `/api/v1/execute/{id}` keeps direct dispatch). Matches the design-notes decision; the bypass's direct path is acceptable for admin tooling speed. ✅
|
||||
|
||||
## 4. The one concern worth surfacing: manager-core → executor-core
|
||||
|
||||
`CLAUDE.md` working rules say:
|
||||
|
||||
> Honor the three-service boundary. Don't reach across `*-core` crates. If
|
||||
> orchestrator-core needs something from manager-core, define a trait in
|
||||
> shared and inject the impl.
|
||||
|
||||
The dispatcher in manager-core directly imports `ExecRequest`, `ExecResponse`, `ExecError`, and `InvocationType` from `executor-core`:
|
||||
|
||||
```rust
|
||||
// crates/manager-core/src/dispatcher.rs:27
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
```
|
||||
|
||||
The HANDBACK justifies this as "DTOs vs behavior — types are fine, behavior is the bright line." That's a defensible interpretation, but not what `CLAUDE.md` actually says.
|
||||
|
||||
**Two options the project can pick:**
|
||||
|
||||
- **(a) Accept the dependency and update `CLAUDE.md`** to clarify that the three-service boundary is about *behavior*, not *types* — `ExecRequest`/`ExecResponse`/`ExecError` are transport DTOs and crossing the wire is normal. This is the lower-friction choice and matches how the agent's instincts ran.
|
||||
- **(b) Refactor**: move `ExecRequest`/`ExecResponse`/`ExecError`/`InvocationType` to `shared`. About 200 lines of moves; would land cleanly as a follow-up PR.
|
||||
|
||||
**My recommendation: (a)**. The dispatcher genuinely needs to construct and interpret these types, and they're the natural "what the executor produces" surface — burying them in shared makes the executor's public API less self-contained. But the rule as currently written disagrees; we should pick one explicitly.
|
||||
|
||||
This is **not a merge blocker** for v1.1.1 — the implementation already exists and works. The CLAUDE.md update can land as a small commit on `main` after the merge.
|
||||
|
||||
## 5. Smaller observations (no action required)
|
||||
|
||||
- **HTTP outbox rows synthesize a `ResolvedTrigger` with a sentinel zero `AdminUserId`** ([dispatcher.rs:342](crates/manager-core/src/dispatcher.rs#L342)). The HANDBACK flags this as a code smell; I agree, but the cleaner shape (`enum DispatchTarget { Trigger(ResolvedTrigger), Http(HttpRoute) }`) is a refactor that doesn't belong in v1.1.1. Worth doing in v1.1.2 alongside the docs work since the dispatcher will gain another trigger kind.
|
||||
- **Triggers parent `dispatch_mode` defaults to `'async'`** ([0008_triggers.sql:30](crates/manager-core/migrations/0008_triggers.sql#L30)) with `sync` allowed by the CHECK constraint but unsupported in v1.1.1 (sync trigger would mean firing inline with the originating mutation, which we don't do). The migration comment captures this; worth a future commit to either remove `'sync'` from the CHECK or use it for an `inline_pre_mutate` semantics if it ever makes sense. Not v1.1.1's problem.
|
||||
- **Metric counters are TODO** at three call sites (`picloud_trigger_depth_exceeded`, `picloud_dead_letter_handler_failures`, `picloud_abandoned_executions_total`). The events are logged via `tracing::warn`/`error` in the meantime. Per the prompt and roadmap, metrics surface is v1.1.7+. ✅
|
||||
- **Dispatcher tick cadence is 100ms with `CLAIM_BATCH = 8`**, serial per tick. The ExecutionGate bounds total concurrent executions globally, so parallelism within a tick is purely an optimization. Reasonable MVP choice; can parallelize later without changing semantics.
|
||||
- **Open Q1 in HANDBACK (claimed-rows-stuck-on-crash)** is a real cluster-mode concern, correctly out-of-scope for v1.1.1 (single dispatcher per process). Cluster mode adds a stale-claim sweeper — track for v1.3+.
|
||||
- **Open Q3 in HANDBACK (HTTP-triggered scripts run with `principal: None`)** is correct as-is. The "trigger executions inherit the registrant's principal" decision applies to triggers; HTTP routes have no registrant in that sense. Public HTTP is anonymous by design.
|
||||
|
||||
## 6. Versioning audit
|
||||
|
||||
| File | Before | After | Status |
|
||||
|---|---|---|---|
|
||||
| Workspace `Cargo.toml` (workspace.package.version) | 1.1.0 | 1.1.1 | ✅ |
|
||||
| SDK schema version (`shared/src/version.rs`) | 1.1 | 1.2 | ✅ correctly bumped — the SDK surface added `KvService` + `DeadLetterService` + `TriggerEvent` |
|
||||
| Dashboard `package.json` | 0.6.0 | 0.7.0 | ✅ |
|
||||
| Migrations | 0001..0006 | 0007..0012 added | ✅ sequential, no skips |
|
||||
| CHANGELOG.md | not present | created at repo root | ✅ first entry covers v1.1.1 |
|
||||
|
||||
## 7. Manual smoke recommendation
|
||||
|
||||
The reviewer (you) does **not** need to run the manual end-to-end smoke before merging — the automated tests + the static review above cover the contracts. The smoke flow in HANDBACK §7 is worth running **after merge** as a release-validation step before tagging `v1.1.1` (if the project tags releases). Specifically:
|
||||
|
||||
1. `docker compose up -d` (fresh DB)
|
||||
2. `cargo run -p picloud`
|
||||
3. Create app + script-that-throws + KV trigger
|
||||
4. Trigger a KV write → wait ~7s → confirm DL row appears
|
||||
5. Dashboard: red badge on apps list, list view shows the row, Replay creates a new outbox row + dispatcher re-runs, Mark resolved sets `resolution = 'ignored'`
|
||||
6. Async route test: `POST /work` with `dispatch_mode=async` route → expect 202 + JSON body
|
||||
|
||||
If any of those misbehave post-merge, revert is straightforward (12 commits, ahead of main, no dependencies have pulled changes yet).
|
||||
|
||||
## 8. Recommended next steps (post-merge)
|
||||
|
||||
1. **Merge** `feat/v1.1.1-storage-and-events` into `main` (fast-forward; branch is linear ahead).
|
||||
2. **Tag** `v1.1.1` if release tagging is the project convention (git log shows v1.1.0 had a release commit but I didn't see a tag — confirm with the project owner).
|
||||
3. **Small CLAUDE.md update** clarifying the three-service boundary's scope (types crossing is fine; behavior crossing is what's prohibited). One-paragraph change.
|
||||
4. **Pause** before dispatching the v1.1.2 (Documents) agent — the v1.1.1 work shipped substantial infrastructure that v1.1.2 will lean on, and there may be small lessons from the v1.1.1 implementation to fold into the v1.1.2 prompt (e.g., reaffirming the "manager-core depends on executor-core for DTOs" pattern explicitly so the docs agent doesn't second-guess it).
|
||||
|
||||
Branch is ready for merge. Verdict: **APPROVE**.
|
||||
@@ -14,7 +14,18 @@ picloud-shared.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
rhai.workspace = true
|
||||
|
||||
# Stdlib utility modules — see crates/executor-core/src/sdk/stdlib/.
|
||||
regex.workspace = true
|
||||
rand.workspace = true
|
||||
base64.workspace = true
|
||||
hex.workspace = true
|
||||
percent-encoding.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait.workspace = true
|
||||
|
||||
@@ -3,30 +3,40 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{ScriptValidator, ValidationError, SDK_VERSION};
|
||||
use picloud_shared::{
|
||||
ScriptValidator, SdkCallCx, Services, TriggerEvent, ValidationError, SDK_VERSION,
|
||||
};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
|
||||
use serde_json::Value as Json;
|
||||
|
||||
use crate::sandbox::Limits;
|
||||
use crate::sdk;
|
||||
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
use crate::types::{
|
||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||
};
|
||||
|
||||
/// Preconfigured Rhai engine with sandbox limits applied.
|
||||
/// Preconfigured Rhai engine with sandbox limits applied and the SDK
|
||||
/// `Services` bundle attached.
|
||||
///
|
||||
/// One `Engine` is constructed at process startup and reused across
|
||||
/// invocations. `execute` is **synchronous** — it owns the per-call
|
||||
/// scope and log buffer. Wall-clock timeouts and offloading off the
|
||||
/// async runtime belong to the caller (orchestrator-core's
|
||||
/// `LocalExecutorClient` wraps this with `spawn_blocking` + `timeout`).
|
||||
///
|
||||
/// The `Services` bundle is empty in v1.1.0; subsequent v1.1.x PRs add
|
||||
/// service handles (KV, docs, …) and `sdk::register_all` wires them
|
||||
/// into each per-call Rhai engine.
|
||||
pub struct Engine {
|
||||
limits: Limits,
|
||||
services: Services,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
#[must_use]
|
||||
pub fn new(limits: Limits) -> Self {
|
||||
Self { limits }
|
||||
pub fn new(limits: Limits, services: Services) -> Self {
|
||||
Self { limits, services }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
@@ -55,7 +65,22 @@ impl Engine {
|
||||
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
|
||||
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
|
||||
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let engine = build_engine(effective_limits, Some(logs.clone()));
|
||||
let mut engine = build_engine(effective_limits, Some(logs.clone()));
|
||||
|
||||
// Per-call context handed to every stateful SDK service via the
|
||||
// `sdk::register_all` hook. The Arc lets future service closures
|
||||
// capture cheap clones of the cx for use at script-call time.
|
||||
let cx = Arc::new(SdkCallCx {
|
||||
app_id: req.app_id,
|
||||
principal: req.principal.clone(),
|
||||
execution_id: req.execution_id,
|
||||
request_id: req.request_id,
|
||||
trigger_depth: req.trigger_depth,
|
||||
root_execution_id: req.root_execution_id,
|
||||
is_dead_letter_handler: req.is_dead_letter_handler,
|
||||
event: req.event.clone(),
|
||||
});
|
||||
sdk::register_all(&mut engine, &self.services, cx);
|
||||
|
||||
let ast = engine
|
||||
.compile(source)
|
||||
@@ -122,6 +147,11 @@ fn build_engine(limits: Limits, logs: Option<Arc<Mutex<Vec<LogEntry>>>>) -> Rhai
|
||||
engine.register_static_module("log", build_log_module(logs).into());
|
||||
}
|
||||
|
||||
// Stateless utility modules — regex::/random::/time::/json::/base64::/
|
||||
// hex::/url::. Always registered, including in the parse-only validate
|
||||
// path, so script authors get consistent surface in both phases.
|
||||
sdk::stdlib::register_stdlib(&mut engine);
|
||||
|
||||
engine
|
||||
}
|
||||
|
||||
@@ -213,9 +243,82 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
|
||||
request.insert("rest".into(), req.rest.clone().into());
|
||||
|
||||
ctx.insert("request".into(), request.into());
|
||||
|
||||
// Triggered invocations: surface the originating event as
|
||||
// `ctx.event`. Direct ingress (HTTP request, manual run) leaves
|
||||
// the key absent so scripts can test `if "event" in ctx`.
|
||||
if let Some(event) = req.event.as_ref() {
|
||||
ctx.insert("event".into(), trigger_event_to_dynamic(event));
|
||||
}
|
||||
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Convert a `TriggerEvent` into the `ctx.event` Rhai shape defined in
|
||||
/// `docs/v1.1.x-design-notes.md` §4 (the dead-letter sub-shape) and
|
||||
/// §2/blueprint §9 (KV). Each variant becomes a Rhai map with a
|
||||
/// `source` discriminant plus per-source fields.
|
||||
fn trigger_event_to_dynamic(event: &TriggerEvent) -> Dynamic {
|
||||
let mut m = Map::new();
|
||||
m.insert("source".into(), event.source().into());
|
||||
match event {
|
||||
TriggerEvent::Kv {
|
||||
op,
|
||||
collection,
|
||||
key,
|
||||
value,
|
||||
} => {
|
||||
m.insert("op".into(), op.as_str().into());
|
||||
let mut kv_map = Map::new();
|
||||
kv_map.insert("collection".into(), collection.clone().into());
|
||||
kv_map.insert("key".into(), key.clone().into());
|
||||
kv_map.insert(
|
||||
"value".into(),
|
||||
value.clone().map_or(Dynamic::UNIT, json_to_dynamic),
|
||||
);
|
||||
m.insert("kv".into(), kv_map.into());
|
||||
}
|
||||
TriggerEvent::DeadLetter {
|
||||
dead_letter_id,
|
||||
original,
|
||||
attempts,
|
||||
last_error,
|
||||
trigger_id,
|
||||
script_id,
|
||||
first_attempt_at,
|
||||
last_attempt_at,
|
||||
} => {
|
||||
let mut dl = Map::new();
|
||||
dl.insert("id".into(), dead_letter_id.to_string().into());
|
||||
dl.insert("original".into(), trigger_event_to_dynamic(original));
|
||||
dl.insert("attempts".into(), i64::from(*attempts).into());
|
||||
dl.insert("last_error".into(), last_error.clone().into());
|
||||
dl.insert(
|
||||
"trigger_id".into(),
|
||||
trigger_id
|
||||
.map(|id| Dynamic::from(id.to_string()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
dl.insert(
|
||||
"script_id".into(),
|
||||
script_id
|
||||
.map(|id| Dynamic::from(id.to_string()))
|
||||
.unwrap_or(Dynamic::UNIT),
|
||||
);
|
||||
dl.insert(
|
||||
"first_attempt_at".into(),
|
||||
first_attempt_at.to_rfc3339().into(),
|
||||
);
|
||||
dl.insert(
|
||||
"last_attempt_at".into(),
|
||||
last_attempt_at.to_rfc3339().into(),
|
||||
);
|
||||
m.insert("dead_letter".into(), dl.into());
|
||||
}
|
||||
}
|
||||
m.into()
|
||||
}
|
||||
|
||||
fn invocation_type_str(it: InvocationType) -> &'static str {
|
||||
match it {
|
||||
InvocationType::Http => "http",
|
||||
@@ -265,69 +368,6 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>,
|
||||
Ok((status_code, headers, body))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Rhai ↔ serde_json bridges
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn json_to_dynamic(value: Json) -> Dynamic {
|
||||
match value {
|
||||
Json::Null => Dynamic::UNIT,
|
||||
Json::Bool(b) => b.into(),
|
||||
Json::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
i.into()
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
f.into()
|
||||
} else {
|
||||
n.to_string().into()
|
||||
}
|
||||
}
|
||||
Json::String(s) => s.into(),
|
||||
Json::Array(arr) => arr
|
||||
.into_iter()
|
||||
.map(json_to_dynamic)
|
||||
.collect::<Vec<Dynamic>>()
|
||||
.into(),
|
||||
Json::Object(obj) => {
|
||||
let mut m = Map::new();
|
||||
for (k, v) in obj {
|
||||
m.insert(k.into(), json_to_dynamic(v));
|
||||
}
|
||||
Dynamic::from(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dynamic_to_json(value: &Dynamic) -> Json {
|
||||
if value.is_unit() {
|
||||
return Json::Null;
|
||||
}
|
||||
if let Ok(b) = value.as_bool() {
|
||||
return Json::Bool(b);
|
||||
}
|
||||
if let Ok(i) = value.as_int() {
|
||||
return Json::Number(i.into());
|
||||
}
|
||||
if let Ok(f) = value.as_float() {
|
||||
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
|
||||
}
|
||||
if value.is_string() {
|
||||
return Json::String(value.clone().into_string().unwrap_or_default());
|
||||
}
|
||||
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
|
||||
return Json::Array(arr.iter().map(dynamic_to_json).collect());
|
||||
}
|
||||
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, v) in map {
|
||||
out.insert(k.to_string(), dynamic_to_json(&v));
|
||||
}
|
||||
return Json::Object(out);
|
||||
}
|
||||
// Anything else (timestamps, custom types) — best-effort string form.
|
||||
Json::String(value.to_string())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Error mapping
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod context;
|
||||
pub mod engine;
|
||||
pub mod logging;
|
||||
pub mod sandbox;
|
||||
pub mod sdk;
|
||||
pub mod types;
|
||||
|
||||
pub use engine::Engine;
|
||||
|
||||
77
crates/executor-core/src/sdk/bridge.rs
Normal file
77
crates/executor-core/src/sdk/bridge.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
//! JSON ↔ Rhai `Dynamic` value bridge.
|
||||
//!
|
||||
//! Originally inline in `engine.rs`; moved here for v1.1.0 so future
|
||||
//! service modules (KV in v1.1.1, docs in v1.1.2, …) can convert
|
||||
//! values without `engine.rs` being the only owner of the conversions.
|
||||
//! Behaviour is unchanged from the pre-extraction implementation —
|
||||
//! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the
|
||||
//! observable round-trip.
|
||||
|
||||
use rhai::{Dynamic, Map};
|
||||
use serde_json::Value as Json;
|
||||
|
||||
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
|
||||
/// pushing into a script's scope. Numbers prefer the narrowest type
|
||||
/// (`i64` over `f64`); anything that can't round-trip falls back to a
|
||||
/// string so the script always sees a defined value.
|
||||
pub fn json_to_dynamic(value: Json) -> Dynamic {
|
||||
match value {
|
||||
Json::Null => Dynamic::UNIT,
|
||||
Json::Bool(b) => b.into(),
|
||||
Json::Number(n) => {
|
||||
if let Some(i) = n.as_i64() {
|
||||
i.into()
|
||||
} else if let Some(f) = n.as_f64() {
|
||||
f.into()
|
||||
} else {
|
||||
n.to_string().into()
|
||||
}
|
||||
}
|
||||
Json::String(s) => s.into(),
|
||||
Json::Array(arr) => arr
|
||||
.into_iter()
|
||||
.map(json_to_dynamic)
|
||||
.collect::<Vec<Dynamic>>()
|
||||
.into(),
|
||||
Json::Object(obj) => {
|
||||
let mut m = Map::new();
|
||||
for (k, v) in obj {
|
||||
m.insert(k.into(), json_to_dynamic(v));
|
||||
}
|
||||
Dynamic::from(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Rhai `Dynamic` back to a `serde_json::Value`. Custom Rhai
|
||||
/// types (timestamps, user-registered modules) fall back to their
|
||||
/// `Display` form so they appear as strings in JSON output rather than
|
||||
/// failing the response build.
|
||||
pub fn dynamic_to_json(value: &Dynamic) -> Json {
|
||||
if value.is_unit() {
|
||||
return Json::Null;
|
||||
}
|
||||
if let Ok(b) = value.as_bool() {
|
||||
return Json::Bool(b);
|
||||
}
|
||||
if let Ok(i) = value.as_int() {
|
||||
return Json::Number(i.into());
|
||||
}
|
||||
if let Ok(f) = value.as_float() {
|
||||
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
|
||||
}
|
||||
if value.is_string() {
|
||||
return Json::String(value.clone().into_string().unwrap_or_default());
|
||||
}
|
||||
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
|
||||
return Json::Array(arr.iter().map(dynamic_to_json).collect());
|
||||
}
|
||||
if let Some(map) = value.clone().try_cast::<Map>() {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, v) in map {
|
||||
out.insert(k.to_string(), dynamic_to_json(&v));
|
||||
}
|
||||
return Json::Object(out);
|
||||
}
|
||||
Json::String(value.to_string())
|
||||
}
|
||||
10
crates/executor-core/src/sdk/cx.rs
Normal file
10
crates/executor-core/src/sdk/cx.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! Re-export of `picloud_shared::SdkCallCx`.
|
||||
//!
|
||||
//! The type itself lives in `picloud-shared` because future stateful
|
||||
//! service impls live in `manager-core` (which `executor-core` must
|
||||
//! not depend on) and need to reference the same cx shape. This
|
||||
//! re-export lets executor-side code write
|
||||
//! `use picloud_executor_core::sdk::SdkCallCx;` instead of reaching
|
||||
//! into `picloud_shared` for one type.
|
||||
|
||||
pub use picloud_shared::SdkCallCx;
|
||||
84
crates/executor-core/src/sdk/dead_letters.rs
Normal file
84
crates/executor-core/src/sdk/dead_letters.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! `dead_letters::` Rhai bridge.
|
||||
//!
|
||||
//! ```rhai
|
||||
//! dead_letters::replay("01234567-..."); // re-enqueue + mark replayed
|
||||
//! dead_letters::resolve("01234567-...", "ignored"); // close out the row
|
||||
//! ```
|
||||
//!
|
||||
//! Sync↔async via `Handle::current().block_on(...)` — same pattern as
|
||||
//! the `kv::` bridge (works because `LocalExecutorClient` runs the
|
||||
//! script under `spawn_blocking`).
|
||||
//!
|
||||
//! `dead_letters::list(filter)` is intentionally NOT shipped — design
|
||||
//! notes §4 defers it to v1.2 to align with the `docs::find()` query
|
||||
//! DSL.
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{DeadLetterError, DeadLetterId, SdkCallCx, Services};
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.dead_letters.clone();
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"replay",
|
||||
move |id: &str| -> Result<(), Box<EvalAltResult>> {
|
||||
let dl_id = parse_dl_id(id)?;
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.replay(&cx, dl_id).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"resolve",
|
||||
move |id: &str, reason: &str| -> Result<(), Box<EvalAltResult>> {
|
||||
let dl_id = parse_dl_id(id)?;
|
||||
let reason = reason.to_string();
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
block_on(async move { svc.resolve(&cx, dl_id, &reason).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("dead_letters", module.into());
|
||||
}
|
||||
|
||||
fn parse_dl_id(s: &str) -> Result<DeadLetterId, Box<EvalAltResult>> {
|
||||
Uuid::from_str(s)
|
||||
.map(DeadLetterId::from)
|
||||
.map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("dead_letters: invalid id {s:?}: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn block_on<F>(fut: F) -> Result<(), Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<(), DeadLetterError>> + Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("dead_letters: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("dead_letters: {err}").into(), rhai::Position::NONE)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
193
crates/executor-core/src/sdk/kv.rs
Normal file
193
crates/executor-core/src/sdk/kv.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! `kv::` Rhai bridge — collection-scoped handle pattern.
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let widgets = kv::collection("widgets");
|
||||
//! widgets.set("k", #{ n: 1 });
|
||||
//! let v = widgets.get("k"); // value or () if absent
|
||||
//! if widgets.has("k") { ... }
|
||||
//! widgets.delete("k"); // bool (was-present)
|
||||
//! let page = widgets.list(); // returns #{ keys: [...], next_cursor: () }
|
||||
//! ```
|
||||
//!
|
||||
//! The `KvHandle` custom Rhai type captures the collection name once
|
||||
//! and routes each call through the injected `Arc<dyn KvService>` with
|
||||
//! the per-call `Arc<SdkCallCx>`. **The service derives `app_id` from
|
||||
//! `cx.app_id` — `app_id` never appears in any function signature
|
||||
//! script-side, preserving cross-app isolation.**
|
||||
//!
|
||||
//! Sync↔async bridge: Rhai is synchronous; the underlying service is
|
||||
//! async. Closures wrap each call in `Handle::current().block_on(...)`
|
||||
//! — safe because `LocalExecutorClient` runs the script under
|
||||
//! `spawn_blocking`, so a runtime handle is reachable and blocking on
|
||||
//! it doesn't park an async worker.
|
||||
//!
|
||||
//! Error convention (per `docs/sdk-shape.md`):
|
||||
//! - throw on failure (Rhai runtime error string)
|
||||
//! - `()` for absent values (`get` on a missing key)
|
||||
//! - `bool` for predicates (`has`; also `delete` returns was-present)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{KvError, KvService, SdkCallCx, Services};
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
use tokio::runtime::Handle as TokioHandle;
|
||||
|
||||
use super::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
/// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs
|
||||
/// plus an owned string).
|
||||
#[derive(Clone)]
|
||||
pub struct KvHandle {
|
||||
collection: String,
|
||||
service: Arc<dyn KvService>,
|
||||
cx: Arc<SdkCallCx>,
|
||||
}
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let kv_service = services.kv.clone();
|
||||
|
||||
// `kv::collection(name)` — handle constructor lives in the `kv`
|
||||
// static module so the script-visible call is `kv::collection(...)`.
|
||||
let mut module = Module::new();
|
||||
{
|
||||
let kv_service = kv_service.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"collection",
|
||||
move |name: &str| -> Result<KvHandle, Box<EvalAltResult>> {
|
||||
if name.is_empty() {
|
||||
return Err("kv::collection name must not be empty".into());
|
||||
}
|
||||
Ok(KvHandle {
|
||||
collection: name.to_string(),
|
||||
service: kv_service.clone(),
|
||||
cx: cx.clone(),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
engine.register_static_module("kv", module.into());
|
||||
|
||||
// Methods on KvHandle — `register_fn` with `&mut KvHandle` first
|
||||
// argument lets Rhai dispatch them as `handle.get(k)` /
|
||||
// `handle.set(k, v)` / etc. through the dot-notation.
|
||||
engine.register_type_with_name::<KvHandle>("KvHandle");
|
||||
|
||||
register_get(engine);
|
||||
register_set(engine);
|
||||
register_has(engine);
|
||||
register_delete(engine);
|
||||
register_list(engine);
|
||||
}
|
||||
|
||||
fn register_get(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"get",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on(async move { h.service.get(&h.cx, &h.collection, key).await })
|
||||
.map(|opt| opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_set(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"set",
|
||||
|handle: &mut KvHandle, key: &str, value: Dynamic| -> Result<(), Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let json = dynamic_to_json(&value);
|
||||
block_on(async move { h.service.set(&h.cx, &h.collection, key, json).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_has(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"has",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on(async move { h.service.has(&h.cx, &h.collection, key).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_delete(engine: &mut RhaiEngine) {
|
||||
engine.register_fn(
|
||||
"delete",
|
||||
|handle: &mut KvHandle, key: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
block_on(async move { h.service.delete(&h.cx, &h.collection, key).await })
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_list(engine: &mut RhaiEngine) {
|
||||
// Zero-arg form — full page, no cursor.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut KvHandle| -> Result<Map, Box<EvalAltResult>> { list_call(handle, None, 0) },
|
||||
);
|
||||
|
||||
// One-arg form — cursor only.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut KvHandle, cursor: &str| -> Result<Map, Box<EvalAltResult>> {
|
||||
list_call(handle, Some(cursor.to_string()), 0)
|
||||
},
|
||||
);
|
||||
|
||||
// Two-arg form — cursor + limit.
|
||||
engine.register_fn(
|
||||
"list",
|
||||
|handle: &mut KvHandle, cursor: &str, limit: i64| -> Result<Map, Box<EvalAltResult>> {
|
||||
let limit = u32::try_from(limit.max(0)).unwrap_or(0);
|
||||
list_call(handle, Some(cursor.to_string()), limit)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn list_call(
|
||||
handle: &KvHandle,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> Result<Map, Box<EvalAltResult>> {
|
||||
let h = handle.clone();
|
||||
let page = block_on(async move {
|
||||
h.service
|
||||
.list(&h.cx, &h.collection, cursor.as_deref(), limit)
|
||||
.await
|
||||
})?;
|
||||
let mut m = Map::new();
|
||||
let keys: Array = page.keys.into_iter().map(Dynamic::from).collect();
|
||||
m.insert("keys".into(), keys.into());
|
||||
m.insert(
|
||||
"next_cursor".into(),
|
||||
page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from),
|
||||
);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Run an async future inside the synchronous Rhai context.
|
||||
///
|
||||
/// `LocalExecutorClient` wraps script execution in `spawn_blocking`, so
|
||||
/// the current Tokio runtime is reachable via `Handle::current()`. We
|
||||
/// block on it directly; we are NOT calling this from an async task,
|
||||
/// so blocking is the correct primitive (`block_in_place` would also
|
||||
/// work, but we're already on a blocking worker).
|
||||
fn block_on<F, T>(fut: F) -> Result<T, Box<EvalAltResult>>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, KvError>> + Send,
|
||||
T: Send,
|
||||
{
|
||||
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(
|
||||
format!("kv: no tokio runtime available: {e}").into(),
|
||||
rhai::Position::NONE,
|
||||
)
|
||||
.into()
|
||||
})?;
|
||||
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
|
||||
EvalAltResult::ErrorRuntime(format!("kv: {err}").into(), rhai::Position::NONE).into()
|
||||
})
|
||||
}
|
||||
37
crates/executor-core/src/sdk/mod.rs
Normal file
37
crates/executor-core/src/sdk/mod.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
//! SDK plumbing — types and the per-call registration entry point.
|
||||
//!
|
||||
//! `executor-core` is responsible for building the per-invocation Rhai
|
||||
//! engine and wiring stateful services into it. v1.1.0 ships the
|
||||
//! shapes (`Services` bundle, `SdkCallCx`, `register_all` entry point)
|
||||
//! but no actual services — subsequent v1.1.x PRs (KV in v1.1.1,
|
||||
//! docs in v1.1.2, …) extend `register_all` rather than re-threading
|
||||
//! plumbing through `engine.rs`.
|
||||
//!
|
||||
//! Bridge functions (`json_to_dynamic` / `dynamic_to_json`) also live
|
||||
//! here so service modules can convert values without `engine.rs`
|
||||
//! being the only home for the conversion logic.
|
||||
|
||||
pub mod bridge;
|
||||
pub mod cx;
|
||||
pub mod dead_letters;
|
||||
pub mod kv;
|
||||
pub mod stdlib;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
pub use cx::SdkCallCx;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::Services;
|
||||
use rhai::Engine as RhaiEngine;
|
||||
|
||||
/// Single hook every v1.1.x stateful service registers into. Called
|
||||
/// once per invocation, just after `build_engine` constructs the
|
||||
/// sandboxed Rhai engine and just before script compilation.
|
||||
///
|
||||
/// v1.1.1 wires the first stateful service (KV). Subsequent PRs add a
|
||||
/// single `<service>::register(...)` line per service.
|
||||
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
kv::register(engine, services, cx.clone());
|
||||
dead_letters::register(engine, services, cx);
|
||||
}
|
||||
48
crates/executor-core/src/sdk/stdlib/base64.rs
Normal file
48
crates/executor-core/src/sdk/stdlib/base64.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! `base64::` — standard and URL-safe Base64.
|
||||
//!
|
||||
//! Two encoders are exposed: standard alphabet with padding (`encode`/
|
||||
//! `decode`) and URL-safe alphabet without padding (`encode_url`/
|
||||
//! `decode_url`). Each encoder accepts both `String` and `Blob` inputs
|
||||
//! as separate Rhai overloads; decoders always return `Blob` — the
|
||||
//! caller knows whether the original bytes were textual.
|
||||
|
||||
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
|
||||
use base64::Engine as _;
|
||||
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
|
||||
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(STANDARD.encode(s.as_bytes()))
|
||||
});
|
||||
module.set_native_fn("encode", |b: Blob| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(STANDARD.encode(&b))
|
||||
});
|
||||
module.set_native_fn("decode", |s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||
STANDARD
|
||||
.decode(s)
|
||||
.map_err(|e| format!("base64::decode: {e}").into())
|
||||
});
|
||||
|
||||
module.set_native_fn(
|
||||
"encode_url",
|
||||
|s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(URL_SAFE_NO_PAD.encode(s.as_bytes()))
|
||||
},
|
||||
);
|
||||
module.set_native_fn(
|
||||
"encode_url",
|
||||
|b: Blob| -> Result<String, Box<EvalAltResult>> { Ok(URL_SAFE_NO_PAD.encode(&b)) },
|
||||
);
|
||||
module.set_native_fn(
|
||||
"decode_url",
|
||||
|s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||
URL_SAFE_NO_PAD
|
||||
.decode(s)
|
||||
.map_err(|e| format!("base64::decode_url: {e}").into())
|
||||
},
|
||||
);
|
||||
|
||||
engine.register_static_module("base64", module.into());
|
||||
}
|
||||
21
crates/executor-core/src/sdk/stdlib/hex.rs
Normal file
21
crates/executor-core/src/sdk/stdlib/hex.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
//! `hex::` — hexadecimal encode/decode (lowercase output, case-
|
||||
//! insensitive input). String and Blob inputs are both accepted on
|
||||
//! encode; decode always returns `Blob`.
|
||||
|
||||
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
|
||||
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(hex::encode(s.as_bytes()))
|
||||
});
|
||||
module.set_native_fn("encode", |b: Blob| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(hex::encode(&b))
|
||||
});
|
||||
module.set_native_fn("decode", |s: &str| -> Result<Blob, Box<EvalAltResult>> {
|
||||
hex::decode(s).map_err(|e| format!("hex::decode: {e}").into())
|
||||
});
|
||||
|
||||
engine.register_static_module("hex", module.into());
|
||||
}
|
||||
43
crates/executor-core/src/sdk/stdlib/json.rs
Normal file
43
crates/executor-core/src/sdk/stdlib/json.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! `json::` — JSON parse and stringify. Reuses the bridge functions in
|
||||
//! `crate::sdk::bridge` so script-visible JSON has the same shape
|
||||
//! (numbers, maps, arrays, nulls) as `ctx.request.body` already does.
|
||||
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_parse(&mut module);
|
||||
register_stringify(&mut module);
|
||||
register_stringify_pretty(&mut module);
|
||||
engine.register_static_module("json", module.into());
|
||||
}
|
||||
|
||||
fn register_parse(module: &mut Module) {
|
||||
module.set_native_fn("parse", |s: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(s).map_err(|e| format!("json::parse: {e}"))?;
|
||||
Ok(json_to_dynamic(value))
|
||||
});
|
||||
}
|
||||
|
||||
fn register_stringify(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"stringify",
|
||||
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||
serde_json::to_string(&dynamic_to_json(&v))
|
||||
.map_err(|e| format!("json::stringify: {e}").into())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_stringify_pretty(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"stringify_pretty",
|
||||
|v: Dynamic| -> Result<String, Box<EvalAltResult>> {
|
||||
serde_json::to_string_pretty(&dynamic_to_json(&v))
|
||||
.map_err(|e| format!("json::stringify_pretty: {e}").into())
|
||||
},
|
||||
);
|
||||
}
|
||||
25
crates/executor-core/src/sdk/stdlib/mod.rs
Normal file
25
crates/executor-core/src/sdk/stdlib/mod.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
//! Stateless utility modules registered once at engine build via
|
||||
//! `Engine::register_static_module`. They have no per-call state, no
|
||||
//! cross-app sensitivity, and no `SdkCallCx` — distinguishing them
|
||||
//! from stateful service modules (KV, docs, …) which hook into
|
||||
//! `sdk::register_all` instead. See [docs/sdk-shape.md](../../../../../docs/sdk-shape.md).
|
||||
|
||||
use rhai::Engine as RhaiEngine;
|
||||
|
||||
pub mod base64;
|
||||
pub mod hex;
|
||||
pub mod json;
|
||||
pub mod random;
|
||||
pub mod regex;
|
||||
pub mod time;
|
||||
pub mod url;
|
||||
|
||||
pub fn register_stdlib(engine: &mut RhaiEngine) {
|
||||
regex::register(engine);
|
||||
random::register(engine);
|
||||
time::register(engine);
|
||||
json::register(engine);
|
||||
base64::register(engine);
|
||||
hex::register(engine);
|
||||
url::register(engine);
|
||||
}
|
||||
70
crates/executor-core/src/sdk/stdlib/random.rs
Normal file
70
crates/executor-core/src/sdk/stdlib/random.rs
Normal file
@@ -0,0 +1,70 @@
|
||||
//! `random::` — CSPRNG primitives (`rand::rngs::OsRng`).
|
||||
//!
|
||||
//! Only the OS RNG is exposed. No "fast non-crypto" variant — scripts
|
||||
//! should not pick between secure and insecure entropy. Output sizes
|
||||
//! are capped to keep a single script call from blowing host memory.
|
||||
|
||||
use rand::distributions::{Alphanumeric, DistString};
|
||||
use rand::{rngs::OsRng, Rng, RngCore};
|
||||
use rhai::{Blob, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
use uuid::Uuid;
|
||||
|
||||
const MAX_BYTES: i64 = 65_536;
|
||||
const MAX_STRING: i64 = 4_096;
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_int(&mut module);
|
||||
register_float(&mut module);
|
||||
register_bytes(&mut module);
|
||||
register_string(&mut module);
|
||||
register_uuid(&mut module);
|
||||
engine.register_static_module("random", module.into());
|
||||
}
|
||||
|
||||
fn register_int(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"int",
|
||||
|min: i64, max: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||
if min > max {
|
||||
return Err(format!("random::int: min ({min}) > max ({max})").into());
|
||||
}
|
||||
Ok(OsRng.gen_range(min..=max))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_float(module: &mut Module) {
|
||||
module.set_native_fn("float", || -> Result<f64, Box<EvalAltResult>> {
|
||||
Ok(OsRng.gen::<f64>())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_bytes(module: &mut Module) {
|
||||
module.set_native_fn("bytes", |n: i64| -> Result<Blob, Box<EvalAltResult>> {
|
||||
if !(0..=MAX_BYTES).contains(&n) {
|
||||
return Err(format!("random::bytes: n must be in 0..={MAX_BYTES}, got {n}").into());
|
||||
}
|
||||
// Safe: n is non-negative and bounded by MAX_BYTES, which fits in usize.
|
||||
let len = usize::try_from(n).expect("n bounded above by MAX_BYTES");
|
||||
let mut buf = vec![0u8; len];
|
||||
OsRng.fill_bytes(&mut buf);
|
||||
Ok(buf)
|
||||
});
|
||||
}
|
||||
|
||||
fn register_string(module: &mut Module) {
|
||||
module.set_native_fn("string", |n: i64| -> Result<String, Box<EvalAltResult>> {
|
||||
if !(0..=MAX_STRING).contains(&n) {
|
||||
return Err(format!("random::string: n must be in 0..={MAX_STRING}, got {n}").into());
|
||||
}
|
||||
let len = usize::try_from(n).expect("n bounded above by MAX_STRING");
|
||||
Ok(Alphanumeric.sample_string(&mut OsRng, len))
|
||||
});
|
||||
}
|
||||
|
||||
fn register_uuid(module: &mut Module) {
|
||||
module.set_native_fn("uuid", || -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(Uuid::new_v4().to_string())
|
||||
});
|
||||
}
|
||||
105
crates/executor-core/src/sdk/stdlib/regex.rs
Normal file
105
crates/executor-core/src/sdk/stdlib/regex.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
//! `regex::` — non-backtracking regular expressions (Rust `regex` crate).
|
||||
//!
|
||||
//! Patterns compile per call. No cache: premature for v1.1.0, and the
|
||||
//! `regex` crate's linear-time guarantees keep per-call cost bounded.
|
||||
//! Catastrophic patterns are rejected at compile time by the crate
|
||||
//! itself; no extra defense needed.
|
||||
|
||||
use regex::Regex;
|
||||
use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_is_match(&mut module);
|
||||
register_find(&mut module);
|
||||
register_find_all(&mut module);
|
||||
register_replace(&mut module);
|
||||
register_replace_all(&mut module);
|
||||
register_split(&mut module);
|
||||
register_captures(&mut module);
|
||||
engine.register_static_module("regex", module.into());
|
||||
}
|
||||
|
||||
fn compile(pattern: &str) -> Result<Regex, Box<EvalAltResult>> {
|
||||
Regex::new(pattern).map_err(|e| format!("invalid regex: {e}").into())
|
||||
}
|
||||
|
||||
fn register_is_match(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"is_match",
|
||||
|pattern: &str, text: &str| -> Result<bool, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?.is_match(text))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_find(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"find",
|
||||
|pattern: &str, text: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?
|
||||
.find(text)
|
||||
.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string())))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_find_all(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"find_all",
|
||||
|pattern: &str, text: &str| -> Result<Array, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?
|
||||
.find_iter(text)
|
||||
.map(|m| Dynamic::from(m.as_str().to_string()))
|
||||
.collect())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_replace(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"replace",
|
||||
|pattern: &str, text: &str, replacement: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?.replace(text, replacement).into_owned())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_replace_all(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"replace_all",
|
||||
|pattern: &str, text: &str, replacement: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?
|
||||
.replace_all(text, replacement)
|
||||
.into_owned())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_split(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"split",
|
||||
|pattern: &str, text: &str| -> Result<Array, Box<EvalAltResult>> {
|
||||
Ok(compile(pattern)?
|
||||
.split(text)
|
||||
.map(|s| Dynamic::from(s.to_string()))
|
||||
.collect())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_captures(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"captures",
|
||||
|pattern: &str, text: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let re = compile(pattern)?;
|
||||
Ok(re.captures(text).map_or(Dynamic::UNIT, |caps| {
|
||||
let arr: Array = caps
|
||||
.iter()
|
||||
.map(|m| m.map_or(Dynamic::UNIT, |m| Dynamic::from(m.as_str().to_string())))
|
||||
.collect();
|
||||
Dynamic::from(arr)
|
||||
}))
|
||||
},
|
||||
);
|
||||
}
|
||||
68
crates/executor-core/src/sdk/stdlib/time.rs
Normal file
68
crates/executor-core/src/sdk/stdlib/time.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! `time::` — UTC time. The canonical "time value" is milliseconds
|
||||
//! since the Unix epoch as `i64`. ISO 8601 strings are for parsing and
|
||||
//! display only. UTC only — no timezone support in v1.1.0 (would pull
|
||||
//! in chrono-tz, deferred until a real use case demands it).
|
||||
|
||||
use chrono::{DateTime, SecondsFormat, Utc};
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module};
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_now(&mut module);
|
||||
register_now_ms(&mut module);
|
||||
register_parse(&mut module);
|
||||
register_format(&mut module);
|
||||
register_add_seconds(&mut module);
|
||||
register_diff_seconds(&mut module);
|
||||
engine.register_static_module("time", module.into());
|
||||
}
|
||||
|
||||
fn register_now(module: &mut Module) {
|
||||
module.set_native_fn("now", || -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
});
|
||||
}
|
||||
|
||||
fn register_now_ms(module: &mut Module) {
|
||||
module.set_native_fn("now_ms", || -> Result<i64, Box<EvalAltResult>> {
|
||||
Ok(Utc::now().timestamp_millis())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_parse(module: &mut Module) {
|
||||
module.set_native_fn("parse", |iso: &str| -> Result<i64, Box<EvalAltResult>> {
|
||||
DateTime::parse_from_rfc3339(iso)
|
||||
.map(|dt| dt.timestamp_millis())
|
||||
.map_err(|e| format!("time::parse: invalid ISO 8601 / RFC 3339: {e}").into())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_format(module: &mut Module) {
|
||||
module.set_native_fn("format", |ms: i64| -> Result<String, Box<EvalAltResult>> {
|
||||
DateTime::<Utc>::from_timestamp_millis(ms)
|
||||
.map(|dt| dt.to_rfc3339_opts(SecondsFormat::Millis, true))
|
||||
.ok_or_else(|| format!("time::format: ms ({ms}) out of representable range").into())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_add_seconds(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"add_seconds",
|
||||
|ms: i64, secs: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||
secs.checked_mul(1000)
|
||||
.and_then(|delta| ms.checked_add(delta))
|
||||
.ok_or_else(|| format!("time::add_seconds: overflow (ms={ms}, secs={secs})").into())
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn register_diff_seconds(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"diff_seconds",
|
||||
|a_ms: i64, b_ms: i64| -> Result<i64, Box<EvalAltResult>> {
|
||||
b_ms.checked_sub(a_ms)
|
||||
.map(|d| d / 1000)
|
||||
.ok_or_else(|| format!("time::diff_seconds: overflow (a={a_ms}, b={b_ms})").into())
|
||||
},
|
||||
);
|
||||
}
|
||||
64
crates/executor-core/src/sdk/stdlib/url.rs
Normal file
64
crates/executor-core/src/sdk/stdlib/url.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
//! `url::` — RFC 3986 percent-encoding.
|
||||
//!
|
||||
//! `encode`/`decode` operate on opaque component values; `encode_query`
|
||||
//! builds an `application/x-www-form-urlencoded`-style query string
|
||||
//! from a Rhai `Map`. Key ordering is the map's natural order (Rhai's
|
||||
//! `Map` is a `BTreeMap`, so keys come out alphabetically — fine for
|
||||
//! query strings, which RFC 3986 leaves unordered).
|
||||
|
||||
use percent_encoding::{percent_decode_str, utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
/// RFC 3986 unreserved set: `A-Z / a-z / 0-9 / - / _ / . / ~`.
|
||||
/// Everything outside this set gets percent-encoded.
|
||||
const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
|
||||
.remove(b'-')
|
||||
.remove(b'_')
|
||||
.remove(b'.')
|
||||
.remove(b'~');
|
||||
|
||||
pub fn register(engine: &mut RhaiEngine) {
|
||||
let mut module = Module::new();
|
||||
register_encode(&mut module);
|
||||
register_decode(&mut module);
|
||||
register_encode_query(&mut module);
|
||||
engine.register_static_module("url", module.into());
|
||||
}
|
||||
|
||||
fn register_encode(module: &mut Module) {
|
||||
module.set_native_fn("encode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
Ok(utf8_percent_encode(s, UNRESERVED).to_string())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_decode(module: &mut Module) {
|
||||
module.set_native_fn("decode", |s: &str| -> Result<String, Box<EvalAltResult>> {
|
||||
percent_decode_str(s)
|
||||
.decode_utf8()
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.map_err(|e| format!("url::decode: invalid UTF-8: {e}").into())
|
||||
});
|
||||
}
|
||||
|
||||
fn register_encode_query(module: &mut Module) {
|
||||
module.set_native_fn(
|
||||
"encode_query",
|
||||
|m: Map| -> Result<String, Box<EvalAltResult>> {
|
||||
let mut out = String::new();
|
||||
for (k, v) in m {
|
||||
if !out.is_empty() {
|
||||
out.push('&');
|
||||
}
|
||||
out.push_str(&utf8_percent_encode(&k, UNRESERVED).to_string());
|
||||
out.push('=');
|
||||
// Coerce values via `to_string` rather than throwing on
|
||||
// non-strings — scripts commonly pass numbers/bools here
|
||||
// and a forced cast at the call site is friction with
|
||||
// no upside.
|
||||
let value = v.to_string();
|
||||
out.push_str(&utf8_percent_encode(&value, UNRESERVED).to_string());
|
||||
}
|
||||
Ok(out)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
@@ -50,6 +52,49 @@ pub struct ExecRequest {
|
||||
/// override) before the Rhai engine is built.
|
||||
#[serde(default)]
|
||||
pub sandbox_overrides: ScriptSandbox,
|
||||
|
||||
/// Owning application. Source of truth for every `(app_id, …)`
|
||||
/// storage lookup the script makes via stateful SDK services.
|
||||
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
||||
pub app_id: AppId,
|
||||
|
||||
/// Caller identity, when authenticated. `None` for unauthenticated
|
||||
/// data-plane HTTP requests (the common case for public scripts);
|
||||
/// `Some` when a bearer token or session cookie was resolved.
|
||||
/// Internal-only — exposed via `SdkCallCx` to service trait impls.
|
||||
///
|
||||
/// `#[serde(skip)]`: `ExecRequest` is serializable so cluster mode
|
||||
/// (v1.3+) can ship invocations to remote executors over HTTP, but
|
||||
/// `Principal` has no wire derivation today. Skipping here keeps
|
||||
/// v1.1.0 compiling; the cluster-mode PR will introduce a wire-safe
|
||||
/// snapshot then.
|
||||
#[serde(skip)]
|
||||
pub principal: Option<Principal>,
|
||||
|
||||
/// Triggers-framework depth. `0` for direct invocations. The
|
||||
/// dispatcher (v1.1.1) increments on each indirection to bound
|
||||
/// runaway feedback loops.
|
||||
#[serde(default)]
|
||||
pub trigger_depth: u32,
|
||||
|
||||
/// Originating execution id of a trigger chain. Equal to
|
||||
/// `execution_id` for direct invocations; preserves the root
|
||||
/// across fan-out for audit log grouping.
|
||||
pub root_execution_id: ExecutionId,
|
||||
|
||||
/// `true` only when the dispatcher resolved this invocation
|
||||
/// against a `dead_letter` trigger. The retry / dead-letter
|
||||
/// machinery short-circuits when this is set so handler failures
|
||||
/// cannot themselves be dead-lettered (design notes §4
|
||||
/// recursion-stop rule).
|
||||
#[serde(default)]
|
||||
pub is_dead_letter_handler: bool,
|
||||
|
||||
/// The originating event for a triggered invocation. `None` for
|
||||
/// direct ingress (sync HTTP, manual admin run). Flattened into
|
||||
/// `ctx.event` by the executor's per-call ctx builder.
|
||||
#[serde(default)]
|
||||
pub event: Option<TriggerEvent>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -100,4 +145,11 @@ pub enum ExecError {
|
||||
|
||||
#[error("script runtime error: {0}")]
|
||||
Runtime(String),
|
||||
|
||||
/// Concurrency gate (orchestrator-core::ExecutionGate) refused
|
||||
/// admission. Surfaced as HTTP 503 with a `Retry-After` header.
|
||||
/// The gate enforces a global cap so a script storm can't park
|
||||
/// every blocking thread.
|
||||
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
|
||||
Overloaded { retry_after_secs: u32 },
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
|
||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, KvEventOp, RequestId, ScriptId, ScriptSandbox, Services, TriggerEvent,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
fn req(body: serde_json::Value) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id: ExecutionId::new(),
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "test".into(),
|
||||
@@ -18,11 +21,17 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn engine() -> Engine {
|
||||
Engine::new(Limits::default())
|
||||
Engine::new(Limits::default(), Services::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -121,7 +130,7 @@ fn enforces_operation_budget() {
|
||||
max_operations: 1_000,
|
||||
..Limits::default()
|
||||
};
|
||||
let engine = Engine::new(limits);
|
||||
let engine = Engine::new(limits, Services::default());
|
||||
// 10_000 iterations vastly exceeds 1_000 ops.
|
||||
let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
|
||||
let err = engine
|
||||
@@ -230,3 +239,67 @@ fn body_passes_through_nested_json_round_trip() {
|
||||
let resp = engine().execute(src, req(body.clone())).unwrap();
|
||||
assert_eq!(resp.body, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctx_event_absent_for_direct_invocations() {
|
||||
// Scripts not fired through the triggers framework see no
|
||||
// `ctx.event` key — they can use `"event" in ctx` to detect.
|
||||
let src = r#"
|
||||
if "event" in ctx { #{ statusCode: 500, body: "should be absent" } }
|
||||
else { "absent" }
|
||||
"#;
|
||||
let resp = engine().execute(src, req(json!(null))).unwrap();
|
||||
assert_eq!(resp.body, json!("absent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctx_event_kv_shape_matches_design_notes() {
|
||||
// Build an ExecRequest mimicking what the dispatcher hands a
|
||||
// KV-triggered handler — `event = Some(TriggerEvent::Kv { … })`.
|
||||
let mut r = req(json!(null));
|
||||
r.event = Some(TriggerEvent::Kv {
|
||||
op: KvEventOp::Insert,
|
||||
collection: "widgets".into(),
|
||||
key: "k1".into(),
|
||||
value: Some(json!({ "n": 1 })),
|
||||
});
|
||||
let src = r"
|
||||
#{
|
||||
source: ctx.event.source,
|
||||
op: ctx.event.op,
|
||||
collection: ctx.event.kv.collection,
|
||||
key: ctx.event.kv.key,
|
||||
value: ctx.event.kv.value
|
||||
}
|
||||
";
|
||||
let resp = engine().execute(src, r).unwrap();
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
json!({
|
||||
"source": "kv",
|
||||
"op": "insert",
|
||||
"collection": "widgets",
|
||||
"key": "k1",
|
||||
"value": { "n": 1 }
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctx_event_kv_delete_has_unit_value() {
|
||||
let mut r = req(json!(null));
|
||||
r.event = Some(TriggerEvent::Kv {
|
||||
op: KvEventOp::Delete,
|
||||
collection: "widgets".into(),
|
||||
key: "k1".into(),
|
||||
value: None,
|
||||
});
|
||||
let src = r"
|
||||
#{
|
||||
op: ctx.event.op,
|
||||
value_is_unit: ctx.event.kv.value == ()
|
||||
}
|
||||
";
|
||||
let resp = engine().execute(src, r).unwrap();
|
||||
assert_eq!(resp.body, json!({ "op": "delete", "value_is_unit": true }));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel};
|
||||
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -31,12 +31,13 @@ use serde_json::{json, Value};
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn engine() -> Engine {
|
||||
Engine::new(Limits::default())
|
||||
Engine::new(Limits::default(), Services::default())
|
||||
}
|
||||
|
||||
fn baseline_request() -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id: ExecutionId::new(),
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "contract".into(),
|
||||
@@ -48,6 +49,12 @@ fn baseline_request() -> ExecRequest {
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
260
crates/executor-core/tests/sdk_kv.rs
Normal file
260
crates/executor-core/tests/sdk_kv.rs
Normal file
@@ -0,0 +1,260 @@
|
||||
//! `kv::` SDK bridge integration tests — runs a real Rhai engine
|
||||
//! against an in-memory `KvService` impl. Mirrors how
|
||||
//! `orchestrator-core::LocalExecutorClient` invokes the engine: under
|
||||
//! `tokio::task::spawn_blocking` so the bridge's `block_on` has a
|
||||
//! reachable runtime.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, KvError, KvListPage, KvService, NoopDeadLetterService, NoopEventEmitter,
|
||||
RequestId, ScriptId, ScriptSandbox, SdkCallCx, Services,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryKv {
|
||||
data: Mutex<HashMap<(AppId, String, String), Value>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KvService for InMemoryKv {
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<Value>, KvError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, collection.to_string(), key.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: Value,
|
||||
) -> Result<(), KvError> {
|
||||
self.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((cx.app_id, collection.to_string(), key.to_string()), value);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(cx.app_id, collection.to_string(), key.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
|
||||
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
||||
Ok(self.data.lock().await.contains_key(&(
|
||||
cx.app_id,
|
||||
collection.to_string(),
|
||||
key.to_string(),
|
||||
)))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, KvError> {
|
||||
let data = self.data.lock().await;
|
||||
let mut keys: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == cx.app_id && c == collection)
|
||||
.map(|((_, _, k), _)| k.clone())
|
||||
.filter(|k| cursor.is_none_or(|c| k.as_str() > c))
|
||||
.collect();
|
||||
keys.sort();
|
||||
let take = if limit == 0 {
|
||||
usize::MAX
|
||||
} else {
|
||||
limit as usize
|
||||
};
|
||||
let next_cursor = if keys.len() > take {
|
||||
keys.truncate(take);
|
||||
keys.last().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(KvListPage { keys, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
fn make_engine() -> Arc<Engine> {
|
||||
let services = Services::new(
|
||||
Arc::new(InMemoryKv::default()),
|
||||
Arc::new(NoopDeadLetterService),
|
||||
Arc::new(NoopEventEmitter),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "kv-test".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/kv-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_script(engine: Arc<Engine>, src: &str, req: ExecRequest) -> Value {
|
||||
let src = src.to_string();
|
||||
tokio::task::spawn_blocking(move || engine.execute(&src, req))
|
||||
.await
|
||||
.expect("spawn_blocking should not panic")
|
||||
.expect("script execution should succeed")
|
||||
.body
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_set_then_get_round_trip() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let widgets = kv::collection("widgets");
|
||||
widgets.set("k1", #{ n: 1 });
|
||||
widgets.get("k1")
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!({ "n": 1 }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_get_missing_returns_unit() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = kv::collection("widgets");
|
||||
let v = c.get("nope");
|
||||
v == ()
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_has_returns_bool() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = kv::collection("widgets");
|
||||
let before = c.has("k");
|
||||
c.set("k", "v");
|
||||
let after = c.has("k");
|
||||
#{ before: before, after: after }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!({ "before": false, "after": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_delete_returns_was_present() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = kv::collection("widgets");
|
||||
let nope = c.delete("missing");
|
||||
c.set("k", 1);
|
||||
let yep = c.delete("k");
|
||||
#{ nope: nope, yep: yep }
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
assert_eq!(body, json!({ "nope": false, "yep": true }));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_empty_collection_name_throws() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"kv::collection("")"#;
|
||||
let req = baseline_request(app);
|
||||
let err = tokio::task::spawn_blocking(move || engine.execute(src, req))
|
||||
.await
|
||||
.unwrap()
|
||||
.expect_err("empty collection should throw");
|
||||
assert!(format!("{err:?}").contains("kv::collection"));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_list_pages_via_cursor() {
|
||||
let engine = make_engine();
|
||||
let app = AppId::new();
|
||||
let src = r#"
|
||||
let c = kv::collection("widgets");
|
||||
for i in 0..5 { c.set(`k${i}`, i); }
|
||||
let p1 = c.list("", 2);
|
||||
let p2 = c.list(p1.next_cursor, 2);
|
||||
#{
|
||||
p1_keys: p1.keys,
|
||||
p1_cursor: p1.next_cursor,
|
||||
p2_keys: p2.keys,
|
||||
}
|
||||
"#;
|
||||
let body = run_script(engine, src, baseline_request(app)).await;
|
||||
let obj = body.as_object().unwrap();
|
||||
let p1_keys = obj["p1_keys"].as_array().unwrap();
|
||||
let p2_keys = obj["p2_keys"].as_array().unwrap();
|
||||
assert_eq!(p1_keys.len(), 2);
|
||||
assert_eq!(p2_keys.len(), 2);
|
||||
assert!(obj["p1_cursor"].is_string());
|
||||
}
|
||||
|
||||
/// Cross-app isolation via `cx.app_id` — script with `app_id = A`
|
||||
/// cannot see entries from `app_id = B`. The kv:: bridge never
|
||||
/// surfaces `app_id` to the script, so this is enforced purely by the
|
||||
/// service deriving it from the captured `Arc<SdkCallCx>`.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn kv_bridge_preserves_cross_app_isolation() {
|
||||
let engine = make_engine();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
|
||||
let writer = r#"
|
||||
let c = kv::collection("shared");
|
||||
c.set("k", "from-a");
|
||||
"ok"
|
||||
"#;
|
||||
let _ = run_script(engine.clone(), writer, baseline_request(app_a)).await;
|
||||
|
||||
// App B sees nothing under the same collection/key.
|
||||
let reader = r#"
|
||||
let c = kv::collection("shared");
|
||||
c.get("k")
|
||||
"#;
|
||||
let body = run_script(engine, reader, baseline_request(app_b)).await;
|
||||
assert_eq!(body, Value::Null);
|
||||
}
|
||||
384
crates/executor-core/tests/stdlib.rs
Normal file
384
crates/executor-core/tests/stdlib.rs
Normal file
@@ -0,0 +1,384 @@
|
||||
//! Integration tests for the v1.1.0 stdlib utility modules.
|
||||
//!
|
||||
//! These exist alongside `sdk_contract.rs` rather than inside it
|
||||
//! because the stateless utilities aren't part of the same versioned
|
||||
//! SDK contract surface — `sdk_contract.rs` covers things that bump
|
||||
//! `SDK_VERSION` when they change; stdlib additions don't.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Test harness — duplicated from sdk_contract.rs (each integration test
|
||||
// crate has its own; there is no tests/common/).
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn engine() -> Engine {
|
||||
Engine::new(Limits::default(), Services::default())
|
||||
}
|
||||
|
||||
fn baseline_request() -> ExecRequest {
|
||||
let execution_id = ExecutionId::new();
|
||||
ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
script_name: "stdlib".into(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: "/stdlib-test".into(),
|
||||
headers: BTreeMap::new(),
|
||||
body: Value::Null,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(source: &str) -> Value {
|
||||
engine()
|
||||
.execute(source, baseline_request())
|
||||
.expect("stdlib test should execute cleanly")
|
||||
.body
|
||||
}
|
||||
|
||||
fn run_err(source: &str) -> ExecError {
|
||||
engine()
|
||||
.execute(source, baseline_request())
|
||||
.expect_err("stdlib test expected to throw")
|
||||
}
|
||||
|
||||
fn assert_runtime_err(err: ExecError, needle: &str) {
|
||||
match err {
|
||||
ExecError::Runtime(msg) => assert!(
|
||||
msg.contains(needle),
|
||||
"runtime error did not contain `{needle}`: {msg}"
|
||||
),
|
||||
other => panic!("expected Runtime error containing `{needle}`, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// regex
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn regex_is_match_true_and_false() {
|
||||
assert_eq!(run(r#"regex::is_match("^h", "hello")"#), json!(true));
|
||||
assert_eq!(run(r#"regex::is_match("^x", "hello")"#), json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_find_returns_first_match() {
|
||||
assert_eq!(run(r#"regex::find("\\d+", "abc 42 def 99")"#), json!("42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_find_returns_unit_when_no_match() {
|
||||
// () serializes to JSON null via dynamic_to_json.
|
||||
assert_eq!(run(r#"regex::find("\\d+", "abc")"#), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_find_all_returns_array() {
|
||||
assert_eq!(
|
||||
run(r#"regex::find_all("\\d+", "a1 b22 c333")"#),
|
||||
json!(["1", "22", "333"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_first_only() {
|
||||
assert_eq!(
|
||||
run(r#"regex::replace("a", "banana", "X")"#),
|
||||
json!("bXnana")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace_all() {
|
||||
assert_eq!(
|
||||
run(r#"regex::replace_all("a", "banana", "X")"#),
|
||||
json!("bXnXnX")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_split() {
|
||||
assert_eq!(
|
||||
run(r#"regex::split(",\\s*", "a, b,c, d")"#),
|
||||
json!(["a", "b", "c", "d"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_captures_extracts_groups() {
|
||||
assert_eq!(
|
||||
run(r#"regex::captures("(\\d+)-(\\w+)", "42-abc")"#),
|
||||
json!(["42-abc", "42", "abc"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_captures_returns_unit_when_no_match() {
|
||||
assert_eq!(run(r#"regex::captures("(\\d+)", "abc")"#), Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_invalid_pattern_throws() {
|
||||
assert_runtime_err(run_err(r#"regex::is_match("(", "x")"#), "invalid regex");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// random
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn random_int_within_range() {
|
||||
// Run a few times to exercise the bounds — each call is independent.
|
||||
let body = run(r"
|
||||
let n = random::int(10, 20);
|
||||
n >= 10 && n <= 20
|
||||
");
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_int_throws_when_min_greater_than_max() {
|
||||
assert_runtime_err(run_err("random::int(20, 10)"), "min");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_float_in_unit_interval() {
|
||||
let body = run(r"
|
||||
let f = random::float();
|
||||
f >= 0.0 && f < 1.0
|
||||
");
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_returns_blob_of_correct_length() {
|
||||
assert_eq!(run("random::bytes(16).len()"), json!(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_rejects_negative() {
|
||||
assert_runtime_err(run_err("random::bytes(-1)"), "random::bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_bytes_rejects_oversize() {
|
||||
assert_runtime_err(run_err("random::bytes(70000)"), "random::bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_string_produces_alphanumeric_of_correct_length() {
|
||||
let body = run(r#"
|
||||
let s = random::string(32);
|
||||
s.len == 32 && regex::is_match("^[A-Za-z0-9]+$", s)
|
||||
"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_uuid_has_canonical_format() {
|
||||
let body = run(
|
||||
r#"regex::is_match("^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", random::uuid())"#,
|
||||
);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// time
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn time_now_ms_is_positive() {
|
||||
let body = run("time::now_ms() > 0");
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_now_string_looks_like_iso() {
|
||||
let body = run(r#"regex::is_match("^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", time::now())"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_parse_format_round_trip() {
|
||||
let body = run(r"
|
||||
let ms = 1700000000000;
|
||||
time::parse(time::format(ms)) == ms
|
||||
");
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_add_seconds() {
|
||||
assert_eq!(run("time::add_seconds(0, 60)"), json!(60_000));
|
||||
assert_eq!(run("time::add_seconds(1000, -1)"), json!(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_diff_seconds_truncates() {
|
||||
assert_eq!(run("time::diff_seconds(0, 65_500)"), json!(65));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_parse_rejects_garbage() {
|
||||
assert_runtime_err(run_err(r#"time::parse("nonsense")"#), "time::parse");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// json
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn json_parse_then_stringify_round_trip() {
|
||||
let body = run(r#"
|
||||
let src = `{"a":1,"b":"x"}`;
|
||||
json::stringify(json::parse(src)) == src
|
||||
"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_stringify_compact() {
|
||||
assert_eq!(run(r"json::stringify(#{ a: 1 })"), json!(r#"{"a":1}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_stringify_pretty_has_newlines() {
|
||||
let body = run(r#"json::stringify_pretty(#{ a: 1 }).contains("\n")"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_parse_invalid_throws() {
|
||||
assert_runtime_err(run_err(r#"json::parse("not json")"#), "json::parse");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// base64
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn base64_encode_string() {
|
||||
assert_eq!(run(r#"base64::encode("hi")"#), json!("aGk="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_then_re_encode_round_trip() {
|
||||
assert_eq!(
|
||||
run(r#"base64::encode(base64::decode("aGVsbG8="))"#),
|
||||
json!("aGVsbG8=")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_encode_url_has_no_padding() {
|
||||
let body = run(r#"
|
||||
let s = base64::encode_url("hello world!?");
|
||||
!s.contains("=") && !s.contains("+") && !s.contains("/")
|
||||
"#);
|
||||
assert_eq!(body, json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_url_round_trip() {
|
||||
assert_eq!(
|
||||
run(r#"base64::encode_url(base64::decode_url("aGVsbG8"))"#),
|
||||
json!("aGVsbG8")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_decode_invalid_throws() {
|
||||
assert_runtime_err(run_err(r#"base64::decode("!!!")"#), "base64::decode");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// hex
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn hex_encode_produces_lowercase() {
|
||||
assert_eq!(run(r#"hex::encode("Z")"#), json!("5a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_then_re_encode_round_trip() {
|
||||
// mixed-case input → lowercase output proves both case-insensitive
|
||||
// decode and lowercase encode.
|
||||
assert_eq!(
|
||||
run(r#"hex::encode(hex::decode("DeAdBeEf"))"#),
|
||||
json!("deadbeef")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_returns_correct_length() {
|
||||
assert_eq!(run(r#"hex::decode("deadbeef").len()"#), json!(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_decode_invalid_throws() {
|
||||
assert_runtime_err(run_err(r#"hex::decode("xyz")"#), "hex::decode");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// url
|
||||
// ============================================================================
|
||||
|
||||
#[test]
|
||||
fn url_encode_basic() {
|
||||
assert_eq!(run(r#"url::encode("hello world")"#), json!("hello%20world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_preserves_unreserved() {
|
||||
assert_eq!(
|
||||
run(r#"url::encode("abcXYZ123-_.~")"#),
|
||||
json!("abcXYZ123-_.~")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_decode_round_trip() {
|
||||
assert_eq!(
|
||||
run(r#"url::decode(url::encode("hello world!?"))"#),
|
||||
json!("hello world!?")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_query_basic() {
|
||||
// Map keys come out alphabetically (Rhai's Map is a BTreeMap).
|
||||
assert_eq!(
|
||||
run(r#"url::encode_query(#{ a: "1", b: "x y" })"#),
|
||||
json!("a=1&b=x%20y")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_encode_query_coerces_non_strings() {
|
||||
// Numbers and bools shouldn't throw; they coerce via to_string().
|
||||
let body = run(r"url::encode_query(#{ n: 42, b: true })");
|
||||
// Order is alphabetical: b before n.
|
||||
assert_eq!(body, json!("b=true&n=42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_decode_rejects_invalid_utf8() {
|
||||
assert_runtime_err(run_err(r#"url::decode("%FF%FE%80")"#), "url::decode");
|
||||
}
|
||||
@@ -10,13 +10,16 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
picloud-shared.workspace = true
|
||||
picloud-executor-core.workspace = true
|
||||
picloud-orchestrator-core.workspace = true
|
||||
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
@@ -24,9 +27,9 @@ sqlx.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
argon2.workspace = true
|
||||
rand.workspace = true
|
||||
sha2.workspace = true
|
||||
base64.workspace = true
|
||||
data-encoding.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio.workspace = true
|
||||
|
||||
117
crates/manager-core/migrations/0005_apps.sql
Normal file
117
crates/manager-core/migrations/0005_apps.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
-- Phase 3b multi-app scoping — see blueprint §11.5.
|
||||
--
|
||||
-- Apps are the top-level isolation boundary for scripts, routes, domain
|
||||
-- claims and (forward) data. The orchestrator dispatches Host → app_id →
|
||||
-- route trie; cross-app resource access is not possible.
|
||||
--
|
||||
-- This migration is unconditional:
|
||||
-- 1. Creates the three new tables (apps, app_domains, app_slug_history).
|
||||
-- 2. Always inserts a "default" app claiming `localhost` so existing
|
||||
-- installs get a usable home for their pre-existing scripts/routes.
|
||||
-- 3. Backfills app_id on scripts, routes, execution_logs from the
|
||||
-- default app row, then promotes the columns to NOT NULL + FK.
|
||||
--
|
||||
-- Fresh installs get the same "default" app row; an in-Rust bootstrap
|
||||
-- step (manager-core::app_bootstrap) decides whether to seed a Hello
|
||||
-- World script into it. Doing the seed in Rust keeps it testable and
|
||||
-- lets the script source live in a real .rhai file.
|
||||
|
||||
CREATE TABLE apps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- URL-safe identifier; mutable via the rename flow which records
|
||||
-- the prior slug in app_slug_history for permanent 301 redirects.
|
||||
-- Format validation (`^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||
-- check) lives in Rust handlers, not SQL.
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Domain claims. Most-specific wins at request time; same-shape
|
||||
-- collisions are rejected at claim time via the UNIQUE(shape_key).
|
||||
-- shape_key encoding:
|
||||
-- exact:<lowercased-host> for shape='exact'
|
||||
-- wildcard:<lowercased-suffix> for shape='wildcard' AND 'parameterized'
|
||||
-- (parameterized is the same shape as wildcard for collision — the
|
||||
-- parameter name is a binding, not a discriminator. See blueprint §11.5.)
|
||||
CREATE TABLE app_domains (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
pattern TEXT NOT NULL,
|
||||
shape TEXT NOT NULL CHECK (shape IN ('exact', 'wildcard', 'parameterized')),
|
||||
shape_key TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX app_domains_app_id_idx ON app_domains (app_id);
|
||||
|
||||
-- Permanent 301 redirects after a slug rename. A row dies only when
|
||||
-- another app explicitly claims the retired slug (with confirmation in
|
||||
-- the UI). On_delete cascade: if the owning app is deleted, its history
|
||||
-- row goes too (otherwise the redirect would point at a dead app).
|
||||
CREATE TABLE app_slug_history (
|
||||
slug TEXT PRIMARY KEY,
|
||||
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
retired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Seed the default app + a localhost claim. Used by both upgrade and
|
||||
-- fresh-install paths; the Rust bootstrap layers Hello World on top
|
||||
-- only when the install was fresh.
|
||||
WITH default_app AS (
|
||||
INSERT INTO apps (slug, name, description)
|
||||
VALUES ('default', 'Default', 'The default application — assigned to all pre-existing scripts and routes during the multi-app migration.')
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO app_domains (app_id, pattern, shape, shape_key)
|
||||
SELECT id, 'localhost', 'exact', 'exact:localhost' FROM default_app;
|
||||
|
||||
-- Add app_id to scripts. The default app already exists (above), so
|
||||
-- there is exactly one row to look up.
|
||||
ALTER TABLE scripts ADD COLUMN app_id UUID;
|
||||
UPDATE scripts SET app_id = (SELECT id FROM apps WHERE slug = 'default');
|
||||
ALTER TABLE scripts ALTER COLUMN app_id SET NOT NULL;
|
||||
ALTER TABLE scripts
|
||||
ADD CONSTRAINT scripts_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT;
|
||||
|
||||
-- Per-app name uniqueness. Two apps can each have a script called
|
||||
-- "echo"; previously they could not.
|
||||
DROP INDEX scripts_name_uidx;
|
||||
CREATE UNIQUE INDEX scripts_name_uidx ON scripts (app_id, LOWER(name));
|
||||
|
||||
CREATE INDEX scripts_app_id_idx ON scripts (app_id);
|
||||
|
||||
-- Add app_id to routes, mirroring the script's app.
|
||||
ALTER TABLE routes ADD COLUMN app_id UUID;
|
||||
UPDATE routes
|
||||
SET app_id = scripts.app_id
|
||||
FROM scripts
|
||||
WHERE routes.script_id = scripts.id;
|
||||
ALTER TABLE routes ALTER COLUMN app_id SET NOT NULL;
|
||||
ALTER TABLE routes
|
||||
ADD CONSTRAINT routes_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||
|
||||
-- Replace the route uniqueness index so two apps can claim identical
|
||||
-- (host_kind, host, path_kind, path, method) tuples — they live in
|
||||
-- separate route trees and never see each other.
|
||||
DROP INDEX routes_unique_binding_idx;
|
||||
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''));
|
||||
|
||||
CREATE INDEX routes_app_id_idx ON routes (app_id);
|
||||
|
||||
-- Add app_id to execution_logs. Materialized at write time so future
|
||||
-- script-moves (or eventual export/import) don't silently retag history.
|
||||
ALTER TABLE execution_logs ADD COLUMN app_id UUID;
|
||||
UPDATE execution_logs
|
||||
SET app_id = scripts.app_id
|
||||
FROM scripts
|
||||
WHERE execution_logs.script_id = scripts.id;
|
||||
ALTER TABLE execution_logs ALTER COLUMN app_id SET NOT NULL;
|
||||
ALTER TABLE execution_logs
|
||||
ADD CONSTRAINT execution_logs_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX execution_logs_app_id_created_at_idx
|
||||
ON execution_logs (app_id, created_at DESC);
|
||||
112
crates/manager-core/migrations/0006_users_authz.sql
Normal file
112
crates/manager-core/migrations/0006_users_authz.sql
Normal file
@@ -0,0 +1,112 @@
|
||||
-- Phase 3.5 users, roles, and bearer-token auth — see blueprint §11.6.
|
||||
--
|
||||
-- Lays down the schema that the unified can(principal, capability) gate
|
||||
-- runs against, plus the api_keys table that backs `Authorization: Bearer
|
||||
-- pic_…` credentials. No data-plane impact; Phase 4 SDKs (KV, docs, HTTP,
|
||||
-- cron) will plug into this same authz pipeline.
|
||||
--
|
||||
-- Three changes:
|
||||
-- 1. admin_users gains instance_role ('owner'/'admin'/'member') plus a
|
||||
-- reserved email column and mfa_secret slot (neither is read yet).
|
||||
-- Every pre-existing row becomes 'owner' via the DEFAULT — Phase 3a
|
||||
-- had no role concept, so promoting all current admins to owner is
|
||||
-- the only safe interpretation (and matches the spec). The Rust
|
||||
-- startup path logs a warning when more than one active owner
|
||||
-- exists, so operators can demote extras via the admin PATCH.
|
||||
-- 2. app_members records explicit per-app grants for 'member' users.
|
||||
-- Owners and admins get implicit grants in code (owner→app_admin
|
||||
-- everywhere, admin→editor everywhere); no rows here.
|
||||
-- 3. api_keys holds Argon2id-hashed bearer credentials. Lookup is
|
||||
-- prefix-indexed (first 8 chars after `pic_`) then hash-verified;
|
||||
-- raw token only ever exists in the POST response. Optional
|
||||
-- expires_at / app_id implement TTL and app-binding respectively.
|
||||
|
||||
ALTER TABLE admin_users
|
||||
-- DEFAULT 'owner' so the Phase 3a bootstrap admin (and any other
|
||||
-- pre-existing rows) become full owners without a backfill step.
|
||||
-- Multi-owner installs are flagged at startup; demotion is a
|
||||
-- deliberate PATCH, not an automatic migration choice.
|
||||
ADD COLUMN instance_role TEXT NOT NULL DEFAULT 'owner'
|
||||
CHECK (instance_role IN ('owner', 'admin', 'member')),
|
||||
-- Reserved for the eventual invite flow + Phase 4 user-management
|
||||
-- SDK. UNIQUE so we never end up with two rows claiming the same
|
||||
-- contact. Nullable because pre-existing admins have no email on
|
||||
-- file and we don't want to force a backfill.
|
||||
ADD COLUMN email TEXT UNIQUE,
|
||||
-- Reserved slot for TOTP secrets. Not read in Phase 3.5 — present
|
||||
-- now only to avoid a schema bump when MFA lands.
|
||||
ADD COLUMN mfa_secret TEXT;
|
||||
|
||||
CREATE INDEX admin_users_instance_role_idx ON admin_users (instance_role);
|
||||
|
||||
-- Per-(user, app) explicit grant. Owners and admins do NOT appear here;
|
||||
-- their app authority is implicit in their instance_role and resolved in
|
||||
-- code. Only 'member' users need rows in this table — without one, a
|
||||
-- member has no access to the app at all.
|
||||
CREATE TABLE app_members (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, user_id)
|
||||
);
|
||||
|
||||
-- Lookup pattern is "what apps can this user see?" — needed for the
|
||||
-- membership-filtered GET /admin/apps and GET /admin/scripts.
|
||||
CREATE INDEX app_members_user_id_idx ON app_members (user_id);
|
||||
|
||||
-- Bearer API keys. Format on the wire: `pic_<base32(32 random bytes)>`.
|
||||
-- prefix = first 8 chars after `pic_` (indexed for O(1) candidate lookup)
|
||||
-- hash = Argon2id PHC of the full body after `pic_`
|
||||
-- Raw value is returned exactly once at mint time and never persisted.
|
||||
--
|
||||
-- Optional fields:
|
||||
-- expires_at: TTL. Lookup always filters `expires_at IS NULL OR > NOW()`.
|
||||
-- app_id : "bound key" — capability checks deny any App*(other_app),
|
||||
-- regardless of the owning user's role. Cannot combine with
|
||||
-- instance:* scopes (validated in the mint handler, not SQL).
|
||||
CREATE TABLE api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
hash TEXT NOT NULL,
|
||||
prefix TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
-- TEXT[] keeps the scope set open to additions without a migration;
|
||||
-- the seven legal values are validated at mint time in Rust, not by
|
||||
-- a CHECK constraint here (so new scopes can land without a schema
|
||||
-- bump).
|
||||
scopes TEXT[] NOT NULL,
|
||||
app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMPTZ NULL,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX api_keys_prefix_idx ON api_keys (prefix);
|
||||
CREATE INDEX api_keys_user_id_idx ON api_keys (user_id);
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Reserved schema room (not built in Phase 3.5)
|
||||
-- ---------------------------------------------------------------------
|
||||
-- These tables are deliberately commented out, not created. They are
|
||||
-- listed here so the design intent is visible at the migration boundary
|
||||
-- and future authors don't reinvent the shape. Each lands in its own
|
||||
-- numbered migration when the corresponding flow ships.
|
||||
--
|
||||
-- CREATE TABLE invites (
|
||||
-- token TEXT PRIMARY KEY, -- raw at email-link time, hashed at rest
|
||||
-- email TEXT NOT NULL,
|
||||
-- instance_role TEXT NULL CHECK (instance_role IN ('owner','admin','member')),
|
||||
-- app_id UUID NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
-- app_role TEXT NULL CHECK (app_role IN ('app_admin','editor','viewer')),
|
||||
-- invited_by UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
-- expires_at TIMESTAMPTZ NOT NULL,
|
||||
-- consumed_at TIMESTAMPTZ NULL
|
||||
-- );
|
||||
--
|
||||
-- CREATE TABLE service_accounts (
|
||||
-- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- name TEXT NOT NULL,
|
||||
-- owning_user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE RESTRICT,
|
||||
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
-- );
|
||||
28
crates/manager-core/migrations/0007_kv.sql
Normal file
28
crates/manager-core/migrations/0007_kv.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- v1.1.1: Key-value store — see blueprint §8.1 + docs/sdk-shape.md.
|
||||
--
|
||||
-- Identity tuple `(app_id, collection, key)`. `app_id` is first in the
|
||||
-- primary key so the implicit index is always per-app; cross-app reads
|
||||
-- cannot happen even with a buggy query. Collections are a required
|
||||
-- namespace inside an app — the same key can live in different
|
||||
-- collections without collision.
|
||||
--
|
||||
-- `value` is JSONB so scripts can store nested structures without
|
||||
-- a separate serialization step. No TTL column in v1.1.1; deferred
|
||||
-- until a concrete need surfaces (the blueprint reserved one but the
|
||||
-- v1.1.1 SDK surface — get/set/has/delete/list — doesn't expose TTL).
|
||||
|
||||
CREATE TABLE kv_entries (
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
collection TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (app_id, collection, key)
|
||||
);
|
||||
|
||||
-- Supports list-by-collection (keyset pagination) and per-collection
|
||||
-- triggers' fan-out scans. The PK already covers (app_id, collection)
|
||||
-- as a prefix but spelling out the explicit index makes intent clear
|
||||
-- for the planner.
|
||||
CREATE INDEX idx_kv_entries_app_collection ON kv_entries (app_id, collection);
|
||||
72
crates/manager-core/migrations/0008_triggers.sql
Normal file
72
crates/manager-core/migrations/0008_triggers.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
-- v1.1.1: Trigger framework — Layout E (design notes §2 + §7).
|
||||
--
|
||||
-- A parent `triggers` table holds the common columns (script_id, retry
|
||||
-- config, dispatch_mode, registered-by principal); per-kind detail
|
||||
-- tables hold the kind-specific filter columns. v1.1.1 ships two
|
||||
-- kinds: KV (collection_glob + ops) and dead_letter (source / trigger
|
||||
-- / script filters). Future kinds (cron, pubsub, queue, email) extend
|
||||
-- the parent and add their own detail table.
|
||||
--
|
||||
-- `registered_by_principal` captures the admin user that registered
|
||||
-- the trigger. The dispatcher resolves this back to a `Principal` at
|
||||
-- execution time so the trigger runs as the user that set it up
|
||||
-- (design notes §4: "a trigger execution runs as the principal that
|
||||
-- registered the trigger").
|
||||
--
|
||||
-- HTTP routes stay in their own `routes` table for now (Phase 3
|
||||
-- production schema with its own trie-index columns); the dispatcher
|
||||
-- discriminates HTTP outbox rows by `source_kind = 'http'` and
|
||||
-- `trigger_id` referencing `routes.id`. Folding routes into triggers
|
||||
-- is a v1.2 cleanup, not a v1.1.1 requirement.
|
||||
|
||||
CREATE TABLE triggers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('kv', 'dead_letter')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
-- Async by default — sync would mean the trigger fires inline with
|
||||
-- the originating mutation, which v1.1.1 doesn't support.
|
||||
dispatch_mode TEXT NOT NULL DEFAULT 'async'
|
||||
CHECK (dispatch_mode IN ('sync', 'async')),
|
||||
-- Defaults applied at write time so the row is auditable on its
|
||||
-- own. Per-trigger overrides set on create; the env-defined
|
||||
-- defaults provide the fallback values.
|
||||
retry_max_attempts INT NOT NULL,
|
||||
retry_backoff TEXT NOT NULL
|
||||
CHECK (retry_backoff IN ('exponential', 'linear', 'constant')),
|
||||
retry_base_ms INT NOT NULL,
|
||||
registered_by_principal UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- The dispatcher's hot lookup: "all enabled triggers for app X of
|
||||
-- kind Y". Indexed only when enabled = TRUE so disabled rows don't
|
||||
-- pollute the index.
|
||||
CREATE INDEX idx_triggers_app_kind_enabled
|
||||
ON triggers (app_id, kind)
|
||||
WHERE enabled = TRUE;
|
||||
|
||||
-- One row per KV trigger. `collection_glob` accepts:
|
||||
-- "*" — any collection in the app
|
||||
-- "widgets" — exact match
|
||||
-- "users:*" — prefix wildcard (matched in Rust, not SQL)
|
||||
-- `ops` is the subset of {insert, update, delete} this trigger
|
||||
-- subscribes to. Empty array means "any op" (the trigger fires on
|
||||
-- every mutation; admin endpoint validates this).
|
||||
CREATE TABLE kv_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
collection_glob TEXT NOT NULL,
|
||||
ops TEXT[] NOT NULL
|
||||
);
|
||||
|
||||
-- One row per dead-letter trigger. All three filter columns are
|
||||
-- nullable — NULL means "no filter on this dimension". A trigger
|
||||
-- with all three nullable filters fires on every dead-letter row.
|
||||
CREATE TABLE dead_letter_trigger_details (
|
||||
trigger_id UUID PRIMARY KEY REFERENCES triggers(id) ON DELETE CASCADE,
|
||||
source_filter TEXT,
|
||||
trigger_id_filter UUID,
|
||||
script_id_filter UUID
|
||||
);
|
||||
64
crates/manager-core/migrations/0009_outbox.sql
Normal file
64
crates/manager-core/migrations/0009_outbox.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
-- v1.1.1: Universal trigger outbox — design notes §2.
|
||||
--
|
||||
-- One table for every async dispatch in the system. KV/cron/pubsub/
|
||||
-- queue/email/dead-letter all write rows in this shape; the dispatcher
|
||||
-- claims due rows with `FOR UPDATE SKIP LOCKED` and routes them to
|
||||
-- the executor.
|
||||
--
|
||||
-- Sync HTTP also writes here (NATS-style inbox, design notes §3) —
|
||||
-- `reply_to` carries an `inbox_id` that the orchestrator awaits on a
|
||||
-- oneshot channel. `reply_to.is_some()` is the "don't retry" signal:
|
||||
-- one attempt, surface the result via the inbox.
|
||||
--
|
||||
-- `trigger_id` is a polymorphic reference discriminated by
|
||||
-- `source_kind`: for `source_kind='http'` it references `routes.id`;
|
||||
-- otherwise it references `triggers.id`. Polymorphism handled in
|
||||
-- Rust (the dispatcher); no DB-level FK because Postgres doesn't
|
||||
-- support polymorphic FKs cleanly. NULL is allowed because direct
|
||||
-- admin-replay paths may not have a triggering row at all.
|
||||
--
|
||||
-- `script_id` denormalized so the dispatcher resolves the target
|
||||
-- script without an extra round-trip per row.
|
||||
|
||||
CREATE TABLE outbox (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
source_kind TEXT NOT NULL
|
||||
CHECK (source_kind IN ('http', 'kv', 'dead_letter')),
|
||||
-- Polymorphic — see comment above. No FK constraint.
|
||||
trigger_id UUID,
|
||||
-- Pre-resolved at write time so the dispatcher doesn't re-look it up.
|
||||
script_id UUID,
|
||||
-- NULL = async (retry per policy). Some(inbox_id) = sync HTTP
|
||||
-- (never retry; resolve the inbox with the result).
|
||||
reply_to UUID,
|
||||
-- ServiceEvent + ExecRequest scaffold serialized as JSONB.
|
||||
payload JSONB NOT NULL,
|
||||
-- Forensic field — the principal that triggered the originating
|
||||
-- event. NOT the execution principal for trigger fan-out (that
|
||||
-- comes from `triggers.registered_by_principal`).
|
||||
origin_principal UUID,
|
||||
-- Trigger-depth as the dispatcher will hand it to the executor.
|
||||
-- Read out into ExecRequest.trigger_depth at dispatch time.
|
||||
trigger_depth INT NOT NULL DEFAULT 0,
|
||||
-- Originating execution id (for audit log grouping). Equals the
|
||||
-- root for direct invocations; preserved across fan-out chains.
|
||||
root_execution_id UUID,
|
||||
attempt_count INT NOT NULL DEFAULT 0,
|
||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
-- Set inside the SELECT FOR UPDATE SKIP LOCKED transaction so
|
||||
-- the dispatcher can't double-pick a row across concurrent loop
|
||||
-- iterations.
|
||||
claimed_at TIMESTAMPTZ,
|
||||
claimed_by TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Hot index: the dispatcher's `WHERE next_attempt_at <= NOW() AND
|
||||
-- claimed_at IS NULL` claim query. Partial index keeps the hot set
|
||||
-- small even if the table grows large.
|
||||
CREATE INDEX idx_outbox_due
|
||||
ON outbox (next_attempt_at)
|
||||
WHERE claimed_at IS NULL;
|
||||
|
||||
CREATE INDEX idx_outbox_app ON outbox (app_id);
|
||||
50
crates/manager-core/migrations/0010_dead_letters.sql
Normal file
50
crates/manager-core/migrations/0010_dead_letters.sql
Normal file
@@ -0,0 +1,50 @@
|
||||
-- v1.1.1: dead_letters — design notes §4.
|
||||
--
|
||||
-- Async invocations that exhaust their retry policy land here. Each
|
||||
-- row carries the original event payload verbatim plus the attempt
|
||||
-- history so handlers (registered via `dead_letter` triggers) and the
|
||||
-- dashboard can decide what to do.
|
||||
--
|
||||
-- Schema mirrors design notes §4. The CHECK constraint on
|
||||
-- `resolution` enforces the closed vocabulary used by both the SDK
|
||||
-- (`dead_letters::resolve(id, reason)`) and the recursion-stop rule
|
||||
-- (`handler_failed`). Sync HTTP failures (`reply_to.is_some()`) never
|
||||
-- land here — they're served via the inbox channel.
|
||||
--
|
||||
-- Indexes:
|
||||
-- - partial index on unresolved rows: the dashboard's
|
||||
-- unresolved-count badge query (`COUNT(*) WHERE app_id = $1 AND
|
||||
-- resolved_at IS NULL`).
|
||||
-- - GC index on `created_at`: the weekly retention sweep.
|
||||
|
||||
CREATE TABLE dead_letters (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
-- The outbox.id row that exhausted retries. The outbox row itself
|
||||
-- has been deleted at this point.
|
||||
original_event_id UUID NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
op TEXT NOT NULL,
|
||||
-- Nullable because direct admin replays may have no trigger row.
|
||||
trigger_id UUID,
|
||||
script_id UUID,
|
||||
payload JSONB NOT NULL,
|
||||
attempt_count INT NOT NULL,
|
||||
first_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
last_attempt_at TIMESTAMPTZ NOT NULL,
|
||||
last_error TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolution TEXT
|
||||
CHECK (resolution IN
|
||||
('replayed', 'ignored', 'handled_by_script', 'handler_failed'))
|
||||
);
|
||||
|
||||
-- Dashboard unresolved-count badge — partial index on the predicate
|
||||
-- the query uses.
|
||||
CREATE INDEX idx_dead_letters_app_unresolved
|
||||
ON dead_letters (app_id)
|
||||
WHERE resolved_at IS NULL;
|
||||
|
||||
-- GC sweep scans by creation time.
|
||||
CREATE INDEX idx_dead_letters_gc ON dead_letters (created_at);
|
||||
31
crates/manager-core/migrations/0011_abandoned_executions.sql
Normal file
31
crates/manager-core/migrations/0011_abandoned_executions.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- v1.1.1: abandoned_executions — design notes §3 #9.
|
||||
--
|
||||
-- Forensic table for the "dispatcher tried to resolve a oneshot inbox
|
||||
-- but the receiver was already dropped" edge case. The orchestrator
|
||||
-- timed out (returned 504 to the caller) and gave up on the channel,
|
||||
-- but then the dispatcher's execution succeeded later. The caller
|
||||
-- never sees the result; the row exists so the operator can
|
||||
-- correlate when the abandoned-counter metric spikes.
|
||||
--
|
||||
-- Only the dispatcher-after-orchestrator-timeout edge case writes
|
||||
-- here; ordinary "script timed out, caller got 504" stays uneventful.
|
||||
--
|
||||
-- 7-day retention, GC by `created_at`, sweep alongside dead_letters.
|
||||
|
||||
CREATE TABLE abandoned_executions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
-- Original outbox row id (the row itself has been deleted).
|
||||
outbox_id UUID NOT NULL,
|
||||
script_id UUID,
|
||||
-- The inbox channel id the dispatcher tried to resolve.
|
||||
inbox_id UUID NOT NULL,
|
||||
-- The HTTP status code the dispatcher attempted to send back.
|
||||
status_code INT NOT NULL,
|
||||
-- Truncated body / error description (capped at write time —
|
||||
-- the dispatcher doesn't need to ship megabytes here).
|
||||
result_summary TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_abandoned_executions_gc ON abandoned_executions (created_at);
|
||||
16
crates/manager-core/migrations/0012_routes_dispatch_mode.sql
Normal file
16
crates/manager-core/migrations/0012_routes_dispatch_mode.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- v1.1.1: per-route dispatch mode (design notes §2 + §3).
|
||||
--
|
||||
-- `sync` (default): orchestrator awaits the executor inline and
|
||||
-- returns the response in the same HTTP request — current MVP
|
||||
-- behaviour.
|
||||
-- `async`: orchestrator writes the request to the trigger outbox,
|
||||
-- returns `202 Accepted` immediately. The dispatcher runs the
|
||||
-- script in the background and surfaces failures via the
|
||||
-- retry / dead-letter machinery — same shape as any other async
|
||||
-- event.
|
||||
--
|
||||
-- Existing routes default to `sync` so the migration is non-breaking.
|
||||
|
||||
ALTER TABLE routes
|
||||
ADD COLUMN dispatch_mode TEXT NOT NULL DEFAULT 'sync'
|
||||
CHECK (dispatch_mode IN ('sync', 'async'));
|
||||
15
crates/manager-core/seeds/hello.rhai
Normal file
15
crates/manager-core/seeds/hello.rhai
Normal file
@@ -0,0 +1,15 @@
|
||||
// Hello World — the reference example seeded into the default app on
|
||||
// fresh installs. Bound to GET /hello.
|
||||
|
||||
let who = ctx.request.body;
|
||||
let name = if who != () && type_of(who) == "map" && who.contains("name") {
|
||||
who.name
|
||||
} else {
|
||||
"world"
|
||||
};
|
||||
|
||||
return #{
|
||||
statusCode: 200,
|
||||
headers: #{ "Content-Type": "application/json" },
|
||||
body: #{ message: `Hello, ${name}!` }
|
||||
};
|
||||
128
crates/manager-core/src/abandoned_repo.rs
Normal file
128
crates/manager-core/src/abandoned_repo.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
//! `AbandonedExecutionsRepo` — forensic table written by the
|
||||
//! dispatcher when it tries to resolve a sync-HTTP inbox channel
|
||||
//! that's already been dropped (orchestrator timed out and gave up).
|
||||
//!
|
||||
//! Schema: see `migrations/0011_abandoned_executions.sql`.
|
||||
//!
|
||||
//! Tiny surface: insert + GC. Reading happens via direct SQL when
|
||||
//! correlating the metric counter spike.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, ScriptId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AbandonedRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAbandonedExecution {
|
||||
pub app_id: AppId,
|
||||
pub outbox_id: Uuid,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub inbox_id: Uuid,
|
||||
pub status_code: u16,
|
||||
pub result_summary: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AbandonedRepo: Send + Sync {
|
||||
async fn insert(&self, row: NewAbandonedExecution) -> Result<Uuid, AbandonedRepoError>;
|
||||
|
||||
/// Retention sweep — deletes rows older than `older_than` up to
|
||||
/// `limit` at a time.
|
||||
async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, AbandonedRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAbandonedRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresAbandonedRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const SUMMARY_CAP_BYTES: usize = 4096;
|
||||
|
||||
#[async_trait]
|
||||
impl AbandonedRepo for PostgresAbandonedRepo {
|
||||
async fn insert(&self, row: NewAbandonedExecution) -> Result<Uuid, AbandonedRepoError> {
|
||||
// Truncate the summary at write-time. The forensic table
|
||||
// doesn't need megabytes; the original outbox row may have
|
||||
// been arbitrary size but we lose nothing useful by clipping.
|
||||
let summary = row.result_summary.map(|s| truncate(s, SUMMARY_CAP_BYTES));
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO abandoned_executions ( \
|
||||
app_id, outbox_id, script_id, inbox_id, status_code, result_summary \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(row.app_id.into_inner())
|
||||
.bind(row.outbox_id)
|
||||
.bind(row.script_id.map(ScriptId::into_inner))
|
||||
.bind(row.inbox_id)
|
||||
.bind(i32::from(row.status_code))
|
||||
.bind(summary)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, AbandonedRepoError> {
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM abandoned_executions \
|
||||
WHERE id IN ( \
|
||||
SELECT id FROM abandoned_executions \
|
||||
WHERE created_at < $1 \
|
||||
FOR UPDATE SKIP LOCKED \
|
||||
LIMIT $2 \
|
||||
)",
|
||||
)
|
||||
.bind(older_than)
|
||||
.bind(limit)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(mut s: String, max_bytes: usize) -> String {
|
||||
if s.len() <= max_bytes {
|
||||
return s;
|
||||
}
|
||||
// Walk back from `max_bytes` to a UTF-8 char boundary so we never
|
||||
// panic on `truncate` mid-codepoint.
|
||||
let mut cut = max_bytes;
|
||||
while cut > 0 && !s.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
s.truncate(cut);
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn truncate_respects_char_boundaries() {
|
||||
// 3-byte UTF-8 chars; cap inside the middle char should walk
|
||||
// back to the start.
|
||||
let s = "héllo".to_string();
|
||||
let t = truncate(s, 2);
|
||||
assert!(t.is_char_boundary(t.len()));
|
||||
assert_eq!(t, "h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_passthrough_for_short_strings() {
|
||||
assert_eq!(truncate("ok".into(), 100), "ok");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -20,6 +20,12 @@ pub enum AdminUserRepositoryError {
|
||||
|
||||
#[error("username already taken: {0}")]
|
||||
DuplicateUsername(String),
|
||||
|
||||
#[error("email already taken: {0}")]
|
||||
DuplicateEmail(String),
|
||||
|
||||
#[error("invalid instance_role stored in DB: {0}")]
|
||||
InvalidInstanceRole(String),
|
||||
}
|
||||
|
||||
/// Row returned to handlers and bootstrap. Never includes the password
|
||||
@@ -30,6 +36,8 @@ pub struct AdminUserRow {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
pub email: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
@@ -44,6 +52,7 @@ pub struct AdminUserCredentials {
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -58,10 +67,16 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError>;
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||
/// Create a new admin. `instance_role` defaults to `Owner` for the
|
||||
/// env-var bootstrap path; admin-creates-admin flows pass an
|
||||
/// explicit role. `email` is optional — pass `None` to leave the
|
||||
/// column NULL.
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
async fn update_username(
|
||||
&self,
|
||||
@@ -73,6 +88,20 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
id: AdminUserId,
|
||||
password_hash: &str,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
/// Set or clear the email address. `None` writes NULL to the column.
|
||||
async fn update_email(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
/// Update the instance_role. Used by `PATCH /api/v1/admin/admins/{id}`;
|
||||
/// callers enforce the last-owner guard (`count_other_active_owners`)
|
||||
/// before invoking when role transitions away from `Owner`.
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError>;
|
||||
async fn set_active(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
@@ -90,6 +119,15 @@ pub trait AdminUserRepository: Send + Sync {
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError>;
|
||||
/// All active owners — used for the multi-owner startup warning.
|
||||
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError>;
|
||||
/// Count of active owners excluding the given id. Used by the
|
||||
/// last-owner guard when demoting / deactivating / deleting an
|
||||
/// owner: "would this leave zero owners?"
|
||||
async fn count_other_active_owners(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAdminUserRepository {
|
||||
@@ -107,13 +145,14 @@ impl PostgresAdminUserRepository {
|
||||
impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
async fn get(&self, id: AdminUserId) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn get_by_username(
|
||||
@@ -121,13 +160,14 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn get_credentials_by_username(
|
||||
@@ -135,45 +175,62 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
username: &str,
|
||||
) -> Result<Option<AdminUserCredentials>, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminCredsRecord>(
|
||||
"SELECT id, username, password_hash, is_active \
|
||||
"SELECT id, username, password_hash, is_active, instance_role \
|
||||
FROM admin_users WHERE username = $1",
|
||||
)
|
||||
.bind(username)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, created_at, updated_at, last_login_at \
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users ORDER BY username",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"INSERT INTO admin_users (username, password_hash) \
|
||||
VALUES ($1, $2) \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
"INSERT INTO admin_users (username, password_hash, instance_role, email) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(username)
|
||||
.bind(password_hash)
|
||||
.bind(instance_role.as_str())
|
||||
.bind(email)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||
),
|
||||
Ok(row) => row.try_into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
// username and email both have unique constraints; the
|
||||
// create path can collide on either, so peek at the
|
||||
// constraint name to surface the right error.
|
||||
if e.constraint() == Some("admin_users_email_key") {
|
||||
Err(AdminUserRepositoryError::DuplicateEmail(
|
||||
email.unwrap_or("").to_string(),
|
||||
))
|
||||
} else {
|
||||
Err(AdminUserRepositoryError::DuplicateUsername(
|
||||
username.to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
@@ -186,7 +243,8 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET username = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(username)
|
||||
@@ -194,7 +252,7 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.into()),
|
||||
Ok(Some(row)) => row.try_into(),
|
||||
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateUsername(username.to_string()),
|
||||
@@ -211,14 +269,60 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET password_hash = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(password_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn update_email(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET email = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(email)
|
||||
.fetch_optional(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(Some(row)) => row.try_into(),
|
||||
Ok(None) => Err(AdminUserRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
AdminUserRepositoryError::DuplicateEmail(email.unwrap_or("").to_string()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
instance_role: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET instance_role = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(instance_role.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn set_active(
|
||||
@@ -229,14 +333,15 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
let row = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"UPDATE admin_users SET is_active = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, username, is_active, created_at, updated_at, last_login_at",
|
||||
RETURNING id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(is_active)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
row.ok_or(AdminUserRepositoryError::NotFound(id))
|
||||
.and_then(TryInto::try_into)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: AdminUserId) -> Result<(), AdminUserRepositoryError> {
|
||||
@@ -277,6 +382,33 @@ impl AdminUserRepository for PostgresAdminUserRepository {
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AdminUserRecord>(
|
||||
"SELECT id, username, is_active, instance_role, email, \
|
||||
created_at, updated_at, last_login_at \
|
||||
FROM admin_users \
|
||||
WHERE is_active AND instance_role = 'owner' \
|
||||
ORDER BY username",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn count_other_active_owners(
|
||||
&self,
|
||||
id: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*)::BIGINT FROM admin_users \
|
||||
WHERE is_active AND instance_role = 'owner' AND id <> $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
@@ -284,21 +416,28 @@ struct AdminUserRecord {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
is_active: bool,
|
||||
instance_role: String,
|
||||
email: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl From<AdminUserRecord> for AdminUserRow {
|
||||
fn from(r: AdminUserRecord) -> Self {
|
||||
Self {
|
||||
impl TryFrom<AdminUserRecord> for AdminUserRow {
|
||||
type Error = AdminUserRepositoryError;
|
||||
fn try_from(r: AdminUserRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
username: r.username,
|
||||
is_active: r.is_active,
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||
)?,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
last_login_at: r.last_login_at,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,15 +447,20 @@ struct AdminCredsRecord {
|
||||
username: String,
|
||||
password_hash: String,
|
||||
is_active: bool,
|
||||
instance_role: String,
|
||||
}
|
||||
|
||||
impl From<AdminCredsRecord> for AdminUserCredentials {
|
||||
fn from(r: AdminCredsRecord) -> Self {
|
||||
Self {
|
||||
impl TryFrom<AdminCredsRecord> for AdminUserCredentials {
|
||||
type Error = AdminUserRepositoryError;
|
||||
fn try_from(r: AdminCredsRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
username: r.username,
|
||||
password_hash: r.password_hash,
|
||||
is_active: r.is_active,
|
||||
}
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role).ok_or(
|
||||
AdminUserRepositoryError::InvalidInstanceRole(r.instance_role),
|
||||
)?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,17 @@ use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::admin_session_repo::AdminSessionRepository;
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||
use crate::api_key_repo::ApiKeyRepository;
|
||||
use crate::auth::hash_password;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
|
||||
/// Validation knobs are tuned by NIST 800-63B-ish guidance: username is
|
||||
/// a strict ASCII subset so the lookup column stays predictable, and
|
||||
@@ -36,6 +38,13 @@ const PASSWORD_MIN: usize = 8;
|
||||
pub struct AdminsState {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
/// Phase 3.5 deactivation symmetry — flipping `is_active = false`
|
||||
/// also expires every active API key for that user so cookie and
|
||||
/// bearer credentials become inert at the same moment.
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
/// Capability gate: every endpoint here requires
|
||||
/// `InstanceManageUsers` (owner / admin).
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn admins_router(state: AdminsState) -> Router {
|
||||
@@ -57,6 +66,8 @@ pub struct AdminDto {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
pub email: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -67,6 +78,8 @@ impl From<AdminUserRow> for AdminDto {
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
is_active: r.is_active,
|
||||
instance_role: r.instance_role,
|
||||
email: r.email,
|
||||
created_at: r.created_at,
|
||||
last_login_at: r.last_login_at,
|
||||
}
|
||||
@@ -77,6 +90,18 @@ impl From<AdminUserRow> for AdminDto {
|
||||
pub struct CreateAdminRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
/// Defaults to `Admin` when absent — minting an owner via the API
|
||||
/// is a deliberate step. The env-var bootstrap path is the only
|
||||
/// channel that defaults to `Owner`.
|
||||
#[serde(default = "default_create_role")]
|
||||
pub instance_role: InstanceRole,
|
||||
/// Optional contact email. Blank/whitespace is normalized to None.
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
const fn default_create_role() -> InstanceRole {
|
||||
InstanceRole::Admin
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
@@ -84,6 +109,27 @@ pub struct PatchAdminRequest {
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub is_active: Option<bool>,
|
||||
pub instance_role: Option<InstanceRole>,
|
||||
/// JSON Merge Patch (RFC 7396) semantics for email:
|
||||
/// absent → don't change
|
||||
/// null → clear (set DB column to NULL)
|
||||
/// "<string>" → set to that string
|
||||
/// `Option<Option<T>>` is the idiomatic Rust shape for that
|
||||
/// tri-state; the custom deserializer below distinguishes the
|
||||
/// "missing" case from the "present-and-null" case that serde
|
||||
/// would otherwise collapse together.
|
||||
#[allow(clippy::option_option)]
|
||||
#[serde(default, deserialize_with = "deserialize_present_optional")]
|
||||
pub email: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[allow(clippy::option_option)]
|
||||
fn deserialize_present_optional<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
|
||||
where
|
||||
T: serde::Deserialize<'de>,
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Ok(Some(Option::<T>::deserialize(deserializer)?))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -92,15 +138,29 @@ pub struct PatchAdminRequest {
|
||||
|
||||
async fn list_admins(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<AdminDto>>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
let rows = state.users.list().await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn get_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.users
|
||||
.get(id)
|
||||
@@ -112,24 +172,50 @@ async fn get_admin(
|
||||
|
||||
async fn create_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateAdminRequest>,
|
||||
) -> Result<(StatusCode, Json<AdminDto>), AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
// Minting an owner via the API requires the caller to ALSO be an
|
||||
// owner — admin cannot self-elevate (or elevate someone else)
|
||||
// beyond their own ceiling. Owner-creation by env-var bootstrap
|
||||
// bypasses this path.
|
||||
if input.instance_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner
|
||||
{
|
||||
return Err(AdminApiError::CannotEscalate);
|
||||
}
|
||||
let username = input.username.trim();
|
||||
validate_username(username)?;
|
||||
validate_password(&input.password)?;
|
||||
let email = normalize_email(input.email.as_deref())?;
|
||||
let hash = hash_password(&input.password).map_err(|e| AdminApiError::Hash(e.to_string()))?;
|
||||
let row = state.users.create(username, &hash).await?;
|
||||
let row = state
|
||||
.users
|
||||
.create(username, &hash, input.instance_role, email.as_deref())
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(row.into())))
|
||||
}
|
||||
|
||||
async fn patch_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
Json(input): Json<PatchAdminRequest>,
|
||||
) -> Result<Json<AdminDto>, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
// Verify the target exists upfront — keeps the error path uniform
|
||||
// for "rename a missing user" etc.
|
||||
let _ = state
|
||||
let current = state
|
||||
.users
|
||||
.get(id)
|
||||
.await?
|
||||
@@ -154,6 +240,32 @@ async fn patch_admin(
|
||||
// for the initial cut.)
|
||||
}
|
||||
|
||||
if let Some(email_patch) = input.email.as_ref() {
|
||||
// email_patch is Some(None) → clear, Some(Some(s)) → set.
|
||||
let normalized = normalize_email(email_patch.as_deref())?;
|
||||
latest = Some(state.users.update_email(id, normalized.as_deref()).await?);
|
||||
}
|
||||
|
||||
if let Some(new_role) = input.instance_role {
|
||||
// Self-elevation guard: only an owner can promote anyone TO
|
||||
// owner. An admin cannot turn themselves (or anyone else)
|
||||
// into one.
|
||||
if new_role == InstanceRole::Owner && principal.instance_role != InstanceRole::Owner {
|
||||
return Err(AdminApiError::CannotEscalate);
|
||||
}
|
||||
// Last-active-owner guard: a transition off of `Owner` cannot
|
||||
// leave the install with zero owners. The check is on the
|
||||
// source role (current.instance_role) so demoting an
|
||||
// already-non-owner is always fine.
|
||||
if current.instance_role == InstanceRole::Owner && new_role != InstanceRole::Owner {
|
||||
let remaining = state.users.count_other_active_owners(id).await?;
|
||||
if remaining == 0 {
|
||||
return Err(AdminApiError::LastActiveOwner);
|
||||
}
|
||||
}
|
||||
latest = Some(state.users.update_instance_role(id, new_role).await?);
|
||||
}
|
||||
|
||||
if let Some(new_active) = input.is_active {
|
||||
// Last-active-admin guard: only when transitioning to inactive.
|
||||
if !new_active {
|
||||
@@ -161,14 +273,40 @@ async fn patch_admin(
|
||||
if remaining == 0 {
|
||||
return Err(AdminApiError::LastActiveAdmin);
|
||||
}
|
||||
// ALSO: if the target is currently the last active owner,
|
||||
// deactivating them leaves no owner. Belt-and-suspenders to
|
||||
// the role guard above (which only triggers on an explicit
|
||||
// role transition).
|
||||
let target_role = latest
|
||||
.as_ref()
|
||||
.map_or(current.instance_role, |r| r.instance_role);
|
||||
if target_role == InstanceRole::Owner {
|
||||
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||
if remaining_owners == 0 {
|
||||
return Err(AdminApiError::LastActiveOwner);
|
||||
}
|
||||
}
|
||||
}
|
||||
latest = Some(state.users.set_active(id, new_active).await?);
|
||||
// Deactivation invalidates all of the user's sessions. Cheap
|
||||
// and safer than waiting for sliding-window expiry.
|
||||
// Deactivation invalidates BOTH credential surfaces — sessions
|
||||
// (cookie / session bearer) and API keys. Both writes are
|
||||
// logged on failure but do not undo the deactivation; the
|
||||
// alternative (leaving the user active when one cascade fails)
|
||||
// is worse than slightly stale credential rows on a DB blip.
|
||||
if !new_active {
|
||||
if let Err(err) = state.sessions.delete_for_user(id).await {
|
||||
tracing::error!(?err, "failed to delete sessions for deactivated admin");
|
||||
}
|
||||
match state.keys.expire_all_for_user(id).await {
|
||||
Ok(n) => {
|
||||
if n > 0 {
|
||||
tracing::info!(user_id = %id, expired = n, "expired api keys on deactivation");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "failed to expire api keys for deactivated admin");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +323,15 @@ async fn patch_admin(
|
||||
|
||||
async fn delete_admin(
|
||||
State(state): State<AdminsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<AdminUserId>,
|
||||
) -> Result<StatusCode, AdminApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceManageUsers,
|
||||
)
|
||||
.await?;
|
||||
let target = state
|
||||
.users
|
||||
.get(id)
|
||||
@@ -197,9 +342,18 @@ async fn delete_admin(
|
||||
if remaining == 0 {
|
||||
return Err(AdminApiError::LastActiveAdmin);
|
||||
}
|
||||
// Last-owner guard mirrors the role-transition guard in
|
||||
// patch_admin — deleting the only owner is just as bad as
|
||||
// demoting them.
|
||||
if target.instance_role == InstanceRole::Owner {
|
||||
let remaining_owners = state.users.count_other_active_owners(id).await?;
|
||||
if remaining_owners == 0 {
|
||||
return Err(AdminApiError::LastActiveOwner);
|
||||
}
|
||||
}
|
||||
}
|
||||
state.users.delete(id).await?;
|
||||
// Sessions cascade via FK; no explicit delete needed.
|
||||
// Sessions + api_keys cascade via FK; no explicit delete needed.
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -234,6 +388,26 @@ fn validate_password(s: &str) -> Result<(), AdminApiError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Trim and reject empty / pathological emails, returning the
|
||||
/// canonical form (or None when the input was blank). The shape
|
||||
/// check is intentionally loose — we mainly want to reject blanks
|
||||
/// and obvious junk; real verification is a future concern.
|
||||
fn normalize_email(raw: Option<&str>) -> Result<Option<String>, AdminApiError> {
|
||||
let Some(raw) = raw else {
|
||||
return Ok(None);
|
||||
};
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.len() > 254 || !trimmed.contains('@') {
|
||||
return Err(AdminApiError::InvalidEmail(
|
||||
"email must contain '@' and be at most 254 characters".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -249,9 +423,24 @@ pub enum AdminApiError {
|
||||
#[error("{0}")]
|
||||
InvalidPassword(String),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidEmail(String),
|
||||
|
||||
#[error("cannot leave the system with zero active admins")]
|
||||
LastActiveAdmin,
|
||||
|
||||
#[error("cannot leave the system with zero active owners")]
|
||||
LastActiveOwner,
|
||||
|
||||
#[error("only an owner can grant the owner role")]
|
||||
CannotEscalate,
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("failed to hash password: {0}")]
|
||||
Hash(String),
|
||||
|
||||
@@ -259,16 +448,40 @@ pub enum AdminApiError {
|
||||
Repo(#[from] AdminUserRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for AdminApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AdminApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::Repo(AdminUserRepositoryError::DuplicateUsername(_)) => {
|
||||
(StatusCode::CONFLICT, self.to_string())
|
||||
}
|
||||
Self::InvalidUsername(_) | Self::InvalidPassword(_) | Self::LastActiveAdmin => {
|
||||
Self::Repo(
|
||||
AdminUserRepositoryError::DuplicateUsername(_)
|
||||
| AdminUserRepositoryError::DuplicateEmail(_),
|
||||
) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::InvalidUsername(_)
|
||||
| Self::InvalidPassword(_)
|
||||
| Self::InvalidEmail(_)
|
||||
| Self::LastActiveAdmin
|
||||
| Self::LastActiveOwner
|
||||
| Self::CannotEscalate
|
||||
| Self::Repo(AdminUserRepositoryError::InvalidInstanceRole(_)) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "admin_users authz error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Repo(AdminUserRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
|
||||
@@ -5,17 +5,20 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{
|
||||
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
||||
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptSandbox, ScriptValidator,
|
||||
ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::{
|
||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||
};
|
||||
@@ -27,6 +30,13 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
|
||||
pub struct AdminState<R, L> {
|
||||
pub repo: Arc<R>,
|
||||
pub logs: Arc<L>,
|
||||
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
/// Phase 3.5 capability checks — every script handler resolves
|
||||
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
|
||||
/// loading the resource.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub validator: Arc<dyn ScriptValidator>,
|
||||
pub sandbox_ceiling: SandboxCeiling,
|
||||
}
|
||||
@@ -36,6 +46,8 @@ impl<R, L> Clone for AdminState<R, L> {
|
||||
Self {
|
||||
repo: self.repo.clone(),
|
||||
logs: self.logs.clone(),
|
||||
apps: self.apps.clone(),
|
||||
authz: self.authz.clone(),
|
||||
validator: self.validator.clone(),
|
||||
sandbox_ceiling: self.sandbox_ceiling,
|
||||
}
|
||||
@@ -70,6 +82,9 @@ where
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateScriptRequest {
|
||||
/// Owning app. Required since Phase 3b — scripts cannot exist
|
||||
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -82,6 +97,14 @@ pub struct CreateScriptRequest {
|
||||
pub sandbox: ScriptSandbox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListScriptsQuery {
|
||||
/// Optional filter: list scripts belonging to a single app, by id
|
||||
/// or slug. Absent = all scripts across all apps (admin-global view).
|
||||
#[serde(default)]
|
||||
pub app: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateScriptRequest {
|
||||
pub name: Option<String>,
|
||||
@@ -113,31 +136,83 @@ where
|
||||
|
||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Query(q): Query<ListScriptsQuery>,
|
||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||
// Membership filter: `member` users see only scripts in apps they
|
||||
// belong to. `?app=` filters further by app and additionally
|
||||
// requires the member to belong to that app (the read check uses
|
||||
// the resource's app_id).
|
||||
if let Some(ident) = q.app {
|
||||
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
|
||||
return Ok(Json(state.repo.list_for_app(app).await?));
|
||||
}
|
||||
if principal.instance_role == InstanceRole::Member {
|
||||
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
|
||||
}
|
||||
Ok(Json(state.repo.list().await?))
|
||||
}
|
||||
|
||||
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||
/// for redirects, but here we just need the live current id; if a
|
||||
/// retired slug is given, we follow it to the current app silently.
|
||||
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
|
||||
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
let id = AppId::from(uuid);
|
||||
apps.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||
return Ok(id);
|
||||
}
|
||||
let lookup = apps
|
||||
.get_by_slug_or_history(ident)
|
||||
.await?
|
||||
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||
Ok(lookup.app.id)
|
||||
}
|
||||
|
||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<Json<Script>, ApiError> {
|
||||
state
|
||||
.repo
|
||||
.get(id)
|
||||
.await?
|
||||
.map(Json)
|
||||
.ok_or(ApiError::NotFound(id))
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(script))
|
||||
}
|
||||
|
||||
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateScriptRequest>,
|
||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||
// Capability is bound to the *requested* app_id since there's no
|
||||
// resource to load yet. If the app doesn't exist we 422 below;
|
||||
// checking authz first means a Member trying to create against an
|
||||
// unknown app gets 403 (no enumeration of app existence).
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.validator.validate(&input.source)?;
|
||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||
// raw FK violation surfacing as 500.
|
||||
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
||||
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
||||
}
|
||||
let created = state
|
||||
.repo
|
||||
.create(NewScript {
|
||||
app_id: input.app_id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
@@ -155,9 +230,17 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
|
||||
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
Json(input): Json<UpdateScriptRequest>,
|
||||
) -> Result<Json<Script>, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
if let Some(src) = input.source.as_deref() {
|
||||
state.validator.validate(src)?;
|
||||
}
|
||||
@@ -183,8 +266,19 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
|
||||
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
// Delete is gated tighter than Save: editors can edit scripts but
|
||||
// only app_admin / instance admin / owner can remove them. See
|
||||
// blueprint §11.6.
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppAdmin(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.repo.delete(id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -203,9 +297,17 @@ const fn default_limit() -> i64 {
|
||||
|
||||
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ScriptId>,
|
||||
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
|
||||
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppLogRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
// Cap to keep the dashboard responsive; the data plane writes are
|
||||
// unbounded over time so a paged read is the only sane default.
|
||||
let limit = q.limit.clamp(1, 200);
|
||||
@@ -223,6 +325,9 @@ pub enum ApiError {
|
||||
#[error("script not found: {0}")]
|
||||
NotFound(ScriptId),
|
||||
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
@@ -232,18 +337,42 @@ pub enum ApiError {
|
||||
#[error("{0}")]
|
||||
Ceiling(#[from] CeilingError),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for ApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
|
||||
292
crates/manager-core/src/api_key_repo.rs
Normal file
292
crates/manager-core/src/api_key_repo.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! CRUD over the `api_keys` table — backs the `Authorization: Bearer
|
||||
//! pic_…` credential flow from blueprint §11.6.
|
||||
//!
|
||||
//! The repo never sees the raw token; only the 8-char `prefix` and the
|
||||
//! Argon2id `hash`. Mint logic (random-bytes generation, prefix split,
|
||||
//! hash compute) lives in `api_keys_api.rs`. Verification logic
|
||||
//! (prefix lookup + Argon2 verify per candidate) lives in
|
||||
//! `auth_middleware.rs`. Both call this repo for the storage layer.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, ApiKeyId, AppId, Scope};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiKeyRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("api key not found: {0}")]
|
||||
NotFound(ApiKeyId),
|
||||
|
||||
#[error("invalid scope stored in DB: {0}")]
|
||||
InvalidScope(String),
|
||||
}
|
||||
|
||||
/// Insert payload — built by `api_keys_api` after generating the raw
|
||||
/// token and hashing it. `hash` is an Argon2id PHC string covering the
|
||||
/// body of the token (everything after `pic_`); `prefix` is the first
|
||||
/// 8 chars of that body, indexed for fast candidate lookup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewApiKey {
|
||||
pub user_id: AdminUserId,
|
||||
pub hash: String,
|
||||
pub prefix: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<Scope>,
|
||||
pub app_id: Option<AppId>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Public-facing row — never exposes the hash. Used for `GET
|
||||
/// /admin/api-keys` and the `POST` response (alongside the
|
||||
/// one-shot raw token).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyRow {
|
||||
pub id: ApiKeyId,
|
||||
pub user_id: AdminUserId,
|
||||
pub prefix: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<Scope>,
|
||||
pub app_id: Option<AppId>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Verification candidate — includes the Argon2id `hash` and `user_id`
|
||||
/// so middleware can verify the supplied token and assemble the
|
||||
/// `Principal`. Kept separate from `ApiKeyRow` so handlers can't leak
|
||||
/// the hash through a careless `Json(row)`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ApiKeyVerification {
|
||||
pub id: ApiKeyId,
|
||||
pub user_id: AdminUserId,
|
||||
pub hash: String,
|
||||
pub scopes: Vec<Scope>,
|
||||
pub app_id: Option<AppId>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ApiKeyRepository: Send + Sync {
|
||||
/// Mint. Caller has already hashed the raw token + computed prefix.
|
||||
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError>;
|
||||
|
||||
/// Return every non-expired key with the given 8-char prefix. The
|
||||
/// caller (middleware) Argon2-verifies the supplied token against
|
||||
/// each candidate's `hash`. Returning a Vec rather than one row
|
||||
/// keeps the contract correct even if two keys happen to share a
|
||||
/// prefix (statistically near-zero but possible).
|
||||
async fn find_active_by_prefix(
|
||||
&self,
|
||||
prefix: &str,
|
||||
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError>;
|
||||
|
||||
/// Update `last_used_at` for an authenticated request. Inline (not
|
||||
/// fire-and-forget) so a DB blip surfaces as a 500 rather than
|
||||
/// silent stale timestamps.
|
||||
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError>;
|
||||
|
||||
/// Caller's own keys, for `GET /admin/api-keys`.
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError>;
|
||||
|
||||
/// Look up a key by id — used by `DELETE` to verify ownership
|
||||
/// before issuing the delete.
|
||||
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError>;
|
||||
|
||||
/// Delete the row only if it belongs to `user_id`. Returns whether
|
||||
/// a row was actually deleted (false = key didn't exist OR wasn't
|
||||
/// theirs — handlers map both to 404 to avoid leaking the
|
||||
/// distinction).
|
||||
async fn delete_by_id_and_user(
|
||||
&self,
|
||||
id: ApiKeyId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<bool, ApiKeyRepositoryError>;
|
||||
|
||||
/// Set `expires_at = NOW()` on every active key for a user. Wired
|
||||
/// into `set_active(false)` so deactivation invalidates both
|
||||
/// sessions (already done by `AdminSessionRepository::delete_for_user`)
|
||||
/// and bearer keys at the same moment.
|
||||
async fn expire_all_for_user(&self, user_id: AdminUserId)
|
||||
-> Result<u64, ApiKeyRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresApiKeyRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresApiKeyRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ApiKeyRepository for PostgresApiKeyRepository {
|
||||
async fn create(&self, key: NewApiKey) -> Result<ApiKeyRow, ApiKeyRepositoryError> {
|
||||
let scope_strings: Vec<String> =
|
||||
key.scopes.iter().map(|s| s.as_str().to_string()).collect();
|
||||
let row = sqlx::query_as::<_, ApiKeyRecord>(
|
||||
"INSERT INTO api_keys \
|
||||
(user_id, hash, prefix, name, scopes, app_id, expires_at) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, user_id, prefix, name, scopes, app_id, \
|
||||
expires_at, last_used_at, created_at",
|
||||
)
|
||||
.bind(key.user_id.into_inner())
|
||||
.bind(&key.hash)
|
||||
.bind(&key.prefix)
|
||||
.bind(&key.name)
|
||||
.bind(&scope_strings)
|
||||
.bind(key.app_id.map(picloud_shared::AppId::into_inner))
|
||||
.bind(key.expires_at)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
row.try_into()
|
||||
}
|
||||
|
||||
async fn find_active_by_prefix(
|
||||
&self,
|
||||
prefix: &str,
|
||||
) -> Result<Vec<ApiKeyVerification>, ApiKeyRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ApiKeyVerifyRecord>(
|
||||
"SELECT id, user_id, hash, scopes, app_id \
|
||||
FROM api_keys \
|
||||
WHERE prefix = $1 \
|
||||
AND (expires_at IS NULL OR expires_at > NOW())",
|
||||
)
|
||||
.bind(prefix)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn touch_last_used(&self, id: ApiKeyId) -> Result<(), ApiKeyRepositoryError> {
|
||||
sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<ApiKeyRow>, ApiKeyRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ApiKeyRecord>(
|
||||
"SELECT id, user_id, prefix, name, scopes, app_id, \
|
||||
expires_at, last_used_at, created_at \
|
||||
FROM api_keys WHERE user_id = $1 \
|
||||
ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn get(&self, id: ApiKeyId) -> Result<Option<ApiKeyRow>, ApiKeyRepositoryError> {
|
||||
let row = sqlx::query_as::<_, ApiKeyRecord>(
|
||||
"SELECT id, user_id, prefix, name, scopes, app_id, \
|
||||
expires_at, last_used_at, created_at \
|
||||
FROM api_keys WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn delete_by_id_and_user(
|
||||
&self,
|
||||
id: ApiKeyId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<bool, ApiKeyRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM api_keys WHERE id = $1 AND user_id = $2")
|
||||
.bind(id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn expire_all_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<u64, ApiKeyRepositoryError> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE api_keys \
|
||||
SET expires_at = NOW() \
|
||||
WHERE user_id = $1 \
|
||||
AND (expires_at IS NULL OR expires_at > NOW())",
|
||||
)
|
||||
.bind(user_id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ApiKeyRecord {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
prefix: String,
|
||||
name: String,
|
||||
scopes: Vec<String>,
|
||||
app_id: Option<uuid::Uuid>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
last_used_at: Option<DateTime<Utc>>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<ApiKeyRecord> for ApiKeyRow {
|
||||
type Error = ApiKeyRepositoryError;
|
||||
fn try_from(r: ApiKeyRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
user_id: r.user_id.into(),
|
||||
prefix: r.prefix,
|
||||
name: r.name,
|
||||
scopes: parse_scopes(r.scopes)?,
|
||||
app_id: r.app_id.map(Into::into),
|
||||
expires_at: r.expires_at,
|
||||
last_used_at: r.last_used_at,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ApiKeyVerifyRecord {
|
||||
id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
hash: String,
|
||||
scopes: Vec<String>,
|
||||
app_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
impl TryFrom<ApiKeyVerifyRecord> for ApiKeyVerification {
|
||||
type Error = ApiKeyRepositoryError;
|
||||
fn try_from(r: ApiKeyVerifyRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
id: r.id.into(),
|
||||
user_id: r.user_id.into(),
|
||||
hash: r.hash,
|
||||
scopes: parse_scopes(r.scopes)?,
|
||||
app_id: r.app_id.map(Into::into),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_scopes(raw: Vec<String>) -> Result<Vec<Scope>, ApiKeyRepositoryError> {
|
||||
raw.into_iter()
|
||||
.map(|s| Scope::from_wire(&s).ok_or(ApiKeyRepositoryError::InvalidScope(s)))
|
||||
.collect()
|
||||
}
|
||||
251
crates/manager-core/src/api_keys_api.rs
Normal file
251
crates/manager-core/src/api_keys_api.rs
Normal file
@@ -0,0 +1,251 @@
|
||||
//! `/api/v1/admin/api-keys/*` — bearer API key CRUD (blueprint §11.6).
|
||||
//!
|
||||
//! All endpoints are guarded by `require_authenticated`. Capability
|
||||
//! checks: none — every authenticated user manages **their own** keys.
|
||||
//! The repo enforces caller ownership on `delete`, and `list` is
|
||||
//! scoped to the caller's user_id. No instance-level authority is
|
||||
//! exposed (no listing other users' keys, no admin-issued keys for
|
||||
//! another user — those flows belong with the invite system).
|
||||
//!
|
||||
//! Mint semantics:
|
||||
//! * raw token is returned **exactly once** in the POST response and
|
||||
//! never logged. Lose it = mint a new key.
|
||||
//! * `app_id` (optional) binds the key to one app; capability checks
|
||||
//! deny every `App*(other_app)`.
|
||||
//! * scopes containing `instance:*` are rejected when `app_id` is
|
||||
//! set — the combination is irreconcilable.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get};
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ApiKeyId, AppId, Principal, Scope};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::api_key_repo::{ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, NewApiKey};
|
||||
use crate::auth::generate_api_key;
|
||||
|
||||
/// Validation bounds for the user-supplied `name` field — keeps the
|
||||
/// dashboard's list view tidy and rejects accidental whole-token
|
||||
/// pastes.
|
||||
const NAME_MIN: usize = 1;
|
||||
const NAME_MAX: usize = 64;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ApiKeysState {
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
}
|
||||
|
||||
pub fn api_keys_router(state: ApiKeysState) -> Router {
|
||||
Router::new()
|
||||
.route("/api-keys", get(list_keys).post(mint_key))
|
||||
.route("/api-keys/{id}", delete(delete_key))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MintApiKeyRequest {
|
||||
pub name: String,
|
||||
pub scopes: Vec<Scope>,
|
||||
/// When set, the key is bound to this app — every `App*(other)`
|
||||
/// capability is denied regardless of role.
|
||||
#[serde(default)]
|
||||
pub app_id: Option<AppId>,
|
||||
/// When set, lookup rejects the key after this instant. Absent =
|
||||
/// never expires (until explicit DELETE).
|
||||
#[serde(default)]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Response body for a freshly-minted key. `raw_token` only appears
|
||||
/// here — `GET /api-keys` returns `ApiKeyDto` without it.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MintApiKeyResponse {
|
||||
#[serde(flatten)]
|
||||
pub key: ApiKeyDto,
|
||||
/// The full wire-format token (`pic_<base32>`). Shown exactly once;
|
||||
/// store it client-side immediately.
|
||||
pub raw_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ApiKeyDto {
|
||||
pub id: ApiKeyId,
|
||||
pub prefix: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<Scope>,
|
||||
pub app_id: Option<AppId>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<ApiKeyRow> for ApiKeyDto {
|
||||
fn from(r: ApiKeyRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
prefix: r.prefix,
|
||||
name: r.name,
|
||||
scopes: r.scopes,
|
||||
app_id: r.app_id,
|
||||
expires_at: r.expires_at,
|
||||
last_used_at: r.last_used_at,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn mint_key(
|
||||
State(state): State<ApiKeysState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<MintApiKeyRequest>,
|
||||
) -> Result<(StatusCode, Json<MintApiKeyResponse>), ApiKeysError> {
|
||||
validate_name(&input.name)?;
|
||||
validate_scopes(&input.scopes, input.app_id)?;
|
||||
|
||||
let minted = generate_api_key().map_err(|e| ApiKeysError::Hash(e.to_string()))?;
|
||||
let row = state
|
||||
.keys
|
||||
.create(NewApiKey {
|
||||
user_id: principal.user_id,
|
||||
hash: minted.hash,
|
||||
prefix: minted.prefix,
|
||||
name: input.name,
|
||||
scopes: input.scopes,
|
||||
app_id: input.app_id,
|
||||
expires_at: input.expires_at,
|
||||
})
|
||||
.await?;
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(MintApiKeyResponse {
|
||||
key: row.into(),
|
||||
raw_token: minted.raw,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn list_keys(
|
||||
State(state): State<ApiKeysState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<ApiKeyDto>>, ApiKeysError> {
|
||||
let rows = state.keys.list_for_user(principal.user_id).await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn delete_key(
|
||||
State(state): State<ApiKeysState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id): Path<ApiKeyId>,
|
||||
) -> Result<StatusCode, ApiKeysError> {
|
||||
let deleted = state
|
||||
.keys
|
||||
.delete_by_id_and_user(id, principal.user_id)
|
||||
.await?;
|
||||
if !deleted {
|
||||
// 404 covers both "doesn't exist" and "exists but not yours" —
|
||||
// we deliberately don't leak the distinction.
|
||||
return Err(ApiKeysError::NotFound(id));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn validate_name(s: &str) -> Result<(), ApiKeysError> {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.len() < NAME_MIN || trimmed.len() > NAME_MAX {
|
||||
return Err(ApiKeysError::InvalidName(format!(
|
||||
"name must be {NAME_MIN}-{NAME_MAX} characters after trimming"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_scopes(scopes: &[Scope], app_id: Option<AppId>) -> Result<(), ApiKeysError> {
|
||||
if scopes.is_empty() {
|
||||
return Err(ApiKeysError::InvalidScopes(
|
||||
"scopes must be non-empty".into(),
|
||||
));
|
||||
}
|
||||
// Bound key + any instance:* scope → irreconcilable.
|
||||
if app_id.is_some() && scopes.iter().any(|s| s.is_instance()) {
|
||||
return Err(ApiKeysError::InvalidScopes(
|
||||
"bound keys (app_id set) cannot carry instance:* scopes".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiKeysError {
|
||||
#[error("api key not found: {0}")]
|
||||
NotFound(ApiKeyId),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidName(String),
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidScopes(String),
|
||||
|
||||
#[error("failed to hash key: {0}")]
|
||||
Hash(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ApiKeyRepositoryError),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiKeysError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::InvalidName(_) | Self::InvalidScopes(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Hash(_) => {
|
||||
tracing::error!(error = %self, "api key hash failure");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Repo(ApiKeyRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, self.to_string())
|
||||
}
|
||||
Self::Repo(ApiKeyRepositoryError::InvalidScope(_)) => {
|
||||
tracing::error!(error = %self, "api key row carries an unknown scope");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
Self::Repo(ApiKeyRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "api_keys db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
}
|
||||
93
crates/manager-core/src/app_bootstrap.rs
Normal file
93
crates/manager-core/src/app_bootstrap.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
//! Hello-World seed for fresh installs.
|
||||
//!
|
||||
//! Idempotent. Runs after migrations and after admin bootstrap. Only
|
||||
//! seeds when the default app is empty (no scripts, no routes); on
|
||||
//! upgrades it does nothing so existing content isn't polluted.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{App, AppId, HostKind, PathKind};
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
const HELLO_RHAI_SOURCE: &str = include_str!("../seeds/hello.rhai");
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HelloWorldOutcome {
|
||||
/// Default app already has scripts (or doesn't exist) — left alone.
|
||||
SkippedExisting,
|
||||
/// Inserted the hello.rhai script and the `/hello` route.
|
||||
Seeded,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SeedError {
|
||||
#[error("default app not found — did the migration run?")]
|
||||
MissingDefaultApp,
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
pub async fn seed_hello_world_if_fresh(
|
||||
apps: Arc<dyn AppRepository>,
|
||||
scripts: Arc<dyn ScriptRepository>,
|
||||
routes: Arc<dyn RouteRepository>,
|
||||
) -> Result<HelloWorldOutcome, SeedError> {
|
||||
let default = apps
|
||||
.get_by_slug("default")
|
||||
.await?
|
||||
.ok_or(SeedError::MissingDefaultApp)?;
|
||||
|
||||
// Idempotence: only seed when both scripts AND routes are empty.
|
||||
// (Either alone is suspicious enough to skip — the operator may have
|
||||
// already started shaping the default app.)
|
||||
let existing_scripts = scripts.list_for_app(default.id).await?;
|
||||
let existing_routes = routes.list_for_app(default.id).await?;
|
||||
if !existing_scripts.is_empty() || !existing_routes.is_empty() {
|
||||
return Ok(HelloWorldOutcome::SkippedExisting);
|
||||
}
|
||||
|
||||
seed_into(&*scripts, &*routes, &default).await?;
|
||||
Ok(HelloWorldOutcome::Seeded)
|
||||
}
|
||||
|
||||
async fn seed_into(
|
||||
scripts: &dyn ScriptRepository,
|
||||
routes: &dyn RouteRepository,
|
||||
default: &App,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
let script = scripts
|
||||
.create(NewScript {
|
||||
app_id: default.id,
|
||||
name: "hello".to_string(),
|
||||
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||
source: HELLO_RHAI_SOURCE.to_string(),
|
||||
timeout_seconds: Some(5),
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
routes
|
||||
.create(NewRoute {
|
||||
app_id: default.id,
|
||||
script_id: script.id,
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/hello".to_string(),
|
||||
// Accept any method so both `curl /hello` and
|
||||
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _typecheck(_id: AppId) {} // suppress unused-import warnings if reshuffled
|
||||
152
crates/manager-core/src/app_domain_repo.rs
Normal file
152
crates/manager-core/src/app_domain_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! CRUD over the `app_domains` table.
|
||||
//!
|
||||
//! Parsing + shape_key derivation live in `orchestrator-core`'s
|
||||
//! `routing::pattern::parse_app_domain` — this repo just stores what
|
||||
//! the API handler hands it. Same-shape collisions surface as a unique
|
||||
//! constraint violation on `shape_key`, mapped here to a clean error.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppDomain, AppId, DomainShape};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAppDomain {
|
||||
pub app_id: AppId,
|
||||
pub pattern: String,
|
||||
pub shape: DomainShape,
|
||||
pub shape_key: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AppDomainRepository: Send + Sync {
|
||||
/// All domain claims across all apps — used by the orchestrator's
|
||||
/// `AppDomainTable` to build its lookup cache at startup and after
|
||||
/// every write.
|
||||
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError>;
|
||||
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAppDomainRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresAppDomainRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppDomainRepository for PostgresAppDomainRepository {
|
||||
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, DomainRow>(
|
||||
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||
FROM app_domains ORDER BY pattern",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, DomainRow>(
|
||||
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||
FROM app_domains WHERE app_id = $1 ORDER BY pattern",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, DomainRow>(
|
||||
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||
FROM app_domains WHERE id = $1",
|
||||
)
|
||||
.bind(domain_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, DomainRow>(
|
||||
"INSERT INTO app_domains (app_id, pattern, shape, shape_key) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, app_id, pattern, shape, shape_key, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.pattern)
|
||||
.bind(shape_str(input.shape))
|
||||
.bind(&input.shape_key)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(format!(
|
||||
"domain {:?} (or another claim of the same shape) is already claimed",
|
||||
input.pattern
|
||||
)))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM app_domains WHERE id = $1")
|
||||
.bind(domain_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"domain {domain_id} not found"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const fn shape_str(s: DomainShape) -> &'static str {
|
||||
match s {
|
||||
DomainShape::Exact => "exact",
|
||||
DomainShape::Wildcard => "wildcard",
|
||||
DomainShape::Parameterized => "parameterized",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DomainRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
pattern: String,
|
||||
shape: String,
|
||||
shape_key: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<DomainRow> for AppDomain {
|
||||
fn from(r: DomainRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
pattern: r.pattern,
|
||||
shape: match r.shape.as_str() {
|
||||
"wildcard" => DomainShape::Wildcard,
|
||||
"parameterized" => DomainShape::Parameterized,
|
||||
_ => DomainShape::Exact,
|
||||
},
|
||||
shape_key: r.shape_key,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
331
crates/manager-core/src/app_members_api.rs
Normal file
331
crates/manager-core/src/app_members_api.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
//! `/api/v1/admin/apps/{id_or_slug}/members/*` — CRUD over the
|
||||
//! `app_members` table (blueprint §11.6).
|
||||
//!
|
||||
//! Every endpoint is gated on `Capability::AppAdmin(app_id)` after
|
||||
//! resolving the app from `id_or_slug`. Editors and viewers receive
|
||||
//! 403 from list and never see the dashboard's Members tab.
|
||||
//!
|
||||
//! POST is **non-idempotent on purpose**: a duplicate `(app_id,
|
||||
//! user_id)` returns 409 rather than upsert-200, so the UI can show
|
||||
//! "already a member — promote / demote them instead" cleanly. Role
|
||||
//! changes go through PATCH.
|
||||
//!
|
||||
//! No last-app-admin guard: owners always implicitly satisfy
|
||||
//! `Capability::AppAdmin(_)` (authz::role_grants), so removing the
|
||||
//! final explicit `app_admin` membership cannot orphan an app.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, patch};
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppRole, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError, AdminUserRow};
|
||||
use crate::app_members_repo::{
|
||||
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||
};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppMembersState {
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub members: Arc<dyn AppMembersRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn app_members_router(state: AppMembersState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/apps/{id_or_slug}/members",
|
||||
get(list_members).post(grant_member),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id_or_slug}/members/{user_id}",
|
||||
patch(patch_member).delete(remove_member),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppMemberDto {
|
||||
pub user_id: AdminUserId,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub instance_role: InstanceRole,
|
||||
pub is_active: bool,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<AppMembershipDetail> for AppMemberDto {
|
||||
fn from(d: AppMembershipDetail) -> Self {
|
||||
Self {
|
||||
user_id: d.user_id,
|
||||
username: d.username,
|
||||
email: d.email,
|
||||
instance_role: d.instance_role,
|
||||
is_active: d.is_active,
|
||||
role: d.role,
|
||||
created_at: d.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose a DTO from an `AdminUserRow` (fetched for validation) and
|
||||
/// the `AppMembershipRow` returned by `upsert`. Saves a re-fetch on
|
||||
/// POST/PATCH at the cost of trusting the two inputs reference the
|
||||
/// same user_id — caller's responsibility.
|
||||
fn compose_dto(user: AdminUserRow, membership: AppMembershipRow) -> AppMemberDto {
|
||||
AppMemberDto {
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
instance_role: user.instance_role,
|
||||
is_active: user.is_active,
|
||||
role: membership.role,
|
||||
created_at: membership.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GrantMemberRequest {
|
||||
pub user_id: AdminUserId,
|
||||
pub role: AppRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchMemberRequest {
|
||||
pub role: AppRole,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_members(
|
||||
State(s): State<AppMembersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<AppMemberDto>>, AppMembersApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||
let rows = s.members.list_for_app_enriched(app.id).await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn grant_member(
|
||||
State(s): State<AppMembersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<GrantMemberRequest>,
|
||||
) -> Result<(StatusCode, Json<AppMemberDto>), AppMembersApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||
|
||||
let user = s
|
||||
.users
|
||||
.get(input.user_id)
|
||||
.await?
|
||||
.ok_or(AppMembersApiError::UserNotFound(input.user_id))?;
|
||||
validate_grant_target(&user)?;
|
||||
|
||||
// Atomic insert — if a row already exists, returns None and we 409.
|
||||
// Avoids the find-then-upsert race where two concurrent POSTs would
|
||||
// both pass the existence check and the second `upsert` would
|
||||
// silently rewrite the role.
|
||||
let row = s
|
||||
.members
|
||||
.try_insert(app.id, user.id, input.role)
|
||||
.await?
|
||||
.ok_or_else(|| AppMembersApiError::AlreadyMember {
|
||||
username: user.username.clone(),
|
||||
})?;
|
||||
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
|
||||
}
|
||||
|
||||
async fn patch_member(
|
||||
State(s): State<AppMembersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||
Json(input): Json<PatchMemberRequest>,
|
||||
) -> Result<Json<AppMemberDto>, AppMembersApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||
|
||||
let user_id = AdminUserId::from(user_id);
|
||||
let user = s
|
||||
.users
|
||||
.get(user_id)
|
||||
.await?
|
||||
.ok_or(AppMembersApiError::UserNotFound(user_id))?;
|
||||
|
||||
// Atomic update — returns None if no row exists, so 404 is decided
|
||||
// by the same statement that does the write. Eliminates the
|
||||
// find-then-upsert race where a concurrent DELETE between the two
|
||||
// calls would let PATCH silently re-create the row.
|
||||
let row = s
|
||||
.members
|
||||
.update_role(app.id, user_id, input.role)
|
||||
.await?
|
||||
.ok_or(AppMembersApiError::MembershipNotFound)?;
|
||||
Ok(Json(compose_dto(user, row)))
|
||||
}
|
||||
|
||||
async fn remove_member(
|
||||
State(s): State<AppMembersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||
) -> Result<StatusCode, AppMembersApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||
s.members.remove(app.id, AdminUserId::from(user_id)).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Validation + helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn validate_grant_target(user: &AdminUserRow) -> Result<(), AppMembersApiError> {
|
||||
if !user.is_active {
|
||||
return Err(AppMembersApiError::TargetInactive {
|
||||
username: user.username.clone(),
|
||||
});
|
||||
}
|
||||
if user.instance_role != InstanceRole::Member {
|
||||
return Err(AppMembersApiError::TargetNotMember {
|
||||
username: user.username.clone(),
|
||||
instance_role: user.instance_role,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve_app(
|
||||
apps: &dyn AppRepository,
|
||||
ident: &str,
|
||||
) -> Result<picloud_shared::App, AppMembersApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await?
|
||||
.map(|l| l.app)
|
||||
.ok_or_else(|| AppMembersApiError::AppNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppMembersApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("user not found: {0}")]
|
||||
UserNotFound(AdminUserId),
|
||||
|
||||
#[error("no membership exists for this user on this app")]
|
||||
MembershipNotFound,
|
||||
|
||||
#[error("{username} is already a member of this app — use PATCH to change their role")]
|
||||
AlreadyMember { username: String },
|
||||
|
||||
#[error("{username} is deactivated and cannot be added as a member")]
|
||||
TargetInactive { username: String },
|
||||
|
||||
#[error(
|
||||
"{username} has instance_role {instance_role:?} and already has implicit access \
|
||||
on every app — no explicit membership needed"
|
||||
)]
|
||||
TargetNotMember {
|
||||
username: String,
|
||||
instance_role: InstanceRole,
|
||||
},
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Members(#[from] AppMembersRepositoryError),
|
||||
|
||||
#[error("user repository error: {0}")]
|
||||
Users(#[from] AdminUserRepositoryError),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Apps(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for AppMembersApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppMembersApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_)
|
||||
| Self::UserNotFound(_)
|
||||
| Self::MembershipNotFound
|
||||
| Self::Apps(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::AlreadyMember { .. } | Self::Apps(ScriptRepositoryError::Conflict(_)) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "app members authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Members(e) => {
|
||||
tracing::error!(error = %e, "app members repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Users(e) => {
|
||||
tracing::error!(error = %e, "admin users repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Apps(ScriptRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "apps repo error in app_members");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
340
crates/manager-core/src/app_members_repo.rs
Normal file
340
crates/manager-core/src/app_members_repo.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
//! CRUD over the `app_members` table — explicit per-(user, app) role
|
||||
//! grants for `member` instance-role users. Owners and admins do NOT
|
||||
//! appear here; their app authority is implicit (see authz.rs).
|
||||
//!
|
||||
//! Doubles as the production `AuthzRepo` implementation: the
|
||||
//! membership lookup `can()` needs is the same single-row SELECT as
|
||||
//! `find` here.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppMembersRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("membership row not found: app={app_id}, user={user_id}")]
|
||||
NotFound { app_id: AppId, user_id: AdminUserId },
|
||||
|
||||
#[error("invalid app_role stored in DB: {0}")]
|
||||
InvalidRole(String),
|
||||
}
|
||||
|
||||
/// One row of `app_members`. Returned by `list_for_user` / `list_for_app`
|
||||
/// so handlers can render the cross-reference without joining to apps
|
||||
/// or admin_users themselves.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppMembershipRow {
|
||||
pub app_id: AppId,
|
||||
pub user_id: AdminUserId,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// `app_members` row joined with `admin_users` so the dashboard's
|
||||
/// Members tab can render usernames / emails / status without an N+1
|
||||
/// fetch per row. Drives `GET /apps/{id}/members`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppMembershipDetail {
|
||||
pub user_id: AdminUserId,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub instance_role: InstanceRole,
|
||||
pub is_active: bool,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AppMembersRepository: Send + Sync {
|
||||
/// Single (user, app) lookup. Returns `None` for non-members and
|
||||
/// for unrelated apps. This is the hot path for `authz::can`.
|
||||
async fn find(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AppMembersRepositoryError>;
|
||||
|
||||
/// Upsert a membership. Used both for first-time grants and role
|
||||
/// promotions/demotions on an existing row.
|
||||
async fn upsert(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<AppMembershipRow, AppMembersRepositoryError>;
|
||||
|
||||
/// Atomic insert. Returns `Some(row)` on success, `None` if a
|
||||
/// membership already exists. Lets the HTTP handler return 409
|
||||
/// without a separate `find` round-trip (no TOCTOU between check
|
||||
/// and insert).
|
||||
async fn try_insert(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||
|
||||
/// Atomic role update. Returns `Some(row)` on success, `None` if no
|
||||
/// membership row exists. Lets PATCH return 404 without a separate
|
||||
/// `find` round-trip (no TOCTOU between check and update).
|
||||
async fn update_role(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError>;
|
||||
|
||||
/// Remove a membership. No-op (Ok) when the row doesn't exist —
|
||||
/// the user wasn't a member, which is the desired post-condition.
|
||||
async fn remove(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<(), AppMembersRepositoryError>;
|
||||
|
||||
/// Every membership the user holds. Drives the membership-filtered
|
||||
/// list endpoints (`GET /admin/apps`, `GET /admin/scripts` for
|
||||
/// `member` callers).
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
||||
|
||||
/// Every membership on a given app. Used by `GET
|
||||
/// /admin/apps/{id}/members` once that surface lands; included now
|
||||
/// so the trait is complete enough for tests.
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError>;
|
||||
|
||||
/// Like `list_for_app` but joined with `admin_users` so the
|
||||
/// dashboard can render member rows in one round-trip. Ordered by
|
||||
/// username for a stable list.
|
||||
async fn list_for_app_enriched(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAppMembersRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresAppMembersRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppMembersRepository for PostgresAppMembersRepository {
|
||||
async fn find(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AppMembersRepositoryError> {
|
||||
let row: Option<(String,)> =
|
||||
sqlx::query_as("SELECT role FROM app_members WHERE user_id = $1 AND app_id = $2")
|
||||
.bind(user_id.into_inner())
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(|(role,)| {
|
||||
AppRole::from_db_str(&role).ok_or(AppMembersRepositoryError::InvalidRole(role))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
async fn upsert(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<AppMembershipRow, AppMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||
"INSERT INTO app_members (app_id, user_id, role) \
|
||||
VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (app_id, user_id) DO UPDATE SET role = EXCLUDED.role \
|
||||
RETURNING app_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.bind(role.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
row.try_into()
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<(), AppMembersRepositoryError> {
|
||||
sqlx::query("DELETE FROM app_members WHERE app_id = $1 AND user_id = $2")
|
||||
.bind(app_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn try_insert(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||
"INSERT INTO app_members (app_id, user_id, role) \
|
||||
VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (app_id, user_id) DO NOTHING \
|
||||
RETURNING app_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.bind(role.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn update_role(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<AppMembershipRow>, AppMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppMembershipRecord>(
|
||||
"UPDATE app_members SET role = $1 \
|
||||
WHERE app_id = $2 AND user_id = $3 \
|
||||
RETURNING app_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(role.as_str())
|
||||
.bind(app_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppMembershipRecord>(
|
||||
"SELECT app_id, user_id, role, created_at \
|
||||
FROM app_members WHERE user_id = $1 \
|
||||
ORDER BY created_at",
|
||||
)
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<AppMembershipRow>, AppMembersRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppMembershipRecord>(
|
||||
"SELECT app_id, user_id, role, created_at \
|
||||
FROM app_members WHERE app_id = $1 \
|
||||
ORDER BY created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
|
||||
async fn list_for_app_enriched(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<AppMembershipDetail>, AppMembersRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppMembershipDetailRecord>(
|
||||
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
||||
am.role, am.created_at \
|
||||
FROM app_members am \
|
||||
JOIN admin_users au ON au.id = am.user_id \
|
||||
WHERE am.app_id = $1 \
|
||||
ORDER BY au.username",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Forwarding impl so the Postgres repo satisfies `AuthzRepo` directly
|
||||
/// — handlers store a single `Arc<dyn AppMembersRepository>` and pass
|
||||
/// it to `authz::can` without casting.
|
||||
#[async_trait]
|
||||
impl AuthzRepo for PostgresAppMembersRepository {
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
self.find(user_id, app_id)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AppMembershipRecord {
|
||||
app_id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<AppMembershipRecord> for AppMembershipRow {
|
||||
type Error = AppMembersRepositoryError;
|
||||
fn try_from(r: AppMembershipRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
app_id: r.app_id.into(),
|
||||
user_id: r.user_id.into(),
|
||||
role: AppRole::from_db_str(&r.role)
|
||||
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AppMembershipDetailRecord {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
instance_role: String,
|
||||
is_active: bool,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<AppMembershipDetailRecord> for AppMembershipDetail {
|
||||
type Error = AppMembersRepositoryError;
|
||||
fn try_from(r: AppMembershipDetailRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
user_id: r.id.into(),
|
||||
username: r.username,
|
||||
email: r.email,
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||
.ok_or(AppMembersRepositoryError::InvalidRole(r.instance_role))?,
|
||||
is_active: r.is_active,
|
||||
role: AppRole::from_db_str(&r.role)
|
||||
.ok_or(AppMembersRepositoryError::InvalidRole(r.role))?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
450
crates/manager-core/src/app_repo.rs
Normal file
450
crates/manager-core/src/app_repo.rs
Normal file
@@ -0,0 +1,450 @@
|
||||
//! CRUD over the `apps` and `app_slug_history` tables.
|
||||
//!
|
||||
//! Slug validation (regex, reserved-word check) lives in the API
|
||||
//! handler; this repo enforces only what Postgres enforces (uniqueness,
|
||||
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
|
||||
//! that writes the history row in the same transaction.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AdminUserId, App, AppId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
|
||||
/// Result of looking up an app by slug or via the redirect history.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppLookup {
|
||||
pub app: App,
|
||||
/// `true` when the slug was found in `app_slug_history` rather than
|
||||
/// directly on `apps`. Dashboards should issue a redirect.
|
||||
pub redirected: bool,
|
||||
}
|
||||
|
||||
/// Resolve a free-form path param (UUID *or* slug *or* historical slug)
|
||||
/// to an `AppLookup`. UUID lookups never set `redirected`; slug lookups
|
||||
/// fall through to `app_slug_history` and set `redirected: true` when
|
||||
/// they hit it.
|
||||
///
|
||||
/// Returns `Ok(None)` when nothing matches — callers map that to their
|
||||
/// own not-found error variant.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates any underlying repository error.
|
||||
pub async fn resolve_app(
|
||||
apps: &dyn AppRepository,
|
||||
ident: &str,
|
||||
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
return Ok(apps
|
||||
.get_by_id(AppId::from(uuid))
|
||||
.await?
|
||||
.map(|app| AppLookup {
|
||||
app,
|
||||
redirected: false,
|
||||
}));
|
||||
}
|
||||
apps.get_by_slug_or_history(ident).await
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AppRepository: Send + Sync {
|
||||
/// Every app on the instance. For owner/admin callers — `member`
|
||||
/// users go through `list_for_user`.
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||
/// Only apps the user has an `app_members` row for. Drives the
|
||||
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Create that also consumes a matching `app_slug_history` row, if
|
||||
/// any. Used after the operator has confirmed they want to break old
|
||||
/// redirects.
|
||||
async fn create_with_takeover(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
id: AppId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Rename and record the old slug in `app_slug_history` (so
|
||||
/// retired URLs keep redirecting). If `take_over_history` is true,
|
||||
/// any existing history row for `new_slug` is consumed.
|
||||
async fn rename_slug(
|
||||
&self,
|
||||
id: AppId,
|
||||
new_slug: &str,
|
||||
take_over_history: bool,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||
/// Delete the app along with all its scripts (which in turn cascades
|
||||
/// routes and execution logs via their `script_id` FK). Domains and
|
||||
/// app-slug-history rows cascade off the app row itself. Runs in a
|
||||
/// single transaction so a partial delete cannot be observed.
|
||||
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAppRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresAppRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppRepository for PostgresAppRepository {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps ORDER BY name",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
FROM apps a \
|
||||
JOIN app_members m ON m.app_id = a.id \
|
||||
WHERE m.user_id = $1 \
|
||||
ORDER BY a.name",
|
||||
)
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||
if let Some(app) = self.get_by_slug(slug).await? {
|
||||
return Ok(Some(AppLookup {
|
||||
app,
|
||||
redirected: false,
|
||||
}));
|
||||
}
|
||||
if let Some(app) = self.slug_in_history(slug).await? {
|
||||
return Ok(Some(AppLookup {
|
||||
app,
|
||||
redirected: true,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
FROM app_slug_history h \
|
||||
JOIN apps a ON a.id = h.current_app_id \
|
||||
WHERE h.slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_with_takeover(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
Ok(r) => r,
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {slug:?} is already in use"
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
id: AppId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
.bind(description.is_some())
|
||||
.bind(description.and_then(|d| d))
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
|
||||
}
|
||||
|
||||
async fn rename_slug(
|
||||
&self,
|
||||
id: AppId,
|
||||
new_slug: &str,
|
||||
take_over_history: bool,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// 1. Read the current slug (so we can record it in history).
|
||||
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some((current_slug,)) = current else {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"app {id} not found"
|
||||
)));
|
||||
};
|
||||
|
||||
if current_slug == new_slug {
|
||||
// No-op rename; just return the row.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Ok(row.into());
|
||||
}
|
||||
|
||||
// 2. If renaming back to this app's own retired slug, just
|
||||
// consume the history row silently (no warning, no takeover
|
||||
// flag required).
|
||||
let owns_history: Option<(uuid::Uuid,)> =
|
||||
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
match owns_history {
|
||||
Some((owner,)) if owner == id.into_inner() => {
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Some(_) if take_over_history => {
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {new_slug:?} is in history; rename with takeover to claim it"
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// 3. Record the current slug in history (replacing any older
|
||||
// entry — the same slug can pass through history multiple
|
||||
// times across many renames).
|
||||
sqlx::query(
|
||||
"INSERT INTO app_slug_history (slug, current_app_id) \
|
||||
VALUES ($1, $2) \
|
||||
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
|
||||
retired_at = NOW()",
|
||||
)
|
||||
.bind(¤t_slug)
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 4. Apply the rename. Unique violation = another live app
|
||||
// already holds this slug.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(new_slug)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
Ok(r) => r,
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {new_slug:?} is already in use by another app"
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
|
||||
"app {id} not found"
|
||||
))),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// ON DELETE RESTRICT on scripts.app_id — surface a clean
|
||||
// "has dependents" error rather than a raw SQL message.
|
||||
Err(ScriptRepositoryError::Conflict(
|
||||
"app still contains scripts; delete or move them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_cascade(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM scripts WHERE app_id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"app {id} not found"
|
||||
)));
|
||||
}
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AppRow {
|
||||
id: uuid::Uuid,
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<AppRow> for App {
|
||||
fn from(r: AppRow) -> Self {
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
619
crates/manager-core/src/apps_api.rs
Normal file
619
crates/manager-core/src/apps_api.rs
Normal file
@@ -0,0 +1,619 @@
|
||||
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
|
||||
//!
|
||||
//! All endpoints are guarded by `require_admin`. Per-app permissions
|
||||
//! are deferred (every authenticated admin can act on every app); the
|
||||
//! middleware seam exists for when that lands.
|
||||
//!
|
||||
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||
//! list rejected. Slug renames record the old slug in
|
||||
//! `app_slug_history` for permanent 301 redirects; reclaiming a
|
||||
//! historical slug requires `"force_takeover": true` in the request.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||
use picloud_shared::{App, AppDomain, AppId, AppRole, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
const SLUG_MIN: usize = 1;
|
||||
const SLUG_MAX: usize = 63;
|
||||
const RESERVED_SLUGS: &[&str] = &[
|
||||
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppsState {
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub routes: Arc<dyn RouteRepository>,
|
||||
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||
/// operation so the orchestrator sees changes immediately.
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn apps_router(state: AppsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps", get(list_apps).post(create_app))
|
||||
.route(
|
||||
"/apps/{id_or_slug}",
|
||||
get(get_app).patch(patch_app).delete(delete_app),
|
||||
)
|
||||
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
|
||||
.route(
|
||||
"/apps/{id_or_slug}/domains",
|
||||
get(list_domains).post(create_domain),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id_or_slug}/domains/{domain_id}",
|
||||
delete(delete_domain),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppDto {
|
||||
#[serde(flatten)]
|
||||
pub app: App,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAppRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||
/// the requested slug (breaking old redirects).
|
||||
#[serde(default)]
|
||||
pub force_takeover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchAppRequest {
|
||||
pub name: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
||||
#[allow(clippy::option_option)]
|
||||
pub description: Option<Option<String>>,
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub force_takeover: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::option_option)]
|
||||
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SlugCheckRequest {
|
||||
pub new_slug: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SlugCheckResponse {
|
||||
pub ok: bool,
|
||||
pub conflict_kind: Option<&'static str>,
|
||||
pub current_app: Option<App>,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDomainRequest {
|
||||
pub pattern: String,
|
||||
}
|
||||
|
||||
/// Query params for `DELETE /apps/{id_or_slug}`. `force=true` opts into
|
||||
/// a cascading delete that also removes every script in the app (and
|
||||
/// thereby their routes and execution logs). Without it the request is
|
||||
/// rejected when the app still contains scripts.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct DeleteAppQuery {
|
||||
#[serde(default)]
|
||||
pub force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppLookupResponse {
|
||||
#[serde(flatten)]
|
||||
pub app: App,
|
||||
/// When the operator hits the API with a retired slug, this points
|
||||
/// at the live slug so dashboards can redirect.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redirect_to: Option<String>,
|
||||
/// The caller's role on this app, used by the dashboard to decide
|
||||
/// whether to render admin-only surfaces (Members tab, settings).
|
||||
/// `Owner` and `Admin` both map to `app_admin` (implicit per
|
||||
/// blueprint §11.6); `Member` carries its explicit
|
||||
/// `app_members.role`.
|
||||
pub my_role: Option<AppRole>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_apps(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||
// Member callers see only apps they're a member of; owner/admin
|
||||
// see everything. Filter at the SQL layer (not just in the
|
||||
// dashboard) — that's the strict-isolation guarantee from §11.6.
|
||||
let apps = if principal.instance_role == InstanceRole::Member {
|
||||
s.apps.list_for_user(principal.user_id).await?
|
||||
} else {
|
||||
s.apps.list().await?
|
||||
};
|
||||
Ok(Json(apps))
|
||||
}
|
||||
|
||||
async fn create_app(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateAppRequest>,
|
||||
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
// Historical-slug check before insert: if the slug is in history
|
||||
// and the caller hasn't asked to force takeover, surface a clean
|
||||
// 409 so the dashboard can present a "this will break old links"
|
||||
// confirmation.
|
||||
if !input.force_takeover {
|
||||
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
}
|
||||
}
|
||||
|
||||
let created = if input.force_takeover {
|
||||
s.apps
|
||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||
.await?
|
||||
} else {
|
||||
s.apps
|
||||
.create(&input.slug, &input.name, input.description.as_deref())
|
||||
.await?
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn get_app(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(lookup.app.id),
|
||||
)
|
||||
.await?;
|
||||
let redirect_to = if lookup.redirected {
|
||||
Some(lookup.app.slug.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let my_role = compute_my_role(s.authz.as_ref(), &principal, lookup.app.id).await?;
|
||||
Ok(Json(AppLookupResponse {
|
||||
app: lookup.app,
|
||||
redirect_to,
|
||||
my_role,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
|
||||
/// the implicit-grant logic in `authz::role_grants` but returns the
|
||||
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
|
||||
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
|
||||
/// consults `app_members`.
|
||||
async fn compute_my_role(
|
||||
authz: &dyn AuthzRepo,
|
||||
principal: &Principal,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AppsApiError> {
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||
}
|
||||
}
|
||||
|
||||
async fn patch_app(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<PatchAppRequest>,
|
||||
) -> Result<Json<App>, AppsApiError> {
|
||||
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppAdmin(current.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Edits to name/description go first (separate from rename so we
|
||||
// don't conflate the two errors).
|
||||
let after_meta = if input.name.is_some() || input.description.is_some() {
|
||||
s.apps
|
||||
.update(
|
||||
current.id,
|
||||
input.name.as_deref(),
|
||||
input.description.as_ref().map(|d| d.as_deref()),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
current
|
||||
};
|
||||
|
||||
// Slug rename is a separate operation; the rename method does its
|
||||
// own history bookkeeping in a transaction.
|
||||
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
|
||||
validate_slug(new_slug)?;
|
||||
match s
|
||||
.apps
|
||||
.rename_slug(after_meta.id, new_slug, input.force_takeover)
|
||||
.await
|
||||
{
|
||||
Ok(app) => app,
|
||||
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
}
|
||||
return Err(AppsApiError::Conflict(msg));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
after_meta
|
||||
};
|
||||
|
||||
Ok(Json(after_rename))
|
||||
}
|
||||
|
||||
async fn delete_app(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Query(q): Query<DeleteAppQuery>,
|
||||
) -> Result<StatusCode, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||
|
||||
if q.force {
|
||||
s.apps.delete_cascade(app.id).await?;
|
||||
} else {
|
||||
// Soft pre-check for a clean error; the DB FK is the real guard
|
||||
// (ON DELETE RESTRICT on scripts.app_id).
|
||||
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
||||
if n_scripts > 0 {
|
||||
return Err(AppsApiError::HasScripts(n_scripts));
|
||||
}
|
||||
s.apps.delete(app.id).await?;
|
||||
}
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn slug_check(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SlugCheckRequest>,
|
||||
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppAdmin(app.id)).await?;
|
||||
match validate_slug(&input.new_slug) {
|
||||
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("invalid"),
|
||||
current_app: None,
|
||||
reason: Some(reason),
|
||||
}));
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
Ok(()) => {}
|
||||
}
|
||||
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("current"),
|
||||
current_app: Some(app),
|
||||
reason: Some("another app currently uses this slug".into()),
|
||||
}));
|
||||
}
|
||||
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("historical"),
|
||||
current_app: Some(app),
|
||||
reason: Some("slug is a retired redirect; using it will break old links".into()),
|
||||
}));
|
||||
}
|
||||
Ok(Json(SlugCheckResponse {
|
||||
ok: true,
|
||||
conflict_kind: None,
|
||||
current_app: None,
|
||||
reason: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_domains(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppRead(app.id)).await?;
|
||||
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||
}
|
||||
|
||||
async fn create_domain(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<CreateDomainRequest>,
|
||||
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageDomains(app.id),
|
||||
)
|
||||
.await?;
|
||||
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||
let created = s
|
||||
.domains
|
||||
.create(NewAppDomain {
|
||||
app_id: app.id,
|
||||
pattern: input.pattern,
|
||||
shape: parsed.shape,
|
||||
shape_key: parsed.shape_key,
|
||||
})
|
||||
.await?;
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_domain(
|
||||
State(s): State<AppsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||
) -> Result<StatusCode, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageDomains(app.id),
|
||||
)
|
||||
.await?;
|
||||
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||
};
|
||||
if domain.app_id != app.id {
|
||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||
}
|
||||
|
||||
// Guard: routes inside this app may reference this exact host
|
||||
// pattern. The host-kind on the route is `strict` or `wildcard`
|
||||
// (Any routes don't pin a specific host). We block deletion in
|
||||
// either case and let the operator clean up first.
|
||||
let strict = s
|
||||
.routes
|
||||
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
|
||||
.await?;
|
||||
let wild_suffix = domain
|
||||
.pattern
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let wild = if wild_suffix.is_empty() {
|
||||
0
|
||||
} else {
|
||||
s.routes
|
||||
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
|
||||
.await?
|
||||
};
|
||||
if strict + wild > 0 {
|
||||
return Err(AppsApiError::DomainHasRoutes(strict + wild));
|
||||
}
|
||||
|
||||
s.domains.delete(domain_id).await?;
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(
|
||||
apps: &dyn AppRepository,
|
||||
ident: &str,
|
||||
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await?
|
||||
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
||||
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
|
||||
return Err(AppsApiError::InvalidSlug(format!(
|
||||
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
|
||||
)));
|
||||
}
|
||||
if !slug
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphanumeric())
|
||||
{
|
||||
return Err(AppsApiError::InvalidSlug(
|
||||
"slug must start with [a-z0-9]".into(),
|
||||
));
|
||||
}
|
||||
for c in slug.chars() {
|
||||
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||
return Err(AppsApiError::InvalidSlug(
|
||||
"slug may only contain lowercase letters, digits, and '-'".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&slug) {
|
||||
return Err(AppsApiError::InvalidSlug(format!(
|
||||
"slug {slug:?} is reserved for system use"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||
/// Called after every domain CRUD operation.
|
||||
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||
let all = state.domains.list_all().await?;
|
||||
let compiled = all
|
||||
.into_iter()
|
||||
.filter_map(|d| {
|
||||
// Parse the stored pattern; skip on parse error rather than
|
||||
// poisoning the entire cache. The handlers reject bad input,
|
||||
// so this is purely defensive against a future migration
|
||||
// that loosens the constraints.
|
||||
pattern::parse_app_domain(&d.pattern)
|
||||
.ok()
|
||||
.map(|p| CompiledAppDomain {
|
||||
app_id: d.app_id,
|
||||
pattern: p.pattern,
|
||||
shape_key: p.shape_key,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
state.domain_table.replace(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppsApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("domain not found: {0}")]
|
||||
DomainNotFound(Uuid),
|
||||
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||
SlugInHistory(App),
|
||||
|
||||
#[error("app still contains {0} script(s); delete or move them first")]
|
||||
HasScripts(i64),
|
||||
|
||||
#[error("domain has {0} route(s) bound to it; delete the routes first")]
|
||||
DomainHasRoutes(i64),
|
||||
|
||||
#[error("invalid pattern: {0}")]
|
||||
Pattern(#[from] pattern::ParseError),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for AppsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for AppsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_)
|
||||
| Self::DomainNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::InvalidSlug(_) | Self::Pattern(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::SlugInHistory(current) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({
|
||||
"error": self.to_string(),
|
||||
"conflict_kind": "historical",
|
||||
"current_app": current,
|
||||
}),
|
||||
),
|
||||
Self::HasScripts(n) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({ "error": self.to_string(), "script_count": n }),
|
||||
),
|
||||
Self::DomainHasRoutes(n) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({ "error": self.to_string(), "route_count": n }),
|
||||
),
|
||||
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apps authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "apps api db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, Salt
|
||||
use argon2::Argon2;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -93,6 +94,66 @@ fn hex(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// API key generation (Phase 3.5)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Wire-format prefix that marks a Bearer value as an API key (vs. a
|
||||
/// session token). Mirrors `auth_middleware::API_KEY_PREFIX` so the
|
||||
/// generator and the verifier agree.
|
||||
pub const API_KEY_WIRE_PREFIX: &str = "pic_";
|
||||
|
||||
/// Length of the indexed prefix portion (the first 8 chars of the
|
||||
/// `pic_`-stripped body). Mirrors `auth_middleware::API_KEY_PREFIX_LEN`.
|
||||
pub const API_KEY_INDEX_PREFIX_LEN: usize = 8;
|
||||
|
||||
/// Newly minted API key — returned exactly once by `POST /api/v1/admin/api-keys`.
|
||||
///
|
||||
/// * `raw` is the full wire-format token (`pic_<base32>`) shown to the
|
||||
/// caller in the response body and never persisted.
|
||||
/// * `prefix` is the indexed 8-char slice persisted to
|
||||
/// `api_keys.prefix` for lookup.
|
||||
/// * `hash` is the Argon2id PHC string persisted to `api_keys.hash`;
|
||||
/// covers the body after `pic_` (i.e., `raw[4..]`).
|
||||
pub struct GeneratedApiKey {
|
||||
pub raw: String,
|
||||
pub prefix: String,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
/// Generate a fresh API key. 32 random bytes → unpadded base32, then
|
||||
/// `pic_` prefix on the wire. The first 8 base32 chars are the index
|
||||
/// key; everything after `pic_` is what the verifier hashes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `argon2::password_hash::Error` if the Argon2 hash step
|
||||
/// fails (which it shouldn't under normal conditions).
|
||||
pub fn generate_api_key() -> Result<GeneratedApiKey, argon2::password_hash::Error> {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut bytes);
|
||||
let body = BASE32_NOPAD.encode(&bytes);
|
||||
debug_assert!(
|
||||
body.len() >= API_KEY_INDEX_PREFIX_LEN,
|
||||
"32 bytes base32 must exceed the 8-char prefix length"
|
||||
);
|
||||
let prefix = body[..API_KEY_INDEX_PREFIX_LEN].to_string();
|
||||
let salt = SaltString::generate(&mut ArgonRng);
|
||||
let hash = Argon2::default()
|
||||
.hash_password(body.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
let raw = format!("{API_KEY_WIRE_PREFIX}{body}");
|
||||
Ok(GeneratedApiKey { raw, prefix, hash })
|
||||
}
|
||||
|
||||
/// Verify a wire-format token body (the portion *after* `pic_`)
|
||||
/// against a stored Argon2id hash. Convenience wrapper around
|
||||
/// `verify_password` named to reflect its caller.
|
||||
#[must_use]
|
||||
pub fn verify_api_key(stored_hash: &str, presented_body: &str) -> bool {
|
||||
verify_password(stored_hash, presented_body)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -129,4 +190,42 @@ mod tests {
|
||||
assert_eq!(a.hash, hash_token(&a.raw), "hash must be reproducible");
|
||||
assert_eq!(a.hash.len(), 64, "sha256-hex is 64 chars");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_api_key_round_trip() {
|
||||
let key = generate_api_key().expect("mint");
|
||||
assert!(
|
||||
key.raw.starts_with(API_KEY_WIRE_PREFIX),
|
||||
"raw must carry the pic_ prefix"
|
||||
);
|
||||
let body = key
|
||||
.raw
|
||||
.strip_prefix(API_KEY_WIRE_PREFIX)
|
||||
.expect("starts with prefix");
|
||||
assert_eq!(
|
||||
&body[..API_KEY_INDEX_PREFIX_LEN],
|
||||
key.prefix,
|
||||
"stored prefix matches the first 8 chars of the body"
|
||||
);
|
||||
assert!(
|
||||
verify_api_key(&key.hash, body),
|
||||
"Argon2 verify must accept the original body"
|
||||
);
|
||||
assert!(
|
||||
!verify_api_key(&key.hash, "wrong-body-entirely"),
|
||||
"Argon2 verify must reject anything else"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_api_key_unique() {
|
||||
let a = generate_api_key().expect("mint a");
|
||||
let b = generate_api_key().expect("mint b");
|
||||
assert_ne!(a.raw, b.raw);
|
||||
assert_ne!(a.hash, b.hash);
|
||||
assert_ne!(
|
||||
a.prefix, b.prefix,
|
||||
"32 random bytes → prefix collision is negligible"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,14 @@ use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use chrono::{DateTime, Duration as ChronoDuration, Utc};
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use picloud_shared::Principal;
|
||||
|
||||
use crate::auth::{generate_session_token, hash_token, verify_password};
|
||||
use crate::auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||
use crate::auth_middleware::{require_authenticated, AuthState, SESSION_COOKIE};
|
||||
|
||||
pub fn auth_router(state: AuthState) -> Router {
|
||||
// /login + /logout are unguarded (login is how you get in; logout
|
||||
@@ -31,7 +33,7 @@ pub fn auth_router(state: AuthState) -> Router {
|
||||
// who you are, so the middleware must run first.
|
||||
let guarded = Router::new()
|
||||
.route("/auth/me", get(me))
|
||||
.route_layer(from_fn_with_state(state.clone(), require_admin));
|
||||
.route_layer(from_fn_with_state(state.clone(), require_authenticated));
|
||||
|
||||
Router::new()
|
||||
.route("/auth/login", post(login))
|
||||
@@ -61,6 +63,8 @@ pub struct LoginResponse {
|
||||
pub struct AdminUserDto {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub instance_role: InstanceRole,
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -85,9 +89,11 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
||||
}
|
||||
};
|
||||
|
||||
let (stored_hash, user_id, username, is_active) = match creds {
|
||||
Some(c) => (c.password_hash, Some(c.id), c.username, c.is_active),
|
||||
None => (DUMMY_HASH.to_string(), None, String::new(), false),
|
||||
// username from creds is discarded — the re-fetch below carries the
|
||||
// canonical row used in the response DTO.
|
||||
let (stored_hash, user_id, is_active) = match creds {
|
||||
Some(c) => (c.password_hash, Some(c.id), c.is_active),
|
||||
None => (DUMMY_HASH.to_string(), None, false),
|
||||
};
|
||||
|
||||
let password_ok = verify_password(&stored_hash, &input.password);
|
||||
@@ -96,6 +102,18 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
||||
}
|
||||
let user_id = user_id.unwrap();
|
||||
|
||||
// Re-fetch the full row so the login response carries the same
|
||||
// shape /me does (instance_role, email). The credentials struct
|
||||
// intentionally omits email; one extra query per login is fine.
|
||||
let user_row = match state.users.get(user_id).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return invalid_credentials(),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users lookup after login failed");
|
||||
return internal_error();
|
||||
}
|
||||
};
|
||||
|
||||
let token = generate_session_token();
|
||||
let expires_at = Utc::now()
|
||||
+ ChronoDuration::from_std(state.ttl).unwrap_or_else(|_| ChronoDuration::hours(24));
|
||||
@@ -128,8 +146,10 @@ async fn login(State(state): State<AuthState>, Json(input): Json<LoginRequest>)
|
||||
headers,
|
||||
Json(LoginResponse {
|
||||
user: AdminUserDto {
|
||||
id: user_id,
|
||||
username,
|
||||
id: user_row.id,
|
||||
username: user_row.username,
|
||||
instance_role: user_row.instance_role,
|
||||
email: user_row.email,
|
||||
},
|
||||
token: token.raw,
|
||||
expires_at,
|
||||
@@ -158,11 +178,27 @@ async fn logout(State(state): State<AuthState>, req: Request<Body>) -> Response
|
||||
(StatusCode::NO_CONTENT, headers).into_response()
|
||||
}
|
||||
|
||||
async fn me(Extension(admin): Extension<AuthedAdmin>) -> Json<AdminUserDto> {
|
||||
Json(AdminUserDto {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
})
|
||||
async fn me(
|
||||
State(state): State<AuthState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
) -> Response {
|
||||
// /me consumes the resolved Principal directly; we re-fetch the
|
||||
// user row only to surface a fresh username (it can change via
|
||||
// PATCH while a session/key is still valid).
|
||||
match state.users.get(principal.user_id).await {
|
||||
Ok(Some(row)) => Json(AdminUserDto {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
instance_role: row.instance_role,
|
||||
email: row.email,
|
||||
})
|
||||
.into_response(),
|
||||
Ok(None) => invalid_credentials(),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users lookup for /me failed");
|
||||
internal_error()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@@ -116,7 +116,16 @@ pub async fn bootstrap_first_admin_with<R: AdminUserRepository + ?Sized>(
|
||||
(None, None) => return Err(BootstrapError::MissingPassword),
|
||||
};
|
||||
|
||||
repo.create(&username, &password_hash).await?;
|
||||
// Bootstrap admin is always seeded as Owner — Phase 3.5 keys the
|
||||
// first row to full instance control. Subsequent admins minted via
|
||||
// the API default to Admin and can be promoted explicitly.
|
||||
repo.create(
|
||||
&username,
|
||||
&password_hash,
|
||||
picloud_shared::InstanceRole::Owner,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
info!(username = %username, "bootstrapped initial admin user");
|
||||
Ok(())
|
||||
}
|
||||
@@ -130,7 +139,7 @@ mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, InstanceRole};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserCredentials, AdminUserRepositoryError, AdminUserRow};
|
||||
@@ -167,11 +176,15 @@ mod tests {
|
||||
&self,
|
||||
username: &str,
|
||||
_password_hash: &str,
|
||||
instance_role: InstanceRole,
|
||||
email: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
let row = AdminUserRow {
|
||||
id: AdminUserId::new(),
|
||||
username: username.to_string(),
|
||||
is_active: true,
|
||||
instance_role,
|
||||
email: email.map(str::to_string),
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
last_login_at: None,
|
||||
@@ -193,6 +206,20 @@ mod tests {
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn update_email(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_e: Option<&str>,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn update_instance_role(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
_r: InstanceRole,
|
||||
) -> Result<AdminUserRow, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn set_active(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
@@ -215,6 +242,15 @@ mod tests {
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_active_owners(&self) -> Result<Vec<AdminUserRow>, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn count_other_active_owners(
|
||||
&self,
|
||||
_i: AdminUserId,
|
||||
) -> Result<i64, AdminUserRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -245,7 +281,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn populated_db_is_noop() {
|
||||
let repo = InMemoryRepo::default();
|
||||
repo.create("seeded", "x").await.unwrap();
|
||||
repo.create("seeded", "x", InstanceRole::Owner, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let env = BootstrapEnv {
|
||||
username: Some("alice".into()),
|
||||
password: Some("supersecret".into()),
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
//! `require_admin` axum middleware: gates a router on a valid admin
|
||||
//! session. Accepts the token from either the `picloud_session` cookie
|
||||
//! or an `Authorization: Bearer …` header — same token system serves
|
||||
//! the dashboard and CLI/CI clients.
|
||||
//! Authentication middleware — resolves the caller's `Principal` from
|
||||
//! either a session cookie / Bearer session-token OR an API key
|
||||
//! (`Authorization: Bearer pic_…`). Both paths converge on the same
|
||||
//! request extension so downstream handlers see one shape.
|
||||
//!
|
||||
//! On success, injects `AuthedAdmin` as a request extension so handlers
|
||||
//! can `Extension<AuthedAdmin>` to know who's calling. On failure,
|
||||
//! returns 401 with a generic JSON body (no enumeration about whether
|
||||
//! the token was wrong vs. the user was deactivated).
|
||||
//! Capability checks live in `crate::authz` and are called per-handler
|
||||
//! (after the relevant resource is loaded, so the capability binds to
|
||||
//! the actual resource's `app_id`). This middleware is gate-only: it
|
||||
//! ensures *some* `Principal` is attached, or returns 401.
|
||||
//!
|
||||
//! Token discriminator: the `pic_` prefix on a Bearer value selects
|
||||
//! the API-key path; anything else (raw 32-byte base64-url-encoded
|
||||
//! string) takes the session path. The session cookie can only ever
|
||||
//! carry a session token (cookies are never API keys).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -17,35 +22,51 @@ use axum::http::{header, StatusCode};
|
||||
use axum::middleware::Next;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use chrono::Utc;
|
||||
use picloud_shared::AdminUserId;
|
||||
use picloud_shared::{AdminUserId, Principal};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::admin_session_repo::AdminSessionRepository;
|
||||
use crate::admin_user_repo::AdminUserRepository;
|
||||
use crate::auth::hash_token;
|
||||
use crate::api_key_repo::{ApiKeyRepository, ApiKeyVerification};
|
||||
use crate::auth::{hash_token, verify_password};
|
||||
|
||||
pub const SESSION_COOKIE: &str = "picloud_session";
|
||||
|
||||
/// Shared state for auth: the two repos plus the configured sliding
|
||||
/// session TTL. Cheap to clone (`Arc` everywhere).
|
||||
/// Prefix on the wire that selects the API-key path. The body that
|
||||
/// follows is `base32(32 random bytes)`; the first 8 chars of the body
|
||||
/// index into `api_keys.prefix` for verification.
|
||||
pub const API_KEY_PREFIX: &str = "pic_";
|
||||
|
||||
/// Length of the indexed prefix portion of an API key (the 8 chars
|
||||
/// immediately after `pic_`). Schema-side index is on this slice.
|
||||
pub const API_KEY_PREFIX_LEN: usize = 8;
|
||||
|
||||
/// Shared state for auth: the user / session / API-key repos plus the
|
||||
/// configured sliding session TTL. Cheap to clone (`Arc` everywhere).
|
||||
#[derive(Clone)]
|
||||
pub struct AuthState {
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub sessions: Arc<dyn AdminSessionRepository>,
|
||||
pub keys: Arc<dyn ApiKeyRepository>,
|
||||
pub ttl: Duration,
|
||||
}
|
||||
|
||||
/// Request-extension type that authenticated handlers extract via
|
||||
/// `Extension<AuthedAdmin>`. Available only inside guarded routers.
|
||||
/// Legacy request-extension alias retained so the (only remaining)
|
||||
/// handler that pulled `AuthedAdmin` out — `GET /admin/auth/me` —
|
||||
/// keeps compiling during the migration. New handlers should pull
|
||||
/// `Extension<Principal>` directly.
|
||||
#[deprecated(note = "use Extension<Principal> directly")]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthedAdmin {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Middleware function. Wire with
|
||||
/// `axum::middleware::from_fn_with_state(auth_state, require_admin)`.
|
||||
pub async fn require_admin(
|
||||
/// Middleware entry point. Wire with
|
||||
/// `axum::middleware::from_fn_with_state(auth_state, require_authenticated)`.
|
||||
/// Inserts `Principal` (and the legacy `AuthedAdmin`) as request
|
||||
/// extensions on success; returns 401 on any failure mode.
|
||||
pub async fn require_authenticated(
|
||||
State(state): State<AuthState>,
|
||||
mut req: Request<Body>,
|
||||
next: Next,
|
||||
@@ -53,48 +74,191 @@ pub async fn require_admin(
|
||||
let Some(token) = extract_token(&req) else {
|
||||
return unauthorized();
|
||||
};
|
||||
let token_hash = hash_token(&token);
|
||||
let principal = match resolve_principal(&state, &token).await {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return unauthorized(),
|
||||
Err(InternalError) => return internal_error(),
|
||||
};
|
||||
|
||||
let username_for_legacy = username_for(&state, principal.user_id).await;
|
||||
req.extensions_mut().insert(principal.clone());
|
||||
#[allow(deprecated)]
|
||||
if let Some(username) = username_for_legacy {
|
||||
req.extensions_mut().insert(AuthedAdmin {
|
||||
id: principal.user_id,
|
||||
username,
|
||||
});
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Backwards-compatible alias — the single callsite that still names
|
||||
/// `require_admin` keeps working without an immediate rename. New
|
||||
/// wiring should call `require_authenticated`.
|
||||
#[deprecated(note = "renamed to require_authenticated")]
|
||||
pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Next) -> Response {
|
||||
require_authenticated(state, req, next).await
|
||||
}
|
||||
|
||||
/// Opportunistic data-plane variant: always inserts an
|
||||
/// `Extension<Option<Principal>>` and forwards the request. Used on
|
||||
/// `/execute/{id}` and the user-route fallback, where most invocations
|
||||
/// are anonymous public HTTP and the few authed ones (dashboard
|
||||
/// test-runs, API keys) should still let scripts see the caller via
|
||||
/// `cx.principal` once services consume it.
|
||||
///
|
||||
/// Failure modes — all degrade to `None` rather than rejecting:
|
||||
/// * No bearer / cookie → `None`.
|
||||
/// * Malformed or unknown token → `None`.
|
||||
/// * DB blip while resolving → `None` (fail-open; the data plane
|
||||
/// should not 500 on transient infra failures for an *optional*
|
||||
/// identity check).
|
||||
///
|
||||
/// Admin-side routes that REQUIRE an identity keep using
|
||||
/// `require_authenticated`.
|
||||
pub async fn attach_principal_if_present(
|
||||
State(state): State<AuthState>,
|
||||
mut req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let principal: Option<Principal> = match extract_token(&req) {
|
||||
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
|
||||
None => None,
|
||||
};
|
||||
req.extensions_mut().insert(principal);
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Decide whether the token is an API key (pic_ prefix) or a session
|
||||
/// token, then resolve the corresponding `Principal`. `Ok(None)`
|
||||
/// means the token was structurally valid but didn't match any active
|
||||
/// credential; `Err(InternalError)` means a DB blip.
|
||||
async fn resolve_principal(
|
||||
state: &AuthState,
|
||||
token: &str,
|
||||
) -> Result<Option<Principal>, InternalError> {
|
||||
if let Some(rest) = token.strip_prefix(API_KEY_PREFIX) {
|
||||
return verify_api_key(state, rest).await;
|
||||
}
|
||||
verify_session(state, token).await
|
||||
}
|
||||
|
||||
async fn verify_session(
|
||||
state: &AuthState,
|
||||
token: &str,
|
||||
) -> Result<Option<Principal>, InternalError> {
|
||||
let token_hash = hash_token(token);
|
||||
|
||||
let lookup = match state.sessions.lookup(&token_hash).await {
|
||||
Ok(Some(lookup)) => lookup,
|
||||
Ok(None) => return unauthorized(),
|
||||
Ok(Some(l)) => l,
|
||||
Ok(None) => return Ok(None),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_sessions lookup failed");
|
||||
return internal_error();
|
||||
return Err(InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve the user. A deleted user is impossible here (FK cascade
|
||||
// wipes their sessions), but a deactivated user still needs to be
|
||||
// rejected — and so does the edge case of a session predating the
|
||||
// deactivate (we wipe their sessions on deactivate, but a race
|
||||
// could land a request in flight).
|
||||
let user = match state.users.get(lookup.user_id).await {
|
||||
Ok(Some(u)) if u.is_active => u,
|
||||
Ok(_) => return unauthorized(),
|
||||
Ok(_) => return Ok(None),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users lookup failed");
|
||||
return internal_error();
|
||||
return Err(InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
// Sliding window bump. Inline (not fire-and-forget) so a DB blip
|
||||
// surfaces as a request error rather than silent stale sessions.
|
||||
// Sliding-window bump — inline so a DB blip surfaces as 500 rather
|
||||
// than silent stale sessions. Same shape as Phase 3a.
|
||||
let new_expires_at = Utc::now() + chrono::Duration::from_std(state.ttl).unwrap_or_default();
|
||||
if let Err(err) = state.sessions.touch(&token_hash, new_expires_at).await {
|
||||
tracing::error!(?err, "admin_sessions touch failed");
|
||||
return internal_error();
|
||||
return Err(InternalError);
|
||||
}
|
||||
|
||||
req.extensions_mut().insert(AuthedAdmin {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
next.run(req).await
|
||||
Ok(Some(Principal {
|
||||
user_id: user.id,
|
||||
instance_role: user.instance_role,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// API-key verification path. `rest` is the portion of the bearer
|
||||
/// value *after* `pic_`. We slice off the first 8 chars as the
|
||||
/// indexed lookup key, then Argon2id-verify each candidate's hash
|
||||
/// against the full `rest`. At most one match is expected; multiple
|
||||
/// candidates with the same prefix is statistically negligible but
|
||||
/// handled correctly (verify each, take the first match).
|
||||
async fn verify_api_key(state: &AuthState, rest: &str) -> Result<Option<Principal>, InternalError> {
|
||||
if rest.len() <= API_KEY_PREFIX_LEN {
|
||||
return Ok(None);
|
||||
}
|
||||
let prefix = &rest[..API_KEY_PREFIX_LEN];
|
||||
|
||||
let candidates = match state.keys.find_active_by_prefix(prefix).await {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "api_keys lookup failed");
|
||||
return Err(InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
let matched: Option<ApiKeyVerification> = candidates
|
||||
.into_iter()
|
||||
.find(|c| verify_password(&c.hash, rest));
|
||||
let Some(matched) = matched else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Resolve the owning user. is_active = false → reject even if the
|
||||
// key itself hasn't been expired yet (the expire_all_for_user
|
||||
// cascade on deactivation is the primary defense; this is the
|
||||
// belt-and-suspenders check at request time).
|
||||
let user = match state.users.get(matched.user_id).await {
|
||||
Ok(Some(u)) if u.is_active => u,
|
||||
Ok(_) => return Ok(None),
|
||||
Err(err) => {
|
||||
tracing::error!(?err, "admin_users lookup for api key failed");
|
||||
return Err(InternalError);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(err) = state.keys.touch_last_used(matched.id).await {
|
||||
tracing::error!(?err, "api_keys touch_last_used failed");
|
||||
// Soft-fail: a timestamp blip should not invalidate the
|
||||
// request. Continue with the resolved Principal.
|
||||
}
|
||||
|
||||
Ok(Some(Principal {
|
||||
user_id: user.id,
|
||||
instance_role: user.instance_role,
|
||||
scopes: Some(matched.scopes),
|
||||
app_binding: matched.app_id,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Best-effort username lookup for the legacy `AuthedAdmin` extension.
|
||||
/// Returns `None` on DB error (the caller treats `None` as "skip the
|
||||
/// legacy extension"). New handlers use `Principal` and don't depend
|
||||
/// on this.
|
||||
async fn username_for(state: &AuthState, id: AdminUserId) -> Option<String> {
|
||||
match state.users.get(id).await {
|
||||
Ok(Some(u)) => Some(u.username),
|
||||
Ok(None) => None,
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
?err,
|
||||
"username lookup for AuthedAdmin failed; skipping legacy ext"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the bearer token out of an `Authorization` header (preferred)
|
||||
/// or the `picloud_session` cookie (fallback for browser clients).
|
||||
/// Same shape as Phase 3a; the cookie only ever carries session
|
||||
/// tokens — no `pic_` prefix expected there.
|
||||
fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||
if let Some(value) = req.headers().get(header::AUTHORIZATION) {
|
||||
if let Ok(s) = value.to_str() {
|
||||
@@ -121,6 +285,11 @@ fn extract_token(req: &Request<Body>) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Sentinel returned from the resolve functions when a DB error should
|
||||
/// produce a 500 rather than a 401. Empty struct because the actual
|
||||
/// error is already logged at the failure site.
|
||||
struct InternalError;
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
@@ -141,6 +310,7 @@ fn internal_error() -> Response {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::Request;
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
fn req_with_header(name: &str, value: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
@@ -155,6 +325,12 @@ mod tests {
|
||||
assert_eq!(extract_token(&r).as_deref(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_bearer_pic_prefixed_token() {
|
||||
let r = req_with_header("authorization", "Bearer pic_abcdefghIJKL");
|
||||
assert_eq!(extract_token(&r).as_deref(), Some("pic_abcdefghIJKL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_bearer_with_no_token() {
|
||||
let r = req_with_header("authorization", "Bearer ");
|
||||
@@ -182,4 +358,20 @@ mod tests {
|
||||
let r = Request::builder().body(Body::empty()).unwrap();
|
||||
assert_eq!(extract_token(&r), None);
|
||||
}
|
||||
|
||||
// Round-trip test for the unused-variable to keep `Principal`
|
||||
// visibly tied to InstanceRole — caught a real bug during dev when
|
||||
// the field order in the struct literal had drifted.
|
||||
#[test]
|
||||
fn principal_construction_is_explicit() {
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
};
|
||||
assert_eq!(p.instance_role, InstanceRole::Owner);
|
||||
assert!(p.scopes.is_none());
|
||||
assert!(p.app_binding.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
643
crates/manager-core/src/authz.rs
Normal file
643
crates/manager-core/src/authz.rs
Normal file
@@ -0,0 +1,643 @@
|
||||
//! Capability-based authorization — see blueprint §11.6.
|
||||
//!
|
||||
//! Single entry point for every admin endpoint: `can(repo, principal,
|
||||
//! capability)` returns whether the caller can perform the action.
|
||||
//! Handlers call `require` (which wraps `can` + a `Forbidden` error)
|
||||
//! after loading the resource so the capability binds to the resource's
|
||||
//! actual `app_id`, not a path param the caller controls.
|
||||
//!
|
||||
//! Three layers of intersection, evaluated in order:
|
||||
//!
|
||||
//! 1. **Role grant** — does the caller's `InstanceRole` plus any
|
||||
//! `app_members` row authorize this capability?
|
||||
//! 2. **Scope intersection** — if the principal came from an API key
|
||||
//! (`principal.scopes.is_some()`), does the key's scope set cover
|
||||
//! the capability's required scope?
|
||||
//! 3. **App binding** — if the key was minted bound to a specific
|
||||
//! app (`principal.app_binding`), does the capability target the
|
||||
//! same app? (Instance-level capabilities are denied for bound
|
||||
//! keys; the mint handler also rejects the combination upfront.)
|
||||
//!
|
||||
//! The capability set is intentionally finer-grained than the seven
|
||||
//! scopes (e.g., `AppWriteScript` vs `AppWriteRoute` both fall under
|
||||
//! the `script:write` / `route:write` scopes respectively). Keeping
|
||||
//! capabilities precise lets a `script:write`-only key write scripts
|
||||
//! without also being able to mutate routes. The scope set stays at
|
||||
//! seven values — capabilities are the internal check, scopes are the
|
||||
//! external user-facing label.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
|
||||
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||
/// the `AppId` of the resource the action targets — handlers compute
|
||||
/// it from the loaded resource (e.g., `script.app_id`), not from a
|
||||
/// path param.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Capability {
|
||||
/// Create a new app. Owner / admin only.
|
||||
InstanceCreateApp,
|
||||
/// Create / update / delete admin_users rows (other than self
|
||||
/// password change, which is a separate flow). Owner / admin.
|
||||
InstanceManageUsers,
|
||||
/// Mutate instance-wide configuration (sandbox ceiling, etc.).
|
||||
/// Owner only.
|
||||
InstanceManageSettings,
|
||||
/// Read app metadata, scripts, routes. Viewer / editor / app_admin
|
||||
/// (member); implicit for admin / owner.
|
||||
AppRead(AppId),
|
||||
/// Create / update / delete a script in this app.
|
||||
AppWriteScript(AppId),
|
||||
/// Create / update / delete a route in this app.
|
||||
AppWriteRoute(AppId),
|
||||
/// Manage domain claims on this app (add / remove).
|
||||
AppManageDomains(AppId),
|
||||
/// App settings + delete app. app_admin only (or owner via
|
||||
/// implicit grant).
|
||||
AppAdmin(AppId),
|
||||
/// Read execution logs for scripts in this app.
|
||||
AppLogRead(AppId),
|
||||
/// Read entries from this app's KV store (v1.1.1). Granted to
|
||||
/// `viewer`+ in the per-app role table. Maps to `script:read` on
|
||||
/// API keys — the seven-scope vocabulary stays locked.
|
||||
AppKvRead(AppId),
|
||||
/// Write entries to this app's KV store (v1.1.1). Granted to
|
||||
/// `editor`+. Maps to `script:write` on API keys.
|
||||
AppKvWrite(AppId),
|
||||
/// Create / list / delete triggers for this app (v1.1.1). Maps to
|
||||
/// `app:admin` on API keys — triggers are app-configuration acts
|
||||
/// rather than data-plane access. Granted to `app_admin`+.
|
||||
AppManageTriggers(AppId),
|
||||
/// Replay / resolve dead-letter rows for this app (v1.1.1). Maps
|
||||
/// to `app:admin` on API keys. Public-HTTP scripts (principal None)
|
||||
/// fail this check — managing dead letters is an admin act.
|
||||
AppDeadLetterManage(AppId),
|
||||
}
|
||||
|
||||
impl Capability {
|
||||
/// Extract the `AppId` for app-scoped capabilities; `None` for
|
||||
/// instance-scoped ones. Used by the app-binding check on API keys.
|
||||
#[must_use]
|
||||
pub const fn app_id(self) -> Option<AppId> {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
None
|
||||
}
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
| Self::AppManageDomains(id)
|
||||
| Self::AppAdmin(id)
|
||||
| Self::AppLogRead(id)
|
||||
| Self::AppKvRead(id)
|
||||
| Self::AppKvWrite(id)
|
||||
| Self::AppManageTriggers(id)
|
||||
| Self::AppDeadLetterManage(id) => Some(id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The single scope that authorizes this capability on an API key.
|
||||
/// Strict mapping — a `script:write` key cannot read scripts unless
|
||||
/// it also carries `script:read`. The intent is predictability: a
|
||||
/// key has exactly the scopes it was minted with, no implicit
|
||||
/// upgrades.
|
||||
#[must_use]
|
||||
pub const fn required_scope(self) -> Scope {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
Scope::InstanceAdmin
|
||||
}
|
||||
Self::AppRead(_) | Self::AppKvRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_) | Self::AppKvWrite(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
Self::AppAdmin(_) | Self::AppManageTriggers(_) | Self::AppDeadLetterManage(_) => {
|
||||
Scope::AppAdmin
|
||||
}
|
||||
Self::AppLogRead(_) => Scope::LogRead,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Repo seam for membership lookups. Implemented in the DB-backed
|
||||
/// repos crate (`app_members_repo.rs`); keeping it as a trait here
|
||||
/// means unit tests can stub it.
|
||||
#[async_trait]
|
||||
pub trait AuthzRepo: Send + Sync {
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError>;
|
||||
}
|
||||
|
||||
/// Repo errors surface here so handlers can map them to 500 without
|
||||
/// dragging sqlx types across the boundary.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthzError {
|
||||
#[error("authorization repo error: {0}")]
|
||||
Repo(String),
|
||||
}
|
||||
|
||||
/// Decision flavor returned by `can` — distinguishes outright denial
|
||||
/// from a partial answer that requires further checks (none today,
|
||||
/// but the shape lets us add audit/explain mode later without rewriting
|
||||
/// every caller).
|
||||
#[must_use = "an authorization decision must be acted on"]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Decision {
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
impl Decision {
|
||||
#[must_use]
|
||||
pub const fn is_allow(self) -> bool {
|
||||
matches!(self, Self::Allow)
|
||||
}
|
||||
}
|
||||
|
||||
/// Core authorization check. Walks the three intersection layers in
|
||||
/// order and returns the resulting `Decision`.
|
||||
pub async fn can(
|
||||
repo: &dyn AuthzRepo,
|
||||
principal: &Principal,
|
||||
cap: Capability,
|
||||
) -> Result<Decision, AuthzError> {
|
||||
if !role_grants(repo, principal, cap).await? {
|
||||
return Ok(Decision::Deny);
|
||||
}
|
||||
if !scope_allows(principal, cap) {
|
||||
return Ok(Decision::Deny);
|
||||
}
|
||||
if !binding_allows(principal, cap) {
|
||||
return Ok(Decision::Deny);
|
||||
}
|
||||
Ok(Decision::Allow)
|
||||
}
|
||||
|
||||
/// Helper: returns `Ok(())` on Allow, `Err(AuthzDenied)` on Deny.
|
||||
/// Handlers call this so the `?` operator threads the 403 through
|
||||
/// naturally.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns `AuthzDenied::Denied` when the capability is not granted,
|
||||
/// or `AuthzDenied::Repo` if the underlying membership lookup fails.
|
||||
pub async fn require(
|
||||
repo: &dyn AuthzRepo,
|
||||
principal: &Principal,
|
||||
cap: Capability,
|
||||
) -> Result<(), AuthzDenied> {
|
||||
match can(repo, principal, cap).await {
|
||||
Ok(Decision::Allow) => Ok(()),
|
||||
Ok(Decision::Deny) => Err(AuthzDenied::Denied),
|
||||
Err(e) => Err(AuthzDenied::Repo(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AuthzDenied {
|
||||
#[error("forbidden")]
|
||||
Denied,
|
||||
#[error(transparent)]
|
||||
Repo(#[from] AuthzError),
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Layer 1: role-derived grant
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn role_grants(
|
||||
repo: &dyn AuthzRepo,
|
||||
principal: &Principal,
|
||||
cap: Capability,
|
||||
) -> Result<bool, AuthzError> {
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner => Ok(true),
|
||||
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
|
||||
/// They can create apps, manage users, and take any app-scoped action
|
||||
/// on any app without an explicit `app_members` row — single-human
|
||||
/// installs would otherwise need to add themselves to every new app.
|
||||
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
|
||||
/// owner-only.
|
||||
const fn admin_grants(cap: Capability) -> bool {
|
||||
!matches!(cap, Capability::InstanceManageSettings)
|
||||
}
|
||||
|
||||
/// Member has zero instance authority. App authority requires an
|
||||
/// explicit `app_members` row with sufficient `AppRole`.
|
||||
async fn member_grants(
|
||||
repo: &dyn AuthzRepo,
|
||||
user_id: UserId,
|
||||
cap: Capability,
|
||||
) -> Result<bool, AuthzError> {
|
||||
let Some(app_id) = cap.app_id() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(role) = repo.membership(user_id, app_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(role_satisfies(role, cap))
|
||||
}
|
||||
|
||||
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
||||
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||
/// the check is "is this capability in the role's set?".
|
||||
const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
let in_viewer = matches!(
|
||||
cap,
|
||||
Capability::AppRead(_) | Capability::AppLogRead(_) | Capability::AppKvRead(_)
|
||||
);
|
||||
let in_editor = in_viewer
|
||||
|| matches!(
|
||||
cap,
|
||||
Capability::AppWriteScript(_)
|
||||
| Capability::AppWriteRoute(_)
|
||||
| Capability::AppKvWrite(_)
|
||||
);
|
||||
let in_app_admin = in_editor
|
||||
|| matches!(
|
||||
cap,
|
||||
Capability::AppManageDomains(_)
|
||||
| Capability::AppAdmin(_)
|
||||
| Capability::AppManageTriggers(_)
|
||||
| Capability::AppDeadLetterManage(_)
|
||||
);
|
||||
match role {
|
||||
AppRole::Viewer => in_viewer,
|
||||
AppRole::Editor => in_editor,
|
||||
AppRole::AppAdmin => in_app_admin,
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Layer 2: API-key scope intersection
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn scope_allows(principal: &Principal, cap: Capability) -> bool {
|
||||
match &principal.scopes {
|
||||
None => true, // cookie session — full role authority
|
||||
Some(scopes) => scopes.contains(&cap.required_scope()),
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Layer 3: API-key app binding
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
fn binding_allows(principal: &Principal, cap: Capability) -> bool {
|
||||
let Some(bound_app) = principal.app_binding else {
|
||||
return true;
|
||||
};
|
||||
match cap.app_id() {
|
||||
// Instance-scoped capability + bound key → always denied. The
|
||||
// mint handler also rejects this combination upfront, but
|
||||
// defending in depth here means a stale/malformed row can't
|
||||
// escalate.
|
||||
None => false,
|
||||
Some(target_app) => target_app == bound_app,
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use picloud_shared::{AdminUserId, AppId};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
||||
#[derive(Default)]
|
||||
struct InMemoryAuthzRepo {
|
||||
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||
}
|
||||
|
||||
impl InMemoryAuthzRepo {
|
||||
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||
self.memberships.lock().await.insert((user, app), role);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for InMemoryAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(self
|
||||
.memberships
|
||||
.lock()
|
||||
.await
|
||||
.get(&(user_id, app_id))
|
||||
.copied())
|
||||
}
|
||||
}
|
||||
|
||||
fn principal(role: InstanceRole) -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: role,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_can_do_everything() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Owner);
|
||||
let app = AppId::new();
|
||||
for cap in [
|
||||
Capability::InstanceCreateApp,
|
||||
Capability::InstanceManageUsers,
|
||||
Capability::InstanceManageSettings,
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppWriteRoute(app),
|
||||
Capability::AppManageDomains(app),
|
||||
Capability::AppAdmin(app),
|
||||
Capability::AppLogRead(app),
|
||||
] {
|
||||
assert_eq!(
|
||||
can(&repo, &p, cap).await.unwrap(),
|
||||
Decision::Allow,
|
||||
"owner denied {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_cannot_manage_instance_settings() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceManageSettings)
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_is_implicit_app_admin_on_every_app() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
let app = AppId::new();
|
||||
// Instance-scoped allowances.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||
Decision::Allow,
|
||||
);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceManageUsers)
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Allow,
|
||||
);
|
||||
// Editor-like + app-admin grants both succeed without any
|
||||
// app_members row.
|
||||
for cap in [
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppWriteRoute(app),
|
||||
Capability::AppLogRead(app),
|
||||
Capability::AppManageDomains(app),
|
||||
Capability::AppAdmin(app),
|
||||
] {
|
||||
assert_eq!(
|
||||
can(&repo, &p, cap).await.unwrap(),
|
||||
Decision::Allow,
|
||||
"admin denied app-scoped capability {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_without_row_is_denied_everywhere() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Member);
|
||||
let app = AppId::new();
|
||||
for cap in [
|
||||
Capability::InstanceCreateApp,
|
||||
Capability::InstanceManageUsers,
|
||||
Capability::InstanceManageSettings,
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppWriteRoute(app),
|
||||
Capability::AppAdmin(app),
|
||||
Capability::AppLogRead(app),
|
||||
] {
|
||||
assert_eq!(
|
||||
can(&repo, &p, cap).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"member granted {cap:?} without a membership row"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_with_viewer_role_can_read_but_not_write() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Member);
|
||||
let app = AppId::new();
|
||||
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert!(can(&repo, &p, Capability::AppLogRead(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_with_editor_role_can_write_scripts_and_routes() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Member);
|
||||
let app = AppId::new();
|
||||
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert!(can(&repo, &p, Capability::AppWriteRoute(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
|
||||
/// (Delete). The script-delete handler gates on the latter so the
|
||||
/// API can't be tricked into letting an editor remove the script
|
||||
/// they were only allowed to edit.
|
||||
#[tokio::test]
|
||||
async fn editor_can_write_scripts_but_not_delete_them() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Member);
|
||||
let app = AppId::new();
|
||||
repo.grant(p.user_id, app, AppRole::Editor).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
// Delete is gated on AppAdmin in the handler — editors must be
|
||||
// denied here for that gate to bite.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_with_app_admin_role_can_do_app_admin_actions() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let p = principal(InstanceRole::Member);
|
||||
let app = AppId::new();
|
||||
repo.grant(p.user_id, app, AppRole::AppAdmin).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert!(can(&repo, &p, Capability::AppManageDomains(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
// Membership in App A does NOT grant access to App B
|
||||
let other_app = AppId::new();
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(other_app))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scoped_key_intersects_with_role() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let app = AppId::new();
|
||||
// Owner key with only script:read — cannot write
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::ScriptRead]),
|
||||
app_binding: None,
|
||||
};
|
||||
assert!(can(&repo, &p, Capability::AppRead(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
// Even though the user is owner — the key's scope set is the
|
||||
// hard ceiling.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bound_key_cannot_escape_its_app() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let bound_app = AppId::new();
|
||||
let other_app = AppId::new();
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::ScriptWrite]),
|
||||
app_binding: Some(bound_app),
|
||||
};
|
||||
assert!(can(&repo, &p, Capability::AppWriteScript(bound_app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppWriteScript(other_app))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bound_key_cannot_do_instance_actions() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let bound_app = AppId::new();
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::InstanceAdmin]), // mint handler also rejects this combo
|
||||
app_binding: Some(bound_app),
|
||||
};
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"bound key with instance scope must still be denied at the binding layer"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_app_id_extraction() {
|
||||
let app = AppId::new();
|
||||
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_required_scope_mapping_is_complete() {
|
||||
// Sanity: every variant returns a scope. Compiler-enforced
|
||||
// exhaustiveness lives in the match itself; this test guards
|
||||
// against accidental drift to a default branch.
|
||||
let app = AppId::new();
|
||||
for cap in [
|
||||
Capability::InstanceCreateApp,
|
||||
Capability::InstanceManageUsers,
|
||||
Capability::InstanceManageSettings,
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppWriteRoute(app),
|
||||
Capability::AppManageDomains(app),
|
||||
Capability::AppAdmin(app),
|
||||
Capability::AppLogRead(app),
|
||||
] {
|
||||
let _ = cap.required_scope(); // does not panic
|
||||
}
|
||||
}
|
||||
}
|
||||
261
crates/manager-core/src/dead_letter_repo.rs
Normal file
261
crates/manager-core/src/dead_letter_repo.rs
Normal file
@@ -0,0 +1,261 @@
|
||||
//! `DeadLetterRepo` — CRUD over the `dead_letters` table.
|
||||
//!
|
||||
//! The dispatcher writes new rows when an async trigger exhausts its
|
||||
//! retry policy. Admin endpoints (commit 8) read for the dashboard
|
||||
//! list view and write to mark rows resolved or replay them. The GC
|
||||
//! sweeper (commit 10) deletes expired rows by `created_at`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, DeadLetterId, ScriptId, TriggerId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeadLetterRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("dead-letter row not found: {0}")]
|
||||
NotFound(DeadLetterId),
|
||||
|
||||
#[error("invalid resolution {0:?}")]
|
||||
InvalidResolution(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewDeadLetter {
|
||||
pub app_id: AppId,
|
||||
/// `outbox.id` that exhausted retries. Outbox row deleted at the
|
||||
/// same time.
|
||||
pub original_event_id: Uuid,
|
||||
pub source: String,
|
||||
pub op: String,
|
||||
pub trigger_id: Option<TriggerId>,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub payload: serde_json::Value,
|
||||
pub attempt_count: u32,
|
||||
pub first_attempt_at: DateTime<Utc>,
|
||||
pub last_attempt_at: DateTime<Utc>,
|
||||
pub last_error: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeadLetterRow {
|
||||
pub id: DeadLetterId,
|
||||
pub app_id: AppId,
|
||||
pub original_event_id: Uuid,
|
||||
pub source: String,
|
||||
pub op: String,
|
||||
pub trigger_id: Option<TriggerId>,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub payload: serde_json::Value,
|
||||
pub attempt_count: u32,
|
||||
pub first_attempt_at: DateTime<Utc>,
|
||||
pub last_attempt_at: DateTime<Utc>,
|
||||
pub last_error: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
pub resolution: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait DeadLetterRepo: Send + Sync {
|
||||
/// Insert a new dead-letter row. Returns the assigned id.
|
||||
async fn insert(&self, row: NewDeadLetter) -> Result<DeadLetterId, DeadLetterRepoError>;
|
||||
|
||||
async fn get(&self, id: DeadLetterId) -> Result<Option<DeadLetterRow>, DeadLetterRepoError>;
|
||||
|
||||
/// Lookup for the dashboard list view. `unresolved_only=true`
|
||||
/// filters to `resolved_at IS NULL`.
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
unresolved_only: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DeadLetterRow>, DeadLetterRepoError>;
|
||||
|
||||
/// Hot path for the dashboard's per-app unresolved-count badge.
|
||||
async fn unresolved_count(&self, app_id: AppId) -> Result<i64, DeadLetterRepoError>;
|
||||
|
||||
/// Mark the row resolved with the given reason. The reason MUST
|
||||
/// be one of the four CHECK-constraint values
|
||||
/// (`replayed`, `ignored`, `handled_by_script`, `handler_failed`).
|
||||
async fn resolve(&self, id: DeadLetterId, reason: &str) -> Result<(), DeadLetterRepoError>;
|
||||
|
||||
/// Retention sweep. Deletes rows with `created_at < older_than`
|
||||
/// up to `limit` at a time, using FOR UPDATE SKIP LOCKED to play
|
||||
/// nicely with concurrent dispatchers. Returns the count deleted.
|
||||
async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, DeadLetterRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresDeadLetterRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresDeadLetterRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_RESOLUTIONS: &[&str] =
|
||||
&["replayed", "ignored", "handled_by_script", "handler_failed"];
|
||||
|
||||
#[async_trait]
|
||||
impl DeadLetterRepo for PostgresDeadLetterRepo {
|
||||
async fn insert(&self, row: NewDeadLetter) -> Result<DeadLetterId, DeadLetterRepoError> {
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO dead_letters ( \
|
||||
app_id, original_event_id, source, op, trigger_id, script_id, \
|
||||
payload, attempt_count, first_attempt_at, last_attempt_at, last_error \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(row.app_id.into_inner())
|
||||
.bind(row.original_event_id)
|
||||
.bind(row.source)
|
||||
.bind(row.op)
|
||||
.bind(row.trigger_id.map(TriggerId::into_inner))
|
||||
.bind(row.script_id.map(ScriptId::into_inner))
|
||||
.bind(row.payload)
|
||||
.bind(i32::try_from(row.attempt_count).unwrap_or(0))
|
||||
.bind(row.first_attempt_at)
|
||||
.bind(row.last_attempt_at)
|
||||
.bind(row.last_error)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(id.into())
|
||||
}
|
||||
|
||||
async fn get(&self, id: DeadLetterId) -> Result<Option<DeadLetterRow>, DeadLetterRepoError> {
|
||||
let row: Option<DeadLetterRowRaw> = sqlx::query_as(
|
||||
"SELECT id, app_id, original_event_id, source, op, trigger_id, script_id, \
|
||||
payload, attempt_count, first_attempt_at, last_attempt_at, \
|
||||
last_error, created_at, resolved_at, resolution \
|
||||
FROM dead_letters WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(DeadLetterRowRaw::into_row))
|
||||
}
|
||||
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
unresolved_only: bool,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> Result<Vec<DeadLetterRow>, DeadLetterRepoError> {
|
||||
let rows: Vec<DeadLetterRowRaw> = sqlx::query_as(
|
||||
"SELECT id, app_id, original_event_id, source, op, trigger_id, script_id, \
|
||||
payload, attempt_count, first_attempt_at, last_attempt_at, \
|
||||
last_error, created_at, resolved_at, resolution \
|
||||
FROM dead_letters \
|
||||
WHERE app_id = $1 \
|
||||
AND ($2::bool = FALSE OR resolved_at IS NULL) \
|
||||
ORDER BY created_at DESC \
|
||||
LIMIT $3 OFFSET $4",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(unresolved_only)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(DeadLetterRowRaw::into_row).collect())
|
||||
}
|
||||
|
||||
async fn unresolved_count(&self, app_id: AppId) -> Result<i64, DeadLetterRepoError> {
|
||||
let (count,): (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM dead_letters \
|
||||
WHERE app_id = $1 AND resolved_at IS NULL",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn resolve(&self, id: DeadLetterId, reason: &str) -> Result<(), DeadLetterRepoError> {
|
||||
if !ALLOWED_RESOLUTIONS.contains(&reason) {
|
||||
return Err(DeadLetterRepoError::InvalidResolution(reason.to_string()));
|
||||
}
|
||||
let res = sqlx::query(
|
||||
"UPDATE dead_letters \
|
||||
SET resolution = $2, resolved_at = NOW() \
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(reason)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(DeadLetterRepoError::NotFound(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn gc(&self, older_than: DateTime<Utc>, limit: i64) -> Result<u64, DeadLetterRepoError> {
|
||||
// Tombstones picked under FOR UPDATE SKIP LOCKED so concurrent
|
||||
// sweepers (cluster mode) don't fight each other.
|
||||
let res = sqlx::query(
|
||||
"DELETE FROM dead_letters \
|
||||
WHERE id IN ( \
|
||||
SELECT id FROM dead_letters \
|
||||
WHERE created_at < $1 \
|
||||
FOR UPDATE SKIP LOCKED \
|
||||
LIMIT $2 \
|
||||
)",
|
||||
)
|
||||
.bind(older_than)
|
||||
.bind(limit)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DeadLetterRowRaw {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
original_event_id: Uuid,
|
||||
source: String,
|
||||
op: String,
|
||||
trigger_id: Option<Uuid>,
|
||||
script_id: Option<Uuid>,
|
||||
payload: serde_json::Value,
|
||||
attempt_count: i32,
|
||||
first_attempt_at: DateTime<Utc>,
|
||||
last_attempt_at: DateTime<Utc>,
|
||||
last_error: String,
|
||||
created_at: DateTime<Utc>,
|
||||
resolved_at: Option<DateTime<Utc>>,
|
||||
resolution: Option<String>,
|
||||
}
|
||||
|
||||
impl DeadLetterRowRaw {
|
||||
fn into_row(self) -> DeadLetterRow {
|
||||
DeadLetterRow {
|
||||
id: self.id.into(),
|
||||
app_id: self.app_id.into(),
|
||||
original_event_id: self.original_event_id,
|
||||
source: self.source,
|
||||
op: self.op,
|
||||
trigger_id: self.trigger_id.map(Into::into),
|
||||
script_id: self.script_id.map(Into::into),
|
||||
payload: self.payload,
|
||||
attempt_count: u32::try_from(self.attempt_count).unwrap_or(0),
|
||||
first_attempt_at: self.first_attempt_at,
|
||||
last_attempt_at: self.last_attempt_at,
|
||||
last_error: self.last_error,
|
||||
created_at: self.created_at,
|
||||
resolved_at: self.resolved_at,
|
||||
resolution: self.resolution,
|
||||
}
|
||||
}
|
||||
}
|
||||
118
crates/manager-core/src/dead_letter_service.rs
Normal file
118
crates/manager-core/src/dead_letter_service.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! `PostgresDeadLetterService` — replaces `NoopDeadLetterService` in
|
||||
//! v1.1.1's `Services` bundle. Implements `replay` (re-enqueue the
|
||||
//! original event into the outbox + mark the DL row replayed) and
|
||||
//! `resolve` (close the row out with a reason).
|
||||
//!
|
||||
//! Both methods are gated by `Capability::AppDeadLetterManage(AppId)`
|
||||
//! evaluated against `cx.principal`. Public-HTTP scripts with
|
||||
//! `principal: None` fail the check — design notes §4: managing
|
||||
//! dead letters is an admin act.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{DeadLetterError, DeadLetterId, DeadLetterService, SdkCallCx};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::dead_letter_repo::{DeadLetterRepo, DeadLetterRepoError, DeadLetterRow};
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
||||
|
||||
pub struct PostgresDeadLetterService {
|
||||
repo: Arc<dyn DeadLetterRepo>,
|
||||
outbox: Arc<dyn OutboxRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
impl PostgresDeadLetterService {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn DeadLetterRepo>,
|
||||
outbox: Arc<dyn OutboxRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
outbox,
|
||||
authz,
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_dl_capability(&self, cx: &SdkCallCx) -> Result<(), DeadLetterError> {
|
||||
let Some(ref principal) = cx.principal else {
|
||||
return Err(DeadLetterError::Forbidden);
|
||||
};
|
||||
authz::require(
|
||||
&*self.authz,
|
||||
principal,
|
||||
Capability::AppDeadLetterManage(cx.app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| DeadLetterError::Forbidden)
|
||||
}
|
||||
|
||||
async fn load_row(&self, id: DeadLetterId) -> Result<DeadLetterRow, DeadLetterError> {
|
||||
self.repo
|
||||
.get(id)
|
||||
.await
|
||||
.map_err(map_repo_err)?
|
||||
.ok_or(DeadLetterError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DeadLetterService for PostgresDeadLetterService {
|
||||
async fn replay(&self, cx: &SdkCallCx, id: DeadLetterId) -> Result<(), DeadLetterError> {
|
||||
self.require_dl_capability(cx).await?;
|
||||
let row = self.load_row(id).await?;
|
||||
if row.app_id != cx.app_id {
|
||||
// Cross-app — treat as not-found to avoid leaking
|
||||
// information about other apps' dead letters.
|
||||
return Err(DeadLetterError::NotFound);
|
||||
}
|
||||
|
||||
let source_kind = OutboxSourceKind::from_wire(&row.source).unwrap_or(OutboxSourceKind::Kv);
|
||||
self.outbox
|
||||
.insert(NewOutboxRow {
|
||||
app_id: row.app_id,
|
||||
source_kind,
|
||||
trigger_id: row.trigger_id,
|
||||
script_id: row.script_id,
|
||||
reply_to: None,
|
||||
payload: row.payload.clone(),
|
||||
origin_principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| DeadLetterError::Backend(e.to_string()))?;
|
||||
|
||||
self.repo
|
||||
.resolve(id, "replayed")
|
||||
.await
|
||||
.map_err(map_repo_err)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
id: DeadLetterId,
|
||||
reason: &str,
|
||||
) -> Result<(), DeadLetterError> {
|
||||
self.require_dl_capability(cx).await?;
|
||||
let row = self.load_row(id).await?;
|
||||
if row.app_id != cx.app_id {
|
||||
return Err(DeadLetterError::NotFound);
|
||||
}
|
||||
self.repo.resolve(id, reason).await.map_err(map_repo_err)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn map_repo_err(e: DeadLetterRepoError) -> DeadLetterError {
|
||||
match e {
|
||||
DeadLetterRepoError::NotFound(_) => DeadLetterError::NotFound,
|
||||
DeadLetterRepoError::InvalidResolution(s) => DeadLetterError::InvalidResolution(s),
|
||||
DeadLetterRepoError::Db(e) => DeadLetterError::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
316
crates/manager-core/src/dead_letters_api.rs
Normal file
316
crates/manager-core/src/dead_letters_api.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
//! `/api/v1/admin/apps/{id}/dead_letters/*` — dashboard surface for
|
||||
//! the no-default-handler model (design notes §4).
|
||||
//!
|
||||
//! Endpoints:
|
||||
//! - `GET /apps/{id}/dead_letters?unresolved=true` — list view
|
||||
//! - `GET /apps/{id}/dead_letters/count` — badge count
|
||||
//! - `GET /apps/{id}/dead_letters/{dl_id}` — row detail
|
||||
//! - `POST /apps/{id}/dead_letters/{dl_id}/replay` — re-enqueue
|
||||
//! - `POST /apps/{id}/dead_letters/{dl_id}/resolve` — mark resolved
|
||||
//!
|
||||
//! All gated on `Capability::AppDeadLetterManage(app_id)`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, DeadLetterId, DeadLetterService, Principal, SdkCallCx};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::dead_letter_repo::{DeadLetterRepo, DeadLetterRepoError, DeadLetterRow};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DeadLettersState {
|
||||
pub repo: Arc<dyn DeadLetterRepo>,
|
||||
pub service: Arc<dyn DeadLetterService>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn dead_letters_router(state: DeadLettersState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/dead_letters", get(list))
|
||||
.route("/apps/{app_id}/dead_letters/count", get(count))
|
||||
.route("/apps/{app_id}/dead_letters/{dl_id}", get(detail))
|
||||
.route("/apps/{app_id}/dead_letters/{dl_id}/replay", post(replay))
|
||||
.route("/apps/{app_id}/dead_letters/{dl_id}/resolve", post(resolve))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListQuery {
|
||||
#[serde(default)]
|
||||
pub unresolved: bool,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
const fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListResponse {
|
||||
pub dead_letters: Vec<DeadLetterDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CountResponse {
|
||||
pub unresolved: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ResolveBody {
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeadLetterDto {
|
||||
pub id: DeadLetterId,
|
||||
pub app_id: AppId,
|
||||
pub source: String,
|
||||
pub op: String,
|
||||
pub trigger_id: Option<picloud_shared::TriggerId>,
|
||||
pub script_id: Option<picloud_shared::ScriptId>,
|
||||
pub payload: serde_json::Value,
|
||||
pub attempt_count: u32,
|
||||
pub first_attempt_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_attempt_at: chrono::DateTime<chrono::Utc>,
|
||||
pub last_error: String,
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
pub resolved_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
pub resolution: Option<String>,
|
||||
}
|
||||
|
||||
impl From<DeadLetterRow> for DeadLetterDto {
|
||||
fn from(r: DeadLetterRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id,
|
||||
source: r.source,
|
||||
op: r.op,
|
||||
trigger_id: r.trigger_id,
|
||||
script_id: r.script_id,
|
||||
payload: r.payload,
|
||||
attempt_count: r.attempt_count,
|
||||
first_attempt_at: r.first_attempt_at,
|
||||
last_attempt_at: r.last_attempt_at,
|
||||
last_error: r.last_error,
|
||||
created_at: r.created_at,
|
||||
resolved_at: r.resolved_at,
|
||||
resolution: r.resolution,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn list(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<ListResponse>, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppDeadLetterManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
let rows = s
|
||||
.repo
|
||||
.list_for_app(app_id, q.unresolved, q.limit.clamp(1, 200), q.offset.max(0))
|
||||
.await?;
|
||||
Ok(Json(ListResponse {
|
||||
dead_letters: rows.into_iter().map(Into::into).collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn count(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
) -> Result<Json<CountResponse>, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppDeadLetterManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
let n = s.repo.unresolved_count(app_id).await?;
|
||||
Ok(Json(CountResponse { unresolved: n }))
|
||||
}
|
||||
|
||||
async fn detail(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
|
||||
) -> Result<Json<DeadLetterDto>, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppDeadLetterManage(app_id),
|
||||
)
|
||||
.await?;
|
||||
let row = s
|
||||
.repo
|
||||
.get(dl_id)
|
||||
.await?
|
||||
.ok_or(DeadLettersApiError::NotFound(dl_id))?;
|
||||
if row.app_id != app_id {
|
||||
return Err(DeadLettersApiError::NotFound(dl_id));
|
||||
}
|
||||
Ok(Json(row.into()))
|
||||
}
|
||||
|
||||
async fn replay(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
|
||||
) -> Result<StatusCode, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
// Authz handled inside the service via SdkCallCx.
|
||||
let cx = admin_cx(app_id, &principal);
|
||||
s.service
|
||||
.replay(&cx, dl_id)
|
||||
.await
|
||||
.map_err(map_service_err)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn resolve(
|
||||
State(s): State<DeadLettersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, dl_id)): Path<(AppId, DeadLetterId)>,
|
||||
Json(body): Json<ResolveBody>,
|
||||
) -> Result<StatusCode, DeadLettersApiError> {
|
||||
ensure_app(&*s.apps, app_id).await?;
|
||||
let cx = admin_cx(app_id, &principal);
|
||||
s.service
|
||||
.resolve(&cx, dl_id, &body.reason)
|
||||
.await
|
||||
.map_err(map_service_err)?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Synthesize an `SdkCallCx` for the admin path. The service layer
|
||||
/// reads `cx.app_id` + `cx.principal` and ignores the trigger /
|
||||
/// execution fields, so the per-call ids are arbitrary.
|
||||
fn admin_cx(app_id: AppId, principal: &Principal) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
principal: Some(principal.clone()),
|
||||
execution_id: picloud_shared::ExecutionId::new(),
|
||||
request_id: picloud_shared::RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: picloud_shared::ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_app(apps: &dyn AppRepository, app_id: AppId) -> Result<(), DeadLettersApiError> {
|
||||
apps.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| DeadLettersApiError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| DeadLettersApiError::AppNotFound(app_id.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_service_err(e: picloud_shared::DeadLetterError) -> DeadLettersApiError {
|
||||
match e {
|
||||
picloud_shared::DeadLetterError::NotFound => {
|
||||
DeadLettersApiError::NotFound(DeadLetterId::new())
|
||||
}
|
||||
picloud_shared::DeadLetterError::Forbidden => DeadLettersApiError::Forbidden,
|
||||
picloud_shared::DeadLetterError::InvalidResolution(s) => {
|
||||
DeadLettersApiError::Invalid(format!("invalid resolution: {s}"))
|
||||
}
|
||||
picloud_shared::DeadLetterError::Backend(s) => DeadLettersApiError::Backend(s),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeadLettersApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("dead-letter not found: {0}")]
|
||||
NotFound(DeadLetterId),
|
||||
|
||||
#[error("invalid: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("dead-letter backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for DeadLettersApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for DeadLettersApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DeadLetterRepoError> for DeadLettersApiError {
|
||||
fn from(e: DeadLetterRepoError) -> Self {
|
||||
match e {
|
||||
DeadLetterRepoError::NotFound(id) => Self::NotFound(id),
|
||||
DeadLetterRepoError::InvalidResolution(s) => Self::Invalid(s),
|
||||
DeadLetterRepoError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for DeadLettersApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_) | Self::NotFound(_) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "dead_letters authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "dead_letters api backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
685
crates/manager-core/src/dispatcher.rs
Normal file
685
crates/manager-core/src/dispatcher.rs
Normal file
@@ -0,0 +1,685 @@
|
||||
//! The triggers-framework dispatcher.
|
||||
//!
|
||||
//! Single tokio task that polls the outbox, claims due rows
|
||||
//! (`FOR UPDATE SKIP LOCKED`), and routes each to the executor.
|
||||
//! Shares the `ExecutionGate` with sync HTTP — they compete for the
|
||||
//! same permit budget, matching design notes §2.
|
||||
//!
|
||||
//! Outcome handling per design notes §3 and §4:
|
||||
//! - reply_to.is_some() (sync HTTP): never retry. Deliver to inbox
|
||||
//! (or write `abandoned_executions` if the receiver dropped).
|
||||
//! - is_dead_letter_handler == true: never retry, never DL. Failure
|
||||
//! just annotates the original DL row with `resolution =
|
||||
//! 'handler_failed'` and bumps a metric.
|
||||
//! - Otherwise on failure: if `attempt_count + 1 < max_attempts`,
|
||||
//! reschedule with backoff + jitter. Else, write a `dead_letters`
|
||||
//! row and delete from outbox.
|
||||
//!
|
||||
//! Depth-limit: `trigger_depth > max_trigger_depth` skips execution
|
||||
//! entirely (log + metric) and deletes the row — does NOT dead-letter
|
||||
//! (design notes §4: depth-exceeded means "you built a loop", and
|
||||
//! dead-lettering would just re-fire the same loop).
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
ExecResponseSummary, ExecutionId, HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind,
|
||||
InboxResolver, InboxResult, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use rand::Rng;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::abandoned_repo::{AbandonedRepo, NewAbandonedExecution};
|
||||
use crate::dead_letter_repo::{DeadLetterRepo, NewDeadLetter};
|
||||
use crate::outbox_repo::{OutboxRepo, OutboxRow, OutboxSourceKind};
|
||||
use crate::principal_resolver::PrincipalResolver;
|
||||
use crate::repo::ScriptRepository;
|
||||
use crate::trigger_config::{BackoffShape, TriggerConfig};
|
||||
use crate::trigger_repo::{TriggerKind, TriggerRepo};
|
||||
|
||||
/// Bundle the dispatcher reads from. Each handle is `Arc<dyn …>` so
|
||||
/// tests can substitute in-memory backings.
|
||||
pub struct Dispatcher {
|
||||
pub outbox: Arc<dyn OutboxRepo>,
|
||||
pub triggers: Arc<dyn TriggerRepo>,
|
||||
pub scripts: Arc<dyn ScriptRepository>,
|
||||
pub dead_letters: Arc<dyn DeadLetterRepo>,
|
||||
pub abandoned: Arc<dyn AbandonedRepo>,
|
||||
pub principals: Arc<dyn PrincipalResolver>,
|
||||
pub executor: Arc<dyn ExecutorClient>,
|
||||
pub gate: Arc<ExecutionGate>,
|
||||
pub inbox: Arc<dyn InboxResolver>,
|
||||
pub config: TriggerConfig,
|
||||
/// Stable id for this dispatcher instance — written into
|
||||
/// `outbox.claimed_by` for forensics. In MVP this is the host's
|
||||
/// pid; cluster mode (v1.3+) uses node identity.
|
||||
pub instance_id: String,
|
||||
}
|
||||
|
||||
/// How many outbox rows the dispatcher tries to claim per tick.
|
||||
/// Bounded to keep the working set small even if there's a flood.
|
||||
const CLAIM_BATCH: i64 = 8;
|
||||
|
||||
/// Polling cadence. Short enough that fan-out feels instant; long
|
||||
/// enough that an idle dispatcher doesn't burn cycles.
|
||||
const TICK_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Hard cap on the wall-clock budget passed to the executor for an
|
||||
/// async-dispatched script. Sync HTTP gets a per-script timeout via
|
||||
/// the orchestrator path; async rows don't have one, so we apply a
|
||||
/// platform-wide ceiling here. Matches `LocalExecutorClient`'s own
|
||||
/// 5-minute cap.
|
||||
const ASYNC_EXEC_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
impl Dispatcher {
|
||||
/// Spawn the dispatcher loop as a detached `tokio::task`. The
|
||||
/// returned `JoinHandle` is dropped — the loop runs for the
|
||||
/// process lifetime.
|
||||
pub fn spawn(self) {
|
||||
tokio::spawn(async move {
|
||||
self.run().await;
|
||||
});
|
||||
}
|
||||
|
||||
async fn run(self) {
|
||||
let mut ticker = tokio::time::interval(TICK_INTERVAL);
|
||||
// Skip the immediate first fire so we don't race startup.
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
if let Err(err) = self.tick().await {
|
||||
tracing::warn!(?err, "dispatcher tick errored");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn tick(&self) -> Result<(), DispatcherError> {
|
||||
// Cheap gate sample so we don't claim rows we can't dispatch.
|
||||
// The exact permit budget is reapplied per-row below.
|
||||
let rows = self
|
||||
.outbox
|
||||
.claim_due(&self.instance_id, CLAIM_BATCH)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for row in rows {
|
||||
// Process serially within a tick — the outer ticker is the
|
||||
// pacing mechanism. Concurrent dispatchers are a cluster-
|
||||
// mode concern; v1.1.1 MVP has one.
|
||||
if let Err(err) = self.dispatch_one(row).await {
|
||||
tracing::warn!(?err, "dispatch one errored");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn dispatch_one(&self, row: OutboxRow) -> Result<(), DispatcherError> {
|
||||
// Depth-limit check — design notes §4: loops aren't DL'd.
|
||||
if row.trigger_depth > self.config.max_trigger_depth {
|
||||
tracing::warn!(
|
||||
outbox_id = %row.id,
|
||||
app_id = %row.app_id,
|
||||
trigger_depth = row.trigger_depth,
|
||||
"trigger depth exceeded; dropping row"
|
||||
);
|
||||
// TODO(metrics): bump `picloud_trigger_depth_exceeded{app_id,trigger_id}`.
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Gate admission — non-blocking. If the gate is saturated,
|
||||
// release the claim by rescheduling so another tick can pick
|
||||
// it up. The row stays "due" essentially immediately.
|
||||
let Ok(permit) = self.gate.try_acquire() else {
|
||||
let next = Utc::now() + chrono::Duration::milliseconds(100);
|
||||
self.outbox
|
||||
.reschedule(row.id, row.attempt_count, next)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Resolve the trigger config (KV / DL) or pull the HTTP
|
||||
// payload directly off the outbox row.
|
||||
let (resolved, exec_req) = match row.source_kind {
|
||||
OutboxSourceKind::Http => match self.build_http_request(&row).await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
tracing::warn!(outbox_id = %row.id, ?err, "http exec build failed; dropping");
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
drop(permit);
|
||||
return Ok(());
|
||||
}
|
||||
},
|
||||
OutboxSourceKind::Kv | OutboxSourceKind::DeadLetter => {
|
||||
let resolved = self.resolve_trigger(&row).await?;
|
||||
let req = match self.build_exec_request(&row, &resolved).await {
|
||||
Ok(req) => req,
|
||||
Err(err) => {
|
||||
tracing::warn!(outbox_id = %row.id, ?err, "exec request build failed; dropping row");
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
drop(permit);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
(resolved, req)
|
||||
}
|
||||
};
|
||||
|
||||
// The gate permit auto-releases when this scope ends or when
|
||||
// the executor finishes. We hand control to the executor and
|
||||
// wait synchronously here — sync HTTP and dispatcher share the
|
||||
// semaphore so this is intentional.
|
||||
let source = resolved.script_source.clone();
|
||||
let outcome = self
|
||||
.executor
|
||||
.execute(&source, exec_req, ASYNC_EXEC_TIMEOUT)
|
||||
.await;
|
||||
drop(permit);
|
||||
|
||||
match outcome {
|
||||
Ok(resp) => self.handle_success(&row, &resolved, resp).await,
|
||||
Err(err) => self.handle_failure(&row, &resolved, err).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_trigger(&self, row: &OutboxRow) -> Result<ResolvedTrigger, DispatcherError> {
|
||||
// For KV and DL kinds, the outbox carries `trigger_id`. Use it
|
||||
// to look up the trigger row, then resolve the script.
|
||||
let Some(trigger_id) = row.trigger_id else {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"outbox row missing trigger_id".into(),
|
||||
));
|
||||
};
|
||||
let trigger = self
|
||||
.triggers
|
||||
.get(trigger_id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
DispatcherError::ResolveTrigger(format!("trigger {trigger_id} not found"))
|
||||
})?;
|
||||
|
||||
let script = self
|
||||
.scripts
|
||||
.get(trigger.script_id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
DispatcherError::ResolveTrigger(format!("script {} not found", trigger.script_id))
|
||||
})?;
|
||||
|
||||
Ok(ResolvedTrigger {
|
||||
trigger_kind: trigger.kind,
|
||||
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
|
||||
script_id: script.id,
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
sandbox_overrides: script.sandbox,
|
||||
registered_by_principal: trigger.registered_by_principal,
|
||||
retry_max_attempts: trigger.retry_max_attempts,
|
||||
retry_backoff: trigger.retry_backoff,
|
||||
retry_base_ms: trigger.retry_base_ms,
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_exec_request(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
resolved: &ResolvedTrigger,
|
||||
) -> Result<ExecRequest, DispatcherError> {
|
||||
let trigger_event: TriggerEvent = serde_json::from_value(row.payload.clone())
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(format!("decode payload: {e}")))?;
|
||||
|
||||
let principal = self
|
||||
.principals
|
||||
.resolve(resolved.registered_by_principal)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?;
|
||||
|
||||
let execution_id = ExecutionId::new();
|
||||
Ok(ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: resolved.script_id,
|
||||
script_name: resolved.script_name.clone(),
|
||||
invocation_type: InvocationType::Function,
|
||||
path: format!("/trigger/{}", trigger_event.source()),
|
||||
headers: std::collections::BTreeMap::new(),
|
||||
body: serde_json::Value::Null,
|
||||
params: std::collections::BTreeMap::new(),
|
||||
query: std::collections::BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
sandbox_overrides: resolved.sandbox_overrides,
|
||||
app_id: row.app_id,
|
||||
principal: Some(principal),
|
||||
trigger_depth: row.trigger_depth,
|
||||
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
|
||||
is_dead_letter_handler: resolved.is_dead_letter_handler,
|
||||
event: Some(trigger_event),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an `(ResolvedTrigger, ExecRequest)` for an HTTP outbox
|
||||
/// row. HTTP rows don't have a backing `triggers` row (the
|
||||
/// `trigger_id` references `routes.id` instead). We pull the
|
||||
/// script id off the outbox row, the request shape off the
|
||||
/// payload, and synthesize a `ResolvedTrigger` with retry
|
||||
/// settings irrelevant for HTTP (sync HTTP is never retried;
|
||||
/// async HTTP uses default policy from `TriggerConfig`).
|
||||
async fn build_http_request(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
) -> Result<(ResolvedTrigger, ExecRequest), DispatcherError> {
|
||||
let Some(script_id) = row.script_id else {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"HTTP outbox row missing script_id".into(),
|
||||
));
|
||||
};
|
||||
let script = self
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
DispatcherError::ResolveTrigger(format!("script {script_id} not found"))
|
||||
})?;
|
||||
|
||||
let payload: HttpDispatchPayload = serde_json::from_value(row.payload.clone())
|
||||
.map_err(|e| DispatcherError::ResolveTrigger(format!("decode http payload: {e}")))?;
|
||||
|
||||
let execution_id = ExecutionId::new();
|
||||
let req = ExecRequest {
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id,
|
||||
script_name: payload.script_name.clone(),
|
||||
invocation_type: InvocationType::Http,
|
||||
path: payload.path.clone(),
|
||||
headers: payload.headers,
|
||||
body: payload.body,
|
||||
params: payload.params,
|
||||
query: payload.query,
|
||||
rest: payload.rest,
|
||||
sandbox_overrides: script.sandbox,
|
||||
app_id: row.app_id,
|
||||
// HTTP outbox rows don't run as the trigger registrant —
|
||||
// they run with no principal (public ingress) or the
|
||||
// attached one (origin_principal forensic field is not
|
||||
// promoted to execution principal in this MVP).
|
||||
principal: None,
|
||||
trigger_depth: row.trigger_depth,
|
||||
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
};
|
||||
|
||||
let resolved = ResolvedTrigger {
|
||||
trigger_kind: TriggerKind::Kv, // placeholder; HTTP doesn't have a kind
|
||||
is_dead_letter_handler: false,
|
||||
script_id,
|
||||
script_source: script.source,
|
||||
script_name: payload.script_name,
|
||||
sandbox_overrides: script.sandbox,
|
||||
// HTTP outbox rows don't carry a registered_by_principal
|
||||
// — use a sentinel zero UUID since this field isn't used
|
||||
// downstream for HTTP (no retries, no inbox principal).
|
||||
registered_by_principal: picloud_shared::AdminUserId::from(uuid::Uuid::nil()),
|
||||
// Async HTTP uses the platform default retry policy from
|
||||
// TriggerConfig. Sync HTTP (reply_to.is_some) never retries
|
||||
// regardless.
|
||||
retry_max_attempts: self.config.retry_max_attempts,
|
||||
retry_backoff: self.config.retry_backoff,
|
||||
retry_base_ms: self.config.retry_base_ms,
|
||||
};
|
||||
Ok((resolved, req))
|
||||
}
|
||||
|
||||
async fn handle_success(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
_resolved: &ResolvedTrigger,
|
||||
resp: ExecResponse,
|
||||
) -> Result<(), DispatcherError> {
|
||||
if let Some(inbox_id) = row.reply_to {
|
||||
self.deliver_inbox(row, inbox_id, InboxResult::Success(summarize(&resp)))
|
||||
.await;
|
||||
}
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_failure(
|
||||
&self,
|
||||
row: &OutboxRow,
|
||||
resolved: &ResolvedTrigger,
|
||||
err: ExecError,
|
||||
) -> Result<(), DispatcherError> {
|
||||
// Sync HTTP: always single-attempt. Always deliver outcome
|
||||
// (success-or-failure) to the inbox. Never retry, never DL.
|
||||
if let Some(inbox_id) = row.reply_to {
|
||||
let (kind, message) = classify_exec_error(&err);
|
||||
self.deliver_inbox(
|
||||
row,
|
||||
inbox_id,
|
||||
InboxResult::Failure {
|
||||
kind,
|
||||
message: message.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Dead-letter handler: never retry, never DL. Failure
|
||||
// annotates the original DL row + bumps a metric.
|
||||
if resolved.is_dead_letter_handler {
|
||||
tracing::error!(
|
||||
outbox_id = %row.id,
|
||||
app_id = %row.app_id,
|
||||
?err,
|
||||
"dead-letter handler failed; not retrying"
|
||||
);
|
||||
// TODO(metrics): bump `picloud_dead_letter_handler_failures{app_id}`.
|
||||
// Annotate the original DL row (id is `row.payload.dead_letter.id`
|
||||
// when the payload is a DeadLetter TriggerEvent). Best-effort:
|
||||
// if the payload doesn't decode, just log and move on.
|
||||
if let Ok(TriggerEvent::DeadLetter { dead_letter_id, .. }) =
|
||||
serde_json::from_value::<TriggerEvent>(row.payload.clone())
|
||||
{
|
||||
if let Err(e) = self
|
||||
.dead_letters
|
||||
.resolve(dead_letter_id, "handler_failed")
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "could not annotate DL row as handler_failed");
|
||||
}
|
||||
}
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Async event: retry per policy, then dead-letter.
|
||||
let attempt = row.attempt_count + 1;
|
||||
if attempt < resolved.retry_max_attempts {
|
||||
let delay = compute_backoff(
|
||||
attempt,
|
||||
resolved.retry_backoff,
|
||||
resolved.retry_base_ms,
|
||||
self.config.retry_jitter_pct,
|
||||
);
|
||||
let next = Utc::now() + chrono::Duration::milliseconds(i64::from(delay));
|
||||
tracing::info!(
|
||||
outbox_id = %row.id,
|
||||
attempt,
|
||||
max_attempts = resolved.retry_max_attempts,
|
||||
retry_in_ms = delay,
|
||||
"rescheduling outbox row"
|
||||
);
|
||||
self.outbox
|
||||
.reschedule(row.id, attempt, next)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Exhausted retries → dead-letter.
|
||||
let (op, source) = describe_event(&row.payload);
|
||||
let now = Utc::now();
|
||||
if let Err(e) = self
|
||||
.dead_letters
|
||||
.insert(NewDeadLetter {
|
||||
app_id: row.app_id,
|
||||
original_event_id: row.id,
|
||||
source,
|
||||
op,
|
||||
trigger_id: row.trigger_id,
|
||||
script_id: Some(resolved.script_id),
|
||||
payload: row.payload.clone(),
|
||||
attempt_count: attempt,
|
||||
first_attempt_at: row.created_at,
|
||||
last_attempt_at: now,
|
||||
last_error: err.to_string(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!(?e, "failed to write dead-letter row");
|
||||
}
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn deliver_inbox(&self, row: &OutboxRow, inbox_id: Uuid, result: InboxResult) {
|
||||
match self.inbox.deliver(inbox_id, result.clone()).await {
|
||||
InboxDeliveryOutcome::Delivered => {}
|
||||
InboxDeliveryOutcome::Abandoned => {
|
||||
// Receiver was dropped — record forensic row + bump
|
||||
// metric.
|
||||
let (status_code, summary) = match &result {
|
||||
InboxResult::Success(s) => (s.status_code, None),
|
||||
InboxResult::Failure { kind, message } => {
|
||||
(failure_kind_to_status(*kind), Some(message.clone()))
|
||||
}
|
||||
};
|
||||
if let Err(e) = self
|
||||
.abandoned
|
||||
.insert(NewAbandonedExecution {
|
||||
app_id: row.app_id,
|
||||
outbox_id: row.id,
|
||||
script_id: row.script_id,
|
||||
inbox_id,
|
||||
status_code,
|
||||
result_summary: summary,
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "abandoned_executions insert failed");
|
||||
}
|
||||
// TODO(metrics): bump `picloud_abandoned_executions_total{app_id}`.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedTrigger {
|
||||
pub trigger_kind: TriggerKind,
|
||||
pub is_dead_letter_handler: bool,
|
||||
pub script_id: ScriptId,
|
||||
pub script_source: String,
|
||||
pub script_name: String,
|
||||
pub sandbox_overrides: ScriptSandbox,
|
||||
pub registered_by_principal: picloud_shared::AdminUserId,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DispatcherError {
|
||||
#[error("outbox: {0}")]
|
||||
Outbox(String),
|
||||
#[error("resolve trigger: {0}")]
|
||||
ResolveTrigger(String),
|
||||
}
|
||||
|
||||
fn summarize(resp: &ExecResponse) -> ExecResponseSummary {
|
||||
ExecResponseSummary {
|
||||
status_code: resp.status_code,
|
||||
headers: resp.headers.clone(),
|
||||
body: resp.body.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map `ExecError` onto the design-notes §3 status-code table.
|
||||
fn classify_exec_error(err: &ExecError) -> (InboxFailureKind, String) {
|
||||
match err {
|
||||
ExecError::Parse(s) | ExecError::InvalidResponse(s) => {
|
||||
(InboxFailureKind::Validation, s.clone())
|
||||
}
|
||||
ExecError::Timeout(_) => (InboxFailureKind::Timeout, err.to_string()),
|
||||
ExecError::OperationBudgetExceeded => (InboxFailureKind::OperationBudget, err.to_string()),
|
||||
ExecError::Overloaded { .. } => (InboxFailureKind::Overloaded, err.to_string()),
|
||||
ExecError::Runtime(s) => (InboxFailureKind::Runtime, s.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_kind_to_status(k: InboxFailureKind) -> u16 {
|
||||
match k {
|
||||
InboxFailureKind::Validation => 422,
|
||||
InboxFailureKind::Runtime => 502,
|
||||
InboxFailureKind::Overloaded => 503,
|
||||
InboxFailureKind::Timeout => 504,
|
||||
InboxFailureKind::OperationBudget => 507,
|
||||
InboxFailureKind::Platform => 500,
|
||||
}
|
||||
}
|
||||
|
||||
/// `(op, source)` extracted from the outbox payload. Used to seed the
|
||||
/// `dead_letters` row when retries exhaust.
|
||||
fn describe_event(payload: &serde_json::Value) -> (String, String) {
|
||||
let source = payload
|
||||
.get("source")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let op = payload
|
||||
.get("op")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
(op, source)
|
||||
}
|
||||
|
||||
/// Compute backoff (ms) for the given attempt + policy + jitter.
|
||||
/// Attempt is 1-indexed (first retry = attempt 1).
|
||||
#[must_use]
|
||||
pub fn compute_backoff(attempt: u32, backoff: BackoffShape, base_ms: u32, jitter_pct: u32) -> u32 {
|
||||
let base_ms = u64::from(base_ms);
|
||||
let attempt = u64::from(attempt.saturating_sub(1));
|
||||
let raw = match backoff {
|
||||
BackoffShape::Constant => base_ms,
|
||||
BackoffShape::Linear => base_ms * (attempt + 1),
|
||||
// 1x base, 2x base, 4x base, … (saturating).
|
||||
BackoffShape::Exponential => base_ms.saturating_mul(1u64 << attempt.min(20)),
|
||||
};
|
||||
let raw = u32::try_from(raw.min(u64::from(u32::MAX))).unwrap_or(u32::MAX);
|
||||
apply_jitter(raw, jitter_pct)
|
||||
}
|
||||
|
||||
fn apply_jitter(raw: u32, pct: u32) -> u32 {
|
||||
if pct == 0 {
|
||||
return raw;
|
||||
}
|
||||
let pct = pct.min(100);
|
||||
// ±span% — bounded by raw itself so we can't underflow when
|
||||
// raw + offset goes below zero.
|
||||
let span = u64::from(raw) * u64::from(pct) / 100;
|
||||
if span == 0 {
|
||||
return raw;
|
||||
}
|
||||
let span_i64 = i64::try_from(span).unwrap_or(i64::MAX);
|
||||
let mut rng = rand::thread_rng();
|
||||
let offset = rng.gen_range(-span_i64..=span_i64);
|
||||
let signed = i64::from(raw).saturating_add(offset).max(0);
|
||||
u32::try_from(signed.min(i64::from(u32::MAX))).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exponential_backoff_doubles_per_attempt() {
|
||||
// No jitter (pct=0) for a deterministic check.
|
||||
assert_eq!(compute_backoff(1, BackoffShape::Exponential, 1000, 0), 1000);
|
||||
assert_eq!(compute_backoff(2, BackoffShape::Exponential, 1000, 0), 2000);
|
||||
assert_eq!(compute_backoff(3, BackoffShape::Exponential, 1000, 0), 4000);
|
||||
assert_eq!(compute_backoff(4, BackoffShape::Exponential, 1000, 0), 8000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_backoff_scales_with_attempt() {
|
||||
assert_eq!(compute_backoff(1, BackoffShape::Linear, 100, 0), 100);
|
||||
assert_eq!(compute_backoff(2, BackoffShape::Linear, 100, 0), 200);
|
||||
assert_eq!(compute_backoff(5, BackoffShape::Linear, 100, 0), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_backoff_returns_base() {
|
||||
for attempt in 1..=5 {
|
||||
assert_eq!(
|
||||
compute_backoff(attempt, BackoffShape::Constant, 750, 0),
|
||||
750
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jitter_within_pct_of_base() {
|
||||
for _ in 0..100 {
|
||||
let v = compute_backoff(1, BackoffShape::Constant, 1000, 20);
|
||||
// ±20% of 1000 = 800..=1200.
|
||||
assert!((800..=1200).contains(&v), "jitter out of range: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_exec_error_covers_every_variant() {
|
||||
let parse = classify_exec_error(&ExecError::Parse("nope".into()));
|
||||
assert!(matches!(parse.0, InboxFailureKind::Validation));
|
||||
let invalid = classify_exec_error(&ExecError::InvalidResponse("bad".into()));
|
||||
assert!(matches!(invalid.0, InboxFailureKind::Validation));
|
||||
let timeout = classify_exec_error(&ExecError::Timeout(30));
|
||||
assert!(matches!(timeout.0, InboxFailureKind::Timeout));
|
||||
let budget = classify_exec_error(&ExecError::OperationBudgetExceeded);
|
||||
assert!(matches!(budget.0, InboxFailureKind::OperationBudget));
|
||||
let runtime = classify_exec_error(&ExecError::Runtime("threw".into()));
|
||||
assert!(matches!(runtime.0, InboxFailureKind::Runtime));
|
||||
let overload = classify_exec_error(&ExecError::Overloaded {
|
||||
retry_after_secs: 1,
|
||||
});
|
||||
assert!(matches!(overload.0, InboxFailureKind::Overloaded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failure_kind_status_codes_match_design_notes() {
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Validation), 422);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Runtime), 502);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Overloaded), 503);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Timeout), 504);
|
||||
assert_eq!(
|
||||
failure_kind_to_status(InboxFailureKind::OperationBudget),
|
||||
507
|
||||
);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Platform), 500);
|
||||
}
|
||||
}
|
||||
95
crates/manager-core/src/gc.rs
Normal file
95
crates/manager-core/src/gc.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
//! Weekly retention sweepers for `dead_letters` + `abandoned_executions`.
|
||||
//!
|
||||
//! Both use the `FOR UPDATE SKIP LOCKED` claim pattern so concurrent
|
||||
//! sweepers (cluster mode v1.3+) don't fight each other. Defaults
|
||||
//! match design notes §3 / §4: 30 days for DL, 7 days for abandoned.
|
||||
//! Both env-overridable via `PICLOUD_DEAD_LETTER_RETENTION_DAYS` and
|
||||
//! `PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS` (loaded by
|
||||
//! `TriggerConfig::from_env`).
|
||||
//!
|
||||
//! Spawned from `build_app` alongside `spawn_session_pruner`.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::Utc;
|
||||
|
||||
use crate::abandoned_repo::AbandonedRepo;
|
||||
use crate::dead_letter_repo::DeadLetterRepo;
|
||||
|
||||
/// Weekly sweep cadence — matches `spawn_session_pruner` shape.
|
||||
const SWEEP_INTERVAL: Duration = Duration::from_secs(7 * 24 * 60 * 60);
|
||||
|
||||
/// Per-tick batch cap so we don't try to delete millions of rows in
|
||||
/// one transaction. The loop keeps deleting batches until a tick
|
||||
/// returns 0 rows affected.
|
||||
const SWEEP_BATCH: i64 = 5_000;
|
||||
|
||||
pub fn spawn_dead_letter_gc(repo: Arc<dyn DeadLetterRepo>, retention_days: u32) {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
|
||||
// Skip the immediate first fire — don't sweep at process start.
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
sweep_dead_letters(&*repo, retention_days).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn spawn_abandoned_gc(repo: Arc<dyn AbandonedRepo>, retention_days: u32) {
|
||||
tokio::spawn(async move {
|
||||
let mut ticker = tokio::time::interval(SWEEP_INTERVAL);
|
||||
ticker.tick().await;
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
sweep_abandoned(&*repo, retention_days).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn sweep_dead_letters(repo: &dyn DeadLetterRepo, retention_days: u32) {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(i64::from(retention_days));
|
||||
let mut total: u64 = 0;
|
||||
loop {
|
||||
match repo.gc(cutoff, SWEEP_BATCH).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
total += n;
|
||||
if n < SWEEP_BATCH as u64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "dead_letters GC sweep errored");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
tracing::info!(swept = total, "dead_letters GC swept");
|
||||
}
|
||||
}
|
||||
|
||||
async fn sweep_abandoned(repo: &dyn AbandonedRepo, retention_days: u32) {
|
||||
let cutoff = Utc::now() - chrono::Duration::days(i64::from(retention_days));
|
||||
let mut total: u64 = 0;
|
||||
loop {
|
||||
match repo.gc(cutoff, SWEEP_BATCH).await {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
total += n;
|
||||
if n < SWEEP_BATCH as u64 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(?e, "abandoned_executions GC sweep errored");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if total > 0 {
|
||||
tracing::info!(swept = total, "abandoned_executions GC swept");
|
||||
}
|
||||
}
|
||||
223
crates/manager-core/src/kv_repo.rs
Normal file
223
crates/manager-core/src/kv_repo.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
//! Low-level Postgres CRUD over `kv_entries`. Stays storage-only;
|
||||
//! authorization, event emission, and empty-collection validation live
|
||||
//! one layer up in `KvServiceImpl`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use picloud_shared::{AppId, KvListPage};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum KvRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("invalid pagination cursor")]
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
/// Repo surface. The trait is exposed so tests can substitute an
|
||||
/// in-memory backing without spinning up Postgres.
|
||||
#[async_trait]
|
||||
pub trait KvRepo: Send + Sync {
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError>;
|
||||
|
||||
/// Upserts the row. Returns the previous value (if any) so callers
|
||||
/// can determine whether this was an `insert` or an `update` for
|
||||
/// the emitted `ServiceEvent`.
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError>;
|
||||
|
||||
/// Returns the deleted value if present, `None` if the row didn't
|
||||
/// exist. The caller turns the `bool was-present` part into the
|
||||
/// SDK's return value; the `Option<value>` part feeds the
|
||||
/// `old_payload` field of the emitted delete event.
|
||||
async fn delete(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError>;
|
||||
|
||||
async fn has(&self, app_id: AppId, collection: &str, key: &str) -> Result<bool, KvRepoError>;
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, KvRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresKvRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresKvRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard ceiling on `list` page size — scripts that pass anything larger
|
||||
/// silently get clamped to this. Cursor-style pagination keeps a single
|
||||
/// request bounded; clients fetch the next page via the returned cursor.
|
||||
const KV_LIST_MAX_LIMIT: u32 = 1_000;
|
||||
const KV_LIST_DEFAULT_LIMIT: u32 = 100;
|
||||
|
||||
#[async_trait]
|
||||
impl KvRepo for PostgresKvRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"SELECT value FROM kv_entries \
|
||||
WHERE app_id = $1 AND collection = $2 AND key = $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
||||
// `RETURNING` after `ON CONFLICT DO UPDATE` exposes the old
|
||||
// value via the `xmax`/old-row trick: capture the prior value
|
||||
// with a CTE so callers know whether this was insert vs update.
|
||||
let row: Option<(Option<serde_json::Value>,)> = sqlx::query_as(
|
||||
"WITH prev AS (\
|
||||
SELECT value FROM kv_entries \
|
||||
WHERE app_id = $1 AND collection = $2 AND key = $3\
|
||||
), \
|
||||
upserted AS (\
|
||||
INSERT INTO kv_entries (app_id, collection, key, value) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
ON CONFLICT (app_id, collection, key) DO UPDATE \
|
||||
SET value = EXCLUDED.value, updated_at = NOW() \
|
||||
RETURNING 1\
|
||||
) \
|
||||
SELECT (SELECT value FROM prev) FROM upserted",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.and_then(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
||||
let row: Option<(serde_json::Value,)> = sqlx::query_as(
|
||||
"DELETE FROM kv_entries \
|
||||
WHERE app_id = $1 AND collection = $2 AND key = $3 \
|
||||
RETURNING value",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(v,)| v))
|
||||
}
|
||||
|
||||
async fn has(&self, app_id: AppId, collection: &str, key: &str) -> Result<bool, KvRepoError> {
|
||||
let row: Option<(i64,)> = sqlx::query_as(
|
||||
"SELECT 1 FROM kv_entries \
|
||||
WHERE app_id = $1 AND collection = $2 AND key = $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(key)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.is_some())
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, KvRepoError> {
|
||||
let limit = if limit == 0 {
|
||||
KV_LIST_DEFAULT_LIMIT
|
||||
} else {
|
||||
limit.min(KV_LIST_MAX_LIMIT)
|
||||
};
|
||||
|
||||
let last_key = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
// Keyset pagination: rows beyond `last_key` ordered by key.
|
||||
// `+1` to detect a "more pages" condition without a separate
|
||||
// COUNT query.
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT key FROM kv_entries \
|
||||
WHERE app_id = $1 AND collection = $2 \
|
||||
AND ($3::text IS NULL OR key > $3) \
|
||||
ORDER BY key ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(collection)
|
||||
.bind(last_key.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut keys: Vec<String> = rows.into_iter().map(|(k,)| k).collect();
|
||||
let next_cursor = if keys.len() > limit as usize {
|
||||
keys.truncate(limit as usize);
|
||||
keys.last().map(|k| encode_cursor(k))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(KvListPage { keys, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_cursor(last_key: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(last_key.as_bytes())
|
||||
}
|
||||
|
||||
fn decode_cursor(cursor: &str) -> Result<String, KvRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| KvRepoError::InvalidCursor)?;
|
||||
String::from_utf8(bytes).map_err(|_| KvRepoError::InvalidCursor)
|
||||
}
|
||||
525
crates/manager-core/src/kv_service.rs
Normal file
525
crates/manager-core/src/kv_service.rs
Normal file
@@ -0,0 +1,525 @@
|
||||
//! `KvServiceImpl` — wires the `KvRepo` underneath the
|
||||
//! `picloud_shared::KvService` trait that scripts see via the Rhai
|
||||
//! bridge.
|
||||
//!
|
||||
//! Layers added here (vs the raw repo):
|
||||
//!
|
||||
//! 1. Empty-collection rejection at the SDK boundary
|
||||
//! (`docs/sdk-shape.md`).
|
||||
//! 2. **Script-as-gate authz**: when `cx.principal.is_some()` we run
|
||||
//! `authz::require(...)`; when it's `None` (public unauthenticated
|
||||
//! HTTP — the common case for public routes) we skip the check.
|
||||
//! Cross-app isolation isn't affected — every query is keyed by
|
||||
//! `cx.app_id`, never an argument.
|
||||
//! 3. `ServiceEvent` emission after each mutation (`insert` / `update`
|
||||
//! / `delete`). v1.1.0 ships a `NoopEventEmitter` so this is a
|
||||
//! no-op until the outbox emitter lands later in v1.1.1.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
KvError, KvListPage, KvService, SdkCallCx, ServiceEvent, ServiceEventEmitter,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::kv_repo::{KvRepo, KvRepoError};
|
||||
|
||||
pub struct KvServiceImpl {
|
||||
repo: Arc<dyn KvRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
}
|
||||
|
||||
impl KvServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
repo: Arc<dyn KvRepo>,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
events: Arc<dyn ServiceEventEmitter>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repo,
|
||||
authz,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), KvError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppKvRead(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| KvError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_write(&self, cx: &SdkCallCx) -> Result<(), KvError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppKvWrite(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| KvError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_collection(collection: &str) -> Result<(), KvError> {
|
||||
if collection.is_empty() {
|
||||
return Err(KvError::InvalidCollection);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl From<KvRepoError> for KvError {
|
||||
fn from(e: KvRepoError) -> Self {
|
||||
Self::Backend(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KvService for KvServiceImpl {
|
||||
async fn get(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, KvError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.repo.get(cx.app_id, collection, key).await?)
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<(), KvError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
let previous = self
|
||||
.repo
|
||||
.set(cx.app_id, collection, key, value.clone())
|
||||
.await?;
|
||||
let op = if previous.is_some() {
|
||||
"update"
|
||||
} else {
|
||||
"insert"
|
||||
};
|
||||
// Emit unconditionally; the noop emitter drops it, the outbox
|
||||
// emitter persists it. Best-effort: a failed emit is logged
|
||||
// but does not roll back the write.
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit(
|
||||
cx,
|
||||
ServiceEvent {
|
||||
source: "kv",
|
||||
op,
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(key.to_string()),
|
||||
payload: Some(value),
|
||||
old_payload: previous,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, source = "kv", op, "event emit failed");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_write(cx).await?;
|
||||
let previous = self.repo.delete(cx.app_id, collection, key).await?;
|
||||
let was_present = previous.is_some();
|
||||
if was_present {
|
||||
if let Err(e) = self
|
||||
.events
|
||||
.emit(
|
||||
cx,
|
||||
ServiceEvent {
|
||||
source: "kv",
|
||||
op: "delete",
|
||||
collection: Some(collection.to_string()),
|
||||
key: Some(key.to_string()),
|
||||
payload: None,
|
||||
old_payload: previous,
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, source = "kv", op = "delete", "event emit failed");
|
||||
}
|
||||
}
|
||||
Ok(was_present)
|
||||
}
|
||||
|
||||
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, KvError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.repo.has(cx.app_id, collection, key).await?)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, KvError> {
|
||||
validate_collection(collection)?;
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.repo.list(cx.app_id, collection, cursor, limit).await?)
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests — in-memory KvRepo so unit tests don't need Postgres.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, NoopEventEmitter, Principal,
|
||||
RequestId, UserId,
|
||||
};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryKvRepo {
|
||||
data: Mutex<BTreeMap<(AppId, String, String), serde_json::Value>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl KvRepo for InMemoryKvRepo {
|
||||
async fn get(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, collection.to_string(), key.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((app_id, collection.to_string(), key.to_string()), value))
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<Option<serde_json::Value>, KvRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(app_id, collection.to_string(), key.to_string())))
|
||||
}
|
||||
|
||||
async fn has(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, KvRepoError> {
|
||||
Ok(self.data.lock().await.contains_key(&(
|
||||
app_id,
|
||||
collection.to_string(),
|
||||
key.to_string(),
|
||||
)))
|
||||
}
|
||||
|
||||
async fn list(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<KvListPage, KvRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
let last_key = cursor.map(std::string::ToString::to_string);
|
||||
let mut keys: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, c, _), _)| *a == app_id && c == collection)
|
||||
.map(|((_, _, k), _)| k.clone())
|
||||
.filter(|k| last_key.as_ref().is_none_or(|lk| k > lk))
|
||||
.collect();
|
||||
keys.sort();
|
||||
let take = (limit as usize).max(1);
|
||||
let next_cursor = if keys.len() > take {
|
||||
keys.truncate(take);
|
||||
keys.last().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(KvListPage { keys, next_cursor })
|
||||
}
|
||||
}
|
||||
|
||||
/// AuthzRepo that always denies — used to confirm the service
|
||||
/// short-circuits on cx.principal.is_some() with a denial, and
|
||||
/// that it does NOT call into authz when cx.principal is None.
|
||||
#[derive(Default)]
|
||||
struct DenyingAuthzRepo;
|
||||
|
||||
#[async_trait]
|
||||
impl AuthzRepo for DenyingAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn anon_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
principal: None,
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn member_no_role_cx(app_id: AppId) -> SdkCallCx {
|
||||
SdkCallCx {
|
||||
app_id,
|
||||
principal: Some(Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
execution_id: ExecutionId::new(),
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: ExecutionId::new(),
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn svc() -> KvServiceImpl {
|
||||
KvServiceImpl::new(
|
||||
Arc::new(InMemoryKvRepo::default()),
|
||||
Arc::new(DenyingAuthzRepo),
|
||||
Arc::new(NoopEventEmitter),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_then_get_round_trips() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
kv.set(&cx, "widgets", "k1", serde_json::json!({"n": 1}))
|
||||
.await
|
||||
.unwrap();
|
||||
let v = kv.get(&cx, "widgets", "k1").await.unwrap();
|
||||
assert_eq!(v, Some(serde_json::json!({"n": 1})));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_missing_returns_none() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let v = kv.get(&cx, "widgets", "nope").await.unwrap();
|
||||
assert_eq!(v, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn has_returns_bool() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
assert!(!kv.has(&cx, "widgets", "k1").await.unwrap());
|
||||
kv.set(&cx, "widgets", "k1", serde_json::json!(true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(kv.has(&cx, "widgets", "k1").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_returns_was_present() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
assert!(!kv.delete(&cx, "widgets", "missing").await.unwrap());
|
||||
kv.set(&cx, "widgets", "k1", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(kv.delete(&cx, "widgets", "k1").await.unwrap());
|
||||
// Idempotent — second delete returns false.
|
||||
assert!(!kv.delete(&cx, "widgets", "k1").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_collection_rejected() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
let err = kv.get(&cx, "", "k1").await.unwrap_err();
|
||||
assert!(matches!(err, KvError::InvalidCollection));
|
||||
}
|
||||
|
||||
/// Load-bearing: a script with `cx.app_id = A` must NOT see
|
||||
/// entries inserted under `cx.app_id = B`. This is the cross-app
|
||||
/// isolation boundary; getting this wrong is a security
|
||||
/// vulnerability.
|
||||
#[tokio::test]
|
||||
async fn cross_app_isolation_via_cx_app_id() {
|
||||
let kv = svc();
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let cx_a = anon_cx(app_a);
|
||||
let cx_b = anon_cx(app_b);
|
||||
|
||||
kv.set(&cx_a, "shared", "k", serde_json::json!("from-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
kv.set(&cx_b, "shared", "k", serde_json::json!("from-b"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
kv.get(&cx_a, "shared", "k").await.unwrap(),
|
||||
Some(serde_json::json!("from-a"))
|
||||
);
|
||||
assert_eq!(
|
||||
kv.get(&cx_b, "shared", "k").await.unwrap(),
|
||||
Some(serde_json::json!("from-b"))
|
||||
);
|
||||
}
|
||||
|
||||
/// Script-as-gate: an `anon_cx` (principal = None) skips the
|
||||
/// capability check entirely. Even with a denying authz repo,
|
||||
/// the write succeeds.
|
||||
#[tokio::test]
|
||||
async fn anonymous_cx_skips_authz() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
kv.set(&cx, "widgets", "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
// No panic, no Forbidden.
|
||||
}
|
||||
|
||||
/// Authenticated principal with no role on the app: the
|
||||
/// `DenyingAuthzRepo` returns no membership, so the capability
|
||||
/// check denies. Set must surface KvError::Forbidden.
|
||||
#[tokio::test]
|
||||
async fn authed_cx_with_no_role_is_forbidden() {
|
||||
let kv = svc();
|
||||
let cx = member_no_role_cx(AppId::new());
|
||||
let err = kv
|
||||
.set(&cx, "widgets", "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, KvError::Forbidden));
|
||||
}
|
||||
|
||||
/// Owner principal: instance-role grants kick in inside `authz::can`
|
||||
/// (Owner -> implicit AppAdmin which covers KvWrite).
|
||||
#[tokio::test]
|
||||
async fn owner_principal_can_write() {
|
||||
let kv = svc();
|
||||
let cx = owner_cx(AppId::new());
|
||||
kv.set(&cx, "widgets", "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_cursor_pagination() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
for i in 0..5 {
|
||||
kv.set(
|
||||
&cx,
|
||||
"widgets",
|
||||
&format!("k{i:02}"),
|
||||
serde_json::json!({"i": i}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
// page 1 — 2 keys
|
||||
let p1 = kv.list(&cx, "widgets", None, 2).await.unwrap();
|
||||
assert_eq!(p1.keys, vec!["k00".to_string(), "k01".to_string()]);
|
||||
assert!(p1.next_cursor.is_some());
|
||||
// page 2 — 2 keys
|
||||
let p2 = kv
|
||||
.list(&cx, "widgets", p1.next_cursor.as_deref(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(p2.keys, vec!["k02".to_string(), "k03".to_string()]);
|
||||
// final page — 1 key, no cursor
|
||||
let p3 = kv
|
||||
.list(&cx, "widgets", p2.next_cursor.as_deref(), 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(p3.keys, vec!["k04".to_string()]);
|
||||
assert!(p3.next_cursor.is_none());
|
||||
}
|
||||
|
||||
/// Pinning the v1.1.0 contract: services hold the emitter as a
|
||||
/// dyn Arc and call `emit().await` unconditionally. This test
|
||||
/// proves the call site doesn't blow up against the noop impl —
|
||||
/// the outbox emitter (v1.1.1) drops in transparently.
|
||||
#[tokio::test]
|
||||
async fn noop_emitter_does_not_block_mutations() {
|
||||
let kv = svc();
|
||||
let cx = anon_cx(AppId::new());
|
||||
kv.set(&cx, "widgets", "k", serde_json::json!(1))
|
||||
.await
|
||||
.unwrap();
|
||||
kv.delete(&cx, "widgets", "k").await.unwrap();
|
||||
// Reaching here means emit() returned Ok and didn't panic.
|
||||
// Suppress unused-import warning when run alone:
|
||||
let _ = HashMap::<String, String>::new();
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,48 @@
|
||||
//! the same DB for now; once we add caching and per-node ingress, the
|
||||
//! manager will publish change events.
|
||||
|
||||
pub mod abandoned_repo;
|
||||
pub mod admin_session_repo;
|
||||
pub mod admin_user_repo;
|
||||
pub mod admin_users_api;
|
||||
pub mod api;
|
||||
pub mod api_key_repo;
|
||||
pub mod api_keys_api;
|
||||
pub mod app_bootstrap;
|
||||
pub mod app_domain_repo;
|
||||
pub mod app_members_api;
|
||||
pub mod app_members_repo;
|
||||
pub mod app_repo;
|
||||
pub mod apps_api;
|
||||
pub mod auth;
|
||||
pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
pub mod auth_middleware;
|
||||
pub mod authz;
|
||||
pub mod dead_letter_repo;
|
||||
pub mod dead_letter_service;
|
||||
pub mod dead_letters_api;
|
||||
pub mod dispatcher;
|
||||
pub mod gc;
|
||||
pub mod kv_repo;
|
||||
pub mod kv_service;
|
||||
pub mod log_sink;
|
||||
pub mod migrations;
|
||||
pub mod outbox_event_emitter;
|
||||
pub mod outbox_repo;
|
||||
pub mod principal_resolver;
|
||||
pub mod repo;
|
||||
pub mod route_admin;
|
||||
pub mod route_repo;
|
||||
pub mod sandbox;
|
||||
pub mod scheduler;
|
||||
pub mod trigger_config;
|
||||
pub mod trigger_repo;
|
||||
pub mod triggers_api;
|
||||
|
||||
pub use abandoned_repo::{
|
||||
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
|
||||
};
|
||||
pub use admin_session_repo::{
|
||||
AdminSessionLookup, AdminSessionRepository, AdminSessionRepositoryError,
|
||||
PostgresAdminSessionRepository,
|
||||
@@ -30,12 +56,45 @@ pub use admin_user_repo::{
|
||||
};
|
||||
pub use admin_users_api::{admins_router, AdminsState};
|
||||
pub use api::{admin_router, AdminState};
|
||||
pub use api_key_repo::{
|
||||
ApiKeyRepository, ApiKeyRepositoryError, ApiKeyRow, ApiKeyVerification, NewApiKey,
|
||||
PostgresApiKeyRepository,
|
||||
};
|
||||
pub use api_keys_api::{api_keys_router, ApiKeysState};
|
||||
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||
pub use app_members_api::{app_members_router, AppMembersApiError, AppMembersState};
|
||||
pub use app_members_repo::{
|
||||
AppMembersRepository, AppMembersRepositoryError, AppMembershipDetail, AppMembershipRow,
|
||||
PostgresAppMembersRepository,
|
||||
};
|
||||
pub use app_repo::{resolve_app, AppLookup, AppRepository, PostgresAppRepository};
|
||||
pub use apps_api::{apps_router, AppsState};
|
||||
pub use auth_api::auth_router;
|
||||
pub use auth_bootstrap::{
|
||||
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||
};
|
||||
pub use auth_middleware::{require_admin, AuthState, AuthedAdmin, SESSION_COOKIE};
|
||||
#[allow(deprecated)]
|
||||
pub use auth_middleware::{
|
||||
attach_principal_if_present, require_admin, require_authenticated, AuthState, AuthedAdmin,
|
||||
API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
|
||||
};
|
||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||
pub use dead_letter_repo::{
|
||||
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
|
||||
};
|
||||
pub use dead_letter_service::PostgresDeadLetterService;
|
||||
pub use dead_letters_api::{dead_letters_router, DeadLettersApiError, DeadLettersState};
|
||||
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
|
||||
pub use gc::{spawn_abandoned_gc, spawn_dead_letter_gc};
|
||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||
pub use kv_service::KvServiceImpl;
|
||||
pub use log_sink::PostgresExecutionLogSink;
|
||||
pub use outbox_event_emitter::OutboxEventEmitter;
|
||||
pub use outbox_repo::{
|
||||
NewOutboxRow, OutboxRepo, OutboxRepoError, OutboxRow, OutboxSourceKind, PostgresOutboxRepo,
|
||||
};
|
||||
pub use principal_resolver::{AdminPrincipalResolver, PrincipalResolver, PrincipalResolverError};
|
||||
pub use repo::{
|
||||
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
|
||||
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||
@@ -43,3 +102,10 @@ pub use repo::{
|
||||
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
|
||||
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||
pub use trigger_config::{BackoffShape, TriggerConfig};
|
||||
pub use trigger_repo::{
|
||||
collection_matches, CreateDeadLetterTrigger, CreateKvTrigger, DeadLetterTriggerMatch,
|
||||
KvTriggerMatch, PostgresTriggerRepo, Trigger, TriggerDetails, TriggerDispatchMode, TriggerKind,
|
||||
TriggerRepo, TriggerRepoError,
|
||||
};
|
||||
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||
|
||||
@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO execution_logs ( \
|
||||
id, script_id, request_id, \
|
||||
id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
) VALUES ( \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||
)",
|
||||
)
|
||||
.bind(log.id)
|
||||
.bind(log.app_id.into_inner())
|
||||
.bind(log.script_id.into_inner())
|
||||
.bind(log.request_id.into_inner())
|
||||
.bind(&log.request_path)
|
||||
|
||||
103
crates/manager-core/src/outbox_event_emitter.rs
Normal file
103
crates/manager-core/src/outbox_event_emitter.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
//! `OutboxEventEmitter` — the real `ServiceEventEmitter` that replaces
|
||||
//! v1.1.0's `NoopEventEmitter` once the triggers framework lands.
|
||||
//!
|
||||
//! On each `emit` (a KV mutation, future doc/file/pubsub event, etc.):
|
||||
//! 1. Look up matching triggers for the event's (app_id, source, op,
|
||||
//! collection) tuple via `TriggerRepo::list_matching_*`.
|
||||
//! 2. For each match, write one outbox row carrying the event payload
|
||||
//! serialized as a `TriggerEvent`.
|
||||
//!
|
||||
//! Defaults applied at write time so `OutboxRow.payload` carries
|
||||
//! everything the dispatcher needs to reconstruct the executor
|
||||
//! invocation without joining back to the trigger row.
|
||||
//!
|
||||
//! Non-KV `ServiceEvent` sources are silently dropped in v1.1.1 — the
|
||||
//! dispatcher only knows how to fire KV triggers this release. Future
|
||||
//! sources (docs/files/pubsub) add their own dispatch arm.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
EmitError, KvEventOp, SdkCallCx, ServiceEvent, ServiceEventEmitter, TriggerEvent,
|
||||
};
|
||||
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxSourceKind};
|
||||
use crate::trigger_repo::TriggerRepo;
|
||||
|
||||
pub struct OutboxEventEmitter {
|
||||
triggers: Arc<dyn TriggerRepo>,
|
||||
outbox: Arc<dyn OutboxRepo>,
|
||||
}
|
||||
|
||||
impl OutboxEventEmitter {
|
||||
#[must_use]
|
||||
pub fn new(triggers: Arc<dyn TriggerRepo>, outbox: Arc<dyn OutboxRepo>) -> Self {
|
||||
Self { triggers, outbox }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ServiceEventEmitter for OutboxEventEmitter {
|
||||
async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
|
||||
match event.source {
|
||||
"kv" => self.emit_kv(cx, event).await,
|
||||
// Future sources land here. For now, silently drop — the
|
||||
// SDK calls `events.emit(...)` unconditionally for forward
|
||||
// compat, so swallowing without an error is correct.
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OutboxEventEmitter {
|
||||
async fn emit_kv(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError> {
|
||||
let Some(op) = KvEventOp::from_wire(event.op) else {
|
||||
return Ok(()); // unknown op — drop quietly
|
||||
};
|
||||
let Some(collection) = event.collection.clone() else {
|
||||
return Ok(()); // KV events always carry a collection — defensively skip
|
||||
};
|
||||
let key = event.key.clone().unwrap_or_default();
|
||||
|
||||
let matches = self
|
||||
.triggers
|
||||
.list_matching_kv(cx.app_id, &collection, op)
|
||||
.await
|
||||
.map_err(|e| EmitError::Unavailable(format!("trigger lookup: {e}")))?;
|
||||
|
||||
if matches.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Serialize the originating event as a TriggerEvent so the
|
||||
// dispatcher can hand it to the script as `ctx.event` without
|
||||
// round-tripping back to the trigger row.
|
||||
let trigger_event = TriggerEvent::Kv {
|
||||
op,
|
||||
collection,
|
||||
key,
|
||||
value: event.payload.clone(),
|
||||
};
|
||||
let payload = serde_json::to_value(&trigger_event)
|
||||
.map_err(|e| EmitError::Rejected(format!("event serialize: {e}")))?;
|
||||
|
||||
for m in matches {
|
||||
self.outbox
|
||||
.insert(NewOutboxRow {
|
||||
app_id: cx.app_id,
|
||||
source_kind: OutboxSourceKind::Kv,
|
||||
trigger_id: Some(m.trigger_id),
|
||||
script_id: Some(m.script_id),
|
||||
reply_to: None,
|
||||
payload: payload.clone(),
|
||||
origin_principal: cx.principal.as_ref().map(|p| p.user_id),
|
||||
trigger_depth: cx.trigger_depth.saturating_add(1),
|
||||
root_execution_id: Some(cx.root_execution_id),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| EmitError::Unavailable(format!("outbox insert: {e}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
258
crates/manager-core/src/outbox_repo.rs
Normal file
258
crates/manager-core/src/outbox_repo.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
//! `OutboxRepo` — universal trigger outbox CRUD. Hot writes come from
|
||||
//! the `OutboxEventEmitter` (KV mutations fan out via this) and the
|
||||
//! sync-HTTP path. Hot reads come from the dispatcher, which claims
|
||||
//! due rows via `FOR UPDATE SKIP LOCKED`.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionId, NewHttpOutbox, OutboxWriter, OutboxWriterError, ScriptId,
|
||||
TriggerId,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OutboxRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OutboxSourceKind {
|
||||
Http,
|
||||
Kv,
|
||||
DeadLetter,
|
||||
}
|
||||
|
||||
impl OutboxSourceKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Http => "http",
|
||||
Self::Kv => "kv",
|
||||
Self::DeadLetter => "dead_letter",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_wire(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"http" => Some(Self::Http),
|
||||
"kv" => Some(Self::Kv),
|
||||
"dead_letter" => Some(Self::DeadLetter),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert payload — what each event source writes when fanning out
|
||||
/// to the outbox. `payload` is the serialized `TriggerEvent` (plus
|
||||
/// any extra context the dispatcher needs to reconstruct an
|
||||
/// `ExecRequest`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewOutboxRow {
|
||||
pub app_id: AppId,
|
||||
pub source_kind: OutboxSourceKind,
|
||||
pub trigger_id: Option<TriggerId>,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub payload: serde_json::Value,
|
||||
pub origin_principal: Option<AdminUserId>,
|
||||
pub trigger_depth: u32,
|
||||
pub root_execution_id: Option<ExecutionId>,
|
||||
}
|
||||
|
||||
/// Row as the dispatcher sees it after a claim.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutboxRow {
|
||||
pub id: Uuid,
|
||||
pub app_id: AppId,
|
||||
pub source_kind: OutboxSourceKind,
|
||||
pub trigger_id: Option<TriggerId>,
|
||||
pub script_id: Option<ScriptId>,
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub payload: serde_json::Value,
|
||||
pub origin_principal: Option<AdminUserId>,
|
||||
pub trigger_depth: u32,
|
||||
pub root_execution_id: Option<ExecutionId>,
|
||||
pub attempt_count: u32,
|
||||
pub next_attempt_at: DateTime<Utc>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait OutboxRepo: Send + Sync {
|
||||
async fn insert(&self, row: NewOutboxRow) -> Result<Uuid, OutboxRepoError>;
|
||||
|
||||
/// Claim up to `limit` due rows. Wraps the claim in a single
|
||||
/// transaction so two concurrent dispatchers (cluster mode) can't
|
||||
/// double-pick a row. Empty Vec when nothing is due.
|
||||
async fn claim_due(
|
||||
&self,
|
||||
claimed_by: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<OutboxRow>, OutboxRepoError>;
|
||||
|
||||
/// Remove a row after a terminal outcome (success or dead-letter).
|
||||
async fn delete(&self, id: Uuid) -> Result<(), OutboxRepoError>;
|
||||
|
||||
/// Failure path: bump attempt_count, clear the claim, set the
|
||||
/// next attempt time. The dispatcher computes the delay (with
|
||||
/// backoff + jitter) and passes it in.
|
||||
async fn reschedule(
|
||||
&self,
|
||||
id: Uuid,
|
||||
attempt_count: u32,
|
||||
next_attempt_at: DateTime<Utc>,
|
||||
) -> Result<(), OutboxRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresOutboxRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresOutboxRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboxRepo for PostgresOutboxRepo {
|
||||
async fn insert(&self, row: NewOutboxRow) -> Result<Uuid, OutboxRepoError> {
|
||||
let (id,): (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO outbox ( \
|
||||
app_id, source_kind, trigger_id, script_id, reply_to, \
|
||||
payload, origin_principal, trigger_depth, root_execution_id \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(row.app_id.into_inner())
|
||||
.bind(row.source_kind.as_str())
|
||||
.bind(row.trigger_id.map(TriggerId::into_inner))
|
||||
.bind(row.script_id.map(ScriptId::into_inner))
|
||||
.bind(row.reply_to)
|
||||
.bind(row.payload)
|
||||
.bind(row.origin_principal.map(AdminUserId::into_inner))
|
||||
.bind(i32::try_from(row.trigger_depth).unwrap_or(0))
|
||||
.bind(row.root_execution_id.map(ExecutionId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn claim_due(
|
||||
&self,
|
||||
claimed_by: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<OutboxRow>, OutboxRepoError> {
|
||||
let rows: Vec<OutboxRowRaw> = sqlx::query_as(
|
||||
"WITH due AS ( \
|
||||
SELECT id FROM outbox \
|
||||
WHERE claimed_at IS NULL AND next_attempt_at <= NOW() \
|
||||
ORDER BY next_attempt_at \
|
||||
FOR UPDATE SKIP LOCKED \
|
||||
LIMIT $1 \
|
||||
) \
|
||||
UPDATE outbox SET claimed_at = NOW(), claimed_by = $2 \
|
||||
WHERE id IN (SELECT id FROM due) \
|
||||
RETURNING id, app_id, source_kind, trigger_id, script_id, reply_to, \
|
||||
payload, origin_principal, trigger_depth, \
|
||||
root_execution_id, attempt_count, next_attempt_at, created_at",
|
||||
)
|
||||
.bind(limit)
|
||||
.bind(claimed_by)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().filter_map(OutboxRowRaw::hydrate).collect())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: Uuid) -> Result<(), OutboxRepoError> {
|
||||
sqlx::query("DELETE FROM outbox WHERE id = $1")
|
||||
.bind(id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reschedule(
|
||||
&self,
|
||||
id: Uuid,
|
||||
attempt_count: u32,
|
||||
next_attempt_at: DateTime<Utc>,
|
||||
) -> Result<(), OutboxRepoError> {
|
||||
sqlx::query(
|
||||
"UPDATE outbox SET attempt_count = $2, next_attempt_at = $3, \
|
||||
claimed_at = NULL, claimed_by = NULL \
|
||||
WHERE id = $1",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(i32::try_from(attempt_count).unwrap_or(0))
|
||||
.bind(next_attempt_at)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `OutboxWriter` implementation so orchestrator-core (which can't
|
||||
/// depend on manager-core) can enqueue HTTP outbox rows through the
|
||||
/// shared trait.
|
||||
#[async_trait]
|
||||
impl OutboxWriter for PostgresOutboxRepo {
|
||||
async fn enqueue_http(&self, row: NewHttpOutbox) -> Result<Uuid, OutboxWriterError> {
|
||||
self.insert(NewOutboxRow {
|
||||
app_id: row.app_id,
|
||||
source_kind: OutboxSourceKind::Http,
|
||||
trigger_id: Some(TriggerId::from(row.route_id)),
|
||||
script_id: Some(row.script_id),
|
||||
reply_to: row.reply_to,
|
||||
payload: row.payload,
|
||||
origin_principal: row.origin_principal,
|
||||
trigger_depth: row.trigger_depth,
|
||||
root_execution_id: row.root_execution_id,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| OutboxWriterError::Backend(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct OutboxRowRaw {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
source_kind: String,
|
||||
trigger_id: Option<Uuid>,
|
||||
script_id: Option<Uuid>,
|
||||
reply_to: Option<Uuid>,
|
||||
payload: serde_json::Value,
|
||||
origin_principal: Option<Uuid>,
|
||||
trigger_depth: i32,
|
||||
root_execution_id: Option<Uuid>,
|
||||
attempt_count: i32,
|
||||
next_attempt_at: DateTime<Utc>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl OutboxRowRaw {
|
||||
fn hydrate(self) -> Option<OutboxRow> {
|
||||
Some(OutboxRow {
|
||||
id: self.id,
|
||||
app_id: self.app_id.into(),
|
||||
source_kind: OutboxSourceKind::from_wire(&self.source_kind)?,
|
||||
trigger_id: self.trigger_id.map(Into::into),
|
||||
script_id: self.script_id.map(Into::into),
|
||||
reply_to: self.reply_to,
|
||||
payload: self.payload,
|
||||
origin_principal: self.origin_principal.map(Into::into),
|
||||
trigger_depth: u32::try_from(self.trigger_depth).unwrap_or(0),
|
||||
root_execution_id: self.root_execution_id.map(Into::into),
|
||||
attempt_count: u32::try_from(self.attempt_count).unwrap_or(0),
|
||||
next_attempt_at: self.next_attempt_at,
|
||||
created_at: self.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
62
crates/manager-core/src/principal_resolver.rs
Normal file
62
crates/manager-core/src/principal_resolver.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! `PrincipalResolver` — turns a `registered_by_principal` user id from
|
||||
//! a trigger row into the `Principal` the dispatcher passes through to
|
||||
//! the executor. Per design notes §4, a trigger execution runs as the
|
||||
//! user that registered the trigger; the original event's caller is
|
||||
//! recorded elsewhere (on the outbox row, for forensics) and does not
|
||||
//! become the execution principal.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AdminUserId, Principal};
|
||||
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRepositoryError};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PrincipalResolverError {
|
||||
#[error("user not found: {0}")]
|
||||
NotFound(AdminUserId),
|
||||
#[error("user is inactive: {0}")]
|
||||
Inactive(AdminUserId),
|
||||
#[error("admin user repo error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait PrincipalResolver: Send + Sync {
|
||||
async fn resolve(&self, user_id: AdminUserId) -> Result<Principal, PrincipalResolverError>;
|
||||
}
|
||||
|
||||
pub struct AdminPrincipalResolver {
|
||||
users: std::sync::Arc<dyn AdminUserRepository>,
|
||||
}
|
||||
|
||||
impl AdminPrincipalResolver {
|
||||
#[must_use]
|
||||
pub fn new(users: std::sync::Arc<dyn AdminUserRepository>) -> Self {
|
||||
Self { users }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PrincipalResolver for AdminPrincipalResolver {
|
||||
async fn resolve(&self, user_id: AdminUserId) -> Result<Principal, PrincipalResolverError> {
|
||||
let row = self
|
||||
.users
|
||||
.get(user_id)
|
||||
.await
|
||||
.map_err(|e: AdminUserRepositoryError| PrincipalResolverError::Backend(e.to_string()))?
|
||||
.ok_or(PrincipalResolverError::NotFound(user_id))?;
|
||||
if !row.is_active {
|
||||
return Err(PrincipalResolverError::Inactive(user_id));
|
||||
}
|
||||
Ok(Principal {
|
||||
user_id,
|
||||
instance_role: row.instance_role,
|
||||
// Trigger executions are cookie-session-style (no API key
|
||||
// scope restriction). Per-app permissions are evaluated
|
||||
// via `authz::can` against the `app_id` of the resource
|
||||
// the script touches, exactly like an admin invocation.
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -21,7 +23,18 @@ pub enum ScriptRepositoryError {
|
||||
#[async_trait]
|
||||
pub trait ScriptRepository: Send + Sync {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Every script across all apps. Mostly for tests and admin
|
||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
/// Every script in any app the user is a member of. Drives
|
||||
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||
/// API never returns scripts they shouldn't see — even before the
|
||||
/// per-handler capability check fires.
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
@@ -35,6 +48,7 @@ pub trait ScriptRepository: Send + Sync {
|
||||
/// constraints; the repo enforces them in the DB regardless.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewScript {
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -78,7 +92,7 @@ impl PostgresScriptRepository {
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, name, description, version, source, \
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE id = $1",
|
||||
)
|
||||
@@ -90,7 +104,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, name, description, version, source, \
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts ORDER BY name",
|
||||
)
|
||||
@@ -99,17 +113,48 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT s.id, s.app_id, s.name, s.description, s.version, s.source, \
|
||||
s.timeout_seconds, s.memory_limit_mb, s.sandbox, s.created_at, s.updated_at \
|
||||
FROM scripts s \
|
||||
JOIN app_members m ON m.app_id = s.app_id \
|
||||
WHERE m.user_id = $1 \
|
||||
ORDER BY s.name",
|
||||
)
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
"INSERT INTO scripts ( \
|
||||
name, description, source, \
|
||||
app_id, name, description, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
||||
RETURNING id, name, description, version, source, \
|
||||
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
@@ -123,7 +168,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists",
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)))
|
||||
}
|
||||
@@ -141,12 +186,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||
// Sandbox is replaced wholesale when present; per-field merging
|
||||
// happens in the API layer (clearer semantics for a "PUT a new
|
||||
// sandbox config" call).
|
||||
// sandbox config" call). app_id is immutable — moving a script
|
||||
// to another app is a copy-and-delete, not an in-place edit.
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
@@ -157,7 +203,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, name, description, version, source, \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -169,10 +215,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
row.map(Into::into)
|
||||
.ok_or(ScriptRepositoryError::NotFound(id))
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.into()),
|
||||
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||
@@ -191,6 +245,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
version: i32,
|
||||
@@ -211,6 +266,7 @@ impl From<ScriptRow> for Script {
|
||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
version: r.version,
|
||||
@@ -284,7 +340,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
offset: i64,
|
||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
||||
"SELECT id, script_id, request_id, \
|
||||
"SELECT id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
@@ -306,6 +362,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExecutionLogRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
script_id: uuid::Uuid,
|
||||
request_id: uuid::Uuid,
|
||||
request_path: Option<String>,
|
||||
@@ -331,6 +388,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
||||
};
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
request_id: RequestId::from(r.request_id),
|
||||
request_path: r.request_path.unwrap_or_default(),
|
||||
|
||||
@@ -10,42 +10,56 @@ use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Principal, Route, ScriptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::app_domain_repo::AppDomainRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
pub struct RouteAdminState<RR> {
|
||||
pub struct RouteAdminState<RR, SR> {
|
||||
pub routes: Arc<RR>,
|
||||
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||
/// route inherits the script's app) and to scope conflict checks.
|
||||
pub scripts: Arc<SR>,
|
||||
/// Used to validate the route's host against the parent app's
|
||||
/// declared domain claims.
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub table: Arc<RouteTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
impl<RR> Clone for RouteAdminState<RR> {
|
||||
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
routes: self.routes.clone(),
|
||||
scripts: self.scripts.clone(),
|
||||
domains: self.domains.clone(),
|
||||
table: self.table.clone(),
|
||||
authz: self.authz.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
||||
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
||||
where
|
||||
RR: RouteRepository + 'static,
|
||||
SR: ScriptRepository + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/scripts/{id}/routes",
|
||||
get(list_routes::<RR>).post(create_route::<RR>),
|
||||
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||
)
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
||||
.route("/routes:check", post(check_route::<RR>))
|
||||
.route("/routes:match", post(match_route::<RR>))
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||
.route("/routes:check", post(check_route::<RR, SR>))
|
||||
.route("/routes:match", post(match_route::<RR, SR>))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -63,10 +77,20 @@ pub struct CreateRouteRequest {
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
/// Per-route dispatch mode (v1.1.1). Defaults to `Sync` when
|
||||
/// omitted so older clients aren't broken. `Async` routes return
|
||||
/// `202 Accepted` immediately and run the script in the
|
||||
/// background via the dispatcher.
|
||||
#[serde(default)]
|
||||
pub dispatch_mode: picloud_shared::DispatchMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckRouteRequest {
|
||||
/// Required: which app's route table this hypothetical route would
|
||||
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||
/// errors would leak tenant info — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
@@ -84,6 +108,9 @@ pub struct CheckRouteResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchRouteRequest {
|
||||
/// Which app's route table to dispatch against. The dashboard's
|
||||
/// route-preview tester always knows the current app context.
|
||||
pub app_id: AppId,
|
||||
pub url: String,
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
@@ -111,15 +138,28 @@ pub struct MatchedRoute {
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_routes<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||
let script = state
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||
}
|
||||
|
||||
async fn create_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
Json(input): Json<CreateRouteRequest>,
|
||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||
@@ -130,8 +170,28 @@ async fn create_route<RR: RouteRepository>(
|
||||
input.host_param_name.as_deref(),
|
||||
)?;
|
||||
|
||||
// Within-kind conflict check against existing routes.
|
||||
let existing = state.routes.list_all().await?;
|
||||
// Look up the script's owning app — every route inherits it.
|
||||
let script = state
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
let app_id = script.app_id;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Validate the route's host is consistent with one of the app's
|
||||
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||
// host the app already owns). Specific hosts must match a claim.
|
||||
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
|
||||
.await?;
|
||||
|
||||
// Within-app conflict check (cross-app is impossible by construction).
|
||||
let existing = state.routes.list_for_app(app_id).await?;
|
||||
if let Some((conflicting, reason)) = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -149,6 +209,7 @@ async fn create_route<RR: RouteRepository>(
|
||||
let created = state
|
||||
.routes
|
||||
.create(NewRoute {
|
||||
app_id,
|
||||
script_id,
|
||||
host_kind: input.host_kind,
|
||||
host: input.host,
|
||||
@@ -156,29 +217,54 @@ async fn create_route<RR: RouteRepository>(
|
||||
path_kind: input.path_kind,
|
||||
path: normalized_path,
|
||||
method: input.method,
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(route_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, RouteApiError> {
|
||||
// Resolve the route's app before we delete, so the capability
|
||||
// binds to the actual route's app_id (not a path param).
|
||||
let route = state
|
||||
.routes
|
||||
.get(route_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::RouteNotFound(route_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(route.app_id),
|
||||
)
|
||||
.await?;
|
||||
state.routes.delete(route_id).await?;
|
||||
refresh_table(&state).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn check_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CheckRouteRequest>,
|
||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||
// routes:check is read-only — peeking at a hypothetical conflict
|
||||
// is bounded by AppRead on the target app (otherwise members
|
||||
// could probe other apps).
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||
|
||||
let existing = state.routes.list_all().await?;
|
||||
let existing = state.routes.list_for_app(input.app_id).await?;
|
||||
let conflict = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -201,16 +287,25 @@ async fn check_route<RR: RouteRepository>(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn match_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<MatchRouteRequest>,
|
||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(input.app_id),
|
||||
)
|
||||
.await?;
|
||||
let parsed = url::Url::parse(&input.url)
|
||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||
let host = parsed.host_str().unwrap_or("").to_string();
|
||||
let path = parsed.path().to_string();
|
||||
|
||||
let result = state.table.match_request(&host, &input.method, &path);
|
||||
let result = state
|
||||
.table
|
||||
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||
Ok(Json(MatchRouteResponse {
|
||||
matched: result.map(|r| MatchedRoute {
|
||||
route_id: r.matched.route_id,
|
||||
@@ -263,12 +358,12 @@ fn first_conflict(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn refresh_table<RR: RouteRepository>(
|
||||
state: &RouteAdminState<RR>,
|
||||
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
state: &RouteAdminState<RR, SR>,
|
||||
) -> Result<(), RouteApiError> {
|
||||
let rows = state.routes.list_all().await?;
|
||||
let compiled = compile_routes(&rows)?;
|
||||
state.table.replace(compiled);
|
||||
state.table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -277,15 +372,90 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.map(|r| {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
app_id: r.app_id,
|
||||
script_id: r.script_id,
|
||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||
method: r.method.clone(),
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||
/// always permitted — it catches every host the app already owns.
|
||||
async fn validate_route_host_against_app(
|
||||
domains: &dyn AppDomainRepository,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<(), RouteApiError> {
|
||||
if matches!(host_kind, HostKind::Any) {
|
||||
return Ok(());
|
||||
}
|
||||
let claims = domains.list_for_app(app_id).await?;
|
||||
if claims.is_empty() {
|
||||
return Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
for claim in &claims {
|
||||
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||
match (host_kind, claim.shape) {
|
||||
// Strict route under exact claim: must match exactly.
|
||||
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||
if host_lower == claim_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Strict route under wildcard/parameterized: must end with
|
||||
// ".<suffix>" where the claim's suffix is the part after
|
||||
// `*.` or `{...}.`.
|
||||
(
|
||||
HostKind::Strict,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let needle = format!(".{suffix}");
|
||||
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route: must match a wildcard or parameterized
|
||||
// claim with identical suffix.
|
||||
(
|
||||
HostKind::Wildcard,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let claim_suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
if claim_suffix == host_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route under exact claim: not allowed (would
|
||||
// shadow other apps' subdomains the operator didn't claim).
|
||||
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||
(HostKind::Any, _) => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -304,10 +474,37 @@ pub enum RouteApiError {
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("script not found: {0}")]
|
||||
ScriptNotFound(ScriptId),
|
||||
|
||||
#[error("route not found: {0}")]
|
||||
RouteNotFound(Uuid),
|
||||
|
||||
#[error("host {host:?} is not claimed by this app")]
|
||||
HostNotClaimed {
|
||||
host: String,
|
||||
available_claims: Vec<String>,
|
||||
},
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for RouteApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for RouteApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
@@ -326,10 +523,34 @@ impl IntoResponse for RouteApiError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
Self::ScriptNotFound(_)
|
||||
| Self::RouteNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (
|
||||
StatusCode::FORBIDDEN,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "route authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
serde_json::json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::HostNotClaimed {
|
||||
host,
|
||||
available_claims,
|
||||
} => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({
|
||||
"error": self.to_string(),
|
||||
"host": host,
|
||||
"available_claims": available_claims,
|
||||
}),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||
StatusCode::CONFLICT,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! CRUD over the `routes` table.
|
||||
//!
|
||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
||||
//! every write — see the route_admin module for the binding.
|
||||
//! The orchestrator's `AppRouteTables` is repopulated from this repo
|
||||
//! after every write — see the route_admin module for the binding.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, DispatchMode, HostKind, PathKind, Route, ScriptId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewRoute {
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
@@ -19,17 +20,31 @@ pub struct NewRoute {
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RouteRepository: Send + Sync {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
/// Single-row lookup. Used by `DELETE /api/v1/admin/routes/{id}` so
|
||||
/// the capability check binds to the route's actual `app_id`
|
||||
/// (not a path param).
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||
/// Count routes whose host_kind/host pair matches a pattern in
|
||||
/// `app_id`. Used by the domain-claim delete guard.
|
||||
async fn count_for_app_host(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<i64, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresRouteRepository {
|
||||
@@ -47,8 +62,8 @@ impl PostgresRouteRepository {
|
||||
impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -56,13 +71,37 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
FROM routes WHERE id = $1",
|
||||
)
|
||||
.bind(route_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(script_id.into_inner())
|
||||
@@ -74,12 +113,13 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at",
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
@@ -87,6 +127,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
.bind(path_kind_str(input.path_kind))
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
@@ -112,6 +153,24 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_for_app_host(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM routes \
|
||||
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(host_kind_str(host_kind))
|
||||
.bind(host)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||
@@ -133,6 +192,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
@@ -140,6 +200,7 @@ struct RouteRow {
|
||||
path_kind: String,
|
||||
path: String,
|
||||
method: Option<String>,
|
||||
dispatch_mode: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -147,6 +208,7 @@ impl From<RouteRow> for Route {
|
||||
fn from(r: RouteRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
@@ -162,6 +224,7 @@ impl From<RouteRow> for Route {
|
||||
},
|
||||
path: r.path,
|
||||
method: r.method,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
157
crates/manager-core/src/trigger_config.rs
Normal file
157
crates/manager-core/src/trigger_config.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
//! Trigger-framework tunables. Defaults match design notes §3 (retry
|
||||
//! policy) and §4 (retention). Each knob is env-overridable via a
|
||||
//! `PICLOUD_*` variable following the same `tracing::warn` on parse
|
||||
//! error pattern `SandboxCeiling::from_env` uses.
|
||||
|
||||
use std::env;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BackoffShape {
|
||||
Exponential,
|
||||
Linear,
|
||||
Constant,
|
||||
}
|
||||
|
||||
impl BackoffShape {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Exponential => "exponential",
|
||||
Self::Linear => "linear",
|
||||
Self::Constant => "constant",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_wire(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"exponential" => Some(Self::Exponential),
|
||||
"linear" => Some(Self::Linear),
|
||||
"constant" => Some(Self::Constant),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct TriggerConfig {
|
||||
/// Maximum `cx.trigger_depth` before the dispatcher refuses
|
||||
/// execution. Above this, the row is dropped + a metric bumped;
|
||||
/// it is NOT dead-lettered (design notes §4: depth-exceeded
|
||||
/// means "you built a loop"). Default 8.
|
||||
pub max_trigger_depth: u32,
|
||||
|
||||
/// Default retry attempts (per-trigger override on the row).
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
/// ±jitter as a percentage of the computed delay. Applied at
|
||||
/// dispatch time — not per-trigger.
|
||||
pub retry_jitter_pct: u32,
|
||||
|
||||
/// dead-letter retention before GC, in days. Default 30.
|
||||
pub dead_letter_retention_days: u32,
|
||||
/// abandoned-execution retention before GC, in days. Default 7.
|
||||
pub abandoned_retention_days: u32,
|
||||
}
|
||||
|
||||
impl TriggerConfig {
|
||||
#[must_use]
|
||||
pub const fn conservative() -> Self {
|
||||
Self {
|
||||
max_trigger_depth: 8,
|
||||
retry_max_attempts: 3,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
retry_base_ms: 1000,
|
||||
retry_jitter_pct: 20,
|
||||
dead_letter_retention_days: 30,
|
||||
abandoned_retention_days: 7,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_env() -> Self {
|
||||
let mut c = Self::conservative();
|
||||
load_u32(&mut c.max_trigger_depth, "PICLOUD_MAX_TRIGGER_DEPTH");
|
||||
load_u32(
|
||||
&mut c.retry_max_attempts,
|
||||
"PICLOUD_TRIGGER_RETRY_MAX_ATTEMPTS",
|
||||
);
|
||||
load_backoff(&mut c.retry_backoff, "PICLOUD_TRIGGER_RETRY_BACKOFF");
|
||||
load_u32(&mut c.retry_base_ms, "PICLOUD_TRIGGER_RETRY_BASE_MS");
|
||||
load_u32(&mut c.retry_jitter_pct, "PICLOUD_TRIGGER_RETRY_JITTER_PCT");
|
||||
load_u32(
|
||||
&mut c.dead_letter_retention_days,
|
||||
"PICLOUD_DEAD_LETTER_RETENTION_DAYS",
|
||||
);
|
||||
load_u32(
|
||||
&mut c.abandoned_retention_days,
|
||||
"PICLOUD_ABANDONED_EXECUTIONS_RETENTION_DAYS",
|
||||
);
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TriggerConfig {
|
||||
fn default() -> Self {
|
||||
Self::conservative()
|
||||
}
|
||||
}
|
||||
|
||||
fn load_u32(dst: &mut u32, key: &str) {
|
||||
if let Ok(v) = env::var(key) {
|
||||
match v.parse::<u32>() {
|
||||
Ok(n) => *dst = n,
|
||||
Err(e) => {
|
||||
tracing::warn!(env = key, error = %e, "ignoring invalid trigger-config value");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_backoff(dst: &mut BackoffShape, key: &str) {
|
||||
if let Ok(v) = env::var(key) {
|
||||
match BackoffShape::from_wire(&v) {
|
||||
Some(b) => *dst = b,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
env = key,
|
||||
value = %v,
|
||||
"ignoring invalid trigger-config backoff shape (use exponential|linear|constant)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conservative_defaults_match_design_notes() {
|
||||
let c = TriggerConfig::conservative();
|
||||
assert_eq!(c.max_trigger_depth, 8);
|
||||
assert_eq!(c.retry_max_attempts, 3);
|
||||
assert_eq!(c.retry_backoff, BackoffShape::Exponential);
|
||||
assert_eq!(c.retry_base_ms, 1000);
|
||||
assert_eq!(c.retry_jitter_pct, 20);
|
||||
assert_eq!(c.dead_letter_retention_days, 30);
|
||||
assert_eq!(c.abandoned_retention_days, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_round_trips() {
|
||||
for shape in [
|
||||
BackoffShape::Exponential,
|
||||
BackoffShape::Linear,
|
||||
BackoffShape::Constant,
|
||||
] {
|
||||
assert_eq!(BackoffShape::from_wire(shape.as_str()), Some(shape));
|
||||
}
|
||||
assert_eq!(BackoffShape::from_wire("garbage"), None);
|
||||
}
|
||||
}
|
||||
617
crates/manager-core/src/trigger_repo.rs
Normal file
617
crates/manager-core/src/trigger_repo.rs
Normal file
@@ -0,0 +1,617 @@
|
||||
//! `TriggerRepo` — CRUD over the `triggers` parent + per-kind detail
|
||||
//! tables. The admin endpoints (commit 4) sit on top of this; the
|
||||
//! dispatcher (commit 5) reads `list_matching_*` to fan out events to
|
||||
//! handler scripts.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppId, KvEventOp, ScriptId, TriggerId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::trigger_config::BackoffShape;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TriggerRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
|
||||
#[error("trigger not found: {0}")]
|
||||
NotFound(TriggerId),
|
||||
|
||||
#[error("invalid trigger payload: {0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
/// Parent-table row plus the per-kind detail merged in. Serialized
|
||||
/// back to admin clients via the JSON API.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Trigger {
|
||||
pub id: TriggerId,
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub kind: TriggerKind,
|
||||
pub enabled: bool,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub details: TriggerDetails,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TriggerKind {
|
||||
Kv,
|
||||
DeadLetter,
|
||||
}
|
||||
|
||||
impl TriggerKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Kv => "kv",
|
||||
Self::DeadLetter => "dead_letter",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_wire(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"kv" => Some(Self::Kv),
|
||||
"dead_letter" => Some(Self::DeadLetter),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TriggerDispatchMode {
|
||||
Sync,
|
||||
Async,
|
||||
}
|
||||
|
||||
impl TriggerDispatchMode {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Sync => "sync",
|
||||
Self::Async => "async",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum TriggerDetails {
|
||||
Kv {
|
||||
collection_glob: String,
|
||||
ops: Vec<KvEventOp>,
|
||||
},
|
||||
DeadLetter {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
source_filter: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
trigger_id_filter: Option<TriggerId>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
script_id_filter: Option<ScriptId>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Create payload for a KV trigger. Defaults applied at the admin
|
||||
/// layer (uses `TriggerConfig::from_env` to fill retry settings if
|
||||
/// the request omitted them — keeps the row auditable).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateKvTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub collection_glob: String,
|
||||
pub ops: Vec<KvEventOp>,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CreateDeadLetterTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub source_filter: Option<String>,
|
||||
pub trigger_id_filter: Option<TriggerId>,
|
||||
pub script_id_filter: Option<ScriptId>,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
/// One match for the dispatcher's "which KV triggers fire on this
|
||||
/// event" lookup. Carries everything the dispatcher needs to construct
|
||||
/// the outbox row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KvTriggerMatch {
|
||||
pub trigger_id: TriggerId,
|
||||
pub script_id: ScriptId,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub retry_max_attempts: u32,
|
||||
pub retry_backoff: BackoffShape,
|
||||
pub retry_base_ms: u32,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
/// One match for the dispatcher's "which dead-letter triggers fire
|
||||
/// on this dead-letter row" lookup.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DeadLetterTriggerMatch {
|
||||
pub trigger_id: TriggerId,
|
||||
pub script_id: ScriptId,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
pub registered_by_principal: AdminUserId,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait TriggerRepo: Send + Sync {
|
||||
async fn create_kv_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateKvTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError>;
|
||||
|
||||
async fn create_dead_letter_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateDeadLetterTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError>;
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError>;
|
||||
|
||||
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError>;
|
||||
|
||||
async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError>;
|
||||
|
||||
/// Dispatcher hot path: find every enabled KV trigger in `app_id`
|
||||
/// whose `collection_glob` matches `collection` and whose `ops`
|
||||
/// covers `op`. Glob matching done in Rust (the column is plain
|
||||
/// TEXT, the matcher applies "*"/"prefix:*" semantics).
|
||||
async fn list_matching_kv(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
op: KvEventOp,
|
||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError>;
|
||||
|
||||
/// Dispatcher hot path for dead-letter fan-out. Filters: source
|
||||
/// (or any-source), originating trigger_id (or any), originating
|
||||
/// script_id (or any). Each filter is "match OR is_null".
|
||||
async fn list_matching_dead_letter(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
source: &str,
|
||||
trigger_id: Option<TriggerId>,
|
||||
script_id: Option<ScriptId>,
|
||||
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError>;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Postgres impl
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub struct PostgresTriggerRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresTriggerRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TriggerRepo for PostgresTriggerRepo {
|
||||
async fn create_kv_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateKvTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
if req.collection_glob.is_empty() {
|
||||
return Err(TriggerRepoError::Invalid(
|
||||
"collection_glob must not be empty".into(),
|
||||
));
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let parent: TriggerRow = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'kv', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(req.script_id.into_inner())
|
||||
.bind(req.dispatch_mode.as_str())
|
||||
.bind(i32::try_from(req.retry_max_attempts).unwrap_or(3))
|
||||
.bind(req.retry_backoff.as_str())
|
||||
.bind(i32::try_from(req.retry_base_ms).unwrap_or(1000))
|
||||
.bind(req.registered_by_principal.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let ops_str: Vec<String> = req.ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.bind(&req.collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
kind: TriggerKind::Kv,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: parent.registered_by_principal.into(),
|
||||
created_at: parent.created_at,
|
||||
updated_at: parent.updated_at,
|
||||
details: TriggerDetails::Kv {
|
||||
collection_glob: req.collection_glob,
|
||||
ops: req.ops,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_dead_letter_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateDeadLetterTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Dead-letter triggers force max_attempts=1 (design notes §4
|
||||
// recursion-stop). Backoff/base_ms irrelevant but the columns
|
||||
// are NOT NULL — store sensible values.
|
||||
let parent: TriggerRow = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'dead_letter', TRUE, 'async', 1, 'constant', 0, $3) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(req.script_id.into_inner())
|
||||
.bind(req.registered_by_principal.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO dead_letter_trigger_details \
|
||||
(trigger_id, source_filter, trigger_id_filter, script_id_filter) \
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.bind(req.source_filter.as_deref())
|
||||
.bind(req.trigger_id_filter.map(TriggerId::into_inner))
|
||||
.bind(req.script_id_filter.map(ScriptId::into_inner))
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
kind: TriggerKind::DeadLetter,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(1),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Constant),
|
||||
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(0),
|
||||
registered_by_principal: parent.registered_by_principal.into(),
|
||||
created_at: parent.created_at,
|
||||
updated_at: parent.updated_at,
|
||||
details: TriggerDetails::DeadLetter {
|
||||
source_filter: req.source_filter,
|
||||
trigger_id_filter: req.trigger_id_filter,
|
||||
script_id_filter: req.script_id_filter,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut out = Vec::with_capacity(parents.len());
|
||||
for p in parents {
|
||||
out.push(hydrate_one(&self.pool, p).await?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
|
||||
let parent: Option<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
match parent {
|
||||
Some(p) => Ok(Some(hydrate_one(&self.pool, p).await?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError> {
|
||||
// ON DELETE CASCADE on the detail tables takes care of them.
|
||||
let res = sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn list_matching_kv(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
collection: &str,
|
||||
op: KvEventOp,
|
||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
||||
// Fetch all enabled KV triggers for the app — glob matching
|
||||
// happens in Rust so we don't have to teach the query about
|
||||
// `*` and `prefix:*`. Sets are tiny in practice (one app's
|
||||
// worth of triggers, usually a handful).
|
||||
let rows: Vec<KvMatchRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, \
|
||||
t.retry_max_attempts, t.retry_backoff, t.retry_base_ms, \
|
||||
t.registered_by_principal, \
|
||||
d.collection_glob, d.ops \
|
||||
FROM triggers t \
|
||||
JOIN kv_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'kv' AND t.enabled = TRUE",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let op_str = op.as_str();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
if !collection_matches(&r.collection_glob, collection) {
|
||||
continue;
|
||||
}
|
||||
let any_op = r.ops.is_empty();
|
||||
if !any_op && !r.ops.iter().any(|o| o == op_str) {
|
||||
continue;
|
||||
}
|
||||
out.push(KvTriggerMatch {
|
||||
trigger_id: r.id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(r.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&r.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(r.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: r.registered_by_principal.into(),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn list_matching_dead_letter(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
source: &str,
|
||||
trigger_id: Option<TriggerId>,
|
||||
script_id: Option<ScriptId>,
|
||||
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
|
||||
let rows: Vec<DlMatchRow> = sqlx::query_as(
|
||||
"SELECT t.id, t.script_id, t.dispatch_mode, t.registered_by_principal, \
|
||||
d.source_filter, d.trigger_id_filter, d.script_id_filter \
|
||||
FROM triggers t \
|
||||
JOIN dead_letter_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'dead_letter' AND t.enabled = TRUE \
|
||||
AND (d.source_filter IS NULL OR d.source_filter = $2) \
|
||||
AND (d.trigger_id_filter IS NULL OR d.trigger_id_filter = $3) \
|
||||
AND (d.script_id_filter IS NULL OR d.script_id_filter = $4)",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(source)
|
||||
.bind(trigger_id.map(TriggerId::into_inner))
|
||||
.bind(script_id.map(ScriptId::into_inner))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DeadLetterTriggerMatch {
|
||||
trigger_id: r.id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
dispatch_mode: dispatch_from_str(&r.dispatch_mode),
|
||||
registered_by_principal: r.registered_by_principal.into(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, TriggerRepoError> {
|
||||
let kind = TriggerKind::from_wire(&parent.kind).ok_or_else(|| {
|
||||
TriggerRepoError::Invalid(format!("unknown trigger kind {}", parent.kind))
|
||||
})?;
|
||||
|
||||
let details = match kind {
|
||||
TriggerKind::Kv => {
|
||||
let row: KvDetailRow = sqlx::query_as(
|
||||
"SELECT collection_glob, ops FROM kv_trigger_details WHERE trigger_id = $1",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
let ops = row
|
||||
.ops
|
||||
.iter()
|
||||
.filter_map(|s| KvEventOp::from_wire(s))
|
||||
.collect();
|
||||
TriggerDetails::Kv {
|
||||
collection_glob: row.collection_glob,
|
||||
ops,
|
||||
}
|
||||
}
|
||||
TriggerKind::DeadLetter => {
|
||||
let row: DlDetailRow = sqlx::query_as(
|
||||
"SELECT source_filter, trigger_id_filter, script_id_filter \
|
||||
FROM dead_letter_trigger_details WHERE trigger_id = $1",
|
||||
)
|
||||
.bind(parent.id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
TriggerDetails::DeadLetter {
|
||||
source_filter: row.source_filter,
|
||||
trigger_id_filter: row.trigger_id_filter.map(Into::into),
|
||||
script_id_filter: row.script_id_filter.map(Into::into),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Trigger {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
kind,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
retry_max_attempts: u32::try_from(parent.retry_max_attempts).unwrap_or(3),
|
||||
retry_backoff: BackoffShape::from_wire(&parent.retry_backoff)
|
||||
.unwrap_or(BackoffShape::Exponential),
|
||||
retry_base_ms: u32::try_from(parent.retry_base_ms).unwrap_or(1000),
|
||||
registered_by_principal: parent.registered_by_principal.into(),
|
||||
created_at: parent.created_at,
|
||||
updated_at: parent.updated_at,
|
||||
details,
|
||||
})
|
||||
}
|
||||
|
||||
fn dispatch_from_str(s: &str) -> TriggerDispatchMode {
|
||||
match s {
|
||||
"sync" => TriggerDispatchMode::Sync,
|
||||
_ => TriggerDispatchMode::Async,
|
||||
}
|
||||
}
|
||||
|
||||
/// Match a `collection_glob` against an actual `collection` name.
|
||||
/// Supported forms (in priority order):
|
||||
/// - `"*"` → matches every collection
|
||||
/// - `"foo*"` → prefix match (anything starting with "foo")
|
||||
/// - `"foo"` → exact match
|
||||
#[must_use]
|
||||
pub fn collection_matches(glob: &str, collection: &str) -> bool {
|
||||
if glob == "*" {
|
||||
return true;
|
||||
}
|
||||
if let Some(prefix) = glob.strip_suffix('*') {
|
||||
return collection.starts_with(prefix);
|
||||
}
|
||||
glob == collection
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TriggerRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
kind: String,
|
||||
enabled: bool,
|
||||
dispatch_mode: String,
|
||||
retry_max_attempts: i32,
|
||||
retry_backoff: String,
|
||||
retry_base_ms: i32,
|
||||
registered_by_principal: Uuid,
|
||||
created_at: DateTime<Utc>,
|
||||
updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct KvDetailRow {
|
||||
collection_glob: String,
|
||||
ops: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
#[allow(clippy::struct_field_names)]
|
||||
struct DlDetailRow {
|
||||
source_filter: Option<String>,
|
||||
trigger_id_filter: Option<Uuid>,
|
||||
script_id_filter: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct KvMatchRow {
|
||||
id: Uuid,
|
||||
script_id: Uuid,
|
||||
dispatch_mode: String,
|
||||
retry_max_attempts: i32,
|
||||
retry_backoff: String,
|
||||
retry_base_ms: i32,
|
||||
registered_by_principal: Uuid,
|
||||
collection_glob: String,
|
||||
ops: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DlMatchRow {
|
||||
id: Uuid,
|
||||
script_id: Uuid,
|
||||
dispatch_mode: String,
|
||||
registered_by_principal: Uuid,
|
||||
#[allow(dead_code)]
|
||||
source_filter: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
trigger_id_filter: Option<Uuid>,
|
||||
#[allow(dead_code)]
|
||||
script_id_filter: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn collection_matcher_handles_star_prefix_exact() {
|
||||
assert!(collection_matches("*", "widgets"));
|
||||
assert!(collection_matches("*", ""));
|
||||
assert!(collection_matches("users:*", "users:1"));
|
||||
assert!(collection_matches("users:*", "users:"));
|
||||
assert!(!collection_matches("users:*", "orgs:1"));
|
||||
assert!(collection_matches("widgets", "widgets"));
|
||||
assert!(!collection_matches("widgets", "Widgets"));
|
||||
}
|
||||
}
|
||||
748
crates/manager-core/src/triggers_api.rs
Normal file
748
crates/manager-core/src/triggers_api.rs
Normal file
@@ -0,0 +1,748 @@
|
||||
//! `/api/v1/admin/apps/{id}/triggers/*` — trigger CRUD admin endpoints.
|
||||
//!
|
||||
//! Per design notes §2, two kinds ship in v1.1.1: `kv` (with
|
||||
//! collection_glob + ops) and `dead_letter` (with optional source /
|
||||
//! trigger_id / script_id filters). Separate endpoints per kind keep
|
||||
//! validation clean.
|
||||
//!
|
||||
//! Every endpoint is guarded by `Capability::AppManageTriggers(app_id)`
|
||||
//! evaluated after the resource lookup so the capability binds to the
|
||||
//! resource's actual `app_id` (mirrors `apps_api`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, KvEventOp, Principal, ScriptId, TriggerId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::trigger_config::{BackoffShape, TriggerConfig};
|
||||
use crate::trigger_repo::{
|
||||
CreateDeadLetterTrigger, CreateKvTrigger, Trigger, TriggerDispatchMode, TriggerRepo,
|
||||
TriggerRepoError,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TriggersState {
|
||||
pub triggers: Arc<dyn TriggerRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
/// Defaults applied to created triggers when the request omits
|
||||
/// retry settings. Kept on the state struct so tests can swap
|
||||
/// in a stricter / looser config without env tinkering.
|
||||
pub config: TriggerConfig,
|
||||
}
|
||||
|
||||
pub fn triggers_router(state: TriggersState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/apps/{app_id}/triggers",
|
||||
get(list_triggers).delete(noop_405),
|
||||
)
|
||||
.route("/apps/{app_id}/triggers/kv", post(create_kv_trigger))
|
||||
.route(
|
||||
"/apps/{app_id}/triggers/dead_letter",
|
||||
post(create_dl_trigger),
|
||||
)
|
||||
.route(
|
||||
"/apps/{app_id}/triggers/{trigger_id}",
|
||||
delete(delete_trigger),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn noop_405() -> StatusCode {
|
||||
StatusCode::METHOD_NOT_ALLOWED
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateKvTriggerRequest {
|
||||
pub script_id: ScriptId,
|
||||
pub collection_glob: String,
|
||||
/// Subset of `{insert, update, delete}`. Empty array means "any
|
||||
/// op" (the trigger fires on every mutation in matching
|
||||
/// collections).
|
||||
#[serde(default)]
|
||||
pub ops: Vec<KvEventOp>,
|
||||
#[serde(default = "default_dispatch")]
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
/// Overrides for the platform retry defaults. Omitted fields fall
|
||||
/// back to `TriggerConfig` (env-overridable) at write time.
|
||||
#[serde(default)]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub retry_backoff: Option<BackoffShape>,
|
||||
#[serde(default)]
|
||||
pub retry_base_ms: Option<u32>,
|
||||
}
|
||||
|
||||
const fn default_dispatch() -> TriggerDispatchMode {
|
||||
TriggerDispatchMode::Async
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDeadLetterTriggerRequest {
|
||||
pub script_id: ScriptId,
|
||||
#[serde(default)]
|
||||
pub source_filter: Option<String>,
|
||||
#[serde(default)]
|
||||
pub trigger_id_filter: Option<TriggerId>,
|
||||
#[serde(default)]
|
||||
pub script_id_filter: Option<ScriptId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TriggerListResponse {
|
||||
pub triggers: Vec<Trigger>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_triggers(
|
||||
State(s): State<TriggersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
) -> Result<Json<TriggerListResponse>, TriggersApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await?;
|
||||
let triggers = s.triggers.list_for_app(app_id).await?;
|
||||
Ok(Json(TriggerListResponse { triggers }))
|
||||
}
|
||||
|
||||
async fn create_kv_trigger(
|
||||
State(s): State<TriggersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Json(input): Json<CreateKvTriggerRequest>,
|
||||
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if input.collection_glob.trim().is_empty() {
|
||||
return Err(TriggersApiError::Invalid(
|
||||
"collection_glob must not be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let req = CreateKvTrigger {
|
||||
script_id: input.script_id,
|
||||
collection_glob: input.collection_glob,
|
||||
ops: input.ops,
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
retry_max_attempts: input
|
||||
.retry_max_attempts
|
||||
.unwrap_or(s.config.retry_max_attempts),
|
||||
retry_backoff: input.retry_backoff.unwrap_or(s.config.retry_backoff),
|
||||
retry_base_ms: input.retry_base_ms.unwrap_or(s.config.retry_base_ms),
|
||||
registered_by_principal: principal.user_id,
|
||||
};
|
||||
let created = s.triggers.create_kv_trigger(app_id, req).await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn create_dl_trigger(
|
||||
State(s): State<TriggersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(app_id): Path<AppId>,
|
||||
Json(input): Json<CreateDeadLetterTriggerRequest>,
|
||||
) -> Result<(StatusCode, Json<Trigger>), TriggersApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await?;
|
||||
let req = CreateDeadLetterTrigger {
|
||||
script_id: input.script_id,
|
||||
source_filter: input.source_filter,
|
||||
trigger_id_filter: input.trigger_id_filter,
|
||||
script_id_filter: input.script_id_filter,
|
||||
registered_by_principal: principal.user_id,
|
||||
};
|
||||
let created = s.triggers.create_dead_letter_trigger(app_id, req).await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_trigger(
|
||||
State(s): State<TriggersState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((app_id, trigger_id)): Path<(AppId, TriggerId)>,
|
||||
) -> Result<StatusCode, TriggersApiError> {
|
||||
ensure_app_exists(&*s.apps, app_id).await?;
|
||||
// Load the trigger so we can confirm it belongs to the right
|
||||
// app; this prevents a caller from deleting a trigger by id alone
|
||||
// when their capability is bound to a different app.
|
||||
let trigger = s
|
||||
.triggers
|
||||
.get(trigger_id)
|
||||
.await?
|
||||
.ok_or(TriggersApiError::NotFound(trigger_id))?;
|
||||
if trigger.app_id != app_id {
|
||||
return Err(TriggersApiError::NotFound(trigger_id));
|
||||
}
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await?;
|
||||
if !s.triggers.delete(trigger_id).await? {
|
||||
return Err(TriggersApiError::NotFound(trigger_id));
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn ensure_app_exists(
|
||||
apps: &dyn AppRepository,
|
||||
app_id: AppId,
|
||||
) -> Result<(), TriggersApiError> {
|
||||
apps.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| TriggersApiError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| TriggersApiError::AppNotFound(app_id.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum TriggersApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("trigger not found: {0}")]
|
||||
NotFound(TriggerId),
|
||||
|
||||
#[error("invalid trigger: {0}")]
|
||||
Invalid(String),
|
||||
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
|
||||
#[error("trigger backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for TriggersApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for TriggersApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TriggerRepoError> for TriggersApiError {
|
||||
fn from(e: TriggerRepoError) -> Self {
|
||||
match e {
|
||||
TriggerRepoError::NotFound(id) => Self::NotFound(id),
|
||||
TriggerRepoError::Invalid(s) => Self::Invalid(s),
|
||||
TriggerRepoError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for TriggersApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_) | Self::NotFound(_) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "triggers authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "triggers api backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! In-memory tests for the trigger admin path. The Axum routing
|
||||
//! / extractor surface is exercised by integration tests (which
|
||||
//! need a real Postgres for the trigger repo); these tests cover
|
||||
//! the handlers' invariant logic — capability enforcement, app
|
||||
//! validation, default fallback for retry settings.
|
||||
|
||||
use super::*;
|
||||
use crate::app_repo::{AppLookup, AppRepository};
|
||||
use crate::trigger_repo::{
|
||||
DeadLetterTriggerMatch, KvTriggerMatch, Trigger, TriggerDetails, TriggerRepo,
|
||||
TriggerRepoError,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_shared::{AdminUserId, App, AppRole, KvEventOp, ScriptId, TriggerId, UserId};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Default)]
|
||||
struct InMemoryTriggerRepo {
|
||||
inner: Mutex<HashMap<TriggerId, Trigger>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TriggerRepo for InMemoryTriggerRepo {
|
||||
async fn create_kv_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateKvTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
let now = Utc::now();
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
kind: crate::trigger_repo::TriggerKind::Kv,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
retry_max_attempts: req.retry_max_attempts,
|
||||
retry_backoff: req.retry_backoff,
|
||||
retry_base_ms: req.retry_base_ms,
|
||||
registered_by_principal: req.registered_by_principal,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
details: TriggerDetails::Kv {
|
||||
collection_glob: req.collection_glob,
|
||||
ops: req.ops,
|
||||
},
|
||||
};
|
||||
self.inner.lock().await.insert(id, trigger.clone());
|
||||
Ok(trigger)
|
||||
}
|
||||
async fn create_dead_letter_trigger(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
req: CreateDeadLetterTrigger,
|
||||
) -> Result<Trigger, TriggerRepoError> {
|
||||
let now = Utc::now();
|
||||
let id = TriggerId::new();
|
||||
let trigger = Trigger {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
kind: crate::trigger_repo::TriggerKind::DeadLetter,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 1,
|
||||
retry_backoff: BackoffShape::Constant,
|
||||
retry_base_ms: 0,
|
||||
registered_by_principal: req.registered_by_principal,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
details: TriggerDetails::DeadLetter {
|
||||
source_filter: req.source_filter,
|
||||
trigger_id_filter: req.trigger_id_filter,
|
||||
script_id_filter: req.script_id_filter,
|
||||
},
|
||||
};
|
||||
self.inner.lock().await.insert(id, trigger.clone());
|
||||
Ok(trigger)
|
||||
}
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
Ok(self
|
||||
.inner
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.filter(|t| t.app_id == app_id)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
|
||||
Ok(self.inner.lock().await.get(&id).cloned())
|
||||
}
|
||||
async fn delete(&self, id: TriggerId) -> Result<bool, TriggerRepoError> {
|
||||
Ok(self.inner.lock().await.remove(&id).is_some())
|
||||
}
|
||||
async fn list_matching_kv(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_collection: &str,
|
||||
_op: KvEventOp,
|
||||
) -> Result<Vec<KvTriggerMatch>, TriggerRepoError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
async fn list_matching_dead_letter(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_source: &str,
|
||||
_trigger_id: Option<TriggerId>,
|
||||
_script_id: Option<ScriptId>,
|
||||
) -> Result<Vec<DeadLetterTriggerMatch>, TriggerRepoError> {
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
|
||||
struct InMemoryAppRepo {
|
||||
existing: Mutex<HashMap<AppId, App>>,
|
||||
}
|
||||
|
||||
impl InMemoryAppRepo {
|
||||
fn with(app_id: AppId) -> Arc<Self> {
|
||||
let now = Utc::now();
|
||||
let mut existing = HashMap::new();
|
||||
existing.insert(
|
||||
app_id,
|
||||
App {
|
||||
id: app_id,
|
||||
slug: "test".into(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
);
|
||||
Arc::new(Self {
|
||||
existing: Mutex::new(existing),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppRepository for InMemoryAppRepo {
|
||||
async fn create(
|
||||
&self,
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn create_with_takeover(
|
||||
&self,
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn slug_in_history(
|
||||
&self,
|
||||
_slug: &str,
|
||||
) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
id: AppId,
|
||||
) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
|
||||
Ok(self.existing.lock().await.get(&id).cloned())
|
||||
}
|
||||
async fn get_by_slug(
|
||||
&self,
|
||||
_slug: &str,
|
||||
) -> Result<Option<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
_slug: &str,
|
||||
) -> Result<Option<AppLookup>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
_id: AppId,
|
||||
_name: Option<&str>,
|
||||
_description: Option<Option<&str>>,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn rename_slug(
|
||||
&self,
|
||||
_id: AppId,
|
||||
_new_slug: &str,
|
||||
_take_over_history: bool,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete(&self, _id: AppId) -> Result<(), crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn delete_cascade(
|
||||
&self,
|
||||
_id: AppId,
|
||||
) -> Result<(), crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn count_scripts_in_app(
|
||||
&self,
|
||||
_id: AppId,
|
||||
) -> Result<i64, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
struct AlwaysAllowAuthzRepo;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for AlwaysAllowAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(Some(AppRole::AppAdmin))
|
||||
}
|
||||
}
|
||||
|
||||
struct AlwaysDenyAuthzRepo;
|
||||
#[async_trait]
|
||||
impl AuthzRepo for AlwaysDenyAuthzRepo {
|
||||
async fn membership(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn member_principal() -> Principal {
|
||||
Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: picloud_shared::InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn state_with(authz: Arc<dyn AuthzRepo>, app_id: AppId) -> TriggersState {
|
||||
TriggersState {
|
||||
triggers: Arc::new(InMemoryTriggerRepo::default()),
|
||||
apps: InMemoryAppRepo::with(app_id),
|
||||
authz,
|
||||
config: TriggerConfig::conservative(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_app_returns_404() {
|
||||
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), AppId::new());
|
||||
let res = create_kv_trigger(
|
||||
State(state),
|
||||
Extension(member_principal()),
|
||||
Path(AppId::new()), // a different (non-existent) app
|
||||
Json(CreateKvTriggerRequest {
|
||||
script_id: ScriptId::new(),
|
||||
collection_glob: "*".into(),
|
||||
ops: vec![],
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: None,
|
||||
retry_backoff: None,
|
||||
retry_base_ms: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = res.expect_err("missing app should error");
|
||||
assert!(matches!(err, TriggersApiError::AppNotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn member_without_role_is_forbidden() {
|
||||
let app_id = AppId::new();
|
||||
let state = state_with(Arc::new(AlwaysDenyAuthzRepo), app_id);
|
||||
let res = create_kv_trigger(
|
||||
State(state),
|
||||
Extension(member_principal()),
|
||||
Path(app_id),
|
||||
Json(CreateKvTriggerRequest {
|
||||
script_id: ScriptId::new(),
|
||||
collection_glob: "*".into(),
|
||||
ops: vec![],
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: None,
|
||||
retry_backoff: None,
|
||||
retry_base_ms: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = res.expect_err("member without role should be forbidden");
|
||||
assert!(matches!(err, TriggersApiError::Forbidden));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn kv_trigger_uses_env_defaults_when_omitted() {
|
||||
let app_id = AppId::new();
|
||||
let mut state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
|
||||
// Tweak the config so we can detect that defaults were used.
|
||||
state.config.retry_max_attempts = 7;
|
||||
state.config.retry_base_ms = 12_345;
|
||||
let (status, Json(trigger)) = create_kv_trigger(
|
||||
State(state),
|
||||
Extension(member_principal()),
|
||||
Path(app_id),
|
||||
Json(CreateKvTriggerRequest {
|
||||
script_id: ScriptId::new(),
|
||||
collection_glob: "widgets".into(),
|
||||
ops: vec![KvEventOp::Insert],
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: None,
|
||||
retry_backoff: None,
|
||||
retry_base_ms: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED);
|
||||
assert_eq!(trigger.retry_max_attempts, 7);
|
||||
assert_eq!(trigger.retry_base_ms, 12_345);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_collection_glob_rejected() {
|
||||
let app_id = AppId::new();
|
||||
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_id);
|
||||
let res = create_kv_trigger(
|
||||
State(state),
|
||||
Extension(member_principal()),
|
||||
Path(app_id),
|
||||
Json(CreateKvTriggerRequest {
|
||||
script_id: ScriptId::new(),
|
||||
collection_glob: " ".into(),
|
||||
ops: vec![],
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: None,
|
||||
retry_backoff: None,
|
||||
retry_base_ms: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let err = res.expect_err("empty glob should reject");
|
||||
assert!(matches!(err, TriggersApiError::Invalid(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_rejects_cross_app_trigger_id() {
|
||||
let app_a = AppId::new();
|
||||
let app_b = AppId::new();
|
||||
let state = state_with(Arc::new(AlwaysAllowAuthzRepo), app_a);
|
||||
// Inject the app_b row into the in-memory apps repo too so
|
||||
// the path-existence check succeeds against app_a.
|
||||
// Insert a trigger that belongs to app_a.
|
||||
let trigger = state
|
||||
.triggers
|
||||
.create_kv_trigger(
|
||||
app_a,
|
||||
CreateKvTrigger {
|
||||
script_id: ScriptId::new(),
|
||||
collection_glob: "*".into(),
|
||||
ops: vec![],
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
retry_max_attempts: 3,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
retry_base_ms: 1000,
|
||||
registered_by_principal: AdminUserId::new(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let _ = app_b;
|
||||
|
||||
// Attempt to delete via app_b's path — should 404.
|
||||
// First, give the in-memory app repo a record for app_b.
|
||||
// (Otherwise we'd 404 on app-existence before reaching the
|
||||
// cross-app check.)
|
||||
let state = TriggersState {
|
||||
apps: {
|
||||
let now = Utc::now();
|
||||
let mut existing = HashMap::new();
|
||||
existing.insert(
|
||||
app_a,
|
||||
App {
|
||||
id: app_a,
|
||||
slug: "a".into(),
|
||||
name: "a".into(),
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
);
|
||||
existing.insert(
|
||||
app_b,
|
||||
App {
|
||||
id: app_b,
|
||||
slug: "b".into(),
|
||||
name: "b".into(),
|
||||
description: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
);
|
||||
Arc::new(InMemoryAppRepo {
|
||||
existing: Mutex::new(existing),
|
||||
})
|
||||
},
|
||||
..state
|
||||
};
|
||||
|
||||
let res = delete_trigger(
|
||||
State(state),
|
||||
Extension(member_principal()),
|
||||
Path((app_b, trigger.id)),
|
||||
)
|
||||
.await;
|
||||
let err = res.expect_err("cross-app delete should 404");
|
||||
assert!(matches!(err, TriggersApiError::NotFound(_)));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,64 @@
|
||||
|
||||
## tables
|
||||
|
||||
table: admin_sessions
|
||||
token_hash: text NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
expires_at: timestamp with time zone NOT NULL
|
||||
last_used_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: admin_users
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
username: text NOT NULL
|
||||
password_hash: text NOT NULL
|
||||
is_active: boolean NOT NULL default=true
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
last_login_at: timestamp with time zone NULL
|
||||
instance_role: text NOT NULL default='owner'::text
|
||||
email: text NULL
|
||||
mfa_secret: text NULL
|
||||
|
||||
table: api_keys
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
user_id: uuid NOT NULL
|
||||
hash: text NOT NULL
|
||||
prefix: text NOT NULL
|
||||
name: text NOT NULL
|
||||
scopes: ARRAY NOT NULL
|
||||
app_id: uuid NULL
|
||||
expires_at: timestamp with time zone NULL
|
||||
last_used_at: timestamp with time zone NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: app_domains
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
pattern: text NOT NULL
|
||||
shape: text NOT NULL
|
||||
shape_key: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: app_members
|
||||
app_id: uuid NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
role: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: app_slug_history
|
||||
slug: text NOT NULL
|
||||
current_app_id: uuid NOT NULL
|
||||
retired_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: apps
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
slug: text NOT NULL
|
||||
name: text NOT NULL
|
||||
description: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: execution_logs
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -16,6 +74,7 @@ table: execution_logs
|
||||
duration_ms: integer NOT NULL default=0
|
||||
status: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: routes
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -27,6 +86,7 @@ table: routes
|
||||
path: text NOT NULL
|
||||
method: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: scripts
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -39,42 +99,119 @@ table: scripts
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
## indexes
|
||||
|
||||
indexes on admin_sessions:
|
||||
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||
|
||||
indexes on admin_users:
|
||||
admin_users_email_key: public.admin_users USING btree (email)
|
||||
admin_users_instance_role_idx: public.admin_users USING btree (instance_role)
|
||||
admin_users_pkey: public.admin_users USING btree (id)
|
||||
admin_users_username_key: public.admin_users USING btree (username)
|
||||
|
||||
indexes on api_keys:
|
||||
api_keys_pkey: public.api_keys USING btree (id)
|
||||
api_keys_prefix_idx: public.api_keys USING btree (prefix)
|
||||
api_keys_user_id_idx: public.api_keys USING btree (user_id)
|
||||
|
||||
indexes on app_domains:
|
||||
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
||||
app_domains_pkey: public.app_domains USING btree (id)
|
||||
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
||||
|
||||
indexes on app_members:
|
||||
app_members_pkey: public.app_members USING btree (app_id, user_id)
|
||||
app_members_user_id_idx: public.app_members USING btree (user_id)
|
||||
|
||||
indexes on app_slug_history:
|
||||
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||
|
||||
indexes on apps:
|
||||
apps_pkey: public.apps USING btree (id)
|
||||
apps_slug_key: public.apps USING btree (slug)
|
||||
|
||||
indexes on execution_logs:
|
||||
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||
|
||||
indexes on routes:
|
||||
routes_app_id_idx: public.routes USING btree (app_id)
|
||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||
routes_pkey: public.routes USING btree (id)
|
||||
routes_script_id_idx: public.routes USING btree (script_id)
|
||||
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
|
||||
indexes on scripts:
|
||||
scripts_name_uidx: public.scripts USING btree (lower(name))
|
||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||
scripts_pkey: public.scripts USING btree (id)
|
||||
|
||||
## constraints
|
||||
|
||||
constraints on admin_sessions:
|
||||
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||
|
||||
constraints on admin_users:
|
||||
[CHECK] admin_users_instance_role_check: CHECK ((instance_role = ANY (ARRAY['owner'::text, 'admin'::text, 'member'::text])))
|
||||
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] admin_users_email_key: UNIQUE (email)
|
||||
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||
|
||||
constraints on api_keys:
|
||||
[FOREIGN KEY] api_keys_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] api_keys_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] api_keys_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on app_domains:
|
||||
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
||||
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
||||
|
||||
constraints on app_members:
|
||||
[CHECK] app_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||
[FOREIGN KEY] app_members_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] app_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_members_pkey: PRIMARY KEY (app_id, user_id)
|
||||
|
||||
constraints on app_slug_history:
|
||||
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||
|
||||
constraints on apps:
|
||||
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||
|
||||
constraints on execution_logs:
|
||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on routes:
|
||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on scripts:
|
||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||
|
||||
## applied migrations
|
||||
0001: init
|
||||
0002: sandbox
|
||||
0003: routes
|
||||
0004: admin auth
|
||||
0005: apps
|
||||
0006: users authz
|
||||
|
||||
@@ -12,28 +12,43 @@ use axum::{
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Json, Router,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_shared::{
|
||||
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||
AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus,
|
||||
HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox, OutboxWriter, Principal,
|
||||
RequestId, ScriptId,
|
||||
};
|
||||
use serde_json::Value as Json_;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::ExecutorClient;
|
||||
use crate::inbox::InboxRegistry;
|
||||
use crate::resolver::{ResolverError, ScriptResolver};
|
||||
use crate::routing::RouteTable;
|
||||
use crate::routing::{AppDomainTable, RouteTable};
|
||||
|
||||
/// State shared by data-plane handlers.
|
||||
pub struct DataPlaneState<E, R> {
|
||||
pub executor: Arc<E>,
|
||||
pub resolver: Arc<R>,
|
||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||
/// Routing table for user-defined paths. Shared with the manager
|
||||
/// (admin router writes; this side reads).
|
||||
/// Host → app_id resolver. Run before `routes` to filter to the
|
||||
/// owning app's slice. Shared with the manager (writes invalidate
|
||||
/// the cache by replacing the table).
|
||||
pub app_domains: Arc<AppDomainTable>,
|
||||
/// Routing table for user-defined paths, partitioned per app.
|
||||
/// Shared with the manager (admin router writes; this side reads).
|
||||
pub routes: Arc<RouteTable>,
|
||||
/// NATS-style inbox registry (v1.1.1). Used by sync HTTP via
|
||||
/// outbox to await the dispatcher's delivery on a oneshot
|
||||
/// channel.
|
||||
pub inbox: Arc<InboxRegistry>,
|
||||
/// Writer for the universal trigger outbox (v1.1.1). The sync
|
||||
/// HTTP path inserts a row with `reply_to = inbox_id`; the async
|
||||
/// path inserts with `reply_to = None` and returns 202.
|
||||
pub outbox: Arc<dyn OutboxWriter>,
|
||||
}
|
||||
|
||||
impl<E, R> Clone for DataPlaneState<E, R> {
|
||||
@@ -42,13 +57,21 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
||||
executor: self.executor.clone(),
|
||||
resolver: self.resolver.clone(),
|
||||
log_sink: self.log_sink.clone(),
|
||||
app_domains: self.app_domains.clone(),
|
||||
routes: self.routes.clone(),
|
||||
inbox: self.inbox.clone(),
|
||||
outbox: self.outbox.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the data-plane router. Handles `POST /execute/:id` — the
|
||||
/// always-available ID-based bypass.
|
||||
///
|
||||
/// Handlers expect an `Extension<Option<Principal>>` to be attached by
|
||||
/// upstream middleware (`manager-core::attach_principal_if_present`);
|
||||
/// requests without that extension panic at extraction time. The
|
||||
/// picloud binary wires this in `build_app`.
|
||||
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||
where
|
||||
E: ExecutorClient + 'static,
|
||||
@@ -62,6 +85,10 @@ where
|
||||
/// Build a router that handles ALL paths via the user-defined routing
|
||||
/// table. Intended to be merged into the picloud app router as a
|
||||
/// fallback (after the system routes are mounted).
|
||||
///
|
||||
/// Same middleware expectation as `data_plane_router` — wrap with
|
||||
/// `attach_principal_if_present` so handlers can extract
|
||||
/// `Extension<Option<Principal>>`.
|
||||
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||
where
|
||||
E: ExecutorClient + 'static,
|
||||
@@ -79,6 +106,7 @@ where
|
||||
async fn execute_by_id<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
Path(id): Path<ScriptId>,
|
||||
Extension(principal): Extension<Option<Principal>>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response, ApiError>
|
||||
@@ -92,7 +120,7 @@ where
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound(id))?;
|
||||
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body)?;
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
|
||||
req.sandbox_overrides = script.sandbox;
|
||||
let request_id = req.request_id;
|
||||
let request_path = req.path.clone();
|
||||
@@ -109,6 +137,7 @@ where
|
||||
// audit-visible platform — but a sink failure must not mask the
|
||||
// user-facing result, so we only log a warning if it fails.
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -127,6 +156,7 @@ where
|
||||
|
||||
async fn user_route_handler<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
Extension(principal): Extension<Option<Principal>>,
|
||||
request: Request,
|
||||
) -> Result<Response, ApiError>
|
||||
where
|
||||
@@ -145,7 +175,23 @@ where
|
||||
.to_string();
|
||||
let headers = request.headers().clone();
|
||||
|
||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
||||
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
|
||||
// then run the existing matcher on that app's slice. No app claims
|
||||
// this host → flat 404; the path doesn't get the chance to fire.
|
||||
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("no app claims host {host:?}")
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
let Some(matched) = state
|
||||
.routes
|
||||
.match_request_for_app(app_id, &host, &method, &path)
|
||||
else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
@@ -168,47 +214,312 @@ where
|
||||
Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))),
|
||||
};
|
||||
|
||||
let mut req = build_exec_request(
|
||||
matched.matched.script_id,
|
||||
&script.name,
|
||||
&headers,
|
||||
&body_bytes,
|
||||
)?;
|
||||
req.path = path;
|
||||
req.params = matched.params;
|
||||
req.query = parse_query_string(&query_str);
|
||||
req.rest = matched.rest.unwrap_or_default();
|
||||
req.sandbox_overrides = script.sandbox;
|
||||
let body_json: Json_ = if body_bytes.is_empty() {
|
||||
Json_::Null
|
||||
} else {
|
||||
serde_json::from_slice(&body_bytes)
|
||||
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
|
||||
};
|
||||
let header_map: BTreeMap<String, String> = headers
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
v.to_str()
|
||||
.ok()
|
||||
.map(|s| (k.as_str().to_string(), s.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let query = parse_query_string(&query_str);
|
||||
let rest = matched.rest.clone().unwrap_or_default();
|
||||
|
||||
let request_id = req.request_id;
|
||||
let request_path = req.path.clone();
|
||||
let request_headers = req.headers.clone();
|
||||
let request_body = req.body.clone();
|
||||
match matched.matched.dispatch_mode {
|
||||
DispatchMode::Async => {
|
||||
handle_async_route(
|
||||
&state,
|
||||
app_id,
|
||||
matched.matched.route_id,
|
||||
matched.matched.script_id,
|
||||
&script.name,
|
||||
path,
|
||||
method,
|
||||
header_map,
|
||||
body_json,
|
||||
matched.params,
|
||||
query,
|
||||
rest,
|
||||
script.timeout_seconds,
|
||||
principal,
|
||||
)
|
||||
.await
|
||||
}
|
||||
DispatchMode::Sync => {
|
||||
handle_sync_route(
|
||||
&state,
|
||||
app_id,
|
||||
matched.matched.route_id,
|
||||
matched.matched.script_id,
|
||||
&script.name,
|
||||
path,
|
||||
method,
|
||||
header_map,
|
||||
body_json,
|
||||
matched.params,
|
||||
query,
|
||||
rest,
|
||||
script.timeout_seconds,
|
||||
principal,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let timeout = Duration::from_secs(u64::from(script.timeout_seconds));
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_async_route<E, R>(
|
||||
state: &DataPlaneState<E, R>,
|
||||
app_id: AppId,
|
||||
route_id: Uuid,
|
||||
script_id: ScriptId,
|
||||
script_name: &str,
|
||||
path: String,
|
||||
method: String,
|
||||
headers: BTreeMap<String, String>,
|
||||
body: Json_,
|
||||
params: BTreeMap<String, String>,
|
||||
query: BTreeMap<String, String>,
|
||||
rest: String,
|
||||
timeout_seconds: u32,
|
||||
principal: Option<Principal>,
|
||||
) -> Result<Response, ApiError>
|
||||
where
|
||||
E: ExecutorClient + 'static,
|
||||
R: ScriptResolver + 'static,
|
||||
{
|
||||
let payload = HttpDispatchPayload {
|
||||
script_name: script_name.to_string(),
|
||||
path,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
params,
|
||||
query,
|
||||
rest,
|
||||
timeout_seconds,
|
||||
};
|
||||
let payload_value = serde_json::to_value(&payload)
|
||||
.map_err(|e| ApiError::BadRequest(format!("payload serialize: {e}")))?;
|
||||
let execution_id = ExecutionId::new();
|
||||
state
|
||||
.outbox
|
||||
.enqueue_http(NewHttpOutbox {
|
||||
app_id,
|
||||
route_id,
|
||||
script_id,
|
||||
reply_to: None,
|
||||
payload: payload_value,
|
||||
origin_principal: principal.map(|p| p.user_id),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: Some(execution_id),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ApiError::OutboxWrite(e.to_string()))?;
|
||||
Ok((
|
||||
StatusCode::ACCEPTED,
|
||||
Json(serde_json::json!({
|
||||
"accepted_at": Utc::now().to_rfc3339(),
|
||||
"execution_id": execution_id.to_string(),
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_sync_route<E, R>(
|
||||
state: &DataPlaneState<E, R>,
|
||||
app_id: AppId,
|
||||
route_id: Uuid,
|
||||
script_id: ScriptId,
|
||||
script_name: &str,
|
||||
path: String,
|
||||
method: String,
|
||||
headers: BTreeMap<String, String>,
|
||||
body: Json_,
|
||||
params: BTreeMap<String, String>,
|
||||
query: BTreeMap<String, String>,
|
||||
rest: String,
|
||||
timeout_seconds: u32,
|
||||
principal: Option<Principal>,
|
||||
) -> Result<Response, ApiError>
|
||||
where
|
||||
E: ExecutorClient + 'static,
|
||||
R: ScriptResolver + 'static,
|
||||
{
|
||||
let payload = HttpDispatchPayload {
|
||||
script_name: script_name.to_string(),
|
||||
path: path.clone(),
|
||||
method,
|
||||
headers: headers.clone(),
|
||||
body: body.clone(),
|
||||
params,
|
||||
query,
|
||||
rest,
|
||||
timeout_seconds,
|
||||
};
|
||||
let payload_value = serde_json::to_value(&payload)
|
||||
.map_err(|e| ApiError::BadRequest(format!("payload serialize: {e}")))?;
|
||||
|
||||
// Register the inbox before writing the outbox row so the
|
||||
// dispatcher can't race-deliver before the orchestrator is
|
||||
// listening.
|
||||
let (inbox_id, rx) = state.inbox.register();
|
||||
|
||||
let execution_id = ExecutionId::new();
|
||||
let outbox_id = state
|
||||
.outbox
|
||||
.enqueue_http(NewHttpOutbox {
|
||||
app_id,
|
||||
route_id,
|
||||
script_id,
|
||||
reply_to: Some(inbox_id),
|
||||
payload: payload_value,
|
||||
origin_principal: principal.map(|p| p.user_id),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: Some(execution_id),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| {
|
||||
// Failed outbox write — abandon the inbox so the dispatcher
|
||||
// can never deliver to a stale entry.
|
||||
state.inbox.cancel(inbox_id);
|
||||
ApiError::OutboxWrite(e.to_string())
|
||||
})?;
|
||||
|
||||
// Wait for the dispatcher's delivery. Outer timeout = script
|
||||
// wall-clock + a small buffer to cover dispatcher latency.
|
||||
let wait_budget = Duration::from_secs(u64::from(timeout_seconds)) + Duration::from_secs(2);
|
||||
let request_id = RequestId::new();
|
||||
let started = Utc::now();
|
||||
let outcome = state.executor.execute(&script.source, req, timeout).await;
|
||||
let result = tokio::time::timeout(wait_budget, rx).await;
|
||||
let finished = Utc::now();
|
||||
|
||||
let log = build_execution_log(
|
||||
matched.matched.script_id,
|
||||
// Tear down the receiver if it's still alive. `inbox.cancel` is a
|
||||
// no-op when the dispatcher already delivered.
|
||||
let _ = state.inbox.cancel(inbox_id);
|
||||
|
||||
let response = match result {
|
||||
Ok(Ok(InboxResult::Success(summary))) => http_response_from_summary(summary),
|
||||
Ok(Ok(InboxResult::Failure { kind, message })) => failure_to_response(kind, &message),
|
||||
Ok(Err(_recv)) => {
|
||||
// Channel was closed without a value — dispatcher dropped
|
||||
// the sender. Treat as platform failure.
|
||||
tracing::warn!(
|
||||
outbox_id = %outbox_id,
|
||||
"inbox channel closed without delivery"
|
||||
);
|
||||
failure_to_response(
|
||||
InboxFailureKind::Platform,
|
||||
"dispatcher closed inbox without delivery",
|
||||
)
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
// Outer timeout — either the script was too slow or the
|
||||
// dispatcher is wedged. Returns 504 by default.
|
||||
failure_to_response(InboxFailureKind::Timeout, "request timed out")
|
||||
}
|
||||
};
|
||||
|
||||
let log = build_inbox_execution_log(
|
||||
app_id,
|
||||
script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
request_headers,
|
||||
request_body,
|
||||
&outcome,
|
||||
path,
|
||||
headers,
|
||||
body,
|
||||
response.status().as_u16(),
|
||||
started,
|
||||
finished,
|
||||
);
|
||||
if let Err(e) = state.log_sink.record(log).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
script_id = %matched.matched.script_id,
|
||||
%script_id,
|
||||
"failed to persist execution log"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(exec_response_to_http(outcome?))
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn http_response_from_summary(summary: picloud_shared::ExecResponseSummary) -> Response {
|
||||
let status =
|
||||
StatusCode::from_u16(summary.status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
let mut http_headers = HeaderMap::new();
|
||||
for (k, v) in summary.headers {
|
||||
if let (Ok(name), Ok(value)) = (k.parse::<HeaderName>(), v.parse::<HeaderValue>()) {
|
||||
http_headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
http_headers
|
||||
.entry(axum::http::header::CONTENT_TYPE)
|
||||
.or_insert_with(|| HeaderValue::from_static("application/json"));
|
||||
(status, http_headers, Json(summary.body)).into_response()
|
||||
}
|
||||
|
||||
/// Map `InboxFailureKind` onto the design-notes §3 status-code table.
|
||||
fn failure_to_response(kind: InboxFailureKind, message: &str) -> Response {
|
||||
let status = match kind {
|
||||
InboxFailureKind::Validation => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
InboxFailureKind::Runtime => StatusCode::BAD_GATEWAY,
|
||||
InboxFailureKind::Overloaded => StatusCode::SERVICE_UNAVAILABLE,
|
||||
InboxFailureKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
|
||||
InboxFailureKind::OperationBudget => StatusCode::INSUFFICIENT_STORAGE,
|
||||
InboxFailureKind::Platform => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
let body = Json(serde_json::json!({ "error": message }));
|
||||
if matches!(kind, InboxFailureKind::Overloaded) {
|
||||
return (status, [(axum::http::header::RETRY_AFTER, "1")], body).into_response();
|
||||
}
|
||||
(status, body).into_response()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_inbox_execution_log(
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
request_id: RequestId,
|
||||
request_path: String,
|
||||
request_headers: BTreeMap<String, String>,
|
||||
request_body: Json_,
|
||||
response_code: u16,
|
||||
started: chrono::DateTime<Utc>,
|
||||
finished: chrono::DateTime<Utc>,
|
||||
) -> ExecutionLog {
|
||||
let duration_ms = u64::try_from(
|
||||
finished
|
||||
.signed_duration_since(started)
|
||||
.num_milliseconds()
|
||||
.max(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let status = if (200..400).contains(&response_code) {
|
||||
ExecutionStatus::Success
|
||||
} else {
|
||||
ExecutionStatus::Error
|
||||
};
|
||||
ExecutionLog {
|
||||
id: Uuid::new_v4(),
|
||||
app_id,
|
||||
script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
request_headers,
|
||||
request_body,
|
||||
response_code: Some(response_code),
|
||||
response_body: None,
|
||||
script_logs: Json_::Array(vec![]),
|
||||
duration_ms,
|
||||
status,
|
||||
created_at: started,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_query_string(s: &str) -> BTreeMap<String, String> {
|
||||
@@ -241,6 +552,8 @@ fn build_exec_request(
|
||||
name: &str,
|
||||
headers: &HeaderMap,
|
||||
body: &Bytes,
|
||||
app_id: AppId,
|
||||
principal: Option<Principal>,
|
||||
) -> Result<ExecRequest, ApiError> {
|
||||
let mut hmap = BTreeMap::new();
|
||||
for (k, v) in headers {
|
||||
@@ -256,8 +569,9 @@ fn build_exec_request(
|
||||
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
|
||||
};
|
||||
|
||||
let execution_id = ExecutionId::new();
|
||||
Ok(ExecRequest {
|
||||
execution_id: ExecutionId::new(),
|
||||
execution_id,
|
||||
request_id: RequestId::new(),
|
||||
script_id: id,
|
||||
script_name: name.to_string(),
|
||||
@@ -270,6 +584,18 @@ fn build_exec_request(
|
||||
rest: String::new(),
|
||||
// Overwritten by the handler after the script is resolved.
|
||||
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||
app_id,
|
||||
principal,
|
||||
// Direct invocations are at depth 0 with a self-referential
|
||||
// root. The triggers framework (v1.1.1) increments depth and
|
||||
// preserves the original root for chained executions.
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
// Direct invocations are never DL handlers — that flag is only
|
||||
// set by the dispatcher when it picks a dead_letter trigger row.
|
||||
is_dead_letter_handler: false,
|
||||
// No originating trigger event for direct ingress.
|
||||
event: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -292,6 +618,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_execution_log(
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
request_id: RequestId,
|
||||
request_path: String,
|
||||
@@ -336,6 +663,7 @@ fn build_execution_log(
|
||||
|
||||
ExecutionLog {
|
||||
id: Uuid::new_v4(),
|
||||
app_id,
|
||||
script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -367,14 +695,39 @@ pub enum ApiError {
|
||||
|
||||
#[error("execution error: {0}")]
|
||||
Exec(#[from] ExecError),
|
||||
|
||||
#[error("outbox write failed: {0}")]
|
||||
OutboxWrite(String),
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
// Overloaded is the only variant that needs to attach an HTTP
|
||||
// header (Retry-After), so it short-circuits the (status, body)
|
||||
// reduction below. Axum's tuple builder makes per-arm header
|
||||
// injection awkward otherwise.
|
||||
use ApiError as E;
|
||||
if let E::Exec(ExecError::Overloaded { retry_after_secs }) = &self {
|
||||
let retry = retry_after_secs.to_string();
|
||||
let body = Json(serde_json::json!({ "error": self.to_string() }));
|
||||
return (
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
[(axum::http::header::RETRY_AFTER, retry)],
|
||||
body,
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let (status, message) = match &self {
|
||||
E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
|
||||
E::OutboxWrite(e) => {
|
||||
tracing::error!(error = %e, "outbox write failed");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"internal error".to_string(),
|
||||
)
|
||||
}
|
||||
E::Resolver(e) => {
|
||||
tracing::error!(error = %e, "resolver failure");
|
||||
(
|
||||
@@ -391,6 +744,7 @@ impl IntoResponse for ApiError {
|
||||
(StatusCode::INSUFFICIENT_STORAGE, e.to_string())
|
||||
}
|
||||
ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()),
|
||||
ExecError::Overloaded { .. } => unreachable!("handled above"),
|
||||
},
|
||||
};
|
||||
(status, Json(serde_json::json!({ "error": message }))).into_response()
|
||||
|
||||
@@ -4,6 +4,8 @@ use std::time::Duration;
|
||||
use async_trait::async_trait;
|
||||
use picloud_executor_core::{Engine, ExecError, ExecRequest, ExecResponse};
|
||||
|
||||
use crate::gate::{AcquireError, ExecutionGate};
|
||||
|
||||
/// Maximum wall-clock time we'll wait for a single invocation, regardless
|
||||
/// of the per-script `timeout_seconds`. Provides a hard ceiling on
|
||||
/// resource usage independent of misconfigured scripts.
|
||||
@@ -30,14 +32,19 @@ pub trait ExecutorClient: Send + Sync {
|
||||
/// `executor-core::Engine::execute` is synchronous; we offload it to a
|
||||
/// blocking thread so it doesn't park a Tokio worker, and apply the
|
||||
/// wall-clock timeout here.
|
||||
///
|
||||
/// Holds an `ExecutionGate` and acquires a permit before `spawn_blocking`
|
||||
/// so a script storm can't drain the blocking-thread pool. The permit
|
||||
/// drops with the future, returning the slot.
|
||||
pub struct LocalExecutorClient {
|
||||
engine: Arc<Engine>,
|
||||
gate: Arc<ExecutionGate>,
|
||||
}
|
||||
|
||||
impl LocalExecutorClient {
|
||||
#[must_use]
|
||||
pub fn new(engine: Arc<Engine>) -> Self {
|
||||
Self { engine }
|
||||
pub fn new(engine: Arc<Engine>, gate: Arc<ExecutionGate>) -> Self {
|
||||
Self { engine, gate }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +56,24 @@ impl ExecutorClient for LocalExecutorClient {
|
||||
req: ExecRequest,
|
||||
timeout: Duration,
|
||||
) -> Result<ExecResponse, ExecError> {
|
||||
// Acquire before spending any wall-clock budget. The permit is
|
||||
// held by this future; on `tokio::time::timeout` firing, the
|
||||
// future drops and the permit returns to the pool — but the
|
||||
// detached `spawn_blocking` thread keeps running until the
|
||||
// Rhai script finishes (or panics). So in-use blocking threads
|
||||
// can briefly exceed the gate's permit count after a timeout.
|
||||
// That is intentional: a new admission can be served while the
|
||||
// already-doomed script winds down, which is preferable to
|
||||
// wedging the slot for the worst-case timeout duration.
|
||||
let _permit =
|
||||
self.gate
|
||||
.try_acquire()
|
||||
.map_err(
|
||||
|AcquireError::Overloaded { retry_after_secs }| ExecError::Overloaded {
|
||||
retry_after_secs,
|
||||
},
|
||||
)?;
|
||||
|
||||
let timeout = timeout.min(HARD_TIMEOUT_CAP);
|
||||
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);
|
||||
|
||||
|
||||
155
crates/orchestrator-core/src/gate.rs
Normal file
155
crates/orchestrator-core/src/gate.rs
Normal file
@@ -0,0 +1,155 @@
|
||||
//! Global concurrency gate for the data plane.
|
||||
//!
|
||||
//! Wraps a single `tokio::sync::Semaphore` so the executor can refuse
|
||||
//! admission immediately when too many invocations are already in
|
||||
//! flight. Designed for v1.1.0's single-node MVP — one cap across all
|
||||
//! apps and scripts. Per-app or per-script caps come later when a real
|
||||
//! workload surfaces the need.
|
||||
//!
|
||||
//! Policy: **non-blocking, no queue**. If a permit isn't free right
|
||||
//! now, the call returns `AcquireError::Overloaded` and the data-plane
|
||||
//! HTTP layer translates that to a 503 with `Retry-After: 1`. Pushing
|
||||
//! back hard beats letting requests pile up against a finite pool of
|
||||
//! blocking threads (executor work runs under `spawn_blocking`).
|
||||
//!
|
||||
//! Configured via the `PICLOUD_MAX_CONCURRENT_EXECUTIONS` env var.
|
||||
//! Default is 32 — comfortable for a single-node Pi, low enough that
|
||||
//! a script storm doesn't park every blocking thread.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use thiserror::Error;
|
||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
|
||||
|
||||
/// Env var consulted by `from_env`.
|
||||
pub const ENV_MAX_CONCURRENT: &str = "PICLOUD_MAX_CONCURRENT_EXECUTIONS";
|
||||
|
||||
/// Default cap when the env var is unset or invalid.
|
||||
pub const DEFAULT_MAX_CONCURRENT: u32 = 32;
|
||||
|
||||
/// `Retry-After` header value (seconds) returned alongside the 503
|
||||
/// when the gate refuses. Fixed for v1.1.0; later versions may compute
|
||||
/// a smarter value from in-flight latency.
|
||||
pub const DEFAULT_RETRY_AFTER_SECS: u32 = 1;
|
||||
|
||||
/// Refused admission. The HTTP layer translates this to 503 with a
|
||||
/// `Retry-After` header.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum AcquireError {
|
||||
#[error("at capacity (retry after {retry_after_secs}s)")]
|
||||
Overloaded { retry_after_secs: u32 },
|
||||
}
|
||||
|
||||
/// Global execution gate. Constructed once at orchestrator startup and
|
||||
/// shared via `Arc`. Holds an inner `Arc<Semaphore>` so permits are
|
||||
/// owned (they release on drop independent of the gate's lifetime).
|
||||
pub struct ExecutionGate {
|
||||
permits: Arc<Semaphore>,
|
||||
max_permits: u32,
|
||||
}
|
||||
|
||||
impl ExecutionGate {
|
||||
/// Construct with an explicit cap. Mostly for tests; production
|
||||
/// uses `from_env`.
|
||||
#[must_use]
|
||||
pub fn new(max_permits: u32) -> Self {
|
||||
Self {
|
||||
permits: Arc::new(Semaphore::new(max_permits as usize)),
|
||||
max_permits,
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `PICLOUD_MAX_CONCURRENT_EXECUTIONS` from the environment.
|
||||
/// Falls back to `DEFAULT_MAX_CONCURRENT` on absence; warns and
|
||||
/// falls back on parse failure or non-positive value. Mirrors the
|
||||
/// `SandboxCeiling::from_env` ergonomics so operators see a
|
||||
/// consistent shape across the env-tunables.
|
||||
#[must_use]
|
||||
pub fn from_env() -> Self {
|
||||
let max = match std::env::var(ENV_MAX_CONCURRENT) {
|
||||
Err(_) => DEFAULT_MAX_CONCURRENT,
|
||||
Ok(v) => match v.parse::<u32>() {
|
||||
Ok(n) if n > 0 => n,
|
||||
Ok(_) => {
|
||||
tracing::warn!(
|
||||
env = ENV_MAX_CONCURRENT,
|
||||
value = %v,
|
||||
"value must be > 0; using default {DEFAULT_MAX_CONCURRENT}"
|
||||
);
|
||||
DEFAULT_MAX_CONCURRENT
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
env = ENV_MAX_CONCURRENT,
|
||||
value = %v,
|
||||
error = %e,
|
||||
"invalid value; using default {DEFAULT_MAX_CONCURRENT}"
|
||||
);
|
||||
DEFAULT_MAX_CONCURRENT
|
||||
}
|
||||
},
|
||||
};
|
||||
Self::new(max)
|
||||
}
|
||||
|
||||
/// Maximum concurrent permits this gate was configured for. Useful
|
||||
/// for diagnostics / future metrics.
|
||||
#[must_use]
|
||||
pub fn max_permits(&self) -> u32 {
|
||||
self.max_permits
|
||||
}
|
||||
|
||||
/// Non-blocking permit acquisition. Returns the owned permit on
|
||||
/// success (drop releases the slot) or `AcquireError::Overloaded`
|
||||
/// when saturated. Sync because the semaphore's non-blocking try is
|
||||
/// sync — no runtime hop needed.
|
||||
pub fn try_acquire(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
|
||||
self.permits
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|err| match err {
|
||||
TryAcquireError::NoPermits | TryAcquireError::Closed => AcquireError::Overloaded {
|
||||
retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn acquire_succeeds_under_capacity() {
|
||||
let gate = ExecutionGate::new(2);
|
||||
let _p1 = gate.try_acquire().expect("first permit available");
|
||||
let _p2 = gate.try_acquire().expect("second permit available");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acquire_overloaded_when_saturated() {
|
||||
let gate = ExecutionGate::new(1);
|
||||
let _p = gate.try_acquire().expect("first permit available");
|
||||
let AcquireError::Overloaded { retry_after_secs } = gate
|
||||
.try_acquire()
|
||||
.expect_err("second permit must be refused");
|
||||
assert!(retry_after_secs > 0, "retry-after must be positive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permit_drop_releases_slot() {
|
||||
let gate = ExecutionGate::new(1);
|
||||
{
|
||||
let _p = gate.try_acquire().expect("first permit available");
|
||||
}
|
||||
let _ = gate
|
||||
.try_acquire()
|
||||
.expect("slot must be returned after permit drops");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_permits_exposed() {
|
||||
let gate = ExecutionGate::new(7);
|
||||
assert_eq!(gate.max_permits(), 7);
|
||||
}
|
||||
}
|
||||
139
crates/orchestrator-core/src/inbox.rs
Normal file
139
crates/orchestrator-core/src/inbox.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
//! In-process `InboxRegistry` — the NATS-style request/reply
|
||||
//! implementation for sync HTTP via the trigger outbox (design notes
|
||||
//! §3).
|
||||
//!
|
||||
//! Workflow:
|
||||
//! 1. Orchestrator allocates an `inbox_id`, calls
|
||||
//! `registry.register()` to get a oneshot receiver.
|
||||
//! 2. Orchestrator writes an outbox row with `reply_to = inbox_id`.
|
||||
//! 3. Dispatcher picks the row, runs the script, calls
|
||||
//! `registry.deliver(inbox_id, result)`.
|
||||
//! 4. Orchestrator's `.await` on the receiver fires; it maps the
|
||||
//! `InboxResult` back into an HTTP response.
|
||||
//!
|
||||
//! `Delivered` means the receiver was alive when delivery hit. If the
|
||||
//! orchestrator timed out and dropped the receiver before delivery,
|
||||
//! `Abandoned` comes back — the dispatcher writes an
|
||||
//! `abandoned_executions` row (design notes §3 #9).
|
||||
//!
|
||||
//! Cluster mode (v1.3+) swaps this for a Postgres `LISTEN/NOTIFY`-
|
||||
//! based resolver; the `InboxResolver` trait stays the same.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{InboxDeliveryOutcome, InboxResolver, InboxResult};
|
||||
use tokio::sync::oneshot;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct InboxRegistry {
|
||||
inner: Mutex<HashMap<Uuid, oneshot::Sender<InboxResult>>>,
|
||||
}
|
||||
|
||||
impl InboxRegistry {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate a new inbox id and register the sender side. The
|
||||
/// caller awaits the returned `Receiver`; the dispatcher delivers
|
||||
/// the outcome via `deliver(id, …)`.
|
||||
#[must_use]
|
||||
pub fn register(&self) -> (Uuid, oneshot::Receiver<InboxResult>) {
|
||||
let id = Uuid::new_v4();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
if let Ok(mut g) = self.inner.lock() {
|
||||
g.insert(id, tx);
|
||||
}
|
||||
(id, rx)
|
||||
}
|
||||
|
||||
/// Cancel a pending inbox (orchestrator timed out and gave up).
|
||||
/// Drops the sender so any future `deliver` returns `Abandoned`.
|
||||
/// Returns `true` if the receiver was still registered.
|
||||
pub fn cancel(&self, id: Uuid) -> bool {
|
||||
self.inner
|
||||
.lock()
|
||||
.map(|mut g| g.remove(&id).is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InboxRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InboxResolver for InboxRegistry {
|
||||
async fn deliver(&self, inbox_id: Uuid, result: InboxResult) -> InboxDeliveryOutcome {
|
||||
let Ok(mut g) = self.inner.lock() else {
|
||||
return InboxDeliveryOutcome::Abandoned;
|
||||
};
|
||||
let Some(tx) = g.remove(&inbox_id) else {
|
||||
return InboxDeliveryOutcome::Abandoned;
|
||||
};
|
||||
// `send` returns Err iff the receiver was dropped — exactly
|
||||
// the abandoned-execution case.
|
||||
if tx.send(result).is_err() {
|
||||
InboxDeliveryOutcome::Abandoned
|
||||
} else {
|
||||
InboxDeliveryOutcome::Delivered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use picloud_shared::ExecResponseSummary;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn ok_result() -> InboxResult {
|
||||
InboxResult::Success(ExecResponseSummary {
|
||||
status_code: 200,
|
||||
headers: BTreeMap::new(),
|
||||
body: serde_json::json!({ "ok": true }),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_then_deliver_resolves_receiver() {
|
||||
let reg = InboxRegistry::new();
|
||||
let (id, rx) = reg.register();
|
||||
let outcome = reg.deliver(id, ok_result()).await;
|
||||
assert_eq!(outcome, InboxDeliveryOutcome::Delivered);
|
||||
let received = rx.await.expect("receiver should fire");
|
||||
assert!(matches!(received, InboxResult::Success(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deliver_to_unknown_id_is_abandoned() {
|
||||
let reg = InboxRegistry::new();
|
||||
let outcome = reg.deliver(Uuid::new_v4(), ok_result()).await;
|
||||
assert_eq!(outcome, InboxDeliveryOutcome::Abandoned);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dropping_receiver_then_delivering_is_abandoned() {
|
||||
let reg = InboxRegistry::new();
|
||||
let (id, rx) = reg.register();
|
||||
drop(rx);
|
||||
let outcome = reg.deliver(id, ok_result()).await;
|
||||
assert_eq!(outcome, InboxDeliveryOutcome::Abandoned);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancel_removes_sender() {
|
||||
let reg = InboxRegistry::new();
|
||||
let (id, _rx) = reg.register();
|
||||
assert!(reg.cancel(id));
|
||||
let outcome = reg.deliver(id, ok_result()).await;
|
||||
assert_eq!(outcome, InboxDeliveryOutcome::Abandoned);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,13 @@
|
||||
|
||||
pub mod api;
|
||||
pub mod client;
|
||||
pub mod gate;
|
||||
pub mod inbox;
|
||||
pub mod resolver;
|
||||
pub mod routing;
|
||||
|
||||
pub use api::{data_plane_router, user_routes_router, DataPlaneState};
|
||||
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient};
|
||||
pub use gate::{AcquireError, ExecutionGate};
|
||||
pub use inbox::InboxRegistry;
|
||||
pub use resolver::{ResolverError, ScriptResolver};
|
||||
|
||||
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Host → app_id resolver. The first phase of the orchestrator's
|
||||
//! two-phase dispatch (the second phase is the per-app route matcher
|
||||
//! in `routing::table::RouteTable`).
|
||||
//!
|
||||
//! Cached in memory; the manager rebuilds the table after each
|
||||
//! domain-claim CRUD operation (same pattern as `RouteTable`).
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
use super::pattern::{HostPattern, HostSpecificity};
|
||||
|
||||
/// A parsed domain claim ready for runtime matching.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledAppDomain {
|
||||
pub app_id: AppId,
|
||||
pub pattern: HostPattern,
|
||||
pub shape_key: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppDomainTable {
|
||||
inner: RwLock<Vec<CompiledAppDomain>>,
|
||||
}
|
||||
|
||||
impl AppDomainTable {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Atomic full replacement; called at startup and after every
|
||||
/// domain CRUD operation.
|
||||
pub fn replace(&self, domains: Vec<CompiledAppDomain>) {
|
||||
let mut guard = self.inner.write().expect("app domain table poisoned");
|
||||
*guard = domains;
|
||||
}
|
||||
|
||||
/// Resolve a request's `Host` header to an `AppId`. Most-specific
|
||||
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
|
||||
/// `None` when no claim covers `host` (orchestrator should 404).
|
||||
#[must_use]
|
||||
pub fn resolve_app(&self, host: &str) -> Option<AppId> {
|
||||
let host = strip_port(host).to_ascii_lowercase();
|
||||
let guard = self.inner.read().expect("app domain table poisoned");
|
||||
let mut best: Option<(HostSpecificity, AppId)> = None;
|
||||
for claim in guard.iter() {
|
||||
if let Some(()) = host_matches(&claim.pattern, &host) {
|
||||
let s = claim.pattern.specificity();
|
||||
if best.is_none_or(|(prev, _)| s > prev) {
|
||||
best = Some((s, claim.app_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, app_id)| app_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CompiledAppDomain> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("app domain table poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_port(host: &str) -> &str {
|
||||
host.split(':').next().unwrap_or(host)
|
||||
}
|
||||
|
||||
fn host_matches(pattern: &HostPattern, host: &str) -> Option<()> {
|
||||
match pattern {
|
||||
HostPattern::Any => Some(()),
|
||||
HostPattern::Strict(s) => {
|
||||
if s.eq_ignore_ascii_case(host) {
|
||||
Some(())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
HostPattern::Wildcard { suffix, .. } => {
|
||||
let dotted = format!(".{}", suffix.to_ascii_lowercase());
|
||||
host.strip_suffix(&dotted)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::routing::pattern::parse_app_domain;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn id() -> AppId {
|
||||
AppId::from(Uuid::new_v4())
|
||||
}
|
||||
|
||||
fn compile(app_id: AppId, raw: &str) -> CompiledAppDomain {
|
||||
let d = parse_app_domain(raw).unwrap();
|
||||
CompiledAppDomain {
|
||||
app_id,
|
||||
pattern: d.pattern,
|
||||
shape_key: d.shape_key,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_exact_over_wildcard() {
|
||||
let app_a = id();
|
||||
let app_b = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![
|
||||
compile(app_a, "foo.example.com"),
|
||||
compile(app_b, "*.example.com"),
|
||||
]);
|
||||
assert_eq!(table.resolve_app("foo.example.com"), Some(app_a));
|
||||
assert_eq!(table.resolve_app("bar.example.com"), Some(app_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_wildcard_beats_shorter() {
|
||||
let inner = id();
|
||||
let outer = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![
|
||||
compile(inner, "*.api.example.com"),
|
||||
compile(outer, "*.example.com"),
|
||||
]);
|
||||
assert_eq!(
|
||||
table.resolve_app("v1.api.example.com"),
|
||||
Some(inner),
|
||||
"more-specific wildcard should win"
|
||||
);
|
||||
assert_eq!(table.resolve_app("v1.example.com"), Some(outer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameterized_resolves_like_wildcard() {
|
||||
let app = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![compile(app, "{tenant}.example.com")]);
|
||||
assert_eq!(table.resolve_app("acme.example.com"), Some(app));
|
||||
assert!(table.resolve_app("example.com").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_claim() {
|
||||
let app = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![compile(app, "foo.example.com")]);
|
||||
assert!(table.resolve_app("nope.com").is_none());
|
||||
assert!(table.resolve_app("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_port() {
|
||||
let app = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![compile(app, "localhost")]);
|
||||
assert_eq!(table.resolve_app("localhost:18080"), Some(app));
|
||||
}
|
||||
}
|
||||
@@ -38,16 +38,25 @@ pub struct MatchResult {
|
||||
pub struct Matched {
|
||||
pub route_id: uuid::Uuid,
|
||||
pub script_id: picloud_shared::ScriptId,
|
||||
/// Per-route dispatch mode (v1.1.1). Forwarded to the
|
||||
/// orchestrator's HTTP handler so it can pick the sync or async
|
||||
/// path. Defaults to `Sync` for older routes that predate the
|
||||
/// column.
|
||||
pub dispatch_mode: picloud_shared::DispatchMode,
|
||||
}
|
||||
|
||||
/// A single route ready for matching.
|
||||
/// A single route ready for matching. `app_id` is carried so the
|
||||
/// caller (the orchestrator's `AppRouteTables`) can partition the
|
||||
/// table; the matcher itself doesn't read it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledRoute {
|
||||
pub route_id: uuid::Uuid,
|
||||
pub app_id: picloud_shared::AppId,
|
||||
pub script_id: picloud_shared::ScriptId,
|
||||
pub host: HostPattern,
|
||||
pub path: PathPattern,
|
||||
pub method: Option<String>,
|
||||
pub dispatch_mode: picloud_shared::DispatchMode,
|
||||
}
|
||||
|
||||
/// Find the best matching route for the request. Returns `None` if no
|
||||
@@ -177,6 +186,7 @@ fn match_within_bucket(
|
||||
matched: Matched {
|
||||
route_id: route.route_id,
|
||||
script_id: route.script_id,
|
||||
dispatch_mode: route.dispatch_mode,
|
||||
},
|
||||
params: BTreeMap::new(),
|
||||
rest: None,
|
||||
@@ -227,6 +237,7 @@ fn match_within_bucket(
|
||||
matched: Matched {
|
||||
route_id: route.route_id,
|
||||
script_id: route.script_id,
|
||||
dispatch_mode: route.dispatch_mode,
|
||||
},
|
||||
params,
|
||||
rest,
|
||||
@@ -298,16 +309,18 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
|
||||
mod tests {
|
||||
use super::super::pattern::parse_path;
|
||||
use super::*;
|
||||
use picloud_shared::{PathKind, ScriptId};
|
||||
use picloud_shared::{AppId, PathKind, ScriptId};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||
CompiledRoute {
|
||||
route_id: Uuid::new_v4(),
|
||||
app_id: AppId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
host,
|
||||
path: parse_path(path_kind, raw).unwrap(),
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||
//! wildcard suffix breaks ties between wildcards.
|
||||
|
||||
pub mod app_domains;
|
||||
pub mod conflict;
|
||||
pub mod matcher;
|
||||
pub mod pattern;
|
||||
pub mod table;
|
||||
|
||||
pub use app_domains::{AppDomainTable, CompiledAppDomain};
|
||||
pub use conflict::{conflicts, ConflictReason};
|
||||
pub use matcher::{MatchResult, Matched};
|
||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
||||
pub use pattern::{
|
||||
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
|
||||
};
|
||||
pub use table::RouteTable;
|
||||
|
||||
@@ -251,6 +251,106 @@ pub fn parse_host(
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App-domain patterns
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
use picloud_shared::DomainShape;
|
||||
|
||||
/// Result of parsing a user-supplied app domain claim. Carries the
|
||||
/// host pattern (used at request time), the shape (used at write time
|
||||
/// for collision checks), and the normalized shape_key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedAppDomain {
|
||||
pub pattern: HostPattern,
|
||||
pub shape: DomainShape,
|
||||
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
|
||||
/// for both wildcard AND parameterized — they share a shape per
|
||||
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
|
||||
/// check").
|
||||
pub shape_key: String,
|
||||
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
|
||||
/// for `{tenant}.example.com`. Currently informational; the binding
|
||||
/// is surfaced into request context in a future iteration.
|
||||
pub binding: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a user-supplied app domain claim. Accepts:
|
||||
/// * `app.example.com` — exact host
|
||||
/// * `*.example.com` — wildcard suffix
|
||||
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
|
||||
///
|
||||
/// Distinct from `parse_host` (which is for route host fields): the
|
||||
/// route parser still rejects `{...}` syntax — see
|
||||
/// `ParseError::ReservedHostBraceSyntax`.
|
||||
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ParseError::EmptyHost);
|
||||
}
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
|
||||
// Wildcard: starts with "*."
|
||||
if let Some(suffix) = lowered.strip_prefix("*.") {
|
||||
if suffix.is_empty() {
|
||||
return Err(ParseError::EmptyWildcardSuffix);
|
||||
}
|
||||
return Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: None,
|
||||
},
|
||||
shape: DomainShape::Wildcard,
|
||||
shape_key: format!("wildcard:{suffix}"),
|
||||
binding: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Parameterized: starts with "{name}." where `name` is an ident.
|
||||
if let Some(stripped) = lowered.strip_prefix('{') {
|
||||
let (binding, rest) = stripped
|
||||
.split_once('}')
|
||||
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||
if binding.is_empty()
|
||||
|| !binding
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
|
||||
{
|
||||
return Err(ParseError::InvalidParamName(binding.to_string()));
|
||||
}
|
||||
let suffix = rest
|
||||
.strip_prefix('.')
|
||||
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
return Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: Some(binding.to_string()),
|
||||
},
|
||||
shape: DomainShape::Parameterized,
|
||||
// Same shape_key as the equivalent wildcard — parameter
|
||||
// name is a binding, not a discriminator.
|
||||
shape_key: format!("wildcard:{suffix}"),
|
||||
binding: Some(binding.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Anything else: exact host. Reject braces anywhere in the body
|
||||
// (they'd be a malformed parameterized form).
|
||||
if lowered.contains('{') || lowered.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Strict(lowered.clone()),
|
||||
shape: DomainShape::Exact,
|
||||
shape_key: format!("exact:{lowered}"),
|
||||
binding: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -393,6 +493,49 @@ mod tests {
|
||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_exact() {
|
||||
let d = parse_app_domain("App.Example.COM").unwrap();
|
||||
assert_eq!(d.shape, DomainShape::Exact);
|
||||
assert_eq!(d.shape_key, "exact:app.example.com");
|
||||
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
|
||||
assert!(d.binding.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
|
||||
let w = parse_app_domain("*.example.com").unwrap();
|
||||
let p = parse_app_domain("{tenant}.example.com").unwrap();
|
||||
assert_eq!(w.shape, DomainShape::Wildcard);
|
||||
assert_eq!(p.shape, DomainShape::Parameterized);
|
||||
// Same shape_key — they collide at claim time (blueprint §11.5).
|
||||
assert_eq!(w.shape_key, "wildcard:example.com");
|
||||
assert_eq!(p.shape_key, "wildcard:example.com");
|
||||
assert_eq!(p.binding.as_deref(), Some("tenant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_rejects_garbage() {
|
||||
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
|
||||
assert!(matches!(
|
||||
parse_app_domain("*."),
|
||||
Err(ParseError::EmptyWildcardSuffix)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_app_domain("{}.example.com"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_app_domain("{1tenant}.example.com"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
// Mid-host braces — disallowed.
|
||||
assert!(matches!(
|
||||
parse_app_domain("foo.{tenant}.example.com"),
|
||||
Err(ParseError::ReservedHostBraceSyntax)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_literal_count_works() {
|
||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
||||
//! and orchestrator (reads).
|
||||
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
|
||||
//!
|
||||
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
|
||||
//! read without contending against the writer; in MVP-single-process
|
||||
//! we just use `RwLock` and accept the cheap contention.
|
||||
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
|
||||
//! has resolved Host → app_id, then runs the existing matcher on that
|
||||
//! slice. The matcher is unchanged; this type is just a per-app bucket.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||
|
||||
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
|
||||
/// via `replace_all`); contention against readers is minimal so a plain
|
||||
/// `RwLock` is fine.
|
||||
#[derive(Default)]
|
||||
pub struct RouteTable {
|
||||
inner: RwLock<Vec<CompiledRoute>>,
|
||||
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
|
||||
}
|
||||
|
||||
impl RouteTable {
|
||||
@@ -20,24 +25,54 @@ impl RouteTable {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Replace the whole table atomically. The manager calls this after
|
||||
/// each successful route CRUD operation (by re-reading from DB).
|
||||
pub fn replace(&self, routes: Vec<CompiledRoute>) {
|
||||
/// Replace every per-app slice atomically. The manager calls this
|
||||
/// after each successful route CRUD operation; in cluster mode the
|
||||
/// orchestrator's HTTP-fed receiver will too.
|
||||
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
|
||||
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
|
||||
for r in routes {
|
||||
by_app.entry(r.app_id).or_default().push(r);
|
||||
}
|
||||
let mut guard = self.inner.write().expect("route table poisoned");
|
||||
*guard = routes;
|
||||
*guard = by_app;
|
||||
}
|
||||
|
||||
/// Dispatch a request to a matching route, or `None`.
|
||||
/// Dispatch a request to a matching route within `app_id`, or
|
||||
/// `None`. Returns `None` when the app has no routes at all.
|
||||
#[must_use]
|
||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
||||
pub fn match_request_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
) -> Option<MatchResult> {
|
||||
let guard = self.inner.read().expect("route table poisoned");
|
||||
r#match(guard.iter(), host, method, path)
|
||||
let slice = guard.get(&app_id)?;
|
||||
r#match(slice.iter(), host, method, path)
|
||||
}
|
||||
|
||||
/// Returns a clone of the currently compiled routes; intended for
|
||||
/// the dashboard's "list routes" admin endpoint.
|
||||
/// Returns a clone of the currently compiled routes for `app_id`;
|
||||
/// intended for admin endpoints like "list this app's routes".
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
||||
self.inner.read().expect("route table poisoned").clone()
|
||||
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("route table poisoned")
|
||||
.get(&app_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All compiled routes across all apps. Used by tests and the
|
||||
/// global admin "every route on this install" view.
|
||||
#[must_use]
|
||||
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("route table poisoned")
|
||||
.values()
|
||||
.flat_map(|v| v.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
42
crates/picloud-cli/Cargo.toml
Normal file
42
crates/picloud-cli/Cargo.toml
Normal file
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "picloud-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
authors.workspace = true
|
||||
description = "PiCloud command-line client"
|
||||
# Each top-level `tests/*.rs` would otherwise auto-discover as its own
|
||||
# test binary, respawning picloud once per file. We want one binary
|
||||
# with module sub-files (auth.rs, apps.rs, …) so the LazyLock fixture
|
||||
# is genuinely shared.
|
||||
autotests = false
|
||||
|
||||
[[bin]]
|
||||
name = "pic"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[test]]
|
||||
name = "cli"
|
||||
path = "tests/cli.rs"
|
||||
|
||||
[dependencies]
|
||||
picloud-shared.workspace = true
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
chrono = { workspace = true }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
directories = "5"
|
||||
rpassword = "7"
|
||||
anyhow = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
tempfile = "3"
|
||||
reqwest = { workspace = true, features = ["json", "blocking"] }
|
||||
libc = "0.2"
|
||||
501
crates/picloud-cli/src/client.rs
Normal file
501
crates/picloud-cli/src/client.rs
Normal file
@@ -0,0 +1,501 @@
|
||||
//! Reqwest-backed HTTP client + minimal wire DTOs.
|
||||
//!
|
||||
//! The CLI deliberately re-declares small request/response structs here
|
||||
//! rather than depending on `manager-core` (and pulling its Postgres
|
||||
//! transitive surface). Fields kept to what the CLI actually sends or
|
||||
//! reads.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script,
|
||||
};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::config::Credentials;
|
||||
|
||||
pub struct Client {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub fn from_creds(creds: &Credentials) -> Result<Self> {
|
||||
Self::new(&creds.url, &creds.token)
|
||||
}
|
||||
|
||||
pub fn new(url: &str, token: &str) -> Result<Self> {
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.context("building HTTP client")?;
|
||||
Ok(Self {
|
||||
http,
|
||||
url: url.trim_end_matches('/').to_string(),
|
||||
token: token.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // used by the trailing-slash unit test below.
|
||||
pub fn url(&self) -> &str {
|
||||
&self.url
|
||||
}
|
||||
|
||||
fn request(&self, method: Method, path: &str) -> RequestBuilder {
|
||||
self.http
|
||||
.request(method, format!("{}{path}", self.url))
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", self.token))
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/auth/me`
|
||||
pub async fn auth_me(&self) -> Result<AuthMeDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/auth/me")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps`
|
||||
pub async fn apps_list(&self) -> Result<Vec<App>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/apps")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}` — slug or UUID accepted.
|
||||
pub async fn apps_get(&self, ident: &str) -> Result<AppLookupDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{ident}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps`
|
||||
pub async fn apps_create(&self, body: &CreateAppBody<'_>) -> Result<App> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/apps")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts?app={ident}`
|
||||
pub async fn scripts_list_by_app(&self, ident: &str) -> Result<Vec<Script>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/scripts?app={}", urlencoded(ident)),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
||||
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
||||
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
||||
/// single request that can't be partially-broken by a concurrent app
|
||||
/// delete.
|
||||
pub async fn scripts_list_all(&self) -> Result<Vec<Script>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/scripts")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id_or_slug}` with optional `?force=true`.
|
||||
/// Server requires `AppAdmin` capability; without `force`, returns
|
||||
/// 409 if the app still has scripts.
|
||||
pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> {
|
||||
let path = if force {
|
||||
format!("/api/v1/admin/apps/{ident}?force=true")
|
||||
} else {
|
||||
format!("/api/v1/admin/apps/{ident}")
|
||||
};
|
||||
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
|
||||
/// owning app (stricter than the edit endpoints, by design).
|
||||
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/scripts`
|
||||
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/scripts")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||
/// uses PUT despite the field-level update semantics.
|
||||
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
|
||||
let body = UpdateScriptBody { source };
|
||||
let resp = self
|
||||
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/execute/{id}` — returns the raw HTTP status, headers,
|
||||
/// and JSON body (the orchestrator marshals the script's output as
|
||||
/// the HTTP response itself, not a wrapper object).
|
||||
pub async fn execute(
|
||||
&self,
|
||||
id: &str,
|
||||
body: Value,
|
||||
headers: &[(String, String)],
|
||||
) -> Result<ExecuteResponse> {
|
||||
let mut req = self
|
||||
.request(Method::POST, &format!("/api/v1/execute/{id}"))
|
||||
.json(&body);
|
||||
for (k, v) in headers {
|
||||
req = req.header(k, v);
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
let status = resp.status().as_u16();
|
||||
let mut headers_out: BTreeMap<String, String> = BTreeMap::new();
|
||||
for (k, v) in resp.headers() {
|
||||
if let Ok(val) = v.to_str() {
|
||||
headers_out.insert(k.as_str().to_string(), val.to_string());
|
||||
}
|
||||
}
|
||||
let bytes = resp.bytes().await.context("reading execute response")?;
|
||||
let body_json: Value = if bytes.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
serde_json::from_slice(&bytes)
|
||||
.unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned()))
|
||||
};
|
||||
Ok(ExecuteResponse {
|
||||
status_code: status,
|
||||
headers: headers_out,
|
||||
body: body_json,
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
|
||||
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/logout` — best-effort: server returns
|
||||
/// 204 whether or not the token matched a live session, so we just
|
||||
/// fire and discard the body. Caller still wipes the local creds.
|
||||
pub async fn auth_logout(&self) -> Result<()> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/auth/logout")
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/api-keys` — caller's keys only (server filters
|
||||
/// by user_id, no cross-user enumeration).
|
||||
pub async fn apikeys_list(&self) -> Result<Vec<ApiKeyDto>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/api-keys")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/api-keys` — `raw_token` is in the response
|
||||
/// **once** and never appears in `GET /api-keys` afterward.
|
||||
pub async fn apikeys_mint(&self, body: &MintApiKeyBody<'_>) -> Result<MintApiKeyResponseDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/api-keys")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't
|
||||
/// exist" and "not yours" (server flattens to avoid enumeration).
|
||||
pub async fn apikeys_delete(&self, id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
/// it runs before any token exists. Mirrors the dashboard's login.ts
|
||||
/// wire shape (see `manager-core/src/auth_api.rs:49-60`).
|
||||
pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<LoginResponseDto> {
|
||||
let http = reqwest::Client::builder()
|
||||
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
|
||||
.build()
|
||||
.context("building HTTP client")?;
|
||||
let body = LoginRequestBody { username, password };
|
||||
let resp = http
|
||||
.post(format!(
|
||||
"{}/api/v1/admin/auth/login",
|
||||
url.trim_end_matches('/')
|
||||
))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthMeDto {
|
||||
// Part of the wire shape (and kept for symmetry with the dashboard's
|
||||
// MeDto), even though the CLI never displays it.
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub instance_role: InstanceRole,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppLookupDto {
|
||||
#[serde(flatten)]
|
||||
pub app: App,
|
||||
// Not surfaced yet — `pic apps ls` only shows what `apps_list` returns.
|
||||
// Kept on the DTO so future `pic apps inspect <slug>` work is one-line.
|
||||
#[serde(default)]
|
||||
pub my_role: Option<AppRole>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateAppBody<'a> {
|
||||
pub slug: &'a str,
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateScriptBody<'a> {
|
||||
pub app_id: AppId,
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateScriptBody<'a> {
|
||||
source: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct LoginRequestBody<'a> {
|
||||
username: &'a str,
|
||||
password: &'a str,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginResponseDto {
|
||||
pub user: LoginUserDto,
|
||||
pub token: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct LoginUserDto {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub instance_role: InstanceRole,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MintApiKeyBody<'a> {
|
||||
pub name: &'a str,
|
||||
pub scopes: &'a [Scope],
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app_id: Option<AppId>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Fresh-mint response. The `raw_token` field is the one and only
|
||||
/// chance to capture the bearer string; subsequent `GET /api-keys`
|
||||
/// returns the `ApiKeyDto` portion without it.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MintApiKeyResponseDto {
|
||||
#[serde(flatten)]
|
||||
pub key: ApiKeyDto,
|
||||
pub raw_token: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ApiKeyDto {
|
||||
pub id: ApiKeyId,
|
||||
pub prefix: String,
|
||||
pub name: String,
|
||||
pub scopes: Vec<Scope>,
|
||||
pub app_id: Option<AppId>,
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug)]
|
||||
pub struct ExecuteResponse {
|
||||
pub status_code: u16,
|
||||
// Captured for completeness; not displayed today, but `pic invoke -v`
|
||||
// could surface them later without changing this struct.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
/// Parse `-H "Key: value"` or `-H "Key=value"` into a `(name, value)`
|
||||
/// pair. Trims surrounding whitespace on both sides.
|
||||
pub fn parse_kv_header(raw: &str) -> Result<(String, String), String> {
|
||||
let (k, v) = raw
|
||||
.split_once(':')
|
||||
.or_else(|| raw.split_once('='))
|
||||
.ok_or_else(|| format!("expected `Key: value` or `Key=value`, got {raw:?}"))?;
|
||||
let k = k.trim();
|
||||
let v = v.trim();
|
||||
if k.is_empty() {
|
||||
return Err(format!("empty header name in {raw:?}"));
|
||||
}
|
||||
Ok((k.to_string(), v.to_string()))
|
||||
}
|
||||
|
||||
fn urlencoded(s: &str) -> String {
|
||||
// Minimal pass: percent-encode the few chars that break the query.
|
||||
// Slugs and UUIDs don't contain them in practice, but be safe.
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for ch in s.chars() {
|
||||
match ch {
|
||||
'&' | '=' | '?' | '#' | ' ' => {
|
||||
out.push_str(&format!("%{:02X}", u32::from(ch)));
|
||||
}
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result<T> {
|
||||
if resp.status().is_success() {
|
||||
return resp.json::<T>().await.context("parsing response body");
|
||||
}
|
||||
Err(server_error(resp).await)
|
||||
}
|
||||
|
||||
/// Like `decode` but for endpoints whose 2xx response has no body
|
||||
/// (204 No Content) — DELETE handlers, logout.
|
||||
async fn decode_status(resp: reqwest::Response) -> Result<()> {
|
||||
if resp.status().is_success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(server_error(resp).await)
|
||||
}
|
||||
|
||||
async fn server_error(resp: reqwest::Response) -> anyhow::Error {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
let hint = role_hint(status);
|
||||
if hint.is_empty() {
|
||||
anyhow!("HTTP {}: {}", status.as_u16(), msg)
|
||||
} else {
|
||||
anyhow!("HTTP {}: {} ({})", status.as_u16(), msg, hint)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_error_body(s: &str) -> Option<String> {
|
||||
let v: Value = serde_json::from_str(s).ok()?;
|
||||
let obj = v.as_object()?;
|
||||
if let Some(m) = obj.get("message").and_then(Value::as_str) {
|
||||
return Some(m.to_string());
|
||||
}
|
||||
if let Some(e) = obj.get("error").and_then(Value::as_str) {
|
||||
return Some(e.to_string());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn role_hint(status: StatusCode) -> &'static str {
|
||||
match status {
|
||||
StatusCode::FORBIDDEN => "your role may lack the required capability; check `pic whoami`",
|
||||
StatusCode::UNAUTHORIZED => "token rejected; re-run `pic login`",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_kv_colon() {
|
||||
let (k, v) = parse_kv_header("X-Foo: bar").unwrap();
|
||||
assert_eq!(k, "X-Foo");
|
||||
assert_eq!(v, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kv_equals() {
|
||||
let (k, v) = parse_kv_header("X-Foo=bar").unwrap();
|
||||
assert_eq!(k, "X-Foo");
|
||||
assert_eq!(v, "bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kv_rejects_no_separator() {
|
||||
assert!(parse_kv_header("X-Foo").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_kv_rejects_empty_name() {
|
||||
assert!(parse_kv_header(": bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_strip_trailing_slash() {
|
||||
let c = Client::new("http://localhost:8000/", "pic_x").unwrap();
|
||||
assert_eq!(c.url(), "http://localhost:8000");
|
||||
}
|
||||
}
|
||||
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
//! `pic api-keys` — long-lived bearer-key management.
|
||||
//!
|
||||
//! Server semantics (mirrored from `manager-core/src/api_keys_api.rs`):
|
||||
//! * `raw_token` is returned **once** on mint and never again.
|
||||
//! * `app_id` (optional `--app`) binds the key to one app; instance
|
||||
//! scopes (`instance:*`) are rejected when `--app` is also set.
|
||||
//! * `scopes` is a `text[]` in the wire form (`script:read`, …).
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::Scope;
|
||||
|
||||
use crate::client::{Client, MintApiKeyBody};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn mint(
|
||||
name: &str,
|
||||
scope_strs: &[String],
|
||||
app_ident: Option<&str>,
|
||||
expires: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let scopes = parse_scopes(scope_strs)?;
|
||||
let expires_at = expires.map(parse_expires).transpose()?;
|
||||
let app_id = match app_ident {
|
||||
Some(ident) => Some(client.apps_get(ident).await?.app.id),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let body = MintApiKeyBody {
|
||||
name,
|
||||
scopes: &scopes,
|
||||
app_id,
|
||||
expires_at,
|
||||
};
|
||||
let resp = client.apikeys_mint(&body).await?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", resp.key.id.to_string())
|
||||
.field("name", resp.key.name.clone())
|
||||
.field("prefix", resp.key.prefix.clone())
|
||||
.field(
|
||||
"scopes",
|
||||
resp.key
|
||||
.scopes
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
)
|
||||
.field(
|
||||
"app_id",
|
||||
resp.key
|
||||
.app_id
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
)
|
||||
.field(
|
||||
"expires_at",
|
||||
resp.key
|
||||
.expires_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
)
|
||||
.field("token", resp.raw_token.clone());
|
||||
block.print(mode);
|
||||
if matches!(mode, OutputMode::Tsv) {
|
||||
// The token row is human-easy-to-miss in a wall of metadata;
|
||||
// call it out exactly once on the human path. Skip on JSON
|
||||
// since machine consumers don't need the nudge.
|
||||
eprintln!("Save this token — it will not be shown again.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let keys = client.apikeys_list().await?;
|
||||
let mut table = Table::new([
|
||||
"id",
|
||||
"name",
|
||||
"prefix",
|
||||
"scopes",
|
||||
"app_id",
|
||||
"expires_at",
|
||||
"last_used_at",
|
||||
"created_at",
|
||||
]);
|
||||
for k in keys {
|
||||
table.row([
|
||||
k.id.to_string(),
|
||||
k.name,
|
||||
k.prefix,
|
||||
k.scopes
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
k.app_id
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
k.expires_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
k.last_used_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
k.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.apikeys_delete(id).await?;
|
||||
println!("Revoked api-key {id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_scopes(raw: &[String]) -> Result<Vec<Scope>> {
|
||||
if raw.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"at least one `--scope` is required (e.g. --scope script:read)"
|
||||
));
|
||||
}
|
||||
raw.iter()
|
||||
.map(|s| Scope::from_wire(s).ok_or_else(|| anyhow!("unknown scope: {s}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `--expires` accepts either RFC 3339 (`2026-12-31T23:59:59Z`) or a
|
||||
/// shorthand `<N>d` / `<N>h` / `<N>m` (days / hours / minutes from now).
|
||||
/// Shorthand wins for the common "key good for 30 days" case; full
|
||||
/// RFC 3339 keeps the door open for precise cutoffs.
|
||||
fn parse_expires(raw: &str) -> Result<DateTime<Utc>> {
|
||||
if let Some(spec) = raw.strip_suffix('d') {
|
||||
let days: i64 = spec.parse().map_err(|_| anyhow!("bad days: {raw}"))?;
|
||||
return Ok(Utc::now() + chrono::Duration::days(days));
|
||||
}
|
||||
if let Some(spec) = raw.strip_suffix('h') {
|
||||
let hours: i64 = spec.parse().map_err(|_| anyhow!("bad hours: {raw}"))?;
|
||||
return Ok(Utc::now() + chrono::Duration::hours(hours));
|
||||
}
|
||||
if let Some(spec) = raw.strip_suffix('m') {
|
||||
let mins: i64 = spec.parse().map_err(|_| anyhow!("bad minutes: {raw}"))?;
|
||||
return Ok(Utc::now() + chrono::Duration::minutes(mins));
|
||||
}
|
||||
DateTime::parse_from_rfc3339(raw)
|
||||
.map(|d| d.with_timezone(&Utc))
|
||||
.map_err(|e| anyhow!("expected RFC 3339 or `<N>d/h/m`, got {raw:?}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_scopes_accepts_wire_form() {
|
||||
let scopes = parse_scopes(&["script:read".into(), "log:read".into()]).unwrap();
|
||||
assert_eq!(scopes, vec![Scope::ScriptRead, Scope::LogRead]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scopes_rejects_empty() {
|
||||
let err = parse_scopes(&[]).unwrap_err();
|
||||
assert!(format!("{err}").contains("at least one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scopes_rejects_unknown() {
|
||||
let err = parse_scopes(&["script:nope".into()]).unwrap_err();
|
||||
assert!(format!("{err}").contains("unknown scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expires_days_shorthand() {
|
||||
let d = parse_expires("7d").unwrap();
|
||||
let diff = (d - Utc::now()).num_days();
|
||||
assert!((6..=7).contains(&diff), "got {diff}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expires_rfc3339_passes_through() {
|
||||
let d = parse_expires("2030-01-01T00:00:00Z").unwrap();
|
||||
assert_eq!(d.timestamp(), 1893456000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expires_garbage_errors() {
|
||||
assert!(parse_expires("tomorrow").is_err());
|
||||
}
|
||||
}
|
||||
84
crates/picloud-cli/src/cmds/apps.rs
Normal file
84
crates/picloud-cli/src/cmds/apps.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! `pic apps` subcommands: `ls`, `create`, `show`, `delete`.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::AppRole;
|
||||
|
||||
use crate::client::{Client, CreateAppBody};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let apps = client.apps_list().await?;
|
||||
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
|
||||
for app in apps {
|
||||
// The list endpoint returns App without my_role. We do a per-app
|
||||
// lookup only on demand; for `ls` we leave the column dashed so
|
||||
// the call stays cheap (one HTTP request).
|
||||
table.row([
|
||||
app.slug.clone(),
|
||||
app.name.clone(),
|
||||
"-".to_string(),
|
||||
app.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let body = CreateAppBody {
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
};
|
||||
let app = client.apps_create(&body).await?;
|
||||
println!("Created app {}", app.slug);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic apps show <slug>` — single-app inspect using the lookup
|
||||
/// endpoint, which carries `my_role` for the caller (the `ls` endpoint
|
||||
/// doesn't).
|
||||
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let lookup = client.apps_get(ident).await?;
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", lookup.app.id.to_string())
|
||||
.field("slug", lookup.app.slug.clone())
|
||||
.field("name", lookup.app.name.clone())
|
||||
.field(
|
||||
"description",
|
||||
lookup.app.description.clone().unwrap_or_else(|| "-".into()),
|
||||
)
|
||||
.field("my_role", role_label(lookup.my_role.as_ref()))
|
||||
.field("created_at", lookup.app.created_at.to_rfc3339())
|
||||
.field("updated_at", lookup.app.updated_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic apps delete <slug> [--force]`. Without `--force` the server
|
||||
/// returns 409 if the app still owns scripts — surface that as a
|
||||
/// useful error rather than swallowing.
|
||||
pub async fn delete(ident: &str, force: bool) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.apps_delete(ident, force).await?;
|
||||
println!("Deleted app {ident}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn role_label(role: Option<&AppRole>) -> String {
|
||||
// Use the wire form so the CLI label matches what the dashboard
|
||||
// shows and what the membership APIs accept.
|
||||
match role {
|
||||
Some(r) => r.as_str().to_string(),
|
||||
None => "-".into(),
|
||||
}
|
||||
}
|
||||
129
crates/picloud-cli/src/cmds/login.rs
Normal file
129
crates/picloud-cli/src/cmds/login.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
//! `pic login` — primary auth entry point.
|
||||
//!
|
||||
//! Two flows:
|
||||
//! * **username + password** (default, interactive): POST
|
||||
//! `/api/v1/admin/auth/login` with the credentials and persist the
|
||||
//! returned session token. Mirrors the dashboard's login form.
|
||||
//! * **paste-a-token** (`--token <T>`, or `PICLOUD_TOKEN` env): skip
|
||||
//! the credential exchange and persist a bearer string directly.
|
||||
//! Used by CI and by anyone using a long-lived API key minted via
|
||||
//! `pic api-keys mint`. Validated against `/auth/me` before save.
|
||||
//!
|
||||
//! `--url <U>` (or `PICLOUD_URL`) overrides the URL prompt non-interactively.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
use crate::client::{self, Client};
|
||||
use crate::config::{save, Credentials};
|
||||
|
||||
const DEFAULT_URL: &str = "http://localhost:8000";
|
||||
|
||||
pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> {
|
||||
let url = resolve_url(url_arg)?;
|
||||
let token_from_env = std::env::var("PICLOUD_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
let bearer_token = token_arg.map(str::to_string).or(token_from_env);
|
||||
|
||||
let (token, username, role) = match bearer_token {
|
||||
Some(t) => login_with_bearer(&url, &t).await?,
|
||||
None => login_with_password(&url).await?,
|
||||
};
|
||||
|
||||
let creds = Credentials {
|
||||
url: url.clone(),
|
||||
token,
|
||||
username: username.clone(),
|
||||
};
|
||||
save(&creds)?;
|
||||
println!(
|
||||
"Logged in as {username} ({}) at {url}",
|
||||
instance_role_label(&role)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> {
|
||||
let username = prompt_line("Username: ")?;
|
||||
if username.is_empty() {
|
||||
anyhow::bail!("username is required");
|
||||
}
|
||||
let password = read_password()?;
|
||||
let resp = client::auth_login(url, &username, &password).await?;
|
||||
Ok((resp.token, resp.user.username, resp.user.instance_role))
|
||||
}
|
||||
|
||||
/// Read a password without echoing it where possible. Falls back to a
|
||||
/// plain stdin read when no controlling terminal is attached — CI
|
||||
/// systems and `cargo test`'s piped stdin both land here, and dying
|
||||
/// outright would block scripted use entirely. The fallback is louder
|
||||
/// (visible characters), but it's that or no functioning login.
|
||||
fn read_password() -> Result<String> {
|
||||
match rpassword::prompt_password("Password: ") {
|
||||
Ok(p) => Ok(p),
|
||||
Err(_) => {
|
||||
eprint!("Password: ");
|
||||
io::stderr().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin()
|
||||
.lock()
|
||||
.read_line(&mut buf)
|
||||
.context("reading password from stdin")?;
|
||||
Ok(buf.trim_end_matches(['\r', '\n']).to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bearer-token path: validate against `/auth/me` so a typo doesn't get
|
||||
/// persisted, then trust the username the server reports rather than
|
||||
/// whatever the user typed (which they didn't type at all in this mode).
|
||||
async fn login_with_bearer(url: &str, token: &str) -> Result<(String, String, InstanceRole)> {
|
||||
let client = Client::new(url, token)?;
|
||||
let me = client.auth_me().await?;
|
||||
Ok((token.to_string(), me.username, me.instance_role))
|
||||
}
|
||||
|
||||
fn instance_role_label(role: &InstanceRole) -> &'static str {
|
||||
match role {
|
||||
InstanceRole::Owner => "owner",
|
||||
InstanceRole::Admin => "admin",
|
||||
InstanceRole::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url(url_arg: Option<&str>) -> Result<String> {
|
||||
if let Some(u) = url_arg {
|
||||
return Ok(u.trim_end_matches('/').to_string());
|
||||
}
|
||||
if let Ok(env_url) = std::env::var("PICLOUD_URL") {
|
||||
if !env_url.is_empty() {
|
||||
return Ok(env_url.trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
let typed = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
|
||||
Ok(typed.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn prompt_line(label: &str) -> Result<String> {
|
||||
print!("{label}");
|
||||
io::stdout().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().lock().read_line(&mut buf)?;
|
||||
Ok(buf.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
|
||||
print!("{label} [{default}]: ");
|
||||
io::stdout().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().lock().read_line(&mut buf)?;
|
||||
let trimmed = buf.trim();
|
||||
Ok(if trimmed.is_empty() {
|
||||
default.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! `pic logout` — revoke the saved session server-side, then wipe the
|
||||
//! local credentials file.
|
||||
//!
|
||||
//! Idempotent: if the file doesn't exist or the server already forgot
|
||||
//! the session, we still succeed. The point is leaving the user in a
|
||||
//! clean "no token" state, not enforcing that a session existed.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
// Load before delete so we have a token to POST /logout with; if
|
||||
// there's no creds file there's also nothing to revoke server-side.
|
||||
let creds = config::load().ok();
|
||||
|
||||
if let Some(creds) = creds {
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Best-effort: a 4xx (token already invalid) or network error
|
||||
// shouldn't block the local wipe. The whole point of logout is
|
||||
// leaving no credentials on disk.
|
||||
let _ = client.auth_logout().await;
|
||||
}
|
||||
|
||||
config::delete()?;
|
||||
println!("Logged out");
|
||||
Ok(())
|
||||
}
|
||||
79
crates/picloud-cli/src/cmds/logs.rs
Normal file
79
crates/picloud-cli/src/cmds/logs.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
//! `pic logs <script-id>` — print recent execution log rows.
|
||||
//!
|
||||
//! In TSV mode emits a header + truncated-summary rows (`pic logs` was
|
||||
//! previously headerless — inconsistent with `apps ls` / `scripts ls`).
|
||||
//! In JSON mode emits the raw `ExecutionLog` array (no truncation),
|
||||
//! letting `jq` consumers see request/response bodies in full.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::{ExecutionLog, ExecutionStatus};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let entries = client.logs_list(script_id, limit).await?;
|
||||
match mode {
|
||||
OutputMode::Tsv => render_tsv(&entries),
|
||||
OutputMode::Json => render_json(&entries),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_tsv(entries: &[ExecutionLog]) {
|
||||
let mut table = Table::new(["created_at", "status", "summary"]);
|
||||
for e in entries {
|
||||
let summary = summarize(&e.response_body, &e.script_logs);
|
||||
table.row([
|
||||
e.created_at.to_rfc3339(),
|
||||
status_label(&e.status).to_string(),
|
||||
truncate(&summary, 120),
|
||||
]);
|
||||
}
|
||||
table.print(OutputMode::Tsv);
|
||||
}
|
||||
|
||||
fn render_json(entries: &[ExecutionLog]) {
|
||||
// Pretty for human jq-piping; consumers that want compact can pipe
|
||||
// through `jq -c`.
|
||||
let s = serde_json::to_string_pretty(entries).unwrap_or_else(|_| "[]".to_string());
|
||||
println!("{s}");
|
||||
}
|
||||
|
||||
fn status_label(s: &ExecutionStatus) -> &'static str {
|
||||
match s {
|
||||
ExecutionStatus::Success => "success",
|
||||
ExecutionStatus::Error => "error",
|
||||
ExecutionStatus::Timeout => "timeout",
|
||||
ExecutionStatus::BudgetExceeded => "budget_exceeded",
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize(response_body: &Option<serde_json::Value>, script_logs: &serde_json::Value) -> String {
|
||||
// Prefer the last script-side log line (often the most useful for
|
||||
// grepping). Fall back to the response body.
|
||||
if let Some(arr) = script_logs.as_array() {
|
||||
if let Some(last) = arr.last() {
|
||||
if let Some(msg) = last.get("message").and_then(|m| m.as_str()) {
|
||||
return msg.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
response_body
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "-".to_string())
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
let normalized = s.replace('\n', " ");
|
||||
if normalized.chars().count() <= n {
|
||||
normalized
|
||||
} else {
|
||||
let head: String = normalized.chars().take(n).collect();
|
||||
format!("{head}…")
|
||||
}
|
||||
}
|
||||
7
crates/picloud-cli/src/cmds/mod.rs
Normal file
7
crates/picloud-cli/src/cmds/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod api_keys;
|
||||
pub mod apps;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod scripts;
|
||||
pub mod whoami;
|
||||
197
crates/picloud-cli/src/cmds/scripts.rs
Normal file
197
crates/picloud-cli/src/cmds/scripts.rs
Normal file
@@ -0,0 +1,197 @@
|
||||
//! `pic scripts ls | deploy | invoke | delete`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::AppId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, CreateScriptBody};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
||||
|
||||
if let Some(ident) = app {
|
||||
let app = client.apps_get(ident).await?;
|
||||
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
|
||||
for s in scripts {
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
app.app.slug.clone(),
|
||||
s.name,
|
||||
s.version.to_string(),
|
||||
s.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// No filter → use the single `GET /admin/scripts` call. Server
|
||||
// filters by membership for `Member`; for `Admin`/`Owner` it
|
||||
// returns every script. Two requests total (apps + scripts) run
|
||||
// in parallel; the per-app walk we used to do here aborted on
|
||||
// the first 404 when another caller deleted an app mid-listing,
|
||||
// and was the entire reason a 5× retry existed in the tests.
|
||||
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
|
||||
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
|
||||
for s in scripts {
|
||||
let app_slug = slug_by_id
|
||||
.get(&s.app_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
app_slug,
|
||||
s.name,
|
||||
s.version.to_string(),
|
||||
s.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn deploy(
|
||||
file: &Path,
|
||||
app_ident: &str,
|
||||
name_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let source =
|
||||
std::fs::read_to_string(file).with_context(|| format!("reading {}", file.display()))?;
|
||||
let name = match name_override {
|
||||
Some(n) => n.to_string(),
|
||||
None => file
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
anyhow!(
|
||||
"could not derive script name from path {} (use --name)",
|
||||
file.display()
|
||||
)
|
||||
})?,
|
||||
};
|
||||
|
||||
// Slug-or-id resolution: a single GET satisfies both lookups and
|
||||
// gives us the canonical app_id needed for create.
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source)
|
||||
.await?;
|
||||
println!("Updated {} v{}", updated.name, updated.version);
|
||||
} else {
|
||||
let body = CreateScriptBody {
|
||||
app_id: app.app.id,
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
};
|
||||
let created = client.scripts_create(&body).await?;
|
||||
println!("Created {} v{}", created.name, created.version);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let body = parse_body_arg(body_arg)?;
|
||||
let resp = client.execute(id, body, headers).await?;
|
||||
// Status to stderr so stdout stays JSON for piping into jq.
|
||||
let _ = writeln!(io::stderr(), "<- HTTP {}", resp.status_code);
|
||||
let pretty = serde_json::to_string_pretty(&resp.body).unwrap_or_else(|_| resp.body.to_string());
|
||||
println!("{pretty}");
|
||||
if (200..400).contains(&resp.status_code) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!("execute returned HTTP {}", resp.status_code))
|
||||
}
|
||||
}
|
||||
|
||||
/// `pic scripts delete <id>`. Requires `AppAdmin` on the owning app
|
||||
/// server-side, which is stricter than the edit endpoints — Editor
|
||||
/// can deploy/update but not destroy. Surfaces that as a 403 with the
|
||||
/// usual role hint.
|
||||
pub async fn delete(id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.scripts_delete(id).await?;
|
||||
println!("Deleted script {id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
|
||||
match arg {
|
||||
None => Ok(Value::Object(serde_json::Map::new())),
|
||||
Some("@-") => {
|
||||
let mut buf = String::new();
|
||||
io::stdin()
|
||||
.read_to_string(&mut buf)
|
||||
.context("reading stdin")?;
|
||||
parse_or_string(&buf)
|
||||
}
|
||||
Some(raw) if raw.starts_with('@') => {
|
||||
let path = &raw[1..];
|
||||
let text = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("reading body file {path}"))?;
|
||||
parse_or_string(&text)
|
||||
}
|
||||
Some(raw) => parse_or_string(raw),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_or_string(s: &str) -> Result<Value> {
|
||||
let trimmed = s.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(Value::Object(serde_json::Map::new()));
|
||||
}
|
||||
serde_json::from_str(trimmed)
|
||||
.with_context(|| format!("body is not valid JSON: {}", truncate(trimmed, 80)))
|
||||
}
|
||||
|
||||
fn truncate(s: &str, n: usize) -> String {
|
||||
if s.len() <= n {
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{}…", &s[..n])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_body_inline_json() {
|
||||
let v = parse_body_arg(Some(r#"{"x":1}"#)).unwrap();
|
||||
assert_eq!(v["x"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_body_none_is_empty_object() {
|
||||
let v = parse_body_arg(None).unwrap();
|
||||
assert!(v.is_object());
|
||||
assert_eq!(v.as_object().unwrap().len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_body_invalid_json_reports() {
|
||||
let err = parse_body_arg(Some("not-json{")).unwrap_err();
|
||||
let msg = format!("{err:#}");
|
||||
assert!(msg.contains("not valid JSON"), "got: {msg}");
|
||||
}
|
||||
}
|
||||
34
crates/picloud-cli/src/cmds/whoami.rs
Normal file
34
crates/picloud-cli/src/cmds/whoami.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
|
||||
//! every time. Cached username in the credentials file is for
|
||||
//! display-only contexts; this command is the source of truth.
|
||||
//!
|
||||
//! TSV output uses `KvBlock` (aligned `key: value` rows), JSON output
|
||||
//! is a flat object — both downstream-friendly without the user having
|
||||
//! to parse a headerless tab-line.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let me = client.auth_me().await?;
|
||||
let role = match me.instance_role {
|
||||
InstanceRole::Owner => "owner",
|
||||
InstanceRole::Admin => "admin",
|
||||
InstanceRole::Member => "member",
|
||||
};
|
||||
let email = me.email.as_deref().unwrap_or("-");
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("username", me.username)
|
||||
.field("role", role)
|
||||
.field("email", email)
|
||||
.field("url", creds.url.clone());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
153
crates/picloud-cli/src/config.rs
Normal file
153
crates/picloud-cli/src/config.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! On-disk credentials store.
|
||||
//!
|
||||
//! Path is resolved via `directories::ProjectDirs` so the file lives in
|
||||
//! the platform-appropriate config dir (XDG on Linux, Library on macOS,
|
||||
//! AppData on Windows). On POSIX the file is forced to mode 0600 so the
|
||||
//! pasted bearer token isn't world-readable.
|
||||
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct Credentials {
|
||||
pub url: String,
|
||||
pub token: String,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
/// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an
|
||||
/// override (used by tests to redirect to a tempdir) before falling
|
||||
/// back to the platform default.
|
||||
pub fn credentials_path() -> Result<PathBuf> {
|
||||
if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") {
|
||||
return Ok(PathBuf::from(dir).join("credentials"));
|
||||
}
|
||||
let dirs = ProjectDirs::from("dev", "picloud", "picloud")
|
||||
.ok_or_else(|| anyhow!("could not determine config directory"))?;
|
||||
Ok(dirs.config_dir().join("credentials"))
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Credentials> {
|
||||
let path = credentials_path()?;
|
||||
let body = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"no credentials at {}. run `pic login` first",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
|
||||
}
|
||||
|
||||
/// Resolution order used by every non-login command:
|
||||
/// 1. If both `PICLOUD_URL` and `PICLOUD_TOKEN` are set (and non-empty),
|
||||
/// use them directly. Matches gcloud/aws/kubectl semantics — env
|
||||
/// wins so CI never accidentally reads a developer's stale file.
|
||||
/// 2. Otherwise fall back to the on-disk credentials file.
|
||||
///
|
||||
/// Username is best-effort: env mode has no way to know the real one
|
||||
/// (no round-trip to `/auth/me`), so it shows as `"-"` in `whoami`
|
||||
/// output. Callers that need the canonical username re-fetch via
|
||||
/// `Client::auth_me`.
|
||||
pub fn resolve() -> Result<Credentials> {
|
||||
if let (Ok(url), Ok(token)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
|
||||
if !url.is_empty() && !token.is_empty() {
|
||||
return Ok(Credentials {
|
||||
url,
|
||||
token,
|
||||
username: "-".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
/// Delete the on-disk credentials file. Idempotent — silently succeeds
|
||||
/// if the file is already gone (the user already logged out, or never
|
||||
/// logged in to begin with).
|
||||
pub fn delete() -> Result<()> {
|
||||
let path = credentials_path()?;
|
||||
match fs::remove_file(&path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(err) => Err(err).with_context(|| format!("removing {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(creds: &Credentials) -> Result<()> {
|
||||
let path = credentials_path()?;
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
|
||||
}
|
||||
let body = toml::to_string(creds).context("serializing credentials")?;
|
||||
write_private(&path, body.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let mut f = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("opening {}", path.display()))?;
|
||||
f.write_all(bytes)
|
||||
.with_context(|| format!("writing {}", path.display()))?;
|
||||
// Belt-and-suspenders: re-set perms in case the file already existed
|
||||
// with a wider mode (mode() on create doesn't downgrade existing).
|
||||
let mut perms = fs::metadata(path)?.permissions();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(path, perms)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_toml() {
|
||||
let creds = Credentials {
|
||||
url: "http://localhost:8000".to_string(),
|
||||
token: "pic_abc".to_string(),
|
||||
username: "admin".to_string(),
|
||||
};
|
||||
let serialized = toml::to_string(&creds).unwrap();
|
||||
let parsed: Credentials = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(creds, parsed);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_mode_is_0600() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
std::env::set_var("PICLOUD_CONFIG_DIR", dir.path());
|
||||
let creds = Credentials {
|
||||
url: "http://localhost:8000".to_string(),
|
||||
token: "pic_secret".to_string(),
|
||||
username: "admin".to_string(),
|
||||
};
|
||||
save(&creds).unwrap();
|
||||
let path = credentials_path().unwrap();
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "credentials must be readable only by owner");
|
||||
std::env::remove_var("PICLOUD_CONFIG_DIR");
|
||||
}
|
||||
}
|
||||
268
crates/picloud-cli/src/main.rs
Normal file
268
crates/picloud-cli/src/main.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
//! PiCloud command-line client.
|
||||
//!
|
||||
//! Thin client over the existing admin + execute HTTP surface — the
|
||||
//! server gains nothing for the CLI; the CLI is just a developer
|
||||
//! ergonomics layer over endpoints the dashboard already uses.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod output;
|
||||
|
||||
use crate::output::OutputMode;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "pic", version, about = "PiCloud command-line client")]
|
||||
struct Cli {
|
||||
/// Output format for `ls` / `show` / `whoami` / `logs` commands.
|
||||
/// TSV stays pipe-friendly; JSON is `jq`-ready.
|
||||
#[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)]
|
||||
output: OutputMode,
|
||||
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Authenticate with the server. Default flow prompts for username
|
||||
/// + password and saves the returned session token; `--token` skips
|
||||
/// the password exchange and persists a bearer string directly (use
|
||||
/// this for long-lived API keys minted via `pic api-keys mint`).
|
||||
Login(LoginArgs),
|
||||
|
||||
/// Revoke the saved session server-side and delete the local
|
||||
/// credentials file. Idempotent.
|
||||
Logout,
|
||||
|
||||
/// Print the principal the saved token resolves to.
|
||||
Whoami,
|
||||
|
||||
/// App management.
|
||||
Apps {
|
||||
#[command(subcommand)]
|
||||
cmd: AppsCmd,
|
||||
},
|
||||
|
||||
/// Script management.
|
||||
Scripts {
|
||||
#[command(subcommand)]
|
||||
cmd: ScriptsCmd,
|
||||
},
|
||||
|
||||
/// Long-lived bearer API key management.
|
||||
#[command(name = "api-keys")]
|
||||
ApiKeys {
|
||||
#[command(subcommand)]
|
||||
cmd: ApiKeysCmd,
|
||||
},
|
||||
|
||||
/// Tail recent execution logs for a script.
|
||||
Logs(LogsArgs),
|
||||
|
||||
/// Top-level alias for `pic scripts invoke <id>`.
|
||||
Invoke(InvokeArgs),
|
||||
|
||||
/// Top-level alias for `pic scripts deploy <file> --app <slug>`.
|
||||
Deploy(DeployArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct LoginArgs {
|
||||
/// Override the URL prompt non-interactively. Also reads
|
||||
/// `PICLOUD_URL`.
|
||||
#[arg(long)]
|
||||
url: Option<String>,
|
||||
|
||||
/// Skip the username + password exchange and persist this bearer
|
||||
/// directly (validated against `/auth/me` first). Also reads
|
||||
/// `PICLOUD_TOKEN`.
|
||||
#[arg(long)]
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AppsCmd {
|
||||
/// List apps the caller can see.
|
||||
Ls,
|
||||
|
||||
/// Create a new app.
|
||||
Create {
|
||||
slug: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
|
||||
/// Show a single app, including the caller's role in it.
|
||||
Show { ident: String },
|
||||
|
||||
/// Delete an app. Without `--force`, the server rejects if the app
|
||||
/// still owns scripts.
|
||||
Delete {
|
||||
ident: String,
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScriptsCmd {
|
||||
/// List scripts. With `--app`, scoped to one app; without, one
|
||||
/// `GET /admin/scripts` for everything the caller can see.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
},
|
||||
|
||||
/// Upload a `.rhai` file. Patches the existing script with the
|
||||
/// matching name in `--app` if one exists, otherwise creates it.
|
||||
Deploy(DeployArgs),
|
||||
|
||||
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||
/// `--body @-` for stdin, or inline JSON.
|
||||
Invoke(InvokeArgs),
|
||||
|
||||
/// Delete a script. Requires AppAdmin on the owning app.
|
||||
Delete { id: String },
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct DeployArgs {
|
||||
file: PathBuf,
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct InvokeArgs {
|
||||
id: String,
|
||||
#[arg(long)]
|
||||
body: Option<String>,
|
||||
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
|
||||
headers: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ApiKeysCmd {
|
||||
/// Mint a new long-lived bearer key. Token printed exactly once.
|
||||
Mint {
|
||||
name: String,
|
||||
/// Repeat for multiple scopes: `--scope script:read --scope log:read`.
|
||||
#[arg(long = "scope", required = true)]
|
||||
scopes: Vec<String>,
|
||||
/// Bind the key to a single app (slug or id). Rejects
|
||||
/// `instance:*` scopes when set.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
/// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand
|
||||
/// `<N>d`/`<N>h`/`<N>m`.
|
||||
#[arg(long)]
|
||||
expires: Option<String>,
|
||||
},
|
||||
|
||||
/// List the caller's keys (no `raw_token` after mint).
|
||||
Ls,
|
||||
|
||||
/// Revoke a key by id.
|
||||
Rm { id: String },
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct LogsArgs {
|
||||
script_id: String,
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: u32,
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
let mode = cli.output;
|
||||
let result = match cli.cmd {
|
||||
Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await,
|
||||
Cmd::Logout => cmds::logout::run().await,
|
||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||
Cmd::Apps {
|
||||
cmd:
|
||||
AppsCmd::Create {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
},
|
||||
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
|
||||
Cmd::Apps {
|
||||
cmd: AppsCmd::Show { ident },
|
||||
} => cmds::apps::show(&ident, mode).await,
|
||||
Cmd::Apps {
|
||||
cmd: AppsCmd::Delete { ident, force },
|
||||
} => cmds::apps::delete(&ident, force).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Ls { app },
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Deploy(args),
|
||||
} => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Invoke(args),
|
||||
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Delete { id },
|
||||
} => cmds::scripts::delete(&id).await,
|
||||
Cmd::ApiKeys {
|
||||
cmd:
|
||||
ApiKeysCmd::Mint {
|
||||
name,
|
||||
scopes,
|
||||
app,
|
||||
expires,
|
||||
},
|
||||
} => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
|
||||
Cmd::ApiKeys {
|
||||
cmd: ApiKeysCmd::Ls,
|
||||
} => cmds::api_keys::ls(mode).await,
|
||||
Cmd::ApiKeys {
|
||||
cmd: ApiKeysCmd::Rm { id },
|
||||
} => cmds::api_keys::rm(&id).await,
|
||||
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
|
||||
Cmd::Invoke(args) => {
|
||||
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
|
||||
}
|
||||
Cmd::Deploy(args) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
output::print_error(&err);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
252
crates/picloud-cli/src/output.rs
Normal file
252
crates/picloud-cli/src/output.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
//! Output rendering for the CLI.
|
||||
//!
|
||||
//! Two formats:
|
||||
//! * **TSV** (default): aligned columns separated by `\t`. Stays
|
||||
//! pipe-friendly — `pic apps ls | awk -F'\t' '{print $1}'` works
|
||||
//! without parsing box-drawing.
|
||||
//! * **JSON**: array of `{column: value, …}` objects (for tables) or
|
||||
//! a flat object (for single-row `show`/`whoami`). Designed to be
|
||||
//! `jq`-friendly without escaping the table column names.
|
||||
//!
|
||||
//! Mode is set globally by the top-level `--output` flag and threaded
|
||||
//! through every command. Single-row commands (`whoami`, `apps show`)
|
||||
//! use `KvBlock`; everything plural uses `Table`.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
use clap::ValueEnum;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||
#[clap(rename_all = "lowercase")]
|
||||
pub enum OutputMode {
|
||||
#[default]
|
||||
Tsv,
|
||||
Json,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Table — list views (`apps ls`, `scripts ls`, `logs`)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub struct Table {
|
||||
headers: Vec<String>,
|
||||
rows: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Table {
|
||||
pub fn new<I, S>(headers: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
Self {
|
||||
headers: headers.into_iter().map(Into::into).collect(),
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn row<I, S>(&mut self, cells: I) -> &mut Self
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<String>,
|
||||
{
|
||||
self.rows.push(cells.into_iter().map(Into::into).collect());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn render_tsv(&self) -> String {
|
||||
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
|
||||
for row in &self.rows {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i >= widths.len() {
|
||||
widths.push(cell.len());
|
||||
} else if cell.len() > widths[i] {
|
||||
widths[i] = cell.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
write_row(&mut out, &self.headers, &widths);
|
||||
for row in &self.rows {
|
||||
write_row(&mut out, row, &widths);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// JSON form: `[{header: cell, …}, …]`. Cells go in as strings even
|
||||
/// when they happen to look like numbers — the CLI doesn't carry
|
||||
/// type information all the way through (e.g., `version` is already
|
||||
/// `to_string`'d at the call site). Consumers that need typed
|
||||
/// numbers should parse `jq -r '.[].version|tonumber'`.
|
||||
pub fn render_json(&self) -> String {
|
||||
let arr: Vec<Value> = self
|
||||
.rows
|
||||
.iter()
|
||||
.map(|row| {
|
||||
let mut obj = Map::new();
|
||||
for (i, header) in self.headers.iter().enumerate() {
|
||||
let cell = row.get(i).cloned().unwrap_or_default();
|
||||
obj.insert(header.clone(), Value::String(cell));
|
||||
}
|
||||
Value::Object(obj)
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
pub fn print(&self, mode: OutputMode) {
|
||||
let s = match mode {
|
||||
OutputMode::Tsv => self.render_tsv(),
|
||||
OutputMode::Json => {
|
||||
let mut s = self.render_json();
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
};
|
||||
// Best-effort write — broken pipe from `| head` etc. shouldn't
|
||||
// surface as an error.
|
||||
let _ = io::stdout().write_all(s.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
|
||||
for (i, cell) in row.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\t');
|
||||
}
|
||||
out.push_str(cell);
|
||||
// Right-pad with spaces so tabs land on the column grid for
|
||||
// human readers. Skip on the final column.
|
||||
if i + 1 < row.len() {
|
||||
let w = widths.get(i).copied().unwrap_or(cell.len());
|
||||
for _ in cell.len()..w {
|
||||
out.push(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// KvBlock — single-row views (`whoami`, `apps show`)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// One row's worth of fields, rendered as aligned `key: value` lines in
|
||||
/// TSV mode (one line per field — easier on the eye than a 1-row table)
|
||||
/// or a flat JSON object.
|
||||
pub struct KvBlock {
|
||||
fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl KvBlock {
|
||||
pub fn new() -> Self {
|
||||
Self { fields: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn field(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
|
||||
self.fields.push((key.into(), value.into()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn render_tsv(&self) -> String {
|
||||
let key_width = self.fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
|
||||
let mut out = String::new();
|
||||
for (k, v) in &self.fields {
|
||||
out.push_str(k);
|
||||
for _ in k.len()..key_width {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push('\t');
|
||||
out.push_str(v);
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn render_json(&self) -> String {
|
||||
let mut obj = Map::new();
|
||||
for (k, v) in &self.fields {
|
||||
obj.insert(k.clone(), Value::String(v.clone()));
|
||||
}
|
||||
serde_json::to_string_pretty(&Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
|
||||
}
|
||||
|
||||
pub fn print(&self, mode: OutputMode) {
|
||||
let s = match mode {
|
||||
OutputMode::Tsv => self.render_tsv(),
|
||||
OutputMode::Json => {
|
||||
let mut s = self.render_json();
|
||||
s.push('\n');
|
||||
s
|
||||
}
|
||||
};
|
||||
let _ = io::stdout().write_all(s.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
pub fn print_error(err: &anyhow::Error) {
|
||||
let mut stderr = io::stderr();
|
||||
let _ = writeln!(stderr, "error: {err:#}");
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_aligns_columns_tsv() {
|
||||
let mut t = Table::new(["slug", "name"]);
|
||||
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||
let out = t.render_tsv();
|
||||
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_empty_rows_tsv() {
|
||||
let t = Table::new(["a", "b"]);
|
||||
assert_eq!(t.render_tsv(), "a\tb\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_render_json_is_array_of_objects() {
|
||||
let mut t = Table::new(["slug", "name"]);
|
||||
t.row(["a", "Alpha"]).row(["bravo", "B"]);
|
||||
let raw = t.render_json();
|
||||
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
|
||||
let arr = v.as_array().expect("array");
|
||||
assert_eq!(arr.len(), 2);
|
||||
assert_eq!(arr[0]["slug"], "a");
|
||||
assert_eq!(arr[0]["name"], "Alpha");
|
||||
assert_eq!(arr[1]["slug"], "bravo");
|
||||
assert_eq!(arr[1]["name"], "B");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_block_tsv_aligns_keys() {
|
||||
let mut b = KvBlock::new();
|
||||
b.field("username", "admin").field("role", "owner");
|
||||
let out = b.render_tsv();
|
||||
// username (8 chars) defines the key width.
|
||||
assert_eq!(out, "username\tadmin\nrole \towner\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_block_json_is_flat_object() {
|
||||
let mut b = KvBlock::new();
|
||||
b.field("username", "admin").field("role", "owner");
|
||||
let raw = b.render_json();
|
||||
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
|
||||
assert_eq!(v["username"], "admin");
|
||||
assert_eq!(v["role"], "owner");
|
||||
}
|
||||
}
|
||||
170
crates/picloud-cli/tests/api_keys.rs
Normal file
170
crates/picloud-cli/tests/api_keys.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
//! `pic api-keys` — mint / ls / rm journeys.
|
||||
//!
|
||||
//! Server semantics asserted here:
|
||||
//! * `mint` emits the `raw_token` *exactly once* and never on `ls`.
|
||||
//! * A minted key is a valid bearer for `/auth/me`.
|
||||
//! * After `rm`, the same token is rejected (401).
|
||||
|
||||
use predicates::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::common;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn mint_prints_raw_token_once_and_ls_omits_it() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let name = format!("pic-cli-mint-{}", common::unique_slug("k"));
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"--output",
|
||||
"json",
|
||||
"api-keys",
|
||||
"mint",
|
||||
&name,
|
||||
"--scope",
|
||||
"script:read",
|
||||
])
|
||||
.output()
|
||||
.expect("api-keys mint");
|
||||
assert!(out.status.success(), "mint failed: {out:?}");
|
||||
let body: Value = serde_json::from_slice(&out.stdout).expect("JSON");
|
||||
let token = body["token"]
|
||||
.as_str()
|
||||
.expect("mint should expose `token`")
|
||||
.to_string();
|
||||
let key_id = body["id"]
|
||||
.as_str()
|
||||
.expect("mint should expose `id`")
|
||||
.to_string();
|
||||
assert!(
|
||||
token.starts_with("pic_"),
|
||||
"tokens are pic_-prefixed: {token}"
|
||||
);
|
||||
|
||||
// `ls` must NEVER carry the raw token. The key row should appear,
|
||||
// identified by name, but `token` is mint-only.
|
||||
let ls = common::pic_as(&env)
|
||||
.args(["--output", "json", "api-keys", "ls"])
|
||||
.output()
|
||||
.expect("api-keys ls");
|
||||
assert!(ls.status.success(), "ls failed: {ls:?}");
|
||||
let ls_body: Value = serde_json::from_slice(&ls.stdout).expect("JSON");
|
||||
let arr = ls_body.as_array().expect("array");
|
||||
let row = arr
|
||||
.iter()
|
||||
.find(|r| r.get("id").and_then(Value::as_str) == Some(key_id.as_str()))
|
||||
.expect("our key in ls");
|
||||
assert!(
|
||||
row.get("token").is_none(),
|
||||
"ls must not expose raw_token: {row}"
|
||||
);
|
||||
|
||||
// Cleanup so we don't leak keys across runs.
|
||||
common::pic_as(&env)
|
||||
.args(["api-keys", "rm", &key_id])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn minted_key_works_as_bearer() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let name = format!("pic-cli-bearer-{}", common::unique_slug("k"));
|
||||
|
||||
let mint = common::pic_as(&env)
|
||||
.args([
|
||||
"--output",
|
||||
"json",
|
||||
"api-keys",
|
||||
"mint",
|
||||
&name,
|
||||
"--scope",
|
||||
"script:read",
|
||||
])
|
||||
.output()
|
||||
.expect("mint");
|
||||
assert!(mint.status.success());
|
||||
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
|
||||
let token = body["token"].as_str().unwrap().to_string();
|
||||
let id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
// Drive whoami with the minted token — proves the bearer string we
|
||||
// captured really is what the server stamped.
|
||||
let key_env = common::custom_env(&fx.url, &token);
|
||||
common::seed_credentials(&key_env, &fx.admin_username);
|
||||
common::pic_as(&key_env)
|
||||
.args(["whoami"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(fx.admin_username.as_str()));
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["api-keys", "rm", &id])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
/// After `rm`, the bearer token is dead server-side: a follow-up
|
||||
/// `whoami` driven by it must 401, not 500.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn rm_revokes_the_token() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let name = format!("pic-cli-rm-{}", common::unique_slug("k"));
|
||||
|
||||
let mint = common::pic_as(&env)
|
||||
.args([
|
||||
"--output",
|
||||
"json",
|
||||
"api-keys",
|
||||
"mint",
|
||||
&name,
|
||||
"--scope",
|
||||
"script:read",
|
||||
])
|
||||
.output()
|
||||
.expect("mint");
|
||||
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
|
||||
let token = body["token"].as_str().unwrap().to_string();
|
||||
let id = body["id"].as_str().unwrap().to_string();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["api-keys", "rm", &id])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(format!("Revoked api-key {id}")));
|
||||
|
||||
let dead = common::custom_env(&fx.url, &token);
|
||||
common::pic_as(&dead)
|
||||
.args(["whoami"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 401"));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn mint_with_unknown_scope_is_rejected_client_side() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["api-keys", "mint", "doomed", "--scope", "script:nope"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("unknown scope"));
|
||||
}
|
||||
268
crates/picloud-cli/tests/apps.rs
Normal file
268
crates/picloud-cli/tests/apps.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
//! `pic apps create` / `pic apps ls` edge cases. The integration smoke
|
||||
//! test covers the happy path; this module covers conflict, validation,
|
||||
//! and the persistence of the optional `--name` / `--description` flags
|
||||
//! (which `apps ls` doesn't surface).
|
||||
|
||||
use predicates::prelude::*;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
use crate::common::member;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn create_with_name_and_description_persists() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apps-named");
|
||||
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"apps",
|
||||
"create",
|
||||
&slug,
|
||||
"--name",
|
||||
"Pretty Name",
|
||||
"--description",
|
||||
"test description",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// `apps ls` only shows slug+name+role+created_at, so verify the
|
||||
// persisted shape via the admin GET endpoint.
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get(format!("{}/api/v1/admin/apps/{}", env.url, slug))
|
||||
.bearer_auth(&env.token)
|
||||
.send()
|
||||
.expect("GET app");
|
||||
assert!(resp.status().is_success(), "GET app failed: {resp:?}");
|
||||
let body: Value = resp.json().expect("app json");
|
||||
assert_eq!(body["slug"].as_str(), Some(slug.as_str()));
|
||||
assert_eq!(body["name"].as_str(), Some("Pretty Name"));
|
||||
assert_eq!(body["description"].as_str(), Some("test description"));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn create_duplicate_slug_conflicts() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apps-dup");
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("409").or(predicate::str::contains("conflict")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn create_invalid_slug_rejected() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
|
||||
// Server slug regex is `^[a-z0-9][a-z0-9-]{0,62}$` — uppercase
|
||||
// breaks the rule on the very first char. The server returns 422
|
||||
// (`InvalidSlug` → `UNPROCESSABLE_ENTITY`), not 400 — the previous
|
||||
// `"HTTP 4"` predicate would have silently matched any other 4xx
|
||||
// (a regressed 401 from broken auth, for example).
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", "NotALowerSlug"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 422"));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn ls_includes_created_app_with_expected_columns() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apps-ls");
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apps", "ls"])
|
||||
.output()
|
||||
.expect("apps ls");
|
||||
assert!(out.status.success(), "apps ls failed: {out:?}");
|
||||
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
|
||||
let mut lines = stdout.lines();
|
||||
let header = lines.next().expect("header row");
|
||||
assert_eq!(
|
||||
common::cells(header),
|
||||
vec!["slug", "name", "my_role", "created_at"]
|
||||
);
|
||||
|
||||
// The slug must appear in some data row and its row's my_role column
|
||||
// is dashed (the ls endpoint doesn't compute it per-app).
|
||||
let row = lines
|
||||
.map(common::cells)
|
||||
.find(|c| c.first().copied() == Some(slug.as_str()))
|
||||
.unwrap_or_else(|| panic!("slug {slug} not in apps ls output: {stdout}"));
|
||||
assert_eq!(row.len(), 4, "row should have 4 cells: {row:?}");
|
||||
assert_eq!(row[2], "-", "my_role column should be dashed: {row:?}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn delete_removes_app_from_ls() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apps-del");
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "delete", &slug])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apps", "ls"])
|
||||
.output()
|
||||
.expect("apps ls");
|
||||
assert!(out.status.success());
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
!stdout.lines().any(|l| l.starts_with(&slug)),
|
||||
"deleted slug should not appear in ls: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn delete_with_scripts_errors_without_force() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apps-del-busy");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
// AppGuard is the safety net: if the no-force delete fails (as
|
||||
// expected) the app stays around; AppGuard force-deletes on drop.
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&slug,
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "delete", &slug])
|
||||
.assert()
|
||||
.failure()
|
||||
// Server `HasScripts` → 409 with a "scripts present" message.
|
||||
.stderr(predicate::str::contains("HTTP 409"));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn delete_with_scripts_succeeds_with_force() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apps-del-force");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&slug,
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "delete", &slug, "--force"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn show_prints_my_role_for_member() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let admin_env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apps-show");
|
||||
common::pic_as(&admin_env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
|
||||
|
||||
let m = member::member_user(fx, &common::unique_username("show"));
|
||||
member::grant_membership(fx, &slug, &m.id, "viewer");
|
||||
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
|
||||
let out = common::pic_as(&member_env)
|
||||
.args(["apps", "show", &slug])
|
||||
.output()
|
||||
.expect("apps show");
|
||||
assert!(out.status.success(), "apps show failed: {out:?}");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
// KvBlock output: `my_role` row carries the wire form (`viewer`).
|
||||
assert!(
|
||||
stdout
|
||||
.lines()
|
||||
.any(|l| l.starts_with("my_role") && l.trim_end().ends_with("viewer")),
|
||||
"show should surface my_role=viewer, got: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.lines().any(|l| l.starts_with("slug")),
|
||||
"show should include slug row: {stdout}"
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user