Compare commits
43 Commits
fix/e2e-to
...
feat/group
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46667c309 | ||
|
|
ca360d84cb | ||
|
|
79f8c9d420 | ||
|
|
bb68e5e50a | ||
|
|
11ac168839 | ||
|
|
30441549d5 | ||
|
|
c914758c09 | ||
|
|
e6b4792389 | ||
|
|
b588fc9d35 | ||
|
|
6bd0f4699d | ||
|
|
9ee85993d8 | ||
|
|
343f6d3b4d | ||
|
|
35dbd9f368 | ||
|
|
6db057fb08 | ||
|
|
49c4fb41ce | ||
|
|
8725939172 | ||
|
|
eea1d8984e | ||
|
|
a432091191 | ||
|
|
2b27012f56 | ||
|
|
c900ca5bbf | ||
|
|
695987d6b7 | ||
|
|
d4b5632db1 | ||
|
|
345f265062 | ||
|
|
aa3995ae05 | ||
|
|
a27863d4a6 | ||
|
|
b3f05dfe2a | ||
|
|
5e62f4acfe | ||
|
|
73c8c289c1 | ||
|
|
816f143ffd | ||
|
|
2ba476aac8 | ||
|
|
79153b2063 | ||
|
|
9e1c24f729 | ||
|
|
8c805a07d0 | ||
|
|
bfab7c781d | ||
|
|
4223d3c320 | ||
|
|
55cf995eda | ||
|
|
627996cde7 | ||
|
|
be5df06a48 | ||
|
|
b8a4f30219 | ||
|
|
d9b3e9973c | ||
|
|
3b650a2b14 | ||
|
|
c600177fd6 | ||
|
|
51f14fa2b1 |
@@ -8,7 +8,11 @@ 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 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.
|
||||
**v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`).
|
||||
|
||||
**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache). Next: group-owned scripts/modules (§11 Phase 4) and the project tool mapping onto groups (§11 Phase 5).
|
||||
|
||||
**Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable _config_ tables (`vars`, `secrets`) instead carry a **polymorphic owner** — nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes. Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg (the cross-app isolation boundary).
|
||||
|
||||
## Three-Service Architecture
|
||||
|
||||
@@ -117,7 +121,7 @@ Environment variables consumed by the `picloud` binary:
|
||||
| `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_SECRET_KEY` | — | Master encryption key (base64). Required at startup unless dev mode is acknowledged (below). |
|
||||
| `PICLOUD_DEV_MODE` | `false` | `true` enables local-dev conveniences. Without `PICLOUD_SECRET_KEY` it ALSO requires the acknowledgement var below — `PICLOUD_DEV_MODE=true` alone aborts at startup. |
|
||||
| `PICLOUD_DEV_MODE` | `false` | `true` enables local-dev conveniences. Without `PICLOUD_SECRET_KEY` it ALSO requires the acknowledgement var below — `PICLOUD_DEV_MODE=true` alone aborts at startup. Also: when no SMTP relay is configured, `email::send` switches from disabled (`NotConfigured`) to an **in-memory dev sink** — sends succeed and the last 100 messages are readable at `GET /api/v1/admin/dev/emails` (instance Owner/Admin only; route exists only in this mode). Never in production. |
|
||||
| `PICLOUD_DEV_INSECURE_KEY` | — | Set to the literal `i-understand-this-is-insecure` to let dev mode boot without `PICLOUD_SECRET_KEY`, using a deterministic, world-known dev master key. Never set in production — it would encrypt everything with a public value. |
|
||||
| `PICLOUD_DB_MAX_CONNECTIONS` | `32` | Postgres pool size. Matched to `PICLOUD_MAX_CONCURRENT_EXECUTIONS` so the data plane can't starve background workers. |
|
||||
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |
|
||||
|
||||
138
E2E_STASH_REPORT.md
Normal file
138
E2E_STASH_REPORT.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# E2E Test #2 — "Stash" (paste + file-drop) + security matrix
|
||||
|
||||
**Date:** 2026-06-13 · **Build:** v1.1.9 · **Instance:** host `picloud` on `127.0.0.1:8099`
|
||||
(dev mode, `PICLOUD_FILES_MAX_FILE_SIZE_BYTES=1048576`, SSRF guard on, SMTP unset), Postgres in
|
||||
Docker. Driven entirely through the `pic` CLI; data-plane traffic via `curl`.
|
||||
|
||||
## Goal
|
||||
|
||||
Second end-to-end pass. The first test (To-Do, `E2E_TODO_REPORT.md`) covered users/docs/pubsub/
|
||||
cron/routes/domains/topics. This one builds a **new** app to exercise the *untested* half of the
|
||||
platform — **kv, files, queue, invoke, secrets, api-keys, dead-letters, async + two-param routes**
|
||||
— and then runs a **10-probe security matrix**. Findings: feature/CLI gaps + security verdicts.
|
||||
|
||||
## Verdict
|
||||
|
||||
**Every feature worked** end-to-end, and **every security control held except one Low-severity
|
||||
gap** (reserved-path validation is case-sensitive). Highlights of what's *good*: cross-app
|
||||
isolation is airtight, the SSRF guard blocks cloud-metadata, size/op caps fire before authz, and
|
||||
internal script errors are scrubbed from responses with a correlation id. Two notable
|
||||
**observability/ergonomics gaps** (trigger executions invisible to `pic logs`; the `.trim()`/`.replace()`
|
||||
return-`()` footgun) and the expected CLI-coverage gaps round it out.
|
||||
|
||||
---
|
||||
|
||||
## The app — "Stash" (built CLI-only, no raw admin curl)
|
||||
|
||||
A pastebin with file attachments and a background word-count worker. 9 scripts, 6 routes, a queue
|
||||
consumer trigger, a dead-letter trigger, 2 API keys, 1 secret — all via `pic`.
|
||||
|
||||
| Feature exercised | How | Result |
|
||||
|---|---|---|
|
||||
| **kv** | paste store, hit counter, `/stats` aggregates, event log | ✅ get/set/list round-trip |
|
||||
| **files** | `files::create` from base64 blob + `get`/`head`, two-param download route | ✅ bytes round-trip (`"hello attachment bytes"`, size 22) |
|
||||
| **queue** | `queue::enqueue("ingest")` + `create-from-json --kind queue` consumer | ✅ worker fired |
|
||||
| **invoke** | worker → `invoke("analyzer", #{text})` function-to-function | ✅ returns word count |
|
||||
| **secrets** | `pic secrets set` (stdin) + `secrets::get` to gate `/stats` | ✅ 401 w/o token, 200 with |
|
||||
| **async route** | `routes create --dispatch async` for `POST /events` | ✅ **202** + execution_id |
|
||||
| **dead-letters** | poison job → 3 attempts → DL row; `pic dead-letters replay` | ✅ captured + replayed (+9 words) |
|
||||
| **api-keys** | `pic api-keys mint --scope … [--app …]` | ✅ minted, scopes enforced (below) |
|
||||
| **two-param route** | `/pastes/:code/files/:fid` | ✅ both params captured |
|
||||
|
||||
End-to-end stats after the run: `{total_pastes: 2, total_words: 13}` — proving queue → worker →
|
||||
invoke → kv all fired on real Postgres rows + on-disk file bytes.
|
||||
|
||||
---
|
||||
|
||||
## Security matrix (10 probes, all reproduced live)
|
||||
|
||||
| # | Probe | Verdict | Evidence |
|
||||
|---|---|---|---|
|
||||
| **S1** | Cross-app isolation | ✅ HOLDS | A script in app `evil` doing `kv…list()` / `secrets::get("admin-token")` / `files…list()` returned `{paste_keys:[], attachment_count:0, stolen_secret:null, my_secret_names:[]}`. `app_id` derives from `cx.app_id`; no SDK call takes a script-passed app_id. |
|
||||
| **S2** | Secret confidentiality | ✅ HOLDS | `pic secrets ls` shows names only; the plaintext `sup3r-s3cret-admin` does **not** appear anywhere in the server log even though `/stats` reads it each request. |
|
||||
| **S3** | API-key scope + app-binding | ✅ HOLDS | `script:read` key → **403** on `POST /apps` and `GET /admins`, **200** on in-scope `GET /scripts`. `app:admin` key bound to `stash` → **403** on app `evil`. `instance:admin` + `--app` → **422**. Unknown scope `bogus:scope` → rejected by CLI. |
|
||||
| **S4** | Sandbox op-budget | ✅ HOLDS | `loop {}` → **HTTP 507 "execution exceeded operation budget"** in **0.30 s**; `/healthz` still `ok` (no hang). |
|
||||
| **S5** | Value/size caps | ✅ HOLDS | Oversized kv write rejected (HTTP 502). Notably the detail (`"Length of string too large"`) was **scrubbed from the response** and logged with `correlation_id` — good error hygiene. Caps are checked before authz, so anonymous callers can't DoS. |
|
||||
| **S6** | Reserved-path prefixes | ⚠️ **GAP (Low)** | `/api/x`, `/admin/x`, `/healthz`, `/version` → correctly **422**. But case variants `/API/v2/x`, `/Admin/x`, `/HEALTHZ` were **accepted**. See finding below. |
|
||||
| **S7** | Files path traversal | ✅ HOLDS | `files::collection("../../../etc")` → `"invalid collection name: must not contain '/', '\\', '..', or NUL"`; a non-UUID file id → not found. No FS escape. |
|
||||
| **S8** | docs filter injection | ✅ HOLDS | `docs::find(#{ "x'); DROP TABLE docs;--": 1 })` → ran safely, `count=0`; the `docs` table still exists (`to_regclass` = true). Values/paths are bound as `$N` params. |
|
||||
| **S9** | SSRF via `http::` | ✅ HOLDS | `http::get("http://127.0.0.1:8099/healthz")` → `"blocked by SSRF policy: loopback"`; `http://169.254.169.254/…` (cloud metadata) → `"blocked by SSRF policy: link-local"`. |
|
||||
| **S10** | Anonymous data-plane access | ℹ️ By design (caveat) | Every Stash route is public (no auth) yet freely uses kv/secrets/files/queue. `script_gate` returns `Ok` when `principal.is_none()` — the *script* is the gate. Correct per design, but a sharp threat-model edge (see note). |
|
||||
|
||||
### S6 — reserved-path validation is case-sensitive (Low)
|
||||
**What:** route-creation rejects the exact reserved prefixes but accepts case variants:
|
||||
```
|
||||
pic routes create --path /api/x -> 422 "path '/api/x' is reserved"
|
||||
pic routes create --path /API/v2/x -> Created route … (GET * /API/v2/x) # accepted
|
||||
pic routes create --path /HEALTHZ -> Created route … # accepted
|
||||
pic routes create --path /Admin/x -> Created route … # accepted
|
||||
```
|
||||
**Current impact — Low, *not* a full bypass:** path matching is case-*sensitive*, so a request to
|
||||
the real lowercase `/api/v2/x` still 404s (the system namespace is safe); only the exact
|
||||
mixed-case path serves the script (`GET /API/v2/x` → 200, `/HEALTHZ` → 200). Real `/healthz`
|
||||
still returns `ok` from the top-level handler.
|
||||
**Why it still matters:** (1) inconsistent enforcement of a stated security boundary; (2) lets a
|
||||
tenant publish convincing look-alike paths (`/Admin/login`, `/API/v1/…`) for phishing or log
|
||||
confusion; (3) **fragile** — method matching is already case-insensitive (`matcher.rs:275`); if
|
||||
path matching is ever made case-insensitive too, this instantly becomes a real shadowing bypass
|
||||
of `/api/`, `/admin/`, `/healthz`. The validation should be the durable guard.
|
||||
**Fix:** normalize case before the reserved check in `orchestrator-core/src/routing/pattern.rs`
|
||||
(`check_reserved` ~line 110) — compare `raw.to_ascii_lowercase()` against the reserved list (or
|
||||
reject any case-insensitive match).
|
||||
|
||||
### S10 — anonymous public scripts have full app data-plane access (design caveat)
|
||||
Not a bug, but worth surfacing for users: a *public* (unauthenticated) route's script can read/write
|
||||
**all** of the app's kv, docs, files, secrets, and queue — the platform only gates *authenticated*
|
||||
principals; for anonymous ingress the script itself must enforce access. A developer who assumes
|
||||
"this route is public" ≠ "this code can read every secret in my app" could over-expose data. Worth
|
||||
a prominent doc callout near the SDK auth model.
|
||||
|
||||
---
|
||||
|
||||
## Feature / CLI gaps
|
||||
|
||||
- **G1 — trigger executions are invisible to `pic logs`.** After successful queue-worker and
|
||||
analyzer runs, `pic logs <worker>` and `pic logs <analyzer>` were **empty**. Trigger-dispatched
|
||||
executions (queue/cron/dead-letter/invoke) don't surface in the per-script log tail — the only
|
||||
built-in visibility into worker activity is **dead-letters (failures only)** or the script's own
|
||||
side effects. This is a real observability gap for background workloads. *Suggest: include
|
||||
trigger executions in the logs surface, or add a `pic logs --trigger`/events view.*
|
||||
- **G2 — no `pic kv` / `files` / `queues` / `members` commands.** Data-plane stores are
|
||||
script/HTTP-only (no admin CLI to inspect kv/files/queue contents; queues are read-only HTTP).
|
||||
App **membership** (`/apps/{id}/members`) also has no CLI — multi-user app roles can't be managed
|
||||
with `pic`. *(Confirmed absent via `pic <cmd> --help`.)*
|
||||
- **G3 — no per-script sandbox/timeout flags in `pic deploy`/`scripts`.** `timeout_seconds`,
|
||||
`memory_limit_mb`, and sandbox overrides are only settable via the raw scripts API or instance
|
||||
env (`PICLOUD_SANDBOX_MAX_*`). A developer can't cap a single script's runtime from the CLI.
|
||||
- **G4 — uneven trigger wrappers.** Only `kv`, `cron`, `dead-letter` have first-class
|
||||
`pic triggers create-*` wrappers; `docs`/`files`/`pubsub`/`queue`/`email` require hand-built
|
||||
`create-from-json --kind … --body '{…}'`. Works, but the developer must know each body schema.
|
||||
- **G5 — `email::send` unusable in dev.** Returns `"email is not configured: set
|
||||
PICLOUD_SMTP_HOST/USER/PASSWORD"`. Expected (no silent drop), but the email feature can't be
|
||||
exercised at all without an SMTP relay — worth a documented local-dev fake/sink.
|
||||
- **G6 — Rhai `.trim()` returns `()` (in-place mutation footgun).** Same family as `.replace()`
|
||||
(already documented). `let t = text.trim()` sets `t` to unit; my analyzer dead-lettered with
|
||||
`Function not found: split((), …)` until rewritten to `text.trim();` as a statement. *Suggest:
|
||||
extend the existing stdlib footgun note to list `trim`/`to_upper`/`to_lower` etc., not just
|
||||
`replace`.*
|
||||
|
||||
## Things done especially well (no action)
|
||||
- Cross-app isolation has no script-controlled `app_id` anywhere — the boundary is structural.
|
||||
- SSRF guard covers loopback **and** link-local (cloud-metadata `169.254.169.254`), and blocks at
|
||||
every redirect hop.
|
||||
- Size/operation caps are enforced **before** authz, so anonymous public scripts can't DoS the DB.
|
||||
- Script runtime errors are **scrubbed** from the HTTP response and logged with a `correlation_id`
|
||||
— no internal detail leaks to the caller.
|
||||
- `deploy`/`apps create` honor `--output json` (the earlier F1 fix), so the whole build scripts
|
||||
cleanly with captured ids.
|
||||
|
||||
## Reproduction / teardown
|
||||
Scripts under `/tmp/stash/*.rhai`; apps `stash` + `evil` and their data persist in the dev
|
||||
Postgres. Server: `target/debug/picloud` on `:8099` (see Phase 0 env). Teardown: kill the host
|
||||
`picloud`; `docker compose down [-v]`. (Note: the S6 probe left harmless `/API/v2/x`, `/HEALTHZ`,
|
||||
`/Admin/x`, `/Api/y` routes on `stash` — they vanish with the app.)
|
||||
|
||||
## Priority
|
||||
**S6** is the only code-level finding (Low — confirm-and-harden the reserved-path check). **G1**
|
||||
(trigger-execution observability) is the most impactful *developer-experience* gap for anyone
|
||||
running background workers. Everything else is documented-behavior or known CLI-coverage gaps.
|
||||
@@ -19,5 +19,6 @@ pub use module_resolver::{
|
||||
};
|
||||
pub use sandbox::Limits;
|
||||
pub use types::{
|
||||
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
|
||||
build_execution_log, ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry,
|
||||
LogLevel,
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod retry;
|
||||
pub mod secrets;
|
||||
pub mod stdlib;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
pub use cx::SdkCallCx;
|
||||
@@ -62,6 +63,7 @@ pub fn register_all(
|
||||
queue::register(engine, services, cx.clone());
|
||||
retry::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx.clone());
|
||||
vars::register(engine, services, cx.clone());
|
||||
email::register(engine, services, cx.clone());
|
||||
users::register(engine, services, cx.clone());
|
||||
invoke::register(engine, services, cx, limits, self_engine);
|
||||
|
||||
57
crates/executor-core/src/sdk/vars.rs
Normal file
57
crates/executor-core/src/sdk/vars.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! `vars::` Rhai bridge — read-only access to the app's resolved,
|
||||
//! group-inherited config (Phase 3).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let region = vars::get("region"); // value or ()
|
||||
//! let all = vars::all(); // #{ key: value, ... }
|
||||
//! ```
|
||||
//!
|
||||
//! Values are inherited down the group tree and env-filtered (§3); the
|
||||
//! resolution happens server-side in manager-core. Writes go through the
|
||||
//! admin API, not the SDK. `app_id` is derived from `cx.app_id` in the
|
||||
//! service — never a script argument — preserving cross-app isolation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{SdkCallCx, Services};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
use super::bridge::{block_on, json_to_dynamic};
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.vars.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// vars::get(key) — resolved value, or () if no level defines it.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"get",
|
||||
move |key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let opt = block_on("vars", async move { svc.get(&cx, key).await })?;
|
||||
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// vars::all() — the fully-resolved config map.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn("all", move || -> Result<Map, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let resolved = block_on("vars", async move { svc.all(&cx).await })?;
|
||||
let mut m = Map::new();
|
||||
for (k, v) in resolved {
|
||||
m.insert(k.into(), json_to_dynamic(v));
|
||||
}
|
||||
Ok(m)
|
||||
});
|
||||
}
|
||||
|
||||
engine.register_static_module("vars", module.into());
|
||||
}
|
||||
@@ -2,10 +2,12 @@ use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
|
||||
ScriptId, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -167,3 +169,77 @@ pub enum ExecError {
|
||||
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
|
||||
Overloaded { retry_after_secs: u32 },
|
||||
}
|
||||
|
||||
/// Build an `ExecutionLog` row from one invocation's outcome.
|
||||
///
|
||||
/// Shared by every execution path so the log shape stays identical
|
||||
/// regardless of who ran the script: the orchestrator's sync/direct HTTP
|
||||
/// handlers pass `ExecutionSource::Http`, while the manager's trigger
|
||||
/// dispatcher passes the trigger's kind (`Kv`, `Cron`, `Invoke`, …). That
|
||||
/// `source` is what lets `pic logs` surface background runs that were
|
||||
/// previously invisible. `Overloaded` is never logged — admission is
|
||||
/// refused before a row exists — but it maps to `Error` defensively.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[must_use]
|
||||
pub fn build_execution_log(
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
request_id: RequestId,
|
||||
request_path: String,
|
||||
request_headers: BTreeMap<String, String>,
|
||||
request_body: serde_json::Value,
|
||||
source: ExecutionSource,
|
||||
outcome: &Result<ExecResponse, ExecError>,
|
||||
started: DateTime<Utc>,
|
||||
finished: DateTime<Utc>,
|
||||
) -> ExecutionLog {
|
||||
let duration_ms = u64::try_from(
|
||||
finished
|
||||
.signed_duration_since(started)
|
||||
.num_milliseconds()
|
||||
.max(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
let (status, response_code, response_body, script_logs) = match outcome {
|
||||
Ok(resp) => {
|
||||
let logs = serde_json::to_value(&resp.logs).unwrap_or(serde_json::Value::Array(vec![]));
|
||||
(
|
||||
ExecutionStatus::Success,
|
||||
Some(resp.status_code),
|
||||
Some(resp.body.clone()),
|
||||
logs,
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
let status = match e {
|
||||
ExecError::Timeout(_) => ExecutionStatus::Timeout,
|
||||
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
|
||||
_ => ExecutionStatus::Error,
|
||||
};
|
||||
(
|
||||
status,
|
||||
None,
|
||||
Some(serde_json::json!({ "error": e.to_string() })),
|
||||
serde_json::Value::Array(vec![]),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
ExecutionLog {
|
||||
id: Uuid::new_v4(),
|
||||
app_id,
|
||||
script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
request_headers,
|
||||
request_body,
|
||||
response_code,
|
||||
response_body,
|
||||
script_logs,
|
||||
duration_ms,
|
||||
status,
|
||||
source,
|
||||
created_at: started,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ async fn original_backend_error_is_logged_at_error_level() {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
let engine = Engine::new(Limits::default(), services);
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||
engine.set_self_weak(Arc::downgrade(&engine));
|
||||
|
||||
@@ -114,6 +114,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
Arc::new(NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- G1 (E2E #2 "Stash"): trigger executions were invisible to `pic logs`.
|
||||
--
|
||||
-- Only HTTP-route executions ever wrote an `execution_logs` row; queue,
|
||||
-- cron, dead-letter, and `invoke()` runs left no trace, so background
|
||||
-- workers were observable only via dead-letters (failures) or their own
|
||||
-- side effects. The dispatcher now logs every trigger run too — this
|
||||
-- column records which kind of event dispatched each execution so the
|
||||
-- logs surface can show, and filter by, the origin.
|
||||
--
|
||||
-- DEFAULT 'http' backfills every pre-existing row: before this change the
|
||||
-- only thing that logged was the HTTP path, so 'http' is correct history.
|
||||
-- The CHECK list mirrors `manager-core::OutboxSourceKind` /
|
||||
-- `shared::ExecutionSource`; keep all three in sync.
|
||||
|
||||
ALTER TABLE execution_logs
|
||||
ADD COLUMN source TEXT NOT NULL DEFAULT 'http'
|
||||
CHECK (source IN (
|
||||
'http', 'kv', 'docs', 'dead_letter', 'cron',
|
||||
'files', 'pubsub', 'email', 'invoke', 'queue'
|
||||
));
|
||||
@@ -0,0 +1,22 @@
|
||||
-- H1 (re-review of the S6 reserved-path fix): the reserved-prefix check is
|
||||
-- now case-insensitive, both at route creation AND when the route table is
|
||||
-- compiled at boot. Routes created before the fix — while validation was
|
||||
-- case-sensitive — could hold paths like `/API/v2/x`, `/Admin/x`, or
|
||||
-- `/HEALTHZ`. `compile_routes` now skips such rows with a warning instead of
|
||||
-- aborting startup (so an un-upgraded boot can't be bricked), but those
|
||||
-- routes violate the reserved namespace and can never be served safely, so
|
||||
-- sweep them here on upgrade.
|
||||
--
|
||||
-- Mirrors `orchestrator-core::routing::pattern::check_reserved` exactly,
|
||||
-- case-insensitively: a path is reserved if its lowercased form equals one
|
||||
-- of the bare names (`/api` `/admin` `/healthz` `/version`) or starts with
|
||||
-- one of the prefixes. Note `/api/` and `/admin/` reserve on the trailing
|
||||
-- slash, while `/healthz` and `/version` reserve on bare prefix — matching
|
||||
-- the RESERVED_PATH_PREFIXES list. (Idempotent: a no-op once swept.)
|
||||
|
||||
DELETE FROM routes
|
||||
WHERE lower(path) IN ('/api', '/admin', '/healthz', '/version')
|
||||
OR lower(path) LIKE '/api/%'
|
||||
OR lower(path) LIKE '/admin/%'
|
||||
OR lower(path) LIKE '/healthz%'
|
||||
OR lower(path) LIKE '/version%';
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half.
|
||||
-- Triggers already carry `enabled` (0008) honored at match/schedule time;
|
||||
-- this adds the same toggle to scripts and routes. A disabled script is not
|
||||
-- invocable; a disabled route 404s (indistinguishable from absent). Default
|
||||
-- TRUE so every existing row stays active — no behavior change on migrate.
|
||||
|
||||
ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Trigger `name` (§4.5): an explicit per-app identifier that becomes the
|
||||
-- manifest merge/upsert key (Step B switches the apply diff to key on it).
|
||||
-- Until now triggers were identified only by their per-kind semantic tuple,
|
||||
-- so the declarative tool could only Create/Delete them, never Update.
|
||||
--
|
||||
-- Backfill existing rows with `{kind}-{n}` (n = per-(app,kind) sequence by
|
||||
-- creation order) — the spec-sanctioned fallback when there's no cleaner
|
||||
-- entity token. Then enforce NOT NULL + UNIQUE(app_id, name).
|
||||
|
||||
-- A `gen_random_uuid()` default keeps existing INSERTs (which don't yet
|
||||
-- supply a name) valid and unique — the manager-core write paths start
|
||||
-- providing meaningful names in the follow-up; new rows until then get a
|
||||
-- harmless unique placeholder.
|
||||
ALTER TABLE triggers
|
||||
ADD COLUMN name TEXT NOT NULL DEFAULT gen_random_uuid()::text;
|
||||
|
||||
-- Rewrite the just-defaulted existing rows to the readable `{kind}-{n}` form.
|
||||
UPDATE triggers t
|
||||
SET name = sub.nm
|
||||
FROM (
|
||||
SELECT id,
|
||||
kind || '-' || row_number() OVER (
|
||||
PARTITION BY app_id, kind ORDER BY created_at, id
|
||||
) AS nm
|
||||
FROM triggers
|
||||
) sub
|
||||
WHERE t.id = sub.id;
|
||||
|
||||
CREATE UNIQUE INDEX triggers_app_name_uniq ON triggers (app_id, name);
|
||||
82
crates/manager-core/migrations/0047_groups.sql
Normal file
82
crates/manager-core/migrations/0047_groups.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- Phase 2: groups as a pure org / RBAC / UI container — see
|
||||
-- docs/design/groups-and-project-tool.md §5, §9.
|
||||
--
|
||||
-- Groups form a GitLab-like, single-parent tree ABOVE apps. Phase 2 adds
|
||||
-- the tree, hierarchy-aware membership, and structural-mutation safety —
|
||||
-- but NO group-owned resources yet (scripts/vars/secrets stay app-owned;
|
||||
-- that is Phase 3). The only data-plane touch is apps gaining a parent
|
||||
-- pointer.
|
||||
--
|
||||
-- Adoption (§9): every existing app must have a parent from day one so
|
||||
-- resolution always terminates. This migration seeds a single instance
|
||||
-- root group and reparents every app under it, then promotes
|
||||
-- apps.group_id to NOT NULL.
|
||||
|
||||
CREATE TABLE groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Single parent keeps inheritance acyclic and resolution
|
||||
-- deterministic. NULL parent = a root node. RESTRICT (never CASCADE):
|
||||
-- deleting a non-empty group is refused so descendant apps and their
|
||||
-- isolated data can't be destroyed implicitly (§5.6). The ancestor-walk
|
||||
-- cycle guard that keeps this acyclic lives in manager-core (a SQL
|
||||
-- CHECK can't express it).
|
||||
parent_id UUID REFERENCES groups(id) ON DELETE RESTRICT,
|
||||
-- Instance-global identifier, frozen at creation. A rename/reparent
|
||||
-- updates the display name/path but NEVER rewrites the slug, so the
|
||||
-- deployment key stays stable and external references don't break.
|
||||
-- Format validation lives in Rust handlers (same rule as app slugs).
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- Per-subtree structure version (§6): bumped on every structural
|
||||
-- mutation of this node (reparent/rename/delete) so a future CLI/
|
||||
-- orchestrator can detect structural drift. NOT an authz input —
|
||||
-- authorization is resolved live every request.
|
||||
structure_version BIGINT NOT NULL DEFAULT 1,
|
||||
-- §7 ownership seam — the project-root that manages this node. Inert
|
||||
-- in Phase 2 (no projects table yet); nullable, no FK.
|
||||
owner_project UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX groups_parent_id_idx ON groups (parent_id);
|
||||
|
||||
-- Per-(user, group) explicit grant, mirroring app_members. Inherited
|
||||
-- membership (GitLab-style) is resolved in code by walking ancestors: a
|
||||
-- group_admin on any ancestor is implicitly app_admin on every app and
|
||||
-- subgroup beneath it. Roles reuse the SAME three literals as app_members
|
||||
-- so AppRole round-trips with zero mapping and the authz rank table
|
||||
-- covers both tables.
|
||||
CREATE TABLE group_members (
|
||||
group_id UUID NOT NULL REFERENCES groups(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 (group_id, user_id)
|
||||
);
|
||||
|
||||
-- Hot path is the authz ancestor walk "what groups does this user have a
|
||||
-- role on?" plus the per-group member list.
|
||||
CREATE INDEX group_members_user_id_idx ON group_members (user_id);
|
||||
|
||||
-- Add the parent pointer to apps (nullable for the backfill window).
|
||||
ALTER TABLE apps ADD COLUMN group_id UUID;
|
||||
|
||||
-- Seed a single instance root group and reparent every existing app under
|
||||
-- it. App slugs are already instance-global, so no slug rewrite is needed
|
||||
-- — the parent pointer is new metadata layered on top.
|
||||
WITH root_group AS (
|
||||
INSERT INTO groups (slug, name, description)
|
||||
VALUES ('root', 'Root', 'The instance root group — parent of all apps created before groups landed.')
|
||||
RETURNING id
|
||||
)
|
||||
UPDATE apps SET group_id = (SELECT id FROM root_group);
|
||||
|
||||
-- Every app now has a parent; promote to NOT NULL + FK. RESTRICT so a
|
||||
-- group with apps can't be deleted out from under them (§5.6).
|
||||
ALTER TABLE apps ALTER COLUMN group_id SET NOT NULL;
|
||||
ALTER TABLE apps
|
||||
ADD CONSTRAINT apps_group_id_fk FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX apps_group_id_idx ON apps (group_id);
|
||||
47
crates/manager-core/migrations/0048_vars.sql
Normal file
47
crates/manager-core/migrations/0048_vars.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- Phase 3: group-inherited config — the `vars` table + an app environment
|
||||
-- marker. See docs/design/groups-and-project-tool.md §3, §5.1.
|
||||
--
|
||||
-- `vars` is the net-new, env-scoped configuration layer (greenfield — there
|
||||
-- is no env-agnostic config today, only `secrets`). A var is owned by
|
||||
-- exactly one group OR one app, optionally scoped to an environment, and
|
||||
-- resolved down the tree (§3): env-filter first, then nearest level wins.
|
||||
--
|
||||
-- "An environment is an app": each env is already a distinct app row. To
|
||||
-- env-filter group-level `@E` values, the resolver must know which env an
|
||||
-- app represents — recorded here on `apps.environment` (NULL = env-agnostic,
|
||||
-- set at app-create from the CLI's known env).
|
||||
|
||||
ALTER TABLE apps ADD COLUMN environment TEXT;
|
||||
|
||||
CREATE TABLE vars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a
|
||||
-- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling
|
||||
-- owner is impossible.
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
CONSTRAINT vars_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
-- '*' = env-agnostic; otherwise an env name matched against
|
||||
-- apps.environment. NOT NULL with a '*' sentinel so the uniqueness
|
||||
-- indexes are total (a NULL scope would break UNIQUE).
|
||||
environment_scope TEXT NOT NULL DEFAULT '*',
|
||||
key TEXT NOT NULL,
|
||||
-- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an
|
||||
-- inherited key, §3) is the explicit boolean below, NOT value = 'null'
|
||||
-- — JSON null is a legitimate value and must stay distinguishable.
|
||||
value JSONB NOT NULL,
|
||||
is_tombstone BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One row per (owner, scope, key). Partial unique indexes because the owner
|
||||
-- is split across two nullable columns.
|
||||
CREATE UNIQUE INDEX vars_group_uidx
|
||||
ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX vars_app_uidx
|
||||
ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL;
|
||||
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Phase 3 (v1.1.9): group-owned, environment-scoped secrets.
|
||||
--
|
||||
-- Until now `secrets` was strictly per-app: PK `(app_id, name)`, one
|
||||
-- envelope per app. Phase 3 makes secrets inheritable down the group tree
|
||||
-- (blueprint §11 bullet 3 / docs/design §3), exactly like `vars` (0048):
|
||||
-- a secret may be owned by an app OR an ancestor group, and a descendant
|
||||
-- app resolves the nearest one, environment-filtered.
|
||||
--
|
||||
-- Reshape (mirrors `vars`):
|
||||
-- * `group_id` — nullable FK→groups, CASCADE (a deleted group drops its
|
||||
-- secrets, same as its vars).
|
||||
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||
-- enforces "owned by an app XOR a group".
|
||||
-- * `environment_scope` — `'*'` (env-agnostic) or a concrete env name;
|
||||
-- the resolver filters on it. Existing rows backfill to `'*'`, so every
|
||||
-- current app secret stays env-agnostic and resolves unchanged.
|
||||
-- * PK `(app_id, name)` → two PARTIAL unique indexes, one per owner, both
|
||||
-- keyed `(owner, environment_scope, name)`.
|
||||
--
|
||||
-- CRYPTO INVARIANT (audit 2026-06-11 H-D1): the v1 AAD is
|
||||
-- `secret:{app_id}:{name}` for app secrets and `secret:group:{group_id}:{name}`
|
||||
-- for group secrets — it does NOT include `environment_scope`. Adding the
|
||||
-- column therefore leaves every existing ciphertext decryptable byte-for-byte;
|
||||
-- the app-owner AAD is unchanged and the group namespace is disjoint.
|
||||
|
||||
ALTER TABLE secrets
|
||||
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
ADD COLUMN environment_scope TEXT NOT NULL DEFAULT '*';
|
||||
|
||||
-- Drop the old composite PK first: `app_id` cannot lose NOT NULL while it
|
||||
-- is a primary-key column.
|
||||
ALTER TABLE secrets
|
||||
DROP CONSTRAINT secrets_pkey;
|
||||
|
||||
ALTER TABLE secrets
|
||||
ALTER COLUMN app_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE secrets
|
||||
ADD CONSTRAINT secrets_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||
|
||||
-- One secret per (owner, env, name). Partial so each owner column only
|
||||
-- constrains its own rows; the resolver and every upsert restate the
|
||||
-- predicate as the ON CONFLICT arbiter.
|
||||
CREATE UNIQUE INDEX secrets_app_uidx
|
||||
ON secrets (app_id, environment_scope, name) WHERE app_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX secrets_group_uidx
|
||||
ON secrets (group_id, environment_scope, name) WHERE group_id IS NOT NULL;
|
||||
|
||||
-- Owner lookup index for the group side (the app side keeps idx_secrets_app).
|
||||
CREATE INDEX idx_secrets_group ON secrets (group_id) WHERE group_id IS NOT NULL;
|
||||
@@ -12,8 +12,8 @@ use axum::{
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox,
|
||||
ScriptValidator, ValidatedScript, ValidationError,
|
||||
AppId, ExecutionLog, ExecutionSource, InstanceRole, Principal, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -246,6 +246,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
} else {
|
||||
Some(input.sandbox)
|
||||
},
|
||||
// Scripts are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
imports: validated.imports,
|
||||
})
|
||||
.await?;
|
||||
@@ -258,7 +260,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
/// real KV bridge — defense against author confusion, not a security
|
||||
/// boundary (stdlib namespaces and module imports already live in
|
||||
/// disjoint Rhai scopes).
|
||||
const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||
pub(crate) const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||
"log",
|
||||
"regex",
|
||||
"random",
|
||||
@@ -345,6 +347,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
memory_limit_mb: input.memory_limit_mb,
|
||||
sandbox: input.sandbox,
|
||||
kind: input.kind,
|
||||
enabled: None,
|
||||
imports: imports_for_patch,
|
||||
},
|
||||
)
|
||||
@@ -385,6 +388,10 @@ pub struct LogsQuery {
|
||||
#[serde(default, rename = "offset")]
|
||||
#[allow(dead_code)]
|
||||
pub legacy_offset: Option<i64>,
|
||||
/// Optional origin filter (`http`, `kv`, `cron`, `invoke`, …). Absent
|
||||
/// → all sources. An unrecognized value is a 422 (see `list_logs`).
|
||||
#[serde(default)]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
const fn default_limit() -> i64 {
|
||||
@@ -411,7 +418,17 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
.cursor
|
||||
.as_deref()
|
||||
.and_then(crate::repo::ExecutionLogCursor::decode);
|
||||
let logs = state.logs.list_for_script(id, limit, cursor).await?;
|
||||
let source = match q.source.as_deref() {
|
||||
None | Some("" | "all") => None,
|
||||
Some(s) => Some(
|
||||
ExecutionSource::from_wire(s)
|
||||
.ok_or_else(|| ApiError::BadRequest(format!("unknown log source: {s:?}")))?,
|
||||
),
|
||||
};
|
||||
let logs = state
|
||||
.logs
|
||||
.list_for_script(id, limit, cursor, source)
|
||||
.await?;
|
||||
Ok(Json(logs))
|
||||
}
|
||||
|
||||
@@ -427,6 +444,9 @@ pub enum ApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
@@ -459,11 +479,10 @@ 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(_) => {
|
||||
Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "authz repo error");
|
||||
|
||||
@@ -68,6 +68,7 @@ async fn seed_into(
|
||||
timeout_seconds: Some(5),
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
imports: Vec::new(),
|
||||
})
|
||||
.await?;
|
||||
@@ -85,6 +86,7 @@ async fn seed_into(
|
||||
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
|
||||
/// SQL fragment ranking the three role literals by authority so a CTE can
|
||||
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
|
||||
/// both effective-role queries.
|
||||
const ROLE_RANK_CTE: &str =
|
||||
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppMembersRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
||||
impl AuthzRepo for PostgresAppMembersRepository {
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
self.find(user_id, app_id)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||
}
|
||||
|
||||
/// Highest effective role on `app_id`: one recursive CTE walks the
|
||||
/// app's group chain (app.group_id → groups.parent_id → … → root,
|
||||
/// depth-bounded), then `MAX`es the app's own `app_members` row with
|
||||
/// every ancestor `group_members` row by authority rank. A single
|
||||
/// round-trip regardless of tree depth. `None` = no grant anywhere.
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"WITH RECURSIVE ancestors AS (
|
||||
SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
|
||||
UNION ALL
|
||||
SELECT g.parent_id, anc.depth + 1
|
||||
FROM groups g JOIN ancestors anc ON g.id = anc.gid
|
||||
WHERE g.parent_id IS NOT NULL AND anc.depth < 64
|
||||
),
|
||||
{ROLE_RANK_CTE}
|
||||
SELECT eff.role
|
||||
FROM (
|
||||
SELECT am.role FROM app_members am
|
||||
WHERE am.user_id = $1 AND am.app_id = $2
|
||||
UNION ALL
|
||||
SELECT gm.role FROM group_members gm
|
||||
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||
WHERE gm.user_id = $1
|
||||
) eff
|
||||
JOIN role_rank rr ON rr.role = eff.role
|
||||
ORDER BY rr.rank DESC
|
||||
LIMIT 1"
|
||||
))
|
||||
.bind(user_id.into_inner())
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||
row.map(|(role,)| {
|
||||
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||
"invalid role {role:?} in members table"
|
||||
)))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Highest effective role on a *group* node — walks the group's own
|
||||
/// ancestor chain over `group_members`. Gates group management.
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
|
||||
UNION ALL
|
||||
SELECT g.id, g.parent_id, anc.depth + 1
|
||||
FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
|
||||
WHERE anc.depth < 64
|
||||
),
|
||||
{ROLE_RANK_CTE}
|
||||
SELECT gm.role
|
||||
FROM group_members gm
|
||||
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||
JOIN role_rank rr ON rr.role = gm.role
|
||||
WHERE gm.user_id = $1
|
||||
ORDER BY rr.rank DESC
|
||||
LIMIT 1"
|
||||
))
|
||||
.bind(user_id.into_inner())
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||
row.map(|(role,)| {
|
||||
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||
"invalid role {role:?} in group_members table"
|
||||
)))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! that writes the history row in the same transaction.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AdminUserId, App, AppId};
|
||||
use picloud_shared::{AdminUserId, App, AppId, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync {
|
||||
/// 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>;
|
||||
/// Apps whose parent is `group_id`. Drives the group detail view.
|
||||
async fn list_for_group(&self, group_id: GroupId) -> 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(
|
||||
@@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> 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
|
||||
@@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
@@ -116,7 +120,7 @@ impl PostgresAppRepository {
|
||||
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 \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps ORDER BY name",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -126,7 +130,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
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 \
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||
FROM apps a \
|
||||
JOIN app_members m ON m.app_id = a.id \
|
||||
WHERE m.user_id = $1 \
|
||||
@@ -138,9 +142,20 @@ impl AppRepository for PostgresAppRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE group_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(group_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 \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -151,7 +166,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
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 \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
@@ -181,7 +196,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
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 \
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.group_id, 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",
|
||||
@@ -197,15 +212,17 @@ impl AppRepository for PostgresAppRepository {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> 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",
|
||||
"INSERT INTO apps (slug, name, description, group_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
@@ -223,6 +240,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
@@ -230,13 +248,14 @@ impl AppRepository for PostgresAppRepository {
|
||||
.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",
|
||||
"INSERT INTO apps (slug, name, description, group_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
@@ -264,7 +283,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
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",
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
@@ -298,7 +317,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
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 \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -357,7 +376,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
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",
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(new_slug)
|
||||
@@ -432,6 +451,7 @@ struct AppRow {
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
group_id: uuid::Uuid,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -443,6 +463,7 @@ impl From<AppRow> for App {
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
group_id: r.group_id.into(),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
|
||||
173
crates/manager-core/src/apply_api.rs
Normal file
173
crates/manager-core/src/apply_api.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Admin HTTP surface for the declarative reconcile engine.
|
||||
//!
|
||||
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
|
||||
//! against the app's live state and return the plan. Read-only; requires
|
||||
//! `AppRead`. The `apply` route (write path) lands in the next milestone.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
|
||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||
pub fn apply_router(service: ApplyService) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{id}/plan", post(plan_handler))
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
#[serde(default)]
|
||||
pub prune: bool,
|
||||
/// Optional bound-plan token from a prior `plan`. When present, apply
|
||||
/// refuses (409) if the app's live state has changed since.
|
||||
#[serde(default)]
|
||||
pub expected_token: Option<String>,
|
||||
}
|
||||
|
||||
async fn apply_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(req): Json<ApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// Read is always needed; write caps are required for the resource kinds
|
||||
// the bundle touches — and for ALL kinds when `prune` is set, since
|
||||
// pruning deletes resources whose bundle section is empty (and a script
|
||||
// delete cascades its routes/triggers).
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if req.prune || !req.bundle.scripts.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.routes.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.triggers.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email triggers resolve and decrypt a stored secret by name server-side,
|
||||
// which the secrets API guards with `AppSecretsRead`. Require it here too
|
||||
// so apply can't bind a secret a principal couldn't otherwise read — the
|
||||
// caps aren't strictly nested on the API-key scope path.
|
||||
if req.bundle.triggers.iter().any(BundleTrigger::is_email) {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
let report = svc
|
||||
.apply(
|
||||
app_id,
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
async fn plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(bundle): Json<Bundle>,
|
||||
) -> Result<Json<PlanResult>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
||||
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
||||
// at every tier (same `script:read` scope, both in the viewer role). If a
|
||||
// future authz split puts `AppSecretsRead` on its own tier, this handler
|
||||
// must additionally require it — otherwise it leaks names a principal
|
||||
// couldn't enumerate via the secrets API.
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let plan = svc.plan(app_id, &bundle).await?;
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
||||
/// Mirrors the `triggers_api` helper of the same shape.
|
||||
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
fn map_authz(denied: AuthzDenied) -> ApplyError {
|
||||
match denied {
|
||||
AuthzDenied::Denied => ApplyError::Forbidden,
|
||||
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApplyError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apply authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "apply backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
2405
crates/manager-core/src/apply_service.rs
Normal file
2405
crates/manager-core/src/apply_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ 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::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
@@ -44,6 +45,9 @@ pub struct AppsState {
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
/// Group tree — resolves an app's parent group at create time
|
||||
/// (defaults to the instance root).
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
}
|
||||
|
||||
pub fn apps_router(state: AppsState) -> Router {
|
||||
@@ -80,6 +84,10 @@ pub struct CreateAppRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Parent group (slug or id). Defaults to the instance root group when
|
||||
/// omitted — every app has a parent from day one (§9).
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||
/// the requested slug (breaking old redirects).
|
||||
#[serde(default)]
|
||||
@@ -176,23 +184,46 @@ async fn create_app(
|
||||
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
// Resolve the parent group: an explicit `group` (slug or id) or the
|
||||
// instance root by default. Placing an app under a specific group
|
||||
// additionally requires group-write there.
|
||||
let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
|
||||
if input.group.is_some() {
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupWrite(parent.id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// 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));
|
||||
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||
}
|
||||
}
|
||||
|
||||
let created = if input.force_takeover {
|
||||
s.apps
|
||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||
.create_with_takeover(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent.id,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
s.apps
|
||||
.create(&input.slug, &input.name, input.description.as_deref())
|
||||
.create(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent.id,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
@@ -235,7 +266,31 @@ async fn compute_my_role(
|
||||
) -> 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?),
|
||||
// Effective role: folds in inherited group memberships so the
|
||||
// dashboard badge reflects what the caller can actually do.
|
||||
InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an optional group identifier (slug or UUID) to a group,
|
||||
/// defaulting to the instance root group when `None`.
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: Option<&str>,
|
||||
) -> Result<picloud_shared::Group, AppsApiError> {
|
||||
match ident {
|
||||
None => groups
|
||||
.get_by_slug(ROOT_GROUP_SLUG)
|
||||
.await?
|
||||
.ok_or_else(|| AppsApiError::GroupNotFound(ROOT_GROUP_SLUG.to_string())),
|
||||
Some(ident) => {
|
||||
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
groups.get_by_id(uuid.into()).await?
|
||||
} else {
|
||||
groups.get_by_slug(ident).await?
|
||||
};
|
||||
found.ok_or_else(|| AppsApiError::GroupNotFound(ident.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +334,7 @@ async fn patch_app(
|
||||
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::SlugInHistory(Box::new(current)));
|
||||
}
|
||||
return Err(AppsApiError::Conflict(msg));
|
||||
}
|
||||
@@ -521,14 +576,19 @@ pub enum AppsApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("group not found: {0}")]
|
||||
GroupNotFound(String),
|
||||
|
||||
#[error("domain not found: {0}")]
|
||||
DomainNotFound(Uuid),
|
||||
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
|
||||
// Boxed: `App` is large enough to trip clippy::result_large_err on
|
||||
// every handler returning `Result<_, AppsApiError>`.
|
||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||
SlugInHistory(App),
|
||||
SlugInHistory(Box<App>),
|
||||
|
||||
#[error("app still contains {0} script(s); delete or move them first")]
|
||||
HasScripts(i64),
|
||||
@@ -567,10 +627,22 @@ impl From<AuthzError> for AppsApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::group_repo::GroupRepositoryError> for AppsApiError {
|
||||
fn from(e: crate::group_repo::GroupRepositoryError) -> Self {
|
||||
use crate::group_repo::GroupRepositoryError as G;
|
||||
match e {
|
||||
G::NotFound(id) => Self::GroupNotFound(id.to_string()),
|
||||
G::Conflict(msg) => Self::Conflict(msg),
|
||||
G::Db(e) => Self::Repo(ScriptRepositoryError::Db(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_)
|
||||
| Self::GroupNotFound(_)
|
||||
| Self::DomainNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! external user-facing label.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
use picloud_shared::{AppId, AppRole, GroupId, 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
|
||||
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub enum Capability {
|
||||
/// Create a new app. Owner / admin only.
|
||||
InstanceCreateApp,
|
||||
/// Create a new group (root-level). Owner / admin only — a Member
|
||||
/// creates subgroups under a group they group-admin (gated by
|
||||
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
|
||||
InstanceCreateGroup,
|
||||
/// Read group metadata + list its subgroups/apps. Viewer+ on the
|
||||
/// group (inherited from any ancestor); implicit for admin / owner.
|
||||
GroupRead(GroupId),
|
||||
/// Rename / edit group metadata, move apps into it. Editor+ on the
|
||||
/// group.
|
||||
GroupWrite(GroupId),
|
||||
/// Group settings: delete, reparent, manage group members. group_admin
|
||||
/// on the group (inherited from any ancestor).
|
||||
GroupAdmin(GroupId),
|
||||
/// Create / update / delete admin_users rows (other than self
|
||||
/// password change, which is a separate flow). Owner / admin.
|
||||
InstanceManageUsers,
|
||||
@@ -103,6 +116,25 @@ pub enum Capability {
|
||||
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
||||
/// Granted to `editor`+, maps to `script:write` on API keys.
|
||||
AppSecretsWrite(AppId),
|
||||
/// Read this app's resolved config vars (Phase 3). Same trust shape as
|
||||
/// secrets-read — granted to `viewer`+, maps to `script:read`.
|
||||
AppVarsRead(AppId),
|
||||
/// Write (set/delete) an app-owned config var (Phase 3). Granted to
|
||||
/// `editor`+, maps to `script:write`.
|
||||
AppVarsWrite(AppId),
|
||||
/// Read a group's config vars (Phase 3). Resolved via the group
|
||||
/// ancestor walk; viewer+ on the group.
|
||||
GroupVarsRead(GroupId),
|
||||
/// Write (set/delete) a group-owned config var (Phase 3). editor+ on
|
||||
/// the group.
|
||||
GroupVarsWrite(GroupId),
|
||||
/// Read a group-owned secret's VALUE (Phase 3, the human-read gate).
|
||||
/// group_admin on the owning group — distinct from runtime injection,
|
||||
/// which an inheriting app does without this check.
|
||||
GroupSecretsRead(GroupId),
|
||||
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
|
||||
/// group.
|
||||
GroupSecretsWrite(GroupId),
|
||||
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
||||
/// to `script:write` on API keys (sending mail is an outbound
|
||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||
@@ -154,9 +186,20 @@ impl Capability {
|
||||
#[must_use]
|
||||
pub const fn app_id(self) -> Option<AppId> {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
None
|
||||
}
|
||||
Self::InstanceCreateApp
|
||||
| Self::InstanceManageUsers
|
||||
| Self::InstanceManageSettings
|
||||
| Self::InstanceCreateGroup
|
||||
// Group-scoped caps carry a GroupId, not an AppId. They return
|
||||
// None here so a bound API key (which can only target its one
|
||||
// app) is denied group management at the binding layer.
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_)
|
||||
| Self::GroupVarsRead(_)
|
||||
| Self::GroupVarsWrite(_)
|
||||
| Self::GroupSecretsRead(_)
|
||||
| Self::GroupSecretsWrite(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -174,6 +217,8 @@ impl Capability {
|
||||
| Self::AppQueueEnqueue(id)
|
||||
| Self::AppSecretsRead(id)
|
||||
| Self::AppSecretsWrite(id)
|
||||
| Self::AppVarsRead(id)
|
||||
| Self::AppVarsWrite(id)
|
||||
| Self::AppEmailSend(id)
|
||||
| Self::AppManageTriggers(id)
|
||||
| Self::AppDeadLetterManage(id)
|
||||
@@ -193,15 +238,19 @@ impl Capability {
|
||||
#[must_use]
|
||||
pub const fn required_scope(self) -> Scope {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
Scope::InstanceAdmin
|
||||
}
|
||||
Self::InstanceCreateApp
|
||||
| Self::InstanceManageUsers
|
||||
| Self::InstanceManageSettings
|
||||
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
|
||||
Self::AppRead(_)
|
||||
| Self::AppKvRead(_)
|
||||
| Self::AppDocsRead(_)
|
||||
| Self::AppFilesRead(_)
|
||||
| Self::AppSecretsRead(_)
|
||||
| Self::AppUsersRead(_) => Scope::ScriptRead,
|
||||
| Self::AppUsersRead(_)
|
||||
| Self::AppVarsRead(_)
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupVarsRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
@@ -213,13 +262,23 @@ impl Capability {
|
||||
| Self::AppEmailSend(_)
|
||||
| Self::AppUsersWrite(_)
|
||||
| Self::AppUsersAdmin(_)
|
||||
| Self::AppVarsWrite(_)
|
||||
// Group-secret WRITE is editor-role-gated (same tier as
|
||||
// GroupVarsWrite), so its API-key scope matches: script:write,
|
||||
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
|
||||
// the admin tier below.
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
Self::AppAdmin(_)
|
||||
| Self::AppManageTriggers(_)
|
||||
| Self::AppDeadLetterManage(_)
|
||||
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
||||
| Self::AppTopicManage(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_)
|
||||
| Self::GroupVarsWrite(_)
|
||||
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
|
||||
Self::AppLogRead(_) => Scope::LogRead,
|
||||
}
|
||||
}
|
||||
@@ -230,11 +289,41 @@ impl Capability {
|
||||
/// means unit tests can stub it.
|
||||
#[async_trait]
|
||||
pub trait AuthzRepo: Send + Sync {
|
||||
/// Direct `app_members` row for (user, app). The single-row lookup
|
||||
/// used by member-management surfaces and as the fallback below.
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError>;
|
||||
|
||||
/// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
|
||||
/// the app's own `app_members` row folded with every `group_members`
|
||||
/// row on any ancestor group, max-by-authority. This is what `can()`
|
||||
/// consults so a `group_admin` on an ancestor is implicitly app_admin
|
||||
/// on the app.
|
||||
///
|
||||
/// Default = direct membership only (no inheritance), so the many test
|
||||
/// stubs that model no group tree keep their existing behavior; the
|
||||
/// Postgres repo overrides this with an ancestor-walking CTE.
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
self.membership(user_id, app_id).await
|
||||
}
|
||||
|
||||
/// Highest effective role on a *group* node — the group's own
|
||||
/// ancestor walk over `group_members`. Gates the group-management
|
||||
/// capabilities. Default = no grant; the Postgres repo overrides it.
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Repo errors surface here so handlers can map them to 500 without
|
||||
@@ -353,7 +442,26 @@ async fn role_grants(
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner => Ok(true),
|
||||
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
||||
InstanceRole::Member => match cap {
|
||||
// Group-management caps resolve against the group ancestor
|
||||
// walk (a group_admin on an ancestor is implicitly admin of
|
||||
// the descendant group). Routed before member_grants because
|
||||
// group caps carry no app_id.
|
||||
Capability::GroupRead(g)
|
||||
| Capability::GroupWrite(g)
|
||||
| Capability::GroupAdmin(g)
|
||||
| Capability::GroupVarsRead(g)
|
||||
| Capability::GroupVarsWrite(g)
|
||||
| Capability::GroupSecretsRead(g)
|
||||
| Capability::GroupSecretsWrite(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
|
||||
// the handler, which routes through the arm above.)
|
||||
Capability::InstanceCreateGroup => Ok(false),
|
||||
_ => member_grants(repo, principal.user_id, cap).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,12 +485,51 @@ async fn member_grants(
|
||||
let Some(app_id) = cap.app_id() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(role) = repo.membership(user_id, app_id).await? else {
|
||||
// Effective (inherited) role: the app's own membership folded with any
|
||||
// ancestor group membership. A group_admin on an ancestor group is
|
||||
// implicitly app_admin here.
|
||||
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(role_satisfies(role, cap))
|
||||
}
|
||||
|
||||
/// Member-path resolution for the group-management capabilities. Resolves
|
||||
/// the caller's effective role on the group (ancestor walk over
|
||||
/// `group_members`) and checks it covers the requested group action.
|
||||
async fn group_member_grants(
|
||||
repo: &dyn AuthzRepo,
|
||||
user_id: UserId,
|
||||
cap: Capability,
|
||||
group_id: GroupId,
|
||||
) -> Result<bool, AuthzError> {
|
||||
let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(group_role_satisfies(role, cap))
|
||||
}
|
||||
|
||||
/// Does the effective group `AppRole` cover the group capability?
|
||||
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
match cap {
|
||||
// viewer+ reads group metadata and config vars.
|
||||
Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true,
|
||||
// editor+ writes config vars/secrets.
|
||||
Capability::GroupWrite(_)
|
||||
| Capability::GroupVarsWrite(_)
|
||||
| Capability::GroupSecretsWrite(_) => {
|
||||
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||
}
|
||||
// group_admin manages the group + reads secret VALUES (the
|
||||
// human-read gate, distinct from an app's runtime injection).
|
||||
Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => {
|
||||
matches!(role, AppRole::AppAdmin)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -397,6 +544,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppFilesRead(_)
|
||||
| Capability::AppSecretsRead(_)
|
||||
| Capability::AppUsersRead(_)
|
||||
| Capability::AppVarsRead(_)
|
||||
);
|
||||
let in_editor = in_viewer
|
||||
|| matches!(
|
||||
@@ -412,6 +560,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppSecretsWrite(_)
|
||||
| Capability::AppEmailSend(_)
|
||||
| Capability::AppUsersWrite(_)
|
||||
| Capability::AppVarsWrite(_)
|
||||
| Capability::AppInvoke(_)
|
||||
);
|
||||
let in_app_admin = in_editor
|
||||
@@ -471,16 +620,61 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database. Models
|
||||
/// direct app memberships PLUS a group tree (app→group, group→parent)
|
||||
/// and group memberships, so the hierarchy-aware resolution can be
|
||||
/// exercised without Postgres — mirroring the recursive-CTE behavior.
|
||||
#[derive(Default)]
|
||||
struct InMemoryAuthzRepo {
|
||||
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||
app_group: Mutex<HashMap<AppId, GroupId>>,
|
||||
group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
|
||||
group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
|
||||
}
|
||||
|
||||
impl InMemoryAuthzRepo {
|
||||
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||
self.memberships.lock().await.insert((user, app), role);
|
||||
}
|
||||
/// Register a group node and its parent (`None` = root).
|
||||
async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
|
||||
self.group_parent.lock().await.insert(group, parent);
|
||||
}
|
||||
/// Place an app under a group.
|
||||
async fn put_app(&self, app: AppId, group: GroupId) {
|
||||
self.app_group.lock().await.insert(app, group);
|
||||
}
|
||||
/// Grant a group-level role.
|
||||
async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
|
||||
self.group_memberships
|
||||
.lock()
|
||||
.await
|
||||
.insert((user, group), role);
|
||||
}
|
||||
|
||||
/// Fold every ancestor group membership starting at `group`,
|
||||
/// max-by-authority, into `acc`.
|
||||
async fn fold_group_chain(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
mut group: Option<GroupId>,
|
||||
mut acc: Option<AppRole>,
|
||||
) -> Option<AppRole> {
|
||||
let memberships = self.group_memberships.lock().await;
|
||||
let parents = self.group_parent.lock().await;
|
||||
let mut hops = 0u32;
|
||||
while let Some(g) = group {
|
||||
if let Some(r) = memberships.get(&(user_id, g)).copied() {
|
||||
acc = Some(acc.map_or(r, |a| a.max(r)));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
break;
|
||||
}
|
||||
group = parents.get(&g).copied().flatten();
|
||||
}
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -497,6 +691,29 @@ mod tests {
|
||||
.get(&(user_id, app_id))
|
||||
.copied())
|
||||
}
|
||||
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let direct = self
|
||||
.memberships
|
||||
.lock()
|
||||
.await
|
||||
.get(&(user_id, app_id))
|
||||
.copied();
|
||||
let start = self.app_group.lock().await.get(&app_id).copied();
|
||||
Ok(self.fold_group_chain(user_id, start, direct).await)
|
||||
}
|
||||
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
|
||||
}
|
||||
}
|
||||
|
||||
fn principal(role: InstanceRole) -> Principal {
|
||||
@@ -857,12 +1074,183 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Hierarchy-aware RBAC (Phase 2 groups)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_admin_on_ancestor_is_implicit_app_admin() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
repo.put_app(app, acme).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
// No app_members row — authority comes purely from the group.
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
for cap in [
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppAdmin(app),
|
||||
] {
|
||||
assert!(
|
||||
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||
"inherited group_admin denied {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
repo.put_app(app, acme).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
// Direct viewer on the app, app_admin via the ancestor group:
|
||||
// the higher (app_admin) wins.
|
||||
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_role_inherits_down_a_multi_level_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
repo.put_app(app, team).await;
|
||||
|
||||
// Editor two levels up flows down to the app as editor.
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, root, AppRole::Editor).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"editor must not get app_admin"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_membership_grants_no_instance_capabilities() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
for cap in [
|
||||
Capability::InstanceCreateApp,
|
||||
Capability::InstanceCreateGroup,
|
||||
Capability::InstanceManageUsers,
|
||||
] {
|
||||
assert_eq!(
|
||||
can(&repo, &p, cap).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"group_admin must not grant instance cap {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_admin_walks_ancestors_for_group_caps() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
|
||||
|
||||
// group_admin at root ⇒ admin of the descendant group.
|
||||
assert!(can(&repo, &p, Capability::GroupAdmin(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert!(can(&repo, &p, Capability::GroupWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
// An unrelated member gets nothing.
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let g = GroupId::new();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
for cap in [
|
||||
Capability::InstanceCreateGroup,
|
||||
Capability::GroupRead(g),
|
||||
Capability::GroupWrite(g),
|
||||
Capability::GroupAdmin(g),
|
||||
] {
|
||||
assert!(
|
||||
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||
"admin denied group cap {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bound_key_cannot_manage_groups() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let g = GroupId::new();
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::AppAdmin]),
|
||||
app_binding: Some(AppId::new()),
|
||||
};
|
||||
// Group caps carry no app_id, so a bound key is denied at the
|
||||
// binding layer regardless of role/scope.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_role_max_is_authority_ordered() {
|
||||
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
|
||||
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
|
||||
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
|
||||
}
|
||||
|
||||
#[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));
|
||||
// Group caps are not app-scoped.
|
||||
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
|
||||
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
158
crates/manager-core/src/config_api.rs
Normal file
158
crates/manager-core/src/config_api.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! `GET /api/v1/admin/apps/{id}/config/effective` — the resolved config an
|
||||
//! app actually sees: every inherited var (with its value + provenance) and
|
||||
//! every inherited secret (MASKED — name/owner/scope only, never the value).
|
||||
//!
|
||||
//! This is the read-only companion to the `vars`/`secrets` admin surfaces.
|
||||
//! It runs the same §3 resolution the `vars::`/`secrets::` SDK calls run, so
|
||||
//! a dev can see exactly what `vars::get`/`secrets::get` would return and
|
||||
//! where each value comes from (`--explain` on the CLI surfaces the
|
||||
//! provenance). Gated by `AppVarsRead` (config is app-readable); the secret
|
||||
//! VALUES are deliberately absent — reading those needs `GroupSecretsRead`
|
||||
//! at the owning group via the dedicated value endpoint.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::config_resolver::{
|
||||
fetch_effective_secret_meta, fetch_var_candidates, resolve, OwnerKind,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigApiState {
|
||||
pub pool: PgPool,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn config_router(state: ConfigApiState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{id_or_slug}/config/effective", get(effective_config))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn owner_json(kind: OwnerKind, id: uuid::Uuid, depth: i32) -> serde_json::Value {
|
||||
json!({ "kind": kind.as_str(), "id": id, "depth": depth })
|
||||
}
|
||||
|
||||
async fn effective_config(
|
||||
State(s): State<ConfigApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ConfigApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Vars: resolve to values + provenance (vars are app-readable config).
|
||||
let candidates = fetch_var_candidates(&s.pool, app_id)
|
||||
.await
|
||||
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
|
||||
let (values, provenance) = resolve(candidates);
|
||||
let mut vars = serde_json::Map::new();
|
||||
for (key, value) in values {
|
||||
let p = &provenance[&key];
|
||||
vars.insert(
|
||||
key,
|
||||
json!({
|
||||
"value": value,
|
||||
"owner": owner_json(p.owner_kind, p.owner_id, p.depth),
|
||||
"scope": p.scope,
|
||||
"merged_from": p.merged_from
|
||||
.iter()
|
||||
.map(|(d, sc)| json!({ "depth": d, "scope": sc }))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Secrets: masked — name + owner/level/scope + status, never the value.
|
||||
let secret_meta = fetch_effective_secret_meta(&s.pool, app_id)
|
||||
.await
|
||||
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
|
||||
let mut secrets = serde_json::Map::new();
|
||||
for m in secret_meta {
|
||||
secrets.insert(
|
||||
m.name,
|
||||
json!({
|
||||
"status": "set",
|
||||
"owner": owner_json(m.owner_kind, m.owner_id, m.depth),
|
||||
"scope": m.scope,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "vars": vars, "secrets": secrets })))
|
||||
}
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ConfigApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| ConfigApiError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or(ConfigApiError::AppNotFound)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("config backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for ConfigApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for ConfigApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ConfigApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "config effective authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "config effective backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
456
crates/manager-core/src/config_resolver.rs
Normal file
456
crates/manager-core/src/config_resolver.rs
Normal file
@@ -0,0 +1,456 @@
|
||||
//! The §3 configuration-resolution engine: env-filtered, proximity-first
|
||||
//! inheritance down the group tree.
|
||||
//!
|
||||
//! To resolve a key for app A in environment E (docs/design §3):
|
||||
//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible;
|
||||
//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`.
|
||||
//! Env is *eligibility*, not a precedence tier.
|
||||
//! 2. **Nearest level wins** — walk A → parent group → … → root; the
|
||||
//! closest level that defines the (filtered) key wins. Proximity beats
|
||||
//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`).
|
||||
//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an
|
||||
//! explicit tombstone** that suppresses the inherited key.
|
||||
//!
|
||||
//! The recursive CTE that walks `apps.group_id → groups.parent_id → root`
|
||||
//! mirrors `app_members_repo::effective_app_role`. The env-eligibility
|
||||
//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which
|
||||
//! a window-function pick can't express — happen in the pure `resolve`
|
||||
//! function below, so they're unit-tested without Postgres.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
/// Shared chain-walk CTE: emits one row per owner-level for an app —
|
||||
/// depth 0 = the app itself (`app_owner` set), then ancestor groups
|
||||
/// nearest-first (`group_owner` set), each carrying the app's environment.
|
||||
/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this
|
||||
/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret
|
||||
/// resolvers, which each append their own owner-keyed JOIN.
|
||||
pub(crate) const CHAIN_LEVELS_CTE: &str = "\
|
||||
WITH RECURSIVE chain AS ( \
|
||||
SELECT a.id AS app_owner, NULL::uuid AS group_owner, \
|
||||
a.group_id AS next_group, 0 AS depth, a.environment AS app_env \
|
||||
FROM apps a WHERE a.id = $1 \
|
||||
UNION ALL \
|
||||
SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \
|
||||
FROM groups g JOIN chain c ON g.id = c.next_group \
|
||||
WHERE c.depth < 64 \
|
||||
)";
|
||||
|
||||
/// Owner kind of a resolved value, for `--explain` provenance.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OwnerKind {
|
||||
App,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl OwnerKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::App => "app",
|
||||
Self::Group => "group",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One eligible (env-filtered) candidate row pulled by the resolver, before
|
||||
/// §3 proximity/merge resolution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Candidate {
|
||||
/// 0 = the app itself; 1 = its parent group; … (nearest-first).
|
||||
pub depth: i32,
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: Uuid,
|
||||
/// `*` (env-agnostic) or a concrete environment name.
|
||||
pub scope: String,
|
||||
pub key: String,
|
||||
pub value: Value,
|
||||
pub is_tombstone: bool,
|
||||
}
|
||||
|
||||
/// Where a resolved key came from (for `config --effective --explain`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Provenance {
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: Uuid,
|
||||
pub depth: i32,
|
||||
pub scope: String,
|
||||
/// For a deep-merged map, the `(depth, scope)` of every layer that
|
||||
/// contributed, nearest-first. Empty for a plain scalar/array winner.
|
||||
pub merged_from: Vec<(i32, String)>,
|
||||
}
|
||||
|
||||
/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1).
|
||||
fn env_priority(scope: &str) -> u8 {
|
||||
u8::from(scope != "*")
|
||||
}
|
||||
|
||||
/// Deep-merge `src` into `dst` per key: nested objects merge recursively,
|
||||
/// everything else is replaced by `src`. Caller merges farthest→nearest so
|
||||
/// nearer layers overwrite.
|
||||
fn deep_merge(dst: &mut Map<String, Value>, src: &Map<String, Value>) {
|
||||
for (k, sv) in src {
|
||||
match (dst.get_mut(k), sv) {
|
||||
(Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm),
|
||||
_ => {
|
||||
dst.insert(k.clone(), sv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one key's candidate rows (any order in) to its effective value,
|
||||
/// plus the provenance. Returns `None` when a tombstone suppresses the key.
|
||||
fn resolve_one(mut rows: Vec<Candidate>) -> Option<(Value, Provenance)> {
|
||||
// Nearest-first; within a level, `@E` before `*` (§3 steps 1-2).
|
||||
rows.sort_by(|a, b| {
|
||||
a.depth
|
||||
.cmp(&b.depth)
|
||||
.then(env_priority(&b.scope).cmp(&env_priority(&a.scope)))
|
||||
});
|
||||
|
||||
// §3 step 1: env is eligibility, not a merge tier — within a single level
|
||||
// `@E` *suppresses* `*` (it does not layer on top of it). Each level is one
|
||||
// owner (single-parent tree) with at most one `@E` and one `*` row per key
|
||||
// after env-filtering; keep only the level's winner (the `@E` row sorts
|
||||
// first). Without this, a level holding both an `@E` map and a `*` map would
|
||||
// deep-merge them instead of the `@E` shadowing the `*`.
|
||||
rows.dedup_by_key(|r| r.depth);
|
||||
|
||||
// Collect the contiguous run of map-valued rows from the nearest. A
|
||||
// tombstone or scalar/array boundary stops the run (and, if it's the
|
||||
// nearest row, decides the result outright).
|
||||
let mut maps: Vec<&Candidate> = Vec::new();
|
||||
let mut boundary: Option<&Candidate> = None;
|
||||
for r in &rows {
|
||||
if r.is_tombstone {
|
||||
boundary = Some(r);
|
||||
break;
|
||||
}
|
||||
if r.value.is_object() {
|
||||
maps.push(r);
|
||||
} else {
|
||||
boundary = Some(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(first) = maps.first() {
|
||||
// Map run wins; deep-merge farthest→nearest so nearest keys win.
|
||||
let mut acc = Map::new();
|
||||
for m in maps.iter().rev() {
|
||||
if let Value::Object(obj) = &m.value {
|
||||
deep_merge(&mut acc, obj);
|
||||
}
|
||||
}
|
||||
let prov = Provenance {
|
||||
owner_kind: first.owner_kind,
|
||||
owner_id: first.owner_id,
|
||||
depth: first.depth,
|
||||
scope: first.scope.clone(),
|
||||
merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(),
|
||||
};
|
||||
return Some((Value::Object(acc), prov));
|
||||
}
|
||||
|
||||
// No leading map run: the nearest row is the boundary.
|
||||
match boundary {
|
||||
// Nearest is a tombstone → key deleted.
|
||||
Some(b) if b.is_tombstone => None,
|
||||
// Nearest is a scalar/array → take it verbatim.
|
||||
Some(b) => Some((
|
||||
b.value.clone(),
|
||||
Provenance {
|
||||
owner_kind: b.owner_kind,
|
||||
owner_id: b.owner_id,
|
||||
depth: b.depth,
|
||||
scope: b.scope.clone(),
|
||||
merged_from: Vec::new(),
|
||||
},
|
||||
)),
|
||||
// No rows at all (caller only passes non-empty groups).
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a full candidate set (mixed keys, any order) into the effective
|
||||
/// config map + per-key provenance. Pure — unit-tested without Postgres.
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
candidates: Vec<Candidate>,
|
||||
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
|
||||
let mut by_key: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
|
||||
for c in candidates {
|
||||
by_key.entry(c.key.clone()).or_default().push(c);
|
||||
}
|
||||
let mut values = BTreeMap::new();
|
||||
let mut provenance = BTreeMap::new();
|
||||
for (key, rows) in by_key {
|
||||
if let Some((v, p)) = resolve_one(rows) {
|
||||
values.insert(key.clone(), v);
|
||||
provenance.insert(key, p);
|
||||
}
|
||||
}
|
||||
(values, provenance)
|
||||
}
|
||||
|
||||
/// Pull every env-eligible `vars` candidate for `app_id`: the app's own
|
||||
/// rows + every ancestor group's rows, filtered to `*` or the app's
|
||||
/// environment, ordered nearest-first.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn fetch_var_candidates(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT c.depth, \
|
||||
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(v.app_id, v.group_id) AS owner_id, \
|
||||
v.environment_scope, v.key, v.value, v.is_tombstone \
|
||||
FROM chain c \
|
||||
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
|
||||
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
|
||||
ORDER BY c.depth ASC"
|
||||
);
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// The masked, resolved view of one inherited secret for `config/effective`:
|
||||
/// which owner/level/scope supplies it — **never** the value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectiveSecretMeta {
|
||||
pub name: String,
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: Uuid,
|
||||
pub scope: String,
|
||||
pub depth: i32,
|
||||
}
|
||||
|
||||
/// Resolve the *names* of every secret an app effectively sees — its own
|
||||
/// plus every ancestor group's, env-filtered, nearest-wins per name. Returns
|
||||
/// masked metadata only (owner/level/scope), so it's safe for an app-level
|
||||
/// principal to read. `DISTINCT ON (name)` with the same ordering as the
|
||||
/// per-name secret resolver guarantees the same winner.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn fetch_effective_secret_meta(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<EffectiveSecretMeta>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT DISTINCT ON (s.name) s.name, \
|
||||
CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(s.app_id, s.group_id) AS owner_id, \
|
||||
s.environment_scope, c.depth \
|
||||
FROM chain c \
|
||||
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \
|
||||
ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC"
|
||||
);
|
||||
let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta {
|
||||
name,
|
||||
owner_kind: if owner_kind == "app" {
|
||||
OwnerKind::App
|
||||
} else {
|
||||
OwnerKind::Group
|
||||
},
|
||||
owner_id,
|
||||
scope,
|
||||
depth,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct VarCandidateRow {
|
||||
depth: i32,
|
||||
owner_kind: String,
|
||||
owner_id: Uuid,
|
||||
environment_scope: String,
|
||||
key: String,
|
||||
value: Value,
|
||||
is_tombstone: bool,
|
||||
}
|
||||
|
||||
impl From<VarCandidateRow> for Candidate {
|
||||
fn from(r: VarCandidateRow) -> Self {
|
||||
Self {
|
||||
depth: r.depth,
|
||||
owner_kind: if r.owner_kind == "app" {
|
||||
OwnerKind::App
|
||||
} else {
|
||||
OwnerKind::Group
|
||||
},
|
||||
owner_id: r.owner_id,
|
||||
scope: r.environment_scope,
|
||||
key: r.key,
|
||||
value: r.value,
|
||||
is_tombstone: r.is_tombstone,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate {
|
||||
Candidate {
|
||||
depth,
|
||||
owner_kind: if depth == 0 {
|
||||
OwnerKind::App
|
||||
} else {
|
||||
OwnerKind::Group
|
||||
},
|
||||
owner_id: Uuid::nil(),
|
||||
scope: scope.into(),
|
||||
key: key.into(),
|
||||
value,
|
||||
is_tombstone: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn tomb(depth: i32, scope: &str, key: &str) -> Candidate {
|
||||
Candidate {
|
||||
is_tombstone: true,
|
||||
value: Value::Null,
|
||||
..cand(depth, scope, key, Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_level_wins() {
|
||||
// app (depth 0) overrides group (depth 2).
|
||||
let (v, p) = resolve(vec![
|
||||
cand(2, "*", "region", json!("eu")),
|
||||
cand(0, "*", "region", json!("us")),
|
||||
]);
|
||||
assert_eq!(v["region"], json!("us"));
|
||||
assert_eq!(p["region"].depth, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_scoped_beats_agnostic_within_a_level() {
|
||||
let (v, p) = resolve(vec![
|
||||
cand(1, "*", "db_url", json!("default")),
|
||||
cand(1, "staging", "db_url", json!("staging-db")),
|
||||
]);
|
||||
assert_eq!(v["db_url"], json!("staging-db"));
|
||||
assert_eq!(p["db_url"].scope, "staging");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_beats_env_specificity_across_levels() {
|
||||
// §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`.
|
||||
let (v, _) = resolve(vec![
|
||||
cand(2, "production", "db_url", json!("anc-prod")),
|
||||
cand(0, "*", "db_url", json!("leaf-default")),
|
||||
]);
|
||||
assert_eq!(v["db_url"], json!("leaf-default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_deep_merge_nearest_wins() {
|
||||
// ancestor sets {title, region}; leaf overrides title, adds locale.
|
||||
let (v, p) = resolve(vec![
|
||||
cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})),
|
||||
cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})),
|
||||
]);
|
||||
assert_eq!(
|
||||
v["ui"],
|
||||
json!({"title": "Leaf", "region": "eu", "locale": "en"})
|
||||
);
|
||||
assert_eq!(p["ui"].merged_from.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_level_env_map_suppresses_agnostic_map_no_merge() {
|
||||
// §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it
|
||||
// shadows it. A level holding both an `@staging` map and a `*` map must
|
||||
// resolve to the `@staging` map alone, never a deep-merge of the two.
|
||||
let (v, p) = resolve(vec![
|
||||
cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})),
|
||||
cand(1, "staging", "cfg", json!({"shared": "staging"})),
|
||||
]);
|
||||
assert_eq!(v["cfg"], json!({"shared": "staging"}));
|
||||
assert_eq!(p["cfg"].scope, "staging");
|
||||
// Provenance must not list the suppressed `*` layer.
|
||||
assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() {
|
||||
// The suppressed same-level `*` must also be invisible to cross-level
|
||||
// merge: leaf `@staging` map merges onto the ancestor map, but the
|
||||
// leaf's own `*` map (shadowed at its level) never contributes.
|
||||
let (v, _) = resolve(vec![
|
||||
cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})),
|
||||
cand(0, "*", "cfg", json!({"leak": "should-not-appear"})),
|
||||
cand(0, "staging", "cfg", json!({"tier": "leaf"})),
|
||||
]);
|
||||
assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"}));
|
||||
assert!(!v["cfg"].as_object().unwrap().contains_key("leak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearer_scalar_replaces_whole_inherited_map() {
|
||||
let (v, _) = resolve(vec![
|
||||
cand(2, "*", "x", json!({"a": 1})),
|
||||
cand(0, "*", "x", json!("scalar")),
|
||||
]);
|
||||
assert_eq!(v["x"], json!("scalar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_suppresses_inherited_key() {
|
||||
let (v, _) = resolve(vec![
|
||||
cand(2, "*", "secret_flag", json!(true)),
|
||||
tomb(0, "*", "secret_flag"),
|
||||
]);
|
||||
assert!(!v.contains_key("secret_flag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_null_is_a_value_not_a_deletion() {
|
||||
let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]);
|
||||
assert!(v.contains_key("k"));
|
||||
assert_eq!(v["k"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_merge_stops_at_a_nearer_scalar_boundary() {
|
||||
// nearest map, then a scalar, then a farther map: only the
|
||||
// contiguous top map run merges; the scalar bounds it.
|
||||
let (v, _) = resolve(vec![
|
||||
cand(3, "*", "m", json!({"deep": 1})),
|
||||
cand(2, "*", "m", json!("scalar-boundary")),
|
||||
cand(0, "*", "m", json!({"near": 2})),
|
||||
]);
|
||||
// depth 0 map is the only one above the depth-2 scalar boundary.
|
||||
assert_eq!(v["m"], json!({"near": 2}));
|
||||
}
|
||||
}
|
||||
48
crates/manager-core/src/dev_email_api.rs
Normal file
48
crates/manager-core/src/dev_email_api.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! `GET /api/v1/admin/dev/emails` — dev-only inspection of mail captured
|
||||
//! by the in-memory email sink (G5).
|
||||
//!
|
||||
//! Mounted **only** when the email service is running in dev-capture mode
|
||||
//! (`PICLOUD_DEV_MODE=true` and no SMTP relay configured). In every other
|
||||
//! configuration the route does not exist, so there is no production
|
||||
//! surface here. Capture is instance-wide (the SMTP transport seam can't
|
||||
//! see a script's `app_id`), so the endpoint is instance-wide too and is
|
||||
//! restricted to instance Owners/Admins.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Json;
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{InstanceRole, Principal};
|
||||
|
||||
use crate::email_service::{CapturedEmail, DevEmailSink};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DevEmailState {
|
||||
pub sink: Arc<DevEmailSink>,
|
||||
}
|
||||
|
||||
/// Build the dev-email router. Callers mount this only when dev-capture
|
||||
/// mode is active (i.e. they hold a `Some(sink)`).
|
||||
pub fn dev_emails_router(state: DevEmailState) -> Router {
|
||||
Router::new()
|
||||
.route("/dev/emails", get(list_dev_emails))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn list_dev_emails(
|
||||
Extension(principal): Extension<Principal>,
|
||||
State(state): State<DevEmailState>,
|
||||
) -> Result<Json<Vec<CapturedEmail>>, StatusCode> {
|
||||
// Instance-wide data → require an instance Owner/Admin. A Member
|
||||
// (app-scoped) principal has no business reading every app's mail.
|
||||
if !matches!(
|
||||
principal.instance_role,
|
||||
InstanceRole::Owner | InstanceRole::Admin
|
||||
) {
|
||||
return Err(StatusCode::FORBIDDEN);
|
||||
}
|
||||
Ok(Json(state.sink.snapshot()))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -248,6 +248,15 @@ fn non_empty_env(key: &str) -> Option<String> {
|
||||
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// `PICLOUD_DEV_MODE=true` (case-insensitive). Matches the detection in
|
||||
/// `shared::crypto` so the dev email sink and the dev master key turn on
|
||||
/// together.
|
||||
fn dev_mode_enabled() -> bool {
|
||||
std::env::var("PICLOUD_DEV_MODE")
|
||||
.map(|v| v.trim().eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Internal transport seam so the service can be tested without a live
|
||||
/// SMTP server. The production impl is [`LettreEmailTransport`]; tests
|
||||
/// use a recording fake.
|
||||
@@ -299,6 +308,97 @@ impl EmailTransport for LettreEmailTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// G5: how many recently-captured dev emails the in-memory sink keeps.
|
||||
/// Old entries are evicted FIFO; this is a debugging aid, not storage.
|
||||
pub const DEV_EMAIL_CAPACITY: usize = 100;
|
||||
|
||||
/// One email captured by the dev sink instead of being relayed. Serialized
|
||||
/// straight onto the dev-only inspection endpoint.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
pub struct CapturedEmail {
|
||||
pub captured_at: chrono::DateTime<chrono::Utc>,
|
||||
pub from: Option<String>,
|
||||
pub to: Vec<String>,
|
||||
/// The full RFC 5322 message (headers + body), exactly as it would
|
||||
/// have hit the relay — enough to eyeball subject/body in dev.
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
/// In-memory ring buffer of captured dev emails. Shared (`Arc`) between
|
||||
/// the [`DevEmailTransport`] that writes and the dev endpoint that reads.
|
||||
pub struct DevEmailSink {
|
||||
captured: std::sync::Mutex<std::collections::VecDeque<CapturedEmail>>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl DevEmailSink {
|
||||
#[must_use]
|
||||
pub fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
captured: std::sync::Mutex::new(std::collections::VecDeque::new()),
|
||||
capacity: capacity.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&self, email: CapturedEmail) {
|
||||
let mut q = self
|
||||
.captured
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while q.len() >= self.capacity {
|
||||
q.pop_front();
|
||||
}
|
||||
q.push_back(email);
|
||||
}
|
||||
|
||||
/// Newest-first snapshot of the captured mail.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CapturedEmail> {
|
||||
let q = self
|
||||
.captured
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
q.iter().rev().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Dev transport: instead of relaying, capture the message in memory and
|
||||
/// log it. Wired only when `PICLOUD_DEV_MODE=true` and no SMTP relay is
|
||||
/// configured, so `email::send` is exercisable locally without a relay.
|
||||
/// NEVER constructed in production (no dev mode → disabled mode instead).
|
||||
pub struct DevEmailTransport {
|
||||
sink: Arc<DevEmailSink>,
|
||||
}
|
||||
|
||||
impl DevEmailTransport {
|
||||
#[must_use]
|
||||
pub fn new(sink: Arc<DevEmailSink>) -> Self {
|
||||
Self { sink }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EmailTransport for DevEmailTransport {
|
||||
async fn send(&self, message: &Message) -> Result<(), EmailError> {
|
||||
let envelope = message.envelope();
|
||||
let from = envelope.from().map(ToString::to_string);
|
||||
let to: Vec<String> = envelope.to().iter().map(ToString::to_string).collect();
|
||||
let raw = String::from_utf8_lossy(&message.formatted()).into_owned();
|
||||
tracing::info!(
|
||||
?from,
|
||||
?to,
|
||||
"email DEV CAPTURE: message captured in memory (not relayed)"
|
||||
);
|
||||
self.sink.push(CapturedEmail {
|
||||
captured_at: chrono::Utc::now(),
|
||||
from,
|
||||
to,
|
||||
raw,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EmailServiceImpl {
|
||||
/// `None` → disabled mode (every send returns `NotConfigured`).
|
||||
transport: Option<Arc<dyn EmailTransport>>,
|
||||
@@ -328,27 +428,58 @@ impl EmailServiceImpl {
|
||||
/// — email is non-critical and must not block startup.
|
||||
#[must_use]
|
||||
pub fn from_env(authz: Arc<dyn AuthzRepo>) -> Self {
|
||||
Self::from_env_with_dev_capture(authz).0
|
||||
}
|
||||
|
||||
/// Like [`from_env`](Self::from_env), but in **dev mode with no SMTP
|
||||
/// relay** it wires a [`DevEmailTransport`] that captures mail in
|
||||
/// memory instead of returning `NotConfigured` — so `email::send` is
|
||||
/// exercisable locally (G5). Returns the sink handle (`Some`) when
|
||||
/// capture mode is active, so the caller can expose it via the
|
||||
/// dev-only inspection endpoint.
|
||||
///
|
||||
/// Production is unaffected: without `PICLOUD_DEV_MODE=true` an unset
|
||||
/// relay still yields disabled mode (`NotConfigured`), never capture.
|
||||
#[must_use]
|
||||
pub fn from_env_with_dev_capture(
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
) -> (Self, Option<Arc<DevEmailSink>>) {
|
||||
let config = EmailConfig::from_env();
|
||||
let transport: Option<Arc<dyn EmailTransport>> = match SmtpConfig::from_env() {
|
||||
match SmtpConfig::from_env() {
|
||||
Some(cfg) => {
|
||||
let transport: Option<Arc<dyn EmailTransport>> = match LettreEmailTransport::build(
|
||||
&cfg,
|
||||
) {
|
||||
Ok(t) => {
|
||||
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
|
||||
Some(Arc::new(t))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
|
||||
None
|
||||
}
|
||||
};
|
||||
(Self::new(transport, authz, config), None)
|
||||
}
|
||||
None if dev_mode_enabled() => {
|
||||
tracing::warn!(
|
||||
"email DEV CAPTURE: PICLOUD_DEV_MODE=true and no SMTP relay configured — \
|
||||
email::send will SUCCEED and capture messages in memory (last {DEV_EMAIL_CAPACITY}, \
|
||||
readable at GET /api/v1/admin/dev/emails). NEVER use this in production."
|
||||
);
|
||||
let sink = Arc::new(DevEmailSink::new(DEV_EMAIL_CAPACITY));
|
||||
let transport: Arc<dyn EmailTransport> =
|
||||
Arc::new(DevEmailTransport::new(sink.clone()));
|
||||
(Self::new(Some(transport), authz, config), Some(sink))
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"email is DISABLED: set PICLOUD_SMTP_HOST/USER/PASSWORD to enable \
|
||||
email::send. Scripts calling email::send will get an error."
|
||||
);
|
||||
None
|
||||
(Self::new(None, authz, config), None)
|
||||
}
|
||||
Some(cfg) => match LettreEmailTransport::build(&cfg) {
|
||||
Ok(t) => {
|
||||
tracing::info!(host = %cfg.host, port = cfg.port, "outbound email enabled");
|
||||
Some(Arc::new(t))
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "failed to build SMTP transport; email DISABLED");
|
||||
None
|
||||
}
|
||||
},
|
||||
};
|
||||
Self::new(transport, authz, config)
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_send(&self, cx: &SdkCallCx) -> Result<(), EmailError> {
|
||||
|
||||
205
crates/manager-core/src/group_members_repo.rs
Normal file
205
crates/manager-core/src/group_members_repo.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! CRUD over the `group_members` table — explicit per-(user, group) role
|
||||
//! grants. A `group_admin` here is implicitly app_admin on every app and
|
||||
//! subgroup beneath the group; that inheritance is resolved by the authz
|
||||
//! ancestor walk (`app_members_repo::effective_app_role`), not here.
|
||||
//!
|
||||
//! Mirrors `app_members_repo` — same three role literals, same shapes.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppRole, GroupId, InstanceRole};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupMembersRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("invalid app_role stored in DB: {0}")]
|
||||
InvalidRole(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupMembershipRow {
|
||||
pub group_id: GroupId,
|
||||
pub user_id: AdminUserId,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// `group_members` row joined with `admin_users` for the dashboard's
|
||||
/// per-group Members tab.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupMembershipDetail {
|
||||
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 GroupMembersRepository: Send + Sync {
|
||||
/// Atomic insert. `None` if a membership already exists (handler → 409).
|
||||
async fn try_insert(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
|
||||
|
||||
/// Atomic role update. `None` if no row exists (handler → 404).
|
||||
async fn update_role(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
|
||||
|
||||
/// Remove a membership. No-op when the row doesn't exist.
|
||||
async fn remove(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<(), GroupMembersRepositoryError>;
|
||||
|
||||
/// Per-group member list joined with `admin_users`, ordered by username.
|
||||
async fn list_for_group_enriched(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupMembersRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupMembersRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupMembersRepository for PostgresGroupMembersRepository {
|
||||
async fn try_insert(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupMembershipRecord>(
|
||||
"INSERT INTO group_members (group_id, user_id, role) \
|
||||
VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (group_id, user_id) DO NOTHING \
|
||||
RETURNING group_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(group_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,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupMembershipRecord>(
|
||||
"UPDATE group_members SET role = $1 \
|
||||
WHERE group_id = $2 AND user_id = $3 \
|
||||
RETURNING group_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(role.as_str())
|
||||
.bind(group_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<(), GroupMembersRepositoryError> {
|
||||
sqlx::query("DELETE FROM group_members WHERE group_id = $1 AND user_id = $2")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_for_group_enriched(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupMembershipDetailRecord>(
|
||||
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
||||
gm.role, gm.created_at \
|
||||
FROM group_members gm \
|
||||
JOIN admin_users au ON au.id = gm.user_id \
|
||||
WHERE gm.group_id = $1 \
|
||||
ORDER BY au.username",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupMembershipRecord {
|
||||
group_id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<GroupMembershipRecord> for GroupMembershipRow {
|
||||
type Error = GroupMembersRepositoryError;
|
||||
fn try_from(r: GroupMembershipRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
group_id: r.group_id.into(),
|
||||
user_id: r.user_id.into(),
|
||||
role: AppRole::from_db_str(&r.role)
|
||||
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupMembershipDetailRecord {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
instance_role: String,
|
||||
is_active: bool,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<GroupMembershipDetailRecord> for GroupMembershipDetail {
|
||||
type Error = GroupMembersRepositoryError;
|
||||
fn try_from(r: GroupMembershipDetailRecord) -> 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(GroupMembersRepositoryError::InvalidRole(r.instance_role))?,
|
||||
is_active: r.is_active,
|
||||
role: AppRole::from_db_str(&r.role)
|
||||
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
367
crates/manager-core/src/group_repo.rs
Normal file
367
crates/manager-core/src/group_repo.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
//! CRUD over the `groups` tree (Phase 2).
|
||||
//!
|
||||
//! Groups form a single-parent org tree above apps. Structural mutations
|
||||
//! (reparent/rename/delete) must keep the tree acyclic and non-orphaning:
|
||||
//!
|
||||
//! - **delete = RESTRICT** — refused if the group has child groups or apps
|
||||
//! (the DB FKs enforce this; we surface a clean conflict).
|
||||
//! - **slug-freeze** — `rename` edits name/description only; the slug is
|
||||
//! set once at creation and never rewritten.
|
||||
//! - **cycle guard** — `reparent` walks the destination's ancestors under a
|
||||
//! coarse instance-wide advisory lock and refuses a move that would make
|
||||
//! a node its own ancestor. A SQL `CHECK` can't express this.
|
||||
//! - **structure_version** — bumped on every structural mutation so a
|
||||
//! future CLI/orchestrator can detect structural drift (§6).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{Group, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Instance-wide advisory-lock key for structural group mutations. Coarse
|
||||
/// on purpose: reparent/rename/delete all take it so the ancestor-walk
|
||||
/// cycle guard and the `parent_id` write run serialized — two concurrent
|
||||
/// reparents can't race into a cycle. Distinct from the per-app
|
||||
/// `apply_lock_key` space (a fixed sentinel, hashed-namespace-free).
|
||||
const GROUP_STRUCTURAL_LOCK_KEY: i64 = 0x6701_0047_0000_0001;
|
||||
|
||||
/// Well-known slug of the instance root group seeded by migration 0047.
|
||||
pub const ROOT_GROUP_SLUG: &str = "root";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(GroupId),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
}
|
||||
|
||||
/// Counts of a group's direct children — used to enforce delete=RESTRICT
|
||||
/// with an actionable message and to render the tree.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct GroupChildCounts {
|
||||
pub subgroups: i64,
|
||||
pub apps: i64,
|
||||
}
|
||||
|
||||
impl GroupChildCounts {
|
||||
#[must_use]
|
||||
pub fn is_empty(self) -> bool {
|
||||
self.subgroups == 0 && self.apps == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupRepository: Send + Sync {
|
||||
/// Every group on the instance, ordered by name. The tree is small
|
||||
/// (org structure), so callers assemble the hierarchy in memory.
|
||||
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError>;
|
||||
/// Direct children groups of `parent`.
|
||||
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
/// The node plus its ancestors up to the root, nearest-first. Used for
|
||||
/// path display and as the reparent cycle-guard input.
|
||||
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError>;
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Edit display fields only — the slug is frozen at creation. Bumps
|
||||
/// `structure_version`.
|
||||
async fn rename(
|
||||
&self,
|
||||
id: GroupId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Move `id` under `new_parent` (or to root if `None`). Runs the
|
||||
/// ancestor-walk cycle guard under a coarse structural lock and bumps
|
||||
/// `structure_version`. Refuses a move that would create a cycle.
|
||||
async fn reparent(
|
||||
&self,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Delete an empty group (delete = RESTRICT). Refused with a clean
|
||||
/// conflict if it still has child groups or apps.
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_COLS: &str =
|
||||
"id, parent_id, slug, name, description, structure_version, created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
impl GroupRepository for PostgresGroupRepository {
|
||||
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups ORDER BY name"
|
||||
))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups 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<Group>, GroupRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups WHERE slug = $1"
|
||||
))
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups WHERE parent_id = $1 ORDER BY name"
|
||||
))
|
||||
.bind(parent.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
// Recursive walk node → root, nearest-first. Depth-bounded as a
|
||||
// runaway guard (the cycle guard already prevents cycles).
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"WITH RECURSIVE chain AS (
|
||||
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
|
||||
g.structure_version, g.created_at, g.updated_at, c.depth + 1 \
|
||||
FROM groups g JOIN chain c ON g.id = c.parent_id \
|
||||
WHERE c.depth < 64
|
||||
)
|
||||
SELECT {GROUP_COLS} FROM chain ORDER BY depth"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError> {
|
||||
let row: (i64, i64) = sqlx::query_as(
|
||||
"SELECT \
|
||||
(SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
|
||||
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(GroupChildCounts {
|
||||
subgroups: row.0,
|
||||
apps: row.1,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
&self,
|
||||
id: GroupId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
// Slug is intentionally absent from the SET list — it is frozen.
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.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(GroupRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
async fn reparent(
|
||||
&self,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Coarse structural lock: serialize all structural mutations so the
|
||||
// cycle guard + parent write can't interleave with a concurrent
|
||||
// reparent and race into a cycle.
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
// Cycle guard: walk from the destination up to the root; if we
|
||||
// reach `id`, the move would place `id` beneath itself.
|
||||
let mut cursor = Some(parent);
|
||||
let mut hops = 0u32;
|
||||
while let Some(node) = cursor {
|
||||
if node == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"cannot reparent a group beneath one of its own descendants".into(),
|
||||
));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"group ancestry exceeds the maximum depth".into(),
|
||||
));
|
||||
}
|
||||
let parent_of: Option<(Option<Uuid>,)> =
|
||||
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
|
||||
.bind(node.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
match parent_of {
|
||||
Some((p,)) => cursor = p.map(GroupId::from),
|
||||
// Destination parent doesn't exist.
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
parent_id = $2, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(new_parent.map(GroupId::into_inner))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(GroupRepositoryError::NotFound(id));
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
|
||||
// Pre-check for a clean message; the FK RESTRICT is the real guard.
|
||||
let counts = self.child_counts(id).await?;
|
||||
if !counts.is_empty() {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.subgroups, counts.apps
|
||||
)));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// Lost a race with a concurrent child insert.
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupRow {
|
||||
id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
structure_version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<GroupRow> for Group {
|
||||
fn from(r: GroupRow) -> Self {
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
parent_id: r.parent_id.map(Into::into),
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
structure_version: r.structure_version,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
577
crates/manager-core/src/groups_api.rs
Normal file
577
crates/manager-core/src/groups_api.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
//! `/api/v1/admin/groups/*` — CRUD over the group tree + per-group
|
||||
//! membership (Phase 2, blueprint §5).
|
||||
//!
|
||||
//! Group capabilities resolve by walking the group's ancestor chain
|
||||
//! (`authz::effective_group_role`): a `group_admin` on any ancestor is
|
||||
//! implicitly admin of every descendant group. Structural mutations
|
||||
//! (reparent/delete) are gated on `GroupAdmin`; reparent additionally
|
||||
//! requires admin at BOTH the source and destination parent (§5.6).
|
||||
//!
|
||||
//! Slug is frozen at creation — PATCH edits name/description only.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, patch, post};
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, App, AppRole, Group, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRow};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
};
|
||||
use crate::group_repo::{GroupRepository, GroupRepositoryError};
|
||||
|
||||
const SLUG_MAX: usize = 63;
|
||||
const RESERVED_SLUGS: &[&str] = &[
|
||||
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps", "groups",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupsState {
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub group_members: Arc<dyn GroupMembersRepository>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn groups_router(state: GroupsState) -> Router {
|
||||
Router::new()
|
||||
.route("/groups", get(list_groups).post(create_group))
|
||||
.route(
|
||||
"/groups/{id_or_slug}",
|
||||
get(get_group).patch(patch_group).delete(delete_group),
|
||||
)
|
||||
.route("/groups/{id_or_slug}/reparent", post(reparent_group))
|
||||
.route(
|
||||
"/groups/{id_or_slug}/members",
|
||||
get(list_members).post(grant_member),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/members/{user_id}",
|
||||
patch(patch_member).delete(remove_member),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GroupDetailDto {
|
||||
#[serde(flatten)]
|
||||
pub group: Group,
|
||||
/// Root → … → this group (nearest-last), for breadcrumb display.
|
||||
pub path: Vec<Group>,
|
||||
pub subgroups: Vec<Group>,
|
||||
pub apps: Vec<App>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Parent group (slug or id). Omitted ⇒ a root-level group
|
||||
/// (owner/admin only).
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchGroupRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReparentRequest {
|
||||
/// New parent (slug or id). Omitted/null ⇒ move to root.
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GroupMemberDto {
|
||||
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<GroupMembershipDetail> for GroupMemberDto {
|
||||
fn from(d: GroupMembershipDetail) -> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GrantMemberRequest {
|
||||
pub user_id: AdminUserId,
|
||||
pub role: AppRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchMemberRequest {
|
||||
pub role: AppRole,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group CRUD handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// List the whole tree. Phase-2 simplification: group names/structure are
|
||||
/// low-sensitivity org metadata (groups own no resources/secrets yet), so
|
||||
/// any authenticated admin sees the full tree; per-action authz still
|
||||
/// gates every mutation and all app access. Tighten in Phase 3 when groups
|
||||
/// carry inheritable config.
|
||||
async fn list_groups(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(_principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<Group>>, GroupsApiError> {
|
||||
Ok(Json(s.groups.list().await?))
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateGroupRequest>,
|
||||
) -> Result<(StatusCode, Json<Group>), GroupsApiError> {
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
let parent_id = match input.parent.as_deref() {
|
||||
// Root-level group — an instance act.
|
||||
None => {
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceCreateGroup,
|
||||
)
|
||||
.await?;
|
||||
None
|
||||
}
|
||||
// Subgroup — requires group-admin at the parent.
|
||||
Some(ident) => {
|
||||
let parent = resolve_group(&*s.groups, ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(parent.id),
|
||||
)
|
||||
.await?;
|
||||
Some(parent.id)
|
||||
}
|
||||
};
|
||||
|
||||
let created = s
|
||||
.groups
|
||||
.create(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent_id,
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn get_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<GroupDetailDto>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupRead(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ancestors() is nearest-first incl. the node; reverse for a
|
||||
// root→…→node breadcrumb.
|
||||
let mut path = s.groups.ancestors(group.id).await?;
|
||||
path.reverse();
|
||||
let subgroups = s.groups.list_children(group.id).await?;
|
||||
let apps = s.apps.list_for_group(group.id).await?;
|
||||
Ok(Json(GroupDetailDto {
|
||||
group,
|
||||
path,
|
||||
subgroups,
|
||||
apps,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn patch_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<PatchGroupRequest>,
|
||||
) -> Result<Json<Group>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupWrite(group.id),
|
||||
)
|
||||
.await?;
|
||||
let updated = s
|
||||
.groups
|
||||
.rename(
|
||||
group.id,
|
||||
input.name.as_deref(),
|
||||
input.description.as_ref().map(|d| Some(d.as_str())),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
async fn reparent_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<ReparentRequest>,
|
||||
) -> Result<Json<Group>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
// Admin of the node being moved.
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
// Admin at the SOURCE parent (you're removing it from that domain).
|
||||
if let Some(src) = group.parent_id {
|
||||
require(s.authz.as_ref(), &principal, Capability::GroupAdmin(src)).await?;
|
||||
}
|
||||
// Resolve + require admin at the DESTINATION parent.
|
||||
let new_parent = match input.parent.as_deref() {
|
||||
None => None,
|
||||
Some(ident) => {
|
||||
let dest = resolve_group(&*s.groups, ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(dest.id),
|
||||
)
|
||||
.await?;
|
||||
Some(dest.id)
|
||||
}
|
||||
};
|
||||
let moved = s.groups.reparent(group.id, new_parent).await?;
|
||||
Ok(Json(moved))
|
||||
}
|
||||
|
||||
async fn delete_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<StatusCode, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
s.groups.delete(group.id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Member handlers — gated on GroupAdmin(group)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_members(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<GroupMemberDto>>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
let rows = s.group_members.list_for_group_enriched(group.id).await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn grant_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<GrantMemberRequest>,
|
||||
) -> Result<(StatusCode, Json<GroupMemberDto>), GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user = s
|
||||
.users
|
||||
.get(input.user_id)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::UserNotFound(input.user_id))?;
|
||||
validate_grant_target(&user)?;
|
||||
|
||||
let row = s
|
||||
.group_members
|
||||
.try_insert(group.id, user.id, input.role)
|
||||
.await?
|
||||
.ok_or_else(|| GroupsApiError::AlreadyMember {
|
||||
username: user.username.clone(),
|
||||
})?;
|
||||
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
|
||||
}
|
||||
|
||||
async fn patch_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||
Json(input): Json<PatchMemberRequest>,
|
||||
) -> Result<Json<GroupMemberDto>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_id = AdminUserId::from(user_id);
|
||||
let user = s
|
||||
.users
|
||||
.get(user_id)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::UserNotFound(user_id))?;
|
||||
let row = s
|
||||
.group_members
|
||||
.update_role(group.id, user_id, input.role)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::MembershipNotFound)?;
|
||||
Ok(Json(compose_dto(user, row)))
|
||||
}
|
||||
|
||||
async fn remove_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||
) -> Result<StatusCode, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
s.group_members
|
||||
.remove(group.id, AdminUserId::from(user_id))
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Validation + helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a group identifier (slug or UUID) to a `Group`.
|
||||
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Group, GroupsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
groups.get_by_id(uuid.into()).await?
|
||||
} else {
|
||||
groups.get_by_slug(ident).await?
|
||||
};
|
||||
found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
|
||||
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
|
||||
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
|
||||
if slug.is_empty() || slug.len() > SLUG_MAX {
|
||||
return Err(invalid("must be 1–63 characters"));
|
||||
}
|
||||
let mut chars = slug.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(invalid("must start with a lowercase letter or digit"));
|
||||
}
|
||||
if !slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(invalid(
|
||||
"may contain only lowercase letters, digits, and hyphens",
|
||||
));
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&slug) {
|
||||
return Err(invalid("is a reserved word"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_grant_target(user: &AdminUserRow) -> Result<(), GroupsApiError> {
|
||||
if !user.is_active {
|
||||
return Err(GroupsApiError::TargetInactive {
|
||||
username: user.username.clone(),
|
||||
});
|
||||
}
|
||||
if user.instance_role != InstanceRole::Member {
|
||||
return Err(GroupsApiError::TargetNotMember {
|
||||
username: user.username.clone(),
|
||||
instance_role: user.instance_role,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compose_dto(user: AdminUserRow, membership: GroupMembershipRow) -> GroupMemberDto {
|
||||
GroupMemberDto {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupsApiError {
|
||||
#[error("group not found: {0}")]
|
||||
GroupNotFound(String),
|
||||
#[error("user not found: {0}")]
|
||||
UserNotFound(AdminUserId),
|
||||
#[error("{username} is already a member of this group")]
|
||||
AlreadyMember { username: String },
|
||||
#[error("membership not found")]
|
||||
MembershipNotFound,
|
||||
#[error("{username} is deactivated")]
|
||||
TargetInactive { username: String },
|
||||
#[error("{username} has instance role {instance_role:?}; only members get explicit grants")]
|
||||
TargetNotMember {
|
||||
username: String,
|
||||
instance_role: InstanceRole,
|
||||
},
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("group repository error: {0}")]
|
||||
Repo(#[from] GroupRepositoryError),
|
||||
#[error("member repository error: {0}")]
|
||||
MembersRepo(#[from] GroupMembersRepositoryError),
|
||||
#[error("user repository error: {0}")]
|
||||
UsersRepo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||
#[error("app repository error: {0}")]
|
||||
AppsRepo(#[from] crate::repo::ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for GroupsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for GroupsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for GroupsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::GroupNotFound(_)
|
||||
| Self::UserNotFound(_)
|
||||
| Self::MembershipNotFound
|
||||
| Self::Repo(GroupRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::InvalidSlug(_) | Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::AlreadyMember { .. } | Self::Conflict(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Repo(GroupRepositoryError::Conflict(msg)) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": msg }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "groups authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Repo(GroupRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "groups api db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::MembersRepo(e) => {
|
||||
tracing::error!(error = %e, "group members repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::UsersRepo(e) => {
|
||||
tracing::error!(error = %e, "groups api user repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::AppsRepo(e) => {
|
||||
tracing::error!(error = %e, "groups api app repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,11 @@ impl InvokeServiceImpl {
|
||||
if script.app_id != cx.app_id {
|
||||
return Err(InvokeError::CrossApp);
|
||||
}
|
||||
if !script.enabled {
|
||||
// §4.3: a disabled script is not invocable via any path. Surface as
|
||||
// NotFound (indistinguishable from absent), like the data plane.
|
||||
return Err(InvokeError::NotFound(format!("id {script_id}")));
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
@@ -103,6 +108,9 @@ impl InvokeServiceImpl {
|
||||
.await
|
||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
|
||||
if !script.enabled {
|
||||
return Err(InvokeError::NotFound(format!("name {name:?}")));
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
@@ -313,6 +321,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
memory_limit_mb: 64,
|
||||
sandbox: ScriptSandbox::default(),
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
|
||||
162
crates/manager-core/src/kv_api.rs
Normal file
162
crates/manager-core/src/kv_api.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
//! `/api/v1/admin/apps/{id}/kv*` — read-only KV inspection (G2).
|
||||
//!
|
||||
//! Mirrors the minimal `files_api` / `queues_api` admin surface so the
|
||||
//! `pic kv` CLI (and a future dashboard tab) can browse stored keys
|
||||
//! without a script. **Read-only by design** — KV writes go through
|
||||
//! `kv::set` in scripts, which emit change events the trigger framework
|
||||
//! depends on; an admin write would bypass that and could break app
|
||||
//! invariants, so it is deliberately out of scope here (matching the
|
||||
//! read-only queues precedent).
|
||||
//!
|
||||
//! Two operations:
|
||||
//! * `GET /apps/{id}/kv?collection=<c>&cursor=&limit=` — list keys in a
|
||||
//! collection (cursor-paginated).
|
||||
//! * `GET /apps/{id}/kv/{collection}/{key}` — fetch one value.
|
||||
//!
|
||||
//! Capability: `AppKvRead`, resolved against the app loaded from the
|
||||
//! path (same tier the SDK read path uses).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
|
||||
use crate::kv_repo::KvRepo;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct KvAdminState {
|
||||
pub kv: Arc<dyn KvRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn kv_admin_router(state: KvAdminState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/kv", get(list_keys))
|
||||
.route("/apps/{app_id}/kv/{collection}/{key}", get(get_value))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListKvQuery {
|
||||
pub collection: String,
|
||||
#[serde(default)]
|
||||
pub cursor: Option<String>,
|
||||
#[serde(default)]
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GetValueResponse {
|
||||
value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Serialize mirror of `shared::KvListPage` (which is not `Serialize`).
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ListKeysResponse {
|
||||
keys: Vec<String>,
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_keys(
|
||||
State(s): State<KvAdminState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Query(q): Query<ListKvQuery>,
|
||||
) -> Result<Json<ListKeysResponse>, KvApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?;
|
||||
let page =
|
||||
s.kv.list(
|
||||
app_id,
|
||||
&q.collection,
|
||||
q.cursor.as_deref(),
|
||||
q.limit.unwrap_or(0),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| KvApiError::Backend(e.to_string()))?;
|
||||
Ok(Json(ListKeysResponse {
|
||||
keys: page.keys,
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_value(
|
||||
State(s): State<KvAdminState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, collection, key)): Path<(String, String, String)>,
|
||||
) -> Result<Json<GetValueResponse>, KvApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(s.authz.as_ref(), &principal, Capability::AppKvRead(app_id)).await?;
|
||||
let value =
|
||||
s.kv.get(app_id, &collection, &key)
|
||||
.await
|
||||
.map_err(|e| KvApiError::Backend(e.to_string()))?
|
||||
.ok_or(KvApiError::NotFound)?;
|
||||
Ok(Json(GetValueResponse { value }))
|
||||
}
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, KvApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| KvApiError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or(KvApiError::AppNotFound)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum KvApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("key not found")]
|
||||
NotFound,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("kv backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for KvApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for KvApiError {
|
||||
fn into_response(self) -> Response {
|
||||
use axum::http::StatusCode;
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "kv admin authz error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "kv admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -23,16 +23,21 @@ pub mod app_user_repo;
|
||||
pub mod app_user_role_repo;
|
||||
pub mod app_user_session_repo;
|
||||
pub mod app_user_verification_repo;
|
||||
pub mod apply_api;
|
||||
pub mod apply_service;
|
||||
pub mod apps_api;
|
||||
pub mod auth;
|
||||
pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
pub mod auth_middleware;
|
||||
pub mod authz;
|
||||
pub mod config_api;
|
||||
pub mod config_resolver;
|
||||
pub mod cron_scheduler;
|
||||
pub mod dead_letter_repo;
|
||||
pub mod dead_letter_service;
|
||||
pub mod dead_letters_api;
|
||||
pub mod dev_email_api;
|
||||
pub mod dispatcher;
|
||||
pub mod docs_filter;
|
||||
pub mod docs_repo;
|
||||
@@ -44,8 +49,12 @@ pub mod files_repo;
|
||||
pub mod files_service;
|
||||
pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_repo;
|
||||
pub mod groups_api;
|
||||
pub mod http_service;
|
||||
pub mod invoke_service;
|
||||
pub mod kv_api;
|
||||
pub mod kv_repo;
|
||||
pub mod kv_service;
|
||||
pub mod log_sink;
|
||||
@@ -77,6 +86,9 @@ pub mod trigger_repo;
|
||||
pub mod triggers_api;
|
||||
pub mod users_admin_api;
|
||||
pub mod users_service;
|
||||
pub mod vars_api;
|
||||
pub mod vars_repo;
|
||||
pub mod vars_service;
|
||||
|
||||
pub use abandoned_repo::{
|
||||
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
|
||||
@@ -126,6 +138,8 @@ pub use app_user_session_repo::{
|
||||
pub use app_user_verification_repo::{
|
||||
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
||||
};
|
||||
pub use apply_api::apply_router;
|
||||
pub use apply_service::{ApplyError, ApplyService, Bundle, Plan};
|
||||
pub use apps_api::{apps_router, AppsState};
|
||||
pub use auth_api::auth_router;
|
||||
pub use auth_bootstrap::{
|
||||
@@ -137,12 +151,14 @@ pub use auth_middleware::{
|
||||
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
|
||||
};
|
||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||
pub use config_api::{config_router, ConfigApiError, ConfigApiState};
|
||||
pub use cron_scheduler::spawn_cron_scheduler;
|
||||
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 dev_email_api::{dev_emails_router, DevEmailState};
|
||||
pub use dispatcher::{compute_backoff, Dispatcher, DispatcherError};
|
||||
pub use docs_repo::{DocsRepo, DocsRepoError, PostgresDocsRepo};
|
||||
pub use docs_service::DocsServiceImpl;
|
||||
@@ -150,15 +166,25 @@ pub use email_inbound_api::{
|
||||
email_inbound_router, EmailInboundError, EmailInboundState, InboundNonceDedup,
|
||||
};
|
||||
pub use email_service::{
|
||||
EmailConfig, EmailServiceImpl, EmailTransport, LettreEmailTransport, SmtpConfig, SmtpTls,
|
||||
DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
|
||||
CapturedEmail, DevEmailSink, DevEmailTransport, EmailConfig, EmailServiceImpl, EmailTransport,
|
||||
LettreEmailTransport, SmtpConfig, SmtpTls, DEFAULT_EMAIL_MAX_MESSAGE_BYTES,
|
||||
};
|
||||
pub use files_api::{files_admin_router, FilesAdminState};
|
||||
pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
||||
pub use files_service::FilesServiceImpl;
|
||||
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
||||
pub use group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
PostgresGroupMembersRepository,
|
||||
};
|
||||
pub use group_repo::{
|
||||
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||
ROOT_GROUP_SLUG,
|
||||
};
|
||||
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
|
||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||
pub use kv_api::{kv_admin_router, KvAdminState};
|
||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||
pub use kv_service::KvServiceImpl;
|
||||
pub use log_sink::PostgresExecutionLogSink;
|
||||
@@ -180,11 +206,11 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
|
||||
pub use secrets_repo::{
|
||||
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||
PostgresSecretsRepo, ResolvedSecret, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||
SecretsRepoError, StoredSecret,
|
||||
};
|
||||
pub use secrets_service::{
|
||||
open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl,
|
||||
open as open_secret, seal as seal_secret, SecretOwner, SecretsConfig, SecretsServiceImpl,
|
||||
DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||
};
|
||||
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||
@@ -199,3 +225,6 @@ pub use trigger_repo::{
|
||||
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
||||
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
|
||||
pub use vars_api::{vars_router, VarsApiError, VarsApiState};
|
||||
pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError};
|
||||
pub use vars_service::VarsServiceImpl;
|
||||
|
||||
@@ -31,9 +31,9 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
||||
id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
logs, duration_ms, status, source, created_at \
|
||||
) VALUES ( \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 \
|
||||
)",
|
||||
)
|
||||
.bind(log.id)
|
||||
@@ -48,6 +48,7 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
||||
.bind(&log.script_logs)
|
||||
.bind(duration_ms)
|
||||
.bind(log.status.as_str())
|
||||
.bind(log.source.as_str())
|
||||
.bind(log.created_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::collections::BTreeMap;
|
||||
use async_trait::async_trait;
|
||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox,
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, RequestId, Script,
|
||||
ScriptId, ScriptKind, ScriptSandbox,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
@@ -154,6 +154,8 @@ pub struct NewScript {
|
||||
/// Sandbox overrides; `None` means store an empty object (use
|
||||
/// platform defaults at exec time).
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
/// v1.1.3: literal-path `import "<name>"` declarations extracted
|
||||
/// from the source. The repo writes these into `script_imports`
|
||||
/// transactionally with the script row. Empty when validation
|
||||
@@ -176,6 +178,8 @@ pub struct ScriptPatch {
|
||||
/// rejects unsafe transitions (e.g. endpoint→module when routes
|
||||
/// or triggers reference the script).
|
||||
pub kind: Option<ScriptKind>,
|
||||
/// `Some(v)` toggles the three-state lifecycle flag; `None` leaves it.
|
||||
pub enabled: Option<bool>,
|
||||
/// v1.1.3: when `source` is also `Some`, the repo replaces the
|
||||
/// `script_imports` edges for this script with these names.
|
||||
/// `None` keeps the existing edges untouched (a name/description
|
||||
@@ -203,7 +207,8 @@ impl PostgresScriptRepository {
|
||||
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
||||
/// one query.
|
||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at";
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled, \
|
||||
created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
@@ -273,42 +278,8 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
}
|
||||
|
||||
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 mut tx = self.pool.begin().await?;
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
.bind(input.kind.as_str())
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
|
||||
let script: Script = match res {
|
||||
Ok(row) => row.into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Dep-graph: write any literal-path imports declared in the
|
||||
// source. Unresolved names (the referenced module doesn't
|
||||
// exist yet) are silently skipped — best-effort.
|
||||
replace_imports_tx(&mut tx, script.id, script.app_id, &input.imports).await?;
|
||||
let script = insert_script_tx(&mut tx, &input).await?;
|
||||
tx.commit().await?;
|
||||
Ok(script)
|
||||
}
|
||||
@@ -318,62 +289,8 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
id: ScriptId,
|
||||
patch: ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
// COALESCE-based partial update: `NULL` parameters leave columns
|
||||
// untouched. Description is double-Optioned so callers can
|
||||
// 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). 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 mut tx = self.pool.begin().await?;
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
source = COALESCE($5, source), \
|
||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(patch.name.as_deref())
|
||||
.bind(patch.description.is_some())
|
||||
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
|
||||
.bind(patch.source.as_deref())
|
||||
.bind(patch.timeout_seconds)
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
|
||||
let script: Script = match res {
|
||||
Ok(Some(row)) => row.into(),
|
||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Replace imports only when the caller has a fresh list (i.e.
|
||||
// the source actually changed and the validator re-extracted
|
||||
// imports). A name-only or description-only edit leaves the
|
||||
// dep graph alone.
|
||||
if let Some(imports) = patch.imports.as_deref() {
|
||||
replace_imports_tx(&mut tx, script.id, script.app_id, imports).await?;
|
||||
}
|
||||
let script = update_script_tx(&mut tx, id, &patch).await?;
|
||||
tx.commit().await?;
|
||||
Ok(script)
|
||||
}
|
||||
@@ -469,6 +386,117 @@ async fn replace_imports_tx(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a script within an existing transaction — the declarative
|
||||
/// `apply` engine composes scripts + routes + triggers into one tx.
|
||||
/// Mirrors `create` minus the `begin`/`commit`.
|
||||
pub(crate) async fn insert_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
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>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
.bind(input.kind.as_str())
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(input.enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
Ok(row) => row.into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
/// Update a script within an existing transaction. Mirrors `update`
|
||||
/// minus the `begin`/`commit`.
|
||||
pub(crate) async fn update_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: ScriptId,
|
||||
patch: &ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
source = COALESCE($5, source), \
|
||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
enabled = COALESCE($10, enabled), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(patch.name.as_deref())
|
||||
.bind(patch.description.is_some())
|
||||
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
|
||||
.bind(patch.source.as_deref())
|
||||
.bind(patch.timeout_seconds)
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.bind(patch.enabled)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
Ok(Some(row)) => row.into(),
|
||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if let Some(imports) = patch.imports.as_deref() {
|
||||
replace_imports_tx(tx, script.id, script.app_id, imports).await?;
|
||||
}
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
/// Delete a script within an existing transaction (its routes/triggers
|
||||
/// cascade via their FKs). Mirrors `delete` minus the pool.
|
||||
pub(crate) async fn delete_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: ScriptId,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(ScriptRepositoryError::NotFound(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Row shape mirroring the `scripts` table for sqlx FromRow.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
@@ -485,6 +513,7 @@ struct ScriptRow {
|
||||
timeout_seconds: i32,
|
||||
memory_limit_mb: i32,
|
||||
sandbox: serde_json::Value,
|
||||
enabled: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -511,6 +540,7 @@ impl From<ScriptRow> for Script {
|
||||
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
||||
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
||||
sandbox,
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
@@ -584,6 +614,7 @@ pub trait ExecutionLogRepository: Send + Sync {
|
||||
script_id: ScriptId,
|
||||
limit: i64,
|
||||
cursor: Option<ExecutionLogCursor>,
|
||||
source: Option<ExecutionSource>,
|
||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
@@ -605,23 +636,30 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
script_id: ScriptId,
|
||||
limit: i64,
|
||||
cursor: Option<ExecutionLogCursor>,
|
||||
source: Option<ExecutionSource>,
|
||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||
// The optional `source` filter is folded into one bind via
|
||||
// `$N::text IS NULL OR source = $N` so we don't fan out into four
|
||||
// query strings. `None` → the predicate is always true (no filter).
|
||||
let source = source.map(ExecutionSource::as_str);
|
||||
let rows = match cursor {
|
||||
Some(c) => {
|
||||
sqlx::query_as::<_, ExecutionLogRow>(
|
||||
"SELECT id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
logs, duration_ms, status, source, created_at \
|
||||
FROM execution_logs \
|
||||
WHERE script_id = $1 \
|
||||
AND (created_at, id) < ($2, $3) \
|
||||
AND ($4::text IS NULL OR source = $4) \
|
||||
ORDER BY created_at DESC, id DESC \
|
||||
LIMIT $4",
|
||||
LIMIT $5",
|
||||
)
|
||||
.bind(script_id.into_inner())
|
||||
.bind(c.created_at)
|
||||
.bind(c.id)
|
||||
.bind(source)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
@@ -631,13 +669,15 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
"SELECT id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
logs, duration_ms, status, source, created_at \
|
||||
FROM execution_logs \
|
||||
WHERE script_id = $1 \
|
||||
AND ($2::text IS NULL OR source = $2) \
|
||||
ORDER BY created_at DESC, id DESC \
|
||||
LIMIT $2",
|
||||
LIMIT $3",
|
||||
)
|
||||
.bind(script_id.into_inner())
|
||||
.bind(source)
|
||||
.bind(limit)
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
@@ -662,6 +702,7 @@ struct ExecutionLogRow {
|
||||
logs: serde_json::Value,
|
||||
duration_ms: i32,
|
||||
status: String,
|
||||
source: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -675,6 +716,9 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
||||
"budget_exceeded" => ExecutionStatus::BudgetExceeded,
|
||||
_ => ExecutionStatus::Error,
|
||||
};
|
||||
// Unknown values can't occur (CHECK constraint) but default to
|
||||
// Http rather than panicking on a forward-compat surprise.
|
||||
let source = ExecutionSource::from_wire(&r.source).unwrap_or_default();
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
@@ -688,6 +732,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
||||
script_logs: r.logs,
|
||||
duration_ms: u64::try_from(r.duration_ms).unwrap_or(0),
|
||||
status,
|
||||
source,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +229,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
path: normalized_path,
|
||||
method: input.method,
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
// Routes are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
@@ -373,31 +375,63 @@ 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)?;
|
||||
let compiled = compile_routes(&rows);
|
||||
state.table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::ParseError> {
|
||||
/// Compile stored route rows into the in-memory match table.
|
||||
///
|
||||
/// **Lenient by design (H1).** A row that fails to parse is *skipped with
|
||||
/// a warning*, not propagated as an error. The motivating case: a path
|
||||
/// that was valid when created but became reserved under a later, stricter
|
||||
/// validation (e.g. the case-insensitive reserved-prefix check) — but this
|
||||
/// also covers any other parse failure. A single un-compilable legacy row
|
||||
/// must never take down the entire data plane: this function runs at
|
||||
/// startup (where a hard error aborts boot) and on every table rebuild
|
||||
/// after a route edit (where it would fail an unrelated CRUD op). A skipped
|
||||
/// route simply doesn't match; the warning tells the operator to delete or
|
||||
/// fix it (and migration 0044 sweeps the reserved-path offenders on
|
||||
/// upgrade).
|
||||
#[must_use]
|
||||
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
|
||||
rows.iter()
|
||||
.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,
|
||||
})
|
||||
// A disabled route (§4.3) is dropped from the match table entirely, so
|
||||
// a request to it 404s indistinguishably from an absent route.
|
||||
.filter(|r| r.enabled)
|
||||
.filter_map(|r| match compile_route(r) {
|
||||
Ok(compiled) => Some(compiled),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
route_id = %r.id,
|
||||
app_id = %r.app_id,
|
||||
path = %r.path,
|
||||
error = %e,
|
||||
"skipping un-compilable stored route — it will not match; \
|
||||
delete or fix it"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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(
|
||||
pub(crate) async fn validate_route_host_against_app(
|
||||
domains: &dyn AppDomainRepository,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
@@ -577,3 +611,62 @@ impl IntoResponse for RouteApiError {
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use picloud_shared::DispatchMode;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn route_with_path(path: &str) -> Route {
|
||||
Route {
|
||||
id: Uuid::new_v4(),
|
||||
app_id: AppId::from(Uuid::new_v4()),
|
||||
script_id: ScriptId::from(Uuid::new_v4()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: path.to_string(),
|
||||
method: None,
|
||||
dispatch_mode: DispatchMode::default(),
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_routes_skips_uncompilable_rows_instead_of_failing() {
|
||||
// H1 regression guard: a stored route whose path is now reserved
|
||||
// (creatable before the case-insensitive reserved-prefix fix) must
|
||||
// be skipped, not abort the whole compile — otherwise one legacy
|
||||
// row bricks startup (`compile_routes` runs in `build_app`).
|
||||
let good_a = route_with_path("/ok");
|
||||
let bad = route_with_path("/API/v2/x"); // now reserved, case-insensitive
|
||||
let good_b = route_with_path("/items");
|
||||
let rows = vec![good_a.clone(), bad.clone(), good_b.clone()];
|
||||
|
||||
let compiled = compile_routes(&rows);
|
||||
|
||||
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
||||
assert_eq!(compiled.len(), 2, "the reserved row must be dropped");
|
||||
assert!(ids.contains(&good_a.id));
|
||||
assert!(ids.contains(&good_b.id));
|
||||
assert!(
|
||||
!ids.contains(&bad.id),
|
||||
"a reserved-path route must be skipped, never abort the compile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_route_is_dropped_from_compiled_table() {
|
||||
// §4.3: a disabled route is excluded from the match table, so a
|
||||
// request to it 404s indistinguishably from an absent route.
|
||||
let active = route_with_path("/on");
|
||||
let mut disabled = route_with_path("/off");
|
||||
disabled.enabled = false;
|
||||
let compiled = compile_routes(&[active.clone(), disabled.clone()]);
|
||||
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
||||
assert_eq!(ids, vec![active.id], "only the enabled route compiles");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct NewRoute {
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -63,7 +65,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> 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 \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -74,7 +76,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
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 \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE id = $1",
|
||||
)
|
||||
.bind(route_id)
|
||||
@@ -86,7 +88,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
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 \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
@@ -101,7 +103,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
) -> 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 \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(script_id.into_inner())
|
||||
@@ -111,36 +113,10 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
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)
|
||||
.bind(input.host_param_name.as_deref())
|
||||
.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;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(ScriptRepositoryError::NotFound(input.script_id))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let route = insert_route_tx(&mut tx, &input).await?;
|
||||
tx.commit().await?;
|
||||
Ok(route)
|
||||
}
|
||||
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||
@@ -189,6 +165,63 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a route within an existing transaction (declarative apply
|
||||
/// composes scripts + routes + triggers into one tx). Mirrors `create`
|
||||
/// minus the `begin`/`commit`.
|
||||
pub(crate) async fn insert_route_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
input: &NewRoute,
|
||||
) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
.bind(input.host_param_name.as_deref())
|
||||
.bind(path_kind_str(input.path_kind))
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.bind(input.enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(ScriptRepositoryError::NotFound(input.script_id))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a route by id within an existing transaction.
|
||||
///
|
||||
/// Unlike the non-tx [`RouteRepository::delete`], this is intentionally
|
||||
/// idempotent: a missing row is not an error. The only caller is the
|
||||
/// reconcile engine (`ApplyService`), where "delete a route already gone"
|
||||
/// (e.g. removed out-of-band between the diff read and the write) is a
|
||||
/// no-op to converge on, not a failure to roll back the whole apply.
|
||||
pub(crate) async fn delete_route_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
route_id: Uuid,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
sqlx::query("DELETE FROM routes WHERE id = $1")
|
||||
.bind(route_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
@@ -201,6 +234,7 @@ struct RouteRow {
|
||||
path: String,
|
||||
method: Option<String>,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -225,6 +259,7 @@ impl From<RouteRow> for Route {
|
||||
path: r.path,
|
||||
method: r.method,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
|
||||
//! (v1.1.7).
|
||||
//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints.
|
||||
//!
|
||||
//! * `GET /apps/{id}/secrets` — list names + updated_at
|
||||
//! * `GET /apps/{id}/secrets` — list app secret names + updated_at
|
||||
//! (NEVER values).
|
||||
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
|
||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
|
||||
//! * `POST /apps/{id}/secrets` — set/overwrite an app secret.
|
||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret.
|
||||
//! * `GET /groups/{id}/secrets` — list group secret names + scope
|
||||
//! + updated_at (NEVER values).
|
||||
//! * `POST /groups/{id}/secrets` — set/overwrite a group secret
|
||||
//! (env-scoped).
|
||||
//! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret.
|
||||
//! * `GET /groups/{id}/secrets/{name}/value` — **human value read** —
|
||||
//! the ONE endpoint that returns plaintext, gated at the owning group.
|
||||
//!
|
||||
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
|
||||
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
|
||||
//! returns only names + timestamps — the dashboard never receives
|
||||
//! plaintext. Values are encrypted with the process master key before
|
||||
//! they touch the database (same envelope as the script `secrets::set`).
|
||||
//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`.
|
||||
//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the
|
||||
//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is
|
||||
//! the masked-secret boundary: a descendant app's dev can see that a group
|
||||
//! secret EXISTS (and consume it at runtime via `secrets::get`) but only a
|
||||
//! principal with read rights AT THE OWNING GROUP can read its value. The
|
||||
//! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the
|
||||
//! capability to the resolved owner id — never to a caller-controlled path
|
||||
//! param. Values are encrypted with the process master key before they
|
||||
//! touch the database (owner-bound AAD; see `secrets_service`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -19,19 +30,24 @@ use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
|
||||
use picloud_shared::{validate_secret_name, AppId, GroupId, MasterKey, Principal, SecretsError};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
|
||||
use crate::secrets_service::seal;
|
||||
use crate::group_repo::GroupRepository;
|
||||
use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError};
|
||||
use crate::secrets_service::{open, seal};
|
||||
|
||||
/// App secrets are env-agnostic; only group secrets carry a concrete scope.
|
||||
const APP_SECRET_SCOPE: &str = "*";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretsState {
|
||||
pub repo: Arc<dyn SecretsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub master_key: MasterKey,
|
||||
pub max_value_bytes: usize,
|
||||
@@ -39,10 +55,25 @@ pub struct SecretsState {
|
||||
|
||||
pub fn secrets_router(state: SecretsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
|
||||
.route(
|
||||
"/apps/{app_id}/secrets",
|
||||
get(list_app_secrets).post(set_app_secret),
|
||||
)
|
||||
.route(
|
||||
"/apps/{app_id}/secrets/{name}",
|
||||
axum::routing::delete(delete_secret),
|
||||
axum::routing::delete(delete_app_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets",
|
||||
get(list_group_secrets).post(set_group_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets/{name}",
|
||||
axum::routing::delete(delete_group_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets/{name}/value",
|
||||
get(read_group_secret_value),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -55,9 +86,18 @@ pub struct ListQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnvQuery {
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct SecretItem {
|
||||
name: String,
|
||||
/// Environment scope — `*` for app secrets, possibly a concrete env for
|
||||
/// group secrets.
|
||||
env: String,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -67,7 +107,23 @@ struct ListSecretsResponse {
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_secrets(
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetSecretRequest {
|
||||
pub name: String,
|
||||
/// Any JSON value — the dashboard sends a single-line string, but
|
||||
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
||||
pub value: serde_json::Value,
|
||||
/// Environment scope (group secrets only). `*` (env-agnostic, default)
|
||||
/// or a concrete env matched against `apps.environment` at resolution.
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App handlers (env-agnostic, scope `*`)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_app_secrets(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
@@ -80,32 +136,10 @@ async fn list_secrets(
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
let page = s
|
||||
.repo
|
||||
.list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0))
|
||||
.await?;
|
||||
Ok(Json(ListSecretsResponse {
|
||||
secrets: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|m| SecretItem {
|
||||
name: m.name,
|
||||
updated_at: m.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
list_meta(&*s.repo, SecretOwner::App(app_id), &q).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetSecretRequest {
|
||||
pub name: String,
|
||||
/// Any JSON value — the dashboard sends a single-line string, but
|
||||
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn set_secret(
|
||||
async fn set_app_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
@@ -118,23 +152,17 @@ async fn set_secret(
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
validate_secret_name(&input.name)?;
|
||||
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to
|
||||
// (app_id, name). Same path as the SDK secrets::set.
|
||||
let (ciphertext, nonce, version) = seal(
|
||||
&s.master_key,
|
||||
app_id,
|
||||
&input.name,
|
||||
&input.value,
|
||||
s.max_value_bytes,
|
||||
)?;
|
||||
s.repo
|
||||
.set(app_id, &input.name, &ciphertext, &nonce, version)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
// App secrets are always env-agnostic; reject a stray `env`.
|
||||
if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) {
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"app secrets are env-agnostic; set an environment scope on a group secret instead"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await
|
||||
}
|
||||
|
||||
async fn delete_secret(
|
||||
async fn delete_app_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, name)): Path<(String, String)>,
|
||||
@@ -146,12 +174,161 @@ async fn delete_secret(
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
if !s.repo.delete(app_id, &name).await? {
|
||||
delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group handlers (env-scoped)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_group_secrets(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupSecretsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
list_meta(&*s.repo, SecretOwner::Group(group_id), &q).await
|
||||
}
|
||||
|
||||
async fn set_group_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SetSecretRequest>,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupSecretsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
let env = input.env.clone().unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||
validate_env_scope(&env)?;
|
||||
seal_and_store(&s, SecretOwner::Group(group_id), &env, input).await
|
||||
}
|
||||
|
||||
async fn delete_group_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, name)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupSecretsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||
validate_env_scope(&env)?;
|
||||
delete(&*s.repo, SecretOwner::Group(group_id), &env, &name).await
|
||||
}
|
||||
|
||||
/// The ONE plaintext-returning endpoint. Gated by `GroupSecretsRead`
|
||||
/// (group_admin) — the masked-secret boundary. A descendant app's dev can
|
||||
/// see the secret exists and consume it at runtime, but only a reader at the
|
||||
/// owning group gets the value here.
|
||||
async fn read_group_secret_value(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, name)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<Json<serde_json::Value>, SecretsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupSecretsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
validate_secret_name(&name)?;
|
||||
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||
validate_env_scope(&env)?;
|
||||
let owner = SecretOwner::Group(group_id);
|
||||
let stored = s
|
||||
.repo
|
||||
.get(owner, &env, &name)
|
||||
.await?
|
||||
.ok_or(SecretsApiError::NotFound)?;
|
||||
let value = open(&s.master_key, owner, &name, &stored).map_err(|e| {
|
||||
tracing::error!(group_id = %group_id, secret = %name, "group secret could not be decrypted");
|
||||
SecretsApiError::from(e)
|
||||
})?;
|
||||
Ok(Json(json!({ "name": name, "env": env, "value": value })))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Shared owner-generic bodies
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_meta(
|
||||
repo: &dyn SecretsRepo,
|
||||
owner: SecretOwner,
|
||||
q: &ListQuery,
|
||||
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
|
||||
let page = repo
|
||||
.list_meta(owner, q.cursor.as_deref(), q.limit.unwrap_or(0))
|
||||
.await?;
|
||||
Ok(Json(ListSecretsResponse {
|
||||
secrets: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|m| SecretItem {
|
||||
name: m.name,
|
||||
env: m.environment_scope,
|
||||
updated_at: m.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn seal_and_store(
|
||||
s: &SecretsState,
|
||||
owner: SecretOwner,
|
||||
env: &str,
|
||||
input: SetSecretRequest,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
validate_secret_name(&input.name)?;
|
||||
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to the owner+name.
|
||||
let (ciphertext, nonce, version) = seal(
|
||||
&s.master_key,
|
||||
owner,
|
||||
&input.name,
|
||||
&input.value,
|
||||
s.max_value_bytes,
|
||||
)?;
|
||||
s.repo
|
||||
.set(owner, env, &input.name, &ciphertext, &nonce, version)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
repo: &dyn SecretsRepo,
|
||||
owner: SecretOwner,
|
||||
env: &str,
|
||||
name: &str,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
if !repo.delete(owner, env, name).await? {
|
||||
return Err(SecretsApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Resolution + validation
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
@@ -160,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, Sec
|
||||
.ok_or(SecretsApiError::AppNotFound)
|
||||
}
|
||||
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: &str,
|
||||
) -> Result<GroupId, SecretsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
|
||||
};
|
||||
found.map(|g| g.id).ok_or(SecretsApiError::GroupNotFound)
|
||||
}
|
||||
|
||||
/// Env scope is `*` (env-agnostic) or a kebab env name. Mirrors the vars
|
||||
/// admin validator so a secret and a var share the same env vocabulary.
|
||||
fn validate_env_scope(env: &str) -> Result<(), SecretsApiError> {
|
||||
if env == "*" {
|
||||
return Ok(());
|
||||
}
|
||||
if env.is_empty() || env.len() > 63 {
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"env must be '*' or 1–63 characters".into(),
|
||||
));
|
||||
}
|
||||
let first = env.chars().next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"env must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
if !env
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"env may contain only lowercase letters, digits, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecretsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("secret not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
@@ -214,7 +439,7 @@ impl From<SecretsError> for SecretsApiError {
|
||||
impl IntoResponse for SecretsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
|
||||
//! encoding, authorization, name validation, and the value-size cap all
|
||||
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
|
||||
//!
|
||||
//! Phase 3 made secrets polymorphic-owner + env-scoped (migration
|
||||
//! `0049_group_secrets.sql`): a secret is owned by exactly one app OR one
|
||||
//! ancestor group, and a descendant app resolves the nearest one,
|
||||
//! environment-filtered (mirroring `vars` / `config_resolver`). Writes are
|
||||
//! owner-keyed via [`SecretOwner`]; the SDK read path goes through
|
||||
//! [`SecretsRepo::resolve`], which walks the app→group→root chain.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AppId;
|
||||
use picloud_shared::{AppId, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecretsRepoError {
|
||||
@@ -19,14 +29,22 @@ pub enum SecretsRepoError {
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
/// Who owns a secret (Phase 3). A secret is owned by exactly one app OR
|
||||
/// one group; the owner is bound into the AES-GCM AAD (see
|
||||
/// `secrets_service::secret_aad`) so a cross-owner ciphertext swap fails
|
||||
/// decryption, and it selects the partial-unique conflict target on write.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecretOwner {
|
||||
App(AppId),
|
||||
Group(GroupId),
|
||||
}
|
||||
|
||||
/// An encrypted secret as it lives on disk: ciphertext (auth tag
|
||||
/// appended) plus the nonce it was sealed with.
|
||||
///
|
||||
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
|
||||
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
|
||||
/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration
|
||||
/// `0042_secrets_envelope_version.sql` adds the column with a default
|
||||
/// of `0`, so existing rows keep working.
|
||||
/// AES-GCM with AAD bound to the owner+name (see `secrets_service`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredSecret {
|
||||
pub encrypted_value: Vec<u8>,
|
||||
@@ -34,11 +52,21 @@ pub struct StoredSecret {
|
||||
pub version: i16,
|
||||
}
|
||||
|
||||
/// The winner of an inherited-secret resolution: the stored ciphertext
|
||||
/// plus the owner it actually came from (app-own or an ancestor group),
|
||||
/// which the caller needs to pick the right AAD when decrypting.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedSecret {
|
||||
pub owner: SecretOwner,
|
||||
pub stored: StoredSecret,
|
||||
}
|
||||
|
||||
/// Admin-surface metadata for one secret. Values are never returned —
|
||||
/// only the name and the last-modified timestamp.
|
||||
/// only the name, its environment scope, and the last-modified timestamp.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecretMeta {
|
||||
pub name: String,
|
||||
pub environment_scope: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -60,38 +88,62 @@ pub struct SecretsMetaPage {
|
||||
/// substitute an in-memory backing without Postgres.
|
||||
#[async_trait]
|
||||
pub trait SecretsRepo: Send + Sync {
|
||||
async fn get(
|
||||
/// Resolve the effective secret for `app_id` by name: walk the
|
||||
/// app→ancestor-group chain (depth 0 = the app), env-filter to the
|
||||
/// app's environment or `*`, and return the nearest winner (with
|
||||
/// `@E` beating `*` within a level). `None` if no level defines it.
|
||||
/// This is the runtime injection path — isolation is anchored to
|
||||
/// `app_id`, so an app only ever sees its own + its ancestors' secrets.
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<ResolvedSecret>, SecretsRepoError>;
|
||||
|
||||
/// Read one owner's OWN secret at a specific env scope (no inheritance).
|
||||
/// Backs the group-gated human value-read and the apply email path.
|
||||
async fn get(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError>;
|
||||
|
||||
/// Upsert (overwrite if present). `version` is the AES-GCM envelope
|
||||
/// discriminator from [`StoredSecret::version`].
|
||||
/// Upsert (overwrite if present) one `(owner, env_scope, name)` row.
|
||||
/// `version` is the AES-GCM envelope discriminator.
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError>;
|
||||
|
||||
/// Delete; returns whether a row was present.
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
|
||||
/// Delete one `(owner, env_scope, name)` row; returns whether a row
|
||||
/// was present.
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<bool, SecretsRepoError>;
|
||||
|
||||
/// Names only — the SDK `list` surface.
|
||||
/// Distinct names of an owner's OWN secrets (NOT inherited) — the SDK
|
||||
/// `list` surface. Names are de-duplicated across env scopes.
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError>;
|
||||
|
||||
/// Name + updated_at — the admin `GET` surface.
|
||||
/// Name + scope + updated_at of an owner's OWN secrets — the admin
|
||||
/// `GET` surface.
|
||||
async fn list_meta(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError>;
|
||||
@@ -131,21 +183,98 @@ pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
|
||||
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
|
||||
}
|
||||
|
||||
/// `list_meta` orders by `(name, environment_scope)` — a name can now have
|
||||
/// several env-scoped rows (group secrets) — so its keyset cursor must carry
|
||||
/// BOTH columns, else a name whose scopes straddle a page boundary loses its
|
||||
/// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or
|
||||
/// secret-name char, so it's an unambiguous delimiter).
|
||||
const CURSOR_SEP: char = '\u{1f}';
|
||||
|
||||
fn encode_meta_cursor(name: &str, scope: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes())
|
||||
}
|
||||
|
||||
fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||
let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||
s.split_once(CURSOR_SEP)
|
||||
.map(|(n, sc)| (n.to_string(), sc.to_string()))
|
||||
.ok_or(SecretsRepoError::InvalidCursor)
|
||||
}
|
||||
|
||||
/// `(owner_column, owner_uuid)` for binding an owner into a query.
|
||||
fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) {
|
||||
match owner {
|
||||
SecretOwner::App(a) => ("app_id", a.into_inner()),
|
||||
SecretOwner::Group(g) => ("group_id", g.into_inner()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsRepo for PostgresSecretsRepo {
|
||||
async fn get(
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(
|
||||
"SELECT encrypted_value, nonce, version FROM secrets \
|
||||
WHERE app_id = $1 AND name = $2",
|
||||
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
|
||||
// Reuse the shared chain-walk ($1 = app_id), join secrets by name,
|
||||
// env-filter, and take the nearest level — `@E` beating `*` within a
|
||||
// level via the secondary sort key. One row out, or none.
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(s.app_id, s.group_id) AS owner_id, \
|
||||
s.encrypted_value, s.nonce, s.version \
|
||||
FROM chain c \
|
||||
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.name = $2 \
|
||||
AND (s.environment_scope = '*' OR s.environment_scope = c.app_env) \
|
||||
ORDER BY c.depth ASC, (s.environment_scope <> '*') DESC \
|
||||
LIMIT 1"
|
||||
);
|
||||
let row: Option<(String, Uuid, Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(
|
||||
row.map(|(owner_kind, owner_id, encrypted_value, nonce, version)| {
|
||||
let owner = if owner_kind == "app" {
|
||||
SecretOwner::App(AppId::from(owner_id))
|
||||
} else {
|
||||
SecretOwner::Group(GroupId::from(owner_id))
|
||||
};
|
||||
ResolvedSecret {
|
||||
owner,
|
||||
stored: StoredSecret {
|
||||
encrypted_value,
|
||||
nonce,
|
||||
version,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
let (col, id) = owner_bind(owner);
|
||||
let sql = format!(
|
||||
"SELECT encrypted_value, nonce, version FROM secrets \
|
||||
WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
|
||||
);
|
||||
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
|
||||
.bind(id)
|
||||
.bind(env_scope)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret {
|
||||
encrypted_value,
|
||||
nonce,
|
||||
@@ -155,34 +284,54 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (app_id, name) DO UPDATE \
|
||||
// Owner-kind-specific SQL: write only the owner's nullable column and
|
||||
// restate the partial-unique predicate as the ON CONFLICT arbiter.
|
||||
let (col, id) = owner_bind(owner);
|
||||
let predicate = match owner {
|
||||
SecretOwner::App(_) => "app_id IS NOT NULL",
|
||||
SecretOwner::Group(_) => "group_id IS NOT NULL",
|
||||
};
|
||||
let sql = format!(
|
||||
"INSERT INTO secrets ({col}, environment_scope, name, encrypted_value, nonce, version) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
ON CONFLICT ({col}, environment_scope, name) WHERE {predicate} DO UPDATE \
|
||||
SET encrypted_value = EXCLUDED.encrypted_value, \
|
||||
nonce = EXCLUDED.nonce, \
|
||||
version = EXCLUDED.version, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(encrypted_value)
|
||||
.bind(nonce)
|
||||
.bind(version)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
updated_at = NOW()"
|
||||
);
|
||||
sqlx::query(&sql)
|
||||
.bind(id)
|
||||
.bind(env_scope)
|
||||
.bind(name)
|
||||
.bind(encrypted_value)
|
||||
.bind(nonce)
|
||||
.bind(version)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
||||
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
|
||||
.bind(app_id.into_inner())
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<bool, SecretsRepoError> {
|
||||
let (col, id) = owner_bind(owner);
|
||||
let sql = format!(
|
||||
"DELETE FROM secrets WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
|
||||
);
|
||||
let res = sqlx::query(&sql)
|
||||
.bind(id)
|
||||
.bind(env_scope)
|
||||
.bind(name)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -191,7 +340,7 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||
@@ -201,16 +350,20 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
None => None,
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT name FROM secrets \
|
||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let (col, id) = owner_bind(owner);
|
||||
// DISTINCT collapses a name that exists at several env scopes into
|
||||
// one entry — the SDK `list` is a name catalogue, not per-scope.
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT name FROM secrets \
|
||||
WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3"
|
||||
);
|
||||
let rows: Vec<(String,)> = sqlx::query_as(&sql)
|
||||
.bind(id)
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
|
||||
let next_cursor = if names.len() > limit as usize {
|
||||
@@ -224,34 +377,49 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn list_meta(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||
let limit = clamp_limit(limit);
|
||||
let last_name = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
// Composite keyset on (name, environment_scope) — see encode_meta_cursor.
|
||||
let last = match cursor {
|
||||
Some(c) => Some(decode_meta_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
let (last_name, last_scope) = match &last {
|
||||
Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())),
|
||||
None => (None, None),
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT name, updated_at FROM secrets \
|
||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let (col, id) = owner_bind(owner);
|
||||
let sql = format!(
|
||||
"SELECT name, environment_scope, updated_at FROM secrets \
|
||||
WHERE {col} = $1 \
|
||||
AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \
|
||||
ORDER BY name ASC, environment_scope ASC LIMIT $4"
|
||||
);
|
||||
let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
|
||||
.bind(id)
|
||||
.bind(last_name)
|
||||
.bind(last_scope)
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut items: Vec<SecretMeta> = rows
|
||||
.into_iter()
|
||||
.map(|(name, updated_at)| SecretMeta { name, updated_at })
|
||||
.map(|(name, environment_scope, updated_at)| SecretMeta {
|
||||
name,
|
||||
environment_scope,
|
||||
updated_at,
|
||||
})
|
||||
.collect();
|
||||
let next_cursor = if items.len() > limit as usize {
|
||||
items.truncate(limit as usize);
|
||||
items.last().map(|m| encode_cursor(&m.name))
|
||||
items
|
||||
.last()
|
||||
.map(|m| encode_meta_cursor(&m.name, &m.environment_scope))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -22,22 +22,39 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
crypto, validate_secret_name, AppId, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
||||
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
||||
SecretsService,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
|
||||
|
||||
// `SecretOwner` is defined one layer down in `secrets_repo` (it keys both
|
||||
// the storage CRUD and the AAD). Re-exported here so the historical
|
||||
// `secrets_service::SecretOwner` path stays stable.
|
||||
pub use crate::secrets_repo::SecretOwner;
|
||||
|
||||
/// Current AES-GCM envelope version for the per-app secret store.
|
||||
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
|
||||
pub const SECRET_ENVELOPE_V1: i16 = 1;
|
||||
|
||||
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a
|
||||
/// cross-row swap (e.g. moving one app's ciphertext under another
|
||||
/// app's `(app_id, name)` slot) fails decryption.
|
||||
fn secret_aad(app_id: AppId, name: &str) -> Vec<u8> {
|
||||
format!("secret:{app_id}:{name}").into_bytes()
|
||||
/// The env scope under which an app's OWN secrets are stored. App secrets
|
||||
/// are env-agnostic — only group secrets carry a concrete environment.
|
||||
const APP_SECRET_SCOPE: &str = "*";
|
||||
|
||||
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row
|
||||
/// swap (moving one owner's ciphertext under another's slot) fails
|
||||
/// decryption. The **app** form is byte-identical to the pre-Phase-3
|
||||
/// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting
|
||||
/// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}`
|
||||
/// namespace (the `group:` infix keeps app and group AAD disjoint even if a
|
||||
/// group UUID happened to equal an app UUID).
|
||||
fn secret_aad(owner: SecretOwner, name: &str) -> Vec<u8> {
|
||||
match owner {
|
||||
SecretOwner::App(app_id) => format!("secret:{app_id}:{name}"),
|
||||
SecretOwner::Group(group_id) => format!("secret:group:{group_id}:{name}"),
|
||||
}
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// Default per-secret plaintext cap (64 KB). Override with
|
||||
@@ -96,7 +113,7 @@ impl Default for SecretsConfig {
|
||||
/// failure (should not happen for a `serde_json::Value`).
|
||||
pub fn seal(
|
||||
master_key: &MasterKey,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
name: &str,
|
||||
value: &serde_json::Value,
|
||||
max_value_bytes: usize,
|
||||
@@ -109,7 +126,7 @@ pub fn seal(
|
||||
actual: plaintext.len(),
|
||||
});
|
||||
}
|
||||
let aad = secret_aad(app_id, name);
|
||||
let aad = secret_aad(owner, name);
|
||||
let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
|
||||
Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
|
||||
}
|
||||
@@ -125,7 +142,7 @@ pub fn seal(
|
||||
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
|
||||
pub fn open(
|
||||
master_key: &MasterKey,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
name: &str,
|
||||
stored: &StoredSecret,
|
||||
) -> Result<serde_json::Value, SecretsError> {
|
||||
@@ -136,7 +153,7 @@ pub fn open(
|
||||
master_key.as_bytes(),
|
||||
),
|
||||
SECRET_ENVELOPE_V1 => {
|
||||
let aad = secret_aad(app_id, name);
|
||||
let aad = secret_aad(owner, name);
|
||||
crypto::decrypt_with_aad(
|
||||
&stored.encrypted_value,
|
||||
&stored.nonce,
|
||||
@@ -259,10 +276,15 @@ impl SecretsService for SecretsServiceImpl {
|
||||
) -> Result<Option<serde_json::Value>, SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_read(cx).await?;
|
||||
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
|
||||
// Inherited resolution: the app's own secret, else the nearest
|
||||
// ancestor group's, env-filtered. `resolve` anchors the walk to
|
||||
// `cx.app_id`, so an app can only ever read its own + its ancestors'
|
||||
// secrets — the cross-app isolation boundary. The winning owner comes
|
||||
// back with the row so we decrypt under the AAD it was sealed with.
|
||||
let Some(resolved) = self.repo.resolve(cx.app_id, name).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
match open(&self.master_key, cx.app_id, name, &stored) {
|
||||
match open(&self.master_key, resolved.owner, name, &resolved.stored) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) => {
|
||||
// A decrypt failure is operationally significant — surface
|
||||
@@ -286,15 +308,11 @@ impl SecretsService for SecretsServiceImpl {
|
||||
) -> Result<(), SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_write(cx).await?;
|
||||
let (ciphertext, nonce, version) = seal(
|
||||
&self.master_key,
|
||||
cx.app_id,
|
||||
name,
|
||||
&value,
|
||||
self.max_value_bytes,
|
||||
)?;
|
||||
let owner = SecretOwner::App(cx.app_id);
|
||||
let (ciphertext, nonce, version) =
|
||||
seal(&self.master_key, owner, name, &value, self.max_value_bytes)?;
|
||||
self.repo
|
||||
.set(cx.app_id, name, &ciphertext, &nonce, version)
|
||||
.set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -302,7 +320,10 @@ impl SecretsService for SecretsServiceImpl {
|
||||
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_write(cx).await?;
|
||||
Ok(self.repo.delete(cx.app_id, name).await?)
|
||||
Ok(self
|
||||
.repo
|
||||
.delete(SecretOwner::App(cx.app_id), APP_SECRET_SCOPE, name)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
@@ -312,7 +333,10 @@ impl SecretsService for SecretsServiceImpl {
|
||||
limit: u32,
|
||||
) -> Result<SecretsListPage, SecretsError> {
|
||||
self.check_read(cx).await?;
|
||||
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
|
||||
let page = self
|
||||
.repo
|
||||
.list_names(SecretOwner::App(cx.app_id), cursor, limit)
|
||||
.await?;
|
||||
Ok(SecretsListPage {
|
||||
names: page.names,
|
||||
next_cursor: page.next_cursor,
|
||||
@@ -328,44 +352,76 @@ impl SecretsService for SecretsServiceImpl {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
|
||||
use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage};
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId,
|
||||
ScriptId, UserId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory backing keyed by `(owner_key, env_scope, name)`. The owner
|
||||
/// key is `"app:{uuid}"` / `"group:{uuid}"` so app and group rows never
|
||||
/// collide. These unit tests exercise the app surface (scope `*`); the
|
||||
/// chain-walk `resolve` is journey-tested against real Postgres, so here
|
||||
/// it degrades to a plain app-own `*` lookup.
|
||||
#[derive(Default)]
|
||||
struct InMemorySecretsRepo {
|
||||
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
|
||||
data: Mutex<BTreeMap<(String, String, String), StoredSecret>>,
|
||||
}
|
||||
|
||||
fn owner_key(owner: SecretOwner) -> String {
|
||||
match owner {
|
||||
SecretOwner::App(a) => format!("app:{a}"),
|
||||
SecretOwner::Group(g) => format!("group:{g}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsRepo for InMemorySecretsRepo {
|
||||
async fn get(
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
|
||||
let owner = SecretOwner::App(app_id);
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(
|
||||
owner_key(owner),
|
||||
APP_SECRET_SCOPE.to_string(),
|
||||
name.to_string(),
|
||||
))
|
||||
.cloned()
|
||||
.map(|stored| ResolvedSecret { owner, stored }))
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, name.to_string()))
|
||||
.get(&(owner_key(owner), env_scope.to_string(), name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
self.data.lock().await.insert(
|
||||
(app_id, name.to_string()),
|
||||
(owner_key(owner), env_scope.to_string(), name.to_string()),
|
||||
StoredSecret {
|
||||
encrypted_value: encrypted_value.to_vec(),
|
||||
nonce: nonce.to_vec(),
|
||||
@@ -374,29 +430,36 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<bool, SecretsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(app_id, name.to_string()))
|
||||
.remove(&(owner_key(owner), env_scope.to_string(), name.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
let ok = owner_key(owner);
|
||||
let last = cursor.map(std::string::ToString::to_string);
|
||||
let mut names: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, _), _)| *a == app_id)
|
||||
.map(|((_, n), _)| n.clone())
|
||||
.filter(|((o, _, _), _)| *o == ok)
|
||||
.map(|((_, _, n), _)| n.clone())
|
||||
.filter(|n| last.as_ref().is_none_or(|l| n > l))
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
let take = (limit as usize).max(1);
|
||||
let next_cursor = if names.len() > take {
|
||||
names.truncate(take);
|
||||
@@ -408,7 +471,7 @@ mod tests {
|
||||
}
|
||||
async fn list_meta(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_owner: SecretOwner,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||
@@ -635,7 +698,11 @@ mod tests {
|
||||
repo.data
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&(app, "k".to_string()))
|
||||
.get_mut(&(
|
||||
owner_key(SecretOwner::App(app)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
))
|
||||
.unwrap()
|
||||
.encrypted_value[0] ^= 0xff;
|
||||
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
|
||||
@@ -666,10 +733,21 @@ mod tests {
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(a, "k".to_string()))
|
||||
.get(&(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
repo.data.lock().await.insert((b, "k".to_string()), stolen);
|
||||
repo.data.lock().await.insert(
|
||||
(
|
||||
owner_key(SecretOwner::App(b)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
),
|
||||
stolen,
|
||||
);
|
||||
let err = s.get(&anon_cx(b), "k").await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SecretsError::Corrupted),
|
||||
@@ -677,6 +755,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aad_distinguishes_app_and_group_owner() {
|
||||
// Phase 3: app and group secrets live in disjoint AAD namespaces
|
||||
// (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed
|
||||
// for a group must not open as an app secret (and vice-versa),
|
||||
// even if the raw UUIDs were equal — the `group:` infix separates
|
||||
// them. Exercise the free seal/open functions directly.
|
||||
let k = key();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let value = serde_json::json!("shared-config");
|
||||
|
||||
// Seal under the GROUP owner.
|
||||
let (ct, nonce, version) =
|
||||
seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap();
|
||||
let stored = StoredSecret {
|
||||
encrypted_value: ct,
|
||||
nonce: nonce.to_vec(),
|
||||
version,
|
||||
};
|
||||
// Opens fine as the same group owner.
|
||||
assert_eq!(
|
||||
open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(),
|
||||
value
|
||||
);
|
||||
// Fails as an app owner (AAD mismatch) — even reusing the UUID.
|
||||
let app_from_group = AppId::from(group.into_inner());
|
||||
assert!(matches!(
|
||||
open(&k, SecretOwner::App(app_from_group), "db_url", &stored),
|
||||
Err(SecretsError::Corrupted)
|
||||
));
|
||||
// And the symmetric direction: an app-sealed row won't open as a group.
|
||||
let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap();
|
||||
let stored2 = StoredSecret {
|
||||
encrypted_value: ct2,
|
||||
nonce: nonce2.to_vec(),
|
||||
version: v2,
|
||||
};
|
||||
assert!(matches!(
|
||||
open(
|
||||
&k,
|
||||
SecretOwner::Group(GroupId::from(app.into_inner())),
|
||||
"db_url",
|
||||
&stored2
|
||||
),
|
||||
Err(SecretsError::Corrupted)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aad_blocks_cross_name_ciphertext_swap() {
|
||||
// Same app, different name → AAD mismatch.
|
||||
@@ -695,13 +822,21 @@ mod tests {
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(a, "real".to_string()))
|
||||
.get(&(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"real".to_string(),
|
||||
))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
repo.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((a, "renamed".to_string()), stolen);
|
||||
repo.data.lock().await.insert(
|
||||
(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"renamed".to_string(),
|
||||
),
|
||||
stolen,
|
||||
);
|
||||
let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::Corrupted));
|
||||
}
|
||||
@@ -726,7 +861,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
repo.data.lock().await.insert(
|
||||
(app, "old".to_string()),
|
||||
(
|
||||
owner_key(SecretOwner::App(app)),
|
||||
"*".to_string(),
|
||||
"old".to_string(),
|
||||
),
|
||||
StoredSecret {
|
||||
encrypted_value: ct,
|
||||
nonce: nonce.to_vec(),
|
||||
|
||||
@@ -383,6 +383,7 @@ mod tests {
|
||||
slug: self.slug.clone(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -396,6 +397,12 @@ mod tests {
|
||||
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
if id != self.id {
|
||||
return Ok(None);
|
||||
@@ -436,6 +443,7 @@ mod tests {
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -444,6 +452,7 @@ mod tests {
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct Trigger {
|
||||
pub id: TriggerId,
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
/// §4.5 per-app trigger identifier; the manifest merge/upsert key.
|
||||
pub name: String,
|
||||
pub kind: TriggerKind,
|
||||
pub enabled: bool,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
@@ -502,6 +504,220 @@ impl PostgresTriggerRepo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a trigger (parent row + per-kind detail) within an existing
|
||||
/// transaction — used by the declarative `apply` engine. Supports the
|
||||
/// five settled kinds; `email`/`queue`/`dead_letter` have their own
|
||||
/// create paths and are rejected here.
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub(crate) async fn insert_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
registered_by: AdminUserId,
|
||||
dispatch_mode: TriggerDispatchMode,
|
||||
retry_max_attempts: u32,
|
||||
retry_backoff: BackoffShape,
|
||||
retry_base_ms: u32,
|
||||
details: &TriggerDetails,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let kind = match details {
|
||||
TriggerDetails::Kv { .. } => "kv",
|
||||
TriggerDetails::Docs { .. } => "docs",
|
||||
TriggerDetails::Files { .. } => "files",
|
||||
TriggerDetails::Cron { .. } => "cron",
|
||||
TriggerDetails::Pubsub { .. } => "pubsub",
|
||||
TriggerDetails::Queue { .. } => "queue",
|
||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
||||
return Err(TriggerRepoError::Invalid(
|
||||
"trigger kind not supported by declarative apply".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Queue: enforce the one-consumer-per-(app_id, queue_name) invariant —
|
||||
// the same advisory-lock + existence guard the interactive
|
||||
// `create_queue_trigger` uses. Without this, a concurrent apply +
|
||||
// interactive create on disjoint locks could double-register a queue
|
||||
// consumer (there is no DB unique constraint backing the invariant).
|
||||
if let TriggerDetails::Queue { queue_name, .. } = details {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(advisory_lock_key(app_id, queue_name))
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
let existing: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT t.id FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'queue' AND d.queue_name = $2",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(queue_name)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
return Err(TriggerRepoError::Invalid(format!(
|
||||
"queue '{queue_name}' already has a consumer trigger; remove the existing one first"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let row: (Uuid,) = 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, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(kind)
|
||||
.bind(dispatch_mode.as_str())
|
||||
.bind(i32::try_from(retry_max_attempts).unwrap_or(3))
|
||||
.bind(retry_backoff.as_str())
|
||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||
.bind(registered_by.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let tid = row.0;
|
||||
|
||||
match details {
|
||||
TriggerDetails::Kv {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = 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(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Docs {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO docs_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Files {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Cron {
|
||||
schedule, timezone, ..
|
||||
} => {
|
||||
sqlx::query(
|
||||
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(schedule)
|
||||
.bind(timezone)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Pubsub { topic_pattern } => {
|
||||
sqlx::query(
|
||||
"INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(topic_pattern)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Queue {
|
||||
queue_name,
|
||||
visibility_timeout_secs,
|
||||
..
|
||||
} => {
|
||||
sqlx::query(
|
||||
"INSERT INTO queue_trigger_details \
|
||||
(trigger_id, queue_name, visibility_timeout_secs) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(queue_name)
|
||||
.bind(i32::try_from(*visibility_timeout_secs).unwrap_or(30))
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
||||
unreachable!("guarded above")
|
||||
}
|
||||
}
|
||||
Ok(tid.into())
|
||||
}
|
||||
|
||||
/// Insert an email trigger within a transaction. The inbound HMAC secret
|
||||
/// is sealed by the apply engine (resolved from the app's secret store);
|
||||
/// this writes the ciphertext. Parent retry settings match the
|
||||
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
|
||||
pub(crate) async fn insert_email_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
registered_by: AdminUserId,
|
||||
inbound_secret_encrypted: &[u8],
|
||||
inbound_secret_nonce: &[u8],
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let row: (Uuid,) = 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, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(registered_by.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO email_trigger_details \
|
||||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(row.0)
|
||||
.bind(inbound_secret_encrypted)
|
||||
.bind(inbound_secret_nonce)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(row.0.into())
|
||||
}
|
||||
|
||||
/// Delete a trigger by id within an existing transaction (its detail row
|
||||
/// cascades via the FK). Used by `apply --prune`.
|
||||
pub(crate) async fn delete_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: TriggerId,
|
||||
) -> Result<(), TriggerRepoError> {
|
||||
sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TriggerRepo for PostgresTriggerRepo {
|
||||
async fn create_kv_trigger(
|
||||
@@ -521,7 +737,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
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, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -552,6 +768,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Kv,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -586,7 +803,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'docs', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -617,6 +834,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Docs,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -649,7 +867,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
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, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -677,6 +895,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::DeadLetter,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -713,7 +932,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'cron', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -743,6 +962,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Cron,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -778,7 +998,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'files', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -809,6 +1029,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Files,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -842,7 +1063,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'pubsub', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -870,6 +1091,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Pubsub,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -902,7 +1124,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -929,6 +1151,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -971,7 +1194,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
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, \
|
||||
"SELECT id, app_id, script_id, name, 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",
|
||||
@@ -999,7 +1222,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
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, \
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE id = $1",
|
||||
@@ -1249,7 +1472,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'queue', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -1280,6 +1503,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1308,7 +1532,8 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
t.registered_by_principal \
|
||||
FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.kind = 'queue' AND t.enabled = TRUE",
|
||||
JOIN scripts s ON s.id = t.script_id \
|
||||
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -1486,6 +1711,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name,
|
||||
kind,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1528,6 +1754,7 @@ struct TriggerRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
name: String,
|
||||
kind: String,
|
||||
enabled: bool,
|
||||
dispatch_mode: String,
|
||||
|
||||
@@ -38,7 +38,7 @@ use crate::trigger_repo::{
|
||||
/// in practice (the dispatcher itself ticks every 100ms; reclaim ticks
|
||||
/// every 30s; an executor needs more wall-clock than this to do useful
|
||||
/// work). Reject below this with a 422 + actionable error.
|
||||
const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
|
||||
pub(crate) const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
|
||||
|
||||
/// Default soft-warning ceiling when `PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC`
|
||||
/// is unset. Re-exported from the dispatcher so the single source of
|
||||
@@ -892,6 +892,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Kv,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -920,6 +921,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Docs,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -948,6 +950,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::DeadLetter,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
@@ -977,6 +980,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
@@ -1021,6 +1025,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Cron,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1050,6 +1055,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Files,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1078,6 +1084,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Pubsub,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1154,6 +1161,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1201,6 +1209,7 @@ mod tests {
|
||||
slug: "test".into(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1218,6 +1227,7 @@ mod tests {
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1226,6 +1236,7 @@ mod tests {
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1244,6 +1255,12 @@ mod tests {
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
id: AppId,
|
||||
@@ -1347,6 +1364,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
sandbox: picloud_shared::ScriptSandbox::default(),
|
||||
memory_limit_mb: 256,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1716,6 +1734,7 @@ mod tests {
|
||||
slug: "a".into(),
|
||||
name: "a".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1727,6 +1746,7 @@ mod tests {
|
||||
slug: "b".into(),
|
||||
name: "b".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
410
crates/manager-core/src/vars_api.rs
Normal file
410
crates/manager-core/src/vars_api.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
//! `/api/v1/admin/{apps,groups}/{id_or_slug}/vars*` — the Phase-3 config
|
||||
//! `vars` admin surface (write/list side; resolution lives in
|
||||
//! `config_resolver` + the `vars::` SDK).
|
||||
//!
|
||||
//! * `GET /apps/{id}/vars` — list the app's OWN vars.
|
||||
//! * `PUT /apps/{id}/vars` — set/overwrite one app var.
|
||||
//! * `DELETE /apps/{id}/vars/{key}` — delete one app var.
|
||||
//! * `GET/PUT/DELETE /groups/{id}/vars[...]` — same, group-owned.
|
||||
//!
|
||||
//! App routes gate on `App{Vars}Read/Write`; group routes on
|
||||
//! `Group{Vars}Read/Write`. The owner is resolved FIRST (slug-or-uuid),
|
||||
//! THEN `authz::require` binds the capability to the resolved owner id —
|
||||
//! never to a caller-controlled path param. Listing returns the owner's
|
||||
//! OWN rows only (not the resolved/inherited view).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, put};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
use crate::vars_repo::{VarOwner, VarsRepo, VarsRepoError};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VarsApiState {
|
||||
pub vars: Arc<dyn VarsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn vars_router(state: VarsApiState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/apps/{id_or_slug}/vars",
|
||||
get(list_app_vars).put(set_app_var),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id_or_slug}/vars/{key}",
|
||||
axum::routing::delete(delete_app_var),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/vars",
|
||||
put(set_group_var).get(list_group_vars),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/vars/{key}",
|
||||
axum::routing::delete(delete_group_var),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetVarRequest {
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
/// Environment scope — `*` (env-agnostic, default) or a concrete env
|
||||
/// name matched against `apps.environment` at resolution time.
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
/// Write a tombstone (suppresses an inherited key) instead of a real
|
||||
/// value. The body's `value` is ignored for a tombstone.
|
||||
#[serde(default)]
|
||||
pub tombstone: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnvQuery {
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct VarItem {
|
||||
key: String,
|
||||
env: String,
|
||||
value: serde_json::Value,
|
||||
is_tombstone: bool,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListVarsResponse {
|
||||
vars: Vec<VarItem>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_app_vars(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
list(&*s.vars, VarOwner::App(app_id)).await
|
||||
}
|
||||
|
||||
async fn set_app_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SetVarRequest>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
set(&*s.vars, VarOwner::App(app_id), input).await
|
||||
}
|
||||
|
||||
async fn delete_app_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, key)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
delete(&*s.vars, VarOwner::App(app_id), &key, q.env.as_deref()).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_group_vars(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
list(&*s.vars, VarOwner::Group(group_id)).await
|
||||
}
|
||||
|
||||
async fn set_group_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SetVarRequest>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
set(&*s.vars, VarOwner::Group(group_id), input).await
|
||||
}
|
||||
|
||||
async fn delete_group_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, key)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
delete(&*s.vars, VarOwner::Group(group_id), &key, q.env.as_deref()).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Shared owner-generic bodies
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let rows = vars.list_for_owner(owner).await?;
|
||||
Ok(Json(ListVarsResponse {
|
||||
vars: rows
|
||||
.into_iter()
|
||||
.map(|r| VarItem {
|
||||
key: r.key,
|
||||
env: r.environment_scope,
|
||||
value: r.value,
|
||||
is_tombstone: r.is_tombstone,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
input: SetVarRequest,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
validate_key(&input.key)?;
|
||||
let env = input.env.as_deref().unwrap_or("*");
|
||||
validate_env_scope(env)?;
|
||||
// A tombstone carries no meaningful value (the resolver suppresses the
|
||||
// key regardless); store JSON null so the NOT NULL column is satisfied.
|
||||
let value = if input.tombstone {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
input.value
|
||||
};
|
||||
vars.set(owner, env, &input.key, &value, input.tombstone)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let env = env.unwrap_or("*");
|
||||
validate_env_scope(env)?;
|
||||
if !vars.delete(owner, env, key).await? {
|
||||
return Err(VarsApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Resolution + validation
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, VarsApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or(VarsApiError::AppNotFound)
|
||||
}
|
||||
|
||||
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<GroupId, VarsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
};
|
||||
found.map(|g| g.id).ok_or(VarsApiError::GroupNotFound)
|
||||
}
|
||||
|
||||
/// Keys are kebab identifiers (`^[a-z0-9][a-z0-9-]*$`) — same shape as the
|
||||
/// manifest's var names (docs/design §4.3).
|
||||
fn validate_key(key: &str) -> Result<(), VarsApiError> {
|
||||
if key.is_empty() || key.len() > 128 {
|
||||
return Err(VarsApiError::Invalid("key must be 1–128 characters".into()));
|
||||
}
|
||||
let mut chars = key.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"key must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
if !key
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(VarsApiError::Invalid(
|
||||
"key may contain only lowercase letters, digits, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Env scope is `*` (env-agnostic) or a kebab env name.
|
||||
fn validate_env_scope(env: &str) -> Result<(), VarsApiError> {
|
||||
if env == "*" {
|
||||
return Ok(());
|
||||
}
|
||||
if env.is_empty() || env.len() > 63 {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env must be '*' or 1–63 characters".into(),
|
||||
));
|
||||
}
|
||||
let mut chars = env.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
if !env
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env may contain only lowercase letters, digits, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VarsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("var not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("vars backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for VarsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for VarsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VarsRepoError> for VarsApiError {
|
||||
fn from(e: VarsRepoError) -> Self {
|
||||
match e {
|
||||
VarsRepoError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for VarsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::GroupNotFound | 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, "vars admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "vars admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
210
crates/manager-core/src/vars_repo.rs
Normal file
210
crates/manager-core/src/vars_repo.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! Low-level Postgres CRUD over `vars` — the write/admin side of the
|
||||
//! Phase-3 config layer (the read/resolution side lives in
|
||||
//! `config_resolver`). Storage-only: it upserts and lists an owner's OWN
|
||||
//! rows. Authorization, env-scope validation, and value encoding live one
|
||||
//! layer up in `vars_api`.
|
||||
//!
|
||||
//! A var is owned by exactly one group OR one app (the migration's
|
||||
//! `vars_owner_exactly_one` CHECK). Because the owner is split across two
|
||||
//! nullable columns (`group_id`, `app_id`), the upsert writes
|
||||
//! owner-kind-specific SQL with the matching partial-unique conflict
|
||||
//! target.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, GroupId};
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VarsRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Which side of the polymorphic owner a var hangs off. The repo chooses
|
||||
/// the conflict target (group vs app partial-unique index) from this.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VarOwner {
|
||||
Group(GroupId),
|
||||
App(AppId),
|
||||
}
|
||||
|
||||
/// One of an owner's OWN var rows (NOT a resolved/inherited value). Backs
|
||||
/// the admin list surface.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VarRow {
|
||||
pub environment_scope: String,
|
||||
pub key: String,
|
||||
pub value: JsonValue,
|
||||
pub is_tombstone: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Repo surface. A trait so service/handler tests can substitute an
|
||||
/// in-memory backing without Postgres.
|
||||
#[async_trait]
|
||||
pub trait VarsRepo: Send + Sync {
|
||||
/// Upsert one (owner, env_scope, key) row.
|
||||
async fn set(
|
||||
&self,
|
||||
owner: VarOwner,
|
||||
env_scope: &str,
|
||||
key: &str,
|
||||
value: &JsonValue,
|
||||
is_tombstone: bool,
|
||||
) -> Result<(), VarsRepoError>;
|
||||
|
||||
/// Delete one (owner, env_scope, key) row; returns whether a row was
|
||||
/// present.
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: VarOwner,
|
||||
env_scope: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, VarsRepoError>;
|
||||
|
||||
/// The owner's OWN rows only (NOT resolved/inherited), ordered by
|
||||
/// (key, environment_scope).
|
||||
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresVarsRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresVarsRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VarsRepo for PostgresVarsRepo {
|
||||
async fn set(
|
||||
&self,
|
||||
owner: VarOwner,
|
||||
env_scope: &str,
|
||||
key: &str,
|
||||
value: &JsonValue,
|
||||
is_tombstone: bool,
|
||||
) -> Result<(), VarsRepoError> {
|
||||
// Owner-kind-specific SQL: only one of the two nullable owner
|
||||
// columns is written, and the conflict target is the matching
|
||||
// partial-unique index.
|
||||
match owner {
|
||||
VarOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
// The conflict target is a PARTIAL unique index, so the
|
||||
// index predicate (`WHERE group_id IS NOT NULL`) must be
|
||||
// restated for Postgres to infer the arbiter.
|
||||
"INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (group_id, environment_scope, key) \
|
||||
WHERE group_id IS NOT NULL DO UPDATE \
|
||||
SET value = EXCLUDED.value, \
|
||||
is_tombstone = EXCLUDED.is_tombstone, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(env_scope)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.bind(is_tombstone)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
VarOwner::App(a) => {
|
||||
sqlx::query(
|
||||
// Partial-index conflict target — restate the predicate.
|
||||
"INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (app_id, environment_scope, key) \
|
||||
WHERE app_id IS NOT NULL DO UPDATE \
|
||||
SET value = EXCLUDED.value, \
|
||||
is_tombstone = EXCLUDED.is_tombstone, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(env_scope)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.bind(is_tombstone)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: VarOwner,
|
||||
env_scope: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, VarsRepoError> {
|
||||
let res = match owner {
|
||||
VarOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM vars \
|
||||
WHERE group_id = $1 AND environment_scope = $2 AND key = $3",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(env_scope)
|
||||
.bind(key)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
}
|
||||
VarOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM vars \
|
||||
WHERE app_id = $1 AND environment_scope = $2 AND key = $3",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(env_scope)
|
||||
.bind(key)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError> {
|
||||
let rows: Vec<(String, String, JsonValue, bool, DateTime<Utc>)> = match owner {
|
||||
VarOwner::Group(g) => {
|
||||
sqlx::query_as(
|
||||
"SELECT environment_scope, key, value, is_tombstone, updated_at \
|
||||
FROM vars WHERE group_id = $1 \
|
||||
ORDER BY key ASC, environment_scope ASC",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
}
|
||||
VarOwner::App(a) => {
|
||||
sqlx::query_as(
|
||||
"SELECT environment_scope, key, value, is_tombstone, updated_at \
|
||||
FROM vars WHERE app_id = $1 \
|
||||
ORDER BY key ASC, environment_scope ASC",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(environment_scope, key, value, is_tombstone, updated_at)| VarRow {
|
||||
environment_scope,
|
||||
key,
|
||||
value,
|
||||
is_tombstone,
|
||||
updated_at,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
63
crates/manager-core/src/vars_service.rs
Normal file
63
crates/manager-core/src/vars_service.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! `VarsService` — the runtime read path for group-inherited config.
|
||||
//!
|
||||
//! Resolves the calling app's config (own rows + inherited group rows,
|
||||
//! env-filtered, proximity-first; see `config_resolver`) and exposes it to
|
||||
//! scripts as `vars::get(key)` / `vars::all()`. Read-only from scripts;
|
||||
//! writes go through the admin API. Like every SDK service, it derives the
|
||||
//! app from `cx.app_id` — never a script argument — so cross-app isolation
|
||||
//! holds.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{SdkCallCx, VarsError, VarsService};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::config_resolver::{fetch_var_candidates, resolve};
|
||||
|
||||
pub struct VarsServiceImpl {
|
||||
pool: PgPool,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
impl VarsServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool, authz: Arc<dyn AuthzRepo>) -> Self {
|
||||
Self { pool, authz }
|
||||
}
|
||||
|
||||
/// Authed principals need `AppVarsRead`; anonymous public-HTTP scripts
|
||||
/// (`principal: None`) read freely under script-as-gate semantics.
|
||||
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), VarsError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppVarsRead(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| VarsError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolved(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||
let candidates = fetch_var_candidates(&self.pool, cx.app_id)
|
||||
.await
|
||||
.map_err(|e| VarsError::Backend(e.to_string()))?;
|
||||
let (values, _provenance) = resolve(candidates);
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VarsService for VarsServiceImpl {
|
||||
async fn get(&self, cx: &SdkCallCx, key: &str) -> Result<Option<Value>, VarsError> {
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.resolved(cx).await?.remove(key))
|
||||
}
|
||||
|
||||
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||
self.check_read(cx).await?;
|
||||
self.resolved(cx).await
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,7 @@ table: execution_logs
|
||||
status: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
source: text NOT NULL default='http'::text
|
||||
|
||||
table: files
|
||||
app_id: uuid NOT NULL
|
||||
@@ -590,6 +591,7 @@ constraints on email_trigger_details:
|
||||
[PRIMARY KEY] email_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on execution_logs:
|
||||
[CHECK] execution_logs_source_check: CHECK ((source = ANY (ARRAY['http'::text, 'kv'::text, 'docs'::text, 'dead_letter'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'invoke'::text, 'queue'::text])))
|
||||
[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 SET NULL
|
||||
@@ -711,3 +713,5 @@ constraints on triggers:
|
||||
0040: execution logs keep history
|
||||
0041: dead letters composite idx
|
||||
0042: secrets envelope version
|
||||
0043: execution logs source
|
||||
0044: delete reserved path routes
|
||||
|
||||
@@ -15,11 +15,13 @@ use axum::{
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_executor_core::{
|
||||
build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType,
|
||||
};
|
||||
use picloud_shared::{
|
||||
AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus,
|
||||
HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox, OutboxWriter, Principal,
|
||||
RequestId, ScriptId,
|
||||
AppId, DispatchMode, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionSource,
|
||||
ExecutionStatus, HttpDispatchPayload, InboxFailureKind, InboxResult, NewHttpOutbox,
|
||||
OutboxWriter, Principal, RequestId, ScriptId,
|
||||
};
|
||||
use serde_json::Value as Json_;
|
||||
use uuid::Uuid;
|
||||
@@ -119,6 +121,11 @@ where
|
||||
.resolve(id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound(id))?;
|
||||
// A disabled script (§4.3) is not invocable — 404, indistinguishable
|
||||
// from an absent one (no info leak that the id exists but is off).
|
||||
if !script.enabled {
|
||||
return Err(ApiError::NotFound(id));
|
||||
}
|
||||
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
|
||||
req.sandbox_overrides = script.sandbox;
|
||||
@@ -150,6 +157,7 @@ where
|
||||
request_path,
|
||||
request_headers,
|
||||
request_body,
|
||||
ExecutionSource::Http,
|
||||
&outcome,
|
||||
started,
|
||||
finished,
|
||||
@@ -161,6 +169,7 @@ where
|
||||
Ok(exec_response_to_http(outcome?))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn user_route_handler<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
Extension(principal): Extension<Option<Principal>>,
|
||||
@@ -218,6 +227,19 @@ where
|
||||
.resolve(matched.matched.script_id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound(matched.matched.script_id))?;
|
||||
// An enabled route bound to a disabled script (§4.3, §4.7) is unreachable.
|
||||
// Return the SAME flat "no route matches" 404 as the unmatched case — not
|
||||
// `NotFound(script_id)`, which would both leak the internal script id to an
|
||||
// anonymous caller and be distinguishable from absent.
|
||||
if !script.enabled {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("no route matches {method} {path}")
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Drain the body now that we know we'll execute. 10 MiB cap matches
|
||||
// the conservative default response/request size in the blueprint.
|
||||
@@ -530,6 +552,7 @@ fn build_inbox_execution_log(
|
||||
script_logs: Json_::Array(vec![]),
|
||||
duration_ms,
|
||||
status,
|
||||
source: ExecutionSource::Http,
|
||||
created_at: started,
|
||||
}
|
||||
}
|
||||
@@ -630,67 +653,9 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
||||
(status, http_headers, Json(resp.body)).into_response()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_execution_log(
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
request_id: RequestId,
|
||||
request_path: String,
|
||||
request_headers: BTreeMap<String, String>,
|
||||
request_body: Json_,
|
||||
outcome: &Result<ExecResponse, ExecError>,
|
||||
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, response_code, response_body, script_logs) = match outcome {
|
||||
Ok(resp) => {
|
||||
let logs = serde_json::to_value(&resp.logs).unwrap_or(Json_::Array(vec![]));
|
||||
(
|
||||
ExecutionStatus::Success,
|
||||
Some(resp.status_code),
|
||||
Some(resp.body.clone()),
|
||||
logs,
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
let status = match e {
|
||||
ExecError::Timeout(_) => ExecutionStatus::Timeout,
|
||||
ExecError::OperationBudgetExceeded => ExecutionStatus::BudgetExceeded,
|
||||
_ => ExecutionStatus::Error,
|
||||
};
|
||||
(
|
||||
status,
|
||||
None,
|
||||
Some(serde_json::json!({ "error": e.to_string() })),
|
||||
Json_::Array(vec![]),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
ExecutionLog {
|
||||
id: Uuid::new_v4(),
|
||||
app_id,
|
||||
script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
request_headers,
|
||||
request_body,
|
||||
response_code,
|
||||
response_body,
|
||||
script_logs,
|
||||
duration_ms,
|
||||
status,
|
||||
created_at: started,
|
||||
}
|
||||
}
|
||||
// `build_execution_log` moved to `picloud_executor_core` so the manager's
|
||||
// trigger dispatcher can build identically-shaped rows (G1 — trigger
|
||||
// executions are now logged with their `ExecutionSource`).
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
|
||||
@@ -108,8 +108,15 @@ pub fn parse_path(kind: PathKind, raw: &str) -> Result<PathPattern, ParseError>
|
||||
}
|
||||
|
||||
fn check_reserved(raw: &str) -> Result<(), ParseError> {
|
||||
// Case-fold before comparing: request-time path matching is case-sensitive,
|
||||
// but the reserved namespace must be rejected regardless of case so a tenant
|
||||
// can't publish look-alikes like `/Admin/login` or `/API/v2/x`. Method/host
|
||||
// matching are already case-insensitive; this keeps the validation guard
|
||||
// durable even if path matching ever follows.
|
||||
let lowered = raw.to_ascii_lowercase();
|
||||
for r in RESERVED_PATH_PREFIXES {
|
||||
if raw == r.trim_end_matches('/') || raw.starts_with(r) {
|
||||
if lowered == r.trim_end_matches('/') || lowered.starts_with(r) {
|
||||
// Preserve the caller's original case in the error for clarity.
|
||||
return Err(ParseError::ReservedPath(raw.to_string()));
|
||||
}
|
||||
}
|
||||
@@ -456,6 +463,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reserved_paths_case_insensitively() {
|
||||
// Case variants of every reserved prefix must be rejected: the reserved
|
||||
// namespace is a security boundary and must not be bypassable by casing.
|
||||
for raw in [
|
||||
"/API/v2/foo",
|
||||
"/Api/v2/foo",
|
||||
"/aPi/x",
|
||||
"/Admin/dashboard",
|
||||
"/ADMIN/x",
|
||||
"/HEALTHZ",
|
||||
"/HealthZ",
|
||||
"/Version",
|
||||
"/VERSION",
|
||||
] {
|
||||
let e = parse_path(PathKind::Exact, raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(e, ParseError::ReservedPath(_)),
|
||||
"expected reserved for {raw:?}, got {e:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_leading_slash() {
|
||||
let e = parse_path(PathKind::Exact, "greet").unwrap_err();
|
||||
|
||||
@@ -12,7 +12,8 @@ use chrono::{DateTime, Utc};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
|
||||
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId,
|
||||
Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -149,6 +150,144 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// --- Groups (Phase 2) -------------------------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
|
||||
/// client-side from `parent_id`).
|
||||
pub async fn groups_list(&self) -> Result<Vec<Group>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/groups")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
|
||||
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups`
|
||||
pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result<Group> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/groups")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only
|
||||
/// (the slug is frozen).
|
||||
pub async fn groups_rename(
|
||||
&self,
|
||||
ident: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
) -> Result<Group> {
|
||||
let ident = seg(ident);
|
||||
let body = serde_json::json!({ "name": name, "description": description });
|
||||
let resp = self
|
||||
.request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a
|
||||
/// slug/id, or `None` to move to root.
|
||||
pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result<Group> {
|
||||
let ident = seg(ident);
|
||||
let body = serde_json::json!({ "parent": parent });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/groups/{ident}/reparent"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty.
|
||||
pub async fn groups_delete(&self, ident: &str) -> Result<()> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_list(&self, group: &str) -> Result<Vec<AppMemberDto>> {
|
||||
let group = seg(group);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{group}/members"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_grant(
|
||||
&self,
|
||||
group: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let group = seg(group);
|
||||
let body = serde_json::json!({ "user_id": user_id, "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/groups/{group}/members"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_set_role(
|
||||
&self,
|
||||
group: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let (group, user_id) = (seg(group), seg(user_id));
|
||||
let body = serde_json::json!({ "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> {
|
||||
let (group, user_id) = (seg(group), seg(user_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
|
||||
)
|
||||
.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<()> {
|
||||
@@ -171,10 +310,23 @@ impl Client {
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
/// uses PUT despite the field-level update semantics. `cfg` carries
|
||||
/// optional per-script runtime overrides (G3); unset fields are
|
||||
/// omitted so they keep their stored value.
|
||||
pub async fn scripts_update_source(
|
||||
&self,
|
||||
id: &str,
|
||||
source: &str,
|
||||
cfg: &ScriptConfig,
|
||||
) -> Result<Script> {
|
||||
let id = seg(id);
|
||||
let body = UpdateScriptBody { source };
|
||||
let body = UpdateScriptBody {
|
||||
source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
let resp = self
|
||||
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
|
||||
.json(&body)
|
||||
@@ -222,15 +374,19 @@ impl Client {
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
|
||||
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
|
||||
pub async fn logs_list(
|
||||
&self,
|
||||
script_id: &str,
|
||||
limit: u32,
|
||||
source: Option<&str>,
|
||||
) -> Result<Vec<ExecutionLog>> {
|
||||
let script_id = seg(script_id);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let mut path = format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}");
|
||||
if let Some(src) = source {
|
||||
path.push_str("&source=");
|
||||
path.push_str(&seg(src));
|
||||
}
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
@@ -501,39 +657,138 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
|
||||
let app = seg(app);
|
||||
/// `GET /api/v1/admin/{apps,groups}/{id}/secrets` — secret names +
|
||||
/// last-modified for the owner. Values never travel on this path. `env`
|
||||
/// is only meaningful for group owners (app secrets are env-agnostic).
|
||||
pub async fn secrets_list(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
env: Option<&str>,
|
||||
) -> Result<SecretListDto> {
|
||||
let mut path = format!("{}/secrets", owner.base_path());
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/{apps,groups}/{id}/secrets`. `env` rides in the
|
||||
/// body and is only honored by group owners.
|
||||
pub async fn secrets_set(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
name: &str,
|
||||
value: serde_json::Value,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// `name` travels in the JSON body, not the path.
|
||||
let mut body = serde_json::json!({ "name": name, "value": value });
|
||||
if let Some(env) = env {
|
||||
body["env"] = serde_json::Value::String(env.to_string());
|
||||
}
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.request(Method::POST, &format!("{}/secrets", owner.base_path()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/{apps,groups}/{id}/secrets/{name}`
|
||||
pub async fn secrets_delete(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut path = format!("{}/secrets/{}", owner.base_path(), seg(name));
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id}/secrets/{name}/value` — the ONLY path
|
||||
/// that returns a decrypted secret value. Gated server-side at the owning
|
||||
/// group; there is no app-secret equivalent by design.
|
||||
pub async fn group_secret_read_value(
|
||||
&self,
|
||||
group: &str,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<SecretValueDto> {
|
||||
let (group, name) = (seg(group), seg(name));
|
||||
let mut path = format!("/api/v1/admin/groups/{group}/secrets/{name}/value");
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- vars (Phase 3 config) ----------
|
||||
|
||||
/// `GET /api/v1/admin/{apps,groups}/{id}/vars` — the owner's OWN vars
|
||||
/// (not the resolved/inherited view).
|
||||
pub async fn vars_list(&self, owner: VarOwnerArg<'_>) -> Result<VarListDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("{}/vars", owner.base_path()))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> {
|
||||
// `name` travels in the JSON body, not the path — only `app` needs encoding.
|
||||
let app = seg(app);
|
||||
/// `PUT /api/v1/admin/{apps,groups}/{id}/vars`
|
||||
pub async fn vars_set(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
env: Option<&str>,
|
||||
tombstone: bool,
|
||||
) -> Result<()> {
|
||||
let mut body = serde_json::json!({ "key": key, "value": value, "tombstone": tombstone });
|
||||
if let Some(env) = env {
|
||||
body["env"] = serde_json::Value::String(env.to_string());
|
||||
}
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.json(&serde_json::json!({ "name": name, "value": value }))
|
||||
.request(Method::PUT, &format!("{}/vars", owner.base_path()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
|
||||
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
|
||||
let (app, name) = (seg(app), seg(name));
|
||||
/// `DELETE /api/v1/admin/{apps,groups}/{id}/vars/{key}`
|
||||
pub async fn vars_delete(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut path = format!("{}/vars/{}", owner.base_path(), seg(key));
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/config/effective` — the app's resolved
|
||||
/// (group-inherited) vars plus masked secret statuses, each annotated with
|
||||
/// the owner that won and the layers it merged from (§4.6).
|
||||
pub async fn config_effective(&self, app: &str) -> Result<EffectiveConfigDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/secrets/{name}"),
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/config/effective"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- domains ----------
|
||||
@@ -709,6 +964,228 @@ impl Client {
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// --- App membership (G2) ----------------------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/members`
|
||||
pub async fn members_list(&self, app: &str) -> Result<Vec<AppMemberDto>> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/members"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/members`
|
||||
pub async fn members_grant(
|
||||
&self,
|
||||
app: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({ "user_id": user_id, "role": role });
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/members"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
|
||||
pub async fn members_set_role(
|
||||
&self,
|
||||
app: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let (app, user_id) = (seg(app), seg(user_id));
|
||||
let body = serde_json::json!({ "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
|
||||
pub async fn members_remove(&self, app: &str, user_id: &str) -> Result<()> {
|
||||
let (app, user_id) = (seg(app), seg(user_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// --- Files (G2, read-only admin surface) ------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/files?collection=&limit=`
|
||||
pub async fn files_list(
|
||||
&self,
|
||||
app: &str,
|
||||
collection: &str,
|
||||
limit: u32,
|
||||
) -> Result<ListFilesResponse> {
|
||||
let app = seg(app);
|
||||
let collection = seg(collection);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/files?collection={collection}&limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}` —
|
||||
/// streams the raw bytes (download). Returns the body verbatim.
|
||||
pub async fn files_get_bytes(
|
||||
&self,
|
||||
app: &str,
|
||||
collection: &str,
|
||||
file_id: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status().is_success() {
|
||||
Ok(resp.bytes().await.context("reading file bytes")?.to_vec())
|
||||
} else {
|
||||
Err(server_error(resp).await)
|
||||
}
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}`
|
||||
pub async fn files_delete(&self, app: &str, collection: &str, file_id: &str) -> Result<()> {
|
||||
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// --- KV (G2, read-only admin surface) ---------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/kv?collection=&limit=`
|
||||
pub async fn kv_list(&self, app: &str, collection: &str, limit: u32) -> Result<KvListPageDto> {
|
||||
let app = seg(app);
|
||||
let collection = seg(collection);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/kv?collection={collection}&limit={limit}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/kv/{collection}/{key}`
|
||||
pub async fn kv_get(&self, app: &str, collection: &str, key: &str) -> Result<Value> {
|
||||
let (app, collection, key) = (seg(app), seg(collection), seg(key));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/kv/{collection}/{key}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
let wrapped: KvGetResponse = decode(resp).await?;
|
||||
Ok(wrapped.value)
|
||||
}
|
||||
|
||||
// --- Queues (G2, read-only admin surface) -----------------------------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/queues`
|
||||
pub async fn queues_list(&self, app: &str) -> Result<Vec<QueueSummaryDto>> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/queues"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/queues/{queue_name}`
|
||||
pub async fn queue_get(&self, app: &str, queue_name: &str) -> Result<QueueDetailDto> {
|
||||
let (app, queue_name) = (seg(app), seg(queue_name));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/queues/{queue_name}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
|
||||
/// bundle against the app's live state. Read-only.
|
||||
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
|
||||
.json(bundle)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
|
||||
/// app to the bundle in one transaction.
|
||||
pub async fn apply(
|
||||
&self,
|
||||
app: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
// The apply endpoint returns 409 only for a stale bound plan
|
||||
// (`StateMoved`): the app changed since `pic plan` recorded its
|
||||
// token. Surface an actionable next step instead of a bare
|
||||
// `HTTP 409`.
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe app changed since your last `pic plan`. Re-run \
|
||||
`pic plan` to review the new diff, then `pic apply` — or \
|
||||
`pic apply --force` to apply without re-reviewing."
|
||||
));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -733,6 +1210,54 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlanDto {
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangeDto {
|
||||
pub op: String,
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub scripts_created: u32,
|
||||
#[serde(default)]
|
||||
pub scripts_updated: u32,
|
||||
#[serde(default)]
|
||||
pub scripts_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub routes_created: u32,
|
||||
#[serde(default)]
|
||||
pub routes_updated: u32,
|
||||
#[serde(default)]
|
||||
pub routes_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub triggers_created: u32,
|
||||
#[serde(default)]
|
||||
pub triggers_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthMeDto {
|
||||
@@ -762,6 +1287,31 @@ pub struct CreateAppBody<'a> {
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
/// Parent group (slug or id); omit for the instance root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateGroupBody<'a> {
|
||||
pub slug: &'a str,
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
/// Parent group (slug or id); omit for a root-level group.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// `GET /groups/{id}` response — the group plus its breadcrumb path and
|
||||
/// direct children.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GroupDetailDto {
|
||||
#[serde(flatten)]
|
||||
pub group: Group,
|
||||
pub path: Vec<Group>,
|
||||
pub subgroups: Vec<Group>,
|
||||
pub apps: Vec<App>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -943,6 +1493,37 @@ pub struct DeadLetterDto {
|
||||
pub resolution: Option<String>,
|
||||
}
|
||||
|
||||
/// Which owner a `pic vars` call targets. Selects the `apps` vs `groups`
|
||||
/// admin path prefix; the identifier travels in the path (encoded).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VarOwnerArg<'a> {
|
||||
App(&'a str),
|
||||
Group(&'a str),
|
||||
}
|
||||
|
||||
impl VarOwnerArg<'_> {
|
||||
fn base_path(&self) -> String {
|
||||
match self {
|
||||
Self::App(ident) => format!("/api/v1/admin/apps/{}", seg(ident)),
|
||||
Self::Group(ident) => format!("/api/v1/admin/groups/{}", seg(ident)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VarListDto {
|
||||
pub vars: Vec<VarItemDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VarItemDto {
|
||||
pub key: String,
|
||||
pub env: String,
|
||||
pub value: serde_json::Value,
|
||||
pub is_tombstone: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretListDto {
|
||||
pub secrets: Vec<SecretItemDto>,
|
||||
@@ -954,9 +1535,83 @@ pub struct SecretListDto {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretItemDto {
|
||||
pub name: String,
|
||||
/// Env scope. Only meaningful (and populated) for group owners; the server
|
||||
/// omits it for app secrets, so default to `*` when absent.
|
||||
#[serde(default = "default_env")]
|
||||
pub env: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn default_env() -> String {
|
||||
"*".to_string()
|
||||
}
|
||||
|
||||
/// Plaintext value of a single group secret — the response of the gated
|
||||
/// `.../secrets/{name}/value` read. The decrypted value is arbitrary JSON.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretValueDto {
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub env: String,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
// --- effective config (`/config/effective`) ---
|
||||
|
||||
/// The owner (app or group) a resolved layer belongs to, with its distance
|
||||
/// from the app in the inheritance chain (`depth` 0 = the app itself).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveOwnerDto {
|
||||
pub kind: String,
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub depth: u32,
|
||||
}
|
||||
|
||||
/// One layer a resolved var merged from, deepest-first as the server returns it.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MergedFromDto {
|
||||
pub depth: u32,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveVarDto {
|
||||
pub value: serde_json::Value,
|
||||
pub owner: EffectiveOwnerDto,
|
||||
pub scope: String,
|
||||
#[serde(default)]
|
||||
pub merged_from: Vec<MergedFromDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveSecretDto {
|
||||
#[allow(dead_code)]
|
||||
pub status: String,
|
||||
pub owner: EffectiveOwnerDto,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveConfigDto {
|
||||
#[serde(default)]
|
||||
pub vars: std::collections::BTreeMap<String, EffectiveVarDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: std::collections::BTreeMap<String, EffectiveSecretDto>,
|
||||
}
|
||||
|
||||
/// Per-script runtime config the CLI can now set (G3). All optional — an
|
||||
/// unset field is omitted so the server applies its own default (and the
|
||||
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ScriptConfig {
|
||||
pub timeout_seconds: Option<i32>,
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
pub kind: Option<ScriptKind>,
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateScriptBody<'a> {
|
||||
pub app_id: AppId,
|
||||
@@ -964,11 +1619,104 @@ pub struct CreateScriptBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<ScriptKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateScriptBody<'a> {
|
||||
source: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
timeout_seconds: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
memory_limit_mb: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<ScriptKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
// --- G2 response DTOs (deserialize-only mirrors of the server shapes) ---
|
||||
|
||||
// `#[allow(dead_code)]` on a couple of fields below: these structs mirror
|
||||
// the full server response shape (so the surface is documented and future
|
||||
// columns are a one-line add), but the TSV/KvBlock renderers don't print
|
||||
// every field today.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AppMemberDto {
|
||||
pub user_id: AdminUserId,
|
||||
pub username: String,
|
||||
#[allow(dead_code)]
|
||||
pub email: Option<String>,
|
||||
pub instance_role: InstanceRole,
|
||||
pub is_active: bool,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct FileMetaDto {
|
||||
pub id: String,
|
||||
#[allow(dead_code)]
|
||||
pub collection: String,
|
||||
pub name: String,
|
||||
pub content_type: String,
|
||||
pub size: u64,
|
||||
#[allow(dead_code)]
|
||||
pub checksum: String,
|
||||
#[allow(dead_code)]
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListFilesResponse {
|
||||
pub files: Vec<FileMetaDto>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct KvListPageDto {
|
||||
pub keys: Vec<String>,
|
||||
pub next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct KvGetResponse {
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QueueSummaryDto {
|
||||
pub queue_name: String,
|
||||
pub total: u64,
|
||||
pub pending: u64,
|
||||
pub claimed: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QueueConsumerDto {
|
||||
pub trigger_id: String,
|
||||
pub script_id: ScriptId,
|
||||
pub script_name: String,
|
||||
pub visibility_timeout_secs: u32,
|
||||
pub last_fired_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct QueueDetailDto {
|
||||
pub queue_name: String,
|
||||
pub total: u64,
|
||||
pub pending: u64,
|
||||
pub claimed: u64,
|
||||
pub consumer: Option<QueueConsumerDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
111
crates/picloud-cli/src/cmds/apply.rs
Normal file
111
crates/picloud-cli/src/cmds/apply.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
|
||||
//! manifest's desired state in one server-side transaction. Creates and
|
||||
//! updates are applied; `--prune` additionally deletes live scripts/routes/
|
||||
//! triggers absent from the manifest (secrets are never pruned).
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
env: Option<&str>,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&manifest.app.slug)?;
|
||||
}
|
||||
|
||||
// Bound-plan check: replay the token from the last `pic plan` (for this
|
||||
// app) so the server refuses if the app changed since it was reviewed.
|
||||
// `--force` skips it; no recorded plan means no check (apply still works
|
||||
// standalone). The token is single-use — cleared after a successful apply.
|
||||
let expected_token = if force {
|
||||
None
|
||||
} else {
|
||||
crate::linkstate::read_plan(base_dir)
|
||||
.filter(|l| l.app == manifest.app.slug)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply(
|
||||
&manifest.app.slug,
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.scripts_created, report.scripts_updated, report.scripts_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"routes",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.routes_created, report.routes_updated, report.routes_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"triggers",
|
||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
|
||||
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
||||
/// rather than silently deleting (review the deletions first with `pic plan`).
|
||||
fn confirm_prune(slug: &str) -> Result<()> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
anyhow::bail!(
|
||||
"refusing to `apply --prune` non-interactively without `--yes`: prune \
|
||||
deletes resources absent from the manifest and cannot be undone. \
|
||||
Review with `pic plan`, then re-run with `--yes`."
|
||||
);
|
||||
}
|
||||
eprint!(
|
||||
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
|
||||
absent from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("read confirmation")?;
|
||||
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
|
||||
anyhow::bail!("aborted");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -31,6 +31,7 @@ pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
group: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -39,6 +40,7 @@ pub async fn create(
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
group,
|
||||
};
|
||||
let app = client.apps_create(&body).await?;
|
||||
// Emit the created object so `--output json` callers can capture the
|
||||
|
||||
100
crates/picloud-cli/src/cmds/config.rs
Normal file
100
crates/picloud-cli/src/cmds/config.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
//! `pic config --effective` — read-only view of an app's resolved
|
||||
//! configuration, with secret values masked (§4.6).
|
||||
//!
|
||||
//! Two sections:
|
||||
//! * `vars` — the resolved (group-inherited) config vars, each `key = value`
|
||||
//! annotated with the owner that won (kind + depth) and its scope. Pass
|
||||
//! `--explain` to also dump each var's `merged_from` provenance — the
|
||||
//! ordered (depth, scope) layers that fed the resolution.
|
||||
//! * `secrets` — masked statuses, cross-referenced against the manifest so an
|
||||
//! operator can see which declared secrets are still unset and which live
|
||||
//! secrets aren't declared. Values are never fetched or shown here; the
|
||||
//! server reports only `<set>` / `<unset>` plus the owning layer.
|
||||
//!
|
||||
//! The vars + masked-secret owner info comes from the `/config/effective`
|
||||
//! endpoint; the manifest is only used to flag `declared`/`unset` drift.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::{Client, EffectiveOwnerDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Render an owner as `kind@depth` (e.g. `group@1`, `app@0`).
|
||||
fn owner_label(owner: &EffectiveOwnerDto) -> String {
|
||||
format!("{}@{}", owner.kind, owner.depth)
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
effective: bool,
|
||||
explain: bool,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
if !effective {
|
||||
bail!("`pic config` currently supports only `--effective`");
|
||||
}
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Resolve against the same env overlay as `pic plan`/`apply` so the
|
||||
// masked report targets the app those commands would act on — not the
|
||||
// base slug — when an overlay re-points slug/secrets.
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
|
||||
let eff = client.config_effective(&manifest.app.slug).await?;
|
||||
|
||||
// --- vars: the resolved view, with winning owner + provenance. ---
|
||||
let mut vars_table = Table::new(["key", "value", "owner", "scope"]);
|
||||
for (key, var) in &eff.vars {
|
||||
vars_table.row([
|
||||
key.clone(),
|
||||
var.value.to_string(),
|
||||
owner_label(&var.owner),
|
||||
var.scope.clone(),
|
||||
]);
|
||||
}
|
||||
vars_table.print(mode);
|
||||
|
||||
if explain {
|
||||
// Provenance: the (depth, scope) layers each resolved key merged from.
|
||||
let mut prov = Table::new(["key", "depth", "scope"]);
|
||||
for (key, var) in &eff.vars {
|
||||
for layer in &var.merged_from {
|
||||
prov.row([key.clone(), layer.depth.to_string(), layer.scope.clone()]);
|
||||
}
|
||||
}
|
||||
prov.print(mode);
|
||||
}
|
||||
|
||||
// --- secrets: masked status, folding manifest drift + owning layer. ---
|
||||
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
|
||||
let on_server: BTreeSet<String> = eff.secrets.keys().cloned().collect();
|
||||
|
||||
let mut table = Table::new(["secret", "value", "status", "owner", "scope"]);
|
||||
for name in declared.union(&on_server) {
|
||||
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
|
||||
(true, true) => ("<set>", "managed"),
|
||||
(true, false) => ("<unset>", "declared, not pushed — `pic secrets set`"),
|
||||
(false, true) => ("<set>", "on server, not in manifest"),
|
||||
(false, false) => unreachable!("name came from one of the two sets"),
|
||||
};
|
||||
let (owner, scope) = match eff.secrets.get(name) {
|
||||
Some(s) => (owner_label(&s.owner), s.scope.clone()),
|
||||
None => ("-".to_string(), "-".to_string()),
|
||||
};
|
||||
table.row([
|
||||
name.clone(),
|
||||
value.to_string(),
|
||||
status.to_string(),
|
||||
owner,
|
||||
scope,
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
71
crates/picloud-cli/src/cmds/files.rs
Normal file
71
crates/picloud-cli/src/cmds/files.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
//! `pic files ls | get | rm` — operator-facing files inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/files*` surface. There is
|
||||
//! no `set`/`upload` — blob writes go through scripts (`files::create`);
|
||||
//! the admin surface is inspect + delete only, matching the dashboard.
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let page = client.files_list(app, collection, limit).await?;
|
||||
let mut table = Table::new(["id", "name", "content_type", "size", "updated_at"]);
|
||||
for f in page.files {
|
||||
table.row([
|
||||
f.id,
|
||||
f.name,
|
||||
f.content_type,
|
||||
f.size.to_string(),
|
||||
f.updated_at,
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
if page.next_cursor.is_some() {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"(more results available — raise --limit to see them)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a file's bytes. With `--out <path>` writes to disk; otherwise
|
||||
/// streams raw bytes to stdout (pipe to a file or `xxd`).
|
||||
pub async fn get(app: &str, collection: &str, file_id: &str, out: Option<&Path>) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let bytes = client.files_get_bytes(app, collection, file_id).await?;
|
||||
match out {
|
||||
Some(path) => {
|
||||
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"Wrote {} bytes to {}",
|
||||
bytes.len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
None => {
|
||||
std::io::stdout()
|
||||
.write_all(&bytes)
|
||||
.context("writing bytes to stdout")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, collection: &str, file_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.files_delete(app, collection, file_id).await?;
|
||||
println!("Deleted file {file_id} from {collection}");
|
||||
Ok(())
|
||||
}
|
||||
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! `pic groups` — manage the org-tree groups (Phase 2).
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/groups*`. Structural mutations are gated
|
||||
//! server-side: create/reparent/delete need group-admin (reparent at both
|
||||
//! source and destination parent); the slug is frozen at creation.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::{AppRole, Group};
|
||||
|
||||
use crate::client::{Client, CreateGroupBody};
|
||||
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 groups = client.groups_list().await?;
|
||||
let mut table = Table::new(["slug", "name", "parent", "created_at"]);
|
||||
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect();
|
||||
for g in &groups {
|
||||
let parent = g
|
||||
.parent_id
|
||||
.and_then(|p| by_id.get(&p).cloned())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
table.row([
|
||||
g.slug.clone(),
|
||||
g.name.clone(),
|
||||
parent,
|
||||
g.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic groups tree` — render the hierarchy as an indented tree (text
|
||||
/// mode); falls back to the flat list for `--output json`.
|
||||
pub async fn tree(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let groups = client.groups_list().await?;
|
||||
if matches!(mode, OutputMode::Json) {
|
||||
// Machine consumers get the flat list; the shape carries parent_id.
|
||||
println!("{}", serde_json::to_string_pretty(&groups)?);
|
||||
return Ok(());
|
||||
}
|
||||
// children-by-parent, then DFS from the roots.
|
||||
let mut children: BTreeMap<Option<_>, Vec<&Group>> = BTreeMap::new();
|
||||
for g in &groups {
|
||||
children.entry(g.parent_id).or_default().push(g);
|
||||
}
|
||||
for kids in children.values_mut() {
|
||||
kids.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
print_subtree(&children, None, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_subtree(
|
||||
children: &BTreeMap<Option<picloud_shared::GroupId>, Vec<&Group>>,
|
||||
parent: Option<picloud_shared::GroupId>,
|
||||
depth: usize,
|
||||
) {
|
||||
let Some(kids) = children.get(&parent) else {
|
||||
return;
|
||||
};
|
||||
for g in kids {
|
||||
println!("{}{} ({})", " ".repeat(depth), g.name, g.slug);
|
||||
print_subtree(children, Some(g.id), depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
parent: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let body = CreateGroupBody {
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
parent,
|
||||
};
|
||||
let group = client.groups_create(&body).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let detail = client.groups_get(ident).await?;
|
||||
let path = detail
|
||||
.path
|
||||
.iter()
|
||||
.map(|g| g.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", detail.group.id.to_string())
|
||||
.field("slug", detail.group.slug.clone())
|
||||
.field("name", detail.group.name.clone())
|
||||
.field("path", if path.is_empty() { "-".into() } else { path })
|
||||
.field(
|
||||
"subgroups",
|
||||
detail
|
||||
.subgroups
|
||||
.iter()
|
||||
.map(|g| g.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
.field(
|
||||
"apps",
|
||||
detail
|
||||
.apps
|
||||
.iter()
|
||||
.map(|a| a.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
ident: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let group = client.groups_rename(ident, name, description).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let group = client.groups_reparent(ident, to).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic groups rm <slug>`. The server enforces delete=RESTRICT (409 on a
|
||||
/// non-empty group); `--recursive` expands the delete into ordered,
|
||||
/// leaf-first child deletions (groups + apps) the operator opted into.
|
||||
pub async fn rm(ident: &str, recursive: bool) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
if !recursive {
|
||||
client.groups_delete(ident).await?;
|
||||
println!("Deleted group {ident}");
|
||||
return Ok(());
|
||||
}
|
||||
// Recursive: delete the subtree leaf-first so each DB delete sees an
|
||||
// empty node (the FK stays RESTRICT — we never cascade implicitly).
|
||||
let detail = client.groups_get(ident).await?;
|
||||
if let Some(app) = detail.apps.first() {
|
||||
anyhow::bail!(
|
||||
"group {ident} contains app {:?}; move or delete apps before a recursive group delete \
|
||||
(apps are never auto-deleted)",
|
||||
app.slug
|
||||
);
|
||||
}
|
||||
for sub in &detail.subgroups {
|
||||
Box::pin(rm(&sub.slug, true)).await?;
|
||||
}
|
||||
client.groups_delete(ident).await?;
|
||||
println!("Deleted group {ident}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
|
||||
pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let members = client.group_members_list(group).await?;
|
||||
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
|
||||
for m in members {
|
||||
table.row([
|
||||
m.user_id.to_string(),
|
||||
m.username,
|
||||
m.role.as_str().to_string(),
|
||||
format!("{:?}", m.instance_role).to_lowercase(),
|
||||
m.is_active.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.group_members_grant(group, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.group_members_set_role(group, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_rm(group: &str, user_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.group_members_remove(group, user_id).await?;
|
||||
println!("Removed {user_id} from {group}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_group(g: &Group, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", g.id.to_string())
|
||||
.field("slug", g.slug.clone())
|
||||
.field("name", g.name.clone())
|
||||
.field(
|
||||
"parent_id",
|
||||
g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()),
|
||||
)
|
||||
.field("created_at", g.created_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("user_id", m.user_id.to_string())
|
||||
.field("username", m.username.clone())
|
||||
.field("role", m.role.as_str().to_string());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn parse_role(role: &str) -> Result<AppRole> {
|
||||
AppRole::from_db_str(role)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer"))
|
||||
}
|
||||
278
crates/picloud-cli/src/cmds/init.rs
Normal file
278
crates/picloud-cli/src/cmds/init.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
//! `pic init [slug] [--dir .]` — scaffold a new declarative project: a
|
||||
//! `picloud.toml` describing a minimal working app (one `hello` endpoint +
|
||||
//! route), its `scripts/hello.rhai` source, and a `.gitignore` that ignores
|
||||
//! the project tool's `.picloud/` link-state directory.
|
||||
//!
|
||||
//! Offline by design — it never contacts the server. Run `pic plan` to
|
||||
//! preview the create, then `pic apply` to deploy. Refuses to overwrite an
|
||||
//! existing `picloud.toml` unless `--force`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use picloud_shared::{DispatchMode, HostKind, PathKind, ScriptKind};
|
||||
|
||||
use crate::manifest::{Manifest, ManifestApp, ManifestRoute, ManifestScript, MANIFEST_FILE};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
const HEADER: &str = "\
|
||||
# picloud project manifest — the declarative desired state for one app.
|
||||
# Edit, then `pic plan` to preview changes and `pic apply` to reconcile.
|
||||
# Scripts live under scripts/ and are referenced by `file`.
|
||||
\n";
|
||||
|
||||
const EXAMPLES: &str = "\
|
||||
\n# ---------------------------------------------------------------------------
|
||||
# More to add (uncomment and adapt):
|
||||
#
|
||||
# [[scripts]]
|
||||
# name = \"lib\"
|
||||
# file = \"scripts/lib.rhai\"
|
||||
# kind = \"module\" # default: endpoint
|
||||
#
|
||||
# [[routes]]
|
||||
# script = \"hello\"
|
||||
# method = \"POST\" # omit for ANY
|
||||
# host_kind = \"any\"
|
||||
# path_kind = \"param\" # exact | prefix | param
|
||||
# path = \"/hello/:name\"
|
||||
#
|
||||
# [[triggers.cron]]
|
||||
# script = \"hello\"
|
||||
# schedule = \"0 0 * * * *\" # 6-field cron (seconds first)
|
||||
# timezone = \"UTC\"
|
||||
#
|
||||
# [secrets] # names only — push values with `pic secret set`
|
||||
# names = [\"STRIPE_KEY\"]
|
||||
";
|
||||
|
||||
const HELLO_RHAI: &str = "\
|
||||
// A minimal endpoint script. Its return value is the HTTP response body.
|
||||
// `ctx` exposes the request; see the stdlib reference for the full SDK.
|
||||
\"Hello from PiCloud!\"
|
||||
";
|
||||
|
||||
const GITIGNORE_LINE: &str = ".picloud/";
|
||||
|
||||
pub fn run(
|
||||
dir: &Path,
|
||||
slug_arg: Option<&str>,
|
||||
name_arg: Option<&str>,
|
||||
force: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let slug = resolve_slug(dir, slug_arg)?;
|
||||
let name = name_arg.map_or_else(|| title_from_slug(&slug), str::to_string);
|
||||
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
if manifest_path.exists() && !force {
|
||||
bail!(
|
||||
"{} already exists; refusing to overwrite (use --force)",
|
||||
manifest_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let manifest = scaffold_manifest(&slug, &name);
|
||||
// Build the file as a commented header + the (valid, round-trippable)
|
||||
// active manifest + commented examples. Rendering the active part through
|
||||
// `to_toml` guarantees it parses and matches the wire model.
|
||||
let body = format!("{HEADER}{}{EXAMPLES}", manifest.to_toml()?);
|
||||
|
||||
fs::create_dir_all(dir.join("scripts")).context("creating scripts/ directory")?;
|
||||
let hello_path = dir.join("scripts/hello.rhai");
|
||||
let wrote_hello = !hello_path.exists();
|
||||
if wrote_hello {
|
||||
fs::write(&hello_path, HELLO_RHAI).context("writing scripts/hello.rhai")?;
|
||||
}
|
||||
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
|
||||
ensure_gitignored(dir)?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", slug)
|
||||
.field(
|
||||
"scripts",
|
||||
if wrote_hello {
|
||||
"scripts/hello.rhai"
|
||||
} else {
|
||||
"scripts/hello.rhai (kept existing)"
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.field("next", "pic plan then pic apply".to_string());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
|
||||
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
},
|
||||
scripts: vec![ManifestScript {
|
||||
name: "hello".into(),
|
||||
file: "scripts/hello.rhai".into(),
|
||||
kind: ScriptKind::Endpoint,
|
||||
description: None,
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
}],
|
||||
routes: vec![ManifestRoute {
|
||||
script: "hello".into(),
|
||||
method: Some("GET".into()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/hello".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the explicit slug if given, else derive one from the target
|
||||
/// directory's name. Either way it must satisfy the app-slug rule.
|
||||
fn resolve_slug(dir: &Path, slug_arg: Option<&str>) -> Result<String> {
|
||||
if let Some(s) = slug_arg {
|
||||
if !is_valid_slug(s) {
|
||||
bail!("invalid app slug `{s}`: use lowercase letters, digits, and dashes (start alphanumeric, max 63)");
|
||||
}
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
// Derive from the directory's own name. Use the path as given first —
|
||||
// `canonicalize` requires the dir to already exist, which breaks the
|
||||
// natural `pic init --dir new-project` flow. Fall back to canonicalizing
|
||||
// only when the path has no final component of its own (e.g. `.`).
|
||||
let base = dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.or_else(|| {
|
||||
dir.canonicalize()
|
||||
.ok()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let derived = slugify(&base);
|
||||
if !is_valid_slug(&derived) {
|
||||
bail!(
|
||||
"could not derive a valid app slug from directory `{base}`; \
|
||||
pass one explicitly, e.g. `pic init my-app`"
|
||||
);
|
||||
}
|
||||
Ok(derived)
|
||||
}
|
||||
|
||||
/// Lowercase, map runs of non-`[a-z0-9]` to a single `-`, trim dashes.
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
prev_dash = false;
|
||||
} else if !prev_dash {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// The canonical app-slug rule (mirrors the server): `^[a-z0-9][a-z0-9-]{0,62}$`.
|
||||
fn is_valid_slug(s: &str) -> bool {
|
||||
if s.is_empty() || s.len() > 63 {
|
||||
return false;
|
||||
}
|
||||
let mut chars = s.chars();
|
||||
let first = chars.next().expect("non-empty checked above");
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
/// Title-case a slug for a default display name: `my-blog` → `My Blog`.
|
||||
fn title_from_slug(slug: &str) -> String {
|
||||
slug.split('-')
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map_or_else(String::new, |f| {
|
||||
f.to_ascii_uppercase().to_string() + c.as_str()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Ensure `.gitignore` ignores `.picloud/` (the project tool's link state).
|
||||
/// Appends the line if missing; creates the file if absent. A repo without
|
||||
/// git still gets a correct `.gitignore` for when it's initialized.
|
||||
fn ensure_gitignored(dir: &Path) -> Result<()> {
|
||||
let path = dir.join(".gitignore");
|
||||
// Only a genuinely-absent file is treated as empty; an existing-but-
|
||||
// unreadable `.gitignore` must error rather than be silently clobbered.
|
||||
let existing = match fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e).context("reading .gitignore"),
|
||||
};
|
||||
if existing.lines().any(|l| l.trim() == GITIGNORE_LINE) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut next = existing;
|
||||
if !next.is_empty() && !next.ends_with('\n') {
|
||||
next.push('\n');
|
||||
}
|
||||
next.push_str(GITIGNORE_LINE);
|
||||
next.push('\n');
|
||||
fs::write(&path, next).context("updating .gitignore")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scaffold_is_valid_and_round_trips() {
|
||||
let m = scaffold_manifest("blog", "Blog");
|
||||
let body = format!("{HEADER}{}{EXAMPLES}", m.to_toml().unwrap());
|
||||
// The active manifest (header/examples are comments) must parse back
|
||||
// to exactly the scaffold — the commented examples are inert.
|
||||
let parsed = Manifest::parse(&body).expect("scaffold must be valid TOML");
|
||||
assert_eq!(parsed, m);
|
||||
// And it's a deployable project: one endpoint + its route.
|
||||
assert_eq!(parsed.scripts.len(), 1);
|
||||
assert_eq!(parsed.routes.len(), 1);
|
||||
assert_eq!(parsed.routes[0].script, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_and_validation() {
|
||||
assert_eq!(slugify("My Blog!"), "my-blog");
|
||||
assert_eq!(slugify(" weird__name "), "weird-name");
|
||||
assert_eq!(slugify("Project (2026)"), "project-2026");
|
||||
assert!(is_valid_slug("blog"));
|
||||
assert!(is_valid_slug("a1-b2"));
|
||||
assert!(!is_valid_slug(""));
|
||||
assert!(!is_valid_slug("-leading"));
|
||||
assert!(!is_valid_slug(&"a".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_from_slug_humanizes() {
|
||||
assert_eq!(title_from_slug("my-blog"), "My Blog");
|
||||
assert_eq!(title_from_slug("api"), "Api");
|
||||
}
|
||||
}
|
||||
42
crates/picloud-cli/src/cmds/kv.rs
Normal file
42
crates/picloud-cli/src/cmds/kv.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
//! `pic kv ls | get` — read-only KV inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/kv*` surface. There is no
|
||||
//! `set`/`rm` on purpose: KV writes go through `kv::set` in scripts (which
|
||||
//! emit the change events triggers depend on); an admin write would bypass
|
||||
//! that, so the CLI stays read-only (matching the queues precedent).
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let page = client.kv_list(app, collection, limit).await?;
|
||||
let mut table = Table::new(["key"]);
|
||||
for k in page.keys {
|
||||
table.row([k]);
|
||||
}
|
||||
table.print(mode);
|
||||
if page.next_cursor.is_some() {
|
||||
let _ = writeln!(
|
||||
std::io::stderr(),
|
||||
"(more keys available — raise --limit to see them)"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(app: &str, collection: &str, key: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let value = client.kv_get(app, collection, key).await?;
|
||||
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
|
||||
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
|
||||
println!("{pretty}");
|
||||
Ok(())
|
||||
}
|
||||
@@ -12,10 +12,15 @@ use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
pub async fn run(
|
||||
script_id: &str,
|
||||
limit: u32,
|
||||
source: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let entries = client.logs_list(script_id, limit).await?;
|
||||
let entries = client.logs_list(script_id, limit, source).await?;
|
||||
match mode {
|
||||
OutputMode::Tsv => render_tsv(&entries),
|
||||
OutputMode::Json => render_json(&entries),
|
||||
@@ -24,11 +29,14 @@ pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
}
|
||||
|
||||
fn render_tsv(entries: &[ExecutionLog]) {
|
||||
let mut table = Table::new(["created_at", "status", "summary"]);
|
||||
// `source` is shown so background runs (queue/cron/invoke/…) are
|
||||
// distinguishable from HTTP at a glance — the whole point of G1.
|
||||
let mut table = Table::new(["created_at", "source", "status", "summary"]);
|
||||
for e in entries {
|
||||
let summary = summarize(&e.response_body, &e.script_logs);
|
||||
table.row([
|
||||
e.created_at.to_rfc3339(),
|
||||
e.source.as_str().to_string(),
|
||||
status_label(&e.status).to_string(),
|
||||
truncate(&summary, 120),
|
||||
]);
|
||||
|
||||
86
crates/picloud-cli/src/cmds/members.rs
Normal file
86
crates/picloud-cli/src/cmds/members.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
//! `pic members ls | add | set | rm` — manage app membership (G2).
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/apps/{id}/members*`. All gated on
|
||||
//! `AppAdmin(app)` server-side; Editors/Viewers get a 403.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::AppRole;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let members = client.members_list(app).await?;
|
||||
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
|
||||
for m in members {
|
||||
table.row([
|
||||
m.user_id.to_string(),
|
||||
m.username,
|
||||
app_role_str(m.role).to_string(),
|
||||
format!("{:?}", m.instance_role).to_lowercase(),
|
||||
m.is_active.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn add(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.members_grant(app, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.members_set_role(app, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, user_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.members_remove(app, user_id).await?;
|
||||
println!("Removed {user_id} from {app}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("user_id", m.user_id.to_string())
|
||||
.field("username", m.username.clone())
|
||||
.field("role", app_role_str(m.role))
|
||||
.field("created_at", m.created_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn parse_role(s: &str) -> Result<AppRole> {
|
||||
match s.to_ascii_lowercase().as_str() {
|
||||
"app_admin" | "admin" => Ok(AppRole::AppAdmin),
|
||||
"editor" => Ok(AppRole::Editor),
|
||||
"viewer" => Ok(AppRole::Viewer),
|
||||
other => Err(anyhow::anyhow!(
|
||||
"unknown role {other:?} (want app_admin | editor | viewer)"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn app_role_str(r: AppRole) -> &'static str {
|
||||
match r {
|
||||
AppRole::AppAdmin => "app_admin",
|
||||
AppRole::Editor => "editor",
|
||||
AppRole::Viewer => "viewer",
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
pub mod admins;
|
||||
pub mod api_keys;
|
||||
pub mod apply;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod config;
|
||||
pub mod dead_letters;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
pub mod kv;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod members;
|
||||
pub mod plan;
|
||||
pub mod pull;
|
||||
pub mod queues;
|
||||
pub mod routes;
|
||||
pub mod scripts;
|
||||
pub mod secrets;
|
||||
pub mod topics;
|
||||
pub mod triggers;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
pub mod whoami;
|
||||
|
||||
134
crates/picloud-cli/src/cmds/plan.rs
Normal file
134
crates/picloud-cli/src/cmds/plan.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! `pic plan [--file picloud.toml]` — diff the manifest's desired state
|
||||
//! against the live app and print the per-resource changes. Read-only:
|
||||
//! builds a bundle (manifest + script sources) and POSTs it to the
|
||||
//! server's plan endpoint, which computes the diff.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::client::{ChangeDto, Client, PlanDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
||||
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
||||
// app changing underneath the reviewed plan (best-effort — a read-only
|
||||
// plan still succeeds if the project dir isn't writable).
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) =
|
||||
crate::linkstate::write_plan(base_dir, &manifest.app.slug, &plan.state_token)
|
||||
{
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
}
|
||||
}
|
||||
render(&plan, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||
/// their `file`), routes pass through, triggers flatten into a tagged
|
||||
/// array, secrets are names only.
|
||||
pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
let mut scripts = Vec::with_capacity(manifest.scripts.len());
|
||||
for s in &manifest.scripts {
|
||||
let source = std::fs::read_to_string(base_dir.join(&s.file))
|
||||
.with_context(|| format!("reading script source {}", s.file))?;
|
||||
let mut obj = Map::new();
|
||||
obj.insert("name".into(), json!(s.name));
|
||||
obj.insert("source".into(), json!(source));
|
||||
obj.insert("kind".into(), serde_json::to_value(s.kind)?);
|
||||
if let Some(d) = &s.description {
|
||||
obj.insert("description".into(), json!(d));
|
||||
}
|
||||
if let Some(t) = s.timeout_seconds {
|
||||
obj.insert("timeout_seconds".into(), json!(t));
|
||||
}
|
||||
if let Some(m) = s.memory_limit_mb {
|
||||
obj.insert("memory_limit_mb".into(), json!(m));
|
||||
}
|
||||
if let Some(sb) = &s.sandbox {
|
||||
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
|
||||
}
|
||||
obj.insert("enabled".into(), json!(s.enabled));
|
||||
scripts.push(Value::Object(obj));
|
||||
}
|
||||
|
||||
let routes = manifest
|
||||
.routes
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let t = &manifest.triggers;
|
||||
let mut triggers = Vec::new();
|
||||
for s in &t.kv {
|
||||
triggers.push(tagged("kv", s)?);
|
||||
}
|
||||
for s in &t.docs {
|
||||
triggers.push(tagged("docs", s)?);
|
||||
}
|
||||
for s in &t.files {
|
||||
triggers.push(tagged("files", s)?);
|
||||
}
|
||||
for s in &t.cron {
|
||||
triggers.push(tagged("cron", s)?);
|
||||
}
|
||||
for s in &t.pubsub {
|
||||
triggers.push(tagged("pubsub", s)?);
|
||||
}
|
||||
for s in &t.email {
|
||||
triggers.push(tagged("email", s)?);
|
||||
}
|
||||
for s in &t.queue {
|
||||
triggers.push(tagged("queue", s)?);
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Serialize a trigger spec and stamp its `kind` discriminator.
|
||||
fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
let mut v = serde_json::to_value(spec)?;
|
||||
if let Value::Object(map) = &mut v {
|
||||
map.insert("kind".into(), Value::String(kind.to_string()));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 4] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
table.row([
|
||||
kind.to_string(),
|
||||
c.op.clone(),
|
||||
c.key.clone(),
|
||||
c.detail.clone().unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
348
crates/picloud-cli/src/cmds/pull.rs
Normal file
348
crates/picloud-cli/src/cmds/pull.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
//! `pic pull <app> [--dir .]` — export an app's current server state into
|
||||
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
|
||||
//! declarative management with `pic plan` / `pic apply`.
|
||||
//!
|
||||
//! Read-only: issues `GET`s only and writes local files. Every trigger
|
||||
//! kind is exported except `email` — the server stores the *sealed secret
|
||||
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
|
||||
//! can't be reconstructed (email triggers must be set up by hand).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
||||
QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
// Refuse to clobber an existing project (mirrors `pic init`). `pull`
|
||||
// overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a
|
||||
// stray `pic pull <wrong-app>` in a populated dir would destroy local
|
||||
// edits. Fail fast — before any network call or file write — unless the
|
||||
// operator opted in with `--force`.
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
if !force && manifest_path.exists() {
|
||||
anyhow::bail!(
|
||||
"{} already exists; refusing to overwrite. Re-run with --force to \
|
||||
replace it (and any colliding scripts/*.rhai).",
|
||||
manifest_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// One GET per resource kind (routes are per-script, below).
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
||||
let triggers = client.triggers_list(app_ident).await?.triggers;
|
||||
let secrets = client
|
||||
.secrets_list(VarOwnerArg::App(app_ident), None)
|
||||
.await?
|
||||
.secrets;
|
||||
|
||||
let name_by_id: HashMap<ScriptId, String> =
|
||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||||
|
||||
// Routes: the admin surface lists them per script.
|
||||
let mut routes = Vec::new();
|
||||
for s in &scripts {
|
||||
for r in client.routes_list_for_script(&s.id.to_string()).await? {
|
||||
routes.push(ManifestRoute {
|
||||
script: s.name.clone(),
|
||||
method: r.method,
|
||||
host_kind: r.host_kind,
|
||||
host: r.host,
|
||||
host_param_name: r.host_param_name,
|
||||
path_kind: r.path_kind,
|
||||
path: r.path,
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
enabled: r.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The server does not constrain script names to a filesystem-safe
|
||||
// charset, so a name containing a path separator or `..` would let `pull`
|
||||
// write outside the project dir. Validate ALL names up front, before any
|
||||
// file is written, so a single bad name can't leave a half-written dir.
|
||||
// Reject rather than sanitize: a silent rename would desync the manifest
|
||||
// `name` from its `file`.
|
||||
for s in &scripts {
|
||||
if !is_safe_filename(&s.name) {
|
||||
anyhow::bail!(
|
||||
"script name {:?} is not filesystem-safe (a path separator, \
|
||||
`..`, a leading dot, a control character, or longer than 200 \
|
||||
bytes); cannot pull",
|
||||
s.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scripts: write each source next to the manifest and record a path ref.
|
||||
let scripts_dir = dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir)
|
||||
.with_context(|| format!("creating {}", scripts_dir.display()))?;
|
||||
let mut manifest_scripts = Vec::with_capacity(scripts.len());
|
||||
for s in &scripts {
|
||||
let rel = format!("scripts/{}.rhai", s.name);
|
||||
std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?;
|
||||
manifest_scripts.push(ManifestScript {
|
||||
name: s.name.clone(),
|
||||
file: rel,
|
||||
kind: s.kind,
|
||||
description: s.description.clone(),
|
||||
timeout_seconds: i32::try_from(s.timeout_seconds).ok(),
|
||||
memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(),
|
||||
sandbox: if s.sandbox.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.sandbox)
|
||||
},
|
||||
enabled: s.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Triggers: map the five settled kinds; warn + skip the rest.
|
||||
let mut manifest_triggers = ManifestTriggers::default();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for t in &triggers {
|
||||
let script = name_by_id
|
||||
.get(&t.script_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| t.script_id.to_string());
|
||||
let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
|
||||
let retry_max_attempts = Some(t.retry_max_attempts);
|
||||
match t.kind.as_str() {
|
||||
"kv" => {
|
||||
let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.kv.push(KvTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"docs" => {
|
||||
let d: CollectionDetails<DocsEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.docs.push(DocsTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"files" => {
|
||||
let d: CollectionDetails<FilesEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.files.push(FilesTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"cron" => {
|
||||
let d: CronDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.cron.push(CronTriggerSpec {
|
||||
script,
|
||||
schedule: d.schedule,
|
||||
timezone: d.timezone,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"pubsub" => {
|
||||
let d: PubsubDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.pubsub.push(PubsubTriggerSpec {
|
||||
script,
|
||||
topic_pattern: d.topic_pattern,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"queue" => {
|
||||
let d: QueueDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.queue.push(QueueTriggerSpec {
|
||||
script,
|
||||
queue_name: d.queue_name,
|
||||
visibility_timeout_secs: Some(d.visibility_timeout_secs),
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
// `email` is skipped: the server stores the sealed secret value,
|
||||
// not the secret *name*, so the manifest's `inbound_secret_ref`
|
||||
// can't be reconstructed — set it up by hand.
|
||||
other => skipped.push(format!("{other} ({})", t.id)),
|
||||
}
|
||||
}
|
||||
for s in &skipped {
|
||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
app: ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
},
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
secrets: ManifestSecrets {
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
.with_context(|| format!("writing {}", manifest_path.display()))?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field("scripts", manifest.scripts.len().to_string())
|
||||
.field("routes", manifest.routes.len().to_string())
|
||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
||||
.field("secrets", manifest.secrets.names.len().to_string());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_count(t: &ManifestTriggers) -> usize {
|
||||
t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len()
|
||||
}
|
||||
|
||||
/// True if `name` is safe to use as a single path component in `scripts/`.
|
||||
/// Rejects empty/over-long names, path separators, `.`/`..`, leading dots,
|
||||
/// and any deceptive display character — a server-returned name is otherwise
|
||||
/// written verbatim as a filename and printed to the operator's terminal.
|
||||
fn is_safe_filename(name: &str) -> bool {
|
||||
// Leave headroom under NAME_MAX (255 bytes on common filesystems) for the
|
||||
// `.rhai` suffix.
|
||||
const MAX_LEN: usize = 200;
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_LEN
|
||||
&& !name.starts_with('.')
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& name != ".."
|
||||
&& !name.chars().any(is_deceptive_char)
|
||||
}
|
||||
|
||||
/// Control characters (NUL, newlines, ANSI escapes) plus the Unicode
|
||||
/// bidirectional-override and zero-width/format characters used to spoof how a
|
||||
/// name renders in a terminal — both classes are unsafe to print verbatim.
|
||||
fn is_deceptive_char(c: char) -> bool {
|
||||
c.is_control()
|
||||
|| matches!(c,
|
||||
'\u{200B}'..='\u{200F}' // zero-width space … LTR/RTL marks
|
||||
| '\u{2028}'..='\u{2029}' // line / paragraph separators
|
||||
| '\u{202A}'..='\u{202E}' // bidi embeddings / overrides
|
||||
| '\u{2060}' // word joiner
|
||||
| '\u{2066}'..='\u{2069}' // bidi isolates
|
||||
| '\u{FEFF}' // BOM / zero-width no-break space
|
||||
)
|
||||
}
|
||||
|
||||
/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
|
||||
/// The server tags details with a `kind` field which these structs ignore.
|
||||
fn decode_details<T: for<'de> Deserialize<'de>>(
|
||||
details: &serde_json::Value,
|
||||
kind: &str,
|
||||
) -> Result<T> {
|
||||
serde_json::from_value(details.clone())
|
||||
.with_context(|| format!("decoding {kind} trigger details"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionDetails<Op> {
|
||||
collection_glob: String,
|
||||
#[serde(default = "Vec::new")]
|
||||
ops: Vec<Op>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CronDetails {
|
||||
schedule: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
timezone: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PubsubDetails {
|
||||
topic_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueueDetails {
|
||||
queue_name: String,
|
||||
visibility_timeout_secs: u32,
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_safe_filename;
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_and_separators() {
|
||||
for bad in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../etc/passwd",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
".hidden",
|
||||
"with\0nul",
|
||||
] {
|
||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_control_chars_and_overlong() {
|
||||
for bad in [
|
||||
"a\nb",
|
||||
"a\tb",
|
||||
"line\rdrop",
|
||||
"esc\x1b[2Jseq",
|
||||
"rtl\u{202E}gpj.exe", // bidi override (filename spoof)
|
||||
"zero\u{200B}width", // zero-width space
|
||||
"bom\u{FEFF}name", // BOM
|
||||
] {
|
||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
||||
}
|
||||
assert!(
|
||||
!is_safe_filename(&"a".repeat(201)),
|
||||
"expected an over-long name to be rejected"
|
||||
);
|
||||
assert!(
|
||||
is_safe_filename(&"a".repeat(200)),
|
||||
"a 200-char name is at the limit and allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_names() {
|
||||
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
|
||||
assert!(is_safe_filename(ok), "expected {ok:?} to be accepted");
|
||||
}
|
||||
}
|
||||
}
|
||||
62
crates/picloud-cli/src/cmds/queues.rs
Normal file
62
crates/picloud-cli/src/cmds/queues.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
//! `pic queues ls | show` — read-only queue inspection (G2).
|
||||
//!
|
||||
//! Wraps the read-only `/api/v1/admin/apps/{id}/queues*` surface (no
|
||||
//! purge/requeue — that is v1.2). Gated on `AppLogRead` server-side.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let queues = client.queues_list(app).await?;
|
||||
let mut table = Table::new(["queue", "total", "pending", "claimed"]);
|
||||
for q in queues {
|
||||
table.row([
|
||||
q.queue_name,
|
||||
q.total.to_string(),
|
||||
q.pending.to_string(),
|
||||
q.claimed.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(app: &str, queue_name: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let q = client.queue_get(app, queue_name).await?;
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("queue", q.queue_name)
|
||||
.field("total", q.total.to_string())
|
||||
.field("pending", q.pending.to_string())
|
||||
.field("claimed", q.claimed.to_string());
|
||||
match q.consumer {
|
||||
Some(c) => {
|
||||
block
|
||||
.field("consumer_script", c.script_name)
|
||||
.field("consumer_script_id", c.script_id.to_string())
|
||||
.field("consumer_trigger_id", c.trigger_id)
|
||||
.field(
|
||||
"visibility_timeout_secs",
|
||||
c.visibility_timeout_secs.to_string(),
|
||||
)
|
||||
.field(
|
||||
"last_fired_at",
|
||||
c.last_fired_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
block.field("consumer", "(none registered)");
|
||||
}
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::AppId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, CreateScriptBody};
|
||||
use crate::client::{Client, CreateScriptBody, ScriptConfig};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
@@ -62,6 +62,7 @@ pub async fn deploy(
|
||||
app_ident: &str,
|
||||
name_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
cfg: &ScriptConfig,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -90,7 +91,7 @@ pub async fn deploy(
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source)
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
@@ -99,6 +100,10 @@ pub async fn deploy(
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
};
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
//! `pic secrets` subcommands: `ls`, `set`, `rm`.
|
||||
//! `pic secrets ls | set | rm | read` — manage Phase-3 group/app secrets.
|
||||
//!
|
||||
//! Set reads the secret value from stdin (the only safe channel —
|
||||
//! inline values would leak into shell history). The value is sent
|
||||
//! as a JSON string; pass `--json` to interpret stdin as raw JSON
|
||||
//! (numbers, maps, …) instead.
|
||||
//! Exactly one of `--group` / `--app` selects the owner (mirroring
|
||||
//! `pic vars`). Set reads the secret value from stdin (the only safe
|
||||
//! channel — inline values would leak into shell history). The value is
|
||||
//! sent as a JSON string; pass `--json` to interpret stdin as raw JSON
|
||||
//! (numbers, maps, …) instead. `--env` is only meaningful for group
|
||||
//! owners (app secrets are env-agnostic).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
/// Resolve the `--group`/`--app` pair into exactly one owner.
|
||||
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
||||
match (group, app) {
|
||||
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
|
||||
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
|
||||
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
|
||||
(None, None) => Err(anyhow!("pass one of --group / --app")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ls(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let resp = client.secrets_list(app).await?;
|
||||
let mut table = Table::new(["name", "updated_at"]);
|
||||
let resp = client.secrets_list(owner, env).await?;
|
||||
let mut table = Table::new(["name", "env", "updated_at"]);
|
||||
for s in resp.secrets {
|
||||
table.row([s.name, s.updated_at.to_rfc3339()]);
|
||||
table.row([s.name, s.env, s.updated_at.to_rfc3339()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
||||
pub async fn set(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
as_json: bool,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut buf = String::new();
|
||||
@@ -41,15 +66,36 @@ pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
||||
} else {
|
||||
serde_json::Value::String(trimmed.to_string())
|
||||
};
|
||||
client.secrets_set(app, name, value).await?;
|
||||
client.secrets_set(owner, name, value, env).await?;
|
||||
println!("Set secret {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, name: &str) -> Result<()> {
|
||||
pub async fn rm(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.secrets_delete(app, name).await?;
|
||||
client.secrets_delete(owner, name, env).await?;
|
||||
println!("Deleted secret {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic secrets read --group <slug> <name>` — fetch and print a secret's
|
||||
/// PLAINTEXT value. This is the ONLY command that reveals a secret value,
|
||||
/// and it is gated server-side at the owning group (there is no app-secret
|
||||
/// equivalent). String values print raw; JSON values print pretty.
|
||||
pub async fn read(group: &str, name: &str, env: Option<&str>) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let resp = client.group_secret_read_value(group, name, env).await?;
|
||||
match resp.value {
|
||||
serde_json::Value::String(s) => println!("{s}"),
|
||||
other => println!("{}", serde_json::to_string_pretty(&other)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -124,6 +124,114 @@ pub async fn create_dead_letter(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// docs/files share KV's `{collection_glob, ops?}` shape.
|
||||
async fn create_collection_trigger(
|
||||
kind: &str,
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["collection_glob"] = json!(collection_glob);
|
||||
if !ops.is_empty() {
|
||||
body["ops"] = json!(ops);
|
||||
}
|
||||
let created = client.triggers_create(app, kind, &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_docs(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
create_collection_trigger("docs", app, script_id, collection_glob, ops, dispatch, mode).await
|
||||
}
|
||||
|
||||
pub async fn create_files(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
collection_glob: &str,
|
||||
ops: &[String],
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
create_collection_trigger(
|
||||
"files",
|
||||
app,
|
||||
script_id,
|
||||
collection_glob,
|
||||
ops,
|
||||
dispatch,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_pubsub(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
topic_pattern: &str,
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["topic_pattern"] = json!(topic_pattern);
|
||||
let created = client.triggers_create(app, "pubsub", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_queue(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
queue_name: &str,
|
||||
visibility_timeout_secs: Option<u32>,
|
||||
dispatch: &str,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut body = base_body(script_id, dispatch);
|
||||
body["queue_name"] = json!(queue_name);
|
||||
if let Some(v) = visibility_timeout_secs {
|
||||
body["visibility_timeout_secs"] = json!(v);
|
||||
}
|
||||
let created = client.triggers_create(app, "queue", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_email(
|
||||
app: &str,
|
||||
script_id: &str,
|
||||
inbound_secret: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Email triggers take no dispatch_mode (inbound webhook only) and an
|
||||
// optional shared HMAC secret the provider signs POSTs with.
|
||||
let mut body = json!({ "script_id": script_id });
|
||||
if let Some(secret) = inbound_secret {
|
||||
body["inbound_secret"] = json!(secret);
|
||||
}
|
||||
let created = client.triggers_create(app, "email", &body).await?;
|
||||
print_created(&created, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_from_json(app: &str, kind: &str, body: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
85
crates/picloud-cli/src/cmds/vars.rs
Normal file
85
crates/picloud-cli/src/cmds/vars.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! `pic vars ls | set | rm` — manage Phase-3 group/app config vars.
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/{apps,groups}/{id}/vars*`. Exactly one of
|
||||
//! `--group` / `--app` selects the owner. `ls` shows the owner's OWN rows
|
||||
//! (not the resolved/inherited view). Set values are JSON strings by
|
||||
//! default; `--json` parses the value as raw JSON.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Resolve the `--group`/`--app` pair into exactly one owner.
|
||||
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
||||
match (group, app) {
|
||||
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
|
||||
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
|
||||
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
|
||||
(None, None) => Err(anyhow!("pass one of --group / --app")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ls(group: Option<&str>, app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let resp = client.vars_list(owner).await?;
|
||||
let mut table = Table::new(["key", "env", "value", "tombstone", "updated_at"]);
|
||||
for v in resp.vars {
|
||||
table.row([
|
||||
v.key,
|
||||
v.env,
|
||||
v.value.to_string(),
|
||||
v.is_tombstone.to_string(),
|
||||
v.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
key: &str,
|
||||
value: &str,
|
||||
env: Option<&str>,
|
||||
as_json: bool,
|
||||
tombstone: bool,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// A tombstone carries no value (the server stores JSON null + the
|
||||
// deletion marker); otherwise parse per `--json`.
|
||||
let parsed = if tombstone {
|
||||
serde_json::Value::Null
|
||||
} else if as_json {
|
||||
serde_json::from_str(value).map_err(|e| anyhow!("parse value as JSON: {e}"))?
|
||||
} else {
|
||||
serde_json::Value::String(value.to_string())
|
||||
};
|
||||
client.vars_set(owner, key, parsed, env, tombstone).await?;
|
||||
if tombstone {
|
||||
println!("Set tombstone for {key}");
|
||||
} else {
|
||||
println!("Set var {key}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.vars_delete(owner, key, env).await?;
|
||||
println!("Deleted var {key}");
|
||||
Ok(())
|
||||
}
|
||||
67
crates/picloud-cli/src/linkstate.rs
Normal file
67
crates/picloud-cli/src/linkstate.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! `.picloud/` link state — gitignored, per-project metadata the project tool
|
||||
//! carries between CLI invocations. Today it holds just the bound-plan token:
|
||||
//! `pic plan` records the fingerprint of the live state it diffed against, and
|
||||
//! `pic apply` replays it so the server can refuse if the app moved underneath.
|
||||
//!
|
||||
//! All paths are relative to the manifest's directory (the project root).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DIR: &str = ".picloud";
|
||||
const PLAN_FILE: &str = "plan.json";
|
||||
|
||||
/// The recorded result of the last `pic plan`, scoped to the app it was for.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanLink {
|
||||
/// App slug the token belongs to — guards against replaying a token from a
|
||||
/// different app if the manifest's `slug` changed.
|
||||
pub app: String,
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
fn plan_path(base: &Path) -> PathBuf {
|
||||
base.join(DIR).join(PLAN_FILE)
|
||||
}
|
||||
|
||||
/// Record the bound-plan token for `app` under `base/.picloud/`.
|
||||
pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> {
|
||||
let dir = base.join(DIR);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
// Self-ignore: a `.gitignore` of `*` inside `.picloud/` keeps the whole
|
||||
// dir out of git regardless of the project root's `.gitignore` — so this
|
||||
// is safe even when reached via `pic plan`/`pull` (which, unlike `init`,
|
||||
// don't touch the root `.gitignore`).
|
||||
let ignore = dir.join(".gitignore");
|
||||
if !ignore.exists() {
|
||||
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
|
||||
}
|
||||
let link = PlanLink {
|
||||
app: app.to_string(),
|
||||
state_token: state_token.to_string(),
|
||||
};
|
||||
let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?;
|
||||
fs::write(plan_path(base), body).context("writing .picloud/plan.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the recorded plan token, if any. Returns `None` when absent or
|
||||
/// unreadable (treated as "no prior plan" — never an error).
|
||||
#[must_use]
|
||||
pub fn read_plan(base: &Path) -> Option<PlanLink> {
|
||||
let body = fs::read(plan_path(base)).ok()?;
|
||||
serde_json::from_slice(&body).ok()
|
||||
}
|
||||
|
||||
/// Remove the recorded plan token (best-effort) **iff it belongs to `app`**.
|
||||
/// Called after a successful apply consumes it, so the next apply requires a
|
||||
/// fresh plan — without clobbering a token recorded for a different app that
|
||||
/// happens to share the directory.
|
||||
pub fn clear_plan(base: &Path, app: &str) {
|
||||
if read_plan(base).is_some_and(|l| l.app == app) {
|
||||
let _ = fs::remove_file(plan_path(base));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
523
crates/picloud-cli/src/manifest.rs
Normal file
523
crates/picloud-cli/src/manifest.rs
Normal file
@@ -0,0 +1,523 @@
|
||||
//! Declarative project manifest (`picloud.toml`).
|
||||
//!
|
||||
//! One manifest describes the desired state of a **single app** — its
|
||||
//! scripts, routes, triggers, and the *names* of the secrets it expects
|
||||
//! (values are pushed out-of-band via `pic secret set`, never committed).
|
||||
//!
|
||||
//! This is the foundation of the declarative project tool (`pic pull` /
|
||||
//! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared`
|
||||
//! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`,
|
||||
//! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays
|
||||
//! identical to the admin API — the CLI never depends on `manager-core`.
|
||||
//!
|
||||
//! All eight trigger kinds are representable except `dead_letter` (not
|
||||
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
|
||||
//! (a secret name) resolved server-side at apply.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::{
|
||||
DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Conventional manifest filename at a project root.
|
||||
pub const MANIFEST_FILE: &str = "picloud.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub app: ManifestApp,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub routes: Vec<ManifestRoute>,
|
||||
#[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")]
|
||||
pub triggers: ManifestTriggers,
|
||||
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
|
||||
pub secrets: ManifestSecrets,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Parse a manifest from TOML text.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
toml::from_str(text).context("parsing manifest TOML")
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let body =
|
||||
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
Self::parse(&body)
|
||||
}
|
||||
|
||||
/// Render to TOML text. Tables are emitted after scalars (the struct
|
||||
/// field order already satisfies TOML's "values before tables" rule).
|
||||
pub fn to_toml(&self) -> Result<String> {
|
||||
toml::to_string_pretty(self).context("serializing manifest TOML")
|
||||
}
|
||||
|
||||
/// Load the base manifest, then (if `env` is set) merge the sparse
|
||||
/// `picloud.<env>.toml` overlay on top — the §4.1 base+overlay model
|
||||
/// where "an environment is an app". The overlay carries per-env slug +
|
||||
/// secrets; scripts/routes/triggers stay in the shared base. (Rich
|
||||
/// per-key `vars` resolution is Phase 3.)
|
||||
pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result<Self> {
|
||||
let mut base = Self::load(base_path)?;
|
||||
if let Some(env) = env {
|
||||
let path = overlay_path(base_path, env);
|
||||
let body = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"reading overlay {} for env `{env}` (expected next to the base manifest)",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let overlay: ManifestOverlay = toml::from_str(&body)
|
||||
.with_context(|| format!("parsing overlay {}", path.display()))?;
|
||||
base.apply_overlay(overlay);
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name`
|
||||
/// replace the base's; overlay secret names union into the base set.
|
||||
fn apply_overlay(&mut self, overlay: ManifestOverlay) {
|
||||
if let Some(slug) = overlay.app.slug {
|
||||
self.app.slug = slug;
|
||||
}
|
||||
if let Some(name) = overlay.app.name {
|
||||
self.app.name = name;
|
||||
}
|
||||
for n in overlay.secrets.names {
|
||||
if !self.secrets.names.contains(&n) {
|
||||
self.secrets.names.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `picloud.toml` → `picloud.<env>.toml`, alongside the base (works for a
|
||||
/// custom `--file` too: `custom.toml` → `custom.<env>.toml`).
|
||||
fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
|
||||
let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let name = base_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| MANIFEST_FILE.to_string());
|
||||
let stem = name.strip_suffix(".toml").unwrap_or(&name);
|
||||
parent.join(format!("{stem}.{env}.toml"))
|
||||
}
|
||||
|
||||
/// A sparse per-environment overlay (`picloud.<env>.toml`). Only the fields
|
||||
/// that vary per environment today — slug/name and secret names.
|
||||
///
|
||||
/// `deny_unknown_fields`: an overlay can carry *only* `[app]` and `[secrets]`.
|
||||
/// Scripts/routes/triggers belong in the shared base manifest, so a
|
||||
/// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an
|
||||
/// overlay is a mistake — error loudly rather than silently dropping it.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestOverlay {
|
||||
#[serde(default)]
|
||||
pub app: OverlayApp,
|
||||
#[serde(default)]
|
||||
pub secrets: ManifestSecrets,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OverlayApp {
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestApp {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestScript {
|
||||
pub name: String,
|
||||
/// Path to the `.rhai` source, relative to the manifest's directory.
|
||||
pub file: String,
|
||||
#[serde(default, skip_serializing_if = "is_endpoint")]
|
||||
pub kind: ScriptKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
/// Per-script sandbox overrides; omitted entirely when no knob is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3): `false` deploys the script inert (not
|
||||
/// invocable). Omitted ⇒ active; only serialized when disabled.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestRoute {
|
||||
/// Name of the script this route binds to.
|
||||
pub script: String,
|
||||
/// HTTP method; omit for ANY.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub host: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "is_sync")]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
|
||||
/// Omitted ⇒ active; only serialized when disabled.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggers {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub kv: Vec<KvTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub docs: Vec<DocsTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub files: Vec<FilesTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cron: Vec<CronTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pubsub: Vec<PubsubTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub email: Vec<EmailTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub queue: Vec<QueueTriggerSpec>,
|
||||
}
|
||||
|
||||
impl ManifestTriggers {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.kv.is_empty()
|
||||
&& self.docs.is_empty()
|
||||
&& self.files.is_empty()
|
||||
&& self.cron.is_empty()
|
||||
&& self.pubsub.is_empty()
|
||||
&& self.email.is_empty()
|
||||
&& self.queue.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KvTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<KvEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DocsTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<DocsEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FilesTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<FilesEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CronTriggerSpec {
|
||||
pub script: String,
|
||||
/// 6-field cron expression (with seconds).
|
||||
pub schedule: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PubsubTriggerSpec {
|
||||
pub script: String,
|
||||
pub topic_pattern: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmailTriggerSpec {
|
||||
pub script: String,
|
||||
/// Name of the secret (set via `pic secret set`) holding the inbound
|
||||
/// HMAC value — resolved + sealed server-side at apply. Never the value.
|
||||
pub inbound_secret_ref: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct QueueTriggerSpec {
|
||||
pub script: String,
|
||||
pub queue_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visibility_timeout_secs: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
/// `[secrets] names = [...]` — declares which secrets the app expects.
|
||||
/// Values are never in the manifest; `pic secret set` pushes them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestSecrets {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
impl ManifestSecrets {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.names.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
|
||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||
*kind == ScriptKind::Endpoint
|
||||
}
|
||||
|
||||
fn is_sync(mode: &DispatchMode) -> bool {
|
||||
*mode == DispatchMode::Sync
|
||||
}
|
||||
|
||||
/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
|
||||
fn is_true(b: &bool) -> bool {
|
||||
*b
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
},
|
||||
scripts: vec![
|
||||
ManifestScript {
|
||||
name: "create-post".into(),
|
||||
file: "scripts/create-post.rhai".into(),
|
||||
kind: ScriptKind::Endpoint,
|
||||
description: None,
|
||||
timeout_seconds: Some(10),
|
||||
memory_limit_mb: Some(256),
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
},
|
||||
ManifestScript {
|
||||
name: "lib".into(),
|
||||
file: "scripts/lib.rhai".into(),
|
||||
kind: ScriptKind::Module,
|
||||
description: None,
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: Some(ScriptSandbox {
|
||||
max_operations: Some(5_000_000),
|
||||
..ScriptSandbox::empty()
|
||||
}),
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
routes: vec![ManifestRoute {
|
||||
script: "create-post".into(),
|
||||
method: Some("POST".into()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/posts".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: ManifestTriggers {
|
||||
cron: vec![CronTriggerSpec {
|
||||
script: "create-post".into(),
|
||||
schedule: "0 6 * * * *".into(),
|
||||
timezone: "UTC".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
}],
|
||||
kv: vec![KvTriggerSpec {
|
||||
script: "create-post".into(),
|
||||
collection_glob: "users".into(),
|
||||
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
||||
dispatch_mode: Some(DispatchMode::Async),
|
||||
retry_max_attempts: Some(5),
|
||||
}],
|
||||
..ManifestTriggers::default()
|
||||
},
|
||||
secrets: ManifestSecrets {
|
||||
names: vec!["STRIPE_KEY".into()],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_toml() {
|
||||
let m = sample();
|
||||
let text = m.to_toml().expect("serialize");
|
||||
let back = Manifest::parse(&text).expect("parse");
|
||||
assert_eq!(m, back, "manifest must survive a TOML round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_defaulted_fields() {
|
||||
let text = sample().to_toml().unwrap();
|
||||
// Endpoint kind + sync dispatch are defaults → not emitted.
|
||||
assert!(
|
||||
!text.contains("kind = \"endpoint\""),
|
||||
"default kind should be omitted:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("dispatch_mode = \"sync\""),
|
||||
"default route dispatch should be omitted:\n{text}"
|
||||
);
|
||||
// Module kind IS non-default → emitted.
|
||||
assert!(text.contains("kind = \"module\""), "got:\n{text}");
|
||||
// `enabled` defaults true → omitted for the active script/route, but
|
||||
// the disabled `lib` script emits `enabled = false`.
|
||||
assert!(
|
||||
text.contains("enabled = false"),
|
||||
"a disabled entity must emit enabled:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("enabled = true"),
|
||||
"active entities must omit the default:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_merges_slug_and_unions_secrets() {
|
||||
let mut m = sample(); // slug "blog", secrets ["STRIPE_KEY"]
|
||||
let overlay: ManifestOverlay = toml::from_str(
|
||||
"[app]\nslug = \"blog-staging\"\n\n[secrets]\nnames = [\"STRIPE_KEY\", \"STAGING_ONLY\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
m.apply_overlay(overlay);
|
||||
assert_eq!(m.app.slug, "blog-staging", "overlay slug wins");
|
||||
assert_eq!(
|
||||
m.app.name, "My Blog",
|
||||
"base name kept when overlay omits it"
|
||||
);
|
||||
assert_eq!(
|
||||
m.secrets.names,
|
||||
vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()],
|
||||
"secrets union, no dupes"
|
||||
);
|
||||
// Scripts/routes come from the base, untouched by the overlay.
|
||||
assert_eq!(m.scripts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_rejects_non_overlay_tables() {
|
||||
// An overlay carries only [app]/[secrets]. Scripts/routes/triggers
|
||||
// belong in the shared base, so a `[[scripts]]` table (or a typo'd
|
||||
// key) in an overlay must error loudly, not be silently dropped.
|
||||
let err = toml::from_str::<ManifestOverlay>(
|
||||
"[app]\nslug = \"blog-staging\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n",
|
||||
)
|
||||
.expect_err("overlay with [[scripts]] must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("scripts") || err.to_string().contains("unknown"),
|
||||
"error should point at the offending table: {err}"
|
||||
);
|
||||
|
||||
// Typo'd key inside [app] is likewise rejected.
|
||||
toml::from_str::<ManifestOverlay>("[app]\nslag = \"oops\"\n")
|
||||
.expect_err("overlay with a typo'd [app] key must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_path_derivation() {
|
||||
assert_eq!(
|
||||
overlay_path(Path::new("proj/picloud.toml"), "staging"),
|
||||
Path::new("proj/picloud.staging.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
overlay_path(Path::new("custom.toml"), "prod"),
|
||||
Path::new("custom.prod.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_optional_sections_omitted() {
|
||||
let m = Manifest {
|
||||
app: ManifestApp {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
},
|
||||
scripts: vec![],
|
||||
routes: vec![],
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
}
|
||||
153
crates/picloud-cli/tests/apply.rs
Normal file
153
crates/picloud-cli/tests/apply.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! `pic apply` journey: apply a manifest to an empty app (atomic create),
|
||||
//! re-apply is an idempotent no-op, and a bundle containing any invalid
|
||||
//! resource applies nothing (all-or-nothing).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_creates_then_noop() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apply");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/greet.rhai"),
|
||||
"let body = #{ ok: true }; body",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Apply Test\"\n\n\
|
||||
[[scripts]]\nname = \"greet\"\nfile = \"scripts/greet.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"greet\"\nmethod = \"POST\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n\n\
|
||||
[[triggers.cron]]\nscript = \"greet\"\nschedule = \"0 0 * * * *\"\ntimezone = \"UTC\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// First apply: creates script + route + trigger.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
stdout.contains("+1"),
|
||||
"expected creations in report:\n{stdout}"
|
||||
);
|
||||
|
||||
// The resources now exist.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.contains("greet"), "script not created:\n{s}");
|
||||
|
||||
// Plan is now clean (apply reached desired state).
|
||||
let p = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!p.contains("create") && !p.contains("update"),
|
||||
"expected clean plan after apply:\n{p}"
|
||||
);
|
||||
|
||||
// Re-apply: idempotent — nothing created/updated.
|
||||
let r = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_rejects_bad_bundle_atomically() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apply-atomic");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/good.rhai"), "let x = 1; x").unwrap();
|
||||
// Invalid Rhai — fails validation, so the whole apply must abort.
|
||||
fs::write(dir.path().join("scripts/bad.rhai"), "let x = ;").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Atomic Test\"\n\n\
|
||||
[[scripts]]\nname = \"good\"\nfile = \"scripts/good.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"apply with an invalid script should fail"
|
||||
);
|
||||
|
||||
// Atomic: the valid script must NOT have been created.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.contains("good"),
|
||||
"a failed apply must leave nothing behind:\n{s}"
|
||||
);
|
||||
}
|
||||
@@ -15,14 +15,27 @@ mod common;
|
||||
|
||||
mod admins;
|
||||
mod api_keys;
|
||||
mod apply;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod group_secrets;
|
||||
mod groups;
|
||||
mod init;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
mod output;
|
||||
mod plan;
|
||||
mod prune;
|
||||
mod pull;
|
||||
mod roles;
|
||||
mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod triggers;
|
||||
mod vars;
|
||||
|
||||
@@ -44,6 +44,35 @@ pub struct UserGuard {
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
/// Deletes a group on drop (best-effort). The group must be empty by then
|
||||
/// — register an `AppGuard`/child `GroupGuard` *after* this one so the
|
||||
/// child drops (deletes) first, leaving an empty node here.
|
||||
pub struct GroupGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
impl GroupGuard {
|
||||
pub fn new(url: &str, token: &str, slug: &str) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
token: token.to_string(),
|
||||
slug: slug.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GroupGuard {
|
||||
fn drop(&mut self) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let _ = client
|
||||
.delete(format!("{}/api/v1/admin/groups/{}", self.url, self.slug))
|
||||
.bearer_auth(&self.token)
|
||||
.send();
|
||||
}
|
||||
}
|
||||
|
||||
impl UserGuard {
|
||||
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
|
||||
Self {
|
||||
|
||||
57
crates/picloud-cli/tests/config.rs
Normal file
57
crates/picloud-cli/tests/config.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! `pic config --effective` — masked secret resolution against the manifest.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use predicates::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn config_effective_masks_and_classifies_secrets() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("config");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
format!("[app]\nslug = \"{slug}\"\nname = \"Cfg\"\n\n[secrets]\nnames = [\"API_KEY\"]\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Declared but not pushed → masked + flagged unset; value never shown.
|
||||
common::pic_as(&env)
|
||||
.args(["config", "--effective", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("API_KEY"))
|
||||
.stdout(predicate::str::contains("<unset>"))
|
||||
.stdout(predicate::str::contains("not pushed"));
|
||||
|
||||
// Push it → now masked as <set> / managed, still never the value.
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "API_KEY"])
|
||||
.write_stdin("super-secret-value")
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["config", "--effective", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("<set>"))
|
||||
.stdout(predicate::str::contains("managed"))
|
||||
.stdout(predicate::str::contains("super-secret-value").not());
|
||||
}
|
||||
222
crates/picloud-cli/tests/email_queue.rs
Normal file
222
crates/picloud-cli/tests/email_queue.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! M5: `pic apply` creates email + queue triggers. The email trigger's
|
||||
//! inbound secret is referenced by name (pushed via `pic secret set`) and
|
||||
//! resolved + re-sealed server-side — never written into the manifest.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_email_and_queue_triggers() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// The email trigger references this secret by name; push its value
|
||||
// out-of-band first.
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "email-hmac"])
|
||||
.write_stdin("super-secret-hmac")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[secrets]\nnames = [\"email-hmac\"]\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.queue]]\nscript = \"handler\"\nqueue_name = \"jobs\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// Both triggers exist.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.lines().any(|l| l.contains("queue")),
|
||||
"queue trigger missing:\n{s}"
|
||||
);
|
||||
assert!(
|
||||
s.lines().any(|l| l.contains("email")),
|
||||
"email trigger missing:\n{s}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (both triggers match by identity).
|
||||
let r = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_refuses_to_orphan_email_trigger() {
|
||||
// `pull` can't represent email triggers, so a manifest that omits the
|
||||
// script owning one would, under `--prune`, cascade-delete the trigger
|
||||
// (and its sealed secret) when the script is dropped. Apply must refuse.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5-orphan");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "email-hmac"])
|
||||
.write_stdin("super-secret-hmac")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// v1: a script with an email trigger.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[secrets]\nnames = [\"email-hmac\"]\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// v2: drop the script (and, implicitly, its un-representable email
|
||||
// trigger). A prune apply must REFUSE rather than cascade-destroy it.
|
||||
let v2 = format!("[app]\nslug = \"{slug}\"\nname = \"M5\"\n");
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"prune must refuse to orphan an email trigger"
|
||||
);
|
||||
|
||||
// The script and its email trigger both survive the refused apply.
|
||||
let scripts = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
scripts.contains("handler"),
|
||||
"script must survive:\n{scripts}"
|
||||
);
|
||||
let triggers = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
triggers.lines().any(|l| l.contains("email")),
|
||||
"email trigger must survive:\n{triggers}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_email_unset_secret_fails() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5-nosecret");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"never-set\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// The referenced secret was never set → apply must fail atomically.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"apply must fail when an email secret is unset"
|
||||
);
|
||||
|
||||
// Atomic: neither the script nor the email trigger was created.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.contains("handler"),
|
||||
"failed apply must leave nothing behind:\n{s}"
|
||||
);
|
||||
}
|
||||
226
crates/picloud-cli/tests/enabled.rs
Normal file
226
crates/picloud-cli/tests/enabled.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! `enabled` three-state lifecycle, end to end: disabling a script via the
|
||||
//! manifest makes it non-invocable (404 on the execute-by-id bypass), and
|
||||
//! re-enabling restores it — proving the data path + runtime honoring.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn disabling_a_script_makes_it_uninvocable_then_reenable() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enabled");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = |enabled_line: &str| {
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Enabled\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n{enabled_line}"
|
||||
)
|
||||
};
|
||||
|
||||
// Active → invocable.
|
||||
fs::write(&manifest_path, manifest("")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
let id = script_id(&env, &slug, "hello");
|
||||
assert_eq!(invoke_status(&env, &id), 200, "active script must invoke");
|
||||
|
||||
// Disabled → 404 (not invocable), but still deployed (re-pull would show it).
|
||||
fs::write(&manifest_path, manifest("enabled = false\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
invoke_status(&env, &id),
|
||||
404,
|
||||
"disabled script must 404 on execute-by-id"
|
||||
);
|
||||
|
||||
// Re-enabled → invocable again.
|
||||
fs::write(&manifest_path, manifest("enabled = true\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
invoke_status(&env, &id),
|
||||
200,
|
||||
"re-enabled script must invoke"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn disabling_a_route_makes_it_404_then_reenable() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enbl-route");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// The app must claim a Host before its routes are reachable (two-phase
|
||||
// dispatch: Host → app → route). A unique strict host avoids colliding
|
||||
// with other tests' instance-global claims.
|
||||
let host = format!("{slug}.test");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "domains", "add", &slug, &host])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = |enabled_line: &str| {
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"EnabledRoute\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"hello\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/hello\"\n{enabled_line}"
|
||||
)
|
||||
};
|
||||
|
||||
// Active → the route serves.
|
||||
fs::write(&manifest_path, manifest("")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
200,
|
||||
"active route serves"
|
||||
);
|
||||
|
||||
// Disabled → 404, indistinguishable from absent.
|
||||
fs::write(&manifest_path, manifest("enabled = false\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
404,
|
||||
"disabled route must 404"
|
||||
);
|
||||
|
||||
// Re-enabled → serves again.
|
||||
fs::write(&manifest_path, manifest("enabled = true\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
200,
|
||||
"re-enabled route serves"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn enabled_route_to_disabled_script_404s_flatly() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enbl-os");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
let host = format!("{slug}.test");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "domains", "add", &slug, &host])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
// Route stays enabled; the SCRIPT it binds is disabled (route-on/script-off).
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"OS\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\nenabled = false\n\n\
|
||||
[[routes]]\nscript = \"hello\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/hello\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
|
||||
// 404, and the body must be the flat "no route matches" form — never the
|
||||
// internal script id (no info leak; indistinguishable from absent).
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get(format!("{}/hello", env.url))
|
||||
.header(reqwest::header::HOST, &host)
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 404);
|
||||
let body = resp.text().unwrap();
|
||||
assert!(body.contains("no route matches"), "flat 404 body: {body}");
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, manifest_path: &Path) {
|
||||
common::pic_as(env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
/// GET a user route under an explicit `Host` (the claimed domain) and return
|
||||
/// the HTTP status code.
|
||||
fn route_status(env: &common::TestEnv, host: &str, path: &str) -> u16 {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
client
|
||||
.get(format!("{}{path}", env.url))
|
||||
.header(reqwest::header::HOST, host)
|
||||
.send()
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
|
||||
/// Resolve a script's id via the admin API (the manifest carries no ids).
|
||||
fn script_id(env: &common::TestEnv, slug: &str, name: &str) -> String {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let scripts: Vec<Value> = client
|
||||
.get(format!("{}/api/v1/admin/scripts?app={slug}", env.url))
|
||||
.bearer_auth(&env.token)
|
||||
.send()
|
||||
.unwrap()
|
||||
.json()
|
||||
.unwrap();
|
||||
scripts
|
||||
.into_iter()
|
||||
.find(|s| s["name"] == name)
|
||||
.and_then(|s| s["id"].as_str().map(String::from))
|
||||
.expect("script id")
|
||||
}
|
||||
|
||||
/// POST the execute-by-id bypass and return the HTTP status code.
|
||||
fn invoke_status(env: &common::TestEnv, id: &str) -> u16 {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
client
|
||||
.post(format!("{}/api/v1/execute/{id}", env.url))
|
||||
.bearer_auth(&env.token)
|
||||
.body("{}")
|
||||
.send()
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
76
crates/picloud-cli/tests/env_overlay.rs
Normal file
76
crates/picloud-cli/tests/env_overlay.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Env-scoped overlays (§4.1): `pic apply --env <E>` merges the sparse
|
||||
//! `picloud.<env>.toml` (per-env slug) onto the base and deploys to that
|
||||
//! environment's app — leaving the base app untouched.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_env_overlay_targets_the_env_app() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let base_slug = common::unique_slug("ovl");
|
||||
let staging_slug = format!("{base_slug}-staging");
|
||||
for s in [&base_slug, &staging_slug] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", s])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
let _g1 = AppGuard::new(&env.url, &env.token, &base_slug);
|
||||
let _g2 = AppGuard::new(&env.url, &env.token, &staging_slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let base = dir.path().join("picloud.toml");
|
||||
fs::write(
|
||||
&base,
|
||||
format!(
|
||||
"[app]\nslug = \"{base_slug}\"\nname = \"Ovl\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// Overlay redirects to the staging app.
|
||||
fs::write(
|
||||
dir.path().join("picloud.staging.toml"),
|
||||
format!("[app]\nslug = \"{staging_slug}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Apply to staging only.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&base)
|
||||
.args(["--env", "staging"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
assert!(
|
||||
scripts_ls(&env, &staging_slug).contains("hello"),
|
||||
"overlay apply must deploy to the staging app"
|
||||
);
|
||||
assert!(
|
||||
!scripts_ls(&env, &base_slug).contains("hello"),
|
||||
"the base app must be untouched by an --env apply"
|
||||
);
|
||||
}
|
||||
5
crates/picloud-cli/tests/fixtures/read-secret.rhai
vendored
Normal file
5
crates/picloud-cli/tests/fixtures/read-secret.rhai
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
// Phase-3 group-secrets journey fixture: returns the resolved `stripe-key`
|
||||
// secret verbatim so the test can assert runtime injection across the group
|
||||
// chain (inherited group value) vs an app-owned proximity override. The
|
||||
// value is decrypted under the resolved owner's AAD before injection.
|
||||
secrets::get("stripe-key")
|
||||
4
crates/picloud-cli/tests/fixtures/read-var.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/read-var.rhai
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
// Phase-3 vars journey fixture: returns the resolved `region` config var
|
||||
// verbatim so the test can assert on inheritance (group value) vs an app
|
||||
// proximity override.
|
||||
vars::get("region")
|
||||
187
crates/picloud-cli/tests/group_secrets.rs
Normal file
187
crates/picloud-cli/tests/group_secrets.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Phase-3 group secrets, end to end via `pic`:
|
||||
//!
|
||||
//! 1. **Inheritance + proximity** — a group-owned secret is injected into a
|
||||
//! descendant app's script via `secrets::get` (decrypted under the
|
||||
//! group AAD), and an app-owned secret of the same name shadows it
|
||||
//! (decrypted under the app AAD). Exercises the resolver + the dual
|
||||
//! owner-AAD open path against real Postgres.
|
||||
//! 2. **Masked-read boundary** — a `group_admin` reads the secret VALUE,
|
||||
//! an app-only dev is denied the value (403) yet still sees the secret
|
||||
//! EXISTS (masked) in `config/effective`. That is the headline §5.3
|
||||
//! property: an app runs with config its own devs cannot read.
|
||||
|
||||
use predicates::prelude::*;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_secret_is_injected_then_app_value_overrides() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("gs-acme");
|
||||
let app = common::unique_slug("gs-app");
|
||||
|
||||
// Group `acme` with a `stripe-key` group secret (value via stdin).
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--group", &acme, "stripe-key"])
|
||||
.write_stdin("sk_group")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App under acme with a script that reads + returns the resolved secret.
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let fixture = common::fixture_path("read-secret.rhai");
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
let ls = common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &app])
|
||||
.output()
|
||||
.expect("scripts ls");
|
||||
let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("scripts ls should produce one row");
|
||||
|
||||
// Inherited: the app has no own `stripe-key`, so the group's value is
|
||||
// injected (decrypted under the GROUP AAD).
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::json!("sk_group"),
|
||||
"inherited group secret"
|
||||
);
|
||||
|
||||
// Proximity override: an app-owned `stripe-key` shadows the group value
|
||||
// (decrypted under the APP AAD — proving both AAD namespaces open).
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &app, "stripe-key"])
|
||||
.write_stdin("sk_app")
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::json!("sk_app"),
|
||||
"app override"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_secret_value_is_masked_from_app_devs() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("gsm-acme");
|
||||
let app = common::unique_slug("gsm-app");
|
||||
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--group", &acme, "stripe-key"])
|
||||
.write_stdin("sk_live_masked")
|
||||
.assert()
|
||||
.success();
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// An app dev: a Member granted `editor` on the app, but NO group role.
|
||||
let dev = member::member_user(fx, &common::unique_username("appdev"));
|
||||
let dev_env = common::custom_env(&fx.url, &dev.token);
|
||||
common::seed_credentials(&dev_env, &dev.username);
|
||||
member::grant_membership(fx, &app, &dev.id, "editor");
|
||||
|
||||
// Denied the VALUE: the value endpoint is gated GroupSecretsRead at the
|
||||
// owning group, which the app dev does not hold.
|
||||
common::pic_as(&dev_env)
|
||||
.args(["secrets", "read", "--group", &acme, "stripe-key"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 403"));
|
||||
|
||||
// But the dev DOES see it EXISTS (masked) in the app's effective config —
|
||||
// status `set`, owner group, never the value. Asserted directly against
|
||||
// the endpoint to avoid the CLI's manifest requirement.
|
||||
let effective = reqwest::blocking::Client::new()
|
||||
.get(format!(
|
||||
"{}/api/v1/admin/apps/{}/config/effective",
|
||||
fx.url, app
|
||||
))
|
||||
.bearer_auth(&dev.token)
|
||||
.send()
|
||||
.expect("config effective");
|
||||
assert!(
|
||||
effective.status().is_success(),
|
||||
"dev can read effective config"
|
||||
);
|
||||
let body: serde_json::Value = effective.json().expect("effective json");
|
||||
let masked = &body["secrets"]["stripe-key"];
|
||||
assert_eq!(masked["status"], "set", "secret shown as set");
|
||||
assert_eq!(masked["owner"]["kind"], "group", "owned by the group");
|
||||
assert!(
|
||||
masked.get("value").is_none(),
|
||||
"value must never appear in effective config: {masked}"
|
||||
);
|
||||
|
||||
// A group_admin CAN read the value.
|
||||
let gadmin = member::member_user(fx, &common::unique_username("gadmin"));
|
||||
let gadmin_env = common::custom_env(&fx.url, &gadmin.token);
|
||||
common::seed_credentials(&gadmin_env, &gadmin.username);
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"groups",
|
||||
"members",
|
||||
"add",
|
||||
&acme,
|
||||
&gadmin.id,
|
||||
"--role",
|
||||
"app_admin",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&gadmin_env)
|
||||
.args(["secrets", "read", "--group", &acme, "stripe-key"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("sk_live_masked"));
|
||||
}
|
||||
|
||||
/// Invoke a script via `pic scripts invoke <id>` (→ `/api/v1/execute/{id}`)
|
||||
/// and parse its JSON body.
|
||||
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||
let out = common::pic_as(env)
|
||||
.args(["scripts", "invoke", id])
|
||||
.output()
|
||||
.expect("scripts invoke");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"invoke failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||
}
|
||||
179
crates/picloud-cli/tests/groups.rs
Normal file
179
crates/picloud-cli/tests/groups.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Phase-2 groups, end to end via `pic`: tree CRUD, delete=RESTRICT,
|
||||
//! reparent cycle rejection, and the headline invariant — a `group_admin`
|
||||
//! on an ancestor group can act on an app it is NOT a direct member of
|
||||
//! (inherited membership), and loses that access the instant the group
|
||||
//! grant is revoked.
|
||||
|
||||
use predicates::prelude::*;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_tree_create_show_and_delete_restrict() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("g-acme");
|
||||
let team = common::unique_slug("g-team");
|
||||
let app = common::unique_slug("g-app");
|
||||
|
||||
// Root-level group, then a subgroup under it.
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let _g_team = GroupGuard::new(&env.url, &env.token, &team);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &team, "--parent", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// An app under the subgroup.
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &team])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `groups show team` lists the app.
|
||||
let out = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "show", &team])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.contains(&app),
|
||||
"group detail should list its app:\n{out}"
|
||||
);
|
||||
|
||||
// delete=RESTRICT: acme has a subgroup → refused.
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "rm", &acme])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("409").or(predicate::str::contains("subgroup")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn reparent_into_own_descendant_is_rejected() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("g-cyc-p");
|
||||
let child = common::unique_slug("g-cyc-c");
|
||||
|
||||
let _g_parent = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
let _g_child = GroupGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &child, "--parent", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Moving the parent under its own child would form a cycle → refused.
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "reparent", &parent, "--to", &child])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("409").or(predicate::str::contains("descendant")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn inherited_group_admin_can_deploy_then_revoke() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("g-inh");
|
||||
let app = common::unique_slug("g-inh-app");
|
||||
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A fresh Member with NO app membership.
|
||||
let m = member::member_user(fx, &common::unique_username("inh"));
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
|
||||
// Baseline: without any grant, deploy is forbidden.
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 403"));
|
||||
|
||||
// Grant group_admin on the ANCESTOR group (no app_members row).
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"groups",
|
||||
"members",
|
||||
"add",
|
||||
&acme,
|
||||
&m.id,
|
||||
"--role",
|
||||
"app_admin",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Inherited: the member can now deploy to the app it never joined.
|
||||
// (Deploy prints a KvBlock — assert on the script name + create action,
|
||||
// not a prose string.)
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
|
||||
|
||||
// Revoke the group grant → access drops immediately (no cache lag).
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "members", "rm", &acme, &m.id])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 403"));
|
||||
}
|
||||
99
crates/picloud-cli/tests/init.rs
Normal file
99
crates/picloud-cli/tests/init.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! `pic init` journey — offline scaffolding, no server/DB needed (so these
|
||||
//! tests are NOT gated on `DATABASE_URL` and never touch `common::fixture`).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn pic() -> Command {
|
||||
Command::cargo_bin("pic").expect("pic binary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_scaffolds_a_deployable_project() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let toml = fs::read_to_string(dir.path().join("picloud.toml")).unwrap();
|
||||
assert!(toml.contains("slug = \"demo-app\""), "got:\n{toml}");
|
||||
assert!(
|
||||
toml.contains("name = \"Demo App\""),
|
||||
"name defaults to title-cased slug:\n{toml}"
|
||||
);
|
||||
assert!(
|
||||
dir.path().join("scripts/hello.rhai").exists(),
|
||||
"scaffold writes the example script"
|
||||
);
|
||||
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(
|
||||
gitignore.lines().any(|l| l.trim() == ".picloud/"),
|
||||
"init must gitignore .picloud/:\n{gitignore}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_derives_slug_from_a_not_yet_created_dir() {
|
||||
// The natural `pic init --dir new-project` flow: the target doesn't exist
|
||||
// yet, and the slug is derived from its name (not via canonicalize, which
|
||||
// would fail on a missing path).
|
||||
let parent = TempDir::new().unwrap();
|
||||
let target = parent.path().join("new-project");
|
||||
pic()
|
||||
.args(["init", "--dir"])
|
||||
.arg(&target)
|
||||
.assert()
|
||||
.success();
|
||||
let toml = fs::read_to_string(target.join("picloud.toml")).unwrap();
|
||||
assert!(
|
||||
toml.contains("slug = \"new-project\""),
|
||||
"slug should derive from the dir name:\n{toml}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_refuses_to_overwrite_without_force() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
// A second run must refuse rather than clobber edits.
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.failure();
|
||||
// `--force` overrides.
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app", "--force"])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_appends_to_an_existing_gitignore_once() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(
|
||||
gitignore.contains("target/"),
|
||||
"must preserve existing rules"
|
||||
);
|
||||
assert_eq!(
|
||||
gitignore.matches(".picloud/").count(),
|
||||
1,
|
||||
"must add the ignore exactly once:\n{gitignore}"
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use predicates::prelude::*;
|
||||
use crate::common;
|
||||
|
||||
/// Pick out the data rows from `pic logs` TSV output — the header line
|
||||
/// (`created_at\tstatus\tsummary`) is now always present, so the old
|
||||
/// (`created_at\tsource\tstatus\tsummary`) is now always present, so the old
|
||||
/// "no non-empty lines means no logs" check needs to skip it.
|
||||
fn data_rows(stdout: &str) -> Vec<&str> {
|
||||
stdout
|
||||
@@ -63,10 +63,10 @@ fn logs_after_invoke_records_success_row() {
|
||||
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
|
||||
assert_eq!(
|
||||
cols.len(),
|
||||
3,
|
||||
"row should be 3 tab-delimited cells: {rows:?}"
|
||||
4,
|
||||
"row should be 4 tab-delimited cells (created_at, source, status, summary): {rows:?}"
|
||||
);
|
||||
assert_eq!(cols[1], "success");
|
||||
assert_eq!(cols[2], "success");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
@@ -95,7 +95,7 @@ fn logs_records_error_for_throwing_script() {
|
||||
.next()
|
||||
.expect("at least one data row");
|
||||
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
|
||||
assert_eq!(cols[1], "error", "expected error status, got row: {row}");
|
||||
assert_eq!(cols[2], "error", "expected error status, got row: {row}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
@@ -166,7 +166,7 @@ fn logs_truncates_long_summary() {
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("at least one data row");
|
||||
let summary = row.split('\t').nth(2).expect("summary column");
|
||||
let summary = row.split('\t').nth(3).expect("summary column");
|
||||
assert!(
|
||||
summary.ends_with('…'),
|
||||
"summary should be truncated with `…`, got: {summary}"
|
||||
|
||||
81
crates/picloud-cli/tests/plan.rs
Normal file
81
crates/picloud-cli/tests/plan.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! `pic plan` journey: a freshly-pulled manifest must diff to all-no-op
|
||||
//! (pull→plan is idempotent), and editing a script source must surface
|
||||
//! as an update.
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn plan_roundtrips_then_detects_change() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let (script_id, guard) = common::deploy_fixture(&env, "plan", "hello.rhai");
|
||||
let app = guard.slug().to_string();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"routes", "create", "--script", &script_id, "--path", "/p", "--method", "GET",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull the live state, then plan it back — must be a clean no-op.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let manifest = dir.path().join("picloud.toml");
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest)
|
||||
.output()
|
||||
.expect("plan");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"plan failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let hello = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("hello"))
|
||||
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
|
||||
assert!(
|
||||
hello.contains("noop"),
|
||||
"expected hello no-op, got:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("create") && !stdout.contains("delete"),
|
||||
"fresh pull should diff clean, got:\n{stdout}"
|
||||
);
|
||||
|
||||
// Edit the script source on disk → plan must report an update.
|
||||
std::fs::write(
|
||||
dir.path().join("scripts/hello.rhai"),
|
||||
"let body = #{ ok: false }; body",
|
||||
)
|
||||
.expect("rewrite source");
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest)
|
||||
.output()
|
||||
.expect("plan after edit");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let hello = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("hello"))
|
||||
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
|
||||
assert!(
|
||||
hello.contains("update"),
|
||||
"expected hello update after source edit, got:\n{stdout}"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
177
crates/picloud-cli/tests/prune.rs
Normal file
177
crates/picloud-cli/tests/prune.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
//! `pic apply --prune` journey: a resource dropped from the manifest
|
||||
//! survives a plain (additive) apply but is deleted with `--prune`.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_deletes_stale_resources() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("prune");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
||||
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// v1: two scripts + a route on `drop`.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"drop\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/drop\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// v2: drop `drop` and its route.
|
||||
let v2 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
|
||||
// Plain apply is additive — `drop` survives.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
scripts_ls(&env, &slug).contains("drop"),
|
||||
"additive apply must not delete"
|
||||
);
|
||||
|
||||
// Prune apply removes `drop` and its route. `--yes` skips the
|
||||
// confirmation prompt (this test runs non-interactively).
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"prune failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let report = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
report.contains("-1"),
|
||||
"expected deletions in report:\n{report}"
|
||||
);
|
||||
|
||||
let s = scripts_ls(&env, &slug);
|
||||
assert!(!s.contains("drop"), "prune should delete `drop`:\n{s}");
|
||||
assert!(s.contains("keep"), "prune must keep `keep`:\n{s}");
|
||||
|
||||
// Plan is clean after prune.
|
||||
let p = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!p.contains("delete"),
|
||||
"plan should be clean after prune:\n{p}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The prune confirmation gate: `--prune` without `--yes`, run
|
||||
/// non-interactively (no TTY, as in CI), must refuse and delete nothing.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_without_yes_refuses_noninteractively() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("prune-gate");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
||||
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// Deploy two scripts, then drop one from the manifest.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
let v2 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
|
||||
// `--prune` with no `--yes` and no TTY → refuse, non-zero exit.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"prune without --yes must refuse non-interactively"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("--yes"),
|
||||
"refusal should mention --yes:\n{err}"
|
||||
);
|
||||
|
||||
// The dropped script must still be there — the gate blocked the delete.
|
||||
let s = scripts_ls(&env, &slug);
|
||||
assert!(
|
||||
s.contains("drop"),
|
||||
"refused prune must not delete anything:\n{s}"
|
||||
);
|
||||
}
|
||||
91
crates/picloud-cli/tests/pull.rs
Normal file
91
crates/picloud-cli/tests/pull.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! `pic pull` journey: stand up an app with a script, route, cron trigger,
|
||||
//! and a secret, then export it and assert the manifest + script file.
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn pull_exports_manifest_and_sources() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
|
||||
// App + script "hello" (deploy derives the name from the file stem).
|
||||
let (script_id, guard) = common::deploy_fixture(&env, "pull", "hello.rhai");
|
||||
let app = guard.slug().to_string();
|
||||
|
||||
// Route → script.
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"routes", "create", "--script", &script_id, "--path", "/hook", "--method", "POST",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Cron trigger → script.
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"triggers",
|
||||
"create-cron",
|
||||
"--app",
|
||||
&app,
|
||||
"--script",
|
||||
&script_id,
|
||||
"--schedule",
|
||||
"0 0 * * * *",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Secret (name only ends up in the manifest; value stays server-side).
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &app, "api_key"])
|
||||
.write_stdin("xyzzy")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull into a scratch dir.
|
||||
let out_dir = TempDir::new().expect("pull tempdir");
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(out_dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Manifest exists and captures every resource.
|
||||
let manifest = std::fs::read_to_string(out_dir.path().join("picloud.toml"))
|
||||
.expect("picloud.toml should be written");
|
||||
assert!(
|
||||
manifest.contains(&format!("slug = \"{app}\"")),
|
||||
"manifest missing app slug:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("name = \"hello\"") && manifest.contains("scripts/hello.rhai"),
|
||||
"manifest missing script entry:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("[[routes]]") && manifest.contains("path = \"/hook\""),
|
||||
"manifest missing route:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("[[triggers.cron]]") && manifest.contains("schedule = \"0 0 * * * *\""),
|
||||
"manifest missing cron trigger:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("api_key"),
|
||||
"manifest missing secret name:\n{manifest}"
|
||||
);
|
||||
|
||||
// Script source was written out faithfully.
|
||||
let src = std::fs::read_to_string(out_dir.path().join("scripts/hello.rhai"))
|
||||
.expect("scripts/hello.rhai should be written");
|
||||
assert!(
|
||||
src.contains("hello from pic"),
|
||||
"exported source mismatch:\n{src}"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ fn viewer_cannot_deploy_but_editor_can() {
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Created hello v1"));
|
||||
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
|
||||
@@ -7,6 +7,16 @@ use predicates::prelude::*;
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
/// Extract a field value from a `pic` KvBlock (`key<pad>\tvalue` per line).
|
||||
/// `pic scripts deploy` prints `name`/`version`/`action` rows, not a prose
|
||||
/// "Created X vN" line, so version-bump tests assert on these fields.
|
||||
fn kv_field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> {
|
||||
stdout.lines().find_map(|l| {
|
||||
let (k, v) = l.split_once('\t')?;
|
||||
(k.trim() == key).then(|| v.trim())
|
||||
})
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn deploy_against_unknown_app_errors() {
|
||||
@@ -48,7 +58,7 @@ fn deploy_with_name_override() {
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
common::pic_as(&env)
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -58,11 +68,14 @@ fn deploy_with_name_override() {
|
||||
"--name",
|
||||
"custom-name",
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Created custom-name v1"));
|
||||
.output()
|
||||
.expect("deploy v1");
|
||||
let v1 = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(kv_field(&v1, "name"), Some("custom-name"), "v1 name: {v1}");
|
||||
assert_eq!(kv_field(&v1, "version"), Some("1"), "v1 version: {v1}");
|
||||
assert_eq!(kv_field(&v1, "action"), Some("created"), "v1 action: {v1}");
|
||||
|
||||
common::pic_as(&env)
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -72,9 +85,11 @@ fn deploy_with_name_override() {
|
||||
"--name",
|
||||
"custom-name",
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Updated custom-name v2"));
|
||||
.output()
|
||||
.expect("deploy v2");
|
||||
let v2 = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(kv_field(&v2, "version"), Some("2"), "v2 version: {v2}");
|
||||
assert_eq!(kv_field(&v2, "action"), Some("updated"), "v2 action: {v2}");
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
@@ -106,8 +121,8 @@ fn deploy_bumps_version_each_redeploy() {
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
|
||||
common::pic_as(&env)
|
||||
for (version, action) in [("1", "created"), ("2", "updated"), ("3", "updated")] {
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -115,9 +130,20 @@ fn deploy_bumps_version_each_redeploy() {
|
||||
"--app",
|
||||
&slug,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(expected));
|
||||
.output()
|
||||
.expect("deploy");
|
||||
assert!(out.status.success(), "deploy v{version} failed: {out:?}");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(
|
||||
kv_field(&stdout, "version"),
|
||||
Some(version),
|
||||
"version: {stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
kv_field(&stdout, "action"),
|
||||
Some(action),
|
||||
"action: {stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user