13 Commits

Author SHA1 Message Date
MechaCat02
e375735796 docs(blueprint+gate): drop hstore from Tech Stack; note gate-vs-timeout interaction
Two review-pass nits from the v1.1.0-foundation review:

  - Blueprint §6 Tech Stack table still listed the database as
    "PostgreSQL + hstore" with an hstore-for-KV rationale — directly
    contradicting the §8.1 KV rewrite that explicitly rejected hstore
    in favour of JSONB. Updates the row so the high-level summary
    matches the §8.1 reasoning.
  - LocalExecutorClient::execute now documents the permit-vs-timeout
    interaction: when tokio::time::timeout fires the future drops and
    the permit returns, but the detached spawn_blocking thread keeps
    running until the Rhai script winds down. In-use blocking threads
    can briefly exceed the gate's permit count after a timeout. Calling
    it out so future readers don't read the implementation as buggy.

No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 20:10:05 +02:00
MechaCat02
098e18a989 chore(clippy): silence three v1.1.0-foundation lints
- sdk/bridge.rs: drop #[must_use] on the bridge fns — `Dynamic` and
    `serde_json::Value` are both #[must_use] already; the wrapper
    attribute is double-must-use noise.
  - api.rs IntoResponse: hoist `use ApiError as E;` above the early
    Overloaded branch so `E::Exec(...)` works in the if-let too
    (clippy::items_after_statements).
  - gate.rs test: bind the returned permit with `let _ =` so the
    OwnedSemaphorePermit doesn't trip unused-must-use.

No behaviour change. Caught by `cargo clippy --all-targets
--all-features -- -D warnings`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 19:00:35 +02:00
MechaCat02
9b4a834627 chore(claude-md): refresh for v1.1.0 — focus, working rules, env vars
- Current focus moves to v1.1.0 (SDK foundation + stdlib) with a
    pointer to docs/sdk-shape.md. Notes Phase 3.5 capability gating is
    shipped end-to-end.
  - Tech-stack line drops the misleading "v1.1+ hstore" mention; v1.1+
    data-plane tables now use JSONB (see blueprint §8.1).
  - New Working Rules bullet for the handle pattern + SdkCallCx rule:
    services derive app_id from cx.app_id, never from a script-passed
    arg. That is the cross-app isolation boundary.
  - New "Runtime configuration" table documenting every env var the
    picloud binary consumes — including the new
    PICLOUD_MAX_CONCURRENT_EXECUTIONS alongside the existing
    PICLOUD_BIND, DATABASE_URL, session TTL, and sandbox knobs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:58:29 +02:00
MechaCat02
5302bd3192 docs(sdk): SDK-shape reference + blueprint updates for v1.1.x
Lands the developer-facing reference for the SDK shape every v1.1.x
service implements against, plus the blueprint changes the shape and
the recently-shipped Phase 3.5 imply:

  - New docs/sdk-shape.md — covers handle pattern, :: namespace,
    throw/() error convention, sync↔async bridge, cross-app isolation
    rule, ServiceEventEmitter, ExecutionGate + env var, stateless vs
    stateful module registration.
  - Blueprint §11.6 (Phase 3.5): Pending → ✓ Shipped, with a note that
    it landed ahead of the originally planned slot.
  - Blueprint §8.1 (KV Store): replace hstore schema + rationale with
    JSONB. PK becomes (app_id, collection, key); cross-app isolation
    is enforced at the index, not just the service layer. Note 64 KiB
    per-value cap enforced at the service layer (lands with the KV PR
    in v1.1.1).
  - Blueprint new §7.5 (SDK Architecture): brief overview pointing to
    docs/sdk-shape.md. Includes §7.5.1 sketch of the trigger
    architecture (outbox + depth limit + (service, event, filter) →
    script).
  - Blueprint §12 Phase 4: restructured to enumerate v1.1.0 through
    v1.1.8 with one focused capability per release. Current focus
    moves to Phase 4 (v1.1.0) now that Phase 3.5 is done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:57:44 +02:00
MechaCat02
902dd78027 feat(picloud): opportunistic principal middleware on the data plane
The data-plane (POST /execute/{id} + user-route fallback) is
unauthenticated by default — public scripts get hit by anonymous HTTP
traffic. But some calls are authed (dashboard test-runs, API-key
invocations) and v1.1.x services will want to see the caller via
`cx.principal` for audit / authz once those features land.

  - New manager-core::attach_principal_if_present middleware. Always
    inserts Extension<Option<Principal>>: Some on resolved bearer/cookie,
    None on absent or malformed token. Fail-open on DB blip so a
    transient infra failure can't 500 anonymous traffic.
  - Wired in picloud build_app, scoped to the data-plane and user-routes
    routers only. The admin path keeps using require_authenticated; no
    double-resolve on the same token.
  - orchestrator-core handlers (execute_by_id, user_route_handler) now
    extract Extension<Option<Principal>> and pass it to build_exec_request.
    Replaces the temporary `None` placeholders from the previous commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:53:27 +02:00
MechaCat02
dea776b2a3 feat(orchestrator-core): ExecutionGate + 503/Retry-After on overflow
Adds a single global concurrency cap on the data-plane dispatch path:

  - orchestrator-core::gate::ExecutionGate wraps tokio::Semaphore.
    Non-blocking try_acquire — no queue. PICLOUD_MAX_CONCURRENT_EXECUTIONS
    env var (default 32) sets the cap.
  - LocalExecutorClient acquires a permit before spawn_blocking; the
    permit drops with the future so the slot returns automatically.
  - On refusal, ExecError::Overloaded { retry_after_secs: 1 } surfaces
    upward. ApiError::IntoResponse already maps that to 503 with a
    Retry-After header (landed in the previous commit alongside the
    variant itself).
  - picloud binary constructs the gate once at build_app and shares it
    with LocalExecutorClient.

The cap exists so a Rhai script storm can't drain the blocking-thread
pool — pushing back hard beats letting requests pile up against a
finite worker count. Per-app / per-script caps stay deferred until a
real workload demands them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:50:44 +02:00
MechaCat02
fe1dd90836 feat(executor-core): plumb app_id/principal/depth through ExecRequest
Adds the four internal-only fields every v1.1.x stateful service needs
to isolate by app and audit by caller:

  - app_id            — owning app for this invocation
  - principal         — Option<Principal>; data-plane is unauthenticated
                        today so the orchestrator passes None until the
                        opportunistic middleware lands in the next commit
  - trigger_depth     — 0 for direct invocations; the triggers framework
                        (v1.1.1) bounds runaway feedback loops via this
  - root_execution_id — equal to execution_id for direct invocations;
                        preserved across trigger fan-out for audit grouping

ExecRequest stays serializable (cluster mode still has to ship it across
processes when v1.3+ arrives). principal is `#[serde(skip)]` because
shared::Principal has no wire derivation today — when cluster mode lands
the wire-Principal question gets revisited properly.

Engine now carries a Services bundle (empty in v1.1.0). Engine::execute
constructs an SdkCallCx from the request and hands it to sdk::register_all
just after the per-call Rhai engine is built. The hook is a no-op in v1.1.0;
v1.1.1 KV registers its first native fns there.

Adds ExecError::Overloaded { retry_after_secs } and the matching 503 +
Retry-After mapping in orchestrator-core's IntoResponse. The gate that
actually produces this variant lands in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:48:39 +02:00
MechaCat02
aaba58dee1 refactor(executor-core): extract sdk/ module + move json↔dynamic bridge
Hoist the json_to_dynamic / dynamic_to_json helpers out of engine.rs
into a new sdk/bridge.rs so the v1.1.x service modules (KV, docs, …)
can use them without engine.rs being the sole owner. No behavioural
change — the sdk_contract round-trip test pins the observable JSON
fidelity.

Also lands the structural shape that subsequent v1.1.x PRs hook into:

  - sdk::register_all(engine, services, cx) — single per-call hook
    every stateful service registers through. Body is a no-op for
    v1.1.0; SdkCallCx construction inside Engine::execute lands in
    the next commit alongside the new ExecRequest fields it reads.
  - sdk::cx re-exports picloud_shared::SdkCallCx so SDK callers don't
    cross-import shared for one type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:43:03 +02:00
MechaCat02
2669714a51 feat(shared): SdkCallCx, Services bundle, ServiceEventEmitter trait shape
Foundation for the v1.1.x stateful SDK services. Lands the shape only:

  - SdkCallCx — per-call context plumbed into every future service
    trait method (app_id, principal, execution/request ids, trigger
    depth slots).
  - Services — empty non_exhaustive bundle; v1.1.1 (KV) adds the first
    field, subsequent PRs follow.
  - ServiceEventEmitter — async trait future services emit through;
    real outbox-backed impl lands with triggers in v1.1.1. NoopEventEmitter
    is the v1.1.0 default.

No behaviour change. Subsequent commits in this PR plumb these types
through executor-core and the orchestrator dispatch path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:40:09 +02:00
MechaCat02
662d5a2cf8 Merge branch 'test/cli-journeys'
Refactors the bare-metal CLI e2e into focused journey modules sharing
one `LazyLock<Fixture>` server (mirrors the dashboard Playwright
suite's spawn-once shape), and folds in the comprehensive review-pass
fixes on top:

* `pic login` is now real auth — username + password POST'd to
  `/auth/login`. `--token` / `PICLOUD_TOKEN` keep the paste-a-bearer
  path for CI and API keys.
* `pic logout`, `pic apps delete|show`, `pic scripts delete`,
  `pic api-keys mint|ls|rm`, top-level `pic invoke` / `pic deploy`.
* `PICLOUD_URL` / `PICLOUD_TOKEN` override the on-disk creds file
  globally (gcloud/aws semantics), not just for `pic login`.
* Global `--output tsv|json` flag.
* `pic scripts ls` (no `--app`) collapses the N+1 per-app walk that
  aborted on the first 404 into a single `GET /admin/scripts` plus
  one parallel `apps_list`. Drops the 5× retry the test suite was
  carrying around it.
* HTTP-4xx asserts tightened to specific codes (422/404/403). The
  old loose `"HTTP 4"` predicates would have masked a regressed 401
  from broken auth.
* Redundant `tests/integration.rs` deleted — every step it covered
  lives in one of the focused modules.

All endpoints touched on the server side already existed before this
branch — no `manager-core` change here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:35:11 +02:00
MechaCat02
fc8d473416 Merge branch 'feat/cli'
Lands the `pic` command-line client: `pic login | whoami | apps
ls/create | scripts ls/deploy/invoke | logs`. Thin wrapper over the
existing admin + execute HTTP surface — no new server endpoints
introduced by this branch.

See `crates/picloud-cli/` for the binary and its bare-metal e2e
test. The follow-up `test/cli-journeys` branch refactors that test
into focused journey modules and extends the CLI with login/logout,
delete commands, api-keys, and JSON output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:34:53 +02:00
MechaCat02
c73e3c80c0 test(cli): focused journey suite + cover new commands + tighten asserts
Replace the single bare-metal `integration.rs` test with focused
modules driven by the shared `LazyLock<Fixture>` server. Each module
owns one journey:

* `auth.rs` — login (both bearer and username+password paths),
  logout (local file + server-side session invalidation), env-vars
  overriding the on-disk credentials file, role-label rendering.
* `apps.rs` — create / ls / show / delete (with and without
  `--force`), invalid-slug rejection, conflict on duplicate slug.
* `scripts.rs` — deploy (create + update), name override, version
  bumping, `ls` (with and without `--app`), delete.
* `invoke.rs` — body sources (inline, `@file`, `@-`), header
  propagation, non-2xx exit semantics, top-level `pic invoke` alias.
* `logs.rs` — emptiness, status labels, `--limit`, summary truncation.
* `roles.rs` — Member RBAC: app-list filtering, viewer-vs-editor on
  deploy, member can hit the unguarded data plane, non-member 403
  on logs.
* `output.rs` — TSV column headers, stdout/stderr separation, RFC3339
  shape, and the `--output json` invariants for apps / scripts /
  logs / whoami.
* `api_keys.rs` — mint emits `raw_token` once, `ls` omits it, the
  minted token works as a real bearer, `rm` invalidates server-side.

Bug-bug-fix-bug-fix:

* The 5× retry loop in `ls_without_app_walks_every_accessible_app`
  was masking the abort-on-first-404 walk in the CLI. Now that the
  CLI uses a single server call, the retry is gone — the test runs
  one `pic scripts ls` and asserts.
* Six `predicate::str::contains("HTTP 4")` assertions tightened to
  the specific status code: 422 for invalid-slug, 404 for unknown
  app/script/log id, 403 for role denials. Loose `HTTP 4` would
  have silently matched a regressed 401 from broken auth.
* `tests/integration.rs` deleted — every step it covered is in one
  of the focused modules above.
* Members module exposes `MEMBER_PASSWORD` so auth tests can drive
  the real username+password flow over stdin.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:34:03 +02:00
MechaCat02
f147665157 feat(cli): real auth, delete commands, api-keys, JSON output, env override
Address the review findings on the CLI surface:

* `pic login` now prompts for username + password and POSTs to
  `/api/v1/admin/auth/login`. `--token` (and `PICLOUD_TOKEN`) still
  works for paste-a-bearer flows (CI, long-lived API keys). Falls
  back to a plain stdin read when no controlling tty is attached.
* `pic logout` revokes the session server-side and deletes the local
  credentials file. Idempotent.
* `PICLOUD_URL` / `PICLOUD_TOKEN` now override the on-disk credentials
  file for every command via `config::resolve`, not just for
  `pic login`. Matches gcloud/aws/kubectl semantics.
* New commands: `pic apps delete [--force]`, `pic apps show`,
  `pic scripts delete`, `pic api-keys mint|ls|rm`, plus top-level
  `pic invoke` / `pic deploy` shortcuts.
* `pic scripts ls` (no `--app`) now issues a single
  `GET /admin/scripts` + one `apps_list` in parallel and joins
  client-side, instead of walking N+1 per-app calls that aborted on
  the first 404 — the bug the test suite was retrying around.
* Global `--output tsv|json` flag wired through every list/show and
  through `whoami` / `logs`. TSV stays pipe-friendly; JSON is a real
  array of objects (or a flat object for single-row views).
* `whoami` and `logs` now emit labeled output instead of headerless
  tab lines, consistent with the existing `apps ls` / `scripts ls`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 23:33:44 +02:00
52 changed files with 3881 additions and 416 deletions

View File

@@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint. 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):** data-plane SDKs — KV store, then document store, then HTTP client, then cron triggers. See blueprint §12. Phase 3 (admin auth + multi-app scoping) shipped; 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. **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). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). Every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
## Three-Service Architecture ## Three-Service Architecture
@@ -48,7 +48,7 @@ Caddy fronts everything. Same Caddyfile shape works for single-node and cluster
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml` - **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
- **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres - **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres
- **Rhai** embedded scripting (in `executor-core`) - **Rhai** embedded scripting (in `executor-core`)
- **PostgreSQL 15+** with `pgcrypto` and (v1.1+) `hstore` - **PostgreSQL 15+** with `pgcrypto`. v1.1+ data-plane tables use JSONB for value columns (hstore was considered for KV and rejected — see blueprint §8.1).
- **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor - **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor
- **Caddy 2** reverse proxy (auto-HTTPS in prod) - **Caddy 2** reverse proxy (auto-HTTPS in prod)
- **Docker Compose** for dev and single-node prod - **Docker Compose** for dev and single-node prod
@@ -103,9 +103,22 @@ docs/
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl. - **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits. - **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB. - **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
- **Stateful SDK services use the handle pattern + `SdkCallCx`.** Collection-scoped surfaces look like `kv::collection("x").get(k)`, not `kv::get("x", k)`. Every service trait method takes `&SdkCallCx` and **MUST** derive `app_id` from `cx.app_id` — never trust a script-passed `app_id`. That is the cross-app isolation boundary. See [docs/sdk-shape.md](docs/sdk-shape.md).
- **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented. - **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented.
- **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work. - **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work.
## Runtime configuration
Environment variables consumed by the `picloud` binary:
| Variable | Default | Purpose |
|---|---|---|
| `PICLOUD_BIND` | `0.0.0.0:8080` | HTTP listen address. Port 8080 is owned by another process on this host — override locally. |
| `PICLOUD_MAX_CONCURRENT_EXECUTIONS` | `32` | Global concurrency cap on data-plane script executions. Overflow returns HTTP 503 with `Retry-After: 1` immediately (no queue). |
| `DATABASE_URL` | — | Required. Postgres connection string. |
| `PICLOUD_SESSION_TTL_HOURS` | `24` | Sliding-window session lifetime. |
| `PICLOUD_SANDBOX_MAX_*` | conservative defaults | Per-knob admin ceilings on Rhai sandbox overrides. See `manager-core::sandbox::SandboxCeiling`. |
## Out of MVP ## Out of MVP
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either. Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.

1
Cargo.lock generated
View File

@@ -1535,6 +1535,7 @@ version = "0.6.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"assert_cmd", "assert_cmd",
"chrono",
"clap", "clap",
"directories", "directories",
"libc", "libc",

View File

@@ -3,30 +3,38 @@ use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use chrono::Utc; use chrono::Utc;
use picloud_shared::{ScriptValidator, ValidationError, SDK_VERSION}; use picloud_shared::{ScriptValidator, SdkCallCx, Services, ValidationError, SDK_VERSION};
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope}; use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope};
use serde_json::Value as Json; use serde_json::Value as Json;
use crate::sandbox::Limits; use crate::sandbox::Limits;
use crate::sdk;
use crate::sdk::bridge::{dynamic_to_json, json_to_dynamic};
use crate::types::{ use crate::types::{
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel, ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
}; };
/// Preconfigured Rhai engine with sandbox limits applied. /// Preconfigured Rhai engine with sandbox limits applied and the SDK
/// `Services` bundle attached.
/// ///
/// One `Engine` is constructed at process startup and reused across /// One `Engine` is constructed at process startup and reused across
/// invocations. `execute` is **synchronous** — it owns the per-call /// invocations. `execute` is **synchronous** — it owns the per-call
/// scope and log buffer. Wall-clock timeouts and offloading off the /// scope and log buffer. Wall-clock timeouts and offloading off the
/// async runtime belong to the caller (orchestrator-core's /// async runtime belong to the caller (orchestrator-core's
/// `LocalExecutorClient` wraps this with `spawn_blocking` + `timeout`). /// `LocalExecutorClient` wraps this with `spawn_blocking` + `timeout`).
///
/// The `Services` bundle is empty in v1.1.0; subsequent v1.1.x PRs add
/// service handles (KV, docs, …) and `sdk::register_all` wires them
/// into each per-call Rhai engine.
pub struct Engine { pub struct Engine {
limits: Limits, limits: Limits,
services: Services,
} }
impl Engine { impl Engine {
#[must_use] #[must_use]
pub fn new(limits: Limits) -> Self { pub fn new(limits: Limits, services: Services) -> Self {
Self { limits } Self { limits, services }
} }
#[must_use] #[must_use]
@@ -55,7 +63,20 @@ impl Engine {
pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> { pub fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
let effective_limits = self.limits.with_overrides(&req.sandbox_overrides); let effective_limits = self.limits.with_overrides(&req.sandbox_overrides);
let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new())); let logs: Arc<Mutex<Vec<LogEntry>>> = Arc::new(Mutex::new(Vec::new()));
let engine = build_engine(effective_limits, Some(logs.clone())); let mut engine = build_engine(effective_limits, Some(logs.clone()));
// Per-call context handed to every stateful SDK service via the
// `sdk::register_all` hook. The Arc lets future service closures
// capture cheap clones of the cx for use at script-call time.
let cx = Arc::new(SdkCallCx {
app_id: req.app_id,
principal: req.principal.clone(),
execution_id: req.execution_id,
request_id: req.request_id,
trigger_depth: req.trigger_depth,
root_execution_id: req.root_execution_id,
});
sdk::register_all(&mut engine, &self.services, cx);
let ast = engine let ast = engine
.compile(source) .compile(source)
@@ -265,69 +286,6 @@ fn parse_structured_response(map: Map) -> Result<(u16, BTreeMap<String, String>,
Ok((status_code, headers, body)) Ok((status_code, headers, body))
} }
// ----------------------------------------------------------------------------
// Rhai ↔ serde_json bridges
// ----------------------------------------------------------------------------
fn json_to_dynamic(value: Json) -> Dynamic {
match value {
Json::Null => Dynamic::UNIT,
Json::Bool(b) => b.into(),
Json::Number(n) => {
if let Some(i) = n.as_i64() {
i.into()
} else if let Some(f) = n.as_f64() {
f.into()
} else {
n.to_string().into()
}
}
Json::String(s) => s.into(),
Json::Array(arr) => arr
.into_iter()
.map(json_to_dynamic)
.collect::<Vec<Dynamic>>()
.into(),
Json::Object(obj) => {
let mut m = Map::new();
for (k, v) in obj {
m.insert(k.into(), json_to_dynamic(v));
}
Dynamic::from(m)
}
}
}
fn dynamic_to_json(value: &Dynamic) -> Json {
if value.is_unit() {
return Json::Null;
}
if let Ok(b) = value.as_bool() {
return Json::Bool(b);
}
if let Ok(i) = value.as_int() {
return Json::Number(i.into());
}
if let Ok(f) = value.as_float() {
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
}
if value.is_string() {
return Json::String(value.clone().into_string().unwrap_or_default());
}
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
return Json::Array(arr.iter().map(dynamic_to_json).collect());
}
if let Some(map) = value.clone().try_cast::<Map>() {
let mut out = serde_json::Map::new();
for (k, v) in map {
out.insert(k.to_string(), dynamic_to_json(&v));
}
return Json::Object(out);
}
// Anything else (timestamps, custom types) — best-effort string form.
Json::String(value.to_string())
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
// Error mapping // Error mapping
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------

View File

@@ -8,6 +8,7 @@ pub mod context;
pub mod engine; pub mod engine;
pub mod logging; pub mod logging;
pub mod sandbox; pub mod sandbox;
pub mod sdk;
pub mod types; pub mod types;
pub use engine::Engine; pub use engine::Engine;

View File

@@ -0,0 +1,77 @@
//! JSON ↔ Rhai `Dynamic` value bridge.
//!
//! Originally inline in `engine.rs`; moved here for v1.1.0 so future
//! service modules (KV in v1.1.1, docs in v1.1.2, …) can convert
//! values without `engine.rs` being the only owner of the conversions.
//! Behaviour is unchanged from the pre-extraction implementation —
//! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the
//! observable round-trip.
use rhai::{Dynamic, Map};
use serde_json::Value as Json;
/// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for
/// pushing into a script's scope. Numbers prefer the narrowest type
/// (`i64` over `f64`); anything that can't round-trip falls back to a
/// string so the script always sees a defined value.
pub fn json_to_dynamic(value: Json) -> Dynamic {
match value {
Json::Null => Dynamic::UNIT,
Json::Bool(b) => b.into(),
Json::Number(n) => {
if let Some(i) = n.as_i64() {
i.into()
} else if let Some(f) = n.as_f64() {
f.into()
} else {
n.to_string().into()
}
}
Json::String(s) => s.into(),
Json::Array(arr) => arr
.into_iter()
.map(json_to_dynamic)
.collect::<Vec<Dynamic>>()
.into(),
Json::Object(obj) => {
let mut m = Map::new();
for (k, v) in obj {
m.insert(k.into(), json_to_dynamic(v));
}
Dynamic::from(m)
}
}
}
/// Convert a Rhai `Dynamic` back to a `serde_json::Value`. Custom Rhai
/// types (timestamps, user-registered modules) fall back to their
/// `Display` form so they appear as strings in JSON output rather than
/// failing the response build.
pub fn dynamic_to_json(value: &Dynamic) -> Json {
if value.is_unit() {
return Json::Null;
}
if let Ok(b) = value.as_bool() {
return Json::Bool(b);
}
if let Ok(i) = value.as_int() {
return Json::Number(i.into());
}
if let Ok(f) = value.as_float() {
return serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number);
}
if value.is_string() {
return Json::String(value.clone().into_string().unwrap_or_default());
}
if let Some(arr) = value.clone().try_cast::<rhai::Array>() {
return Json::Array(arr.iter().map(dynamic_to_json).collect());
}
if let Some(map) = value.clone().try_cast::<Map>() {
let mut out = serde_json::Map::new();
for (k, v) in map {
out.insert(k.to_string(), dynamic_to_json(&v));
}
return Json::Object(out);
}
Json::String(value.to_string())
}

View File

@@ -0,0 +1,10 @@
//! Re-export of `picloud_shared::SdkCallCx`.
//!
//! The type itself lives in `picloud-shared` because future stateful
//! service impls live in `manager-core` (which `executor-core` must
//! not depend on) and need to reference the same cx shape. This
//! re-export lets executor-side code write
//! `use picloud_executor_core::sdk::SdkCallCx;` instead of reaching
//! into `picloud_shared` for one type.
pub use picloud_shared::SdkCallCx;

View File

@@ -0,0 +1,39 @@
//! SDK plumbing — types and the per-call registration entry point.
//!
//! `executor-core` is responsible for building the per-invocation Rhai
//! engine and wiring stateful services into it. v1.1.0 ships the
//! shapes (`Services` bundle, `SdkCallCx`, `register_all` entry point)
//! but no actual services — subsequent v1.1.x PRs (KV in v1.1.1,
//! docs in v1.1.2, …) extend `register_all` rather than re-threading
//! plumbing through `engine.rs`.
//!
//! Bridge functions (`json_to_dynamic` / `dynamic_to_json`) also live
//! here so service modules can convert values without `engine.rs`
//! being the only home for the conversion logic.
pub mod bridge;
pub mod cx;
pub use bridge::{dynamic_to_json, json_to_dynamic};
pub use cx::SdkCallCx;
use std::sync::Arc;
use picloud_shared::Services;
use rhai::Engine as RhaiEngine;
/// Single hook every v1.1.x stateful service registers into. Called
/// once per invocation, just after `build_engine` constructs the
/// sandboxed Rhai engine and just before script compilation.
///
/// v1.1.0 ships an intentionally empty body — the call site exists so
/// future PRs (KV first) drop their registration logic here rather
/// than reaching into `engine.rs::build_engine`. The signature is
/// locked: subsequent PRs MUST keep the same parameter shape so that
/// hosts don't have to re-thread the plumbing.
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
// Intentionally inert in v1.1.0. The unused-suppression below is a
// load-bearing placeholder: future PRs replace this `let _` with
// real `register_kv(engine, services, cx.clone())` calls etc.
let _ = (engine, services, cx);
}

View File

@@ -1,7 +1,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox}; use picloud_shared::{AppId, ExecutionId, Principal, RequestId, ScriptId, ScriptSandbox};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
@@ -50,6 +50,35 @@ pub struct ExecRequest {
/// override) before the Rhai engine is built. /// override) before the Rhai engine is built.
#[serde(default)] #[serde(default)]
pub sandbox_overrides: ScriptSandbox, pub sandbox_overrides: ScriptSandbox,
/// Owning application. Source of truth for every `(app_id, …)`
/// storage lookup the script makes via stateful SDK services.
/// Internal-only; not surfaced via `ctx` (which the script sees).
pub app_id: AppId,
/// Caller identity, when authenticated. `None` for unauthenticated
/// data-plane HTTP requests (the common case for public scripts);
/// `Some` when a bearer token or session cookie was resolved.
/// Internal-only — exposed via `SdkCallCx` to service trait impls.
///
/// `#[serde(skip)]`: `ExecRequest` is serializable so cluster mode
/// (v1.3+) can ship invocations to remote executors over HTTP, but
/// `Principal` has no wire derivation today. Skipping here keeps
/// v1.1.0 compiling; the cluster-mode PR will introduce a wire-safe
/// snapshot then.
#[serde(skip)]
pub principal: Option<Principal>,
/// Triggers-framework depth. `0` for direct invocations. The
/// dispatcher (v1.1.1) increments on each indirection to bound
/// runaway feedback loops.
#[serde(default)]
pub trigger_depth: u32,
/// Originating execution id of a trigger chain. Equal to
/// `execution_id` for direct invocations; preserves the root
/// across fan-out for audit log grouping.
pub root_execution_id: ExecutionId,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -100,4 +129,11 @@ pub enum ExecError {
#[error("script runtime error: {0}")] #[error("script runtime error: {0}")]
Runtime(String), Runtime(String),
/// Concurrency gate (orchestrator-core::ExecutionGate) refused
/// admission. Surfaced as HTTP 503 with a `Retry-After` header.
/// The gate enforces a global cap so a script storm can't park
/// every blocking thread.
#[error("execution declined: server at capacity (retry after {retry_after_secs}s)")]
Overloaded { retry_after_secs: u32 },
} }

View File

@@ -1,12 +1,13 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel}; use picloud_executor_core::{Engine, ExecError, ExecRequest, InvocationType, Limits, LogLevel};
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox}; use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
use serde_json::json; use serde_json::json;
fn req(body: serde_json::Value) -> ExecRequest { fn req(body: serde_json::Value) -> ExecRequest {
let execution_id = ExecutionId::new();
ExecRequest { ExecRequest {
execution_id: ExecutionId::new(), execution_id,
request_id: RequestId::new(), request_id: RequestId::new(),
script_id: ScriptId::new(), script_id: ScriptId::new(),
script_name: "test".into(), script_name: "test".into(),
@@ -18,11 +19,15 @@ fn req(body: serde_json::Value) -> ExecRequest {
query: BTreeMap::new(), query: BTreeMap::new(),
rest: String::new(), rest: String::new(),
sandbox_overrides: ScriptSandbox::default(), sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
} }
} }
fn engine() -> Engine { fn engine() -> Engine {
Engine::new(Limits::default()) Engine::new(Limits::default(), Services::new())
} }
#[test] #[test]
@@ -121,7 +126,7 @@ fn enforces_operation_budget() {
max_operations: 1_000, max_operations: 1_000,
..Limits::default() ..Limits::default()
}; };
let engine = Engine::new(limits); let engine = Engine::new(limits, Services::new());
// 10_000 iterations vastly exceeds 1_000 ops. // 10_000 iterations vastly exceeds 1_000 ops.
let src = r"let n = 0; for i in 0..10000 { n += 1; } n"; let src = r"let n = 0; for i in 0..10000 { n += 1; } n";
let err = engine let err = engine

View File

@@ -23,7 +23,7 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel}; use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits, LogLevel};
use picloud_shared::{ExecutionId, RequestId, ScriptId, ScriptSandbox}; use picloud_shared::{AppId, ExecutionId, RequestId, ScriptId, ScriptSandbox, Services};
use serde_json::{json, Value}; use serde_json::{json, Value};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@@ -31,12 +31,13 @@ use serde_json::{json, Value};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
fn engine() -> Engine { fn engine() -> Engine {
Engine::new(Limits::default()) Engine::new(Limits::default(), Services::new())
} }
fn baseline_request() -> ExecRequest { fn baseline_request() -> ExecRequest {
let execution_id = ExecutionId::new();
ExecRequest { ExecRequest {
execution_id: ExecutionId::new(), execution_id,
request_id: RequestId::new(), request_id: RequestId::new(),
script_id: ScriptId::new(), script_id: ScriptId::new(),
script_name: "contract".into(), script_name: "contract".into(),
@@ -48,6 +49,10 @@ fn baseline_request() -> ExecRequest {
query: BTreeMap::new(), query: BTreeMap::new(),
rest: String::new(), rest: String::new(),
sandbox_overrides: ScriptSandbox::default(), sandbox_overrides: ScriptSandbox::default(),
app_id: AppId::new(),
principal: None,
trigger_depth: 0,
root_execution_id: execution_id,
} }
} }

View File

@@ -100,6 +100,35 @@ pub async fn require_admin(state: State<AuthState>, req: Request<Body>, next: Ne
require_authenticated(state, req, next).await require_authenticated(state, req, next).await
} }
/// Opportunistic data-plane variant: always inserts an
/// `Extension<Option<Principal>>` and forwards the request. Used on
/// `/execute/{id}` and the user-route fallback, where most invocations
/// are anonymous public HTTP and the few authed ones (dashboard
/// test-runs, API keys) should still let scripts see the caller via
/// `cx.principal` once services consume it.
///
/// Failure modes — all degrade to `None` rather than rejecting:
/// * No bearer / cookie → `None`.
/// * Malformed or unknown token → `None`.
/// * DB blip while resolving → `None` (fail-open; the data plane
/// should not 500 on transient infra failures for an *optional*
/// identity check).
///
/// Admin-side routes that REQUIRE an identity keep using
/// `require_authenticated`.
pub async fn attach_principal_if_present(
State(state): State<AuthState>,
mut req: Request<Body>,
next: Next,
) -> Response {
let principal: Option<Principal> = match extract_token(&req) {
Some(token) => resolve_principal(&state, &token).await.unwrap_or(None),
None => None,
};
req.extensions_mut().insert(principal);
next.run(req).await
}
/// Decide whether the token is an API key (pic_ prefix) or a session /// Decide whether the token is an API key (pic_ prefix) or a session
/// token, then resolve the corresponding `Principal`. `Ok(None)` /// token, then resolve the corresponding `Principal`. `Ok(None)`
/// means the token was structurally valid but didn't match any active /// means the token was structurally valid but didn't match any active

View File

@@ -59,8 +59,8 @@ pub use auth_bootstrap::{
}; };
#[allow(deprecated)] #[allow(deprecated)]
pub use auth_middleware::{ pub use auth_middleware::{
require_admin, require_authenticated, AuthState, AuthedAdmin, API_KEY_PREFIX, attach_principal_if_present, require_admin, require_authenticated, AuthState, AuthedAdmin,
API_KEY_PREFIX_LEN, SESSION_COOKIE, API_KEY_PREFIX, API_KEY_PREFIX_LEN, SESSION_COOKIE,
}; };
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision}; pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
pub use log_sink::PostgresExecutionLogSink; pub use log_sink::PostgresExecutionLogSink;

View File

@@ -12,12 +12,13 @@ use axum::{
http::{HeaderMap, HeaderName, HeaderValue, StatusCode}, http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::post, routing::post,
Json, Router, Extension, Json, Router,
}; };
use chrono::Utc; use chrono::Utc;
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType}; use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
use picloud_shared::{ use picloud_shared::{
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId, AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, Principal, RequestId,
ScriptId,
}; };
use serde_json::Value as Json_; use serde_json::Value as Json_;
use uuid::Uuid; use uuid::Uuid;
@@ -54,6 +55,11 @@ impl<E, R> Clone for DataPlaneState<E, R> {
/// Build the data-plane router. Handles `POST /execute/:id` — the /// Build the data-plane router. Handles `POST /execute/:id` — the
/// always-available ID-based bypass. /// always-available ID-based bypass.
///
/// Handlers expect an `Extension<Option<Principal>>` to be attached by
/// upstream middleware (`manager-core::attach_principal_if_present`);
/// requests without that extension panic at extraction time. The
/// picloud binary wires this in `build_app`.
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
where where
E: ExecutorClient + 'static, E: ExecutorClient + 'static,
@@ -67,6 +73,10 @@ where
/// Build a router that handles ALL paths via the user-defined routing /// Build a router that handles ALL paths via the user-defined routing
/// table. Intended to be merged into the picloud app router as a /// table. Intended to be merged into the picloud app router as a
/// fallback (after the system routes are mounted). /// fallback (after the system routes are mounted).
///
/// Same middleware expectation as `data_plane_router` — wrap with
/// `attach_principal_if_present` so handlers can extract
/// `Extension<Option<Principal>>`.
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
where where
E: ExecutorClient + 'static, E: ExecutorClient + 'static,
@@ -84,6 +94,7 @@ where
async fn execute_by_id<E, R>( async fn execute_by_id<E, R>(
State(state): State<DataPlaneState<E, R>>, State(state): State<DataPlaneState<E, R>>,
Path(id): Path<ScriptId>, Path(id): Path<ScriptId>,
Extension(principal): Extension<Option<Principal>>,
headers: HeaderMap, headers: HeaderMap,
body: Bytes, body: Bytes,
) -> Result<Response, ApiError> ) -> Result<Response, ApiError>
@@ -97,7 +108,7 @@ where
.await? .await?
.ok_or(ApiError::NotFound(id))?; .ok_or(ApiError::NotFound(id))?;
let mut req = build_exec_request(id, &script.name, &headers, &body)?; let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
req.sandbox_overrides = script.sandbox; req.sandbox_overrides = script.sandbox;
let request_id = req.request_id; let request_id = req.request_id;
let request_path = req.path.clone(); let request_path = req.path.clone();
@@ -133,6 +144,7 @@ where
async fn user_route_handler<E, R>( async fn user_route_handler<E, R>(
State(state): State<DataPlaneState<E, R>>, State(state): State<DataPlaneState<E, R>>,
Extension(principal): Extension<Option<Principal>>,
request: Request, request: Request,
) -> Result<Response, ApiError> ) -> Result<Response, ApiError>
where where
@@ -195,6 +207,8 @@ where
&script.name, &script.name,
&headers, &headers,
&body_bytes, &body_bytes,
app_id,
principal,
)?; )?;
req.path = path; req.path = path;
req.params = matched.params; req.params = matched.params;
@@ -264,6 +278,8 @@ fn build_exec_request(
name: &str, name: &str,
headers: &HeaderMap, headers: &HeaderMap,
body: &Bytes, body: &Bytes,
app_id: AppId,
principal: Option<Principal>,
) -> Result<ExecRequest, ApiError> { ) -> Result<ExecRequest, ApiError> {
let mut hmap = BTreeMap::new(); let mut hmap = BTreeMap::new();
for (k, v) in headers { for (k, v) in headers {
@@ -279,8 +295,9 @@ fn build_exec_request(
.map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))? .map_err(|e| ApiError::BadRequest(format!("invalid JSON body: {e}")))?
}; };
let execution_id = ExecutionId::new();
Ok(ExecRequest { Ok(ExecRequest {
execution_id: ExecutionId::new(), execution_id,
request_id: RequestId::new(), request_id: RequestId::new(),
script_id: id, script_id: id,
script_name: name.to_string(), script_name: name.to_string(),
@@ -293,6 +310,13 @@ fn build_exec_request(
rest: String::new(), rest: String::new(),
// Overwritten by the handler after the script is resolved. // Overwritten by the handler after the script is resolved.
sandbox_overrides: picloud_shared::ScriptSandbox::default(), sandbox_overrides: picloud_shared::ScriptSandbox::default(),
app_id,
principal,
// Direct invocations are at depth 0 with a self-referential
// root. The triggers framework (v1.1.1) increments depth and
// preserves the original root for chained executions.
trigger_depth: 0,
root_execution_id: execution_id,
}) })
} }
@@ -396,7 +420,22 @@ pub enum ApiError {
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
// Overloaded is the only variant that needs to attach an HTTP
// header (Retry-After), so it short-circuits the (status, body)
// reduction below. Axum's tuple builder makes per-arm header
// injection awkward otherwise.
use ApiError as E; use ApiError as E;
if let E::Exec(ExecError::Overloaded { retry_after_secs }) = &self {
let retry = retry_after_secs.to_string();
let body = Json(serde_json::json!({ "error": self.to_string() }));
return (
StatusCode::SERVICE_UNAVAILABLE,
[(axum::http::header::RETRY_AFTER, retry)],
body,
)
.into_response();
}
let (status, message) = match &self { let (status, message) = match &self {
E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), E::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), E::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
@@ -416,6 +455,7 @@ impl IntoResponse for ApiError {
(StatusCode::INSUFFICIENT_STORAGE, e.to_string()) (StatusCode::INSUFFICIENT_STORAGE, e.to_string())
} }
ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()), ExecError::Runtime(_) => (StatusCode::BAD_GATEWAY, e.to_string()),
ExecError::Overloaded { .. } => unreachable!("handled above"),
}, },
}; };
(status, Json(serde_json::json!({ "error": message }))).into_response() (status, Json(serde_json::json!({ "error": message }))).into_response()

View File

@@ -4,6 +4,8 @@ use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecError, ExecRequest, ExecResponse}; use picloud_executor_core::{Engine, ExecError, ExecRequest, ExecResponse};
use crate::gate::{AcquireError, ExecutionGate};
/// Maximum wall-clock time we'll wait for a single invocation, regardless /// Maximum wall-clock time we'll wait for a single invocation, regardless
/// of the per-script `timeout_seconds`. Provides a hard ceiling on /// of the per-script `timeout_seconds`. Provides a hard ceiling on
/// resource usage independent of misconfigured scripts. /// resource usage independent of misconfigured scripts.
@@ -30,14 +32,19 @@ pub trait ExecutorClient: Send + Sync {
/// `executor-core::Engine::execute` is synchronous; we offload it to a /// `executor-core::Engine::execute` is synchronous; we offload it to a
/// blocking thread so it doesn't park a Tokio worker, and apply the /// blocking thread so it doesn't park a Tokio worker, and apply the
/// wall-clock timeout here. /// wall-clock timeout here.
///
/// Holds an `ExecutionGate` and acquires a permit before `spawn_blocking`
/// so a script storm can't drain the blocking-thread pool. The permit
/// drops with the future, returning the slot.
pub struct LocalExecutorClient { pub struct LocalExecutorClient {
engine: Arc<Engine>, engine: Arc<Engine>,
gate: Arc<ExecutionGate>,
} }
impl LocalExecutorClient { impl LocalExecutorClient {
#[must_use] #[must_use]
pub fn new(engine: Arc<Engine>) -> Self { pub fn new(engine: Arc<Engine>, gate: Arc<ExecutionGate>) -> Self {
Self { engine } Self { engine, gate }
} }
} }
@@ -49,6 +56,24 @@ impl ExecutorClient for LocalExecutorClient {
req: ExecRequest, req: ExecRequest,
timeout: Duration, timeout: Duration,
) -> Result<ExecResponse, ExecError> { ) -> Result<ExecResponse, ExecError> {
// Acquire before spending any wall-clock budget. The permit is
// held by this future; on `tokio::time::timeout` firing, the
// future drops and the permit returns to the pool — but the
// detached `spawn_blocking` thread keeps running until the
// Rhai script finishes (or panics). So in-use blocking threads
// can briefly exceed the gate's permit count after a timeout.
// That is intentional: a new admission can be served while the
// already-doomed script winds down, which is preferable to
// wedging the slot for the worst-case timeout duration.
let _permit =
self.gate
.try_acquire()
.map_err(
|AcquireError::Overloaded { retry_after_secs }| ExecError::Overloaded {
retry_after_secs,
},
)?;
let timeout = timeout.min(HARD_TIMEOUT_CAP); let timeout = timeout.min(HARD_TIMEOUT_CAP);
let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX); let timeout_secs = u32::try_from(timeout.as_secs()).unwrap_or(u32::MAX);

View File

@@ -0,0 +1,155 @@
//! Global concurrency gate for the data plane.
//!
//! Wraps a single `tokio::sync::Semaphore` so the executor can refuse
//! admission immediately when too many invocations are already in
//! flight. Designed for v1.1.0's single-node MVP — one cap across all
//! apps and scripts. Per-app or per-script caps come later when a real
//! workload surfaces the need.
//!
//! Policy: **non-blocking, no queue**. If a permit isn't free right
//! now, the call returns `AcquireError::Overloaded` and the data-plane
//! HTTP layer translates that to a 503 with `Retry-After: 1`. Pushing
//! back hard beats letting requests pile up against a finite pool of
//! blocking threads (executor work runs under `spawn_blocking`).
//!
//! Configured via the `PICLOUD_MAX_CONCURRENT_EXECUTIONS` env var.
//! Default is 32 — comfortable for a single-node Pi, low enough that
//! a script storm doesn't park every blocking thread.
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
/// Env var consulted by `from_env`.
pub const ENV_MAX_CONCURRENT: &str = "PICLOUD_MAX_CONCURRENT_EXECUTIONS";
/// Default cap when the env var is unset or invalid.
pub const DEFAULT_MAX_CONCURRENT: u32 = 32;
/// `Retry-After` header value (seconds) returned alongside the 503
/// when the gate refuses. Fixed for v1.1.0; later versions may compute
/// a smarter value from in-flight latency.
pub const DEFAULT_RETRY_AFTER_SECS: u32 = 1;
/// Refused admission. The HTTP layer translates this to 503 with a
/// `Retry-After` header.
#[derive(Debug, Error)]
pub enum AcquireError {
#[error("at capacity (retry after {retry_after_secs}s)")]
Overloaded { retry_after_secs: u32 },
}
/// Global execution gate. Constructed once at orchestrator startup and
/// shared via `Arc`. Holds an inner `Arc<Semaphore>` so permits are
/// owned (they release on drop independent of the gate's lifetime).
pub struct ExecutionGate {
permits: Arc<Semaphore>,
max_permits: u32,
}
impl ExecutionGate {
/// Construct with an explicit cap. Mostly for tests; production
/// uses `from_env`.
#[must_use]
pub fn new(max_permits: u32) -> Self {
Self {
permits: Arc::new(Semaphore::new(max_permits as usize)),
max_permits,
}
}
/// Read `PICLOUD_MAX_CONCURRENT_EXECUTIONS` from the environment.
/// Falls back to `DEFAULT_MAX_CONCURRENT` on absence; warns and
/// falls back on parse failure or non-positive value. Mirrors the
/// `SandboxCeiling::from_env` ergonomics so operators see a
/// consistent shape across the env-tunables.
#[must_use]
pub fn from_env() -> Self {
let max = match std::env::var(ENV_MAX_CONCURRENT) {
Err(_) => DEFAULT_MAX_CONCURRENT,
Ok(v) => match v.parse::<u32>() {
Ok(n) if n > 0 => n,
Ok(_) => {
tracing::warn!(
env = ENV_MAX_CONCURRENT,
value = %v,
"value must be > 0; using default {DEFAULT_MAX_CONCURRENT}"
);
DEFAULT_MAX_CONCURRENT
}
Err(e) => {
tracing::warn!(
env = ENV_MAX_CONCURRENT,
value = %v,
error = %e,
"invalid value; using default {DEFAULT_MAX_CONCURRENT}"
);
DEFAULT_MAX_CONCURRENT
}
},
};
Self::new(max)
}
/// Maximum concurrent permits this gate was configured for. Useful
/// for diagnostics / future metrics.
#[must_use]
pub fn max_permits(&self) -> u32 {
self.max_permits
}
/// Non-blocking permit acquisition. Returns the owned permit on
/// success (drop releases the slot) or `AcquireError::Overloaded`
/// when saturated. Sync because the semaphore's non-blocking try is
/// sync — no runtime hop needed.
pub fn try_acquire(&self) -> Result<OwnedSemaphorePermit, AcquireError> {
self.permits
.clone()
.try_acquire_owned()
.map_err(|err| match err {
TryAcquireError::NoPermits | TryAcquireError::Closed => AcquireError::Overloaded {
retry_after_secs: DEFAULT_RETRY_AFTER_SECS,
},
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acquire_succeeds_under_capacity() {
let gate = ExecutionGate::new(2);
let _p1 = gate.try_acquire().expect("first permit available");
let _p2 = gate.try_acquire().expect("second permit available");
}
#[test]
fn acquire_overloaded_when_saturated() {
let gate = ExecutionGate::new(1);
let _p = gate.try_acquire().expect("first permit available");
let AcquireError::Overloaded { retry_after_secs } = gate
.try_acquire()
.expect_err("second permit must be refused");
assert!(retry_after_secs > 0, "retry-after must be positive");
}
#[test]
fn permit_drop_releases_slot() {
let gate = ExecutionGate::new(1);
{
let _p = gate.try_acquire().expect("first permit available");
}
let _ = gate
.try_acquire()
.expect("slot must be returned after permit drops");
}
#[test]
fn max_permits_exposed() {
let gate = ExecutionGate::new(7);
assert_eq!(gate.max_permits(), 7);
}
}

View File

@@ -10,9 +10,11 @@
pub mod api; pub mod api;
pub mod client; pub mod client;
pub mod gate;
pub mod resolver; pub mod resolver;
pub mod routing; pub mod routing;
pub use api::{data_plane_router, user_routes_router, DataPlaneState}; pub use api::{data_plane_router, user_routes_router, DataPlaneState};
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient}; pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient};
pub use gate::{AcquireError, ExecutionGate};
pub use resolver::{ResolverError, ScriptResolver}; pub use resolver::{ResolverError, ScriptResolver};

View File

@@ -27,6 +27,7 @@ reqwest = { workspace = true, features = ["json"] }
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
chrono = { workspace = true }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
toml = "0.8" toml = "0.8"
directories = "5" directories = "5"

View File

@@ -8,7 +8,10 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use picloud_shared::{App, AppId, AppRole, ExecutionLog, InstanceRole, Script}; use chrono::{DateTime, Utc};
use picloud_shared::{
AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script,
};
use reqwest::{header, Method, RequestBuilder, StatusCode}; use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -38,6 +41,7 @@ impl Client {
}) })
} }
#[allow(dead_code)] // used by the trailing-slash unit test below.
pub fn url(&self) -> &str { pub fn url(&self) -> &str {
&self.url &self.url
} }
@@ -97,6 +101,42 @@ impl Client {
decode(resp).await decode(resp).await
} }
/// `GET /api/v1/admin/scripts` — every script the caller can see
/// (server filters by membership for `Member`). Lets `pic scripts ls`
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
/// single request that can't be partially-broken by a concurrent app
/// delete.
pub async fn scripts_list_all(&self) -> Result<Vec<Script>> {
let resp = self
.request(Method::GET, "/api/v1/admin/scripts")
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}` with optional `?force=true`.
/// Server requires `AppAdmin` capability; without `force`, returns
/// 409 if the app still has scripts.
pub async fn apps_delete(&self, ident: &str, force: bool) -> Result<()> {
let path = if force {
format!("/api/v1/admin/apps/{ident}?force=true")
} else {
format!("/api/v1/admin/apps/{ident}")
};
let resp = self.request(Method::DELETE, &path).send().await?;
decode_status(resp).await
}
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
/// owning app (stricter than the edit endpoints, by design).
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/scripts/{id}"))
.send()
.await?;
decode_status(resp).await
}
/// `POST /api/v1/admin/scripts` /// `POST /api/v1/admin/scripts`
pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> { pub async fn scripts_create(&self, body: &CreateScriptBody<'_>) -> Result<Script> {
let resp = self let resp = self
@@ -167,6 +207,68 @@ impl Client {
.await?; .await?;
decode(resp).await decode(resp).await
} }
/// `POST /api/v1/admin/auth/logout` — best-effort: server returns
/// 204 whether or not the token matched a live session, so we just
/// fire and discard the body. Caller still wipes the local creds.
pub async fn auth_logout(&self) -> Result<()> {
let resp = self
.request(Method::POST, "/api/v1/admin/auth/logout")
.send()
.await?;
decode_status(resp).await
}
/// `GET /api/v1/admin/api-keys` — caller's keys only (server filters
/// by user_id, no cross-user enumeration).
pub async fn apikeys_list(&self) -> Result<Vec<ApiKeyDto>> {
let resp = self
.request(Method::GET, "/api/v1/admin/api-keys")
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/api-keys` — `raw_token` is in the response
/// **once** and never appears in `GET /api-keys` afterward.
pub async fn apikeys_mint(&self, body: &MintApiKeyBody<'_>) -> Result<MintApiKeyResponseDto> {
let resp = self
.request(Method::POST, "/api/v1/admin/api-keys")
.json(body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/api-keys/{id}` — 404 covers both "doesn't
/// exist" and "not yours" (server flattens to avoid enumeration).
pub async fn apikeys_delete(&self, id: &str) -> Result<()> {
let resp = self
.request(Method::DELETE, &format!("/api/v1/admin/api-keys/{id}"))
.send()
.await?;
decode_status(resp).await
}
}
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
/// it runs before any token exists. Mirrors the dashboard's login.ts
/// wire shape (see `manager-core/src/auth_api.rs:49-60`).
pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<LoginResponseDto> {
let http = reqwest::Client::builder()
.user_agent(concat!("pic/", env!("CARGO_PKG_VERSION")))
.build()
.context("building HTTP client")?;
let body = LoginRequestBody { username, password };
let resp = http
.post(format!(
"{}/api/v1/admin/auth/login",
url.trim_end_matches('/')
))
.json(&body)
.send()
.await?;
decode(resp).await
} }
// ---------- DTOs (CLI-local, wire-shape-matched) ---------- // ---------- DTOs (CLI-local, wire-shape-matched) ----------
@@ -216,6 +318,63 @@ struct UpdateScriptBody<'a> {
source: &'a str, source: &'a str,
} }
#[derive(Debug, Serialize)]
struct LoginRequestBody<'a> {
username: &'a str,
password: &'a str,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct LoginResponseDto {
pub user: LoginUserDto,
pub token: String,
pub expires_at: DateTime<Utc>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct LoginUserDto {
pub id: AdminUserId,
pub username: String,
pub instance_role: InstanceRole,
#[serde(default)]
pub email: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct MintApiKeyBody<'a> {
pub name: &'a str,
pub scopes: &'a [Scope],
#[serde(skip_serializing_if = "Option::is_none")]
pub app_id: Option<AppId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
/// Fresh-mint response. The `raw_token` field is the one and only
/// chance to capture the bearer string; subsequent `GET /api-keys`
/// returns the `ApiKeyDto` portion without it.
#[derive(Debug, Deserialize)]
pub struct MintApiKeyResponseDto {
#[serde(flatten)]
pub key: ApiKeyDto,
pub raw_token: String,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize)]
pub struct ApiKeyDto {
pub id: ApiKeyId,
pub prefix: String,
pub name: String,
pub scopes: Vec<Scope>,
pub app_id: Option<AppId>,
pub expires_at: Option<DateTime<Utc>>,
pub last_used_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
}
#[allow(dead_code)] #[allow(dead_code)]
#[derive(Debug)] #[derive(Debug)]
pub struct ExecuteResponse { pub struct ExecuteResponse {
@@ -265,6 +424,15 @@ async fn decode<T: for<'de> Deserialize<'de>>(resp: reqwest::Response) -> Result
Err(server_error(resp).await) Err(server_error(resp).await)
} }
/// Like `decode` but for endpoints whose 2xx response has no body
/// (204 No Content) — DELETE handlers, logout.
async fn decode_status(resp: reqwest::Response) -> Result<()> {
if resp.status().is_success() {
return Ok(());
}
Err(server_error(resp).await)
}
async fn server_error(resp: reqwest::Response) -> anyhow::Error { async fn server_error(resp: reqwest::Response) -> anyhow::Error {
let status = resp.status(); let status = resp.status();
let body = resp.text().await.unwrap_or_default(); let body = resp.text().await.unwrap_or_default();

View File

@@ -0,0 +1,201 @@
//! `pic api-keys` — long-lived bearer-key management.
//!
//! Server semantics (mirrored from `manager-core/src/api_keys_api.rs`):
//! * `raw_token` is returned **once** on mint and never again.
//! * `app_id` (optional `--app`) binds the key to one app; instance
//! scopes (`instance:*`) are rejected when `--app` is also set.
//! * `scopes` is a `text[]` in the wire form (`script:read`, …).
use anyhow::{anyhow, Result};
use chrono::{DateTime, Utc};
use picloud_shared::Scope;
use crate::client::{Client, MintApiKeyBody};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn mint(
name: &str,
scope_strs: &[String],
app_ident: Option<&str>,
expires: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let scopes = parse_scopes(scope_strs)?;
let expires_at = expires.map(parse_expires).transpose()?;
let app_id = match app_ident {
Some(ident) => Some(client.apps_get(ident).await?.app.id),
None => None,
};
let body = MintApiKeyBody {
name,
scopes: &scopes,
app_id,
expires_at,
};
let resp = client.apikeys_mint(&body).await?;
let mut block = KvBlock::new();
block
.field("id", resp.key.id.to_string())
.field("name", resp.key.name.clone())
.field("prefix", resp.key.prefix.clone())
.field(
"scopes",
resp.key
.scopes
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(","),
)
.field(
"app_id",
resp.key
.app_id
.map(|a| a.to_string())
.unwrap_or_else(|| "-".into()),
)
.field(
"expires_at",
resp.key
.expires_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
)
.field("token", resp.raw_token.clone());
block.print(mode);
if matches!(mode, OutputMode::Tsv) {
// The token row is human-easy-to-miss in a wall of metadata;
// call it out exactly once on the human path. Skip on JSON
// since machine consumers don't need the nudge.
eprintln!("Save this token — it will not be shown again.");
}
Ok(())
}
pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let keys = client.apikeys_list().await?;
let mut table = Table::new([
"id",
"name",
"prefix",
"scopes",
"app_id",
"expires_at",
"last_used_at",
"created_at",
]);
for k in keys {
table.row([
k.id.to_string(),
k.name,
k.prefix,
k.scopes
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(","),
k.app_id
.map(|a| a.to_string())
.unwrap_or_else(|| "-".into()),
k.expires_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
k.last_used_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".into()),
k.created_at.to_rfc3339(),
]);
}
table.print(mode);
Ok(())
}
pub async fn rm(id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.apikeys_delete(id).await?;
println!("Revoked api-key {id}");
Ok(())
}
fn parse_scopes(raw: &[String]) -> Result<Vec<Scope>> {
if raw.is_empty() {
return Err(anyhow!(
"at least one `--scope` is required (e.g. --scope script:read)"
));
}
raw.iter()
.map(|s| Scope::from_wire(s).ok_or_else(|| anyhow!("unknown scope: {s}")))
.collect()
}
/// `--expires` accepts either RFC 3339 (`2026-12-31T23:59:59Z`) or a
/// shorthand `<N>d` / `<N>h` / `<N>m` (days / hours / minutes from now).
/// Shorthand wins for the common "key good for 30 days" case; full
/// RFC 3339 keeps the door open for precise cutoffs.
fn parse_expires(raw: &str) -> Result<DateTime<Utc>> {
if let Some(spec) = raw.strip_suffix('d') {
let days: i64 = spec.parse().map_err(|_| anyhow!("bad days: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::days(days));
}
if let Some(spec) = raw.strip_suffix('h') {
let hours: i64 = spec.parse().map_err(|_| anyhow!("bad hours: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::hours(hours));
}
if let Some(spec) = raw.strip_suffix('m') {
let mins: i64 = spec.parse().map_err(|_| anyhow!("bad minutes: {raw}"))?;
return Ok(Utc::now() + chrono::Duration::minutes(mins));
}
DateTime::parse_from_rfc3339(raw)
.map(|d| d.with_timezone(&Utc))
.map_err(|e| anyhow!("expected RFC 3339 or `<N>d/h/m`, got {raw:?}: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_scopes_accepts_wire_form() {
let scopes = parse_scopes(&["script:read".into(), "log:read".into()]).unwrap();
assert_eq!(scopes, vec![Scope::ScriptRead, Scope::LogRead]);
}
#[test]
fn parse_scopes_rejects_empty() {
let err = parse_scopes(&[]).unwrap_err();
assert!(format!("{err}").contains("at least one"));
}
#[test]
fn parse_scopes_rejects_unknown() {
let err = parse_scopes(&["script:nope".into()]).unwrap_err();
assert!(format!("{err}").contains("unknown scope"));
}
#[test]
fn parse_expires_days_shorthand() {
let d = parse_expires("7d").unwrap();
let diff = (d - Utc::now()).num_days();
assert!((6..=7).contains(&diff), "got {diff}");
}
#[test]
fn parse_expires_rfc3339_passes_through() {
let d = parse_expires("2030-01-01T00:00:00Z").unwrap();
assert_eq!(d.timestamp(), 1893456000);
}
#[test]
fn parse_expires_garbage_errors() {
assert!(parse_expires("tomorrow").is_err());
}
}

View File

@@ -1,13 +1,14 @@
//! `pic apps ls` and `pic apps create`. //! `pic apps` subcommands: `ls`, `create`, `show`, `delete`.
use anyhow::Result; use anyhow::Result;
use picloud_shared::AppRole;
use crate::client::{Client, CreateAppBody}; use crate::client::{Client, CreateAppBody};
use crate::config::load; use crate::config;
use crate::output::Table; use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls() -> Result<()> { pub async fn ls(mode: OutputMode) -> Result<()> {
let creds = load()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let apps = client.apps_list().await?; let apps = client.apps_list().await?;
let mut table = Table::new(["slug", "name", "my_role", "created_at"]); let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
@@ -22,12 +23,12 @@ pub async fn ls() -> Result<()> {
app.created_at.to_rfc3339(), app.created_at.to_rfc3339(),
]); ]);
} }
table.print(); table.print(mode);
Ok(()) Ok(())
} }
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> { pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
let creds = load()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let body = CreateAppBody { let body = CreateAppBody {
slug, slug,
@@ -38,3 +39,46 @@ pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -
println!("Created app {}", app.slug); println!("Created app {}", app.slug);
Ok(()) Ok(())
} }
/// `pic apps show <slug>` — single-app inspect using the lookup
/// endpoint, which carries `my_role` for the caller (the `ls` endpoint
/// doesn't).
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let lookup = client.apps_get(ident).await?;
let mut block = KvBlock::new();
block
.field("id", lookup.app.id.to_string())
.field("slug", lookup.app.slug.clone())
.field("name", lookup.app.name.clone())
.field(
"description",
lookup.app.description.clone().unwrap_or_else(|| "-".into()),
)
.field("my_role", role_label(lookup.my_role.as_ref()))
.field("created_at", lookup.app.created_at.to_rfc3339())
.field("updated_at", lookup.app.updated_at.to_rfc3339());
block.print(mode);
Ok(())
}
/// `pic apps delete <slug> [--force]`. Without `--force` the server
/// returns 409 if the app still owns scripts — surface that as a
/// useful error rather than swallowing.
pub async fn delete(ident: &str, force: bool) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.apps_delete(ident, force).await?;
println!("Deleted app {ident}");
Ok(())
}
fn role_label(role: Option<&AppRole>) -> String {
// Use the wire form so the CLI label matches what the dashboard
// shows and what the membership APIs accept.
match role {
Some(r) => r.as_str().to_string(),
None => "-".into(),
}
}

View File

@@ -1,46 +1,118 @@
//! `pic login` — interactively (or via PICLOUD_URL/PICLOUD_TOKEN env //! `pic login` — primary auth entry point.
//! shortcut for non-interactive contexts like CI and integration tests) //!
//! capture the URL + bearer token, validate against `/auth/me`, save. //! Two flows:
//! * **username + password** (default, interactive): POST
//! `/api/v1/admin/auth/login` with the credentials and persist the
//! returned session token. Mirrors the dashboard's login form.
//! * **paste-a-token** (`--token <T>`, or `PICLOUD_TOKEN` env): skip
//! the credential exchange and persist a bearer string directly.
//! Used by CI and by anyone using a long-lived API key minted via
//! `pic api-keys mint`. Validated against `/auth/me` before save.
//!
//! `--url <U>` (or `PICLOUD_URL`) overrides the URL prompt non-interactively.
use std::io::{self, BufRead, Write}; use std::io::{self, BufRead, Write};
use anyhow::Result; use anyhow::{Context, Result};
use picloud_shared::InstanceRole;
use crate::client::Client; use crate::client::{self, Client};
use crate::config::{save, Credentials}; use crate::config::{save, Credentials};
const DEFAULT_URL: &str = "http://localhost:8000"; const DEFAULT_URL: &str = "http://localhost:8000";
pub async fn run() -> Result<()> { pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> {
let (url, token) = collect_credentials()?; let url = resolve_url(url_arg)?;
let client = Client::new(&url, &token)?; let token_from_env = std::env::var("PICLOUD_TOKEN")
let me = client.auth_me().await?; .ok()
.filter(|s| !s.is_empty());
let bearer_token = token_arg.map(str::to_string).or(token_from_env);
let (token, username, role) = match bearer_token {
Some(t) => login_with_bearer(&url, &t).await?,
None => login_with_password(&url).await?,
};
let creds = Credentials { let creds = Credentials {
url: client.url().to_string(), url: url.clone(),
token, token,
username: me.username.clone(), username: username.clone(),
}; };
save(&creds)?; save(&creds)?;
println!( println!(
"Logged in as {} ({}) at {}", "Logged in as {username} ({}) at {url}",
me.username, instance_role_label(&role)
instance_role_label(&me.instance_role),
creds.url
); );
Ok(()) Ok(())
} }
fn collect_credentials() -> Result<(String, String)> { async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> {
// Non-interactive shortcut: both vars set → use as-is. Used by the let username = prompt_line("Username: ")?;
// integration test and any CI flow that wants to skip the prompts. if username.is_empty() {
if let (Ok(url), Ok(tok)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) { anyhow::bail!("username is required");
if !url.is_empty() && !tok.is_empty() { }
return Ok((url, tok)); let password = read_password()?;
let resp = client::auth_login(url, &username, &password).await?;
Ok((resp.token, resp.user.username, resp.user.instance_role))
}
/// Read a password without echoing it where possible. Falls back to a
/// plain stdin read when no controlling terminal is attached — CI
/// systems and `cargo test`'s piped stdin both land here, and dying
/// outright would block scripted use entirely. The fallback is louder
/// (visible characters), but it's that or no functioning login.
fn read_password() -> Result<String> {
match rpassword::prompt_password("Password: ") {
Ok(p) => Ok(p),
Err(_) => {
eprint!("Password: ");
io::stderr().flush()?;
let mut buf = String::new();
io::stdin()
.lock()
.read_line(&mut buf)
.context("reading password from stdin")?;
Ok(buf.trim_end_matches(['\r', '\n']).to_string())
} }
} }
let url = prompt_with_default("PiCloud URL", DEFAULT_URL)?; }
let token = rpassword::prompt_password("API token: ")?;
Ok((url, token)) /// Bearer-token path: validate against `/auth/me` so a typo doesn't get
/// persisted, then trust the username the server reports rather than
/// whatever the user typed (which they didn't type at all in this mode).
async fn login_with_bearer(url: &str, token: &str) -> Result<(String, String, InstanceRole)> {
let client = Client::new(url, token)?;
let me = client.auth_me().await?;
Ok((token.to_string(), me.username, me.instance_role))
}
fn instance_role_label(role: &InstanceRole) -> &'static str {
match role {
InstanceRole::Owner => "owner",
InstanceRole::Admin => "admin",
InstanceRole::Member => "member",
}
}
fn resolve_url(url_arg: Option<&str>) -> Result<String> {
if let Some(u) = url_arg {
return Ok(u.trim_end_matches('/').to_string());
}
if let Ok(env_url) = std::env::var("PICLOUD_URL") {
if !env_url.is_empty() {
return Ok(env_url.trim_end_matches('/').to_string());
}
}
let typed = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
Ok(typed.trim_end_matches('/').to_string())
}
fn prompt_line(label: &str) -> Result<String> {
print!("{label}");
io::stdout().flush()?;
let mut buf = String::new();
io::stdin().lock().read_line(&mut buf)?;
Ok(buf.trim().to_string())
} }
fn prompt_with_default(label: &str, default: &str) -> Result<String> { fn prompt_with_default(label: &str, default: &str) -> Result<String> {
@@ -55,12 +127,3 @@ fn prompt_with_default(label: &str, default: &str) -> Result<String> {
trimmed.to_string() trimmed.to_string()
}) })
} }
fn instance_role_label(role: &picloud_shared::InstanceRole) -> &'static str {
use picloud_shared::InstanceRole as R;
match role {
R::Owner => "owner",
R::Admin => "admin",
R::Member => "member",
}
}

View File

@@ -0,0 +1,29 @@
//! `pic logout` — revoke the saved session server-side, then wipe the
//! local credentials file.
//!
//! Idempotent: if the file doesn't exist or the server already forgot
//! the session, we still succeed. The point is leaving the user in a
//! clean "no token" state, not enforcing that a session existed.
use anyhow::Result;
use crate::client::Client;
use crate::config;
pub async fn run() -> Result<()> {
// Load before delete so we have a token to POST /logout with; if
// there's no creds file there's also nothing to revoke server-side.
let creds = config::load().ok();
if let Some(creds) = creds {
let client = Client::from_creds(&creds)?;
// Best-effort: a 4xx (token already invalid) or network error
// shouldn't block the local wipe. The whole point of logout is
// leaving no credentials on disk.
let _ = client.auth_logout().await;
}
config::delete()?;
println!("Logged out");
Ok(())
}

View File

@@ -1,27 +1,48 @@
//! `pic logs <script-id>` — print recent execution log rows. //! `pic logs <script-id>` — print recent execution log rows.
//!
//! In TSV mode emits a header + truncated-summary rows (`pic logs` was
//! previously headerless — inconsistent with `apps ls` / `scripts ls`).
//! In JSON mode emits the raw `ExecutionLog` array (no truncation),
//! letting `jq` consumers see request/response bodies in full.
use anyhow::Result; use anyhow::Result;
use picloud_shared::ExecutionStatus; use picloud_shared::{ExecutionLog, ExecutionStatus};
use crate::client::Client; use crate::client::Client;
use crate::config::load; use crate::config;
use crate::output::{OutputMode, Table};
pub async fn run(script_id: &str, limit: u32) -> Result<()> { pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = load()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let entries = client.logs_list(script_id, limit).await?; let entries = client.logs_list(script_id, limit).await?;
for e in entries { match mode {
let summary = summarize(&e.response_body, &e.script_logs); OutputMode::Tsv => render_tsv(&entries),
println!( OutputMode::Json => render_json(&entries),
"{}\t{}\t{}",
e.created_at.to_rfc3339(),
status_label(&e.status),
truncate(&summary, 120),
);
} }
Ok(()) Ok(())
} }
fn render_tsv(entries: &[ExecutionLog]) {
let mut table = Table::new(["created_at", "status", "summary"]);
for e in entries {
let summary = summarize(&e.response_body, &e.script_logs);
table.row([
e.created_at.to_rfc3339(),
status_label(&e.status).to_string(),
truncate(&summary, 120),
]);
}
table.print(OutputMode::Tsv);
}
fn render_json(entries: &[ExecutionLog]) {
// Pretty for human jq-piping; consumers that want compact can pipe
// through `jq -c`.
let s = serde_json::to_string_pretty(entries).unwrap_or_else(|_| "[]".to_string());
println!("{s}");
}
fn status_label(s: &ExecutionStatus) -> &'static str { fn status_label(s: &ExecutionStatus) -> &'static str {
match s { match s {
ExecutionStatus::Success => "success", ExecutionStatus::Success => "success",

View File

@@ -1,5 +1,7 @@
pub mod api_keys;
pub mod apps; pub mod apps;
pub mod login; pub mod login;
pub mod logout;
pub mod logs; pub mod logs;
pub mod scripts; pub mod scripts;
pub mod whoami; pub mod whoami;

View File

@@ -1,17 +1,19 @@
//! `pic scripts ls | deploy | invoke`. //! `pic scripts ls | deploy | invoke | delete`.
use std::collections::HashMap;
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
use std::path::Path; use std::path::Path;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId;
use serde_json::Value; use serde_json::Value;
use crate::client::{Client, CreateScriptBody}; use crate::client::{Client, CreateScriptBody};
use crate::config::load; use crate::config;
use crate::output::Table; use crate::output::{OutputMode, Table};
pub async fn ls(app: Option<&str>) -> Result<()> { pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = load()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]); let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
@@ -29,24 +31,29 @@ pub async fn ls(app: Option<&str>) -> Result<()> {
]); ]);
} }
} else { } else {
// No filter → walk every accessible app. One request per app is // No filter → use the single `GET /admin/scripts` call. Server
// fine at MVP scale (handful of apps); a bulk endpoint can come // filters by membership for `Member`; for `Admin`/`Owner` it
// later if the count grows. // returns every script. Two requests total (apps + scripts) run
let apps = client.apps_list().await?; // in parallel; the per-app walk we used to do here aborted on
for a in apps { // the first 404 when another caller deleted an app mid-listing,
let scripts = client.scripts_list_by_app(&a.slug).await?; // and was the entire reason a 5× retry existed in the tests.
for s in scripts { let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
table.row([ let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
s.id.to_string(), for s in scripts {
a.slug.clone(), let app_slug = slug_by_id
s.name, .get(&s.app_id)
s.version.to_string(), .cloned()
s.updated_at.to_rfc3339(), .unwrap_or_else(|| "-".to_string());
]); table.row([
} s.id.to_string(),
app_slug,
s.name,
s.version.to_string(),
s.updated_at.to_rfc3339(),
]);
} }
} }
table.print(); table.print(mode);
Ok(()) Ok(())
} }
@@ -56,7 +63,7 @@ pub async fn deploy(
name_override: Option<&str>, name_override: Option<&str>,
description: Option<&str>, description: Option<&str>,
) -> Result<()> { ) -> Result<()> {
let creds = load()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let source = let source =
@@ -99,7 +106,7 @@ pub async fn deploy(
} }
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> { pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
let creds = load()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let body = parse_body_arg(body_arg)?; let body = parse_body_arg(body_arg)?;
@@ -115,6 +122,18 @@ pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String
} }
} }
/// `pic scripts delete <id>`. Requires `AppAdmin` on the owning app
/// server-side, which is stricter than the edit endpoints — Editor
/// can deploy/update but not destroy. Surfaces that as a 403 with the
/// usual role hint.
pub async fn delete(id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.scripts_delete(id).await?;
println!("Deleted script {id}");
Ok(())
}
fn parse_body_arg(arg: Option<&str>) -> Result<Value> { fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
match arg { match arg {
None => Ok(Value::Object(serde_json::Map::new())), None => Ok(Value::Object(serde_json::Map::new())),

View File

@@ -1,22 +1,34 @@
//! `pic whoami` — re-validates the saved token by hitting `/auth/me` //! `pic whoami` — re-validates the saved token by hitting `/auth/me`
//! every time. Cached username in the credentials file is for //! every time. Cached username in the credentials file is for
//! display-only contexts; this command is the source of truth. //! display-only contexts; this command is the source of truth.
//!
//! TSV output uses `KvBlock` (aligned `key: value` rows), JSON output
//! is a flat object — both downstream-friendly without the user having
//! to parse a headerless tab-line.
use anyhow::Result; use anyhow::Result;
use picloud_shared::InstanceRole;
use crate::client::Client; use crate::client::Client;
use crate::config::load; use crate::config;
use crate::output::{KvBlock, OutputMode};
pub async fn run() -> Result<()> { pub async fn run(mode: OutputMode) -> Result<()> {
let creds = load()?; let creds = config::resolve()?;
let client = Client::from_creds(&creds)?; let client = Client::from_creds(&creds)?;
let me = client.auth_me().await?; let me = client.auth_me().await?;
let role = match me.instance_role { let role = match me.instance_role {
picloud_shared::InstanceRole::Owner => "owner", InstanceRole::Owner => "owner",
picloud_shared::InstanceRole::Admin => "admin", InstanceRole::Admin => "admin",
picloud_shared::InstanceRole::Member => "member", InstanceRole::Member => "member",
}; };
let email = me.email.as_deref().unwrap_or("-"); let email = me.email.as_deref().unwrap_or("-");
println!("{}\t{role}\t{email}\t{}", me.username, creds.url); let mut block = KvBlock::new();
block
.field("username", me.username)
.field("role", role)
.field("email", email)
.field("url", creds.url.clone());
block.print(mode);
Ok(()) Ok(())
} }

View File

@@ -43,6 +43,41 @@ pub fn load() -> Result<Credentials> {
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display())) toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
} }
/// Resolution order used by every non-login command:
/// 1. If both `PICLOUD_URL` and `PICLOUD_TOKEN` are set (and non-empty),
/// use them directly. Matches gcloud/aws/kubectl semantics — env
/// wins so CI never accidentally reads a developer's stale file.
/// 2. Otherwise fall back to the on-disk credentials file.
///
/// Username is best-effort: env mode has no way to know the real one
/// (no round-trip to `/auth/me`), so it shows as `"-"` in `whoami`
/// output. Callers that need the canonical username re-fetch via
/// `Client::auth_me`.
pub fn resolve() -> Result<Credentials> {
if let (Ok(url), Ok(token)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
if !url.is_empty() && !token.is_empty() {
return Ok(Credentials {
url,
token,
username: "-".to_string(),
});
}
}
load()
}
/// Delete the on-disk credentials file. Idempotent — silently succeeds
/// if the file is already gone (the user already logged out, or never
/// logged in to begin with).
pub fn delete() -> Result<()> {
let path = credentials_path()?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| format!("removing {}", path.display())),
}
}
pub fn save(creds: &Credentials) -> Result<()> { pub fn save(creds: &Credentials) -> Result<()> {
let path = credentials_path()?; let path = credentials_path()?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {

View File

@@ -14,17 +14,31 @@ mod cmds;
mod config; mod config;
mod output; mod output;
use crate::output::OutputMode;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "pic", version, about = "PiCloud command-line client")] #[command(name = "pic", version, about = "PiCloud command-line client")]
struct Cli { struct Cli {
/// Output format for `ls` / `show` / `whoami` / `logs` commands.
/// TSV stays pipe-friendly; JSON is `jq`-ready.
#[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)]
output: OutputMode,
#[command(subcommand)] #[command(subcommand)]
cmd: Cmd, cmd: Cmd,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
enum Cmd { enum Cmd {
/// Save URL + bearer token to `~/.picloud/credentials`. /// Authenticate with the server. Default flow prompts for username
Login, /// + password and saves the returned session token; `--token` skips
/// the password exchange and persists a bearer string directly (use
/// this for long-lived API keys minted via `pic api-keys mint`).
Login(LoginArgs),
/// Revoke the saved session server-side and delete the local
/// credentials file. Idempotent.
Logout,
/// Print the principal the saved token resolves to. /// Print the principal the saved token resolves to.
Whoami, Whoami,
@@ -41,8 +55,35 @@ enum Cmd {
cmd: ScriptsCmd, cmd: ScriptsCmd,
}, },
/// Long-lived bearer API key management.
#[command(name = "api-keys")]
ApiKeys {
#[command(subcommand)]
cmd: ApiKeysCmd,
},
/// Tail recent execution logs for a script. /// Tail recent execution logs for a script.
Logs(LogsArgs), Logs(LogsArgs),
/// Top-level alias for `pic scripts invoke <id>`.
Invoke(InvokeArgs),
/// Top-level alias for `pic scripts deploy <file> --app <slug>`.
Deploy(DeployArgs),
}
#[derive(Args)]
struct LoginArgs {
/// Override the URL prompt non-interactively. Also reads
/// `PICLOUD_URL`.
#[arg(long)]
url: Option<String>,
/// Skip the username + password exchange and persist this bearer
/// directly (validated against `/auth/me` first). Also reads
/// `PICLOUD_TOKEN`.
#[arg(long)]
token: Option<String>,
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@@ -58,12 +99,23 @@ enum AppsCmd {
#[arg(long)] #[arg(long)]
description: Option<String>, description: Option<String>,
}, },
/// Show a single app, including the caller's role in it.
Show { ident: String },
/// Delete an app. Without `--force`, the server rejects if the app
/// still owns scripts.
Delete {
ident: String,
#[arg(long)]
force: bool,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
enum ScriptsCmd { enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without, /// List scripts. With `--app`, scoped to one app; without, one
/// iterates over every app the caller can see. /// `GET /admin/scripts` for everything the caller can see.
Ls { Ls {
#[arg(long)] #[arg(long)]
app: Option<String>, app: Option<String>,
@@ -71,25 +123,59 @@ enum ScriptsCmd {
/// Upload a `.rhai` file. Patches the existing script with the /// Upload a `.rhai` file. Patches the existing script with the
/// matching name in `--app` if one exists, otherwise creates it. /// matching name in `--app` if one exists, otherwise creates it.
Deploy { Deploy(DeployArgs),
file: PathBuf,
#[arg(long)]
app: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`, /// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
/// `--body @-` for stdin, or inline JSON. /// `--body @-` for stdin, or inline JSON.
Invoke { Invoke(InvokeArgs),
id: String,
/// Delete a script. Requires AppAdmin on the owning app.
Delete { id: String },
}
#[derive(Args)]
struct DeployArgs {
file: PathBuf,
#[arg(long)]
app: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
}
#[derive(Args)]
struct InvokeArgs {
id: String,
#[arg(long)]
body: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
headers: Vec<(String, String)>,
}
#[derive(Subcommand)]
enum ApiKeysCmd {
/// Mint a new long-lived bearer key. Token printed exactly once.
Mint {
name: String,
/// Repeat for multiple scopes: `--scope script:read --scope log:read`.
#[arg(long = "scope", required = true)]
scopes: Vec<String>,
/// Bind the key to a single app (slug or id). Rejects
/// `instance:*` scopes when set.
#[arg(long)] #[arg(long)]
body: Option<String>, app: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)] /// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand
headers: Vec<(String, String)>, /// `<N>d`/`<N>h`/`<N>m`.
#[arg(long)]
expires: Option<String>,
}, },
/// List the caller's keys (no `raw_token` after mint).
Ls,
/// Revoke a key by id.
Rm { id: String },
} }
#[derive(Args)] #[derive(Args)]
@@ -102,10 +188,12 @@ struct LogsArgs {
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> ExitCode { async fn main() -> ExitCode {
let cli = Cli::parse(); let cli = Cli::parse();
let mode = cli.output;
let result = match cli.cmd { let result = match cli.cmd {
Cmd::Login => cmds::login::run().await, Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await,
Cmd::Whoami => cmds::whoami::run().await, Cmd::Logout => cmds::logout::run().await,
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls().await, Cmd::Whoami => cmds::whoami::run(mode).await,
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
Cmd::Apps { Cmd::Apps {
cmd: cmd:
AppsCmd::Create { AppsCmd::Create {
@@ -114,22 +202,60 @@ async fn main() -> ExitCode {
description, description,
}, },
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await, } => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
Cmd::Apps {
cmd: AppsCmd::Show { ident },
} => cmds::apps::show(&ident, mode).await,
Cmd::Apps {
cmd: AppsCmd::Delete { ident, force },
} => cmds::apps::delete(&ident, force).await,
Cmd::Scripts { Cmd::Scripts {
cmd: ScriptsCmd::Ls { app }, cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref()).await, } => cmds::scripts::ls(app.as_deref(), mode).await,
Cmd::Scripts { Cmd::Scripts {
cmd: ScriptsCmd::Deploy(args),
} => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
)
.await
}
Cmd::Scripts {
cmd: ScriptsCmd::Invoke(args),
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
Cmd::Scripts {
cmd: ScriptsCmd::Delete { id },
} => cmds::scripts::delete(&id).await,
Cmd::ApiKeys {
cmd: cmd:
ScriptsCmd::Deploy { ApiKeysCmd::Mint {
file,
app,
name, name,
description, scopes,
app,
expires,
}, },
} => cmds::scripts::deploy(&file, &app, name.as_deref(), description.as_deref()).await, } => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
Cmd::Scripts { Cmd::ApiKeys {
cmd: ScriptsCmd::Invoke { id, body, headers }, cmd: ApiKeysCmd::Ls,
} => cmds::scripts::invoke(&id, body.as_deref(), &headers).await, } => cmds::api_keys::ls(mode).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit).await, Cmd::ApiKeys {
cmd: ApiKeysCmd::Rm { id },
} => cmds::api_keys::rm(&id).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
Cmd::Invoke(args) => {
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
}
Cmd::Deploy(args) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
)
.await
}
}; };
match result { match result {

View File

@@ -1,11 +1,34 @@
//! Tab-separated table writer + error formatting. //! Output rendering for the CLI.
//! //!
//! Aligned columns are nice for humans but `\t`-separated stays //! Two formats:
//! pipe-friendly: `pic apps ls | awk -F'\t' '{print $1}'` works without //! * **TSV** (default): aligned columns separated by `\t`. Stays
//! parsing box-drawing. //! pipe-friendly — `pic apps ls | awk -F'\t' '{print $1}'` works
//! without parsing box-drawing.
//! * **JSON**: array of `{column: value, …}` objects (for tables) or
//! a flat object (for single-row `show`/`whoami`). Designed to be
//! `jq`-friendly without escaping the table column names.
//!
//! Mode is set globally by the top-level `--output` flag and threaded
//! through every command. Single-row commands (`whoami`, `apps show`)
//! use `KvBlock`; everything plural uses `Table`.
use std::io::{self, Write}; use std::io::{self, Write};
use clap::ValueEnum;
use serde_json::{Map, Value};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
#[clap(rename_all = "lowercase")]
pub enum OutputMode {
#[default]
Tsv,
Json,
}
// ----------------------------------------------------------------------------
// Table — list views (`apps ls`, `scripts ls`, `logs`)
// ----------------------------------------------------------------------------
pub struct Table { pub struct Table {
headers: Vec<String>, headers: Vec<String>,
rows: Vec<Vec<String>>, rows: Vec<Vec<String>>,
@@ -32,7 +55,7 @@ impl Table {
self self
} }
pub fn render(&self) -> String { pub fn render_tsv(&self) -> String {
let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect(); let mut widths: Vec<usize> = self.headers.iter().map(String::len).collect();
for row in &self.rows { for row in &self.rows {
for (i, cell) in row.iter().enumerate() { for (i, cell) in row.iter().enumerate() {
@@ -52,8 +75,36 @@ impl Table {
out out
} }
pub fn print(&self) { /// JSON form: `[{header: cell, …}, …]`. Cells go in as strings even
let s = self.render(); /// when they happen to look like numbers — the CLI doesn't carry
/// type information all the way through (e.g., `version` is already
/// `to_string`'d at the call site). Consumers that need typed
/// numbers should parse `jq -r '.[].version|tonumber'`.
pub fn render_json(&self) -> String {
let arr: Vec<Value> = self
.rows
.iter()
.map(|row| {
let mut obj = Map::new();
for (i, header) in self.headers.iter().enumerate() {
let cell = row.get(i).cloned().unwrap_or_default();
obj.insert(header.clone(), Value::String(cell));
}
Value::Object(obj)
})
.collect();
serde_json::to_string_pretty(&Value::Array(arr)).unwrap_or_else(|_| "[]".to_string())
}
pub fn print(&self, mode: OutputMode) {
let s = match mode {
OutputMode::Tsv => self.render_tsv(),
OutputMode::Json => {
let mut s = self.render_json();
s.push('\n');
s
}
};
// Best-effort write — broken pipe from `| head` etc. shouldn't // Best-effort write — broken pipe from `| head` etc. shouldn't
// surface as an error. // surface as an error.
let _ = io::stdout().write_all(s.as_bytes()); let _ = io::stdout().write_all(s.as_bytes());
@@ -78,26 +129,124 @@ fn write_row(out: &mut String, row: &[String], widths: &[usize]) {
out.push('\n'); out.push('\n');
} }
// ----------------------------------------------------------------------------
// KvBlock — single-row views (`whoami`, `apps show`)
// ----------------------------------------------------------------------------
/// One row's worth of fields, rendered as aligned `key: value` lines in
/// TSV mode (one line per field — easier on the eye than a 1-row table)
/// or a flat JSON object.
pub struct KvBlock {
fields: Vec<(String, String)>,
}
impl KvBlock {
pub fn new() -> Self {
Self { fields: Vec::new() }
}
pub fn field(&mut self, key: impl Into<String>, value: impl Into<String>) -> &mut Self {
self.fields.push((key.into(), value.into()));
self
}
pub fn render_tsv(&self) -> String {
let key_width = self.fields.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
let mut out = String::new();
for (k, v) in &self.fields {
out.push_str(k);
for _ in k.len()..key_width {
out.push(' ');
}
out.push('\t');
out.push_str(v);
out.push('\n');
}
out
}
pub fn render_json(&self) -> String {
let mut obj = Map::new();
for (k, v) in &self.fields {
obj.insert(k.clone(), Value::String(v.clone()));
}
serde_json::to_string_pretty(&Value::Object(obj)).unwrap_or_else(|_| "{}".to_string())
}
pub fn print(&self, mode: OutputMode) {
let s = match mode {
OutputMode::Tsv => self.render_tsv(),
OutputMode::Json => {
let mut s = self.render_json();
s.push('\n');
s
}
};
let _ = io::stdout().write_all(s.as_bytes());
}
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
pub fn print_error(err: &anyhow::Error) { pub fn print_error(err: &anyhow::Error) {
let mut stderr = io::stderr(); let mut stderr = io::stderr();
let _ = writeln!(stderr, "error: {err:#}"); let _ = writeln!(stderr, "error: {err:#}");
} }
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn table_aligns_columns() { fn table_aligns_columns_tsv() {
let mut t = Table::new(["slug", "name"]); let mut t = Table::new(["slug", "name"]);
t.row(["a", "Alpha"]).row(["bravo", "B"]); t.row(["a", "Alpha"]).row(["bravo", "B"]);
let out = t.render(); let out = t.render_tsv();
assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n"); assert_eq!(out, "slug \tname\na \tAlpha\nbravo\tB\n");
} }
#[test] #[test]
fn table_empty_rows() { fn table_empty_rows_tsv() {
let t = Table::new(["a", "b"]); let t = Table::new(["a", "b"]);
assert_eq!(t.render(), "a\tb\n"); assert_eq!(t.render_tsv(), "a\tb\n");
}
#[test]
fn table_render_json_is_array_of_objects() {
let mut t = Table::new(["slug", "name"]);
t.row(["a", "Alpha"]).row(["bravo", "B"]);
let raw = t.render_json();
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
let arr = v.as_array().expect("array");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["slug"], "a");
assert_eq!(arr[0]["name"], "Alpha");
assert_eq!(arr[1]["slug"], "bravo");
assert_eq!(arr[1]["name"], "B");
}
#[test]
fn kv_block_tsv_aligns_keys() {
let mut b = KvBlock::new();
b.field("username", "admin").field("role", "owner");
let out = b.render_tsv();
// username (8 chars) defines the key width.
assert_eq!(out, "username\tadmin\nrole \towner\n");
}
#[test]
fn kv_block_json_is_flat_object() {
let mut b = KvBlock::new();
b.field("username", "admin").field("role", "owner");
let raw = b.render_json();
let v: Value = serde_json::from_str(&raw).expect("valid JSON");
assert_eq!(v["username"], "admin");
assert_eq!(v["role"], "owner");
} }
} }

View File

@@ -0,0 +1,170 @@
//! `pic api-keys` — mint / ls / rm journeys.
//!
//! Server semantics asserted here:
//! * `mint` emits the `raw_token` *exactly once* and never on `ls`.
//! * A minted key is a valid bearer for `/auth/me`.
//! * After `rm`, the same token is rejected (401).
use predicates::prelude::*;
use serde_json::Value;
use crate::common;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn mint_prints_raw_token_once_and_ls_omits_it() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let name = format!("pic-cli-mint-{}", common::unique_slug("k"));
let out = common::pic_as(&env)
.args([
"--output",
"json",
"api-keys",
"mint",
&name,
"--scope",
"script:read",
])
.output()
.expect("api-keys mint");
assert!(out.status.success(), "mint failed: {out:?}");
let body: Value = serde_json::from_slice(&out.stdout).expect("JSON");
let token = body["token"]
.as_str()
.expect("mint should expose `token`")
.to_string();
let key_id = body["id"]
.as_str()
.expect("mint should expose `id`")
.to_string();
assert!(
token.starts_with("pic_"),
"tokens are pic_-prefixed: {token}"
);
// `ls` must NEVER carry the raw token. The key row should appear,
// identified by name, but `token` is mint-only.
let ls = common::pic_as(&env)
.args(["--output", "json", "api-keys", "ls"])
.output()
.expect("api-keys ls");
assert!(ls.status.success(), "ls failed: {ls:?}");
let ls_body: Value = serde_json::from_slice(&ls.stdout).expect("JSON");
let arr = ls_body.as_array().expect("array");
let row = arr
.iter()
.find(|r| r.get("id").and_then(Value::as_str) == Some(key_id.as_str()))
.expect("our key in ls");
assert!(
row.get("token").is_none(),
"ls must not expose raw_token: {row}"
);
// Cleanup so we don't leak keys across runs.
common::pic_as(&env)
.args(["api-keys", "rm", &key_id])
.assert()
.success();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn minted_key_works_as_bearer() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let name = format!("pic-cli-bearer-{}", common::unique_slug("k"));
let mint = common::pic_as(&env)
.args([
"--output",
"json",
"api-keys",
"mint",
&name,
"--scope",
"script:read",
])
.output()
.expect("mint");
assert!(mint.status.success());
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
let token = body["token"].as_str().unwrap().to_string();
let id = body["id"].as_str().unwrap().to_string();
// Drive whoami with the minted token — proves the bearer string we
// captured really is what the server stamped.
let key_env = common::custom_env(&fx.url, &token);
common::seed_credentials(&key_env, &fx.admin_username);
common::pic_as(&key_env)
.args(["whoami"])
.assert()
.success()
.stdout(predicate::str::contains(fx.admin_username.as_str()));
common::pic_as(&env)
.args(["api-keys", "rm", &id])
.assert()
.success();
}
/// After `rm`, the bearer token is dead server-side: a follow-up
/// `whoami` driven by it must 401, not 500.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn rm_revokes_the_token() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let name = format!("pic-cli-rm-{}", common::unique_slug("k"));
let mint = common::pic_as(&env)
.args([
"--output",
"json",
"api-keys",
"mint",
&name,
"--scope",
"script:read",
])
.output()
.expect("mint");
let body: Value = serde_json::from_slice(&mint.stdout).unwrap();
let token = body["token"].as_str().unwrap().to_string();
let id = body["id"].as_str().unwrap().to_string();
common::pic_as(&env)
.args(["api-keys", "rm", &id])
.assert()
.success()
.stdout(predicate::str::contains(format!("Revoked api-key {id}")));
let dead = common::custom_env(&fx.url, &token);
common::pic_as(&dead)
.args(["whoami"])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 401"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn mint_with_unknown_scope_is_rejected_client_side() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
common::pic_as(&env)
.args(["api-keys", "mint", "doomed", "--scope", "script:nope"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown scope"));
}

View File

@@ -0,0 +1,268 @@
//! `pic apps create` / `pic apps ls` edge cases. The integration smoke
//! test covers the happy path; this module covers conflict, validation,
//! and the persistence of the optional `--name` / `--description` flags
//! (which `apps ls` doesn't surface).
use predicates::prelude::*;
use serde_json::Value;
use crate::common;
use crate::common::cleanup::AppGuard;
use crate::common::member;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn create_with_name_and_description_persists() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-named");
common::pic_as(&env)
.args([
"apps",
"create",
&slug,
"--name",
"Pretty Name",
"--description",
"test description",
])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
// `apps ls` only shows slug+name+role+created_at, so verify the
// persisted shape via the admin GET endpoint.
let client = reqwest::blocking::Client::new();
let resp = client
.get(format!("{}/api/v1/admin/apps/{}", env.url, slug))
.bearer_auth(&env.token)
.send()
.expect("GET app");
assert!(resp.status().is_success(), "GET app failed: {resp:?}");
let body: Value = resp.json().expect("app json");
assert_eq!(body["slug"].as_str(), Some(slug.as_str()));
assert_eq!(body["name"].as_str(), Some("Pretty Name"));
assert_eq!(body["description"].as_str(), Some("test description"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn create_duplicate_slug_conflicts() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-dup");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.failure()
.stderr(predicate::str::contains("409").or(predicate::str::contains("conflict")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn create_invalid_slug_rejected() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// Server slug regex is `^[a-z0-9][a-z0-9-]{0,62}$` — uppercase
// breaks the rule on the very first char. The server returns 422
// (`InvalidSlug` → `UNPROCESSABLE_ENTITY`), not 400 — the previous
// `"HTTP 4"` predicate would have silently matched any other 4xx
// (a regressed 401 from broken auth, for example).
common::pic_as(&env)
.args(["apps", "create", "NotALowerSlug"])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 422"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn ls_includes_created_app_with_expected_columns() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-ls");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
assert!(out.status.success(), "apps ls failed: {out:?}");
let stdout = String::from_utf8(out.stdout).expect("utf8 stdout");
let mut lines = stdout.lines();
let header = lines.next().expect("header row");
assert_eq!(
common::cells(header),
vec!["slug", "name", "my_role", "created_at"]
);
// The slug must appear in some data row and its row's my_role column
// is dashed (the ls endpoint doesn't compute it per-app).
let row = lines
.map(common::cells)
.find(|c| c.first().copied() == Some(slug.as_str()))
.unwrap_or_else(|| panic!("slug {slug} not in apps ls output: {stdout}"));
assert_eq!(row.len(), 4, "row should have 4 cells: {row:?}");
assert_eq!(row[2], "-", "my_role column should be dashed: {row:?}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn delete_removes_app_from_ls() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-del");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "delete", &slug])
.assert()
.success()
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
let out = common::pic_as(&env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
!stdout.lines().any(|l| l.starts_with(&slug)),
"deleted slug should not appear in ls: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn delete_with_scripts_errors_without_force() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-del-busy");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
// AppGuard is the safety net: if the no-force delete fails (as
// expected) the app stays around; AppGuard force-deletes on drop.
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "delete", &slug])
.assert()
.failure()
// Server `HasScripts` → 409 with a "scripts present" message.
.stderr(predicate::str::contains("HTTP 409"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn delete_with_scripts_succeeds_with_force() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("apps-del-force");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "delete", &slug, "--force"])
.assert()
.success()
.stdout(predicate::str::contains(format!("Deleted app {slug}")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn show_prints_my_role_for_member() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let admin_env = common::admin_env(fx);
let slug = common::unique_slug("apps-show");
common::pic_as(&admin_env)
.args(["apps", "create", &slug])
.assert()
.success();
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
let m = member::member_user(fx, &common::unique_username("show"));
member::grant_membership(fx, &slug, &m.id, "viewer");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let out = common::pic_as(&member_env)
.args(["apps", "show", &slug])
.output()
.expect("apps show");
assert!(out.status.success(), "apps show failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
// KvBlock output: `my_role` row carries the wire form (`viewer`).
assert!(
stdout
.lines()
.any(|l| l.starts_with("my_role") && l.trim_end().ends_with("viewer")),
"show should surface my_role=viewer, got: {stdout}"
);
assert!(
stdout.lines().any(|l| l.starts_with("slug")),
"show should include slug row: {stdout}"
);
}

View File

@@ -0,0 +1,288 @@
//! Login + whoami journeys beyond the happy path: bad tokens, missing
//! credentials file, stale on-disk creds, and the role-label rendered
//! by `pic login`.
use predicates::prelude::*;
use crate::common;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_persists_credentials_with_correct_perms() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
common::pic_as(&env).args(["login"]).assert().success();
let creds_path = env.config_dir.path().join("credentials");
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
assert!(
body.contains(&format!("url = \"{}\"", env.url)),
"creds missing url line: {body}",
);
assert!(
body.contains(&format!("token = \"{}\"", env.token)),
"creds missing token line: {body}",
);
assert!(
body.contains(&format!("username = \"{}\"", fx.admin_username)),
"creds missing username line: {body}",
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&creds_path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "credentials file must be 0600, got {mode:o}");
}
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_rejects_bad_token() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::custom_env(&fx.url, "pic_garbage_token");
common::pic_as(&env)
.args(["login"])
.assert()
.failure()
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
let creds_path = env.config_dir.path().join("credentials");
assert!(
!creds_path.exists(),
"failed login must not persist credentials"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn whoami_without_credentials_errors() {
let Some(_fx) = common::fixture_or_skip() else {
return;
};
// Build a TestEnv directly so the config dir stays empty —
// `admin_env` would seed a credentials file, masking the bug
// this test is supposed to catch.
let env = common::TestEnv {
url: String::new(),
token: String::new(),
config_dir: tempfile::TempDir::new().unwrap(),
home: tempfile::TempDir::new().unwrap(),
};
common::pic_no_env(&env)
.args(["whoami"])
.assert()
.failure()
.stderr(predicate::str::contains("pic login"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn whoami_with_stale_token_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let body = format!(
"url = \"{}\"\ntoken = \"pic_stale_token\"\nusername = \"ghost\"\n",
env.url
);
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
common::pic_no_env(&env)
.args(["whoami"])
.assert()
.failure()
.stderr(predicate::str::contains("401").or(predicate::str::contains("token rejected")));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_prints_member_role_label() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let username = common::unique_username("auth");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, &m.token);
common::pic_as(&env)
.args(["login"])
.assert()
.success()
.stdout(predicate::str::contains(format!(
"Logged in as {} (member)",
m.username
)));
}
/// Drive the real username+password flow end-to-end. `pic_no_env`
/// strips `PICLOUD_TOKEN` so login can't short-circuit through the
/// bearer path; stdin feeds `username\npassword\n` (the URL is supplied
/// via `--url` to avoid the third prompt).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_with_username_and_password_persists() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let username = common::unique_username("lpw");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, ""); // empty token — file gets written by login
let stdin_payload = format!("{}\n{}\n", m.username, common::member::MEMBER_PASSWORD);
common::pic_no_env(&env)
.args(["login", "--url", &fx.url])
.write_stdin(stdin_payload)
.assert()
.success()
.stdout(predicate::str::contains(format!(
"Logged in as {} (member)",
m.username
)));
let creds_path = env.config_dir.path().join("credentials");
let body = std::fs::read_to_string(&creds_path).expect("credentials file");
assert!(
body.contains(&format!("username = \"{}\"", m.username)),
"creds should carry the canonical username: {body}",
);
// The token persisted must be a real session token, not whatever
// the user typed — a regression where we accidentally saved the
// password as the token would fail this check.
assert!(
!body.contains(common::member::MEMBER_PASSWORD),
"password leaked into credentials file: {body}",
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn login_with_wrong_password_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let username = common::unique_username("lpwbad");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, "");
let stdin_payload = format!("{}\nwrong-password\n", m.username);
common::pic_no_env(&env)
.args(["login", "--url", &fx.url])
.write_stdin(stdin_payload)
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 401"));
let creds_path = env.config_dir.path().join("credentials");
assert!(
!creds_path.exists(),
"failed login must not persist credentials"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logout_clears_local_credentials() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
// Use a member's token so we don't yank the admin session out from
// under parallel tests. The local-file cleanup is the same.
let username = common::unique_username("lout");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&env, &m.username);
let creds_path = env.config_dir.path().join("credentials");
assert!(creds_path.exists(), "precondition: creds file seeded");
common::pic_no_env(&env)
.args(["logout"])
.assert()
.success()
.stdout(predicate::str::contains("Logged out"));
assert!(
!creds_path.exists(),
"credentials file should be removed after logout"
);
}
/// `pic logout` is meant to be idempotent: running it with no
/// credentials file present is not an error.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logout_is_idempotent_when_already_logged_out() {
let Some(_fx) = common::fixture_or_skip() else {
return;
};
let env = common::TestEnv {
url: String::new(),
token: String::new(),
config_dir: tempfile::TempDir::new().unwrap(),
home: tempfile::TempDir::new().unwrap(),
};
common::pic_no_env(&env)
.args(["logout"])
.assert()
.success()
.stdout(predicate::str::contains("Logged out"));
}
/// Server-side session invalidation: after `pic logout`, a subsequent
/// `pic whoami` driven by the same (now-stale) token must 401.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logout_invalidates_server_session() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let username = common::unique_username("lout2");
let m = common::member::member_user(fx, &username);
let env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&env, &m.username);
common::pic_no_env(&env).args(["logout"]).assert().success();
// Replay the member's old token explicitly — pic_no_env reads the
// (now-deleted) file, so we go back to env-driven mode with the
// stale bearer.
let stale = common::custom_env(&fx.url, &m.token);
common::pic_as(&stale)
.args(["whoami"])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 401"));
}
/// Env vars must override the on-disk credentials file globally. Write
/// garbage into the file, set env to the real admin creds, and prove
/// every read-side command (here `whoami`) goes via env.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn env_vars_override_credentials_file() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::custom_env(&fx.url, &fx.admin_token);
// Garbage in the file: would 401 if used.
let body = format!(
"url = \"{}\"\ntoken = \"pic_stale_garbage_token\"\nusername = \"ghost\"\n",
env.url
);
std::fs::write(env.config_dir.path().join("credentials"), body).unwrap();
common::pic_as(&env)
.args(["whoami"])
.assert()
.success()
.stdout(predicate::str::contains(fx.admin_username.as_str()));
}

View File

@@ -12,4 +12,12 @@
//! cargo test -p picloud-cli --test cli -- --include-ignored //! cargo test -p picloud-cli --test cli -- --include-ignored
mod common; mod common;
mod integration;
mod api_keys;
mod apps;
mod auth;
mod invoke;
mod logs;
mod output;
mod roles;
mod scripts;

View File

@@ -30,7 +30,9 @@ pub fn member_user(fx: &Fixture, username: &str) -> MemberUser {
.json(&json!({ .json(&json!({
"username": username, "username": username,
"password": MEMBER_PASSWORD, "password": MEMBER_PASSWORD,
"instance_role": "Member", // InstanceRole / AppRole serialize via `rename_all =
// "snake_case"` — wire forms are always lowercase.
"instance_role": "member",
})) }))
.send() .send()
.expect("create member user"); .expect("create member user");

View File

@@ -89,20 +89,27 @@ pub struct TestEnv {
pub home: TempDir, pub home: TempDir,
} }
/// Per-test env pre-loaded with the admin token. Mirrors what the seed /// Per-test env pre-loaded with the admin token, and a credentials
/// test built inline. /// file already on disk so non-login commands ("pic apps create", …)
/// can run without first calling `pic login`. As of the env-var
/// consistency fix, `PICLOUD_URL`/`PICLOUD_TOKEN` (set by `pic_as`)
/// also work for *every* command, not just `login` — `config::resolve`
/// reads them first and falls back to the on-disk file.
pub fn admin_env(fx: &Fixture) -> TestEnv { pub fn admin_env(fx: &Fixture) -> TestEnv {
TestEnv { let env = TestEnv {
url: fx.url.clone(), url: fx.url.clone(),
token: fx.admin_token.clone(), token: fx.admin_token.clone(),
config_dir: TempDir::new().expect("config tempdir"), config_dir: TempDir::new().expect("config tempdir"),
home: TempDir::new().expect("home tempdir"), home: TempDir::new().expect("home tempdir"),
} };
seed_credentials(&env, &fx.admin_username);
env
} }
/// Per-test env pre-loaded with a specific (URL, token) pair. Used by /// Per-test env pre-loaded with a specific (URL, token) pair. Used by
/// tests that want a non-admin token, a bogus token, or an unreachable /// tests that want a non-admin token, a bogus token, or an unreachable
/// URL. /// URL. Does **not** seed a credentials file — call `seed_credentials`
/// explicitly when the test needs to run non-login commands.
pub fn custom_env(url: &str, token: &str) -> TestEnv { pub fn custom_env(url: &str, token: &str) -> TestEnv {
TestEnv { TestEnv {
url: url.to_string(), url: url.to_string(),
@@ -112,6 +119,19 @@ pub fn custom_env(url: &str, token: &str) -> TestEnv {
} }
} }
/// Write a valid credentials TOML into `env.config_dir` so subsequent
/// `pic_as(&env)` invocations can issue non-login subcommands. Mirrors
/// the file shape `pic login` produces (url/token/username). Tests that
/// exercise the "no credentials" / "stale token" error paths construct
/// `TestEnv` directly to keep the config dir empty.
pub fn seed_credentials(env: &TestEnv, username: &str) {
let body = format!(
"url = \"{}\"\ntoken = \"{}\"\nusername = \"{}\"\n",
env.url, env.token, username,
);
std::fs::write(env.config_dir.path().join("credentials"), body).expect("seed credentials file");
}
/// `pic` invocation with the env wired up — credentials dir, HOME, and /// `pic` invocation with the env wired up — credentials dir, HOME, and
/// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars. /// the `PICLOUD_URL`/`PICLOUD_TOKEN` shortcut env vars.
pub fn pic_as(env: &TestEnv) -> AssertCommand { pub fn pic_as(env: &TestEnv) -> AssertCommand {
@@ -183,6 +203,53 @@ pub fn fixture_path(name: &str) -> PathBuf {
.join(name) .join(name)
} }
/// Create a fresh app and deploy a `tests/fixtures/<fixture_name>` into
/// it. Returns the new script id plus the `AppGuard` that cleans the
/// app (and its scripts via `force=true`) on Drop. Used by invoke /
/// logs / output journeys that all need "deploy something, then drive
/// `pic` against it".
pub fn deploy_fixture(
env: &TestEnv,
app_label: &str,
fixture_name: &str,
) -> (String, cleanup::AppGuard) {
let slug = unique_slug(app_label);
pic_as(env)
.args(["apps", "create", &slug])
.assert()
.success();
let guard = cleanup::AppGuard::new(&env.url, &env.token, &slug);
let fixture = fixture_path(fixture_name);
pic_as(env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
let out = pic_as(env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
let id = parse_first_id(std::str::from_utf8(&out.stdout).unwrap())
.expect("scripts ls should produce one row");
(id, guard)
}
/// Split a row from `pic apps ls` / `pic scripts ls` into trimmed
/// cells. The output writer space-pads each cell to its column's max
/// width before the tab, so raw `split('\t')` leaves trailing spaces;
/// this helper hides that detail from tests that only care about the
/// logical values.
pub fn cells(row: &str) -> Vec<&str> {
row.split('\t').map(str::trim).collect()
}
/// First data row's first tab-delimited cell, used to extract IDs from /// First data row's first tab-delimited cell, used to extract IDs from
/// `pic scripts ls` output. The header is expected to start with "id". /// `pic scripts ls` output. The header is expected to start with "id".
pub fn parse_first_id(table: &str) -> Option<String> { pub fn parse_first_id(table: &str) -> Option<String> {

View File

@@ -0,0 +1,7 @@
// Returns a structured 500. The execution is still `Success` in the
// log because the script ran cleanly — for an `Error`-status log entry
// use throw.rhai instead.
#{
statusCode: 500,
body: #{ ok: false, why: "intentional" },
}

View File

@@ -0,0 +1,6 @@
// Echoes the request body and headers back so invoke tests can verify
// that `--body` (inline / @file / @-) and `-H` flow through end-to-end.
#{
body: ctx.request.body,
headers: ctx.request.headers,
}

View File

@@ -0,0 +1,5 @@
// Logs a long line so the logs-truncation test has something to chew on.
// `pic logs` truncates the summary cell to 120 characters; this line is
// 240 chars after the prefix so the truncation is unambiguous.
log::info("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx");
#{ ok: true }

View File

@@ -0,0 +1,4 @@
// Throws a Rhai runtime error. The orchestrator records this as
// `ExecutionStatus::Error` in the execution log (a structured 5xx
// response is recorded as `Success`).
throw "boom";

View File

@@ -1,131 +0,0 @@
//! End-to-end smoke test: login → whoami → apps create → apps ls →
//! scripts deploy (create + update) → scripts ls → scripts invoke →
//! logs. The original seed test, refactored to run against the shared
//! fixture so subsequent journey modules don't each pay for a server
//! spawn.
#![allow(clippy::too_many_lines)]
use predicates::prelude::*;
use serde_json::Value;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn end_to_end_login_deploy_invoke_logs() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("e2e");
let username = &fx.admin_username;
// 1) login
common::pic_as(&env)
.args(["login"])
.assert()
.success()
.stdout(predicate::str::contains(format!("Logged in as {username}")));
let creds_path = env.config_dir.path().join("credentials");
assert!(
creds_path.exists(),
"credentials file should exist after login"
);
let body = std::fs::read_to_string(&creds_path).unwrap();
assert!(body.contains(&env.url), "creds should contain url: {body}");
assert!(
body.contains(username.as_str()),
"creds should contain username: {body}"
);
// 2) whoami
common::pic_as(&env)
.args(["whoami"])
.assert()
.success()
.stdout(predicate::str::contains(username.clone()));
// 3) apps create
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success()
.stdout(predicate::str::contains(format!("Created app {slug}")));
// Cleanup no matter what subsequent assertions do.
let _guard = AppGuard::new(&env.url, &env.token, &slug);
// 4) apps ls
common::pic_as(&env)
.args(["apps", "ls"])
.assert()
.success()
.stdout(predicate::str::contains(slug.as_str()));
// 5) scripts deploy (create then update)
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains("Created hello v1"));
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains("Updated hello v2"));
// 6) scripts ls and capture the id
let ls_out = common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
assert!(ls_out.status.success(), "scripts ls failed: {ls_out:?}");
let id = common::parse_first_id(std::str::from_utf8(&ls_out.stdout).unwrap())
.expect("scripts ls should print at least one row");
// 7) invoke
let invoke_out = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("scripts invoke");
assert!(
invoke_out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&invoke_out.stderr)
);
let parsed: Value =
serde_json::from_slice(&invoke_out.stdout).expect("invoke stdout should be JSON");
assert_eq!(
parsed["ok"], true,
"expected hello.rhai response, got {parsed}"
);
// 8) logs (the invoke above should have produced exactly one row)
let logs_out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("pic logs");
assert!(logs_out.status.success(), "logs failed: {logs_out:?}");
let stdout = String::from_utf8_lossy(&logs_out.stdout);
assert!(
stdout.lines().any(|l| !l.trim().is_empty()),
"logs should have at least one row, got: {stdout}"
);
}

View File

@@ -0,0 +1,171 @@
//! `pic scripts invoke` — body sources (inline, `@file`, `@-`), header
//! propagation, exit-code semantics for non-2xx responses, and 404
//! handling for unknown ids.
use predicates::prelude::*;
use serde_json::Value;
use crate::common;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_with_inline_json_body_echoes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-inline", "echo.rhai");
let out = common::pic_as(&env)
.args(["scripts", "invoke", &id, "--body", r#"{"x":1}"#])
.output()
.expect("invoke");
assert!(out.status.success(), "invoke failed: {out:?}");
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
assert_eq!(parsed["body"]["x"], 1, "echoed body: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_with_file_body() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-file", "echo.rhai");
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), r#"{"src":"file"}"#).unwrap();
let body_arg = format!("@{}", tmp.path().display());
let out = common::pic_as(&env)
.args(["scripts", "invoke", &id, "--body", &body_arg])
.output()
.expect("invoke");
assert!(out.status.success(), "invoke failed: {out:?}");
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
assert_eq!(parsed["body"]["src"], "file", "echoed body: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_with_stdin_body() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-stdin", "echo.rhai");
let assert = common::pic_as(&env)
.args(["scripts", "invoke", &id, "--body", "@-"])
.write_stdin(r#"{"src":"stdin"}"#)
.assert()
.success();
let out = assert.get_output();
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
assert_eq!(parsed["body"]["src"], "stdin", "echoed body: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_propagates_headers() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-hdr", "echo.rhai");
let out = common::pic_as(&env)
.args([
"scripts",
"invoke",
&id,
"-H",
"X-Foo: bar",
"-H",
"X-Baz=qux",
])
.output()
.expect("invoke");
assert!(out.status.success(), "invoke failed: {out:?}");
let parsed: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
// HTTP normalises header names to lowercase.
assert_eq!(parsed["headers"]["x-foo"], "bar", "echoed: {parsed}");
assert_eq!(parsed["headers"]["x-baz"], "qux", "echoed: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_unknown_script_id_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
// Any well-formed UUID that doesn't exist server-side. The
// orchestrator's `/execute/{id}` handler returns 404 specifically
// for unknown ids — tighten the predicate so a regressed 401
// wouldn't sneak through.
let bogus = "00000000-0000-0000-0000-000000000000";
common::pic_as(&env)
.args(["scripts", "invoke", bogus])
.assert()
.failure()
.stderr(predicate::str::contains("HTTP 404"));
}
/// `pic invoke <id>` (top-level alias) and `pic scripts invoke <id>`
/// must hit the same handler and produce identical-shape stdout.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn top_level_invoke_alias_works() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "inv-alias", "hello.rhai");
let nested = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("scripts invoke");
assert!(nested.status.success());
let nested_body: Value = serde_json::from_slice(&nested.stdout).unwrap();
let aliased = common::pic_as(&env)
.args(["invoke", &id])
.output()
.expect("invoke (top-level)");
assert!(aliased.status.success());
let aliased_body: Value = serde_json::from_slice(&aliased.stdout).unwrap();
assert_eq!(
nested_body, aliased_body,
"top-level alias should produce identical body to scripts invoke"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_non_2xx_exits_nonzero_but_prints_body() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "invoke-500", "boom.rhai");
let out = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("invoke");
assert!(!out.status.success(), "expected non-zero exit: {out:?}");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("<- HTTP 500"),
"stderr should report HTTP 500: {stderr}"
);
let parsed: Value = serde_json::from_slice(&out.stdout)
.unwrap_or_else(|e| panic!("stdout was not JSON ({e}): {:?}", out.stdout));
assert_eq!(parsed["ok"], false, "boom body: {parsed}");
assert_eq!(parsed["why"], "intentional", "boom body: {parsed}");
}

View File

@@ -0,0 +1,179 @@
//! `pic logs <script-id>` — emptiness, status labels, `--limit`
//! clamping, error path for unknown ids, and the 120-char truncate
//! applied to the summary column.
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
/// "no non-empty lines means no logs" check needs to skip it.
fn data_rows(stdout: &str) -> Vec<&str> {
stdout
.lines()
.filter(|l| !l.trim().is_empty())
.filter(|l| !l.starts_with("created_at"))
.collect()
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_for_fresh_script_is_empty() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-empty", "hello.rhai");
let out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
data_rows(&stdout).is_empty(),
"expected no log rows (header is allowed), got: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_after_invoke_records_success_row() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-ok", "hello.rhai");
common::pic_as(&env)
.args(["scripts", "invoke", &id])
.assert()
.success();
let out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let rows = data_rows(&stdout);
assert_eq!(rows.len(), 1, "expected 1 data row, got: {stdout}");
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:?}"
);
assert_eq!(cols[1], "success");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_records_error_for_throwing_script() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-err", "throw.rhai");
// The invoke is expected to fail — we only care that the execution
// gets recorded with `Error` status.
let _ = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output();
let out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let row = data_rows(&stdout)
.into_iter()
.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}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_respects_limit_flag() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-limit", "hello.rhai");
for _ in 0..3 {
common::pic_as(&env)
.args(["scripts", "invoke", &id])
.assert()
.success();
}
let out = common::pic_as(&env)
.args(["logs", &id, "--limit", "1"])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let rows = data_rows(&stdout).len();
assert_eq!(rows, 1, "expected --limit 1, got rows: {stdout}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_for_unknown_id_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let bogus = "00000000-0000-0000-0000-000000000000";
common::pic_as(&env)
.args(["logs", bogus])
.assert()
.failure()
// 404 specifically — same `NotFound(ScriptId)` path the get/edit
// endpoints use.
.stderr(predicate::str::contains("HTTP 404"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_truncates_long_summary() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "logs-loud", "loud.rhai");
common::pic_as(&env)
.args(["scripts", "invoke", &id])
.assert()
.success();
let out = common::pic_as(&env)
.args(["logs", &id])
.output()
.expect("logs");
assert!(out.status.success(), "logs failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let row = data_rows(&stdout)
.into_iter()
.next()
.expect("at least one data row");
let summary = row.split('\t').nth(2).expect("summary column");
assert!(
summary.ends_with('…'),
"summary should be truncated with `…`, got: {summary}"
);
let chars = summary.chars().count();
assert!(
chars <= 121,
"summary should be ≤120 chars + the truncation marker, got {chars}: {summary}"
);
}

View File

@@ -0,0 +1,289 @@
//! Output-shape invariants — the contracts downstream `jq`/`awk`
//! pipelines depend on: column headers, stdout-vs-stderr separation,
//! and RFC3339 timestamps.
use serde_json::Value;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_header_columns() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let out = common::pic_as(&env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let header = stdout.lines().next().expect("header row");
assert_eq!(
common::cells(header),
vec!["slug", "name", "my_role", "created_at"]
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn scripts_ls_header_columns() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("out-ls");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let header = stdout.lines().next().expect("header row");
assert_eq!(
common::cells(header),
vec!["id", "app_slug", "name", "version", "updated_at"]
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn invoke_separates_stdout_and_stderr() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "out-inv", "hello.rhai");
let out = common::pic_as(&env)
.args(["scripts", "invoke", &id])
.output()
.expect("invoke");
assert!(out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.starts_with("<- HTTP 200"),
"stderr should announce HTTP status: {stderr:?}"
);
let parsed: Value = serde_json::from_slice(&out.stdout)
.expect("stdout should be JSON only, with no status prefix");
assert_eq!(parsed["ok"], true, "body: {parsed}");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn error_goes_to_stderr_not_stdout() {
let Some(_fx) = common::fixture_or_skip() else {
return;
};
// Use a pristine env (no credentials file) so `whoami` is guaranteed
// to fail at the `config::load` step — `admin_env` would pre-seed
// creds and the command would succeed.
let env = common::TestEnv {
url: String::new(),
token: String::new(),
config_dir: tempfile::TempDir::new().unwrap(),
home: tempfile::TempDir::new().unwrap(),
};
let out = common::pic_no_env(&env)
.args(["whoami"])
.output()
.expect("whoami");
assert!(!out.status.success(), "expected failure, got: {out:?}");
assert!(
out.stdout.is_empty(),
"stdout should be empty on error, got: {:?}",
String::from_utf8_lossy(&out.stdout),
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("error:"),
"stderr should be prefixed with `error:`: {stderr}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_created_at_is_rfc3339() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("out-date");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
let stdout = String::from_utf8(out.stdout).unwrap();
let row = stdout
.lines()
.map(common::cells)
.find(|c| c.first().copied() == Some(slug.as_str()))
.unwrap_or_else(|| panic!("slug {slug} missing in: {stdout}"));
let created_at = row.get(3).expect("created_at cell");
// Accept the RFC3339 shape without pulling in chrono — `YYYY-MM-DDTHH:MM:SS`
// with optional fraction + timezone is enough of a contract for the test.
assert!(
created_at.len() >= 20
&& created_at.as_bytes()[4] == b'-'
&& created_at.as_bytes()[7] == b'-'
&& created_at.as_bytes()[10] == b'T'
&& created_at.as_bytes()[13] == b':'
&& created_at.as_bytes()[16] == b':',
"created_at not RFC3339-shaped: {created_at}"
);
}
/// `--output json` is the global pipeline-friendly format. Validates
/// `apps ls` returns a real JSON array (not a TSV-with-quotes hack).
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn apps_ls_json_output_is_valid_array() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("out-json-apps");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let out = common::pic_as(&env)
.args(["--output", "json", "apps", "ls"])
.output()
.expect("apps ls --output json");
assert!(out.status.success(), "apps ls failed: {out:?}");
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout should be JSON");
let arr = v.as_array().expect("apps ls JSON should be an array");
assert!(
arr.iter()
.any(|row| row.get("slug").and_then(Value::as_str) == Some(slug.as_str())),
"json should include created slug: {v}"
);
// The header row must NOT bleed into JSON output — the rendered
// objects use header *keys*, not data cells.
assert!(
arr.iter().all(|row| row.get("slug").is_some()),
"every row should have a `slug` key: {v}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn scripts_ls_json_output_has_app_slug() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("out-json-scr");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success();
let out = common::pic_as(&env)
.args(["--output", "json", "scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls --output json");
assert!(out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
let arr = v.as_array().expect("array");
let row = arr
.iter()
.find(|r| r.get("name").and_then(Value::as_str) == Some("hello"))
.expect("hello row");
assert_eq!(row["app_slug"].as_str(), Some(slug.as_str()));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn logs_json_output_is_array_of_objects() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "out-json-log", "hello.rhai");
common::pic_as(&env)
.args(["scripts", "invoke", &id])
.assert()
.success();
let out = common::pic_as(&env)
.args(["--output", "json", "logs", &id])
.output()
.expect("logs --output json");
assert!(out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).expect("stdout JSON");
let arr = v.as_array().expect("array");
assert!(!arr.is_empty(), "expected at least one log");
// Schema: each row carries the raw `ExecutionLog`, not the
// truncated summary the TSV form uses.
assert!(
arr[0].get("status").is_some(),
"log row missing status: {arr:?}"
);
}
/// TSV `whoami` used to be a single tab-separated line with no labels;
/// downstream tools couldn't tell which column was the role. Now it's
/// a key/value block with stable labels.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn whoami_tsv_has_labeled_rows() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let out = common::pic_as(&env)
.args(["whoami"])
.output()
.expect("whoami");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
let labels: Vec<&str> = stdout
.lines()
.filter_map(|l| l.split('\t').next())
.map(str::trim)
.collect();
assert!(
labels.contains(&"username"),
"missing username row: {stdout}"
);
assert!(labels.contains(&"role"), "missing role row: {stdout}");
assert!(labels.contains(&"email"), "missing email row: {stdout}");
assert!(labels.contains(&"url"), "missing url row: {stdout}");
}

View File

@@ -0,0 +1,146 @@
//! RBAC mirror of the dashboard's role-shadowing specs. A Member user
//! is minted via the admin API, granted (or denied) membership on an
//! app, then `pic` is driven against the member's bearer token to
//! confirm the server's capability gates surface as expected exit
//! codes / error messages.
use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::AppGuard;
use crate::common::member;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn member_apps_ls_only_shows_their_apps() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let admin_env = common::admin_env(fx);
let slug_visible = common::unique_slug("roles-visible");
let slug_hidden = common::unique_slug("roles-hidden");
common::pic_as(&admin_env)
.args(["apps", "create", &slug_visible])
.assert()
.success();
let _g1 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_visible);
common::pic_as(&admin_env)
.args(["apps", "create", &slug_hidden])
.assert()
.success();
let _g2 = AppGuard::new(&admin_env.url, &admin_env.token, &slug_hidden);
let m = member::member_user(fx, &common::unique_username("rls"));
member::grant_membership(fx, &slug_visible, &m.id, "viewer");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let out = common::pic_as(&member_env)
.args(["apps", "ls"])
.output()
.expect("apps ls");
assert!(out.status.success(), "apps ls failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains(&slug_visible),
"member should see {slug_visible}, got: {stdout}"
);
assert!(
!stdout.contains(&slug_hidden),
"member should NOT see {slug_hidden}, got: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn viewer_cannot_deploy_but_editor_can() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let admin_env = common::admin_env(fx);
let slug = common::unique_slug("roles-write");
common::pic_as(&admin_env)
.args(["apps", "create", &slug])
.assert()
.success();
let _g = AppGuard::new(&admin_env.url, &admin_env.token, &slug);
let m = member::member_user(fx, &common::unique_username("vw"));
member::grant_membership(fx, &slug, &m.id, "viewer");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.failure()
// `Forbidden` → 403. A regressed predicate of `"HTTP 4"` would
// have masked an auth break (401) as an authz issue.
.stderr(predicate::str::contains("HTTP 403"));
// Promote to Editor and retry — the same command should now succeed.
member::update_membership(fx, &slug, &m.id, "editor");
common::pic_as(&member_env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains("Created hello v1"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn member_can_invoke_any_script_with_id() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
// `/api/v1/execute/{id}` is the unguarded data-plane ingress — even
// a member with no app membership can hit it as long as they hold
// a valid token (the orchestrator doesn't gate it).
let admin_env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&admin_env, "roles-inv", "hello.rhai");
let m = member::member_user(fx, &common::unique_username("inv"));
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
common::pic_as(&member_env)
.args(["scripts", "invoke", &id])
.assert()
.success();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn non_member_cannot_read_logs() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let admin_env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&admin_env, "roles-log", "hello.rhai");
let m = member::member_user(fx, &common::unique_username("rl"));
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
common::pic_as(&member_env)
.args(["logs", &id])
.assert()
.failure()
// Non-member → 403 from the authz layer, not 404 — the script
// exists; the caller just can't see it.
.stderr(predicate::str::contains("HTTP 403"));
}

View File

@@ -0,0 +1,240 @@
//! `pic scripts deploy` / `pic scripts ls` edge cases beyond the
//! smoke test: unknown app, name override, version bumping, missing
//! file, and the no-`--app` walk across every accessible app.
use predicates::prelude::*;
use crate::common;
use crate::common::cleanup::AppGuard;
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_against_unknown_app_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let fixture = common::fixture_path("hello.rhai");
let bogus_slug = common::unique_slug("nope");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&bogus_slug,
])
.assert()
.failure()
// Specifically 404 — `apps_get` short-circuits before the deploy
// request even starts. Loose `"HTTP 4"` would have matched a
// regressed 401 from broken auth.
.stderr(predicate::str::contains("HTTP 404"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_with_name_override() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("scripts-named");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let fixture = common::fixture_path("hello.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
"--name",
"custom-name",
])
.assert()
.success()
.stdout(predicate::str::contains("Created custom-name v1"));
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
"--name",
"custom-name",
])
.assert()
.success()
.stdout(predicate::str::contains("Updated custom-name v2"));
let out = common::pic_as(&env)
.args(["scripts", "ls", "--app", &slug])
.output()
.expect("scripts ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout
.lines()
.map(common::cells)
.any(|c| c.get(2).copied() == Some("custom-name") && c.get(3).copied() == Some("2")),
"expected custom-name v2 row, got: {stdout}",
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_bumps_version_each_redeploy() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("scripts-bump");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
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)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.success()
.stdout(predicate::str::contains(expected));
}
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn deploy_missing_file_errors() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("scripts-missing");
common::pic_as(&env)
.args(["apps", "create", &slug])
.assert()
.success();
let _guard = AppGuard::new(&env.url, &env.token, &slug);
let missing = std::env::temp_dir().join(common::unique_slug("ghost") + ".rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
missing.to_str().unwrap(),
"--app",
&slug,
])
.assert()
.failure()
.stderr(predicate::str::contains("reading"));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn ls_without_app_walks_every_accessible_app() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug_a = common::unique_slug("scripts-walk-a");
let slug_b = common::unique_slug("scripts-walk-b");
common::pic_as(&env)
.args(["apps", "create", &slug_a])
.assert()
.success();
let _guard_a = AppGuard::new(&env.url, &env.token, &slug_a);
common::pic_as(&env)
.args(["apps", "create", &slug_b])
.assert()
.success();
let _guard_b = AppGuard::new(&env.url, &env.token, &slug_b);
let fixture = common::fixture_path("hello.rhai");
for slug in [&slug_a, &slug_b] {
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
slug,
])
.assert()
.success();
}
// `pic scripts ls` (no `--app`) issues a single `GET /admin/scripts`
// against the server now — there's nothing per-app to race against
// a concurrent AppGuard drop. The previous implementation walked
// `apps_list` followed by per-app `scripts_list_by_app` calls and
// aborted on the first 404, which forced this test to retry 5× to
// paper over the bug. Both the walk and the retry are gone.
let out = common::pic_as(&env)
.args(["scripts", "ls"])
.output()
.expect("scripts ls");
assert!(out.status.success(), "scripts ls failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let slugs: std::collections::HashSet<&str> = stdout
.lines()
.map(common::cells)
.filter_map(|c| c.get(1).copied())
.collect();
assert!(
slugs.contains(slug_a.as_str()),
"missing app A in: {stdout}"
);
assert!(
slugs.contains(slug_b.as_str()),
"missing app B in: {stdout}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn delete_removes_script_from_ls() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let (id, _guard) = common::deploy_fixture(&env, "scripts-del", "hello.rhai");
common::pic_as(&env)
.args(["scripts", "delete", &id])
.assert()
.success()
.stdout(predicate::str::contains(format!("Deleted script {id}")));
let out = common::pic_as(&env)
.args(["scripts", "ls"])
.output()
.expect("scripts ls");
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
!stdout.contains(&id),
"deleted script id should not appear in ls: {stdout}"
);
}

View File

@@ -11,21 +11,22 @@ use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits}; use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{ use picloud_manager_core::{
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router, admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
auth_router, compile_routes, migrations, require_authenticated, route_admin_router, attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated,
AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState,
AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, AppRepository, AppsState, AuthState, AuthzRepo, PostgresAdminSessionRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository,
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, PostgresExecutionLogSink, PostgresRouteRepository, PostgresScriptRepository, RepoResolver,
RouteRepository, SandboxCeiling, RouteAdminState, RouteRepository, SandboxCeiling,
}; };
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
use picloud_orchestrator_core::{ use picloud_orchestrator_core::{
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient, data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, LocalExecutorClient,
}; };
use picloud_shared::{ use picloud_shared::{
ExecutionLogSink, ScriptValidator, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION, ExecutionLogSink, ScriptValidator, Services, API_VERSION, PRODUCT_VERSION, SDK_VERSION,
WIRE_VERSION,
}; };
use sqlx::postgres::PgPoolOptions; use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool; use sqlx::PgPool;
@@ -82,7 +83,9 @@ fn read_session_ttl() -> Duration {
/// `/version`) stays open — it's the public ingress for user scripts. /// `/version`) stays open — it's the public ingress for user scripts.
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> { pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let engine = Arc::new(Engine::new(Limits::default())); // `Services` is the SDK service bundle. Empty in v1.1.0; the
// v1.1.1 KV PR will populate it with `kv: Arc::new(...)` here.
let engine = Arc::new(Engine::new(Limits::default(), Services::new()));
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone())); let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone())); let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
@@ -126,7 +129,10 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle( let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
script_repo.clone(), script_repo.clone(),
))); )));
let executor = Arc::new(LocalExecutorClient::new(engine.clone())); // Single global gate — overflow is rejected with 503 + Retry-After.
// See `ExecutionGate` docs and `PICLOUD_MAX_CONCURRENT_EXECUTIONS`.
let gate = Arc::new(ExecutionGate::from_env());
let executor = Arc::new(LocalExecutorClient::new(engine.clone(), gate));
let admin = AdminState { let admin = AdminState {
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())), repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
@@ -200,16 +206,31 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
// facade above; the bare module path is retained so it's discoverable. // facade above; the bare module path is retained so it's discoverable.
let _ = apps_api::AppsState::clone; let _ = apps_api::AppsState::clone;
// Opportunistic principal extraction on every data-plane request.
// Always inserts `Extension<Option<Principal>>`: Some for authed
// ingress (bearer / cookie), None otherwise. Handlers depend on
// this layer being applied — scoped to the data-plane routers so
// the admin path (which uses `require_authenticated`) doesn't
// double-resolve the same token.
let data_plane_routed = data_plane_router(data_plane.clone()).layer(from_fn_with_state(
auth_state.clone(),
attach_principal_if_present,
));
let user_routes = user_routes_router(data_plane).layer(from_fn_with_state(
auth_state.clone(),
attach_principal_if_present,
));
let api_v1 = Router::new() let api_v1 = Router::new()
.nest("/admin", auth_router(auth_state)) .nest("/admin", auth_router(auth_state))
.nest("/admin", guarded_admin) .nest("/admin", guarded_admin)
.merge(data_plane_router(data_plane.clone())); .merge(data_plane_routed);
Ok(Router::new() Ok(Router::new()
.route("/healthz", get(healthz)) .route("/healthz", get(healthz))
.route("/version", get(version)) .route("/version", get(version))
.nest(&format!("/api/v{API_VERSION}"), api_v1) .nest(&format!("/api/v{API_VERSION}"), api_v1)
.merge(user_routes_router(data_plane)) .merge(user_routes)
.layer(TraceLayer::new_for_http())) .layer(TraceLayer::new_for_http()))
} }

119
crates/shared/src/events.rs Normal file
View File

@@ -0,0 +1,119 @@
//! `ServiceEventEmitter` — the contract every stateful SDK service uses
//! to publish events into the (future) triggers framework.
//!
//! v1.1.0 ships only the trait shape and a `NoopEventEmitter` that
//! drops every event. The real outbox-backed implementation lands with
//! the triggers PR in v1.1.1; locking the trait now means services
//! written in subsequent v1.1.x PRs (KV, docs, files, …) don't have to
//! re-thread their plumbing when the dispatcher arrives.
//!
//! Design rationale (full discussion: `docs/sdk-shape.md`):
//! * Async — outbox writes hit Postgres.
//! * Cx is passed in so the emitter can attribute the event to the
//! `app_id` / `principal` / `execution_id` that produced it.
//! * Events carry their semantic identity (`source` + `op`) plus
//! optional locator (`collection` + `key`) and optional payloads
//! (`payload` for the new value, `old_payload` for the previous on
//! updates). The dispatcher matches on (source, op, collection)
//! filters to decide which scripts to fan out to.
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
/// Trait every stateful service depends on to emit events. The host
/// binary constructs one instance and clones the Arc into each service.
#[async_trait]
pub trait ServiceEventEmitter: Send + Sync {
/// Publish a single event. Implementations are expected to be
/// fire-and-forget from the caller's perspective: the outbox impl
/// will return `Ok(())` once the event is durably persisted, the
/// dispatcher reads it out-of-band.
async fn emit(&self, cx: &SdkCallCx, event: ServiceEvent) -> Result<(), EmitError>;
}
/// One service event. `source` and `op` are `&'static str` because they
/// come from a fixed enumeration baked into each service (`"kv"` +
/// `"insert"`/`"update"`/`"delete"`, etc.) — never from user data.
/// `collection`/`key`/payloads come from user data and are owned.
#[derive(Debug, Clone)]
pub struct ServiceEvent {
/// Service namespace. Matches the Rhai module name: `"kv"`,
/// `"docs"`, `"files"`, etc.
pub source: &'static str,
/// Operation verb. Each service defines its own vocabulary;
/// dispatcher filters match on the literal string.
pub op: &'static str,
/// Affected collection, when the service is collection-scoped
/// (`kv`, `docs`, `files`). `None` for collection-less events.
pub collection: Option<String>,
/// Affected key/id within the collection, when applicable.
pub key: Option<String>,
/// New value after the operation, when carrying it is cheap and
/// useful. `None` for deletes.
pub payload: Option<serde_json::Value>,
/// Previous value before the operation, populated on `update` /
/// `delete` so triggers can diff. `None` on `insert`.
pub old_payload: Option<serde_json::Value>,
}
/// Errors an emitter can surface upward. The noop impl never returns
/// these; the v1.1.1 outbox impl uses `Unavailable` for pool/connection
/// failures and `Rejected` for malformed payloads (e.g. event JSON too
/// large for the outbox row).
#[derive(Debug, Error)]
pub enum EmitError {
#[error("event sink unavailable: {0}")]
Unavailable(String),
#[error("event sink rejected event: {0}")]
Rejected(String),
}
/// Default emitter for v1.1.0. Accepts every event, persists nothing,
/// always returns `Ok(())`. Wired in the picloud binary; the v1.1.1
/// triggers PR swaps this for a Postgres outbox writer.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopEventEmitter;
#[async_trait]
impl ServiceEventEmitter for NoopEventEmitter {
async fn emit(&self, _cx: &SdkCallCx, _event: ServiceEvent) -> Result<(), EmitError> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
// Compile-time check that ServiceEventEmitter is dyn-safe — every
// service holds it as `Arc<dyn ServiceEventEmitter>` and would
// silently break the workspace if a non-object-safe method snuck
// in. Behavioural tests for the noop impl come for free once a
// service exercises it (v1.1.1+); avoid pulling tokio into
// `picloud-shared` just for a one-line `emit().await` check.
#[allow(dead_code)]
fn assert_dyn_compatible(_e: &dyn ServiceEventEmitter) {}
#[test]
fn service_event_construction_is_explicit() {
// Pin the field layout so a re-ordering in a future PR causes a
// compile failure here rather than silently misattributing
// events. Hash-derive isn't appropriate (serde_json::Value isn't
// Hash), so structural construction is the assertion.
let _ = ServiceEvent {
source: "kv",
op: "insert",
collection: Some("widgets".into()),
key: Some("k1".into()),
payload: Some(serde_json::json!({"v": 1})),
old_payload: None,
};
}
}

View File

@@ -7,23 +7,29 @@
pub mod app; pub mod app;
pub mod auth; pub mod auth;
pub mod error; pub mod error;
pub mod events;
pub mod execution_log; pub mod execution_log;
pub mod ids; pub mod ids;
pub mod log_sink; pub mod log_sink;
pub mod route; pub mod route;
pub mod sandbox; pub mod sandbox;
pub mod script; pub mod script;
pub mod sdk_cx;
pub mod services;
pub mod validator; pub mod validator;
pub mod version; pub mod version;
pub use app::{App, AppDomain, DomainShape}; pub use app::{App, AppDomain, DomainShape};
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId}; pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
pub use error::Error; pub use error::Error;
pub use events::{EmitError, NoopEventEmitter, ServiceEvent, ServiceEventEmitter};
pub use execution_log::{ExecutionLog, ExecutionStatus}; pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId}; pub use ids::{AdminUserId, ApiKeyId, AppId, ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route}; pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox; pub use sandbox::ScriptSandbox;
pub use script::Script; pub use script::Script;
pub use sdk_cx::SdkCallCx;
pub use services::Services;
pub use validator::{ScriptValidator, ValidationError}; pub use validator::{ScriptValidator, ValidationError};
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};

View File

@@ -0,0 +1,54 @@
//! `SdkCallCx` — per-call context every stateful SDK service receives.
//!
//! Service trait methods (added by subsequent v1.1.x PRs starting with
//! KV) all take `&SdkCallCx` so they can:
//! * scope by `app_id` for cross-app isolation,
//! * audit `principal` when authenticated,
//! * carry `execution_id` / `request_id` into emitted events,
//! * bound trigger chains via `trigger_depth` / `root_execution_id`.
//!
//! The struct lives in `picloud-shared` (not `executor-core`) because
//! future service impls live in `manager-core` and the trait that hands
//! the cx in is shared by both sides. Pure value type — no handles, no
//! DB pool references, no allocations beyond what's in `Principal`.
use crate::{AppId, ExecutionId, Principal, RequestId};
/// Per-invocation context for every stateful SDK service call.
///
/// Constructed once at the start of an invocation by `executor-core`
/// from the incoming `ExecRequest`, then handed (by reference) to every
/// service trait method the script triggers during execution. Services
/// MUST derive `app_id` from this struct — never from script-passed
/// arguments — to preserve cross-app isolation.
#[derive(Debug, Clone)]
pub struct SdkCallCx {
/// Owning application for this invocation. Source of truth for
/// every `(app_id, …)` storage lookup the script makes.
pub app_id: AppId,
/// Caller identity, when authenticated. `None` for unauthenticated
/// data-plane HTTP requests (the common case for public endpoints);
/// `Some` when the call came in via the dashboard, an API key, or a
/// future authed surface.
pub principal: Option<Principal>,
/// Unique id for THIS execution. Matches `ExecRequest.execution_id`.
pub execution_id: ExecutionId,
/// Unique id for the ingress request that started the chain. The
/// same `request_id` is shared across every execution triggered by
/// the same request (direct + trigger fan-out).
pub request_id: RequestId,
/// `0` for direct invocations (HTTP request, manual run). Each
/// indirect invocation through the triggers framework (v1.1.1)
/// increments this; the dispatcher rejects beyond a configured
/// ceiling to prevent runaway feedback loops.
pub trigger_depth: u32,
/// `== execution_id` when `trigger_depth == 0`; otherwise the
/// `execution_id` of the original ingress execution. Lets the audit
/// log group every fan-out execution under the originating event.
pub root_execution_id: ExecutionId,
}

View File

@@ -0,0 +1,38 @@
//! `Services` — bundle of stateful SDK service handles plumbed from the
//! host binary into every Rhai execution.
//!
//! v1.1.0 ships this struct empty. Subsequent PRs in the v1.1.x series
//! add one field per service:
//!
//! ```ignore
//! pub kv: Arc<dyn KvService>, // v1.1.1
//! pub docs: Arc<dyn DocsService>, // v1.1.2
//! pub http: Arc<dyn HttpService>, // v1.1.4
//! // …
//! ```
//!
//! The bundle is cheap to clone (`Arc` per service) and is constructed
//! once at startup in the picloud binary. The executor takes it by
//! reference per invocation, hands it (alongside an `SdkCallCx`) to
//! `executor-core::sdk::register_all`, which wires the corresponding
//! Rhai `::` namespace per service.
//!
//! `#[non_exhaustive]` so adding fields is a non-breaking change for
//! consumers that only *pattern-match* a `&Services`; only crates that
//! *construct* a `Services` (in practice, just the picloud binary) need
//! to update their constructor when new services land.
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
/// expansion plan.
#[non_exhaustive]
#[derive(Default)]
pub struct Services {}
impl Services {
/// Construct an empty bundle. Replaced by a fielded `::new(...)`
/// once the first service (KV, v1.1.1) lands.
#[must_use]
pub fn new() -> Self {
Self {}
}
}

227
docs/sdk-shape.md Normal file
View File

@@ -0,0 +1,227 @@
# SDK shape (v1.1.x stateful services)
This document describes the architectural shape every v1.1.x SDK
service follows. It is **not** a feature reference for any particular
service — those live in their own docs as each PR lands (KV in v1.1.1,
docs in v1.1.2, …). What follows is the contract those PRs implement
against, so the surface stays consistent and the build doesn't drift.
The shape was laid down in v1.1.0 (the SDK foundation PR). If you find
yourself re-litigating any of it inside a service PR, push back and
update this doc explicitly first.
## Two kinds of Rhai modules
**Stateless utility modules** (regex, time, json, base64, hex, url —
landing as v1.1.0's stdlib PR) are registered once at engine build.
They have no per-call state and no cross-app sensitivity. Implementation
goes in `executor-core::engine::build_engine` next to the existing
`log::` registration. They use Rhai's `register_static_module`.
**Stateful service modules** (kv, docs, http, cron, files, pubsub,
secrets, email, users, queue, invoke) are registered **per call** by
`executor-core::sdk::register_all`. They need:
- A service handle bundled in `picloud_shared::Services` (constructed
once at startup, cloned cheaply per call).
- A per-call `SdkCallCx` carrying the calling app, principal,
execution ids, and trigger depth.
- Closures that capture both, registered as Rhai native functions
inside a per-call `rhai::Module`.
Mixing the two categories in one module is wrong — services that
internally consult per-call context are stateful, period.
## `::` namespace style
Every SDK module exposes itself under a `::` namespace, mirroring the
existing `log::`:
```rhai
log::info("hello"); // v1.0 — present
let value = kv::collection("widgets").get("k"); // v1.1.1
let resp = http::get("https://example.com"); // v1.1.4
```
Dotted-object syntax (`kv.get("widgets", "k")`) is **not** used.
Rationale: `::` is consistent with Rust import syntax, doesn't
require a wrapper "module object" in Rhai's scope, and keeps the
module boundary obvious in scripts.
## Handle pattern for collection-scoped services
Services that operate on collections expose a **collection handle**
returned by an `::collection(name)` constructor:
```rhai
let widgets = kv::collection("widgets");
widgets.set("k", "v");
let v = widgets.get("k");
```
Not `kv::set("widgets", "k", "v")`. The handle is a Rhai custom type
the service registers; method calls bind to that type. This:
- Removes the "did I get the collection-name argument right?" foot-gun.
- Lets the implementation cache per-collection state on the handle
(prepared statements, connection affinity) without leaking that
into the call signature.
- Pre-empts the "collection is implicit" failure mode where two
services in the same script accidentally share a default collection.
`(app_id, collection, key)` is the identity tuple for KV; `(app_id,
collection, id)` for docs. Collections are **mandatory**, not optional
— even single-collection apps name their collection. The service layer
rejects requests with empty collection names.
## Error convention
- **Throw on failure.** `widgets.set("k", "v")` throws a Rhai runtime
error on any operational problem (DB unavailable, payload too large,
authz denied). Scripts opting into error handling use Rhai's
`try/catch`.
- **`()` for absent.** `widgets.get("missing")` returns `()` (Rhai
unit). Scripts test absence with `if v == () { ... }` or use the
matching `has(k)` predicate.
- **`bool` for predicates.** `widgets.has(k)` is the cheap existence
check that doesn't deserialize the value.
This convention is uniform across every v1.1.x service. Adding
`Result`-flavoured variants is a design departure that requires a doc
update before implementation.
## `SdkCallCx` and cross-app isolation
Every stateful service trait method takes `&SdkCallCx` as its first
non-self argument. The cx carries:
```rust
pub struct SdkCallCx {
pub app_id: AppId,
pub principal: Option<Principal>,
pub execution_id: ExecutionId,
pub request_id: RequestId,
pub trigger_depth: u32,
pub root_execution_id: ExecutionId,
}
```
**The service implementation MUST derive `app_id` from `cx.app_id`
never from a script-passed argument.** Scripts cannot name another
app's data, period. The closure registered into Rhai captures the
`Arc<SdkCallCx>` for the call; the script never sees or passes
`app_id`.
Why this matters: a `kv::set("widgets", "k", v)` call with a
script-supplied `app_id` would be a tenant-isolation vulnerability if
that arg ever leaked into the storage query. By deriving from the
host-attached cx, the service can't be tricked.
`principal` is `Option<Principal>` because the data plane is
unauthenticated by default — public HTTP scripts run with `None`.
Services that need an authenticated identity (e.g., `users::*`) check
`cx.principal.is_some()` and throw if missing.
## Sync ↔ async bridge
Rhai is synchronous; service trait methods (KV writes, HTTP calls) are
async. The bridge runs *inside the `spawn_blocking` thread* that
already wraps `Engine::execute` (orchestrator-core's
`LocalExecutorClient`):
```rust
// Inside a Rhai-registered closure.
let runtime = tokio::runtime::Handle::current();
let result = runtime.block_on(service.do_thing(&cx, args));
```
`Handle::current()` finds the same Tokio runtime that scheduled the
`spawn_blocking`, so the `block_on` doesn't construct a fresh runtime.
The thread is already off the async worker pool (that's what
`spawn_blocking` does), so blocking inside it is safe.
This pattern goes in every stateful service's registered Rhai closure.
The first service PR (KV, v1.1.1) lands a helper so subsequent services
don't reinvent the boilerplate.
## `ServiceEventEmitter`
Every stateful service that mutates data also emits events for the
(future) triggers framework:
```rust
emitter.emit(&cx, ServiceEvent {
source: "kv",
op: "insert",
collection: Some("widgets".into()),
key: Some("k".into()),
payload: Some(new_value_json),
old_payload: None,
}).await?;
```
v1.1.0 ships only `NoopEventEmitter`. The v1.1.1 triggers PR replaces
that with an outbox-backed implementation: events land in a Postgres
outbox table; a dispatcher worker reads them out-of-band, matches
against registered triggers, and fans out script executions. The
dispatcher enforces a depth limit via `cx.trigger_depth` so a
trigger-fires-its-own-trigger chain can't run away.
Services hold `Arc<dyn ServiceEventEmitter>` and emit unconditionally;
the noop drops events, the real impl persists them. From the service's
perspective the emission is fire-and-forget.
## `ExecutionGate` and `PICLOUD_MAX_CONCURRENT_EXECUTIONS`
A single global semaphore caps concurrent script executions. Default
is 32; override via the `PICLOUD_MAX_CONCURRENT_EXECUTIONS` env var.
Acquisition is **non-blocking, no queue** — if a permit isn't free,
the request is refused immediately with HTTP 503 and a `Retry-After:
1` header.
Rationale: Rhai execution runs under `spawn_blocking`, which uses a
finite pool of blocking threads (defaults to 512 in current Tokio).
Without a cap, a script storm parks every blocking thread and starves
every other workload (DB writes, log sinks, audit emission). Hard
pushback is preferable to silent degradation.
Per-app or per-script caps are deferred until a real workload demands
them. The gate lives in `orchestrator-core::gate::ExecutionGate` and
is constructed once in the picloud binary's `build_app`.
## Registration: where future services hook in
```rust
// orchestrator-core / executor-core internal call path —
// you do not implement this; you implement registration helpers
// that future PRs call from here.
pub fn register_all(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
// v1.1.1: register_kv(engine, services, cx.clone());
// v1.1.2: register_docs(engine, services, cx.clone());
// …
}
```
Each service PR adds:
1. A `Service` trait + impl in `manager-core` (since that's where the
DB-backed implementations live).
2. A field on `picloud_shared::Services` (`pub kv: Arc<dyn KvService>`).
3. A `register_kv` helper inside `executor-core::sdk::kv` that takes
the engine, the service, and the cx, then registers the Rhai
`::collection(...)` constructor and method bindings.
4. A new `Capability` variant in `manager-core::authz` (e.g.
`AppKvRead(AppId)`) and a check inside the service impl.
That sequence is the entire mechanical pattern; nothing here should
require architecture-level discussion past v1.1.0.
## What this doc does NOT cover
- Service-specific schemas (KV table layout, docs query DSL, etc.) —
in each service PR.
- Authentication and the admin auth model — see blueprint §11.5,
§11.6 and Phase 3.5.
- The trigger dispatch design (outbox row layout, fan-out semantics,
trigger CRUD endpoints) — comes with v1.1.1.
- Cluster mode considerations — deferred to v1.3+.

View File

@@ -661,7 +661,7 @@ users.set_permissions(user_id, {
|-------|-----------|-----------| |-------|-----------|-----------|
| **Orchestrator** | Rust + Axum | Performance, safety, async-first; minimal overhead | | **Orchestrator** | Rust + Axum | Performance, safety, async-first; minimal overhead |
| **Dashboard** | Alpine.js + vanilla HTML/CSS | Zero dependencies, simple to deploy, fast enough for MVP | | **Dashboard** | Alpine.js + vanilla HTML/CSS | Zero dependencies, simple to deploy, fast enough for MVP |
| **Database** | PostgreSQL + hstore | Robust ACID database; hstore extension for lightweight KV (v1.1) | | **Database** | PostgreSQL 15+ (`pgcrypto`) | Robust ACID database; JSONB carries data-plane values (v1.1+). See §8.1. |
| **Container Runtime** | Docker (Docker daemon) | Industry standard, simple CLI | | **Container Runtime** | Docker (Docker daemon) | Industry standard, simple CLI |
| **Executor Image** | Alpine Linux + Rhai | Minimal image size (~50-100MB), fast startup | | **Executor Image** | Alpine Linux + Rhai | Minimal image size (~50-100MB), fast startup |
| **Scripting** | Rhai | Lightweight, embedded-friendly, safe by default | | **Scripting** | Rhai | Lightweight, embedded-friendly, safe by default |
@@ -1022,9 +1022,9 @@ The scripts and routes endpoints keep their existing shape — this avoids forci
--- ---
## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — Pending ## 11.6 Users, roles, and bearer-token auth (Phase 3.5) — ✓ Shipped
**Status**: pending. Targets `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, an extended `auth_middleware.rs`, new shared types under `crates/shared/src/auth.rs`, migration `0006_users_authz.sql`. **Status**: shipped, ahead of the originally planned slot. Lives in `crates/manager-core/src/{authz,api_keys_api,api_key_repo}.rs`, the extended `auth_middleware.rs`, shared types under `crates/shared/src/auth.rs`, and migration `0006_users_authz.sql`. `can(principal, capability)` and `require(principal, capability)` are the single gate every admin handler goes through.
**Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth. **Purpose**: bridge Phase 3b → Phase 4. Phase 4's v1.1 SDKs (KV, docs, HTTP, cron) each gate access on the calling principal. Without a real authorization model in place, every SDK addition has to either invent its own gate or stay open. Phase 3.5 lands `can(principal, capability)` as the single check every future SDK + admin endpoint goes through, so v1.1 work focuses on data plane shape, not on re-litigating auth.
@@ -1223,7 +1223,7 @@ Defer to follow-up sessions: dashboard surfaces for invites / key minting (curl
--- ---
### Phase 3: v1.0.x — Foundations (Current focus) ### Phase 3: v1.0.x — Foundations ✓ (Shipped)
Three foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive. Three foundation pieces that must land before the v1.1 service expansion, because retrofitting them later is expensive.
@@ -1231,24 +1231,27 @@ Three foundation pieces that must land before the v1.1 service expansion, becaus
**3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred. **3b. Multi-app scoping** — ✓ shipped. See section 11.5. `apps`, `app_domains`, `app_slug_history` tables; `app_id` columns on `scripts`, `routes`, `execution_logs`. Migration assigns existing data to a `default` app and always claims `localhost`; a Rust-side bootstrap inserts a `Hello World` script + `/hello` route when the default app is empty. Orchestrator dispatch is two-phase (Host → app → route trie). `/api/v1/execute/{id}/*` continues to work without a public domain claim. Dashboard is app-hierarchical (`/admin/apps`, `/admin/apps/{slug}/...`); API stays flat with new endpoints under `/api/v1/admin/apps/*` and a `?app=` filter on script listing. Per-app admin roles deferred.
**3c. Users, roles, and bearer-token auth** — pending. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only. **3c. Users, roles, and bearer-token auth (Phase 3.5)** — ✓ shipped. See section 11.6. Adds `instance_role` to `admin_users` (`owner`/`admin`/`member`), `app_members` for per-app `app_admin`/`editor`/`viewer` grants, and `api_keys` for `Authorization: Bearer pic_…` credentials. Unifies cookie-session and API-key paths behind a single `can(principal, capability)` gate; list endpoints filter by membership at SQL for `member` users. Dashboard surfaces, invites, MFA, service accounts, and the `picloud` CLI binary are deferred — schema room only.
**Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call. **Why all three before v1.1**: every v1.1 service (KV, docs, users, etc.) needs both an `app_id` scoping key in its schema and a `Principal` to authorize against. Adding both now is one migration each on a small surface; adding them after the SDKs ship is many migrations on populated data plus a re-gate of every SDK call.
--- ---
### Phase 4: v1.1 (Expand Capabilities & Services) ### Phase 4: v1.1 (Expand Capabilities & Services) — Current focus
Ordered roughly by foundation value: each row enables the rows below it.
1. **Rhai SDK: KV Store** (`kv.get/set/delete/has` with collections, scoped per app) Released in patch steps (v1.1.0 → v1.1.8), each landing one focused capability. The split lets each release ship behind tests + docs without long-lived branches. SDK shape (handle pattern, `::` namespace, error convention, `ExecutionGate`, `SdkCallCx`, `ServiceEventEmitter` — see §7.5 and [docs/sdk-shape.md](../docs/sdk-shape.md)) is fixed in v1.1.0; every subsequent release fills in the contents without re-litigating the shape.
2. **Rhai SDK: Document Store** (`docs.create/find/update/delete/list/query`, scoped per app)
3. **Rhai SDK: HTTP** (`http.get/post/put/delete` with SSRF deny-list) | Version | Capability |
4. **Cron triggers** (manager scheduler skeleton already exists; needs schedules table + `FOR UPDATE SKIP LOCKED` dispatch) |---------|------------|
5. **Rhai SDK: Email** (`email.send` via SMTP; needs per-deploy config) | **v1.1.0** | **Foundation & Standard Library** — SDK shape (`Services` bundle, `SdkCallCx`, `ExecutionGate`, `ServiceEventEmitter` trait shape); stdlib utilities (regex, random, time, json, base64, hex, url). |
6. **Rhai SDK: User Management** (auth, CRUD, roles, permissions, invitations, password reset; depends on email for invites; scoped per app) | **v1.1.1** | **Storage & Events** — KV store keyed `(app_id, collection, key)`; triggers framework (outbox + dispatcher + trigger CRUD + `ctx.event` + depth limit); KV trigger kinds. |
7. **Queue triggers** (start with Postgres LISTEN/NOTIFY; RabbitMQ/Redis later if needed) | **v1.1.2** | **Documents**`docs::collection(name).create/find/update/delete/list` with `docs:*` triggers. |
8. **`invoke()` + `retry::*`** (function-to-function calls; execution_logs gain `parent_execution_id`) | **v1.1.3** | **Modules**`scripts.kind`, per-app resolver replaces `DummyModuleResolver`, AST cache + dep-graph invalidation. |
9. **Secrets management** (encrypted env vars, per app) | **v1.1.4** | **Outbound HTTP & Scheduled Tasks**`http::*` with SSRF deny-list; cron triggers. |
| **v1.1.5** | **Files & Messaging** — filesystem-backed blobs with `files:*` triggers; pub/sub via LISTEN/NOTIFY with `pubsub:*` triggers. |
| **v1.1.6** | **Configuration & Email** — encrypted per-app secrets; outbound `email::send` / `send_html` + inbound `email:receive` trigger. |
| **v1.1.7** | **User Management**`users::*` for in-script CRUD, auth, roles, invites, password reset. |
| **v1.1.8** | **Durable Queues & Function Composition**`queue::*` with `queue:receive` trigger; `invoke()` + `retry::*` (closures-as-args, re-entrant Rhai). |
--- ---
@@ -1309,59 +1312,71 @@ Ordered roughly by foundation value: each row enables the rows below it.
| **ctx** (global) | `ctx.execution_id`, `ctx.script_id`, `ctx.script_name`, `ctx.request_id`, `ctx.trace_id`, `ctx.invocation_type`, `ctx.parent_execution_id`, `ctx.request.path`, `ctx.request.headers`, `ctx.request.body` | MVP+ | | **ctx** (global) | `ctx.execution_id`, `ctx.script_id`, `ctx.script_name`, `ctx.request_id`, `ctx.trace_id`, `ctx.invocation_type`, `ctx.parent_execution_id`, `ctx.request.path`, `ctx.request.headers`, `ctx.request.body` | MVP+ |
| **Response** | Return `{ statusCode, headers?, body }` | MVP | | **Response** | Return `{ statusCode, headers?, body }` | MVP |
## 7.5 SDK Architecture (v1.1.x foundation)
Stateful Rhai SDK services (KV, docs, HTTP, …) hang off a common shape laid down by the v1.1.0 SDK foundation PR. Full reference lives in [docs/sdk-shape.md](../docs/sdk-shape.md); this section sketches the moving parts so other sections can refer to them by name.
**`Services` bundle** (`picloud_shared::Services`) — an `#[non_exhaustive]` struct constructed once at startup. v1.1.0 ships it empty; each subsequent v1.1.x PR adds one `Arc<dyn KvService>` / `Arc<dyn DocsService>` / … field. Held on `Engine`, passed by reference to the per-call registration hook.
**Per-call context** (`picloud_shared::SdkCallCx`) — every stateful service trait method takes `&SdkCallCx` as its first non-self argument. Carries `app_id`, `Option<Principal>`, `execution_id`, `request_id`, and the `trigger_depth` / `root_execution_id` slots that the triggers framework populates. Services derive `app_id` from the cx — never from script-passed args. **That rule is the cross-app isolation boundary**; scripts cannot name another app's data.
**Handle pattern** — collection-scoped services expose `kv::collection("widgets").get("k")`, not `kv::get("widgets", "k")`. Removes the wrong-collection-name foot-gun and lets implementations cache per-collection state. `(app_id, collection, key)` is the identity tuple for KV; `(app_id, collection, id)` for docs. Collections are mandatory.
**Error convention** — throw on failure, `()` for absent, `bool` for predicates. Uniform across every v1.1.x service. Scripts opt into handling errors via Rhai's `try/catch`.
**`ExecutionGate`** (`orchestrator-core::gate::ExecutionGate`) — single global semaphore capping concurrent script executions. Default 32, override via the `PICLOUD_MAX_CONCURRENT_EXECUTIONS` env var. Non-blocking — on overflow, the orchestrator returns HTTP 503 with `Retry-After: 1` immediately. No queue. Rationale: Rhai runs under `spawn_blocking`, so unbounded concurrency would park every blocking thread and starve every other workload.
**`ServiceEventEmitter`** (`picloud_shared::ServiceEventEmitter`) — every mutating service method emits a `ServiceEvent { source, op, collection, key, payload, old_payload }`. v1.1.0 ships `NoopEventEmitter`; the real outbox-backed dispatcher lands with v1.1.1 (see 7.5.1).
### 7.5.1 Trigger architecture (sketch)
Triggers fire scripts in response to service events. Three locked properties; full design and CRUD endpoints land with v1.1.1.
1. **Async outbox**: services emit events synchronously into a Postgres outbox table; a separate dispatcher worker reads, matches them against registered triggers, and fans out script executions. Service writes don't block on trigger fan-out.
2. **Depth-limited**: each trigger-spawned execution increments `cx.trigger_depth`. The dispatcher refuses to fan out beyond a configured ceiling to prevent runaway feedback loops. `cx.root_execution_id` preserves the originating execution id for audit grouping.
3. **Trigger model**: a trigger is `(service, event, filter) → script`, stored in a `triggers` table. The filter is the dispatcher's match predicate on the emitted `ServiceEvent`.
### 8.1 KV Store Service ### 8.1 KV Store Service
**Purpose**: Simple key-value persistence organized by collections, shared across script invocations and scripts. **Purpose**: Simple key-value persistence organized by collections, scoped per app and shared across script invocations and scripts within that app.
**PostgreSQL Setup:** **PostgreSQL Schema:**
```sql ```sql
-- Enable hstore extension (one-time setup)
CREATE EXTENSION IF NOT EXISTS hstore;
-- Create KV table with collection support
CREATE TABLE kv_store ( CREATE TABLE kv_store (
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
collection TEXT NOT NULL, collection TEXT NOT NULL,
key TEXT NOT NULL, key TEXT NOT NULL,
value hstore NOT NULL, value JSONB NOT NULL,
expires_at TIMESTAMP, expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(), created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW(),
PRIMARY KEY (collection, key) PRIMARY KEY (app_id, collection, key)
); );
CREATE INDEX idx_kv_collection ON kv_store(collection); CREATE INDEX idx_kv_app_collection ON kv_store(app_id, collection);
CREATE INDEX idx_kv_expires ON kv_store(expires_at) CREATE INDEX idx_kv_expires ON kv_store(expires_at)
WHERE expires_at IS NOT NULL; WHERE expires_at IS NOT NULL;
``` ```
**Why hstore + collections?** **Why JSONB + mandatory collections + `app_id` first:**
- Lightweight, purpose-built for key-value storage - `(app_id, collection, key)` is the identity tuple. The PK begins with `app_id` so the index is naturally per-app; cross-app reads can't happen even if the service layer has a bug.
- Collections allow logical grouping (e.g., `kv:sessions`, `kv:counters`, `kv:flags`) - Collections are **mandatory** — every set / get / delete names one. The same key can legitimately live in multiple collections within one app (`sessions:abc` and `counters:abc` are distinct rows).
- Faster than JSONB for simple KV use cases - JSONB carries arbitrary script-side values (nested objects, arrays) without a separate serialization step. `hstore` was considered and ruled out — it doesn't carry nested types and would force a second JSONB column the moment a script writes a structured value.
- Built-in indexing support
- Keeps all data in one database (no Redis dependency)
**Rhai SDK:** **Value-size cap:** 64 KiB per value, enforced at the service layer (script-visible error on overflow). The cap keeps KV "small fast values, not blob storage"; the v1.1.5 files SDK is the right home for large payloads.
**Rhai SDK (handle pattern — see [docs/sdk-shape.md](docs/sdk-shape.md)):**
```rhai ```rhai
// Get a value from a collection let sessions = kv::collection("sessions");
let val = kv.get("sessions", "user:123"); // Returns object or null sessions.set("user:123", #{ token: "abc", created: "2026-04-10" });
let val = sessions.get("user:123"); // value or () if absent
sessions.delete("user:123");
sessions.set("user:123", #{ token: "xyz" }, 3600); // TTL in seconds
if sessions.has("user:123") { ... }
// Set a value in a collection // Distinct collections in one script — different handles.
kv.set("sessions", "user:123", { token: "abc", created: "2026-04-10" }); let counters = kv::collection("counters");
counters.set("api:calls", 42);
// Delete a key from a collection
kv.delete("sessions", "user:123");
// Set with TTL (seconds)
kv.set("sessions", "user:123", { token: "xyz" }, 3600);
// Check if key exists in a collection
if kv.has("sessions", "user:123") { ... }
// Use different collections for different purposes
kv.set("counters", "api:calls", 42);
kv.set("flags", "feature:beta", true);
kv.set("cache", "page:home", { html: "..." });
``` ```
**Use Cases:** **Use Cases:**