Compare commits
42 Commits
fix/e2e-st
...
feat/group
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46667c309 | ||
|
|
ca360d84cb | ||
|
|
79f8c9d420 | ||
|
|
bb68e5e50a | ||
|
|
11ac168839 | ||
|
|
30441549d5 | ||
|
|
c914758c09 | ||
|
|
e6b4792389 | ||
|
|
b588fc9d35 | ||
|
|
6bd0f4699d | ||
|
|
9ee85993d8 | ||
|
|
343f6d3b4d | ||
|
|
35dbd9f368 | ||
|
|
6db057fb08 | ||
|
|
49c4fb41ce | ||
|
|
8725939172 | ||
|
|
eea1d8984e | ||
|
|
a432091191 | ||
|
|
2b27012f56 | ||
|
|
c900ca5bbf | ||
|
|
695987d6b7 | ||
|
|
d4b5632db1 | ||
|
|
345f265062 | ||
|
|
aa3995ae05 | ||
|
|
a27863d4a6 | ||
|
|
b3f05dfe2a | ||
|
|
5e62f4acfe | ||
|
|
73c8c289c1 | ||
|
|
816f143ffd | ||
|
|
2ba476aac8 | ||
|
|
79153b2063 | ||
|
|
9e1c24f729 | ||
|
|
8c805a07d0 | ||
|
|
bfab7c781d | ||
|
|
4223d3c320 | ||
|
|
55cf995eda | ||
|
|
627996cde7 | ||
|
|
be5df06a48 | ||
|
|
b8a4f30219 | ||
|
|
d9b3e9973c | ||
|
|
3b650a2b14 | ||
|
|
c600177fd6 |
@@ -8,7 +8,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
|
||||
|
||||
**Current focus (Phase 4, v1.1.0):** SDK foundation + stdlib utilities — the shape every v1.1.x service module hangs off, see [docs/sdk-shape.md](docs/sdk-shape.md). Stdlib reference at [docs/stdlib-reference.md](docs/stdlib-reference.md). Subsequent v1.1.x releases (KV in v1.1.1, docs in v1.1.2, …) fill it in; see blueprint §12 for the full table. Phase 3 shipped end-to-end: admin auth, multi-app scoping, and Phase 3.5 capability gating (`manager-core::authz::{can, require, Capability}` + migration `0006_users_authz.sql`). Every v1.1+ table starts with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE` and every Rhai SDK call resolves its app from the execution context.
|
||||
**v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`).
|
||||
|
||||
**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache). Next: group-owned scripts/modules (§11 Phase 4) and the project tool mapping onto groups (§11 Phase 5).
|
||||
|
||||
**Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable _config_ tables (`vars`, `secrets`) instead carry a **polymorphic owner** — nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes. Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg (the cross-app isolation boundary).
|
||||
|
||||
## Three-Service Architecture
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod retry;
|
||||
pub mod secrets;
|
||||
pub mod stdlib;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
|
||||
pub use bridge::{dynamic_to_json, json_to_dynamic};
|
||||
pub use cx::SdkCallCx;
|
||||
@@ -62,6 +63,7 @@ pub fn register_all(
|
||||
queue::register(engine, services, cx.clone());
|
||||
retry::register(engine, services, cx.clone());
|
||||
secrets::register(engine, services, cx.clone());
|
||||
vars::register(engine, services, cx.clone());
|
||||
email::register(engine, services, cx.clone());
|
||||
users::register(engine, services, cx.clone());
|
||||
invoke::register(engine, services, cx, limits, self_engine);
|
||||
|
||||
57
crates/executor-core/src/sdk/vars.rs
Normal file
57
crates/executor-core/src/sdk/vars.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! `vars::` Rhai bridge — read-only access to the app's resolved,
|
||||
//! group-inherited config (Phase 3).
|
||||
//!
|
||||
//! ```rhai
|
||||
//! let region = vars::get("region"); // value or ()
|
||||
//! let all = vars::all(); // #{ key: value, ... }
|
||||
//! ```
|
||||
//!
|
||||
//! Values are inherited down the group tree and env-filtered (§3); the
|
||||
//! resolution happens server-side in manager-core. Writes go through the
|
||||
//! admin API, not the SDK. `app_id` is derived from `cx.app_id` in the
|
||||
//! service — never a script argument — preserving cross-app isolation.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{SdkCallCx, Services};
|
||||
use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module};
|
||||
|
||||
use super::bridge::{block_on, json_to_dynamic};
|
||||
|
||||
pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc<SdkCallCx>) {
|
||||
let svc = services.vars.clone();
|
||||
let mut module = Module::new();
|
||||
|
||||
// vars::get(key) — resolved value, or () if no level defines it.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn(
|
||||
"get",
|
||||
move |key: &str| -> Result<Dynamic, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let opt = block_on("vars", async move { svc.get(&cx, key).await })?;
|
||||
Ok(opt.map_or(Dynamic::UNIT, json_to_dynamic))
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// vars::all() — the fully-resolved config map.
|
||||
{
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
module.set_native_fn("all", move || -> Result<Map, Box<EvalAltResult>> {
|
||||
let svc = svc.clone();
|
||||
let cx = cx.clone();
|
||||
let resolved = block_on("vars", async move { svc.all(&cx).await })?;
|
||||
let mut m = Map::new();
|
||||
for (k, v) in resolved {
|
||||
m.insert(k.into(), json_to_dynamic(v));
|
||||
}
|
||||
Ok(m)
|
||||
});
|
||||
}
|
||||
|
||||
engine.register_static_module("vars", module.into());
|
||||
}
|
||||
@@ -107,6 +107,7 @@ async fn original_backend_error_is_logged_at_error_level() {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
let engine = Engine::new(Limits::default(), services);
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -235,6 +235,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ fn engine_with(rec: Arc<RecordingEmail>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -172,6 +172,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ fn engine_with(http: Arc<dyn HttpService>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ fn build_engine(svc: Arc<FakeInvokeService>) -> Arc<Engine> {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
let engine = Arc::new(Engine::new(Limits::default(), services));
|
||||
engine.set_self_weak(Arc::downgrade(&engine));
|
||||
|
||||
@@ -114,6 +114,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ fn make_engine(svc: Arc<RecordingPubsub>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@ fn make_engine(svc: Arc<RecordingQueue>) -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
svc,
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ fn build_engine() -> Arc<Engine> {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
Arc::new(NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ fn make_engine() -> Arc<Engine> {
|
||||
Arc::new(picloud_shared::NoopUsersService),
|
||||
Arc::new(picloud_shared::NoopQueueService),
|
||||
Arc::new(picloud_shared::NoopInvokeService),
|
||||
Arc::new(picloud_shared::NoopVarsService),
|
||||
);
|
||||
Arc::new(Engine::new(Limits::default(), services))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Three-state `enabled` lifecycle (blueprint §4.3), scripts + routes half.
|
||||
-- Triggers already carry `enabled` (0008) honored at match/schedule time;
|
||||
-- this adds the same toggle to scripts and routes. A disabled script is not
|
||||
-- invocable; a disabled route 404s (indistinguishable from absent). Default
|
||||
-- TRUE so every existing row stays active — no behavior change on migrate.
|
||||
|
||||
ALTER TABLE scripts ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
ALTER TABLE routes ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
29
crates/manager-core/migrations/0046_trigger_name.sql
Normal file
@@ -0,0 +1,29 @@
|
||||
-- Trigger `name` (§4.5): an explicit per-app identifier that becomes the
|
||||
-- manifest merge/upsert key (Step B switches the apply diff to key on it).
|
||||
-- Until now triggers were identified only by their per-kind semantic tuple,
|
||||
-- so the declarative tool could only Create/Delete them, never Update.
|
||||
--
|
||||
-- Backfill existing rows with `{kind}-{n}` (n = per-(app,kind) sequence by
|
||||
-- creation order) — the spec-sanctioned fallback when there's no cleaner
|
||||
-- entity token. Then enforce NOT NULL + UNIQUE(app_id, name).
|
||||
|
||||
-- A `gen_random_uuid()` default keeps existing INSERTs (which don't yet
|
||||
-- supply a name) valid and unique — the manager-core write paths start
|
||||
-- providing meaningful names in the follow-up; new rows until then get a
|
||||
-- harmless unique placeholder.
|
||||
ALTER TABLE triggers
|
||||
ADD COLUMN name TEXT NOT NULL DEFAULT gen_random_uuid()::text;
|
||||
|
||||
-- Rewrite the just-defaulted existing rows to the readable `{kind}-{n}` form.
|
||||
UPDATE triggers t
|
||||
SET name = sub.nm
|
||||
FROM (
|
||||
SELECT id,
|
||||
kind || '-' || row_number() OVER (
|
||||
PARTITION BY app_id, kind ORDER BY created_at, id
|
||||
) AS nm
|
||||
FROM triggers
|
||||
) sub
|
||||
WHERE t.id = sub.id;
|
||||
|
||||
CREATE UNIQUE INDEX triggers_app_name_uniq ON triggers (app_id, name);
|
||||
82
crates/manager-core/migrations/0047_groups.sql
Normal file
82
crates/manager-core/migrations/0047_groups.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
-- Phase 2: groups as a pure org / RBAC / UI container — see
|
||||
-- docs/design/groups-and-project-tool.md §5, §9.
|
||||
--
|
||||
-- Groups form a GitLab-like, single-parent tree ABOVE apps. Phase 2 adds
|
||||
-- the tree, hierarchy-aware membership, and structural-mutation safety —
|
||||
-- but NO group-owned resources yet (scripts/vars/secrets stay app-owned;
|
||||
-- that is Phase 3). The only data-plane touch is apps gaining a parent
|
||||
-- pointer.
|
||||
--
|
||||
-- Adoption (§9): every existing app must have a parent from day one so
|
||||
-- resolution always terminates. This migration seeds a single instance
|
||||
-- root group and reparents every app under it, then promotes
|
||||
-- apps.group_id to NOT NULL.
|
||||
|
||||
CREATE TABLE groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Single parent keeps inheritance acyclic and resolution
|
||||
-- deterministic. NULL parent = a root node. RESTRICT (never CASCADE):
|
||||
-- deleting a non-empty group is refused so descendant apps and their
|
||||
-- isolated data can't be destroyed implicitly (§5.6). The ancestor-walk
|
||||
-- cycle guard that keeps this acyclic lives in manager-core (a SQL
|
||||
-- CHECK can't express it).
|
||||
parent_id UUID REFERENCES groups(id) ON DELETE RESTRICT,
|
||||
-- Instance-global identifier, frozen at creation. A rename/reparent
|
||||
-- updates the display name/path but NEVER rewrites the slug, so the
|
||||
-- deployment key stays stable and external references don't break.
|
||||
-- Format validation lives in Rust handlers (same rule as app slugs).
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
-- Per-subtree structure version (§6): bumped on every structural
|
||||
-- mutation of this node (reparent/rename/delete) so a future CLI/
|
||||
-- orchestrator can detect structural drift. NOT an authz input —
|
||||
-- authorization is resolved live every request.
|
||||
structure_version BIGINT NOT NULL DEFAULT 1,
|
||||
-- §7 ownership seam — the project-root that manages this node. Inert
|
||||
-- in Phase 2 (no projects table yet); nullable, no FK.
|
||||
owner_project UUID,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX groups_parent_id_idx ON groups (parent_id);
|
||||
|
||||
-- Per-(user, group) explicit grant, mirroring app_members. Inherited
|
||||
-- membership (GitLab-style) is resolved in code by walking ancestors: a
|
||||
-- group_admin on any ancestor is implicitly app_admin on every app and
|
||||
-- subgroup beneath it. Roles reuse the SAME three literals as app_members
|
||||
-- so AppRole round-trips with zero mapping and the authz rank table
|
||||
-- covers both tables.
|
||||
CREATE TABLE group_members (
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES admin_users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL CHECK (role IN ('app_admin', 'editor', 'viewer')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
);
|
||||
|
||||
-- Hot path is the authz ancestor walk "what groups does this user have a
|
||||
-- role on?" plus the per-group member list.
|
||||
CREATE INDEX group_members_user_id_idx ON group_members (user_id);
|
||||
|
||||
-- Add the parent pointer to apps (nullable for the backfill window).
|
||||
ALTER TABLE apps ADD COLUMN group_id UUID;
|
||||
|
||||
-- Seed a single instance root group and reparent every existing app under
|
||||
-- it. App slugs are already instance-global, so no slug rewrite is needed
|
||||
-- — the parent pointer is new metadata layered on top.
|
||||
WITH root_group AS (
|
||||
INSERT INTO groups (slug, name, description)
|
||||
VALUES ('root', 'Root', 'The instance root group — parent of all apps created before groups landed.')
|
||||
RETURNING id
|
||||
)
|
||||
UPDATE apps SET group_id = (SELECT id FROM root_group);
|
||||
|
||||
-- Every app now has a parent; promote to NOT NULL + FK. RESTRICT so a
|
||||
-- group with apps can't be deleted out from under them (§5.6).
|
||||
ALTER TABLE apps ALTER COLUMN group_id SET NOT NULL;
|
||||
ALTER TABLE apps
|
||||
ADD CONSTRAINT apps_group_id_fk FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX apps_group_id_idx ON apps (group_id);
|
||||
47
crates/manager-core/migrations/0048_vars.sql
Normal file
47
crates/manager-core/migrations/0048_vars.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
-- Phase 3: group-inherited config — the `vars` table + an app environment
|
||||
-- marker. See docs/design/groups-and-project-tool.md §3, §5.1.
|
||||
--
|
||||
-- `vars` is the net-new, env-scoped configuration layer (greenfield — there
|
||||
-- is no env-agnostic config today, only `secrets`). A var is owned by
|
||||
-- exactly one group OR one app, optionally scoped to an environment, and
|
||||
-- resolved down the tree (§3): env-filter first, then nearest level wins.
|
||||
--
|
||||
-- "An environment is an app": each env is already a distinct app row. To
|
||||
-- env-filter group-level `@E` values, the resolver must know which env an
|
||||
-- app represents — recorded here on `apps.environment` (NULL = env-agnostic,
|
||||
-- set at app-create from the CLI's known env).
|
||||
|
||||
ALTER TABLE apps ADD COLUMN environment TEXT;
|
||||
|
||||
CREATE TABLE vars (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a
|
||||
-- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling
|
||||
-- owner is impossible.
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
CONSTRAINT vars_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
-- '*' = env-agnostic; otherwise an env name matched against
|
||||
-- apps.environment. NOT NULL with a '*' sentinel so the uniqueness
|
||||
-- indexes are total (a NULL scope would break UNIQUE).
|
||||
environment_scope TEXT NOT NULL DEFAULT '*',
|
||||
key TEXT NOT NULL,
|
||||
-- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an
|
||||
-- inherited key, §3) is the explicit boolean below, NOT value = 'null'
|
||||
-- — JSON null is a legitimate value and must stay distinguishable.
|
||||
value JSONB NOT NULL,
|
||||
is_tombstone BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One row per (owner, scope, key). Partial unique indexes because the owner
|
||||
-- is split across two nullable columns.
|
||||
CREATE UNIQUE INDEX vars_group_uidx
|
||||
ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX vars_app_uidx
|
||||
ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL;
|
||||
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
51
crates/manager-core/migrations/0049_group_secrets.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Phase 3 (v1.1.9): group-owned, environment-scoped secrets.
|
||||
--
|
||||
-- Until now `secrets` was strictly per-app: PK `(app_id, name)`, one
|
||||
-- envelope per app. Phase 3 makes secrets inheritable down the group tree
|
||||
-- (blueprint §11 bullet 3 / docs/design §3), exactly like `vars` (0048):
|
||||
-- a secret may be owned by an app OR an ancestor group, and a descendant
|
||||
-- app resolves the nearest one, environment-filtered.
|
||||
--
|
||||
-- Reshape (mirrors `vars`):
|
||||
-- * `group_id` — nullable FK→groups, CASCADE (a deleted group drops its
|
||||
-- secrets, same as its vars).
|
||||
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||
-- enforces "owned by an app XOR a group".
|
||||
-- * `environment_scope` — `'*'` (env-agnostic) or a concrete env name;
|
||||
-- the resolver filters on it. Existing rows backfill to `'*'`, so every
|
||||
-- current app secret stays env-agnostic and resolves unchanged.
|
||||
-- * PK `(app_id, name)` → two PARTIAL unique indexes, one per owner, both
|
||||
-- keyed `(owner, environment_scope, name)`.
|
||||
--
|
||||
-- CRYPTO INVARIANT (audit 2026-06-11 H-D1): the v1 AAD is
|
||||
-- `secret:{app_id}:{name}` for app secrets and `secret:group:{group_id}:{name}`
|
||||
-- for group secrets — it does NOT include `environment_scope`. Adding the
|
||||
-- column therefore leaves every existing ciphertext decryptable byte-for-byte;
|
||||
-- the app-owner AAD is unchanged and the group namespace is disjoint.
|
||||
|
||||
ALTER TABLE secrets
|
||||
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
ADD COLUMN environment_scope TEXT NOT NULL DEFAULT '*';
|
||||
|
||||
-- Drop the old composite PK first: `app_id` cannot lose NOT NULL while it
|
||||
-- is a primary-key column.
|
||||
ALTER TABLE secrets
|
||||
DROP CONSTRAINT secrets_pkey;
|
||||
|
||||
ALTER TABLE secrets
|
||||
ALTER COLUMN app_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE secrets
|
||||
ADD CONSTRAINT secrets_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||
|
||||
-- One secret per (owner, env, name). Partial so each owner column only
|
||||
-- constrains its own rows; the resolver and every upsert restate the
|
||||
-- predicate as the ON CONFLICT arbiter.
|
||||
CREATE UNIQUE INDEX secrets_app_uidx
|
||||
ON secrets (app_id, environment_scope, name) WHERE app_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX secrets_group_uidx
|
||||
ON secrets (group_id, environment_scope, name) WHERE group_id IS NOT NULL;
|
||||
|
||||
-- Owner lookup index for the group side (the app side keeps idx_secrets_app).
|
||||
CREATE INDEX idx_secrets_group ON secrets (group_id) WHERE group_id IS NOT NULL;
|
||||
@@ -246,6 +246,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
} else {
|
||||
Some(input.sandbox)
|
||||
},
|
||||
// Scripts are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
imports: validated.imports,
|
||||
})
|
||||
.await?;
|
||||
@@ -258,7 +260,7 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
/// real KV bridge — defense against author confusion, not a security
|
||||
/// boundary (stdlib namespaces and module imports already live in
|
||||
/// disjoint Rhai scopes).
|
||||
const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||
pub(crate) const RESERVED_MODULE_NAMES: &[&str] = &[
|
||||
"log",
|
||||
"regex",
|
||||
"random",
|
||||
@@ -345,6 +347,7 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
memory_limit_mb: input.memory_limit_mb,
|
||||
sandbox: input.sandbox,
|
||||
kind: input.kind,
|
||||
enabled: None,
|
||||
imports: imports_for_patch,
|
||||
},
|
||||
)
|
||||
@@ -476,10 +479,9 @@ impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::AppNotFound(_)
|
||||
| Self::BadRequest(_)
|
||||
| Self::Invalid(_)
|
||||
| Self::Ceiling(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||
Self::AppNotFound(_) | Self::BadRequest(_) | Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
}
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
|
||||
Self::AuthzRepo(e) => {
|
||||
|
||||
@@ -68,6 +68,7 @@ async fn seed_into(
|
||||
timeout_seconds: Some(5),
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
imports: Vec::new(),
|
||||
})
|
||||
.await?;
|
||||
@@ -85,6 +86,7 @@ async fn seed_into(
|
||||
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -8,11 +8,17 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, InstanceRole};
|
||||
use picloud_shared::{AdminUserId, AppId, AppRole, GroupId, InstanceRole, UserId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
|
||||
/// SQL fragment ranking the three role literals by authority so a CTE can
|
||||
/// `MAX` over a mixed set of app_members + group_members rows. Shared by
|
||||
/// both effective-role queries.
|
||||
const ROLE_RANK_CTE: &str =
|
||||
"role_rank(role, rank) AS (VALUES ('viewer', 1), ('editor', 2), ('app_admin', 3))";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppMembersRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
@@ -281,13 +287,95 @@ impl AppMembersRepository for PostgresAppMembersRepository {
|
||||
impl AuthzRepo for PostgresAppMembersRepository {
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
self.find(user_id, app_id)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))
|
||||
}
|
||||
|
||||
/// Highest effective role on `app_id`: one recursive CTE walks the
|
||||
/// app's group chain (app.group_id → groups.parent_id → … → root,
|
||||
/// depth-bounded), then `MAX`es the app's own `app_members` row with
|
||||
/// every ancestor `group_members` row by authority rank. A single
|
||||
/// round-trip regardless of tree depth. `None` = no grant anywhere.
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"WITH RECURSIVE ancestors AS (
|
||||
SELECT a.group_id AS gid, 0 AS depth FROM apps a WHERE a.id = $2
|
||||
UNION ALL
|
||||
SELECT g.parent_id, anc.depth + 1
|
||||
FROM groups g JOIN ancestors anc ON g.id = anc.gid
|
||||
WHERE g.parent_id IS NOT NULL AND anc.depth < 64
|
||||
),
|
||||
{ROLE_RANK_CTE}
|
||||
SELECT eff.role
|
||||
FROM (
|
||||
SELECT am.role FROM app_members am
|
||||
WHERE am.user_id = $1 AND am.app_id = $2
|
||||
UNION ALL
|
||||
SELECT gm.role FROM group_members gm
|
||||
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||
WHERE gm.user_id = $1
|
||||
) eff
|
||||
JOIN role_rank rr ON rr.role = eff.role
|
||||
ORDER BY rr.rank DESC
|
||||
LIMIT 1"
|
||||
))
|
||||
.bind(user_id.into_inner())
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||
row.map(|(role,)| {
|
||||
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||
"invalid role {role:?} in members table"
|
||||
)))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Highest effective role on a *group* node — walks the group's own
|
||||
/// ancestor chain over `group_members`. Gates group management.
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let row: Option<(String,)> = sqlx::query_as(&format!(
|
||||
"WITH RECURSIVE ancestors AS (
|
||||
SELECT id AS gid, parent_id, 0 AS depth FROM groups WHERE id = $2
|
||||
UNION ALL
|
||||
SELECT g.id, g.parent_id, anc.depth + 1
|
||||
FROM groups g JOIN ancestors anc ON g.id = anc.parent_id
|
||||
WHERE anc.depth < 64
|
||||
),
|
||||
{ROLE_RANK_CTE}
|
||||
SELECT gm.role
|
||||
FROM group_members gm
|
||||
JOIN ancestors anc ON anc.gid = gm.group_id
|
||||
JOIN role_rank rr ON rr.role = gm.role
|
||||
WHERE gm.user_id = $1
|
||||
ORDER BY rr.rank DESC
|
||||
LIMIT 1"
|
||||
))
|
||||
.bind(user_id.into_inner())
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| AuthzError::Repo(e.to_string()))?;
|
||||
row.map(|(role,)| {
|
||||
AppRole::from_db_str(&role).ok_or(AuthzError::Repo(format!(
|
||||
"invalid role {role:?} in group_members table"
|
||||
)))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//! that writes the history row in the same transaction.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AdminUserId, App, AppId};
|
||||
use picloud_shared::{AdminUserId, App, AppId, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -55,6 +55,8 @@ pub trait AppRepository: Send + Sync {
|
||||
/// Only apps the user has an `app_members` row for. Drives the
|
||||
/// membership-filtered `GET /admin/apps` for `member` callers.
|
||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||
/// Apps whose parent is `group_id`. Drives the group detail view.
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug_or_history(
|
||||
@@ -67,6 +69,7 @@ pub trait AppRepository: Send + Sync {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Create that also consumes a matching `app_slug_history` row, if
|
||||
/// any. Used after the operator has confirmed they want to break old
|
||||
@@ -76,6 +79,7 @@ pub trait AppRepository: Send + Sync {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
@@ -116,7 +120,7 @@ impl PostgresAppRepository {
|
||||
impl AppRepository for PostgresAppRepository {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps ORDER BY name",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -126,7 +130,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn list_for_user(&self, user_id: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||
FROM apps a \
|
||||
JOIN app_members m ON m.app_id = a.id \
|
||||
WHERE m.user_id = $1 \
|
||||
@@ -138,9 +142,20 @@ impl AppRepository for PostgresAppRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_group(&self, group_id: GroupId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE group_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -151,7 +166,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
@@ -181,7 +196,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.group_id, a.created_at, a.updated_at \
|
||||
FROM app_slug_history h \
|
||||
JOIN apps a ON a.id = h.current_app_id \
|
||||
WHERE h.slug = $1",
|
||||
@@ -197,15 +212,17 @@ impl AppRepository for PostgresAppRepository {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
"INSERT INTO apps (slug, name, description, group_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
@@ -223,6 +240,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
group_id: GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
@@ -230,13 +248,14 @@ impl AppRepository for PostgresAppRepository {
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
"INSERT INTO apps (slug, name, description, group_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
@@ -264,7 +283,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
@@ -298,7 +317,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
if current_slug == new_slug {
|
||||
// No-op rename; just return the row.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
"SELECT id, slug, name, description, group_id, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -357,7 +376,7 @@ impl AppRepository for PostgresAppRepository {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
RETURNING id, slug, name, description, group_id, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(new_slug)
|
||||
@@ -432,6 +451,7 @@ struct AppRow {
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
group_id: uuid::Uuid,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -443,6 +463,7 @@ impl From<AppRow> for App {
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
group_id: r.group_id.into(),
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
|
||||
173
crates/manager-core/src/apply_api.rs
Normal file
173
crates/manager-core/src/apply_api.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
//! Admin HTTP surface for the declarative reconcile engine.
|
||||
//!
|
||||
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
|
||||
//! against the app's live state and return the plan. Read-only; requires
|
||||
//! `AppRead`. The `apply` route (write path) lands in the next milestone.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
|
||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||
pub fn apply_router(service: ApplyService) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{id}/plan", post(plan_handler))
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
#[serde(default)]
|
||||
pub prune: bool,
|
||||
/// Optional bound-plan token from a prior `plan`. When present, apply
|
||||
/// refuses (409) if the app's live state has changed since.
|
||||
#[serde(default)]
|
||||
pub expected_token: Option<String>,
|
||||
}
|
||||
|
||||
async fn apply_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(req): Json<ApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// Read is always needed; write caps are required for the resource kinds
|
||||
// the bundle touches — and for ALL kinds when `prune` is set, since
|
||||
// pruning deletes resources whose bundle section is empty (and a script
|
||||
// delete cascades its routes/triggers).
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if req.prune || !req.bundle.scripts.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.routes.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.triggers.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email triggers resolve and decrypt a stored secret by name server-side,
|
||||
// which the secrets API guards with `AppSecretsRead`. Require it here too
|
||||
// so apply can't bind a secret a principal couldn't otherwise read — the
|
||||
// caps aren't strictly nested on the API-key scope path.
|
||||
if req.bundle.triggers.iter().any(BundleTrigger::is_email) {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
let report = svc
|
||||
.apply(
|
||||
app_id,
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
async fn plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(bundle): Json<Bundle>,
|
||||
) -> Result<Json<PlanResult>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
||||
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
||||
// at every tier (same `script:read` scope, both in the viewer role). If a
|
||||
// future authz split puts `AppSecretsRead` on its own tier, this handler
|
||||
// must additionally require it — otherwise it leaks names a principal
|
||||
// couldn't enumerate via the secrets API.
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let plan = svc.plan(app_id, &bundle).await?;
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
||||
/// Mirrors the `triggers_api` helper of the same shape.
|
||||
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
fn map_authz(denied: AuthzDenied) -> ApplyError {
|
||||
match denied {
|
||||
AuthzDenied::Denied => ApplyError::Forbidden,
|
||||
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApplyError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_) => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apply authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "apply backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
2405
crates/manager-core/src/apply_service.rs
Normal file
2405
crates/manager-core/src/apply_service.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,7 @@ use uuid::Uuid;
|
||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_repo::{GroupRepository, ROOT_GROUP_SLUG};
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
@@ -44,6 +45,9 @@ pub struct AppsState {
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
/// Capability gate — Phase 3.5.
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
/// Group tree — resolves an app's parent group at create time
|
||||
/// (defaults to the instance root).
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
}
|
||||
|
||||
pub fn apps_router(state: AppsState) -> Router {
|
||||
@@ -80,6 +84,10 @@ pub struct CreateAppRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Parent group (slug or id). Defaults to the instance root group when
|
||||
/// omitted — every app has a parent from day one (§9).
|
||||
#[serde(default)]
|
||||
pub group: Option<String>,
|
||||
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||
/// the requested slug (breaking old redirects).
|
||||
#[serde(default)]
|
||||
@@ -176,23 +184,46 @@ async fn create_app(
|
||||
require(s.authz.as_ref(), &principal, Capability::InstanceCreateApp).await?;
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
// Resolve the parent group: an explicit `group` (slug or id) or the
|
||||
// instance root by default. Placing an app under a specific group
|
||||
// additionally requires group-write there.
|
||||
let parent = resolve_group(&*s.groups, input.group.as_deref()).await?;
|
||||
if input.group.is_some() {
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupWrite(parent.id),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Historical-slug check before insert: if the slug is in history
|
||||
// and the caller hasn't asked to force takeover, surface a clean
|
||||
// 409 so the dashboard can present a "this will break old links"
|
||||
// confirmation.
|
||||
if !input.force_takeover {
|
||||
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||
}
|
||||
}
|
||||
|
||||
let created = if input.force_takeover {
|
||||
s.apps
|
||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||
.create_with_takeover(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent.id,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
s.apps
|
||||
.create(&input.slug, &input.name, input.description.as_deref())
|
||||
.create(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent.id,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
@@ -235,7 +266,31 @@ async fn compute_my_role(
|
||||
) -> Result<Option<AppRole>, AppsApiError> {
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
|
||||
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
|
||||
// Effective role: folds in inherited group memberships so the
|
||||
// dashboard badge reflects what the caller can actually do.
|
||||
InstanceRole::Member => Ok(authz.effective_app_role(principal.user_id, app_id).await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an optional group identifier (slug or UUID) to a group,
|
||||
/// defaulting to the instance root group when `None`.
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: Option<&str>,
|
||||
) -> Result<picloud_shared::Group, AppsApiError> {
|
||||
match ident {
|
||||
None => groups
|
||||
.get_by_slug(ROOT_GROUP_SLUG)
|
||||
.await?
|
||||
.ok_or_else(|| AppsApiError::GroupNotFound(ROOT_GROUP_SLUG.to_string())),
|
||||
Some(ident) => {
|
||||
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
groups.get_by_id(uuid.into()).await?
|
||||
} else {
|
||||
groups.get_by_slug(ident).await?
|
||||
};
|
||||
found.ok_or_else(|| AppsApiError::GroupNotFound(ident.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +334,7 @@ async fn patch_app(
|
||||
Ok(app) => app,
|
||||
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
return Err(AppsApiError::SlugInHistory(Box::new(current)));
|
||||
}
|
||||
return Err(AppsApiError::Conflict(msg));
|
||||
}
|
||||
@@ -521,14 +576,19 @@ pub enum AppsApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("group not found: {0}")]
|
||||
GroupNotFound(String),
|
||||
|
||||
#[error("domain not found: {0}")]
|
||||
DomainNotFound(Uuid),
|
||||
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
|
||||
// Boxed: `App` is large enough to trip clippy::result_large_err on
|
||||
// every handler returning `Result<_, AppsApiError>`.
|
||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||
SlugInHistory(App),
|
||||
SlugInHistory(Box<App>),
|
||||
|
||||
#[error("app still contains {0} script(s); delete or move them first")]
|
||||
HasScripts(i64),
|
||||
@@ -567,10 +627,22 @@ impl From<AuthzError> for AppsApiError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<crate::group_repo::GroupRepositoryError> for AppsApiError {
|
||||
fn from(e: crate::group_repo::GroupRepositoryError) -> Self {
|
||||
use crate::group_repo::GroupRepositoryError as G;
|
||||
match e {
|
||||
G::NotFound(id) => Self::GroupNotFound(id.to_string()),
|
||||
G::Conflict(msg) => Self::Conflict(msg),
|
||||
G::Db(e) => Self::Repo(ScriptRepositoryError::Db(e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_)
|
||||
| Self::GroupNotFound(_)
|
||||
| Self::DomainNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! external user-facing label.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
use picloud_shared::{AppId, AppRole, GroupId, InstanceRole, Principal, Scope, UserId};
|
||||
|
||||
/// Things a caller can attempt to do. Each app-scoped variant carries
|
||||
/// the `AppId` of the resource the action targets — handlers compute
|
||||
@@ -37,6 +37,19 @@ use picloud_shared::{AppId, AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub enum Capability {
|
||||
/// Create a new app. Owner / admin only.
|
||||
InstanceCreateApp,
|
||||
/// Create a new group (root-level). Owner / admin only — a Member
|
||||
/// creates subgroups under a group they group-admin (gated by
|
||||
/// `GroupAdmin(parent)` at the handler), not via this instance cap.
|
||||
InstanceCreateGroup,
|
||||
/// Read group metadata + list its subgroups/apps. Viewer+ on the
|
||||
/// group (inherited from any ancestor); implicit for admin / owner.
|
||||
GroupRead(GroupId),
|
||||
/// Rename / edit group metadata, move apps into it. Editor+ on the
|
||||
/// group.
|
||||
GroupWrite(GroupId),
|
||||
/// Group settings: delete, reparent, manage group members. group_admin
|
||||
/// on the group (inherited from any ancestor).
|
||||
GroupAdmin(GroupId),
|
||||
/// Create / update / delete admin_users rows (other than self
|
||||
/// password change, which is a separate flow). Owner / admin.
|
||||
InstanceManageUsers,
|
||||
@@ -103,6 +116,25 @@ pub enum Capability {
|
||||
/// Write (set/delete) a secret in this app's secrets store (v1.1.7).
|
||||
/// Granted to `editor`+, maps to `script:write` on API keys.
|
||||
AppSecretsWrite(AppId),
|
||||
/// Read this app's resolved config vars (Phase 3). Same trust shape as
|
||||
/// secrets-read — granted to `viewer`+, maps to `script:read`.
|
||||
AppVarsRead(AppId),
|
||||
/// Write (set/delete) an app-owned config var (Phase 3). Granted to
|
||||
/// `editor`+, maps to `script:write`.
|
||||
AppVarsWrite(AppId),
|
||||
/// Read a group's config vars (Phase 3). Resolved via the group
|
||||
/// ancestor walk; viewer+ on the group.
|
||||
GroupVarsRead(GroupId),
|
||||
/// Write (set/delete) a group-owned config var (Phase 3). editor+ on
|
||||
/// the group.
|
||||
GroupVarsWrite(GroupId),
|
||||
/// Read a group-owned secret's VALUE (Phase 3, the human-read gate).
|
||||
/// group_admin on the owning group — distinct from runtime injection,
|
||||
/// which an inheriting app does without this check.
|
||||
GroupSecretsRead(GroupId),
|
||||
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
|
||||
/// group.
|
||||
GroupSecretsWrite(GroupId),
|
||||
/// Send an outbound email from a script in this app (v1.1.7). Maps
|
||||
/// to `script:write` on API keys (sending mail is an outbound
|
||||
/// side-effect like an HTTP request). Granted to `editor`+.
|
||||
@@ -154,9 +186,20 @@ impl Capability {
|
||||
#[must_use]
|
||||
pub const fn app_id(self) -> Option<AppId> {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
None
|
||||
}
|
||||
Self::InstanceCreateApp
|
||||
| Self::InstanceManageUsers
|
||||
| Self::InstanceManageSettings
|
||||
| Self::InstanceCreateGroup
|
||||
// Group-scoped caps carry a GroupId, not an AppId. They return
|
||||
// None here so a bound API key (which can only target its one
|
||||
// app) is denied group management at the binding layer.
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_)
|
||||
| Self::GroupVarsRead(_)
|
||||
| Self::GroupVarsWrite(_)
|
||||
| Self::GroupSecretsRead(_)
|
||||
| Self::GroupSecretsWrite(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -174,6 +217,8 @@ impl Capability {
|
||||
| Self::AppQueueEnqueue(id)
|
||||
| Self::AppSecretsRead(id)
|
||||
| Self::AppSecretsWrite(id)
|
||||
| Self::AppVarsRead(id)
|
||||
| Self::AppVarsWrite(id)
|
||||
| Self::AppEmailSend(id)
|
||||
| Self::AppManageTriggers(id)
|
||||
| Self::AppDeadLetterManage(id)
|
||||
@@ -193,15 +238,19 @@ impl Capability {
|
||||
#[must_use]
|
||||
pub const fn required_scope(self) -> Scope {
|
||||
match self {
|
||||
Self::InstanceCreateApp | Self::InstanceManageUsers | Self::InstanceManageSettings => {
|
||||
Scope::InstanceAdmin
|
||||
}
|
||||
Self::InstanceCreateApp
|
||||
| Self::InstanceManageUsers
|
||||
| Self::InstanceManageSettings
|
||||
| Self::InstanceCreateGroup => Scope::InstanceAdmin,
|
||||
Self::AppRead(_)
|
||||
| Self::AppKvRead(_)
|
||||
| Self::AppDocsRead(_)
|
||||
| Self::AppFilesRead(_)
|
||||
| Self::AppSecretsRead(_)
|
||||
| Self::AppUsersRead(_) => Scope::ScriptRead,
|
||||
| Self::AppUsersRead(_)
|
||||
| Self::AppVarsRead(_)
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupVarsRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
@@ -213,13 +262,23 @@ impl Capability {
|
||||
| Self::AppEmailSend(_)
|
||||
| Self::AppUsersWrite(_)
|
||||
| Self::AppUsersAdmin(_)
|
||||
| Self::AppVarsWrite(_)
|
||||
// Group-secret WRITE is editor-role-gated (same tier as
|
||||
// GroupVarsWrite), so its API-key scope matches: script:write,
|
||||
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
|
||||
// the admin tier below.
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
Self::AppAdmin(_)
|
||||
| Self::AppManageTriggers(_)
|
||||
| Self::AppDeadLetterManage(_)
|
||||
| Self::AppTopicManage(_) => Scope::AppAdmin,
|
||||
| Self::AppTopicManage(_)
|
||||
| Self::GroupWrite(_)
|
||||
| Self::GroupAdmin(_)
|
||||
| Self::GroupVarsWrite(_)
|
||||
| Self::GroupSecretsRead(_) => Scope::AppAdmin,
|
||||
Self::AppLogRead(_) => Scope::LogRead,
|
||||
}
|
||||
}
|
||||
@@ -230,11 +289,41 @@ impl Capability {
|
||||
/// means unit tests can stub it.
|
||||
#[async_trait]
|
||||
pub trait AuthzRepo: Send + Sync {
|
||||
/// Direct `app_members` row for (user, app). The single-row lookup
|
||||
/// used by member-management surfaces and as the fallback below.
|
||||
async fn membership(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError>;
|
||||
|
||||
/// Highest *effective* role on `app_id` (hierarchy-aware RBAC, §5.3):
|
||||
/// the app's own `app_members` row folded with every `group_members`
|
||||
/// row on any ancestor group, max-by-authority. This is what `can()`
|
||||
/// consults so a `group_admin` on an ancestor is implicitly app_admin
|
||||
/// on the app.
|
||||
///
|
||||
/// Default = direct membership only (no inheritance), so the many test
|
||||
/// stubs that model no group tree keep their existing behavior; the
|
||||
/// Postgres repo overrides this with an ancestor-walking CTE.
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
self.membership(user_id, app_id).await
|
||||
}
|
||||
|
||||
/// Highest effective role on a *group* node — the group's own
|
||||
/// ancestor walk over `group_members`. Gates the group-management
|
||||
/// capabilities. Default = no grant; the Postgres repo overrides it.
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
_user_id: UserId,
|
||||
_group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Repo errors surface here so handlers can map them to 500 without
|
||||
@@ -353,7 +442,26 @@ async fn role_grants(
|
||||
match principal.instance_role {
|
||||
InstanceRole::Owner => Ok(true),
|
||||
InstanceRole::Admin => Ok(admin_grants(cap)),
|
||||
InstanceRole::Member => member_grants(repo, principal.user_id, cap).await,
|
||||
InstanceRole::Member => match cap {
|
||||
// Group-management caps resolve against the group ancestor
|
||||
// walk (a group_admin on an ancestor is implicitly admin of
|
||||
// the descendant group). Routed before member_grants because
|
||||
// group caps carry no app_id.
|
||||
Capability::GroupRead(g)
|
||||
| Capability::GroupWrite(g)
|
||||
| Capability::GroupAdmin(g)
|
||||
| Capability::GroupVarsRead(g)
|
||||
| Capability::GroupVarsWrite(g)
|
||||
| Capability::GroupSecretsRead(g)
|
||||
| Capability::GroupSecretsWrite(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
// can't. (Subgroup creation is gated on GroupAdmin(parent) at
|
||||
// the handler, which routes through the arm above.)
|
||||
Capability::InstanceCreateGroup => Ok(false),
|
||||
_ => member_grants(repo, principal.user_id, cap).await,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,12 +485,51 @@ async fn member_grants(
|
||||
let Some(app_id) = cap.app_id() else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(role) = repo.membership(user_id, app_id).await? else {
|
||||
// Effective (inherited) role: the app's own membership folded with any
|
||||
// ancestor group membership. A group_admin on an ancestor group is
|
||||
// implicitly app_admin here.
|
||||
let Some(role) = repo.effective_app_role(user_id, app_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(role_satisfies(role, cap))
|
||||
}
|
||||
|
||||
/// Member-path resolution for the group-management capabilities. Resolves
|
||||
/// the caller's effective role on the group (ancestor walk over
|
||||
/// `group_members`) and checks it covers the requested group action.
|
||||
async fn group_member_grants(
|
||||
repo: &dyn AuthzRepo,
|
||||
user_id: UserId,
|
||||
cap: Capability,
|
||||
group_id: GroupId,
|
||||
) -> Result<bool, AuthzError> {
|
||||
let Some(role) = repo.effective_group_role(user_id, group_id).await? else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(group_role_satisfies(role, cap))
|
||||
}
|
||||
|
||||
/// Does the effective group `AppRole` cover the group capability?
|
||||
/// viewer→read, editor→write, group_admin(=AppAdmin)→admin.
|
||||
const fn group_role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
match cap {
|
||||
// viewer+ reads group metadata and config vars.
|
||||
Capability::GroupRead(_) | Capability::GroupVarsRead(_) => true,
|
||||
// editor+ writes config vars/secrets.
|
||||
Capability::GroupWrite(_)
|
||||
| Capability::GroupVarsWrite(_)
|
||||
| Capability::GroupSecretsWrite(_) => {
|
||||
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||
}
|
||||
// group_admin manages the group + reads secret VALUES (the
|
||||
// human-read gate, distinct from an app's runtime injection).
|
||||
Capability::GroupAdmin(_) | Capability::GroupSecretsRead(_) => {
|
||||
matches!(role, AppRole::AppAdmin)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the per-app `AppRole` cover the capability? Viewer can read;
|
||||
/// Editor adds script/route/log mutations; AppAdmin adds settings,
|
||||
/// domain claims, and delete. Roles form a strict subset chain, so
|
||||
@@ -397,6 +544,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppFilesRead(_)
|
||||
| Capability::AppSecretsRead(_)
|
||||
| Capability::AppUsersRead(_)
|
||||
| Capability::AppVarsRead(_)
|
||||
);
|
||||
let in_editor = in_viewer
|
||||
|| matches!(
|
||||
@@ -412,6 +560,7 @@ const fn role_satisfies(role: AppRole, cap: Capability) -> bool {
|
||||
| Capability::AppSecretsWrite(_)
|
||||
| Capability::AppEmailSend(_)
|
||||
| Capability::AppUsersWrite(_)
|
||||
| Capability::AppVarsWrite(_)
|
||||
| Capability::AppInvoke(_)
|
||||
);
|
||||
let in_app_admin = in_editor
|
||||
@@ -471,16 +620,61 @@ mod tests {
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database.
|
||||
/// In-memory `AuthzRepo` so the unit tests don't need a database. Models
|
||||
/// direct app memberships PLUS a group tree (app→group, group→parent)
|
||||
/// and group memberships, so the hierarchy-aware resolution can be
|
||||
/// exercised without Postgres — mirroring the recursive-CTE behavior.
|
||||
#[derive(Default)]
|
||||
struct InMemoryAuthzRepo {
|
||||
memberships: Mutex<HashMap<(UserId, AppId), AppRole>>,
|
||||
app_group: Mutex<HashMap<AppId, GroupId>>,
|
||||
group_parent: Mutex<HashMap<GroupId, Option<GroupId>>>,
|
||||
group_memberships: Mutex<HashMap<(UserId, GroupId), AppRole>>,
|
||||
}
|
||||
|
||||
impl InMemoryAuthzRepo {
|
||||
async fn grant(&self, user: UserId, app: AppId, role: AppRole) {
|
||||
self.memberships.lock().await.insert((user, app), role);
|
||||
}
|
||||
/// Register a group node and its parent (`None` = root).
|
||||
async fn add_group(&self, group: GroupId, parent: Option<GroupId>) {
|
||||
self.group_parent.lock().await.insert(group, parent);
|
||||
}
|
||||
/// Place an app under a group.
|
||||
async fn put_app(&self, app: AppId, group: GroupId) {
|
||||
self.app_group.lock().await.insert(app, group);
|
||||
}
|
||||
/// Grant a group-level role.
|
||||
async fn grant_group(&self, user: UserId, group: GroupId, role: AppRole) {
|
||||
self.group_memberships
|
||||
.lock()
|
||||
.await
|
||||
.insert((user, group), role);
|
||||
}
|
||||
|
||||
/// Fold every ancestor group membership starting at `group`,
|
||||
/// max-by-authority, into `acc`.
|
||||
async fn fold_group_chain(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
mut group: Option<GroupId>,
|
||||
mut acc: Option<AppRole>,
|
||||
) -> Option<AppRole> {
|
||||
let memberships = self.group_memberships.lock().await;
|
||||
let parents = self.group_parent.lock().await;
|
||||
let mut hops = 0u32;
|
||||
while let Some(g) = group {
|
||||
if let Some(r) = memberships.get(&(user_id, g)).copied() {
|
||||
acc = Some(acc.map_or(r, |a| a.max(r)));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
break;
|
||||
}
|
||||
group = parents.get(&g).copied().flatten();
|
||||
}
|
||||
acc
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -497,6 +691,29 @@ mod tests {
|
||||
.get(&(user_id, app_id))
|
||||
.copied())
|
||||
}
|
||||
|
||||
async fn effective_app_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
app_id: AppId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
let direct = self
|
||||
.memberships
|
||||
.lock()
|
||||
.await
|
||||
.get(&(user_id, app_id))
|
||||
.copied();
|
||||
let start = self.app_group.lock().await.get(&app_id).copied();
|
||||
Ok(self.fold_group_chain(user_id, start, direct).await)
|
||||
}
|
||||
|
||||
async fn effective_group_role(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<AppRole>, AuthzError> {
|
||||
Ok(self.fold_group_chain(user_id, Some(group_id), None).await)
|
||||
}
|
||||
}
|
||||
|
||||
fn principal(role: InstanceRole) -> Principal {
|
||||
@@ -857,12 +1074,183 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Hierarchy-aware RBAC (Phase 2 groups)
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_admin_on_ancestor_is_implicit_app_admin() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
repo.put_app(app, acme).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
// No app_members row — authority comes purely from the group.
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
for cap in [
|
||||
Capability::AppRead(app),
|
||||
Capability::AppWriteScript(app),
|
||||
Capability::AppAdmin(app),
|
||||
] {
|
||||
assert!(
|
||||
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||
"inherited group_admin denied {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inherited_role_takes_the_max_of_direct_and_ancestor() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
repo.put_app(app, acme).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
// Direct viewer on the app, app_admin via the ancestor group:
|
||||
// the higher (app_admin) wins.
|
||||
repo.grant(p.user_id, app, AppRole::Viewer).await;
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppAdmin(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_role_inherits_down_a_multi_level_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
let app = AppId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
repo.put_app(app, team).await;
|
||||
|
||||
// Editor two levels up flows down to the app as editor.
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, root, AppRole::Editor).await;
|
||||
|
||||
assert!(can(&repo, &p, Capability::AppWriteScript(app))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"editor must not get app_admin"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_membership_grants_no_instance_capabilities() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let acme = GroupId::new();
|
||||
repo.add_group(acme, None).await;
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, acme, AppRole::AppAdmin).await;
|
||||
|
||||
for cap in [
|
||||
Capability::InstanceCreateApp,
|
||||
Capability::InstanceCreateGroup,
|
||||
Capability::InstanceManageUsers,
|
||||
] {
|
||||
assert_eq!(
|
||||
can(&repo, &p, cap).await.unwrap(),
|
||||
Decision::Deny,
|
||||
"group_admin must not grant instance cap {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn group_admin_walks_ancestors_for_group_caps() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let root = GroupId::new();
|
||||
let team = GroupId::new();
|
||||
repo.add_group(root, None).await;
|
||||
repo.add_group(team, Some(root)).await;
|
||||
|
||||
let p = principal(InstanceRole::Member);
|
||||
repo.grant_group(p.user_id, root, AppRole::AppAdmin).await;
|
||||
|
||||
// group_admin at root ⇒ admin of the descendant group.
|
||||
assert!(can(&repo, &p, Capability::GroupAdmin(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
assert!(can(&repo, &p, Capability::GroupWrite(team))
|
||||
.await
|
||||
.unwrap()
|
||||
.is_allow());
|
||||
|
||||
// An unrelated member gets nothing.
|
||||
let outsider = principal(InstanceRole::Member);
|
||||
assert_eq!(
|
||||
can(&repo, &outsider, Capability::GroupRead(team))
|
||||
.await
|
||||
.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn admin_implicitly_manages_the_whole_group_tree() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let g = GroupId::new();
|
||||
let p = principal(InstanceRole::Admin);
|
||||
for cap in [
|
||||
Capability::InstanceCreateGroup,
|
||||
Capability::GroupRead(g),
|
||||
Capability::GroupWrite(g),
|
||||
Capability::GroupAdmin(g),
|
||||
] {
|
||||
assert!(
|
||||
can(&repo, &p, cap).await.unwrap().is_allow(),
|
||||
"admin denied group cap {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bound_key_cannot_manage_groups() {
|
||||
let repo = InMemoryAuthzRepo::default();
|
||||
let g = GroupId::new();
|
||||
let p = Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: Some(vec![Scope::AppAdmin]),
|
||||
app_binding: Some(AppId::new()),
|
||||
};
|
||||
// Group caps carry no app_id, so a bound key is denied at the
|
||||
// binding layer regardless of role/scope.
|
||||
assert_eq!(
|
||||
can(&repo, &p, Capability::GroupAdmin(g)).await.unwrap(),
|
||||
Decision::Deny
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_role_max_is_authority_ordered() {
|
||||
assert_eq!(AppRole::Viewer.max(AppRole::AppAdmin), AppRole::AppAdmin);
|
||||
assert_eq!(AppRole::Editor.max(AppRole::Viewer), AppRole::Editor);
|
||||
assert_eq!(AppRole::AppAdmin.max(AppRole::Editor), AppRole::AppAdmin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_app_id_extraction() {
|
||||
let app = AppId::new();
|
||||
assert_eq!(Capability::InstanceCreateApp.app_id(), None);
|
||||
assert_eq!(Capability::AppRead(app).app_id(), Some(app));
|
||||
assert_eq!(Capability::AppAdmin(app).app_id(), Some(app));
|
||||
// Group caps are not app-scoped.
|
||||
assert_eq!(Capability::GroupAdmin(GroupId::new()).app_id(), None);
|
||||
assert_eq!(Capability::InstanceCreateGroup.app_id(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
158
crates/manager-core/src/config_api.rs
Normal file
158
crates/manager-core/src/config_api.rs
Normal file
@@ -0,0 +1,158 @@
|
||||
//! `GET /api/v1/admin/apps/{id}/config/effective` — the resolved config an
|
||||
//! app actually sees: every inherited var (with its value + provenance) and
|
||||
//! every inherited secret (MASKED — name/owner/scope only, never the value).
|
||||
//!
|
||||
//! This is the read-only companion to the `vars`/`secrets` admin surfaces.
|
||||
//! It runs the same §3 resolution the `vars::`/`secrets::` SDK calls run, so
|
||||
//! a dev can see exactly what `vars::get`/`secrets::get` would return and
|
||||
//! where each value comes from (`--explain` on the CLI surfaces the
|
||||
//! provenance). Gated by `AppVarsRead` (config is app-readable); the secret
|
||||
//! VALUES are deliberately absent — reading those needs `GroupSecretsRead`
|
||||
//! at the owning group via the dedicated value endpoint.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::config_resolver::{
|
||||
fetch_effective_secret_meta, fetch_var_candidates, resolve, OwnerKind,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConfigApiState {
|
||||
pub pool: PgPool,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn config_router(state: ConfigApiState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{id_or_slug}/config/effective", get(effective_config))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
fn owner_json(kind: OwnerKind, id: uuid::Uuid, depth: i32) -> serde_json::Value {
|
||||
json!({ "kind": kind.as_str(), "id": id, "depth": depth })
|
||||
}
|
||||
|
||||
async fn effective_config(
|
||||
State(s): State<ConfigApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<serde_json::Value>, ConfigApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Vars: resolve to values + provenance (vars are app-readable config).
|
||||
let candidates = fetch_var_candidates(&s.pool, app_id)
|
||||
.await
|
||||
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
|
||||
let (values, provenance) = resolve(candidates);
|
||||
let mut vars = serde_json::Map::new();
|
||||
for (key, value) in values {
|
||||
let p = &provenance[&key];
|
||||
vars.insert(
|
||||
key,
|
||||
json!({
|
||||
"value": value,
|
||||
"owner": owner_json(p.owner_kind, p.owner_id, p.depth),
|
||||
"scope": p.scope,
|
||||
"merged_from": p.merged_from
|
||||
.iter()
|
||||
.map(|(d, sc)| json!({ "depth": d, "scope": sc }))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Secrets: masked — name + owner/level/scope + status, never the value.
|
||||
let secret_meta = fetch_effective_secret_meta(&s.pool, app_id)
|
||||
.await
|
||||
.map_err(|e| ConfigApiError::Backend(e.to_string()))?;
|
||||
let mut secrets = serde_json::Map::new();
|
||||
for m in secret_meta {
|
||||
secrets.insert(
|
||||
m.name,
|
||||
json!({
|
||||
"status": "set",
|
||||
"owner": owner_json(m.owner_kind, m.owner_id, m.depth),
|
||||
"scope": m.scope,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(Json(json!({ "vars": vars, "secrets": secrets })))
|
||||
}
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ConfigApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| ConfigApiError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or(ConfigApiError::AppNotFound)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("config backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for ConfigApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for ConfigApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ConfigApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "config effective authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "config effective backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
456
crates/manager-core/src/config_resolver.rs
Normal file
456
crates/manager-core/src/config_resolver.rs
Normal file
@@ -0,0 +1,456 @@
|
||||
//! The §3 configuration-resolution engine: env-filtered, proximity-first
|
||||
//! inheritance down the group tree.
|
||||
//!
|
||||
//! To resolve a key for app A in environment E (docs/design §3):
|
||||
//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible;
|
||||
//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`.
|
||||
//! Env is *eligibility*, not a precedence tier.
|
||||
//! 2. **Nearest level wins** — walk A → parent group → … → root; the
|
||||
//! closest level that defines the (filtered) key wins. Proximity beats
|
||||
//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`).
|
||||
//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an
|
||||
//! explicit tombstone** that suppresses the inherited key.
|
||||
//!
|
||||
//! The recursive CTE that walks `apps.group_id → groups.parent_id → root`
|
||||
//! mirrors `app_members_repo::effective_app_role`. The env-eligibility
|
||||
//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which
|
||||
//! a window-function pick can't express — happen in the pure `resolve`
|
||||
//! function below, so they're unit-tested without Postgres.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
/// Shared chain-walk CTE: emits one row per owner-level for an app —
|
||||
/// depth 0 = the app itself (`app_owner` set), then ancestor groups
|
||||
/// nearest-first (`group_owner` set), each carrying the app's environment.
|
||||
/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this
|
||||
/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret
|
||||
/// resolvers, which each append their own owner-keyed JOIN.
|
||||
pub(crate) const CHAIN_LEVELS_CTE: &str = "\
|
||||
WITH RECURSIVE chain AS ( \
|
||||
SELECT a.id AS app_owner, NULL::uuid AS group_owner, \
|
||||
a.group_id AS next_group, 0 AS depth, a.environment AS app_env \
|
||||
FROM apps a WHERE a.id = $1 \
|
||||
UNION ALL \
|
||||
SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \
|
||||
FROM groups g JOIN chain c ON g.id = c.next_group \
|
||||
WHERE c.depth < 64 \
|
||||
)";
|
||||
|
||||
/// Owner kind of a resolved value, for `--explain` provenance.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OwnerKind {
|
||||
App,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl OwnerKind {
|
||||
#[must_use]
|
||||
pub const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::App => "app",
|
||||
Self::Group => "group",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One eligible (env-filtered) candidate row pulled by the resolver, before
|
||||
/// §3 proximity/merge resolution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Candidate {
|
||||
/// 0 = the app itself; 1 = its parent group; … (nearest-first).
|
||||
pub depth: i32,
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: Uuid,
|
||||
/// `*` (env-agnostic) or a concrete environment name.
|
||||
pub scope: String,
|
||||
pub key: String,
|
||||
pub value: Value,
|
||||
pub is_tombstone: bool,
|
||||
}
|
||||
|
||||
/// Where a resolved key came from (for `config --effective --explain`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Provenance {
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: Uuid,
|
||||
pub depth: i32,
|
||||
pub scope: String,
|
||||
/// For a deep-merged map, the `(depth, scope)` of every layer that
|
||||
/// contributed, nearest-first. Empty for a plain scalar/array winner.
|
||||
pub merged_from: Vec<(i32, String)>,
|
||||
}
|
||||
|
||||
/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1).
|
||||
fn env_priority(scope: &str) -> u8 {
|
||||
u8::from(scope != "*")
|
||||
}
|
||||
|
||||
/// Deep-merge `src` into `dst` per key: nested objects merge recursively,
|
||||
/// everything else is replaced by `src`. Caller merges farthest→nearest so
|
||||
/// nearer layers overwrite.
|
||||
fn deep_merge(dst: &mut Map<String, Value>, src: &Map<String, Value>) {
|
||||
for (k, sv) in src {
|
||||
match (dst.get_mut(k), sv) {
|
||||
(Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm),
|
||||
_ => {
|
||||
dst.insert(k.clone(), sv.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one key's candidate rows (any order in) to its effective value,
|
||||
/// plus the provenance. Returns `None` when a tombstone suppresses the key.
|
||||
fn resolve_one(mut rows: Vec<Candidate>) -> Option<(Value, Provenance)> {
|
||||
// Nearest-first; within a level, `@E` before `*` (§3 steps 1-2).
|
||||
rows.sort_by(|a, b| {
|
||||
a.depth
|
||||
.cmp(&b.depth)
|
||||
.then(env_priority(&b.scope).cmp(&env_priority(&a.scope)))
|
||||
});
|
||||
|
||||
// §3 step 1: env is eligibility, not a merge tier — within a single level
|
||||
// `@E` *suppresses* `*` (it does not layer on top of it). Each level is one
|
||||
// owner (single-parent tree) with at most one `@E` and one `*` row per key
|
||||
// after env-filtering; keep only the level's winner (the `@E` row sorts
|
||||
// first). Without this, a level holding both an `@E` map and a `*` map would
|
||||
// deep-merge them instead of the `@E` shadowing the `*`.
|
||||
rows.dedup_by_key(|r| r.depth);
|
||||
|
||||
// Collect the contiguous run of map-valued rows from the nearest. A
|
||||
// tombstone or scalar/array boundary stops the run (and, if it's the
|
||||
// nearest row, decides the result outright).
|
||||
let mut maps: Vec<&Candidate> = Vec::new();
|
||||
let mut boundary: Option<&Candidate> = None;
|
||||
for r in &rows {
|
||||
if r.is_tombstone {
|
||||
boundary = Some(r);
|
||||
break;
|
||||
}
|
||||
if r.value.is_object() {
|
||||
maps.push(r);
|
||||
} else {
|
||||
boundary = Some(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(first) = maps.first() {
|
||||
// Map run wins; deep-merge farthest→nearest so nearest keys win.
|
||||
let mut acc = Map::new();
|
||||
for m in maps.iter().rev() {
|
||||
if let Value::Object(obj) = &m.value {
|
||||
deep_merge(&mut acc, obj);
|
||||
}
|
||||
}
|
||||
let prov = Provenance {
|
||||
owner_kind: first.owner_kind,
|
||||
owner_id: first.owner_id,
|
||||
depth: first.depth,
|
||||
scope: first.scope.clone(),
|
||||
merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(),
|
||||
};
|
||||
return Some((Value::Object(acc), prov));
|
||||
}
|
||||
|
||||
// No leading map run: the nearest row is the boundary.
|
||||
match boundary {
|
||||
// Nearest is a tombstone → key deleted.
|
||||
Some(b) if b.is_tombstone => None,
|
||||
// Nearest is a scalar/array → take it verbatim.
|
||||
Some(b) => Some((
|
||||
b.value.clone(),
|
||||
Provenance {
|
||||
owner_kind: b.owner_kind,
|
||||
owner_id: b.owner_id,
|
||||
depth: b.depth,
|
||||
scope: b.scope.clone(),
|
||||
merged_from: Vec::new(),
|
||||
},
|
||||
)),
|
||||
// No rows at all (caller only passes non-empty groups).
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a full candidate set (mixed keys, any order) into the effective
|
||||
/// config map + per-key provenance. Pure — unit-tested without Postgres.
|
||||
#[must_use]
|
||||
pub fn resolve(
|
||||
candidates: Vec<Candidate>,
|
||||
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
|
||||
let mut by_key: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
|
||||
for c in candidates {
|
||||
by_key.entry(c.key.clone()).or_default().push(c);
|
||||
}
|
||||
let mut values = BTreeMap::new();
|
||||
let mut provenance = BTreeMap::new();
|
||||
for (key, rows) in by_key {
|
||||
if let Some((v, p)) = resolve_one(rows) {
|
||||
values.insert(key.clone(), v);
|
||||
provenance.insert(key, p);
|
||||
}
|
||||
}
|
||||
(values, provenance)
|
||||
}
|
||||
|
||||
/// Pull every env-eligible `vars` candidate for `app_id`: the app's own
|
||||
/// rows + every ancestor group's rows, filtered to `*` or the app's
|
||||
/// environment, ordered nearest-first.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn fetch_var_candidates(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT c.depth, \
|
||||
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(v.app_id, v.group_id) AS owner_id, \
|
||||
v.environment_scope, v.key, v.value, v.is_tombstone \
|
||||
FROM chain c \
|
||||
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
|
||||
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
|
||||
ORDER BY c.depth ASC"
|
||||
);
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// The masked, resolved view of one inherited secret for `config/effective`:
|
||||
/// which owner/level/scope supplies it — **never** the value.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EffectiveSecretMeta {
|
||||
pub name: String,
|
||||
pub owner_kind: OwnerKind,
|
||||
pub owner_id: Uuid,
|
||||
pub scope: String,
|
||||
pub depth: i32,
|
||||
}
|
||||
|
||||
/// Resolve the *names* of every secret an app effectively sees — its own
|
||||
/// plus every ancestor group's, env-filtered, nearest-wins per name. Returns
|
||||
/// masked metadata only (owner/level/scope), so it's safe for an app-level
|
||||
/// principal to read. `DISTINCT ON (name)` with the same ordering as the
|
||||
/// per-name secret resolver guarantees the same winner.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn fetch_effective_secret_meta(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<EffectiveSecretMeta>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT DISTINCT ON (s.name) s.name, \
|
||||
CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(s.app_id, s.group_id) AS owner_id, \
|
||||
s.environment_scope, c.depth \
|
||||
FROM chain c \
|
||||
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.environment_scope = '*' OR s.environment_scope = c.app_env \
|
||||
ORDER BY s.name ASC, c.depth ASC, (s.environment_scope <> '*') DESC"
|
||||
);
|
||||
let rows: Vec<(String, String, Uuid, String, i32)> = sqlx::query_as(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(name, owner_kind, owner_id, scope, depth)| EffectiveSecretMeta {
|
||||
name,
|
||||
owner_kind: if owner_kind == "app" {
|
||||
OwnerKind::App
|
||||
} else {
|
||||
OwnerKind::Group
|
||||
},
|
||||
owner_id,
|
||||
scope,
|
||||
depth,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct VarCandidateRow {
|
||||
depth: i32,
|
||||
owner_kind: String,
|
||||
owner_id: Uuid,
|
||||
environment_scope: String,
|
||||
key: String,
|
||||
value: Value,
|
||||
is_tombstone: bool,
|
||||
}
|
||||
|
||||
impl From<VarCandidateRow> for Candidate {
|
||||
fn from(r: VarCandidateRow) -> Self {
|
||||
Self {
|
||||
depth: r.depth,
|
||||
owner_kind: if r.owner_kind == "app" {
|
||||
OwnerKind::App
|
||||
} else {
|
||||
OwnerKind::Group
|
||||
},
|
||||
owner_id: r.owner_id,
|
||||
scope: r.environment_scope,
|
||||
key: r.key,
|
||||
value: r.value,
|
||||
is_tombstone: r.is_tombstone,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate {
|
||||
Candidate {
|
||||
depth,
|
||||
owner_kind: if depth == 0 {
|
||||
OwnerKind::App
|
||||
} else {
|
||||
OwnerKind::Group
|
||||
},
|
||||
owner_id: Uuid::nil(),
|
||||
scope: scope.into(),
|
||||
key: key.into(),
|
||||
value,
|
||||
is_tombstone: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn tomb(depth: i32, scope: &str, key: &str) -> Candidate {
|
||||
Candidate {
|
||||
is_tombstone: true,
|
||||
value: Value::Null,
|
||||
..cand(depth, scope, key, Value::Null)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_level_wins() {
|
||||
// app (depth 0) overrides group (depth 2).
|
||||
let (v, p) = resolve(vec![
|
||||
cand(2, "*", "region", json!("eu")),
|
||||
cand(0, "*", "region", json!("us")),
|
||||
]);
|
||||
assert_eq!(v["region"], json!("us"));
|
||||
assert_eq!(p["region"].depth, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_scoped_beats_agnostic_within_a_level() {
|
||||
let (v, p) = resolve(vec![
|
||||
cand(1, "*", "db_url", json!("default")),
|
||||
cand(1, "staging", "db_url", json!("staging-db")),
|
||||
]);
|
||||
assert_eq!(v["db_url"], json!("staging-db"));
|
||||
assert_eq!(p["db_url"].scope, "staging");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proximity_beats_env_specificity_across_levels() {
|
||||
// §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`.
|
||||
let (v, _) = resolve(vec![
|
||||
cand(2, "production", "db_url", json!("anc-prod")),
|
||||
cand(0, "*", "db_url", json!("leaf-default")),
|
||||
]);
|
||||
assert_eq!(v["db_url"], json!("leaf-default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_deep_merge_nearest_wins() {
|
||||
// ancestor sets {title, region}; leaf overrides title, adds locale.
|
||||
let (v, p) = resolve(vec![
|
||||
cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})),
|
||||
cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})),
|
||||
]);
|
||||
assert_eq!(
|
||||
v["ui"],
|
||||
json!({"title": "Leaf", "region": "eu", "locale": "en"})
|
||||
);
|
||||
assert_eq!(p["ui"].merged_from.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_level_env_map_suppresses_agnostic_map_no_merge() {
|
||||
// §3 step 1: within ONE level, `@E` is not a merge layer over `*` — it
|
||||
// shadows it. A level holding both an `@staging` map and a `*` map must
|
||||
// resolve to the `@staging` map alone, never a deep-merge of the two.
|
||||
let (v, p) = resolve(vec![
|
||||
cand(1, "*", "cfg", json!({"a": 1, "shared": "default"})),
|
||||
cand(1, "staging", "cfg", json!({"shared": "staging"})),
|
||||
]);
|
||||
assert_eq!(v["cfg"], json!({"shared": "staging"}));
|
||||
assert_eq!(p["cfg"].scope, "staging");
|
||||
// Provenance must not list the suppressed `*` layer.
|
||||
assert_eq!(p["cfg"].merged_from, vec![(1, "staging".to_string())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_level_env_map_then_ancestor_map_merges_without_agnostic_sibling() {
|
||||
// The suppressed same-level `*` must also be invisible to cross-level
|
||||
// merge: leaf `@staging` map merges onto the ancestor map, but the
|
||||
// leaf's own `*` map (shadowed at its level) never contributes.
|
||||
let (v, _) = resolve(vec![
|
||||
cand(2, "*", "cfg", json!({"region": "eu", "tier": "base"})),
|
||||
cand(0, "*", "cfg", json!({"leak": "should-not-appear"})),
|
||||
cand(0, "staging", "cfg", json!({"tier": "leaf"})),
|
||||
]);
|
||||
assert_eq!(v["cfg"], json!({"region": "eu", "tier": "leaf"}));
|
||||
assert!(!v["cfg"].as_object().unwrap().contains_key("leak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearer_scalar_replaces_whole_inherited_map() {
|
||||
let (v, _) = resolve(vec![
|
||||
cand(2, "*", "x", json!({"a": 1})),
|
||||
cand(0, "*", "x", json!("scalar")),
|
||||
]);
|
||||
assert_eq!(v["x"], json!("scalar"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tombstone_suppresses_inherited_key() {
|
||||
let (v, _) = resolve(vec![
|
||||
cand(2, "*", "secret_flag", json!(true)),
|
||||
tomb(0, "*", "secret_flag"),
|
||||
]);
|
||||
assert!(!v.contains_key("secret_flag"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_null_is_a_value_not_a_deletion() {
|
||||
let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]);
|
||||
assert!(v.contains_key("k"));
|
||||
assert_eq!(v["k"], Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_merge_stops_at_a_nearer_scalar_boundary() {
|
||||
// nearest map, then a scalar, then a farther map: only the
|
||||
// contiguous top map run merges; the scalar bounds it.
|
||||
let (v, _) = resolve(vec![
|
||||
cand(3, "*", "m", json!({"deep": 1})),
|
||||
cand(2, "*", "m", json!("scalar-boundary")),
|
||||
cand(0, "*", "m", json!({"near": 2})),
|
||||
]);
|
||||
// depth 0 map is the only one above the depth-2 scalar boundary.
|
||||
assert_eq!(v["m"], json!({"near": 2}));
|
||||
}
|
||||
}
|
||||
@@ -384,6 +384,39 @@ impl Dispatcher {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Fire-time `enabled` re-check (§4.3). The queue arm does not flow
|
||||
// through `dispatch_one`'s unified `!active` gate; its only other
|
||||
// `enabled` guard is the per-tick `list_active_queue_consumers`
|
||||
// snapshot, which is stale for messages already claimed in this
|
||||
// tick. A script disabled after the list query but before this
|
||||
// claimed message executes must NOT run (`script` here is a fresh
|
||||
// read from line ~331, so `enabled` is current). Release the claim
|
||||
// (nack) rather than ack/dead-letter: the message stays queued and
|
||||
// is processed when the script is re-enabled, and the next tick
|
||||
// won't re-claim it because the list filters `s.enabled`. Without
|
||||
// this nack the message would sit claimed indefinitely —
|
||||
// `reclaim_visibility_timeouts` only reclaims for enabled triggers.
|
||||
if !script.enabled {
|
||||
tracing::info!(
|
||||
script_id = %consumer.script_id,
|
||||
trigger_id = %consumer.trigger_id,
|
||||
"queue consumer script disabled at fire time; releasing claim"
|
||||
);
|
||||
if let Err(e) = self
|
||||
.queue
|
||||
.nack(
|
||||
claimed.id,
|
||||
claimed.claim_token,
|
||||
chrono::Duration::seconds(1),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(?e, "queue nack on disabled consumer failed");
|
||||
}
|
||||
drop(permit);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let principal = self
|
||||
.principals
|
||||
.resolve(consumer.registered_by_principal)
|
||||
@@ -546,6 +579,7 @@ impl Dispatcher {
|
||||
// TODO(metrics): bump picloud_queue_dead_letters_total{app_id, queue_name}.
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn dispatch_one(&self, row: OutboxRow) -> Result<(), DispatcherError> {
|
||||
// Depth-limit check — design notes §4: loops aren't DL'd.
|
||||
if row.trigger_depth > self.config.max_trigger_depth {
|
||||
@@ -626,6 +660,26 @@ impl Dispatcher {
|
||||
}
|
||||
};
|
||||
|
||||
// §4.3 fire-time re-check, for EVERY outbox source (trigger, async
|
||||
// HTTP/202, and invoke): if the target script (or, for triggers, the
|
||||
// trigger) was disabled after this row was enqueued, drop it rather
|
||||
// than fire a stale event. The match-time `enabled` check can't see a
|
||||
// later toggle, so this is the gate that makes a disabled script
|
||||
// genuinely non-invocable on the async paths too.
|
||||
if !resolved.active {
|
||||
tracing::debug!(
|
||||
outbox_id = %row.id,
|
||||
app_id = %row.app_id,
|
||||
"target script/trigger disabled since enqueue; dropping outbox row"
|
||||
);
|
||||
self.outbox
|
||||
.delete(row.id)
|
||||
.await
|
||||
.map_err(|e| DispatcherError::Outbox(e.to_string()))?;
|
||||
drop(permit);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// The gate permit auto-releases when this scope ends or when
|
||||
// the executor finishes. We hand control to the executor and
|
||||
// wait synchronously here — sync HTTP and dispatcher share the
|
||||
@@ -722,9 +776,26 @@ impl Dispatcher {
|
||||
DispatcherError::ResolveTrigger(format!("script {} not found", trigger.script_id))
|
||||
})?;
|
||||
|
||||
// Audit 2026-06-11 H-F1 sibling — same-app guard mirroring
|
||||
// build_http_request / build_invoke_request / dispatch_one_queue.
|
||||
// `build_exec_request` stamps `ExecRequest.app_id = row.app_id`
|
||||
// while sourcing the body from `trigger.script_id`; without this
|
||||
// check a hand-edited outbox/trigger row (or a partial restore, or
|
||||
// a script re-pointed across apps) could run one app's script under
|
||||
// another app's `SdkCallCx.app_id` — the cross-app isolation
|
||||
// boundary. Not reachable via the trigger-create or `apply` paths
|
||||
// (both resolve the script within the app's own scope), so this is
|
||||
// the runtime backstop the other arms already carry.
|
||||
if script.app_id != row.app_id {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"trigger outbox target belongs to a different app".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ResolvedTrigger {
|
||||
trigger_kind: trigger.kind,
|
||||
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
|
||||
active: trigger.enabled && script.enabled,
|
||||
script_id: script.id,
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
@@ -844,6 +915,9 @@ impl Dispatcher {
|
||||
let resolved = ResolvedTrigger {
|
||||
trigger_kind: TriggerKind::Kv, // placeholder; HTTP doesn't have a kind
|
||||
is_dead_letter_handler: false,
|
||||
// §4.3: an async-HTTP (202) row whose script was disabled after
|
||||
// enqueue is dropped at fire time by the post-match active check.
|
||||
active: script.enabled,
|
||||
script_id,
|
||||
script_source: script.source,
|
||||
script_name: payload.script_name,
|
||||
@@ -941,6 +1015,9 @@ impl Dispatcher {
|
||||
let resolved = ResolvedTrigger {
|
||||
trigger_kind: TriggerKind::Cron, // placeholder; not used downstream
|
||||
is_dead_letter_handler: false,
|
||||
// §4.3: a queued invoke() whose target script was disabled after
|
||||
// enqueue is dropped at fire time by the post-match active check.
|
||||
active: script.enabled,
|
||||
script_id: script.id,
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
@@ -1238,6 +1315,9 @@ impl Dispatcher {
|
||||
pub struct ResolvedTrigger {
|
||||
pub trigger_kind: TriggerKind,
|
||||
pub is_dead_letter_handler: bool,
|
||||
/// §4.3 fire-time gate: `trigger.enabled && script.enabled` at resolve
|
||||
/// time. A row enqueued before either was disabled is dropped, not fired.
|
||||
pub active: bool,
|
||||
pub script_id: ScriptId,
|
||||
pub script_source: String,
|
||||
pub script_name: String,
|
||||
@@ -1504,4 +1584,837 @@ mod tests {
|
||||
);
|
||||
assert_eq!(failure_kind_to_status(InboxFailureKind::Platform), 500);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Queue-arm fire-time `enabled` gate (§4.3).
|
||||
//
|
||||
// `dispatch_one_queue` re-reads the script fresh after claiming a
|
||||
// message and, if it has been disabled since the per-tick
|
||||
// `list_active_queue_consumers` snapshot, releases the claim (nack)
|
||||
// without resolving a principal or executing. This test proves that
|
||||
// gate end-to-end with in-memory stubs.
|
||||
//
|
||||
// Regression property: the principal resolver returns a valid
|
||||
// `Principal` and the executor records `executed = true` before
|
||||
// erroring, so DELETING the `if !script.enabled` gate makes the flow
|
||||
// fall through to resolve → execute, flipping `executed` and failing
|
||||
// `assert!(!executed)`. (Verified by temporarily removing the gate.)
|
||||
// ----------------------------------------------------------------
|
||||
mod queue_enabled_gate {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse};
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, InstanceRole, Principal, Script, ScriptId, ScriptSandbox,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::abandoned_repo::{AbandonedRepo, NewAbandonedExecution};
|
||||
use crate::dead_letter_repo::{DeadLetterRepo, NewDeadLetter};
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxRow};
|
||||
use crate::principal_resolver::{PrincipalResolver, PrincipalResolverError};
|
||||
use crate::queue_repo::{ClaimedMessage, NewQueueMessage, QueueRepo, QueueStats};
|
||||
use crate::repo::{NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError};
|
||||
use crate::trigger_config::BackoffShape;
|
||||
use crate::trigger_repo::{ActiveQueueConsumer, TriggerRepo};
|
||||
|
||||
// ---- ScriptRepository: only `get` is exercised. ----
|
||||
pub(super) struct DisabledScriptRepo {
|
||||
pub(super) script: Script,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ScriptRepository for DisabledScriptRepo {
|
||||
async fn get(&self, _id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
Ok(Some(self.script.clone()))
|
||||
}
|
||||
async fn get_by_name(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create(&self, _input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn update(
|
||||
&self,
|
||||
_id: ScriptId,
|
||||
_patch: ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn delete(&self, _id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn count_routes_for_script(
|
||||
&self,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn count_triggers_for_script(
|
||||
&self,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_imports(
|
||||
&self,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- QueueRepo: `claim` returns the message, `nack` records. ----
|
||||
struct ClaimNackQueue {
|
||||
claimed: ClaimedMessage,
|
||||
nacked: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl QueueRepo for ClaimNackQueue {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
_msg: NewQueueMessage,
|
||||
) -> Result<picloud_shared::QueueMessageId, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn claim(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<Option<ClaimedMessage>, crate::queue_repo::QueueRepoError> {
|
||||
Ok(Some(self.claimed.clone()))
|
||||
}
|
||||
async fn ack(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn nack(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_retry_delay: chrono::Duration,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
self.nacked.store(true, Ordering::SeqCst);
|
||||
Ok(true)
|
||||
}
|
||||
async fn reclaim_visibility_timeouts(
|
||||
&self,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn depth(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<Vec<(String, QueueStats)>, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
_trigger_id: Option<picloud_shared::TriggerId>,
|
||||
_script_id: Option<ScriptId>,
|
||||
_attempt: u32,
|
||||
_first_attempt_at: chrono::DateTime<Utc>,
|
||||
_last_error: &str,
|
||||
) -> Result<picloud_shared::DeadLetterId, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PrincipalResolver: returns a valid Principal (regression). ----
|
||||
pub(super) struct OkPrincipals;
|
||||
|
||||
#[async_trait]
|
||||
impl PrincipalResolver for OkPrincipals {
|
||||
async fn resolve(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<Principal, PrincipalResolverError> {
|
||||
Ok(Principal {
|
||||
user_id,
|
||||
instance_role: InstanceRole::Owner,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ExecutorClient: records execution then errors (regression). ----
|
||||
pub(super) struct RecordingExecutor {
|
||||
pub(super) executed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutorClient for RecordingExecutor {
|
||||
async fn execute(
|
||||
&self,
|
||||
_source: &str,
|
||||
_req: ExecRequest,
|
||||
_timeout: std::time::Duration,
|
||||
) -> Result<ExecResponse, ExecError> {
|
||||
self.executed.store(true, Ordering::SeqCst);
|
||||
Err(ExecError::Runtime("stub".into()))
|
||||
}
|
||||
// Intentionally NOT overriding `execute_with_identity`: the
|
||||
// default impl forwards to `execute`, which is what gate
|
||||
// removal would reach.
|
||||
}
|
||||
|
||||
// ---- Remaining deps: never exercised by the disabled path. ----
|
||||
pub(super) struct UnusedTriggers;
|
||||
|
||||
#[async_trait]
|
||||
impl TriggerRepo for UnusedTriggers {
|
||||
async fn create_kv_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateKvTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_docs_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateDocsTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_dead_letter_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateDeadLetterTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_cron_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateCronTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_files_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateFilesTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_pubsub_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreatePubsubTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_email_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateEmailTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn email_inbound_target(
|
||||
&self,
|
||||
_trigger_id: picloud_shared::TriggerId,
|
||||
) -> Result<
|
||||
Option<crate::trigger_repo::EmailInboundTarget>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<Vec<crate::trigger_repo::Trigger>, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_id: picloud_shared::TriggerId,
|
||||
) -> Result<Option<crate::trigger_repo::Trigger>, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn delete(
|
||||
&self,
|
||||
_id: picloud_shared::TriggerId,
|
||||
) -> Result<bool, crate::trigger_repo::TriggerRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_matching_kv(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_collection: &str,
|
||||
_op: picloud_shared::KvEventOp,
|
||||
) -> Result<
|
||||
Vec<crate::trigger_repo::KvTriggerMatch>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_matching_docs(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_collection: &str,
|
||||
_op: picloud_shared::DocsEventOp,
|
||||
) -> Result<
|
||||
Vec<crate::trigger_repo::DocsTriggerMatch>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_matching_files(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_collection: &str,
|
||||
_op: picloud_shared::FilesEventOp,
|
||||
) -> Result<
|
||||
Vec<crate::trigger_repo::FilesTriggerMatch>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_matching_dead_letter(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_source: &str,
|
||||
_trigger_id: Option<picloud_shared::TriggerId>,
|
||||
_script_id: Option<ScriptId>,
|
||||
) -> Result<
|
||||
Vec<crate::trigger_repo::DeadLetterTriggerMatch>,
|
||||
crate::trigger_repo::TriggerRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn create_queue_trigger(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_req: crate::trigger_repo::CreateQueueTrigger,
|
||||
) -> Result<crate::trigger_repo::Trigger, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_active_queue_consumers(
|
||||
&self,
|
||||
) -> Result<Vec<ActiveQueueConsumer>, crate::trigger_repo::TriggerRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn touch_queue_trigger_last_fired_at(
|
||||
&self,
|
||||
_trigger_id: picloud_shared::TriggerId,
|
||||
_at: chrono::DateTime<Utc>,
|
||||
) -> Result<(), crate::trigger_repo::TriggerRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct UnusedDeadLetters;
|
||||
|
||||
#[async_trait]
|
||||
impl DeadLetterRepo for UnusedDeadLetters {
|
||||
async fn insert(
|
||||
&self,
|
||||
_row: NewDeadLetter,
|
||||
) -> Result<picloud_shared::DeadLetterId, crate::dead_letter_repo::DeadLetterRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
_id: picloud_shared::DeadLetterId,
|
||||
) -> Result<
|
||||
Option<crate::dead_letter_repo::DeadLetterRow>,
|
||||
crate::dead_letter_repo::DeadLetterRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_unresolved_only: bool,
|
||||
_limit: i64,
|
||||
_offset: i64,
|
||||
) -> Result<
|
||||
Vec<crate::dead_letter_repo::DeadLetterRow>,
|
||||
crate::dead_letter_repo::DeadLetterRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn unresolved_count(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<i64, crate::dead_letter_repo::DeadLetterRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn resolve(
|
||||
&self,
|
||||
_id: picloud_shared::DeadLetterId,
|
||||
_reason: &str,
|
||||
) -> Result<(), crate::dead_letter_repo::DeadLetterRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn gc(
|
||||
&self,
|
||||
_older_than: chrono::DateTime<Utc>,
|
||||
_limit: i64,
|
||||
) -> Result<u64, crate::dead_letter_repo::DeadLetterRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
struct UnusedOutbox;
|
||||
|
||||
#[async_trait]
|
||||
impl OutboxRepo for UnusedOutbox {
|
||||
async fn insert(
|
||||
&self,
|
||||
_row: NewOutboxRow,
|
||||
) -> Result<Uuid, crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn claim_due(
|
||||
&self,
|
||||
_claimed_by: &str,
|
||||
_limit: i64,
|
||||
) -> Result<Vec<OutboxRow>, crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn delete(&self, _id: Uuid) -> Result<(), crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn reschedule(
|
||||
&self,
|
||||
_id: Uuid,
|
||||
_attempt_count: u32,
|
||||
_next_attempt_at: chrono::DateTime<Utc>,
|
||||
) -> Result<(), crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct UnusedAbandoned;
|
||||
|
||||
#[async_trait]
|
||||
impl AbandonedRepo for UnusedAbandoned {
|
||||
async fn insert(
|
||||
&self,
|
||||
_row: NewAbandonedExecution,
|
||||
) -> Result<Uuid, crate::abandoned_repo::AbandonedRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn gc(
|
||||
&self,
|
||||
_older_than: chrono::DateTime<Utc>,
|
||||
_limit: i64,
|
||||
) -> Result<u64, crate::abandoned_repo::AbandonedRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct UnusedLogSink;
|
||||
|
||||
#[async_trait]
|
||||
impl ExecutionLogSink for UnusedLogSink {
|
||||
async fn record(
|
||||
&self,
|
||||
_log: picloud_shared::ExecutionLog,
|
||||
) -> Result<(), picloud_shared::LogSinkError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct UnusedInbox;
|
||||
|
||||
#[async_trait]
|
||||
impl InboxResolver for UnusedInbox {
|
||||
async fn deliver(
|
||||
&self,
|
||||
_inbox_id: Uuid,
|
||||
_result: picloud_shared::InboxResult,
|
||||
) -> picloud_shared::InboxDeliveryOutcome {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn disabled_script(app_id: AppId) -> Script {
|
||||
Script {
|
||||
id: ScriptId::new(),
|
||||
app_id,
|
||||
name: "worker".into(),
|
||||
description: None,
|
||||
version: 1,
|
||||
source: "0".into(),
|
||||
kind: picloud_shared::ScriptKind::Endpoint,
|
||||
timeout_seconds: 30,
|
||||
memory_limit_mb: 64,
|
||||
sandbox: ScriptSandbox::default(),
|
||||
enabled: false,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_queue_consumer_releases_claim_without_executing() {
|
||||
let app_id = AppId::new();
|
||||
let script = disabled_script(app_id);
|
||||
|
||||
let claimed = ClaimedMessage {
|
||||
id: picloud_shared::QueueMessageId::new(),
|
||||
app_id,
|
||||
queue_name: "jobs".into(),
|
||||
payload: serde_json::Value::Null,
|
||||
enqueued_at: Utc::now(),
|
||||
attempt: 1,
|
||||
max_attempts: 5,
|
||||
claim_token: Uuid::new_v4(),
|
||||
};
|
||||
|
||||
let consumer = ActiveQueueConsumer {
|
||||
trigger_id: picloud_shared::TriggerId::new(),
|
||||
app_id,
|
||||
script_id: script.id,
|
||||
queue_name: "jobs".into(),
|
||||
visibility_timeout_secs: 30,
|
||||
retry_max_attempts: 5,
|
||||
retry_backoff: BackoffShape::Exponential,
|
||||
retry_base_ms: 1000,
|
||||
registered_by_principal: AdminUserId::new(),
|
||||
};
|
||||
|
||||
let nacked = Arc::new(AtomicBool::new(false));
|
||||
let executed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let dispatcher = Dispatcher {
|
||||
outbox: Arc::new(UnusedOutbox),
|
||||
triggers: Arc::new(UnusedTriggers),
|
||||
scripts: Arc::new(DisabledScriptRepo {
|
||||
script: script.clone(),
|
||||
}),
|
||||
dead_letters: Arc::new(UnusedDeadLetters),
|
||||
abandoned: Arc::new(UnusedAbandoned),
|
||||
principals: Arc::new(OkPrincipals),
|
||||
executor: Arc::new(RecordingExecutor {
|
||||
executed: executed.clone(),
|
||||
}),
|
||||
gate: Arc::new(ExecutionGate::new(1)),
|
||||
log_sink: Arc::new(UnusedLogSink),
|
||||
inbox: Arc::new(UnusedInbox),
|
||||
queue: Arc::new(ClaimNackQueue {
|
||||
claimed,
|
||||
nacked: nacked.clone(),
|
||||
}),
|
||||
config: TriggerConfig::from_env(),
|
||||
instance_id: "test-instance".into(),
|
||||
};
|
||||
|
||||
let result = dispatcher.dispatch_one_queue(&consumer).await;
|
||||
|
||||
// 1. The disabled path returns Ok(()).
|
||||
assert!(result.is_ok(), "dispatch_one_queue returned {result:?}");
|
||||
// 2. The claim was released via nack.
|
||||
assert!(
|
||||
nacked.load(Ordering::SeqCst),
|
||||
"expected nack to release the claim for a disabled consumer"
|
||||
);
|
||||
// 3. The executor was never reached. If the `if !script.enabled`
|
||||
// gate is deleted, the flow falls through to resolve →
|
||||
// execute and this flips to true.
|
||||
assert!(
|
||||
!executed.load(Ordering::SeqCst),
|
||||
"executor ran for a disabled queue consumer; fire-time gate missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// OUTBOX (async-HTTP) arm fire-time `enabled` gate (§4.3).
|
||||
//
|
||||
// `dispatch_one` builds an `ExecRequest` from an HTTP outbox row via
|
||||
// `build_http_request`, which sets `resolved.active = script.enabled`
|
||||
// but does NOT itself reject a disabled script. The unified gate at
|
||||
// `if !resolved.active` then drops the row (delete) without executing.
|
||||
// This test proves that gate end-to-end for an HTTP-source row whose
|
||||
// target script was disabled after the row was enqueued.
|
||||
//
|
||||
// Regression property (mirrors the queue test): `RecordingExecutor`
|
||||
// flips `executed = true` before erroring, so DELETING the
|
||||
// `if !resolved.active` gate makes the flow fall through to execute,
|
||||
// flipping `executed` and failing `assert!(!executed)`.
|
||||
// ----------------------------------------------------------------
|
||||
mod outbox_enabled_gate {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use picloud_orchestrator_core::ExecutionGate;
|
||||
use picloud_shared::AppId;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::outbox_repo::{NewOutboxRow, OutboxRepo, OutboxRow, OutboxSourceKind};
|
||||
|
||||
// Reuse the stub trait impls + helpers from the queue test so the
|
||||
// two gate tests share one set of in-memory deps.
|
||||
use super::queue_enabled_gate::{
|
||||
disabled_script, DisabledScriptRepo, OkPrincipals, RecordingExecutor, UnusedAbandoned,
|
||||
UnusedDeadLetters, UnusedInbox, UnusedLogSink, UnusedTriggers,
|
||||
};
|
||||
|
||||
// QueueRepo is never reached on the outbox arm — a panic stub keeps
|
||||
// the surface honest without pulling the queue test's claim stub.
|
||||
struct UnusedQueue;
|
||||
|
||||
#[async_trait]
|
||||
impl crate::queue_repo::QueueRepo for UnusedQueue {
|
||||
async fn enqueue(
|
||||
&self,
|
||||
_msg: crate::queue_repo::NewQueueMessage,
|
||||
) -> Result<picloud_shared::QueueMessageId, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn claim(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<Option<crate::queue_repo::ClaimedMessage>, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn ack(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn nack(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_retry_delay: chrono::Duration,
|
||||
) -> Result<bool, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn reclaim_visibility_timeouts(
|
||||
&self,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn depth(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn depth_pending(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
) -> Result<u64, crate::queue_repo::QueueRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
) -> Result<
|
||||
Vec<(String, crate::queue_repo::QueueStats)>,
|
||||
crate::queue_repo::QueueRepoError,
|
||||
> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn dead_letter(
|
||||
&self,
|
||||
_message_id: picloud_shared::QueueMessageId,
|
||||
_claim_token: Uuid,
|
||||
_app_id: AppId,
|
||||
_queue_name: &str,
|
||||
_trigger_id: Option<picloud_shared::TriggerId>,
|
||||
_script_id: Option<picloud_shared::ScriptId>,
|
||||
_attempt: u32,
|
||||
_first_attempt_at: chrono::DateTime<Utc>,
|
||||
_last_error: &str,
|
||||
) -> Result<picloud_shared::DeadLetterId, crate::queue_repo::QueueRepoError>
|
||||
{
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
// OutboxRepo whose `delete` records the id it was called with; the
|
||||
// other methods are never reached on the disabled-drop path.
|
||||
struct RecordingOutbox {
|
||||
deleted: Arc<Mutex<Option<Uuid>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl OutboxRepo for RecordingOutbox {
|
||||
async fn insert(
|
||||
&self,
|
||||
_row: NewOutboxRow,
|
||||
) -> Result<Uuid, crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn claim_due(
|
||||
&self,
|
||||
_claimed_by: &str,
|
||||
_limit: i64,
|
||||
) -> Result<Vec<OutboxRow>, crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn delete(&self, id: Uuid) -> Result<(), crate::outbox_repo::OutboxRepoError> {
|
||||
*self.deleted.lock().unwrap() = Some(id);
|
||||
Ok(())
|
||||
}
|
||||
async fn reschedule(
|
||||
&self,
|
||||
_id: Uuid,
|
||||
_attempt_count: u32,
|
||||
_next_attempt_at: chrono::DateTime<Utc>,
|
||||
) -> Result<(), crate::outbox_repo::OutboxRepoError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn disabled_http_outbox_row_is_dropped_without_executing() {
|
||||
let app_id = AppId::new();
|
||||
let script = disabled_script(app_id);
|
||||
|
||||
// Minimal valid HttpDispatchPayload (see shared::outbox_writer).
|
||||
let payload = serde_json::json!({
|
||||
"script_name": "worker",
|
||||
"path": "/hook",
|
||||
"method": "POST",
|
||||
"headers": {},
|
||||
"body": null,
|
||||
"params": {},
|
||||
"query": {},
|
||||
"rest": "",
|
||||
"timeout_seconds": 30,
|
||||
});
|
||||
|
||||
let row_id = Uuid::new_v4();
|
||||
let row = OutboxRow {
|
||||
id: row_id,
|
||||
// Same app_id as the script so the same-app guard passes.
|
||||
app_id,
|
||||
source_kind: OutboxSourceKind::Http,
|
||||
trigger_id: None,
|
||||
script_id: Some(script.id),
|
||||
reply_to: None,
|
||||
payload,
|
||||
origin_principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: None,
|
||||
attempt_count: 0,
|
||||
next_attempt_at: Utc::now(),
|
||||
created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let deleted = Arc::new(Mutex::new(None));
|
||||
let executed = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let dispatcher = Dispatcher {
|
||||
outbox: Arc::new(RecordingOutbox {
|
||||
deleted: deleted.clone(),
|
||||
}),
|
||||
triggers: Arc::new(UnusedTriggers),
|
||||
scripts: Arc::new(DisabledScriptRepo {
|
||||
script: script.clone(),
|
||||
}),
|
||||
dead_letters: Arc::new(UnusedDeadLetters),
|
||||
abandoned: Arc::new(UnusedAbandoned),
|
||||
principals: Arc::new(OkPrincipals),
|
||||
executor: Arc::new(RecordingExecutor {
|
||||
executed: executed.clone(),
|
||||
}),
|
||||
gate: Arc::new(ExecutionGate::new(1)),
|
||||
log_sink: Arc::new(UnusedLogSink),
|
||||
inbox: Arc::new(UnusedInbox),
|
||||
queue: Arc::new(UnusedQueue),
|
||||
config: TriggerConfig::from_env(),
|
||||
instance_id: "test-instance".into(),
|
||||
};
|
||||
|
||||
let result = dispatcher.dispatch_one(row).await;
|
||||
|
||||
// 1. The disabled-drop path returns Ok(()).
|
||||
assert!(result.is_ok(), "dispatch_one returned {result:?}");
|
||||
// 2. The outbox row was deleted with its own id.
|
||||
assert_eq!(
|
||||
*deleted.lock().unwrap(),
|
||||
Some(row_id),
|
||||
"expected the disabled HTTP outbox row to be deleted"
|
||||
);
|
||||
// 3. The executor was never reached. If the `if !resolved.active`
|
||||
// gate at dispatch_one is deleted, the flow falls through to
|
||||
// execute and this flips to true.
|
||||
assert!(
|
||||
!executed.load(Ordering::SeqCst),
|
||||
"executor ran for a disabled HTTP outbox row; fire-time gate missing"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,7 +341,10 @@ impl DevEmailSink {
|
||||
}
|
||||
|
||||
fn push(&self, email: CapturedEmail) {
|
||||
let mut q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut q = self
|
||||
.captured
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
while q.len() >= self.capacity {
|
||||
q.pop_front();
|
||||
}
|
||||
@@ -351,7 +354,10 @@ impl DevEmailSink {
|
||||
/// Newest-first snapshot of the captured mail.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CapturedEmail> {
|
||||
let q = self.captured.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let q = self
|
||||
.captured
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
q.iter().rev().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
205
crates/manager-core/src/group_members_repo.rs
Normal file
205
crates/manager-core/src/group_members_repo.rs
Normal file
@@ -0,0 +1,205 @@
|
||||
//! CRUD over the `group_members` table — explicit per-(user, group) role
|
||||
//! grants. A `group_admin` here is implicitly app_admin on every app and
|
||||
//! subgroup beneath the group; that inheritance is resolved by the authz
|
||||
//! ancestor walk (`app_members_repo::effective_app_role`), not here.
|
||||
//!
|
||||
//! Mirrors `app_members_repo` — same three role literals, same shapes.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, AppRole, GroupId, InstanceRole};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupMembersRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("invalid app_role stored in DB: {0}")]
|
||||
InvalidRole(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupMembershipRow {
|
||||
pub group_id: GroupId,
|
||||
pub user_id: AdminUserId,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// `group_members` row joined with `admin_users` for the dashboard's
|
||||
/// per-group Members tab.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GroupMembershipDetail {
|
||||
pub user_id: AdminUserId,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub instance_role: InstanceRole,
|
||||
pub is_active: bool,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupMembersRepository: Send + Sync {
|
||||
/// Atomic insert. `None` if a membership already exists (handler → 409).
|
||||
async fn try_insert(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
|
||||
|
||||
/// Atomic role update. `None` if no row exists (handler → 404).
|
||||
async fn update_role(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError>;
|
||||
|
||||
/// Remove a membership. No-op when the row doesn't exist.
|
||||
async fn remove(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<(), GroupMembersRepositoryError>;
|
||||
|
||||
/// Per-group member list joined with `admin_users`, ordered by username.
|
||||
async fn list_for_group_enriched(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupMembersRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupMembersRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GroupMembersRepository for PostgresGroupMembersRepository {
|
||||
async fn try_insert(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupMembershipRecord>(
|
||||
"INSERT INTO group_members (group_id, user_id, role) \
|
||||
VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (group_id, user_id) DO NOTHING \
|
||||
RETURNING group_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.bind(role.as_str())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn update_role(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
role: AppRole,
|
||||
) -> Result<Option<GroupMembershipRow>, GroupMembersRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupMembershipRecord>(
|
||||
"UPDATE group_members SET role = $1 \
|
||||
WHERE group_id = $2 AND user_id = $3 \
|
||||
RETURNING group_id, user_id, role, created_at",
|
||||
)
|
||||
.bind(role.as_str())
|
||||
.bind(group_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(TryInto::try_into).transpose()
|
||||
}
|
||||
|
||||
async fn remove(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
user_id: AdminUserId,
|
||||
) -> Result<(), GroupMembersRepositoryError> {
|
||||
sqlx::query("DELETE FROM group_members WHERE group_id = $1 AND user_id = $2")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(user_id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn list_for_group_enriched(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<GroupMembershipDetail>, GroupMembersRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupMembershipDetailRecord>(
|
||||
"SELECT au.id, au.username, au.email, au.instance_role, au.is_active, \
|
||||
gm.role, gm.created_at \
|
||||
FROM group_members gm \
|
||||
JOIN admin_users au ON au.id = gm.user_id \
|
||||
WHERE gm.group_id = $1 \
|
||||
ORDER BY au.username",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
rows.into_iter().map(TryInto::try_into).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupMembershipRecord {
|
||||
group_id: uuid::Uuid,
|
||||
user_id: uuid::Uuid,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<GroupMembershipRecord> for GroupMembershipRow {
|
||||
type Error = GroupMembersRepositoryError;
|
||||
fn try_from(r: GroupMembershipRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
group_id: r.group_id.into(),
|
||||
user_id: r.user_id.into(),
|
||||
role: AppRole::from_db_str(&r.role)
|
||||
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupMembershipDetailRecord {
|
||||
id: uuid::Uuid,
|
||||
username: String,
|
||||
email: Option<String>,
|
||||
instance_role: String,
|
||||
is_active: bool,
|
||||
role: String,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl TryFrom<GroupMembershipDetailRecord> for GroupMembershipDetail {
|
||||
type Error = GroupMembersRepositoryError;
|
||||
fn try_from(r: GroupMembershipDetailRecord) -> Result<Self, Self::Error> {
|
||||
Ok(Self {
|
||||
user_id: r.id.into(),
|
||||
username: r.username,
|
||||
email: r.email,
|
||||
instance_role: InstanceRole::from_db_str(&r.instance_role)
|
||||
.ok_or(GroupMembersRepositoryError::InvalidRole(r.instance_role))?,
|
||||
is_active: r.is_active,
|
||||
role: AppRole::from_db_str(&r.role)
|
||||
.ok_or(GroupMembersRepositoryError::InvalidRole(r.role))?,
|
||||
created_at: r.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
367
crates/manager-core/src/group_repo.rs
Normal file
367
crates/manager-core/src/group_repo.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
//! CRUD over the `groups` tree (Phase 2).
|
||||
//!
|
||||
//! Groups form a single-parent org tree above apps. Structural mutations
|
||||
//! (reparent/rename/delete) must keep the tree acyclic and non-orphaning:
|
||||
//!
|
||||
//! - **delete = RESTRICT** — refused if the group has child groups or apps
|
||||
//! (the DB FKs enforce this; we surface a clean conflict).
|
||||
//! - **slug-freeze** — `rename` edits name/description only; the slug is
|
||||
//! set once at creation and never rewritten.
|
||||
//! - **cycle guard** — `reparent` walks the destination's ancestors under a
|
||||
//! coarse instance-wide advisory lock and refuses a move that would make
|
||||
//! a node its own ancestor. A SQL `CHECK` can't express this.
|
||||
//! - **structure_version** — bumped on every structural mutation so a
|
||||
//! future CLI/orchestrator can detect structural drift (§6).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{Group, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Instance-wide advisory-lock key for structural group mutations. Coarse
|
||||
/// on purpose: reparent/rename/delete all take it so the ancestor-walk
|
||||
/// cycle guard and the `parent_id` write run serialized — two concurrent
|
||||
/// reparents can't race into a cycle. Distinct from the per-app
|
||||
/// `apply_lock_key` space (a fixed sentinel, hashed-namespace-free).
|
||||
const GROUP_STRUCTURAL_LOCK_KEY: i64 = 0x6701_0047_0000_0001;
|
||||
|
||||
/// Well-known slug of the instance root group seeded by migration 0047.
|
||||
pub const ROOT_GROUP_SLUG: &str = "root";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupRepositoryError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
#[error("not found: {0}")]
|
||||
NotFound(GroupId),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
}
|
||||
|
||||
/// Counts of a group's direct children — used to enforce delete=RESTRICT
|
||||
/// with an actionable message and to render the tree.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct GroupChildCounts {
|
||||
pub subgroups: i64,
|
||||
pub apps: i64,
|
||||
}
|
||||
|
||||
impl GroupChildCounts {
|
||||
#[must_use]
|
||||
pub fn is_empty(self) -> bool {
|
||||
self.subgroups == 0 && self.apps == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait GroupRepository: Send + Sync {
|
||||
/// Every group on the instance, ordered by name. The tree is small
|
||||
/// (org structure), so callers assemble the hierarchy in memory.
|
||||
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError>;
|
||||
/// Direct children groups of `parent`.
|
||||
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
/// The node plus its ancestors up to the root, nearest-first. Used for
|
||||
/// path display and as the reparent cycle-guard input.
|
||||
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError>;
|
||||
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError>;
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Edit display fields only — the slug is frozen at creation. Bumps
|
||||
/// `structure_version`.
|
||||
async fn rename(
|
||||
&self,
|
||||
id: GroupId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Move `id` under `new_parent` (or to root if `None`). Runs the
|
||||
/// ancestor-walk cycle guard under a coarse structural lock and bumps
|
||||
/// `structure_version`. Refuses a move that would create a cycle.
|
||||
async fn reparent(
|
||||
&self,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError>;
|
||||
/// Delete an empty group (delete = RESTRICT). Refused with a clean
|
||||
/// conflict if it still has child groups or apps.
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresGroupRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresGroupRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
const GROUP_COLS: &str =
|
||||
"id, parent_id, slug, name, description, structure_version, created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
impl GroupRepository for PostgresGroupRepository {
|
||||
async fn list(&self) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups ORDER BY name"
|
||||
))
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: GroupId) -> Result<Option<Group>, GroupRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups WHERE id = $1"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<Group>, GroupRepositoryError> {
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups WHERE slug = $1"
|
||||
))
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn list_children(&self, parent: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"SELECT {GROUP_COLS} FROM groups WHERE parent_id = $1 ORDER BY name"
|
||||
))
|
||||
.bind(parent.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn ancestors(&self, id: GroupId) -> Result<Vec<Group>, GroupRepositoryError> {
|
||||
// Recursive walk node → root, nearest-first. Depth-bounded as a
|
||||
// runaway guard (the cycle guard already prevents cycles).
|
||||
let rows = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"WITH RECURSIVE chain AS (
|
||||
SELECT {GROUP_COLS}, 0 AS depth FROM groups WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT g.id, g.parent_id, g.slug, g.name, g.description, \
|
||||
g.structure_version, g.created_at, g.updated_at, c.depth + 1 \
|
||||
FROM groups g JOIN chain c ON g.id = c.parent_id \
|
||||
WHERE c.depth < 64
|
||||
)
|
||||
SELECT {GROUP_COLS} FROM chain ORDER BY depth"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn child_counts(&self, id: GroupId) -> Result<GroupChildCounts, GroupRepositoryError> {
|
||||
let row: (i64, i64) = sqlx::query_as(
|
||||
"SELECT \
|
||||
(SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
|
||||
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(GroupChildCounts {
|
||||
subgroups: row.0,
|
||||
apps: row.1,
|
||||
})
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
&self,
|
||||
id: GroupId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
// Slug is intentionally absent from the SET list — it is frozen.
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
.bind(description.is_some())
|
||||
.bind(description.and_then(|d| d))
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or(GroupRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
async fn reparent(
|
||||
&self,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Coarse structural lock: serialize all structural mutations so the
|
||||
// cycle guard + parent write can't interleave with a concurrent
|
||||
// reparent and race into a cycle.
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
// Cycle guard: walk from the destination up to the root; if we
|
||||
// reach `id`, the move would place `id` beneath itself.
|
||||
let mut cursor = Some(parent);
|
||||
let mut hops = 0u32;
|
||||
while let Some(node) = cursor {
|
||||
if node == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"cannot reparent a group beneath one of its own descendants".into(),
|
||||
));
|
||||
}
|
||||
hops += 1;
|
||||
if hops > 64 {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"group ancestry exceeds the maximum depth".into(),
|
||||
));
|
||||
}
|
||||
let parent_of: Option<(Option<Uuid>,)> =
|
||||
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
|
||||
.bind(node.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
match parent_of {
|
||||
Some((p,)) => cursor = p.map(GroupId::from),
|
||||
// Destination parent doesn't exist.
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
parent_id = $2, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(new_parent.map(GroupId::into_inner))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(GroupRepositoryError::NotFound(id));
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
|
||||
// Pre-check for a clean message; the FK RESTRICT is the real guard.
|
||||
let counts = self.child_counts(id).await?;
|
||||
if !counts.is_empty() {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.subgroups, counts.apps
|
||||
)));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// Lost a race with a concurrent child insert.
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct GroupRow {
|
||||
id: Uuid,
|
||||
parent_id: Option<Uuid>,
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
structure_version: i64,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<GroupRow> for Group {
|
||||
fn from(r: GroupRow) -> Self {
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
parent_id: r.parent_id.map(Into::into),
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
structure_version: r.structure_version,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
577
crates/manager-core/src/groups_api.rs
Normal file
577
crates/manager-core/src/groups_api.rs
Normal file
@@ -0,0 +1,577 @@
|
||||
//! `/api/v1/admin/groups/*` — CRUD over the group tree + per-group
|
||||
//! membership (Phase 2, blueprint §5).
|
||||
//!
|
||||
//! Group capabilities resolve by walking the group's ancestor chain
|
||||
//! (`authz::effective_group_role`): a `group_admin` on any ancestor is
|
||||
//! implicitly admin of every descendant group. Structural mutations
|
||||
//! (reparent/delete) are gated on `GroupAdmin`; reparent additionally
|
||||
//! requires admin at BOTH the source and destination parent (§5.6).
|
||||
//!
|
||||
//! Slug is frozen at creation — PATCH edits name/description only.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, patch, post};
|
||||
use axum::{Extension, Router};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AdminUserId, App, AppRole, Group, InstanceRole, Principal};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::admin_user_repo::{AdminUserRepository, AdminUserRow};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
};
|
||||
use crate::group_repo::{GroupRepository, GroupRepositoryError};
|
||||
|
||||
const SLUG_MAX: usize = 63;
|
||||
const RESERVED_SLUGS: &[&str] = &[
|
||||
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps", "groups",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupsState {
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub group_members: Arc<dyn GroupMembersRepository>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub users: Arc<dyn AdminUserRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn groups_router(state: GroupsState) -> Router {
|
||||
Router::new()
|
||||
.route("/groups", get(list_groups).post(create_group))
|
||||
.route(
|
||||
"/groups/{id_or_slug}",
|
||||
get(get_group).patch(patch_group).delete(delete_group),
|
||||
)
|
||||
.route("/groups/{id_or_slug}/reparent", post(reparent_group))
|
||||
.route(
|
||||
"/groups/{id_or_slug}/members",
|
||||
get(list_members).post(grant_member),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/members/{user_id}",
|
||||
patch(patch_member).delete(remove_member),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GroupDetailDto {
|
||||
#[serde(flatten)]
|
||||
pub group: Group,
|
||||
/// Root → … → this group (nearest-last), for breadcrumb display.
|
||||
pub path: Vec<Group>,
|
||||
pub subgroups: Vec<Group>,
|
||||
pub apps: Vec<App>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGroupRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Parent group (slug or id). Omitted ⇒ a root-level group
|
||||
/// (owner/admin only).
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchGroupRequest {
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ReparentRequest {
|
||||
/// New parent (slug or id). Omitted/null ⇒ move to root.
|
||||
#[serde(default)]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct GroupMemberDto {
|
||||
pub user_id: AdminUserId,
|
||||
pub username: String,
|
||||
pub email: Option<String>,
|
||||
pub instance_role: InstanceRole,
|
||||
pub is_active: bool,
|
||||
pub role: AppRole,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl From<GroupMembershipDetail> for GroupMemberDto {
|
||||
fn from(d: GroupMembershipDetail) -> Self {
|
||||
Self {
|
||||
user_id: d.user_id,
|
||||
username: d.username,
|
||||
email: d.email,
|
||||
instance_role: d.instance_role,
|
||||
is_active: d.is_active,
|
||||
role: d.role,
|
||||
created_at: d.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GrantMemberRequest {
|
||||
pub user_id: AdminUserId,
|
||||
pub role: AppRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchMemberRequest {
|
||||
pub role: AppRole,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group CRUD handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// List the whole tree. Phase-2 simplification: group names/structure are
|
||||
/// low-sensitivity org metadata (groups own no resources/secrets yet), so
|
||||
/// any authenticated admin sees the full tree; per-action authz still
|
||||
/// gates every mutation and all app access. Tighten in Phase 3 when groups
|
||||
/// carry inheritable config.
|
||||
async fn list_groups(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(_principal): Extension<Principal>,
|
||||
) -> Result<Json<Vec<Group>>, GroupsApiError> {
|
||||
Ok(Json(s.groups.list().await?))
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(input): Json<CreateGroupRequest>,
|
||||
) -> Result<(StatusCode, Json<Group>), GroupsApiError> {
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
let parent_id = match input.parent.as_deref() {
|
||||
// Root-level group — an instance act.
|
||||
None => {
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::InstanceCreateGroup,
|
||||
)
|
||||
.await?;
|
||||
None
|
||||
}
|
||||
// Subgroup — requires group-admin at the parent.
|
||||
Some(ident) => {
|
||||
let parent = resolve_group(&*s.groups, ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(parent.id),
|
||||
)
|
||||
.await?;
|
||||
Some(parent.id)
|
||||
}
|
||||
};
|
||||
|
||||
let created = s
|
||||
.groups
|
||||
.create(
|
||||
&input.slug,
|
||||
&input.name,
|
||||
input.description.as_deref(),
|
||||
parent_id,
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn get_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<GroupDetailDto>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupRead(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ancestors() is nearest-first incl. the node; reverse for a
|
||||
// root→…→node breadcrumb.
|
||||
let mut path = s.groups.ancestors(group.id).await?;
|
||||
path.reverse();
|
||||
let subgroups = s.groups.list_children(group.id).await?;
|
||||
let apps = s.apps.list_for_group(group.id).await?;
|
||||
Ok(Json(GroupDetailDto {
|
||||
group,
|
||||
path,
|
||||
subgroups,
|
||||
apps,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn patch_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<PatchGroupRequest>,
|
||||
) -> Result<Json<Group>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupWrite(group.id),
|
||||
)
|
||||
.await?;
|
||||
let updated = s
|
||||
.groups
|
||||
.rename(
|
||||
group.id,
|
||||
input.name.as_deref(),
|
||||
input.description.as_ref().map(|d| Some(d.as_str())),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(updated))
|
||||
}
|
||||
|
||||
async fn reparent_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<ReparentRequest>,
|
||||
) -> Result<Json<Group>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
// Admin of the node being moved.
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
// Admin at the SOURCE parent (you're removing it from that domain).
|
||||
if let Some(src) = group.parent_id {
|
||||
require(s.authz.as_ref(), &principal, Capability::GroupAdmin(src)).await?;
|
||||
}
|
||||
// Resolve + require admin at the DESTINATION parent.
|
||||
let new_parent = match input.parent.as_deref() {
|
||||
None => None,
|
||||
Some(ident) => {
|
||||
let dest = resolve_group(&*s.groups, ident).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(dest.id),
|
||||
)
|
||||
.await?;
|
||||
Some(dest.id)
|
||||
}
|
||||
};
|
||||
let moved = s.groups.reparent(group.id, new_parent).await?;
|
||||
Ok(Json(moved))
|
||||
}
|
||||
|
||||
async fn delete_group(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<StatusCode, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
s.groups.delete(group.id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Member handlers — gated on GroupAdmin(group)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_members(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<GroupMemberDto>>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
let rows = s.group_members.list_for_group_enriched(group.id).await?;
|
||||
Ok(Json(rows.into_iter().map(Into::into).collect()))
|
||||
}
|
||||
|
||||
async fn grant_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<GrantMemberRequest>,
|
||||
) -> Result<(StatusCode, Json<GroupMemberDto>), GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user = s
|
||||
.users
|
||||
.get(input.user_id)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::UserNotFound(input.user_id))?;
|
||||
validate_grant_target(&user)?;
|
||||
|
||||
let row = s
|
||||
.group_members
|
||||
.try_insert(group.id, user.id, input.role)
|
||||
.await?
|
||||
.ok_or_else(|| GroupsApiError::AlreadyMember {
|
||||
username: user.username.clone(),
|
||||
})?;
|
||||
Ok((StatusCode::CREATED, Json(compose_dto(user, row))))
|
||||
}
|
||||
|
||||
async fn patch_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||
Json(input): Json<PatchMemberRequest>,
|
||||
) -> Result<Json<GroupMemberDto>, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let user_id = AdminUserId::from(user_id);
|
||||
let user = s
|
||||
.users
|
||||
.get(user_id)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::UserNotFound(user_id))?;
|
||||
let row = s
|
||||
.group_members
|
||||
.update_role(group.id, user_id, input.role)
|
||||
.await?
|
||||
.ok_or(GroupsApiError::MembershipNotFound)?;
|
||||
Ok(Json(compose_dto(user, row)))
|
||||
}
|
||||
|
||||
async fn remove_member(
|
||||
State(s): State<GroupsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, user_id)): Path<(String, Uuid)>,
|
||||
) -> Result<StatusCode, GroupsApiError> {
|
||||
let group = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupAdmin(group.id),
|
||||
)
|
||||
.await?;
|
||||
s.group_members
|
||||
.remove(group.id, AdminUserId::from(user_id))
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Validation + helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a group identifier (slug or UUID) to a `Group`.
|
||||
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<Group, GroupsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
groups.get_by_id(uuid.into()).await?
|
||||
} else {
|
||||
groups.get_by_slug(ident).await?
|
||||
};
|
||||
found.ok_or_else(|| GroupsApiError::GroupNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
/// Same rule as app slugs: `^[a-z0-9][a-z0-9-]{0,62}$`, no reserved words.
|
||||
fn validate_slug(slug: &str) -> Result<(), GroupsApiError> {
|
||||
let invalid = |reason: &str| GroupsApiError::InvalidSlug(format!("{slug:?}: {reason}"));
|
||||
if slug.is_empty() || slug.len() > SLUG_MAX {
|
||||
return Err(invalid("must be 1–63 characters"));
|
||||
}
|
||||
let mut chars = slug.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(invalid("must start with a lowercase letter or digit"));
|
||||
}
|
||||
if !slug
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(invalid(
|
||||
"may contain only lowercase letters, digits, and hyphens",
|
||||
));
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&slug) {
|
||||
return Err(invalid("is a reserved word"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_grant_target(user: &AdminUserRow) -> Result<(), GroupsApiError> {
|
||||
if !user.is_active {
|
||||
return Err(GroupsApiError::TargetInactive {
|
||||
username: user.username.clone(),
|
||||
});
|
||||
}
|
||||
if user.instance_role != InstanceRole::Member {
|
||||
return Err(GroupsApiError::TargetNotMember {
|
||||
username: user.username.clone(),
|
||||
instance_role: user.instance_role,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compose_dto(user: AdminUserRow, membership: GroupMembershipRow) -> GroupMemberDto {
|
||||
GroupMemberDto {
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
instance_role: user.instance_role,
|
||||
is_active: user.is_active,
|
||||
role: membership.role,
|
||||
created_at: membership.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupsApiError {
|
||||
#[error("group not found: {0}")]
|
||||
GroupNotFound(String),
|
||||
#[error("user not found: {0}")]
|
||||
UserNotFound(AdminUserId),
|
||||
#[error("{username} is already a member of this group")]
|
||||
AlreadyMember { username: String },
|
||||
#[error("membership not found")]
|
||||
MembershipNotFound,
|
||||
#[error("{username} is deactivated")]
|
||||
TargetInactive { username: String },
|
||||
#[error("{username} has instance role {instance_role:?}; only members get explicit grants")]
|
||||
TargetNotMember {
|
||||
username: String,
|
||||
instance_role: InstanceRole,
|
||||
},
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("group repository error: {0}")]
|
||||
Repo(#[from] GroupRepositoryError),
|
||||
#[error("member repository error: {0}")]
|
||||
MembersRepo(#[from] GroupMembersRepositoryError),
|
||||
#[error("user repository error: {0}")]
|
||||
UsersRepo(#[from] crate::admin_user_repo::AdminUserRepositoryError),
|
||||
#[error("app repository error: {0}")]
|
||||
AppsRepo(#[from] crate::repo::ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for GroupsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for GroupsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for GroupsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::GroupNotFound(_)
|
||||
| Self::UserNotFound(_)
|
||||
| Self::MembershipNotFound
|
||||
| Self::Repo(GroupRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::InvalidSlug(_) | Self::TargetInactive { .. } | Self::TargetNotMember { .. } => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::AlreadyMember { .. } | Self::Conflict(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Repo(GroupRepositoryError::Conflict(msg)) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": msg }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "groups authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Repo(GroupRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "groups api db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::MembersRepo(e) => {
|
||||
tracing::error!(error = %e, "group members repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::UsersRepo(e) => {
|
||||
tracing::error!(error = %e, "groups api user repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::AppsRepo(e) => {
|
||||
tracing::error!(error = %e, "groups api app repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,11 @@ impl InvokeServiceImpl {
|
||||
if script.app_id != cx.app_id {
|
||||
return Err(InvokeError::CrossApp);
|
||||
}
|
||||
if !script.enabled {
|
||||
// §4.3: a disabled script is not invocable via any path. Surface as
|
||||
// NotFound (indistinguishable from absent), like the data plane.
|
||||
return Err(InvokeError::NotFound(format!("id {script_id}")));
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
@@ -103,6 +108,9 @@ impl InvokeServiceImpl {
|
||||
.await
|
||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
|
||||
if !script.enabled {
|
||||
return Err(InvokeError::NotFound(format!("name {name:?}")));
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
@@ -313,6 +321,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
memory_limit_mb: 64,
|
||||
sandbox: ScriptSandbox::default(),
|
||||
enabled: true,
|
||||
created_at: Utc::now(),
|
||||
updated_at: Utc::now(),
|
||||
}
|
||||
|
||||
@@ -23,12 +23,16 @@ pub mod app_user_repo;
|
||||
pub mod app_user_role_repo;
|
||||
pub mod app_user_session_repo;
|
||||
pub mod app_user_verification_repo;
|
||||
pub mod apply_api;
|
||||
pub mod apply_service;
|
||||
pub mod apps_api;
|
||||
pub mod auth;
|
||||
pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
pub mod auth_middleware;
|
||||
pub mod authz;
|
||||
pub mod config_api;
|
||||
pub mod config_resolver;
|
||||
pub mod cron_scheduler;
|
||||
pub mod dead_letter_repo;
|
||||
pub mod dead_letter_service;
|
||||
@@ -45,6 +49,9 @@ pub mod files_repo;
|
||||
pub mod files_service;
|
||||
pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_repo;
|
||||
pub mod groups_api;
|
||||
pub mod http_service;
|
||||
pub mod invoke_service;
|
||||
pub mod kv_api;
|
||||
@@ -79,6 +86,9 @@ pub mod trigger_repo;
|
||||
pub mod triggers_api;
|
||||
pub mod users_admin_api;
|
||||
pub mod users_service;
|
||||
pub mod vars_api;
|
||||
pub mod vars_repo;
|
||||
pub mod vars_service;
|
||||
|
||||
pub use abandoned_repo::{
|
||||
AbandonedRepo, AbandonedRepoError, NewAbandonedExecution, PostgresAbandonedRepo,
|
||||
@@ -128,6 +138,8 @@ pub use app_user_session_repo::{
|
||||
pub use app_user_verification_repo::{
|
||||
AppUserVerificationRepo, AppUserVerificationRepoError, PostgresAppUserVerificationRepo,
|
||||
};
|
||||
pub use apply_api::apply_router;
|
||||
pub use apply_service::{ApplyError, ApplyService, Bundle, Plan};
|
||||
pub use apps_api::{apps_router, AppsState};
|
||||
pub use auth_api::auth_router;
|
||||
pub use auth_bootstrap::{
|
||||
@@ -139,6 +151,7 @@ pub use auth_middleware::{
|
||||
API_KEY_PREFIX, API_KEY_PREFIX_LEN,
|
||||
};
|
||||
pub use authz::{can, require, AuthzDenied, AuthzError, AuthzRepo, Capability, Decision};
|
||||
pub use config_api::{config_router, ConfigApiError, ConfigApiState};
|
||||
pub use cron_scheduler::spawn_cron_scheduler;
|
||||
pub use dead_letter_repo::{
|
||||
DeadLetterRepo, DeadLetterRepoError, DeadLetterRow, NewDeadLetter, PostgresDeadLetterRepo,
|
||||
@@ -161,6 +174,15 @@ pub use files_repo::{FilesConfig, FilesRepo, FilesRepoError, FsFilesRepo};
|
||||
pub use files_service::FilesServiceImpl;
|
||||
pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepStats};
|
||||
pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc};
|
||||
pub use group_members_repo::{
|
||||
GroupMembersRepository, GroupMembersRepositoryError, GroupMembershipDetail, GroupMembershipRow,
|
||||
PostgresGroupMembersRepository,
|
||||
};
|
||||
pub use group_repo::{
|
||||
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||
ROOT_GROUP_SLUG,
|
||||
};
|
||||
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
|
||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||
pub use kv_api::{kv_admin_router, KvAdminState};
|
||||
pub use kv_repo::{KvRepo, KvRepoError, PostgresKvRepo};
|
||||
@@ -184,11 +206,11 @@ pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
|
||||
pub use sandbox::{CeilingError, SandboxCeiling};
|
||||
pub use secrets_api::{secrets_router, SecretsApiError, SecretsState};
|
||||
pub use secrets_repo::{
|
||||
PostgresSecretsRepo, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||
PostgresSecretsRepo, ResolvedSecret, SecretMeta, SecretsMetaPage, SecretsNamePage, SecretsRepo,
|
||||
SecretsRepoError, StoredSecret,
|
||||
};
|
||||
pub use secrets_service::{
|
||||
open as open_secret, seal as seal_secret, SecretsConfig, SecretsServiceImpl,
|
||||
open as open_secret, seal as seal_secret, SecretOwner, SecretsConfig, SecretsServiceImpl,
|
||||
DEFAULT_SECRET_MAX_VALUE_BYTES,
|
||||
};
|
||||
pub use topic_repo::{PostgresTopicRepo, Topic, TopicAuthMode, TopicRepo, TopicRepoError};
|
||||
@@ -203,3 +225,6 @@ pub use trigger_repo::{
|
||||
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
|
||||
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
|
||||
pub use users_service::{UsersServiceConfig, UsersServiceImpl};
|
||||
pub use vars_api::{vars_router, VarsApiError, VarsApiState};
|
||||
pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError};
|
||||
pub use vars_service::VarsServiceImpl;
|
||||
|
||||
@@ -154,6 +154,8 @@ pub struct NewScript {
|
||||
/// Sandbox overrides; `None` means store an empty object (use
|
||||
/// platform defaults at exec time).
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
/// v1.1.3: literal-path `import "<name>"` declarations extracted
|
||||
/// from the source. The repo writes these into `script_imports`
|
||||
/// transactionally with the script row. Empty when validation
|
||||
@@ -176,6 +178,8 @@ pub struct ScriptPatch {
|
||||
/// rejects unsafe transitions (e.g. endpoint→module when routes
|
||||
/// or triggers reference the script).
|
||||
pub kind: Option<ScriptKind>,
|
||||
/// `Some(v)` toggles the three-state lifecycle flag; `None` leaves it.
|
||||
pub enabled: Option<bool>,
|
||||
/// v1.1.3: when `source` is also `Some`, the repo replaces the
|
||||
/// `script_imports` edges for this script with these names.
|
||||
/// `None` keeps the existing edges untouched (a name/description
|
||||
@@ -203,7 +207,8 @@ impl PostgresScriptRepository {
|
||||
/// adding `kind` (v1.1.3) and future columns can't accidentally skip
|
||||
/// one query.
|
||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, name, description, version, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at";
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled, \
|
||||
created_at, updated_at";
|
||||
|
||||
#[async_trait]
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
@@ -273,42 +278,8 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
.bind(input.kind.as_str())
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
|
||||
let script: Script = match res {
|
||||
Ok(row) => row.into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Dep-graph: write any literal-path imports declared in the
|
||||
// source. Unresolved names (the referenced module doesn't
|
||||
// exist yet) are silently skipped — best-effort.
|
||||
replace_imports_tx(&mut tx, script.id, script.app_id, &input.imports).await?;
|
||||
let script = insert_script_tx(&mut tx, &input).await?;
|
||||
tx.commit().await?;
|
||||
Ok(script)
|
||||
}
|
||||
@@ -318,62 +289,8 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
id: ScriptId,
|
||||
patch: ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
// COALESCE-based partial update: `NULL` parameters leave columns
|
||||
// untouched. Description is double-Optioned so callers can
|
||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||
// Sandbox is replaced wholesale when present; per-field merging
|
||||
// happens in the API layer (clearer semantics for a "PUT a new
|
||||
// sandbox config" call). app_id is immutable — moving a script
|
||||
// to another app is a copy-and-delete, not an in-place edit.
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
source = COALESCE($5, source), \
|
||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(patch.name.as_deref())
|
||||
.bind(patch.description.is_some())
|
||||
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
|
||||
.bind(patch.source.as_deref())
|
||||
.bind(patch.timeout_seconds)
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.fetch_optional(&mut *tx)
|
||||
.await;
|
||||
|
||||
let script: Script = match res {
|
||||
Ok(Some(row)) => row.into(),
|
||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
// Replace imports only when the caller has a fresh list (i.e.
|
||||
// the source actually changed and the validator re-extracted
|
||||
// imports). A name-only or description-only edit leaves the
|
||||
// dep graph alone.
|
||||
if let Some(imports) = patch.imports.as_deref() {
|
||||
replace_imports_tx(&mut tx, script.id, script.app_id, imports).await?;
|
||||
}
|
||||
let script = update_script_tx(&mut tx, id, &patch).await?;
|
||||
tx.commit().await?;
|
||||
Ok(script)
|
||||
}
|
||||
@@ -469,6 +386,117 @@ async fn replace_imports_tx(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a script within an existing transaction — the declarative
|
||||
/// `apply` engine composes scripts + routes + triggers into one tx.
|
||||
/// Mirrors `create` minus the `begin`/`commit`.
|
||||
pub(crate) async fn insert_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
input: &NewScript,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, COALESCE($6, 30), COALESCE($7, 256), $8, $9) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
.bind(input.kind.as_str())
|
||||
.bind(input.timeout_seconds)
|
||||
.bind(input.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(input.enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
Ok(row) => row.into(),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
/// Update a script within an existing transaction. Mirrors `update`
|
||||
/// minus the `begin`/`commit`.
|
||||
pub(crate) async fn update_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: ScriptId,
|
||||
patch: &ScriptPatch,
|
||||
) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
source = COALESCE($5, source), \
|
||||
timeout_seconds = COALESCE($6, timeout_seconds), \
|
||||
memory_limit_mb = COALESCE($7, memory_limit_mb), \
|
||||
sandbox = COALESCE($8, sandbox), \
|
||||
kind = COALESCE($9, kind), \
|
||||
enabled = COALESCE($10, enabled), \
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(patch.name.as_deref())
|
||||
.bind(patch.description.is_some())
|
||||
.bind(patch.description.as_ref().and_then(|d| d.as_deref()))
|
||||
.bind(patch.source.as_deref())
|
||||
.bind(patch.timeout_seconds)
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.bind(patch.kind.map(ScriptKind::as_str))
|
||||
.bind(patch.enabled)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await;
|
||||
let script: Script = match res {
|
||||
Ok(Some(row)) => row.into(),
|
||||
Ok(None) => return Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if let Some(imports) = patch.imports.as_deref() {
|
||||
replace_imports_tx(tx, script.id, script.app_id, imports).await?;
|
||||
}
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
/// Delete a script within an existing transaction (its routes/triggers
|
||||
/// cascade via their FKs). Mirrors `delete` minus the pool.
|
||||
pub(crate) async fn delete_script_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: ScriptId,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM scripts WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(ScriptRepositoryError::NotFound(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Row shape mirroring the `scripts` table for sqlx FromRow.
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
@@ -485,6 +513,7 @@ struct ScriptRow {
|
||||
timeout_seconds: i32,
|
||||
memory_limit_mb: i32,
|
||||
sandbox: serde_json::Value,
|
||||
enabled: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
@@ -511,6 +540,7 @@ impl From<ScriptRow> for Script {
|
||||
timeout_seconds: u32::try_from(r.timeout_seconds).unwrap_or(30),
|
||||
memory_limit_mb: u32::try_from(r.memory_limit_mb).unwrap_or(256),
|
||||
sandbox,
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
|
||||
@@ -229,6 +229,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
path: normalized_path,
|
||||
method: input.method,
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
// Routes are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
@@ -394,6 +396,9 @@ async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
#[must_use]
|
||||
pub fn compile_routes(rows: &[Route]) -> Vec<CompiledRoute> {
|
||||
rows.iter()
|
||||
// A disabled route (§4.3) is dropped from the match table entirely, so
|
||||
// a request to it 404s indistinguishably from an absent route.
|
||||
.filter(|r| r.enabled)
|
||||
.filter_map(|r| match compile_route(r) {
|
||||
Ok(compiled) => Some(compiled),
|
||||
Err(e) => {
|
||||
@@ -426,7 +431,7 @@ fn compile_route(r: &Route) -> Result<CompiledRoute, pattern::ParseError> {
|
||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||
/// always permitted — it catches every host the app already owns.
|
||||
async fn validate_route_host_against_app(
|
||||
pub(crate) async fn validate_route_host_against_app(
|
||||
domains: &dyn AppDomainRepository,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
@@ -625,6 +630,7 @@ mod tests {
|
||||
path: path.to_string(),
|
||||
method: None,
|
||||
dispatch_mode: DispatchMode::default(),
|
||||
enabled: true,
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
@@ -651,4 +657,16 @@ mod tests {
|
||||
"a reserved-path route must be skipped, never abort the compile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_route_is_dropped_from_compiled_table() {
|
||||
// §4.3: a disabled route is excluded from the match table, so a
|
||||
// request to it 404s indistinguishably from an absent route.
|
||||
let active = route_with_path("/on");
|
||||
let mut disabled = route_with_path("/off");
|
||||
disabled.enabled = false;
|
||||
let compiled = compile_routes(&[active.clone(), disabled.clone()]);
|
||||
let ids: Vec<Uuid> = compiled.iter().map(|c| c.route_id).collect();
|
||||
assert_eq!(ids, vec![active.id], "only the enabled route compiles");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub struct NewRoute {
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -63,7 +65,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
@@ -74,7 +76,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn get(&self, route_id: Uuid) -> Result<Option<Route>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE id = $1",
|
||||
)
|
||||
.bind(route_id)
|
||||
@@ -86,7 +88,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
@@ -101,7 +103,7 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(script_id.into_inner())
|
||||
@@ -111,36 +113,10 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
.bind(input.host_param_name.as_deref())
|
||||
.bind(path_kind_str(input.path_kind))
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(ScriptRepositoryError::NotFound(input.script_id))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
let route = insert_route_tx(&mut tx, &input).await?;
|
||||
tx.commit().await?;
|
||||
Ok(route)
|
||||
}
|
||||
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||
@@ -189,6 +165,63 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a route within an existing transaction (declarative apply
|
||||
/// composes scripts + routes + triggers into one tx). Mirrors `create`
|
||||
/// minus the `begin`/`commit`.
|
||||
pub(crate) async fn insert_route_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
input: &NewRoute,
|
||||
) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
.bind(input.host_param_name.as_deref())
|
||||
.bind(path_kind_str(input.path_kind))
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.bind(input.enabled)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(ScriptRepositoryError::NotFound(input.script_id))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a route by id within an existing transaction.
|
||||
///
|
||||
/// Unlike the non-tx [`RouteRepository::delete`], this is intentionally
|
||||
/// idempotent: a missing row is not an error. The only caller is the
|
||||
/// reconcile engine (`ApplyService`), where "delete a route already gone"
|
||||
/// (e.g. removed out-of-band between the diff read and the write) is a
|
||||
/// no-op to converge on, not a failure to roll back the whole apply.
|
||||
pub(crate) async fn delete_route_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
route_id: Uuid,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
sqlx::query("DELETE FROM routes WHERE id = $1")
|
||||
.bind(route_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
@@ -201,6 +234,7 @@ struct RouteRow {
|
||||
path: String,
|
||||
method: Option<String>,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -225,6 +259,7 @@ impl From<RouteRow> for Route {
|
||||
path: r.path,
|
||||
method: r.method,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
//! `/api/v1/admin/apps/{id}/secrets*` — secrets admin endpoints
|
||||
//! (v1.1.7).
|
||||
//! `/api/v1/admin/{apps,groups}/{id}/secrets*` — secrets admin endpoints.
|
||||
//!
|
||||
//! * `GET /apps/{id}/secrets` — list names + updated_at
|
||||
//! * `GET /apps/{id}/secrets` — list app secret names + updated_at
|
||||
//! (NEVER values).
|
||||
//! * `POST /apps/{id}/secrets` — set/overwrite a secret.
|
||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete a secret.
|
||||
//! * `POST /apps/{id}/secrets` — set/overwrite an app secret.
|
||||
//! * `DELETE /apps/{id}/secrets/{name}` — delete an app secret.
|
||||
//! * `GET /groups/{id}/secrets` — list group secret names + scope
|
||||
//! + updated_at (NEVER values).
|
||||
//! * `POST /groups/{id}/secrets` — set/overwrite a group secret
|
||||
//! (env-scoped).
|
||||
//! * `DELETE /groups/{id}/secrets/{name}` — delete a group secret.
|
||||
//! * `GET /groups/{id}/secrets/{name}/value` — **human value read** —
|
||||
//! the ONE endpoint that returns plaintext, gated at the owning group.
|
||||
//!
|
||||
//! Set/delete are gated by `AppSecretsWrite` (→ `script:write`); list by
|
||||
//! `AppSecretsRead` (→ `script:read`). The list surface deliberately
|
||||
//! returns only names + timestamps — the dashboard never receives
|
||||
//! plaintext. Values are encrypted with the process master key before
|
||||
//! they touch the database (same envelope as the script `secrets::set`).
|
||||
//! App set/delete are gated by `AppSecretsWrite`, list by `AppSecretsRead`.
|
||||
//! Group set/list/delete are gated by `GroupSecretsWrite` (editor+); the
|
||||
//! value-read is gated by `GroupSecretsRead` (group_admin only) — that is
|
||||
//! the masked-secret boundary: a descendant app's dev can see that a group
|
||||
//! secret EXISTS (and consume it at runtime via `secrets::get`) but only a
|
||||
//! principal with read rights AT THE OWNING GROUP can read its value. The
|
||||
//! owner is resolved FIRST (slug-or-uuid), THEN `authz::require` binds the
|
||||
//! capability to the resolved owner id — never to a caller-controlled path
|
||||
//! param. Values are encrypted with the process master key before they
|
||||
//! touch the database (owner-bound AAD; see `secrets_service`).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -19,19 +30,24 @@ use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{validate_secret_name, AppId, MasterKey, Principal, SecretsError};
|
||||
use picloud_shared::{validate_secret_name, AppId, GroupId, MasterKey, Principal, SecretsError};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError};
|
||||
use crate::secrets_service::seal;
|
||||
use crate::group_repo::GroupRepository;
|
||||
use crate::secrets_repo::{SecretOwner, SecretsRepo, SecretsRepoError};
|
||||
use crate::secrets_service::{open, seal};
|
||||
|
||||
/// App secrets are env-agnostic; only group secrets carry a concrete scope.
|
||||
const APP_SECRET_SCOPE: &str = "*";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SecretsState {
|
||||
pub repo: Arc<dyn SecretsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub master_key: MasterKey,
|
||||
pub max_value_bytes: usize,
|
||||
@@ -39,10 +55,25 @@ pub struct SecretsState {
|
||||
|
||||
pub fn secrets_router(state: SecretsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{app_id}/secrets", get(list_secrets).post(set_secret))
|
||||
.route(
|
||||
"/apps/{app_id}/secrets",
|
||||
get(list_app_secrets).post(set_app_secret),
|
||||
)
|
||||
.route(
|
||||
"/apps/{app_id}/secrets/{name}",
|
||||
axum::routing::delete(delete_secret),
|
||||
axum::routing::delete(delete_app_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets",
|
||||
get(list_group_secrets).post(set_group_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets/{name}",
|
||||
axum::routing::delete(delete_group_secret),
|
||||
)
|
||||
.route(
|
||||
"/groups/{group_id}/secrets/{name}/value",
|
||||
get(read_group_secret_value),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -55,9 +86,18 @@ pub struct ListQuery {
|
||||
pub limit: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnvQuery {
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct SecretItem {
|
||||
name: String,
|
||||
/// Environment scope — `*` for app secrets, possibly a concrete env for
|
||||
/// group secrets.
|
||||
env: String,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
@@ -67,7 +107,23 @@ struct ListSecretsResponse {
|
||||
next_cursor: Option<String>,
|
||||
}
|
||||
|
||||
async fn list_secrets(
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetSecretRequest {
|
||||
pub name: String,
|
||||
/// Any JSON value — the dashboard sends a single-line string, but
|
||||
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
||||
pub value: serde_json::Value,
|
||||
/// Environment scope (group secrets only). `*` (env-agnostic, default)
|
||||
/// or a concrete env matched against `apps.environment` at resolution.
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App handlers (env-agnostic, scope `*`)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_app_secrets(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
@@ -80,32 +136,10 @@ async fn list_secrets(
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
let page = s
|
||||
.repo
|
||||
.list_meta(app_id, q.cursor.as_deref(), q.limit.unwrap_or(0))
|
||||
.await?;
|
||||
Ok(Json(ListSecretsResponse {
|
||||
secrets: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|m| SecretItem {
|
||||
name: m.name,
|
||||
updated_at: m.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
list_meta(&*s.repo, SecretOwner::App(app_id), &q).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetSecretRequest {
|
||||
pub name: String,
|
||||
/// Any JSON value — the dashboard sends a single-line string, but
|
||||
/// maps/arrays/numbers round-trip too (matching `secrets::set`).
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
async fn set_secret(
|
||||
async fn set_app_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
@@ -118,23 +152,17 @@ async fn set_secret(
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
validate_secret_name(&input.name)?;
|
||||
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to
|
||||
// (app_id, name). Same path as the SDK secrets::set.
|
||||
let (ciphertext, nonce, version) = seal(
|
||||
&s.master_key,
|
||||
app_id,
|
||||
&input.name,
|
||||
&input.value,
|
||||
s.max_value_bytes,
|
||||
)?;
|
||||
s.repo
|
||||
.set(app_id, &input.name, &ciphertext, &nonce, version)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
// App secrets are always env-agnostic; reject a stray `env`.
|
||||
if input.env.as_deref().is_some_and(|e| e != APP_SECRET_SCOPE) {
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"app secrets are env-agnostic; set an environment scope on a group secret instead"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
seal_and_store(&s, SecretOwner::App(app_id), APP_SECRET_SCOPE, input).await
|
||||
}
|
||||
|
||||
async fn delete_secret(
|
||||
async fn delete_app_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, name)): Path<(String, String)>,
|
||||
@@ -146,12 +174,161 @@ async fn delete_secret(
|
||||
Capability::AppSecretsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
if !s.repo.delete(app_id, &name).await? {
|
||||
delete(&*s.repo, SecretOwner::App(app_id), APP_SECRET_SCOPE, &name).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group handlers (env-scoped)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_group_secrets(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupSecretsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
list_meta(&*s.repo, SecretOwner::Group(group_id), &q).await
|
||||
}
|
||||
|
||||
async fn set_group_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SetSecretRequest>,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupSecretsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
let env = input.env.clone().unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||
validate_env_scope(&env)?;
|
||||
seal_and_store(&s, SecretOwner::Group(group_id), &env, input).await
|
||||
}
|
||||
|
||||
async fn delete_group_secret(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, name)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupSecretsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||
validate_env_scope(&env)?;
|
||||
delete(&*s.repo, SecretOwner::Group(group_id), &env, &name).await
|
||||
}
|
||||
|
||||
/// The ONE plaintext-returning endpoint. Gated by `GroupSecretsRead`
|
||||
/// (group_admin) — the masked-secret boundary. A descendant app's dev can
|
||||
/// see the secret exists and consume it at runtime, but only a reader at the
|
||||
/// owning group gets the value here.
|
||||
async fn read_group_secret_value(
|
||||
State(s): State<SecretsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, name)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<Json<serde_json::Value>, SecretsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupSecretsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
validate_secret_name(&name)?;
|
||||
let env = q.env.unwrap_or_else(|| APP_SECRET_SCOPE.into());
|
||||
validate_env_scope(&env)?;
|
||||
let owner = SecretOwner::Group(group_id);
|
||||
let stored = s
|
||||
.repo
|
||||
.get(owner, &env, &name)
|
||||
.await?
|
||||
.ok_or(SecretsApiError::NotFound)?;
|
||||
let value = open(&s.master_key, owner, &name, &stored).map_err(|e| {
|
||||
tracing::error!(group_id = %group_id, secret = %name, "group secret could not be decrypted");
|
||||
SecretsApiError::from(e)
|
||||
})?;
|
||||
Ok(Json(json!({ "name": name, "env": env, "value": value })))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Shared owner-generic bodies
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_meta(
|
||||
repo: &dyn SecretsRepo,
|
||||
owner: SecretOwner,
|
||||
q: &ListQuery,
|
||||
) -> Result<Json<ListSecretsResponse>, SecretsApiError> {
|
||||
let page = repo
|
||||
.list_meta(owner, q.cursor.as_deref(), q.limit.unwrap_or(0))
|
||||
.await?;
|
||||
Ok(Json(ListSecretsResponse {
|
||||
secrets: page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|m| SecretItem {
|
||||
name: m.name,
|
||||
env: m.environment_scope,
|
||||
updated_at: m.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: page.next_cursor,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn seal_and_store(
|
||||
s: &SecretsState,
|
||||
owner: SecretOwner,
|
||||
env: &str,
|
||||
input: SetSecretRequest,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
validate_secret_name(&input.name)?;
|
||||
// Audit 2026-06-11 H-D1 — v1 envelope with AAD bound to the owner+name.
|
||||
let (ciphertext, nonce, version) = seal(
|
||||
&s.master_key,
|
||||
owner,
|
||||
&input.name,
|
||||
&input.value,
|
||||
s.max_value_bytes,
|
||||
)?;
|
||||
s.repo
|
||||
.set(owner, env, &input.name, &ciphertext, &nonce, version)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
repo: &dyn SecretsRepo,
|
||||
owner: SecretOwner,
|
||||
env: &str,
|
||||
name: &str,
|
||||
) -> Result<StatusCode, SecretsApiError> {
|
||||
if !repo.delete(owner, env, name).await? {
|
||||
return Err(SecretsApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Resolution + validation
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, SecretsApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
@@ -160,10 +337,58 @@ async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, Sec
|
||||
.ok_or(SecretsApiError::AppNotFound)
|
||||
}
|
||||
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: &str,
|
||||
) -> Result<GroupId, SecretsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| SecretsApiError::Backend(e.to_string()))?
|
||||
};
|
||||
found.map(|g| g.id).ok_or(SecretsApiError::GroupNotFound)
|
||||
}
|
||||
|
||||
/// Env scope is `*` (env-agnostic) or a kebab env name. Mirrors the vars
|
||||
/// admin validator so a secret and a var share the same env vocabulary.
|
||||
fn validate_env_scope(env: &str) -> Result<(), SecretsApiError> {
|
||||
if env == "*" {
|
||||
return Ok(());
|
||||
}
|
||||
if env.is_empty() || env.len() > 63 {
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"env must be '*' or 1–63 characters".into(),
|
||||
));
|
||||
}
|
||||
let first = env.chars().next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"env must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
if !env
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(SecretsApiError::Invalid(
|
||||
"env may contain only lowercase letters, digits, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecretsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("secret not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
@@ -214,7 +439,7 @@ impl From<SecretsError> for SecretsApiError {
|
||||
impl IntoResponse for SecretsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::NotFound => {
|
||||
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
|
||||
@@ -2,13 +2,23 @@
|
||||
//! opaque ciphertext + nonce blobs in and out. Encryption, JSON
|
||||
//! encoding, authorization, name validation, and the value-size cap all
|
||||
//! live one layer up in `SecretsServiceImpl` / `secrets_api`.
|
||||
//!
|
||||
//! Phase 3 made secrets polymorphic-owner + env-scoped (migration
|
||||
//! `0049_group_secrets.sql`): a secret is owned by exactly one app OR one
|
||||
//! ancestor group, and a descendant app resolves the nearest one,
|
||||
//! environment-filtered (mirroring `vars` / `config_resolver`). Writes are
|
||||
//! owner-keyed via [`SecretOwner`]; the SDK read path goes through
|
||||
//! [`SecretsRepo::resolve`], which walks the app→group→root chain.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::AppId;
|
||||
use picloud_shared::{AppId, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::config_resolver::CHAIN_LEVELS_CTE;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SecretsRepoError {
|
||||
@@ -19,14 +29,22 @@ pub enum SecretsRepoError {
|
||||
InvalidCursor,
|
||||
}
|
||||
|
||||
/// Who owns a secret (Phase 3). A secret is owned by exactly one app OR
|
||||
/// one group; the owner is bound into the AES-GCM AAD (see
|
||||
/// `secrets_service::secret_aad`) so a cross-owner ciphertext swap fails
|
||||
/// decryption, and it selects the partial-unique conflict target on write.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecretOwner {
|
||||
App(AppId),
|
||||
Group(GroupId),
|
||||
}
|
||||
|
||||
/// An encrypted secret as it lives on disk: ciphertext (auth tag
|
||||
/// appended) plus the nonce it was sealed with.
|
||||
///
|
||||
/// Audit 2026-06-11 H-D1: `version` discriminates the envelope layout.
|
||||
/// `0` = legacy AES-GCM with no AAD (pre-2026-06-11 writes); `1` =
|
||||
/// AES-GCM with AAD bound to `"secret:{app_id}:{name}"`. Migration
|
||||
/// `0042_secrets_envelope_version.sql` adds the column with a default
|
||||
/// of `0`, so existing rows keep working.
|
||||
/// AES-GCM with AAD bound to the owner+name (see `secrets_service`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredSecret {
|
||||
pub encrypted_value: Vec<u8>,
|
||||
@@ -34,11 +52,21 @@ pub struct StoredSecret {
|
||||
pub version: i16,
|
||||
}
|
||||
|
||||
/// The winner of an inherited-secret resolution: the stored ciphertext
|
||||
/// plus the owner it actually came from (app-own or an ancestor group),
|
||||
/// which the caller needs to pick the right AAD when decrypting.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedSecret {
|
||||
pub owner: SecretOwner,
|
||||
pub stored: StoredSecret,
|
||||
}
|
||||
|
||||
/// Admin-surface metadata for one secret. Values are never returned —
|
||||
/// only the name and the last-modified timestamp.
|
||||
/// only the name, its environment scope, and the last-modified timestamp.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SecretMeta {
|
||||
pub name: String,
|
||||
pub environment_scope: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -60,38 +88,62 @@ pub struct SecretsMetaPage {
|
||||
/// substitute an in-memory backing without Postgres.
|
||||
#[async_trait]
|
||||
pub trait SecretsRepo: Send + Sync {
|
||||
async fn get(
|
||||
/// Resolve the effective secret for `app_id` by name: walk the
|
||||
/// app→ancestor-group chain (depth 0 = the app), env-filter to the
|
||||
/// app's environment or `*`, and return the nearest winner (with
|
||||
/// `@E` beating `*` within a level). `None` if no level defines it.
|
||||
/// This is the runtime injection path — isolation is anchored to
|
||||
/// `app_id`, so an app only ever sees its own + its ancestors' secrets.
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<ResolvedSecret>, SecretsRepoError>;
|
||||
|
||||
/// Read one owner's OWN secret at a specific env scope (no inheritance).
|
||||
/// Backs the group-gated human value-read and the apply email path.
|
||||
async fn get(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError>;
|
||||
|
||||
/// Upsert (overwrite if present). `version` is the AES-GCM envelope
|
||||
/// discriminator from [`StoredSecret::version`].
|
||||
/// Upsert (overwrite if present) one `(owner, env_scope, name)` row.
|
||||
/// `version` is the AES-GCM envelope discriminator.
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError>;
|
||||
|
||||
/// Delete; returns whether a row was present.
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError>;
|
||||
/// Delete one `(owner, env_scope, name)` row; returns whether a row
|
||||
/// was present.
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<bool, SecretsRepoError>;
|
||||
|
||||
/// Names only — the SDK `list` surface.
|
||||
/// Distinct names of an owner's OWN secrets (NOT inherited) — the SDK
|
||||
/// `list` surface. Names are de-duplicated across env scopes.
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError>;
|
||||
|
||||
/// Name + updated_at — the admin `GET` surface.
|
||||
/// Name + scope + updated_at of an owner's OWN secrets — the admin
|
||||
/// `GET` surface.
|
||||
async fn list_meta(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError>;
|
||||
@@ -131,21 +183,98 @@ pub(crate) fn decode_cursor(cursor: &str) -> Result<String, SecretsRepoError> {
|
||||
String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)
|
||||
}
|
||||
|
||||
/// `list_meta` orders by `(name, environment_scope)` — a name can now have
|
||||
/// several env-scoped rows (group secrets) — so its keyset cursor must carry
|
||||
/// BOTH columns, else a name whose scopes straddle a page boundary loses its
|
||||
/// tail. Encoded as base64url of `name \x1f scope` (US is not a valid env or
|
||||
/// secret-name char, so it's an unambiguous delimiter).
|
||||
const CURSOR_SEP: char = '\u{1f}';
|
||||
|
||||
fn encode_meta_cursor(name: &str, scope: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(format!("{name}{CURSOR_SEP}{scope}").as_bytes())
|
||||
}
|
||||
|
||||
fn decode_meta_cursor(cursor: &str) -> Result<(String, String), SecretsRepoError> {
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(cursor)
|
||||
.map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||
let s = String::from_utf8(bytes).map_err(|_| SecretsRepoError::InvalidCursor)?;
|
||||
s.split_once(CURSOR_SEP)
|
||||
.map(|(n, sc)| (n.to_string(), sc.to_string()))
|
||||
.ok_or(SecretsRepoError::InvalidCursor)
|
||||
}
|
||||
|
||||
/// `(owner_column, owner_uuid)` for binding an owner into a query.
|
||||
fn owner_bind(owner: SecretOwner) -> (&'static str, Uuid) {
|
||||
match owner {
|
||||
SecretOwner::App(a) => ("app_id", a.into_inner()),
|
||||
SecretOwner::Group(g) => ("group_id", g.into_inner()),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsRepo for PostgresSecretsRepo {
|
||||
async fn get(
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(
|
||||
"SELECT encrypted_value, nonce, version FROM secrets \
|
||||
WHERE app_id = $1 AND name = $2",
|
||||
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
|
||||
// Reuse the shared chain-walk ($1 = app_id), join secrets by name,
|
||||
// env-filter, and take the nearest level — `@E` beating `*` within a
|
||||
// level via the secondary sort key. One row out, or none.
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT CASE WHEN s.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(s.app_id, s.group_id) AS owner_id, \
|
||||
s.encrypted_value, s.nonce, s.version \
|
||||
FROM chain c \
|
||||
JOIN secrets s ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.name = $2 \
|
||||
AND (s.environment_scope = '*' OR s.environment_scope = c.app_env) \
|
||||
ORDER BY c.depth ASC, (s.environment_scope <> '*') DESC \
|
||||
LIMIT 1"
|
||||
);
|
||||
let row: Option<(String, Uuid, Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(
|
||||
row.map(|(owner_kind, owner_id, encrypted_value, nonce, version)| {
|
||||
let owner = if owner_kind == "app" {
|
||||
SecretOwner::App(AppId::from(owner_id))
|
||||
} else {
|
||||
SecretOwner::Group(GroupId::from(owner_id))
|
||||
};
|
||||
ResolvedSecret {
|
||||
owner,
|
||||
stored: StoredSecret {
|
||||
encrypted_value,
|
||||
nonce,
|
||||
version,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
async fn get(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
let (col, id) = owner_bind(owner);
|
||||
let sql = format!(
|
||||
"SELECT encrypted_value, nonce, version FROM secrets \
|
||||
WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
|
||||
);
|
||||
let row: Option<(Vec<u8>, Vec<u8>, i16)> = sqlx::query_as(&sql)
|
||||
.bind(id)
|
||||
.bind(env_scope)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|(encrypted_value, nonce, version)| StoredSecret {
|
||||
encrypted_value,
|
||||
nonce,
|
||||
@@ -155,34 +284,54 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
sqlx::query(
|
||||
"INSERT INTO secrets (app_id, name, encrypted_value, nonce, version) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (app_id, name) DO UPDATE \
|
||||
// Owner-kind-specific SQL: write only the owner's nullable column and
|
||||
// restate the partial-unique predicate as the ON CONFLICT arbiter.
|
||||
let (col, id) = owner_bind(owner);
|
||||
let predicate = match owner {
|
||||
SecretOwner::App(_) => "app_id IS NOT NULL",
|
||||
SecretOwner::Group(_) => "group_id IS NOT NULL",
|
||||
};
|
||||
let sql = format!(
|
||||
"INSERT INTO secrets ({col}, environment_scope, name, encrypted_value, nonce, version) \
|
||||
VALUES ($1, $2, $3, $4, $5, $6) \
|
||||
ON CONFLICT ({col}, environment_scope, name) WHERE {predicate} DO UPDATE \
|
||||
SET encrypted_value = EXCLUDED.encrypted_value, \
|
||||
nonce = EXCLUDED.nonce, \
|
||||
version = EXCLUDED.version, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(encrypted_value)
|
||||
.bind(nonce)
|
||||
.bind(version)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
updated_at = NOW()"
|
||||
);
|
||||
sqlx::query(&sql)
|
||||
.bind(id)
|
||||
.bind(env_scope)
|
||||
.bind(name)
|
||||
.bind(encrypted_value)
|
||||
.bind(nonce)
|
||||
.bind(version)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
||||
let res = sqlx::query("DELETE FROM secrets WHERE app_id = $1 AND name = $2")
|
||||
.bind(app_id.into_inner())
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<bool, SecretsRepoError> {
|
||||
let (col, id) = owner_bind(owner);
|
||||
let sql = format!(
|
||||
"DELETE FROM secrets WHERE {col} = $1 AND environment_scope = $2 AND name = $3"
|
||||
);
|
||||
let res = sqlx::query(&sql)
|
||||
.bind(id)
|
||||
.bind(env_scope)
|
||||
.bind(name)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -191,7 +340,7 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||
@@ -201,16 +350,20 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
None => None,
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
"SELECT name FROM secrets \
|
||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let (col, id) = owner_bind(owner);
|
||||
// DISTINCT collapses a name that exists at several env scopes into
|
||||
// one entry — the SDK `list` is a name catalogue, not per-scope.
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT name FROM secrets \
|
||||
WHERE {col} = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3"
|
||||
);
|
||||
let rows: Vec<(String,)> = sqlx::query_as(&sql)
|
||||
.bind(id)
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut names: Vec<String> = rows.into_iter().map(|(n,)| n).collect();
|
||||
let next_cursor = if names.len() > limit as usize {
|
||||
@@ -224,34 +377,49 @@ impl SecretsRepo for PostgresSecretsRepo {
|
||||
|
||||
async fn list_meta(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||
let limit = clamp_limit(limit);
|
||||
let last_name = match cursor {
|
||||
Some(c) => Some(decode_cursor(c)?),
|
||||
// Composite keyset on (name, environment_scope) — see encode_meta_cursor.
|
||||
let last = match cursor {
|
||||
Some(c) => Some(decode_meta_cursor(c)?),
|
||||
None => None,
|
||||
};
|
||||
let (last_name, last_scope) = match &last {
|
||||
Some((n, sc)) => (Some(n.as_str()), Some(sc.as_str())),
|
||||
None => (None, None),
|
||||
};
|
||||
let take = i64::from(limit) + 1;
|
||||
let rows: Vec<(String, DateTime<Utc>)> = sqlx::query_as(
|
||||
"SELECT name, updated_at FROM secrets \
|
||||
WHERE app_id = $1 AND ($2::text IS NULL OR name > $2) \
|
||||
ORDER BY name ASC LIMIT $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(last_name.as_deref())
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
let (col, id) = owner_bind(owner);
|
||||
let sql = format!(
|
||||
"SELECT name, environment_scope, updated_at FROM secrets \
|
||||
WHERE {col} = $1 \
|
||||
AND ($2::text IS NULL OR (name, environment_scope) > ($2, $3)) \
|
||||
ORDER BY name ASC, environment_scope ASC LIMIT $4"
|
||||
);
|
||||
let rows: Vec<(String, String, DateTime<Utc>)> = sqlx::query_as(&sql)
|
||||
.bind(id)
|
||||
.bind(last_name)
|
||||
.bind(last_scope)
|
||||
.bind(take)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
let mut items: Vec<SecretMeta> = rows
|
||||
.into_iter()
|
||||
.map(|(name, updated_at)| SecretMeta { name, updated_at })
|
||||
.map(|(name, environment_scope, updated_at)| SecretMeta {
|
||||
name,
|
||||
environment_scope,
|
||||
updated_at,
|
||||
})
|
||||
.collect();
|
||||
let next_cursor = if items.len() > limit as usize {
|
||||
items.truncate(limit as usize);
|
||||
items.last().map(|m| encode_cursor(&m.name))
|
||||
items
|
||||
.last()
|
||||
.map(|m| encode_meta_cursor(&m.name, &m.environment_scope))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -22,22 +22,39 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
crypto, validate_secret_name, AppId, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
||||
crypto, validate_secret_name, MasterKey, SdkCallCx, SecretsError, SecretsListPage,
|
||||
SecretsService,
|
||||
};
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::secrets_repo::{SecretsRepo, SecretsRepoError, StoredSecret};
|
||||
|
||||
// `SecretOwner` is defined one layer down in `secrets_repo` (it keys both
|
||||
// the storage CRUD and the AAD). Re-exported here so the historical
|
||||
// `secrets_service::SecretOwner` path stays stable.
|
||||
pub use crate::secrets_repo::SecretOwner;
|
||||
|
||||
/// Current AES-GCM envelope version for the per-app secret store.
|
||||
/// `0` = legacy (no AAD); `1` = AAD-bound. New writes always emit v1.
|
||||
pub const SECRET_ENVELOPE_V1: i16 = 1;
|
||||
|
||||
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a
|
||||
/// cross-row swap (e.g. moving one app's ciphertext under another
|
||||
/// app's `(app_id, name)` slot) fails decryption.
|
||||
fn secret_aad(app_id: AppId, name: &str) -> Vec<u8> {
|
||||
format!("secret:{app_id}:{name}").into_bytes()
|
||||
/// The env scope under which an app's OWN secrets are stored. App secrets
|
||||
/// are env-agnostic — only group secrets carry a concrete environment.
|
||||
const APP_SECRET_SCOPE: &str = "*";
|
||||
|
||||
/// Audit 2026-06-11 H-D1 — AAD bound into the GCM auth tag so a cross-row
|
||||
/// swap (moving one owner's ciphertext under another's slot) fails
|
||||
/// decryption. The **app** form is byte-identical to the pre-Phase-3
|
||||
/// `secret:{app_id}:{name}`, so every existing v1 row keeps decrypting
|
||||
/// unchanged; group secrets use a distinct `secret:group:{group_id}:{name}`
|
||||
/// namespace (the `group:` infix keeps app and group AAD disjoint even if a
|
||||
/// group UUID happened to equal an app UUID).
|
||||
fn secret_aad(owner: SecretOwner, name: &str) -> Vec<u8> {
|
||||
match owner {
|
||||
SecretOwner::App(app_id) => format!("secret:{app_id}:{name}"),
|
||||
SecretOwner::Group(group_id) => format!("secret:group:{group_id}:{name}"),
|
||||
}
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
/// Default per-secret plaintext cap (64 KB). Override with
|
||||
@@ -96,7 +113,7 @@ impl Default for SecretsConfig {
|
||||
/// failure (should not happen for a `serde_json::Value`).
|
||||
pub fn seal(
|
||||
master_key: &MasterKey,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
name: &str,
|
||||
value: &serde_json::Value,
|
||||
max_value_bytes: usize,
|
||||
@@ -109,7 +126,7 @@ pub fn seal(
|
||||
actual: plaintext.len(),
|
||||
});
|
||||
}
|
||||
let aad = secret_aad(app_id, name);
|
||||
let aad = secret_aad(owner, name);
|
||||
let enc = crypto::encrypt_with_aad(&plaintext, &aad, master_key.as_bytes());
|
||||
Ok((enc.ciphertext, enc.nonce, SECRET_ENVELOPE_V1))
|
||||
}
|
||||
@@ -125,7 +142,7 @@ pub fn seal(
|
||||
/// [`SecretsError::Corrupted`] when decryption or JSON decoding fails.
|
||||
pub fn open(
|
||||
master_key: &MasterKey,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
name: &str,
|
||||
stored: &StoredSecret,
|
||||
) -> Result<serde_json::Value, SecretsError> {
|
||||
@@ -136,7 +153,7 @@ pub fn open(
|
||||
master_key.as_bytes(),
|
||||
),
|
||||
SECRET_ENVELOPE_V1 => {
|
||||
let aad = secret_aad(app_id, name);
|
||||
let aad = secret_aad(owner, name);
|
||||
crypto::decrypt_with_aad(
|
||||
&stored.encrypted_value,
|
||||
&stored.nonce,
|
||||
@@ -259,10 +276,15 @@ impl SecretsService for SecretsServiceImpl {
|
||||
) -> Result<Option<serde_json::Value>, SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_read(cx).await?;
|
||||
let Some(stored) = self.repo.get(cx.app_id, name).await? else {
|
||||
// Inherited resolution: the app's own secret, else the nearest
|
||||
// ancestor group's, env-filtered. `resolve` anchors the walk to
|
||||
// `cx.app_id`, so an app can only ever read its own + its ancestors'
|
||||
// secrets — the cross-app isolation boundary. The winning owner comes
|
||||
// back with the row so we decrypt under the AAD it was sealed with.
|
||||
let Some(resolved) = self.repo.resolve(cx.app_id, name).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
match open(&self.master_key, cx.app_id, name, &stored) {
|
||||
match open(&self.master_key, resolved.owner, name, &resolved.stored) {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(e) => {
|
||||
// A decrypt failure is operationally significant — surface
|
||||
@@ -286,15 +308,11 @@ impl SecretsService for SecretsServiceImpl {
|
||||
) -> Result<(), SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_write(cx).await?;
|
||||
let (ciphertext, nonce, version) = seal(
|
||||
&self.master_key,
|
||||
cx.app_id,
|
||||
name,
|
||||
&value,
|
||||
self.max_value_bytes,
|
||||
)?;
|
||||
let owner = SecretOwner::App(cx.app_id);
|
||||
let (ciphertext, nonce, version) =
|
||||
seal(&self.master_key, owner, name, &value, self.max_value_bytes)?;
|
||||
self.repo
|
||||
.set(cx.app_id, name, &ciphertext, &nonce, version)
|
||||
.set(owner, APP_SECRET_SCOPE, name, &ciphertext, &nonce, version)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -302,7 +320,10 @@ impl SecretsService for SecretsServiceImpl {
|
||||
async fn delete(&self, cx: &SdkCallCx, name: &str) -> Result<bool, SecretsError> {
|
||||
validate_secret_name(name)?;
|
||||
self.check_write(cx).await?;
|
||||
Ok(self.repo.delete(cx.app_id, name).await?)
|
||||
Ok(self
|
||||
.repo
|
||||
.delete(SecretOwner::App(cx.app_id), APP_SECRET_SCOPE, name)
|
||||
.await?)
|
||||
}
|
||||
|
||||
async fn list(
|
||||
@@ -312,7 +333,10 @@ impl SecretsService for SecretsServiceImpl {
|
||||
limit: u32,
|
||||
) -> Result<SecretsListPage, SecretsError> {
|
||||
self.check_read(cx).await?;
|
||||
let page = self.repo.list_names(cx.app_id, cursor, limit).await?;
|
||||
let page = self
|
||||
.repo
|
||||
.list_names(SecretOwner::App(cx.app_id), cursor, limit)
|
||||
.await?;
|
||||
Ok(SecretsListPage {
|
||||
names: page.names,
|
||||
next_cursor: page.next_cursor,
|
||||
@@ -328,44 +352,76 @@ impl SecretsService for SecretsServiceImpl {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::authz::{AuthzError, AuthzRepo};
|
||||
use crate::secrets_repo::{SecretsMetaPage, SecretsNamePage};
|
||||
use crate::secrets_repo::{ResolvedSecret, SecretsMetaPage, SecretsNamePage};
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId,
|
||||
UserId,
|
||||
AdminUserId, AppId, AppRole, ExecutionId, GroupId, InstanceRole, Principal, RequestId,
|
||||
ScriptId, UserId,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// In-memory backing keyed by `(owner_key, env_scope, name)`. The owner
|
||||
/// key is `"app:{uuid}"` / `"group:{uuid}"` so app and group rows never
|
||||
/// collide. These unit tests exercise the app surface (scope `*`); the
|
||||
/// chain-walk `resolve` is journey-tested against real Postgres, so here
|
||||
/// it degrades to a plain app-own `*` lookup.
|
||||
#[derive(Default)]
|
||||
struct InMemorySecretsRepo {
|
||||
data: Mutex<BTreeMap<(AppId, String), StoredSecret>>,
|
||||
data: Mutex<BTreeMap<(String, String, String), StoredSecret>>,
|
||||
}
|
||||
|
||||
fn owner_key(owner: SecretOwner) -> String {
|
||||
match owner {
|
||||
SecretOwner::App(a) => format!("app:{a}"),
|
||||
SecretOwner::Group(g) => format!("group:{g}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SecretsRepo for InMemorySecretsRepo {
|
||||
async fn get(
|
||||
async fn resolve(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<ResolvedSecret>, SecretsRepoError> {
|
||||
let owner = SecretOwner::App(app_id);
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(
|
||||
owner_key(owner),
|
||||
APP_SECRET_SCOPE.to_string(),
|
||||
name.to_string(),
|
||||
))
|
||||
.cloned()
|
||||
.map(|stored| ResolvedSecret { owner, stored }))
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<Option<StoredSecret>, SecretsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(app_id, name.to_string()))
|
||||
.get(&(owner_key(owner), env_scope.to_string(), name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
async fn set(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
encrypted_value: &[u8],
|
||||
nonce: &[u8],
|
||||
version: i16,
|
||||
) -> Result<(), SecretsRepoError> {
|
||||
self.data.lock().await.insert(
|
||||
(app_id, name.to_string()),
|
||||
(owner_key(owner), env_scope.to_string(), name.to_string()),
|
||||
StoredSecret {
|
||||
encrypted_value: encrypted_value.to_vec(),
|
||||
nonce: nonce.to_vec(),
|
||||
@@ -374,29 +430,36 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
async fn delete(&self, app_id: AppId, name: &str) -> Result<bool, SecretsRepoError> {
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: SecretOwner,
|
||||
env_scope: &str,
|
||||
name: &str,
|
||||
) -> Result<bool, SecretsRepoError> {
|
||||
Ok(self
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.remove(&(app_id, name.to_string()))
|
||||
.remove(&(owner_key(owner), env_scope.to_string(), name.to_string()))
|
||||
.is_some())
|
||||
}
|
||||
async fn list_names(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
owner: SecretOwner,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<SecretsNamePage, SecretsRepoError> {
|
||||
let data = self.data.lock().await;
|
||||
let ok = owner_key(owner);
|
||||
let last = cursor.map(std::string::ToString::to_string);
|
||||
let mut names: Vec<String> = data
|
||||
.iter()
|
||||
.filter(|((a, _), _)| *a == app_id)
|
||||
.map(|((_, n), _)| n.clone())
|
||||
.filter(|((o, _, _), _)| *o == ok)
|
||||
.map(|((_, _, n), _)| n.clone())
|
||||
.filter(|n| last.as_ref().is_none_or(|l| n > l))
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
let take = (limit as usize).max(1);
|
||||
let next_cursor = if names.len() > take {
|
||||
names.truncate(take);
|
||||
@@ -408,7 +471,7 @@ mod tests {
|
||||
}
|
||||
async fn list_meta(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_owner: SecretOwner,
|
||||
_cursor: Option<&str>,
|
||||
_limit: u32,
|
||||
) -> Result<SecretsMetaPage, SecretsRepoError> {
|
||||
@@ -635,7 +698,11 @@ mod tests {
|
||||
repo.data
|
||||
.lock()
|
||||
.await
|
||||
.get_mut(&(app, "k".to_string()))
|
||||
.get_mut(&(
|
||||
owner_key(SecretOwner::App(app)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
))
|
||||
.unwrap()
|
||||
.encrypted_value[0] ^= 0xff;
|
||||
let err = s.get(&anon_cx(app), "k").await.unwrap_err();
|
||||
@@ -666,10 +733,21 @@ mod tests {
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(a, "k".to_string()))
|
||||
.get(&(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
repo.data.lock().await.insert((b, "k".to_string()), stolen);
|
||||
repo.data.lock().await.insert(
|
||||
(
|
||||
owner_key(SecretOwner::App(b)),
|
||||
"*".to_string(),
|
||||
"k".to_string(),
|
||||
),
|
||||
stolen,
|
||||
);
|
||||
let err = s.get(&anon_cx(b), "k").await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SecretsError::Corrupted),
|
||||
@@ -677,6 +755,55 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aad_distinguishes_app_and_group_owner() {
|
||||
// Phase 3: app and group secrets live in disjoint AAD namespaces
|
||||
// (`secret:{app}` vs `secret:group:{group}`). A ciphertext sealed
|
||||
// for a group must not open as an app secret (and vice-versa),
|
||||
// even if the raw UUIDs were equal — the `group:` infix separates
|
||||
// them. Exercise the free seal/open functions directly.
|
||||
let k = key();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
let value = serde_json::json!("shared-config");
|
||||
|
||||
// Seal under the GROUP owner.
|
||||
let (ct, nonce, version) =
|
||||
seal(&k, SecretOwner::Group(group), "db_url", &value, 4096).unwrap();
|
||||
let stored = StoredSecret {
|
||||
encrypted_value: ct,
|
||||
nonce: nonce.to_vec(),
|
||||
version,
|
||||
};
|
||||
// Opens fine as the same group owner.
|
||||
assert_eq!(
|
||||
open(&k, SecretOwner::Group(group), "db_url", &stored).unwrap(),
|
||||
value
|
||||
);
|
||||
// Fails as an app owner (AAD mismatch) — even reusing the UUID.
|
||||
let app_from_group = AppId::from(group.into_inner());
|
||||
assert!(matches!(
|
||||
open(&k, SecretOwner::App(app_from_group), "db_url", &stored),
|
||||
Err(SecretsError::Corrupted)
|
||||
));
|
||||
// And the symmetric direction: an app-sealed row won't open as a group.
|
||||
let (ct2, nonce2, v2) = seal(&k, SecretOwner::App(app), "db_url", &value, 4096).unwrap();
|
||||
let stored2 = StoredSecret {
|
||||
encrypted_value: ct2,
|
||||
nonce: nonce2.to_vec(),
|
||||
version: v2,
|
||||
};
|
||||
assert!(matches!(
|
||||
open(
|
||||
&k,
|
||||
SecretOwner::Group(GroupId::from(app.into_inner())),
|
||||
"db_url",
|
||||
&stored2
|
||||
),
|
||||
Err(SecretsError::Corrupted)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn aad_blocks_cross_name_ciphertext_swap() {
|
||||
// Same app, different name → AAD mismatch.
|
||||
@@ -695,13 +822,21 @@ mod tests {
|
||||
.data
|
||||
.lock()
|
||||
.await
|
||||
.get(&(a, "real".to_string()))
|
||||
.get(&(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"real".to_string(),
|
||||
))
|
||||
.cloned()
|
||||
.unwrap();
|
||||
repo.data
|
||||
.lock()
|
||||
.await
|
||||
.insert((a, "renamed".to_string()), stolen);
|
||||
repo.data.lock().await.insert(
|
||||
(
|
||||
owner_key(SecretOwner::App(a)),
|
||||
"*".to_string(),
|
||||
"renamed".to_string(),
|
||||
),
|
||||
stolen,
|
||||
);
|
||||
let err = s.get(&anon_cx(a), "renamed").await.unwrap_err();
|
||||
assert!(matches!(err, SecretsError::Corrupted));
|
||||
}
|
||||
@@ -726,7 +861,11 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
repo.data.lock().await.insert(
|
||||
(app, "old".to_string()),
|
||||
(
|
||||
owner_key(SecretOwner::App(app)),
|
||||
"*".to_string(),
|
||||
"old".to_string(),
|
||||
),
|
||||
StoredSecret {
|
||||
encrypted_value: ct,
|
||||
nonce: nonce.to_vec(),
|
||||
|
||||
@@ -383,6 +383,7 @@ mod tests {
|
||||
slug: self.slug.clone(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
@@ -396,6 +397,12 @@ mod tests {
|
||||
async fn list_for_user(&self, _: AdminUserId) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
if id != self.id {
|
||||
return Ok(None);
|
||||
@@ -436,6 +443,7 @@ mod tests {
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -444,6 +452,7 @@ mod tests {
|
||||
_: &str,
|
||||
_: &str,
|
||||
_: Option<&str>,
|
||||
_: picloud_shared::GroupId,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct Trigger {
|
||||
pub id: TriggerId,
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
/// §4.5 per-app trigger identifier; the manifest merge/upsert key.
|
||||
pub name: String,
|
||||
pub kind: TriggerKind,
|
||||
pub enabled: bool,
|
||||
pub dispatch_mode: TriggerDispatchMode,
|
||||
@@ -502,6 +504,220 @@ impl PostgresTriggerRepo {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a trigger (parent row + per-kind detail) within an existing
|
||||
/// transaction — used by the declarative `apply` engine. Supports the
|
||||
/// five settled kinds; `email`/`queue`/`dead_letter` have their own
|
||||
/// create paths and are rejected here.
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub(crate) async fn insert_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
registered_by: AdminUserId,
|
||||
dispatch_mode: TriggerDispatchMode,
|
||||
retry_max_attempts: u32,
|
||||
retry_backoff: BackoffShape,
|
||||
retry_base_ms: u32,
|
||||
details: &TriggerDetails,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let kind = match details {
|
||||
TriggerDetails::Kv { .. } => "kv",
|
||||
TriggerDetails::Docs { .. } => "docs",
|
||||
TriggerDetails::Files { .. } => "files",
|
||||
TriggerDetails::Cron { .. } => "cron",
|
||||
TriggerDetails::Pubsub { .. } => "pubsub",
|
||||
TriggerDetails::Queue { .. } => "queue",
|
||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
||||
return Err(TriggerRepoError::Invalid(
|
||||
"trigger kind not supported by declarative apply".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Queue: enforce the one-consumer-per-(app_id, queue_name) invariant —
|
||||
// the same advisory-lock + existence guard the interactive
|
||||
// `create_queue_trigger` uses. Without this, a concurrent apply +
|
||||
// interactive create on disjoint locks could double-register a queue
|
||||
// consumer (there is no DB unique constraint backing the invariant).
|
||||
if let TriggerDetails::Queue { queue_name, .. } = details {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(advisory_lock_key(app_id, queue_name))
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
let existing: Option<(Uuid,)> = sqlx::query_as(
|
||||
"SELECT t.id FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.app_id = $1 AND t.kind = 'queue' AND d.queue_name = $2",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(queue_name)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
if existing.is_some() {
|
||||
return Err(TriggerRepoError::Invalid(format!(
|
||||
"queue '{queue_name}' already has a consumer trigger; remove the existing one first"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(kind)
|
||||
.bind(dispatch_mode.as_str())
|
||||
.bind(i32::try_from(retry_max_attempts).unwrap_or(3))
|
||||
.bind(retry_backoff.as_str())
|
||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||
.bind(registered_by.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let tid = row.0;
|
||||
|
||||
match details {
|
||||
TriggerDetails::Kv {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO kv_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Docs {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO docs_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Files {
|
||||
collection_glob,
|
||||
ops,
|
||||
} => {
|
||||
let ops_str: Vec<String> = ops.iter().map(|o| o.as_str().to_string()).collect();
|
||||
sqlx::query(
|
||||
"INSERT INTO files_trigger_details (trigger_id, collection_glob, ops) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(collection_glob)
|
||||
.bind(&ops_str)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Cron {
|
||||
schedule, timezone, ..
|
||||
} => {
|
||||
sqlx::query(
|
||||
"INSERT INTO cron_trigger_details (trigger_id, schedule, timezone) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(schedule)
|
||||
.bind(timezone)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Pubsub { topic_pattern } => {
|
||||
sqlx::query(
|
||||
"INSERT INTO pubsub_trigger_details (trigger_id, topic_pattern) VALUES ($1, $2)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(topic_pattern)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::Queue {
|
||||
queue_name,
|
||||
visibility_timeout_secs,
|
||||
..
|
||||
} => {
|
||||
sqlx::query(
|
||||
"INSERT INTO queue_trigger_details \
|
||||
(trigger_id, queue_name, visibility_timeout_secs) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(tid)
|
||||
.bind(queue_name)
|
||||
.bind(i32::try_from(*visibility_timeout_secs).unwrap_or(30))
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
TriggerDetails::DeadLetter { .. } | TriggerDetails::Email { .. } => {
|
||||
unreachable!("guarded above")
|
||||
}
|
||||
}
|
||||
Ok(tid.into())
|
||||
}
|
||||
|
||||
/// Insert an email trigger within a transaction. The inbound HMAC secret
|
||||
/// is sealed by the apply engine (resolved from the app's secret store);
|
||||
/// this writes the ciphertext. Parent retry settings match the
|
||||
/// interactive `create_email_trigger` path (async, 3, exponential, 1000).
|
||||
pub(crate) async fn insert_email_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
registered_by: AdminUserId,
|
||||
inbound_secret_encrypted: &[u8],
|
||||
inbound_secret_nonce: &[u8],
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(registered_by.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO email_trigger_details \
|
||||
(trigger_id, inbound_secret_encrypted, inbound_secret_nonce) \
|
||||
VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(row.0)
|
||||
.bind(inbound_secret_encrypted)
|
||||
.bind(inbound_secret_nonce)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(row.0.into())
|
||||
}
|
||||
|
||||
/// Delete a trigger by id within an existing transaction (its detail row
|
||||
/// cascades via the FK). Used by `apply --prune`.
|
||||
pub(crate) async fn delete_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: TriggerId,
|
||||
) -> Result<(), TriggerRepoError> {
|
||||
sqlx::query("DELETE FROM triggers WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TriggerRepo for PostgresTriggerRepo {
|
||||
async fn create_kv_trigger(
|
||||
@@ -521,7 +737,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'kv', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -552,6 +768,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Kv,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -586,7 +803,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'docs', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -617,6 +834,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Docs,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -649,7 +867,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'dead_letter', TRUE, 'async', 1, 'constant', 0, $3) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -677,6 +895,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::DeadLetter,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -713,7 +932,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'cron', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -743,6 +962,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Cron,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -778,7 +998,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'files', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -809,6 +1029,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Files,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -842,7 +1063,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'pubsub', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -870,6 +1091,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Pubsub,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -902,7 +1124,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -929,6 +1151,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -971,7 +1194,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Trigger>, TriggerRepoError> {
|
||||
let parents: Vec<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE app_id = $1 ORDER BY created_at DESC",
|
||||
@@ -999,7 +1222,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
|
||||
async fn get(&self, id: TriggerId) -> Result<Option<Trigger>, TriggerRepoError> {
|
||||
let parent: Option<TriggerRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
"SELECT id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at \
|
||||
FROM triggers WHERE id = $1",
|
||||
@@ -1249,7 +1472,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'queue', TRUE, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
RETURNING id, app_id, script_id, name, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal, created_at, updated_at",
|
||||
)
|
||||
@@ -1280,6 +1503,7 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name.clone(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1308,7 +1532,8 @@ impl TriggerRepo for PostgresTriggerRepo {
|
||||
t.registered_by_principal \
|
||||
FROM triggers t \
|
||||
JOIN queue_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.kind = 'queue' AND t.enabled = TRUE",
|
||||
JOIN scripts s ON s.id = t.script_id \
|
||||
WHERE t.kind = 'queue' AND t.enabled = TRUE AND s.enabled = TRUE",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
@@ -1486,6 +1711,7 @@ async fn hydrate_one(pool: &PgPool, parent: TriggerRow) -> Result<Trigger, Trigg
|
||||
id: parent.id.into(),
|
||||
app_id: parent.app_id.into(),
|
||||
script_id: parent.script_id.into(),
|
||||
name: parent.name,
|
||||
kind,
|
||||
enabled: parent.enabled,
|
||||
dispatch_mode: dispatch_from_str(&parent.dispatch_mode),
|
||||
@@ -1528,6 +1754,7 @@ struct TriggerRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
name: String,
|
||||
kind: String,
|
||||
enabled: bool,
|
||||
dispatch_mode: String,
|
||||
|
||||
@@ -38,7 +38,7 @@ use crate::trigger_repo::{
|
||||
/// in practice (the dispatcher itself ticks every 100ms; reclaim ticks
|
||||
/// every 30s; an executor needs more wall-clock than this to do useful
|
||||
/// work). Reject below this with a 422 + actionable error.
|
||||
const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
|
||||
pub(crate) const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
|
||||
|
||||
/// Default soft-warning ceiling when `PICLOUD_DISPATCHER_ASYNC_EXEC_TIMEOUT_SEC`
|
||||
/// is unset. Re-exported from the dispatcher so the single source of
|
||||
@@ -892,6 +892,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Kv,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -920,6 +921,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Docs,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -948,6 +950,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::DeadLetter,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
@@ -977,6 +980,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Email,
|
||||
enabled: true,
|
||||
dispatch_mode: TriggerDispatchMode::Async,
|
||||
@@ -1021,6 +1025,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Cron,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1050,6 +1055,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Files,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1078,6 +1084,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: crate::trigger_repo::TriggerKind::Pubsub,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1154,6 +1161,7 @@ mod tests {
|
||||
id,
|
||||
app_id,
|
||||
script_id: req.script_id,
|
||||
name: "mock".into(),
|
||||
kind: TriggerKind::Queue,
|
||||
enabled: true,
|
||||
dispatch_mode: req.dispatch_mode,
|
||||
@@ -1201,6 +1209,7 @@ mod tests {
|
||||
slug: "test".into(),
|
||||
name: "test".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1218,6 +1227,7 @@ mod tests {
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1226,6 +1236,7 @@ mod tests {
|
||||
_slug: &str,
|
||||
_name: &str,
|
||||
_description: Option<&str>,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<App, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
@@ -1244,6 +1255,12 @@ mod tests {
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<App>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_by_id(
|
||||
&self,
|
||||
id: AppId,
|
||||
@@ -1347,6 +1364,7 @@ mod tests {
|
||||
timeout_seconds: 30,
|
||||
sandbox: picloud_shared::ScriptSandbox::default(),
|
||||
memory_limit_mb: 256,
|
||||
enabled: true,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1716,6 +1734,7 @@ mod tests {
|
||||
slug: "a".into(),
|
||||
name: "a".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
@@ -1727,6 +1746,7 @@ mod tests {
|
||||
slug: "b".into(),
|
||||
name: "b".into(),
|
||||
description: None,
|
||||
group_id: picloud_shared::GroupId::new(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
|
||||
410
crates/manager-core/src/vars_api.rs
Normal file
410
crates/manager-core/src/vars_api.rs
Normal file
@@ -0,0 +1,410 @@
|
||||
//! `/api/v1/admin/{apps,groups}/{id_or_slug}/vars*` — the Phase-3 config
|
||||
//! `vars` admin surface (write/list side; resolution lives in
|
||||
//! `config_resolver` + the `vars::` SDK).
|
||||
//!
|
||||
//! * `GET /apps/{id}/vars` — list the app's OWN vars.
|
||||
//! * `PUT /apps/{id}/vars` — set/overwrite one app var.
|
||||
//! * `DELETE /apps/{id}/vars/{key}` — delete one app var.
|
||||
//! * `GET/PUT/DELETE /groups/{id}/vars[...]` — same, group-owned.
|
||||
//!
|
||||
//! App routes gate on `App{Vars}Read/Write`; group routes on
|
||||
//! `Group{Vars}Read/Write`. The owner is resolved FIRST (slug-or-uuid),
|
||||
//! THEN `authz::require` binds the capability to the resolved owner id —
|
||||
//! never to a caller-controlled path param. Listing returns the owner's
|
||||
//! OWN rows only (not the resolved/inherited view).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, put};
|
||||
use axum::{Extension, Router};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
use crate::vars_repo::{VarOwner, VarsRepo, VarsRepoError};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct VarsApiState {
|
||||
pub vars: Arc<dyn VarsRepo>,
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
pub fn vars_router(state: VarsApiState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/apps/{id_or_slug}/vars",
|
||||
get(list_app_vars).put(set_app_var),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id_or_slug}/vars/{key}",
|
||||
axum::routing::delete(delete_app_var),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/vars",
|
||||
put(set_group_var).get(list_group_vars),
|
||||
)
|
||||
.route(
|
||||
"/groups/{id_or_slug}/vars/{key}",
|
||||
axum::routing::delete(delete_group_var),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SetVarRequest {
|
||||
pub key: String,
|
||||
pub value: serde_json::Value,
|
||||
/// Environment scope — `*` (env-agnostic, default) or a concrete env
|
||||
/// name matched against `apps.environment` at resolution time.
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
/// Write a tombstone (suppresses an inherited key) instead of a real
|
||||
/// value. The body's `value` is ignored for a tombstone.
|
||||
#[serde(default)]
|
||||
pub tombstone: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EnvQuery {
|
||||
#[serde(default)]
|
||||
pub env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct VarItem {
|
||||
key: String,
|
||||
env: String,
|
||||
value: serde_json::Value,
|
||||
is_tombstone: bool,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
struct ListVarsResponse {
|
||||
vars: Vec<VarItem>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_app_vars(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
list(&*s.vars, VarOwner::App(app_id)).await
|
||||
}
|
||||
|
||||
async fn set_app_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SetVarRequest>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
set(&*s.vars, VarOwner::App(app_id), input).await
|
||||
}
|
||||
|
||||
async fn delete_app_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, key)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppVarsWrite(app_id),
|
||||
)
|
||||
.await?;
|
||||
delete(&*s.vars, VarOwner::App(app_id), &key, q.env.as_deref()).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_group_vars(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
list(&*s.vars, VarOwner::Group(group_id)).await
|
||||
}
|
||||
|
||||
async fn set_group_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<SetVarRequest>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
set(&*s.vars, VarOwner::Group(group_id), input).await
|
||||
}
|
||||
|
||||
async fn delete_group_var(
|
||||
State(s): State<VarsApiState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path((id_or_slug, key)): Path<(String, String)>,
|
||||
Query(q): Query<EnvQuery>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupVarsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
delete(&*s.vars, VarOwner::Group(group_id), &key, q.env.as_deref()).await
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Shared owner-generic bodies
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
) -> Result<Json<ListVarsResponse>, VarsApiError> {
|
||||
let rows = vars.list_for_owner(owner).await?;
|
||||
Ok(Json(ListVarsResponse {
|
||||
vars: rows
|
||||
.into_iter()
|
||||
.map(|r| VarItem {
|
||||
key: r.key,
|
||||
env: r.environment_scope,
|
||||
value: r.value,
|
||||
is_tombstone: r.is_tombstone,
|
||||
updated_at: r.updated_at,
|
||||
})
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn set(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
input: SetVarRequest,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
validate_key(&input.key)?;
|
||||
let env = input.env.as_deref().unwrap_or("*");
|
||||
validate_env_scope(env)?;
|
||||
// A tombstone carries no meaningful value (the resolver suppresses the
|
||||
// key regardless); store JSON null so the NOT NULL column is satisfied.
|
||||
let value = if input.tombstone {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
input.value
|
||||
};
|
||||
vars.set(owner, env, &input.key, &value, input.tombstone)
|
||||
.await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
vars: &dyn VarsRepo,
|
||||
owner: VarOwner,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<StatusCode, VarsApiError> {
|
||||
let env = env.unwrap_or("*");
|
||||
validate_env_scope(env)?;
|
||||
if !vars.delete(owner, env, key).await? {
|
||||
return Err(VarsApiError::NotFound);
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Resolution + validation
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, VarsApiError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or(VarsApiError::AppNotFound)
|
||||
}
|
||||
|
||||
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<GroupId, VarsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| VarsApiError::Backend(e.to_string()))?
|
||||
};
|
||||
found.map(|g| g.id).ok_or(VarsApiError::GroupNotFound)
|
||||
}
|
||||
|
||||
/// Keys are kebab identifiers (`^[a-z0-9][a-z0-9-]*$`) — same shape as the
|
||||
/// manifest's var names (docs/design §4.3).
|
||||
fn validate_key(key: &str) -> Result<(), VarsApiError> {
|
||||
if key.is_empty() || key.len() > 128 {
|
||||
return Err(VarsApiError::Invalid("key must be 1–128 characters".into()));
|
||||
}
|
||||
let mut chars = key.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"key must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
if !key
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(VarsApiError::Invalid(
|
||||
"key may contain only lowercase letters, digits, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Env scope is `*` (env-agnostic) or a kebab env name.
|
||||
fn validate_env_scope(env: &str) -> Result<(), VarsApiError> {
|
||||
if env == "*" {
|
||||
return Ok(());
|
||||
}
|
||||
if env.is_empty() || env.len() > 63 {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env must be '*' or 1–63 characters".into(),
|
||||
));
|
||||
}
|
||||
let mut chars = env.chars();
|
||||
let first = chars.next().unwrap();
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env must start with a lowercase letter or digit".into(),
|
||||
));
|
||||
}
|
||||
if !env
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
{
|
||||
return Err(VarsApiError::Invalid(
|
||||
"env may contain only lowercase letters, digits, and hyphens".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VarsApiError {
|
||||
#[error("app not found")]
|
||||
AppNotFound,
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("var not found")]
|
||||
NotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("vars backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for VarsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for VarsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VarsRepoError> for VarsApiError {
|
||||
fn from(e: VarsRepoError) -> Self {
|
||||
match e {
|
||||
VarsRepoError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for VarsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "vars admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "vars admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
210
crates/manager-core/src/vars_repo.rs
Normal file
210
crates/manager-core/src/vars_repo.rs
Normal file
@@ -0,0 +1,210 @@
|
||||
//! Low-level Postgres CRUD over `vars` — the write/admin side of the
|
||||
//! Phase-3 config layer (the read/resolution side lives in
|
||||
//! `config_resolver`). Storage-only: it upserts and lists an owner's OWN
|
||||
//! rows. Authorization, env-scope validation, and value encoding live one
|
||||
//! layer up in `vars_api`.
|
||||
//!
|
||||
//! A var is owned by exactly one group OR one app (the migration's
|
||||
//! `vars_owner_exactly_one` CHECK). Because the owner is split across two
|
||||
//! nullable columns (`group_id`, `app_id`), the upsert writes
|
||||
//! owner-kind-specific SQL with the matching partial-unique conflict
|
||||
//! target.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{AppId, GroupId};
|
||||
use serde_json::Value as JsonValue;
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VarsRepoError {
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] sqlx::Error),
|
||||
}
|
||||
|
||||
/// Which side of the polymorphic owner a var hangs off. The repo chooses
|
||||
/// the conflict target (group vs app partial-unique index) from this.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VarOwner {
|
||||
Group(GroupId),
|
||||
App(AppId),
|
||||
}
|
||||
|
||||
/// One of an owner's OWN var rows (NOT a resolved/inherited value). Backs
|
||||
/// the admin list surface.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VarRow {
|
||||
pub environment_scope: String,
|
||||
pub key: String,
|
||||
pub value: JsonValue,
|
||||
pub is_tombstone: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Repo surface. A trait so service/handler tests can substitute an
|
||||
/// in-memory backing without Postgres.
|
||||
#[async_trait]
|
||||
pub trait VarsRepo: Send + Sync {
|
||||
/// Upsert one (owner, env_scope, key) row.
|
||||
async fn set(
|
||||
&self,
|
||||
owner: VarOwner,
|
||||
env_scope: &str,
|
||||
key: &str,
|
||||
value: &JsonValue,
|
||||
is_tombstone: bool,
|
||||
) -> Result<(), VarsRepoError>;
|
||||
|
||||
/// Delete one (owner, env_scope, key) row; returns whether a row was
|
||||
/// present.
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: VarOwner,
|
||||
env_scope: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, VarsRepoError>;
|
||||
|
||||
/// The owner's OWN rows only (NOT resolved/inherited), ordered by
|
||||
/// (key, environment_scope).
|
||||
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError>;
|
||||
}
|
||||
|
||||
pub struct PostgresVarsRepo {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresVarsRepo {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VarsRepo for PostgresVarsRepo {
|
||||
async fn set(
|
||||
&self,
|
||||
owner: VarOwner,
|
||||
env_scope: &str,
|
||||
key: &str,
|
||||
value: &JsonValue,
|
||||
is_tombstone: bool,
|
||||
) -> Result<(), VarsRepoError> {
|
||||
// Owner-kind-specific SQL: only one of the two nullable owner
|
||||
// columns is written, and the conflict target is the matching
|
||||
// partial-unique index.
|
||||
match owner {
|
||||
VarOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
// The conflict target is a PARTIAL unique index, so the
|
||||
// index predicate (`WHERE group_id IS NOT NULL`) must be
|
||||
// restated for Postgres to infer the arbiter.
|
||||
"INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (group_id, environment_scope, key) \
|
||||
WHERE group_id IS NOT NULL DO UPDATE \
|
||||
SET value = EXCLUDED.value, \
|
||||
is_tombstone = EXCLUDED.is_tombstone, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(env_scope)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.bind(is_tombstone)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
VarOwner::App(a) => {
|
||||
sqlx::query(
|
||||
// Partial-index conflict target — restate the predicate.
|
||||
"INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
ON CONFLICT (app_id, environment_scope, key) \
|
||||
WHERE app_id IS NOT NULL DO UPDATE \
|
||||
SET value = EXCLUDED.value, \
|
||||
is_tombstone = EXCLUDED.is_tombstone, \
|
||||
updated_at = NOW()",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(env_scope)
|
||||
.bind(key)
|
||||
.bind(value)
|
||||
.bind(is_tombstone)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(
|
||||
&self,
|
||||
owner: VarOwner,
|
||||
env_scope: &str,
|
||||
key: &str,
|
||||
) -> Result<bool, VarsRepoError> {
|
||||
let res = match owner {
|
||||
VarOwner::Group(g) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM vars \
|
||||
WHERE group_id = $1 AND environment_scope = $2 AND key = $3",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.bind(env_scope)
|
||||
.bind(key)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
}
|
||||
VarOwner::App(a) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM vars \
|
||||
WHERE app_id = $1 AND environment_scope = $2 AND key = $3",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.bind(env_scope)
|
||||
.bind(key)
|
||||
.execute(&self.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(res.rows_affected() > 0)
|
||||
}
|
||||
|
||||
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError> {
|
||||
let rows: Vec<(String, String, JsonValue, bool, DateTime<Utc>)> = match owner {
|
||||
VarOwner::Group(g) => {
|
||||
sqlx::query_as(
|
||||
"SELECT environment_scope, key, value, is_tombstone, updated_at \
|
||||
FROM vars WHERE group_id = $1 \
|
||||
ORDER BY key ASC, environment_scope ASC",
|
||||
)
|
||||
.bind(g.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
}
|
||||
VarOwner::App(a) => {
|
||||
sqlx::query_as(
|
||||
"SELECT environment_scope, key, value, is_tombstone, updated_at \
|
||||
FROM vars WHERE app_id = $1 \
|
||||
ORDER BY key ASC, environment_scope ASC",
|
||||
)
|
||||
.bind(a.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?
|
||||
}
|
||||
};
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(environment_scope, key, value, is_tombstone, updated_at)| VarRow {
|
||||
environment_scope,
|
||||
key,
|
||||
value,
|
||||
is_tombstone,
|
||||
updated_at,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
63
crates/manager-core/src/vars_service.rs
Normal file
63
crates/manager-core/src/vars_service.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
//! `VarsService` — the runtime read path for group-inherited config.
|
||||
//!
|
||||
//! Resolves the calling app's config (own rows + inherited group rows,
|
||||
//! env-filtered, proximity-first; see `config_resolver`) and exposes it to
|
||||
//! scripts as `vars::get(key)` / `vars::all()`. Read-only from scripts;
|
||||
//! writes go through the admin API. Like every SDK service, it derives the
|
||||
//! app from `cx.app_id` — never a script argument — so cross-app isolation
|
||||
//! holds.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{SdkCallCx, VarsError, VarsService};
|
||||
use serde_json::Value;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::authz::{self, AuthzRepo, Capability};
|
||||
use crate::config_resolver::{fetch_var_candidates, resolve};
|
||||
|
||||
pub struct VarsServiceImpl {
|
||||
pool: PgPool,
|
||||
authz: Arc<dyn AuthzRepo>,
|
||||
}
|
||||
|
||||
impl VarsServiceImpl {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool, authz: Arc<dyn AuthzRepo>) -> Self {
|
||||
Self { pool, authz }
|
||||
}
|
||||
|
||||
/// Authed principals need `AppVarsRead`; anonymous public-HTTP scripts
|
||||
/// (`principal: None`) read freely under script-as-gate semantics.
|
||||
async fn check_read(&self, cx: &SdkCallCx) -> Result<(), VarsError> {
|
||||
if let Some(ref principal) = cx.principal {
|
||||
authz::require(&*self.authz, principal, Capability::AppVarsRead(cx.app_id))
|
||||
.await
|
||||
.map_err(|_| VarsError::Forbidden)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resolved(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||
let candidates = fetch_var_candidates(&self.pool, cx.app_id)
|
||||
.await
|
||||
.map_err(|e| VarsError::Backend(e.to_string()))?;
|
||||
let (values, _provenance) = resolve(candidates);
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl VarsService for VarsServiceImpl {
|
||||
async fn get(&self, cx: &SdkCallCx, key: &str) -> Result<Option<Value>, VarsError> {
|
||||
self.check_read(cx).await?;
|
||||
Ok(self.resolved(cx).await?.remove(key))
|
||||
}
|
||||
|
||||
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||
self.check_read(cx).await?;
|
||||
self.resolved(cx).await
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,11 @@ where
|
||||
.resolve(id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound(id))?;
|
||||
// A disabled script (§4.3) is not invocable — 404, indistinguishable
|
||||
// from an absent one (no info leak that the id exists but is off).
|
||||
if !script.enabled {
|
||||
return Err(ApiError::NotFound(id));
|
||||
}
|
||||
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
|
||||
req.sandbox_overrides = script.sandbox;
|
||||
@@ -164,6 +169,7 @@ where
|
||||
Ok(exec_response_to_http(outcome?))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn user_route_handler<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
Extension(principal): Extension<Option<Principal>>,
|
||||
@@ -221,6 +227,19 @@ where
|
||||
.resolve(matched.matched.script_id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound(matched.matched.script_id))?;
|
||||
// An enabled route bound to a disabled script (§4.3, §4.7) is unreachable.
|
||||
// Return the SAME flat "no route matches" 404 as the unmatched case — not
|
||||
// `NotFound(script_id)`, which would both leak the internal script id to an
|
||||
// anonymous caller and be distinguishable from absent.
|
||||
if !script.enabled {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("no route matches {method} {path}")
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
// Drain the body now that we know we'll execute. 10 MiB cap matches
|
||||
// the conservative default response/request size in the blueprint.
|
||||
|
||||
@@ -12,7 +12,8 @@ use chrono::{DateTime, Utc};
|
||||
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
|
||||
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox,
|
||||
Group, HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -149,6 +150,144 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// --- Groups (Phase 2) -------------------------------------------------
|
||||
|
||||
/// `GET /api/v1/admin/groups` — the full flat list (assemble the tree
|
||||
/// client-side from `parent_id`).
|
||||
pub async fn groups_list(&self) -> Result<Vec<Group>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/groups")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}` — group + path + children.
|
||||
pub async fn groups_get(&self, ident: &str) -> Result<GroupDetailDto> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups`
|
||||
pub async fn groups_create(&self, body: &CreateGroupBody<'_>) -> Result<Group> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/groups")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/groups/{id_or_slug}` — name/description only
|
||||
/// (the slug is frozen).
|
||||
pub async fn groups_rename(
|
||||
&self,
|
||||
ident: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
) -> Result<Group> {
|
||||
let ident = seg(ident);
|
||||
let body = serde_json::json!({ "name": name, "description": description });
|
||||
let resp = self
|
||||
.request(Method::PATCH, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{id_or_slug}/reparent` — `parent` is a
|
||||
/// slug/id, or `None` to move to root.
|
||||
pub async fn groups_reparent(&self, ident: &str, parent: Option<&str>) -> Result<Group> {
|
||||
let ident = seg(ident);
|
||||
let body = serde_json::json!({ "parent": parent });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/groups/{ident}/reparent"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/groups/{id_or_slug}` — 409 if non-empty.
|
||||
pub async fn groups_delete(&self, ident: &str) -> Result<()> {
|
||||
let ident = seg(ident);
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/groups/{ident}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_list(&self, group: &str) -> Result<Vec<AppMemberDto>> {
|
||||
let group = seg(group);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/groups/{group}/members"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_grant(
|
||||
&self,
|
||||
group: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let group = seg(group);
|
||||
let body = serde_json::json!({ "user_id": user_id, "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/groups/{group}/members"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_set_role(
|
||||
&self,
|
||||
group: &str,
|
||||
user_id: &str,
|
||||
role: AppRole,
|
||||
) -> Result<AppMemberDto> {
|
||||
let (group, user_id) = (seg(group), seg(user_id));
|
||||
let body = serde_json::json!({ "role": role });
|
||||
let resp = self
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
pub async fn group_members_remove(&self, group: &str, user_id: &str) -> Result<()> {
|
||||
let (group, user_id) = (seg(group), seg(user_id));
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/groups/{group}/members/{user_id}"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/scripts/{id}` — requires `AppAdmin` on the
|
||||
/// owning app (stricter than the edit endpoints, by design).
|
||||
pub async fn scripts_delete(&self, id: &str) -> Result<()> {
|
||||
@@ -518,39 +657,138 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_list(&self, app: &str) -> Result<SecretListDto> {
|
||||
let app = seg(app);
|
||||
/// `GET /api/v1/admin/{apps,groups}/{id}/secrets` — secret names +
|
||||
/// last-modified for the owner. Values never travel on this path. `env`
|
||||
/// is only meaningful for group owners (app secrets are env-agnostic).
|
||||
pub async fn secrets_list(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
env: Option<&str>,
|
||||
) -> Result<SecretListDto> {
|
||||
let mut path = format!("{}/secrets", owner.base_path());
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/{apps,groups}/{id}/secrets`. `env` rides in the
|
||||
/// body and is only honored by group owners.
|
||||
pub async fn secrets_set(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
name: &str,
|
||||
value: serde_json::Value,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
// `name` travels in the JSON body, not the path.
|
||||
let mut body = serde_json::json!({ "name": name, "value": value });
|
||||
if let Some(env) = env {
|
||||
body["env"] = serde_json::Value::String(env.to_string());
|
||||
}
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.request(Method::POST, &format!("{}/secrets", owner.base_path()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/{apps,groups}/{id}/secrets/{name}`
|
||||
pub async fn secrets_delete(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut path = format!("{}/secrets/{}", owner.base_path(), seg(name));
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id}/secrets/{name}/value` — the ONLY path
|
||||
/// that returns a decrypted secret value. Gated server-side at the owning
|
||||
/// group; there is no app-secret equivalent by design.
|
||||
pub async fn group_secret_read_value(
|
||||
&self,
|
||||
group: &str,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<SecretValueDto> {
|
||||
let (group, name) = (seg(group), seg(name));
|
||||
let mut path = format!("/api/v1/admin/groups/{group}/secrets/{name}/value");
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::GET, &path).send().await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- vars (Phase 3 config) ----------
|
||||
|
||||
/// `GET /api/v1/admin/{apps,groups}/{id}/vars` — the owner's OWN vars
|
||||
/// (not the resolved/inherited view).
|
||||
pub async fn vars_list(&self, owner: VarOwnerArg<'_>) -> Result<VarListDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("{}/vars", owner.base_path()))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id}/secrets`
|
||||
pub async fn secrets_set(&self, app: &str, name: &str, value: serde_json::Value) -> Result<()> {
|
||||
// `name` travels in the JSON body, not the path — only `app` needs encoding.
|
||||
let app = seg(app);
|
||||
/// `PUT /api/v1/admin/{apps,groups}/{id}/vars`
|
||||
pub async fn vars_set(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
env: Option<&str>,
|
||||
tombstone: bool,
|
||||
) -> Result<()> {
|
||||
let mut body = serde_json::json!({ "key": key, "value": value, "tombstone": tombstone });
|
||||
if let Some(env) = env {
|
||||
body["env"] = serde_json::Value::String(env.to_string());
|
||||
}
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/secrets"))
|
||||
.json(&serde_json::json!({ "name": name, "value": value }))
|
||||
.request(Method::PUT, &format!("{}/vars", owner.base_path()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/apps/{id}/secrets/{name}`
|
||||
pub async fn secrets_delete(&self, app: &str, name: &str) -> Result<()> {
|
||||
let (app, name) = (seg(app), seg(name));
|
||||
/// `DELETE /api/v1/admin/{apps,groups}/{id}/vars/{key}`
|
||||
pub async fn vars_delete(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut path = format!("{}/vars/{}", owner.base_path(), seg(key));
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/config/effective` — the app's resolved
|
||||
/// (group-inherited) vars plus masked secret statuses, each annotated with
|
||||
/// the owner that won and the layers it merged from (§4.6).
|
||||
pub async fn config_effective(&self, app: &str) -> Result<EffectiveConfigDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(
|
||||
Method::DELETE,
|
||||
&format!("/api/v1/admin/apps/{app}/secrets/{name}"),
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/config/effective"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
// ---------- domains ----------
|
||||
@@ -900,6 +1138,54 @@ impl Client {
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
|
||||
/// bundle against the app's live state. Read-only.
|
||||
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
|
||||
.json(bundle)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
|
||||
/// app to the bundle in one transaction.
|
||||
pub async fn apply(
|
||||
&self,
|
||||
app: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
// The apply endpoint returns 409 only for a stale bound plan
|
||||
// (`StateMoved`): the app changed since `pic plan` recorded its
|
||||
// token. Surface an actionable next step instead of a bare
|
||||
// `HTTP 409`.
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe app changed since your last `pic plan`. Re-run \
|
||||
`pic plan` to review the new diff, then `pic apply` — or \
|
||||
`pic apply --force` to apply without re-reviewing."
|
||||
));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -924,6 +1210,54 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlanDto {
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangeDto {
|
||||
pub op: String,
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub scripts_created: u32,
|
||||
#[serde(default)]
|
||||
pub scripts_updated: u32,
|
||||
#[serde(default)]
|
||||
pub scripts_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub routes_created: u32,
|
||||
#[serde(default)]
|
||||
pub routes_updated: u32,
|
||||
#[serde(default)]
|
||||
pub routes_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub triggers_created: u32,
|
||||
#[serde(default)]
|
||||
pub triggers_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthMeDto {
|
||||
@@ -953,6 +1287,31 @@ pub struct CreateAppBody<'a> {
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
/// Parent group (slug or id); omit for the instance root.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateGroupBody<'a> {
|
||||
pub slug: &'a str,
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
/// Parent group (slug or id); omit for a root-level group.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// `GET /groups/{id}` response — the group plus its breadcrumb path and
|
||||
/// direct children.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GroupDetailDto {
|
||||
#[serde(flatten)]
|
||||
pub group: Group,
|
||||
pub path: Vec<Group>,
|
||||
pub subgroups: Vec<Group>,
|
||||
pub apps: Vec<App>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -1134,6 +1493,37 @@ pub struct DeadLetterDto {
|
||||
pub resolution: Option<String>,
|
||||
}
|
||||
|
||||
/// Which owner a `pic vars` call targets. Selects the `apps` vs `groups`
|
||||
/// admin path prefix; the identifier travels in the path (encoded).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VarOwnerArg<'a> {
|
||||
App(&'a str),
|
||||
Group(&'a str),
|
||||
}
|
||||
|
||||
impl VarOwnerArg<'_> {
|
||||
fn base_path(&self) -> String {
|
||||
match self {
|
||||
Self::App(ident) => format!("/api/v1/admin/apps/{}", seg(ident)),
|
||||
Self::Group(ident) => format!("/api/v1/admin/groups/{}", seg(ident)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VarListDto {
|
||||
pub vars: Vec<VarItemDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VarItemDto {
|
||||
pub key: String,
|
||||
pub env: String,
|
||||
pub value: serde_json::Value,
|
||||
pub is_tombstone: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretListDto {
|
||||
pub secrets: Vec<SecretItemDto>,
|
||||
@@ -1145,9 +1535,72 @@ pub struct SecretListDto {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretItemDto {
|
||||
pub name: String,
|
||||
/// Env scope. Only meaningful (and populated) for group owners; the server
|
||||
/// omits it for app secrets, so default to `*` when absent.
|
||||
#[serde(default = "default_env")]
|
||||
pub env: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
fn default_env() -> String {
|
||||
"*".to_string()
|
||||
}
|
||||
|
||||
/// Plaintext value of a single group secret — the response of the gated
|
||||
/// `.../secrets/{name}/value` read. The decrypted value is arbitrary JSON.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretValueDto {
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
#[allow(dead_code)]
|
||||
pub env: String,
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
// --- effective config (`/config/effective`) ---
|
||||
|
||||
/// The owner (app or group) a resolved layer belongs to, with its distance
|
||||
/// from the app in the inheritance chain (`depth` 0 = the app itself).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveOwnerDto {
|
||||
pub kind: String,
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub depth: u32,
|
||||
}
|
||||
|
||||
/// One layer a resolved var merged from, deepest-first as the server returns it.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MergedFromDto {
|
||||
pub depth: u32,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveVarDto {
|
||||
pub value: serde_json::Value,
|
||||
pub owner: EffectiveOwnerDto,
|
||||
pub scope: String,
|
||||
#[serde(default)]
|
||||
pub merged_from: Vec<MergedFromDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveSecretDto {
|
||||
#[allow(dead_code)]
|
||||
pub status: String,
|
||||
pub owner: EffectiveOwnerDto,
|
||||
pub scope: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct EffectiveConfigDto {
|
||||
#[serde(default)]
|
||||
pub vars: std::collections::BTreeMap<String, EffectiveVarDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: std::collections::BTreeMap<String, EffectiveSecretDto>,
|
||||
}
|
||||
|
||||
/// Per-script runtime config the CLI can now set (G3). All optional — an
|
||||
/// unset field is omitted so the server applies its own default (and the
|
||||
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
|
||||
|
||||
111
crates/picloud-cli/src/cmds/apply.rs
Normal file
111
crates/picloud-cli/src/cmds/apply.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
|
||||
//! manifest's desired state in one server-side transaction. Creates and
|
||||
//! updates are applied; `--prune` additionally deletes live scripts/routes/
|
||||
//! triggers absent from the manifest (secrets are never pruned).
|
||||
|
||||
use std::io::{IsTerminal, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
env: Option<&str>,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&manifest.app.slug)?;
|
||||
}
|
||||
|
||||
// Bound-plan check: replay the token from the last `pic plan` (for this
|
||||
// app) so the server refuses if the app changed since it was reviewed.
|
||||
// `--force` skips it; no recorded plan means no check (apply still works
|
||||
// standalone). The token is single-use — cleared after a successful apply.
|
||||
let expected_token = if force {
|
||||
None
|
||||
} else {
|
||||
crate::linkstate::read_plan(base_dir)
|
||||
.filter(|l| l.app == manifest.app.slug)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply(
|
||||
&manifest.app.slug,
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.scripts_created, report.scripts_updated, report.scripts_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"routes",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.routes_created, report.routes_updated, report.routes_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"triggers",
|
||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
|
||||
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
||||
/// rather than silently deleting (review the deletions first with `pic plan`).
|
||||
fn confirm_prune(slug: &str) -> Result<()> {
|
||||
if !std::io::stdin().is_terminal() {
|
||||
anyhow::bail!(
|
||||
"refusing to `apply --prune` non-interactively without `--yes`: prune \
|
||||
deletes resources absent from the manifest and cannot be undone. \
|
||||
Review with `pic plan`, then re-run with `--yes`."
|
||||
);
|
||||
}
|
||||
eprint!(
|
||||
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
|
||||
absent from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("read confirmation")?;
|
||||
if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") {
|
||||
anyhow::bail!("aborted");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -31,6 +31,7 @@ pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
group: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
@@ -39,6 +40,7 @@ pub async fn create(
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
group,
|
||||
};
|
||||
let app = client.apps_create(&body).await?;
|
||||
// Emit the created object so `--output json` callers can capture the
|
||||
|
||||
100
crates/picloud-cli/src/cmds/config.rs
Normal file
100
crates/picloud-cli/src/cmds/config.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
//! `pic config --effective` — read-only view of an app's resolved
|
||||
//! configuration, with secret values masked (§4.6).
|
||||
//!
|
||||
//! Two sections:
|
||||
//! * `vars` — the resolved (group-inherited) config vars, each `key = value`
|
||||
//! annotated with the owner that won (kind + depth) and its scope. Pass
|
||||
//! `--explain` to also dump each var's `merged_from` provenance — the
|
||||
//! ordered (depth, scope) layers that fed the resolution.
|
||||
//! * `secrets` — masked statuses, cross-referenced against the manifest so an
|
||||
//! operator can see which declared secrets are still unset and which live
|
||||
//! secrets aren't declared. Values are never fetched or shown here; the
|
||||
//! server reports only `<set>` / `<unset>` plus the owning layer.
|
||||
//!
|
||||
//! The vars + masked-secret owner info comes from the `/config/effective`
|
||||
//! endpoint; the manifest is only used to flag `declared`/`unset` drift.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use crate::client::{Client, EffectiveOwnerDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Render an owner as `kind@depth` (e.g. `group@1`, `app@0`).
|
||||
fn owner_label(owner: &EffectiveOwnerDto) -> String {
|
||||
format!("{}@{}", owner.kind, owner.depth)
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
manifest_path: &Path,
|
||||
effective: bool,
|
||||
explain: bool,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
if !effective {
|
||||
bail!("`pic config` currently supports only `--effective`");
|
||||
}
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Resolve against the same env overlay as `pic plan`/`apply` so the
|
||||
// masked report targets the app those commands would act on — not the
|
||||
// base slug — when an overlay re-points slug/secrets.
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
|
||||
let eff = client.config_effective(&manifest.app.slug).await?;
|
||||
|
||||
// --- vars: the resolved view, with winning owner + provenance. ---
|
||||
let mut vars_table = Table::new(["key", "value", "owner", "scope"]);
|
||||
for (key, var) in &eff.vars {
|
||||
vars_table.row([
|
||||
key.clone(),
|
||||
var.value.to_string(),
|
||||
owner_label(&var.owner),
|
||||
var.scope.clone(),
|
||||
]);
|
||||
}
|
||||
vars_table.print(mode);
|
||||
|
||||
if explain {
|
||||
// Provenance: the (depth, scope) layers each resolved key merged from.
|
||||
let mut prov = Table::new(["key", "depth", "scope"]);
|
||||
for (key, var) in &eff.vars {
|
||||
for layer in &var.merged_from {
|
||||
prov.row([key.clone(), layer.depth.to_string(), layer.scope.clone()]);
|
||||
}
|
||||
}
|
||||
prov.print(mode);
|
||||
}
|
||||
|
||||
// --- secrets: masked status, folding manifest drift + owning layer. ---
|
||||
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
|
||||
let on_server: BTreeSet<String> = eff.secrets.keys().cloned().collect();
|
||||
|
||||
let mut table = Table::new(["secret", "value", "status", "owner", "scope"]);
|
||||
for name in declared.union(&on_server) {
|
||||
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
|
||||
(true, true) => ("<set>", "managed"),
|
||||
(true, false) => ("<unset>", "declared, not pushed — `pic secrets set`"),
|
||||
(false, true) => ("<set>", "on server, not in manifest"),
|
||||
(false, false) => unreachable!("name came from one of the two sets"),
|
||||
};
|
||||
let (owner, scope) = match eff.secrets.get(name) {
|
||||
Some(s) => (owner_label(&s.owner), s.scope.clone()),
|
||||
None => ("-".to_string(), "-".to_string()),
|
||||
};
|
||||
table.row([
|
||||
name.clone(),
|
||||
value.to_string(),
|
||||
status.to_string(),
|
||||
owner,
|
||||
scope,
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
257
crates/picloud-cli/src/cmds/groups.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
//! `pic groups` — manage the org-tree groups (Phase 2).
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/groups*`. Structural mutations are gated
|
||||
//! server-side: create/reparent/delete need group-admin (reparent at both
|
||||
//! source and destination parent); the slug is frozen at creation.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::{AppRole, Group};
|
||||
|
||||
use crate::client::{Client, CreateGroupBody};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let groups = client.groups_list().await?;
|
||||
let mut table = Table::new(["slug", "name", "parent", "created_at"]);
|
||||
let by_id: BTreeMap<_, _> = groups.iter().map(|g| (g.id, g.slug.clone())).collect();
|
||||
for g in &groups {
|
||||
let parent = g
|
||||
.parent_id
|
||||
.and_then(|p| by_id.get(&p).cloned())
|
||||
.unwrap_or_else(|| "-".into());
|
||||
table.row([
|
||||
g.slug.clone(),
|
||||
g.name.clone(),
|
||||
parent,
|
||||
g.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic groups tree` — render the hierarchy as an indented tree (text
|
||||
/// mode); falls back to the flat list for `--output json`.
|
||||
pub async fn tree(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let groups = client.groups_list().await?;
|
||||
if matches!(mode, OutputMode::Json) {
|
||||
// Machine consumers get the flat list; the shape carries parent_id.
|
||||
println!("{}", serde_json::to_string_pretty(&groups)?);
|
||||
return Ok(());
|
||||
}
|
||||
// children-by-parent, then DFS from the roots.
|
||||
let mut children: BTreeMap<Option<_>, Vec<&Group>> = BTreeMap::new();
|
||||
for g in &groups {
|
||||
children.entry(g.parent_id).or_default().push(g);
|
||||
}
|
||||
for kids in children.values_mut() {
|
||||
kids.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
}
|
||||
print_subtree(&children, None, 0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_subtree(
|
||||
children: &BTreeMap<Option<picloud_shared::GroupId>, Vec<&Group>>,
|
||||
parent: Option<picloud_shared::GroupId>,
|
||||
depth: usize,
|
||||
) {
|
||||
let Some(kids) = children.get(&parent) else {
|
||||
return;
|
||||
};
|
||||
for g in kids {
|
||||
println!("{}{} ({})", " ".repeat(depth), g.name, g.slug);
|
||||
print_subtree(children, Some(g.id), depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
slug: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
parent: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let body = CreateGroupBody {
|
||||
slug,
|
||||
name: name.unwrap_or(slug),
|
||||
description,
|
||||
parent,
|
||||
};
|
||||
let group = client.groups_create(&body).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let detail = client.groups_get(ident).await?;
|
||||
let path = detail
|
||||
.path
|
||||
.iter()
|
||||
.map(|g| g.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", detail.group.id.to_string())
|
||||
.field("slug", detail.group.slug.clone())
|
||||
.field("name", detail.group.name.clone())
|
||||
.field("path", if path.is_empty() { "-".into() } else { path })
|
||||
.field(
|
||||
"subgroups",
|
||||
detail
|
||||
.subgroups
|
||||
.iter()
|
||||
.map(|g| g.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
)
|
||||
.field(
|
||||
"apps",
|
||||
detail
|
||||
.apps
|
||||
.iter()
|
||||
.map(|a| a.slug.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
);
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rename(
|
||||
ident: &str,
|
||||
name: Option<&str>,
|
||||
description: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let group = client.groups_rename(ident, name, description).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reparent(ident: &str, to: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let group = client.groups_reparent(ident, to).await?;
|
||||
print_group(&group, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic groups rm <slug>`. The server enforces delete=RESTRICT (409 on a
|
||||
/// non-empty group); `--recursive` expands the delete into ordered,
|
||||
/// leaf-first child deletions (groups + apps) the operator opted into.
|
||||
pub async fn rm(ident: &str, recursive: bool) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
if !recursive {
|
||||
client.groups_delete(ident).await?;
|
||||
println!("Deleted group {ident}");
|
||||
return Ok(());
|
||||
}
|
||||
// Recursive: delete the subtree leaf-first so each DB delete sees an
|
||||
// empty node (the FK stays RESTRICT — we never cascade implicitly).
|
||||
let detail = client.groups_get(ident).await?;
|
||||
if let Some(app) = detail.apps.first() {
|
||||
anyhow::bail!(
|
||||
"group {ident} contains app {:?}; move or delete apps before a recursive group delete \
|
||||
(apps are never auto-deleted)",
|
||||
app.slug
|
||||
);
|
||||
}
|
||||
for sub in &detail.subgroups {
|
||||
Box::pin(rm(&sub.slug, true)).await?;
|
||||
}
|
||||
client.groups_delete(ident).await?;
|
||||
println!("Deleted group {ident}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- members ---------------------------------------------------------------
|
||||
|
||||
pub async fn members_ls(group: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let members = client.group_members_list(group).await?;
|
||||
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
|
||||
for m in members {
|
||||
table.row([
|
||||
m.user_id.to_string(),
|
||||
m.username,
|
||||
m.role.as_str().to_string(),
|
||||
format!("{:?}", m.instance_role).to_lowercase(),
|
||||
m.is_active.to_string(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_add(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.group_members_grant(group, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_set(group: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let m = client
|
||||
.group_members_set_role(group, user_id, parse_role(role)?)
|
||||
.await?;
|
||||
print_member(&m, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn members_rm(group: &str, user_id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.group_members_remove(group, user_id).await?;
|
||||
println!("Removed {user_id} from {group}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_group(g: &Group, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", g.id.to_string())
|
||||
.field("slug", g.slug.clone())
|
||||
.field("name", g.name.clone())
|
||||
.field(
|
||||
"parent_id",
|
||||
g.parent_id.map_or_else(|| "-".into(), |p| p.to_string()),
|
||||
)
|
||||
.field("created_at", g.created_at.to_rfc3339());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("user_id", m.user_id.to_string())
|
||||
.field("username", m.username.clone())
|
||||
.field("role", m.role.as_str().to_string());
|
||||
block.print(mode);
|
||||
}
|
||||
|
||||
fn parse_role(role: &str) -> Result<AppRole> {
|
||||
AppRole::from_db_str(role)
|
||||
.ok_or_else(|| anyhow::anyhow!("invalid role {role:?}; want app_admin | editor | viewer"))
|
||||
}
|
||||
278
crates/picloud-cli/src/cmds/init.rs
Normal file
278
crates/picloud-cli/src/cmds/init.rs
Normal file
@@ -0,0 +1,278 @@
|
||||
//! `pic init [slug] [--dir .]` — scaffold a new declarative project: a
|
||||
//! `picloud.toml` describing a minimal working app (one `hello` endpoint +
|
||||
//! route), its `scripts/hello.rhai` source, and a `.gitignore` that ignores
|
||||
//! the project tool's `.picloud/` link-state directory.
|
||||
//!
|
||||
//! Offline by design — it never contacts the server. Run `pic plan` to
|
||||
//! preview the create, then `pic apply` to deploy. Refuses to overwrite an
|
||||
//! existing `picloud.toml` unless `--force`.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use picloud_shared::{DispatchMode, HostKind, PathKind, ScriptKind};
|
||||
|
||||
use crate::manifest::{Manifest, ManifestApp, ManifestRoute, ManifestScript, MANIFEST_FILE};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
const HEADER: &str = "\
|
||||
# picloud project manifest — the declarative desired state for one app.
|
||||
# Edit, then `pic plan` to preview changes and `pic apply` to reconcile.
|
||||
# Scripts live under scripts/ and are referenced by `file`.
|
||||
\n";
|
||||
|
||||
const EXAMPLES: &str = "\
|
||||
\n# ---------------------------------------------------------------------------
|
||||
# More to add (uncomment and adapt):
|
||||
#
|
||||
# [[scripts]]
|
||||
# name = \"lib\"
|
||||
# file = \"scripts/lib.rhai\"
|
||||
# kind = \"module\" # default: endpoint
|
||||
#
|
||||
# [[routes]]
|
||||
# script = \"hello\"
|
||||
# method = \"POST\" # omit for ANY
|
||||
# host_kind = \"any\"
|
||||
# path_kind = \"param\" # exact | prefix | param
|
||||
# path = \"/hello/:name\"
|
||||
#
|
||||
# [[triggers.cron]]
|
||||
# script = \"hello\"
|
||||
# schedule = \"0 0 * * * *\" # 6-field cron (seconds first)
|
||||
# timezone = \"UTC\"
|
||||
#
|
||||
# [secrets] # names only — push values with `pic secret set`
|
||||
# names = [\"STRIPE_KEY\"]
|
||||
";
|
||||
|
||||
const HELLO_RHAI: &str = "\
|
||||
// A minimal endpoint script. Its return value is the HTTP response body.
|
||||
// `ctx` exposes the request; see the stdlib reference for the full SDK.
|
||||
\"Hello from PiCloud!\"
|
||||
";
|
||||
|
||||
const GITIGNORE_LINE: &str = ".picloud/";
|
||||
|
||||
pub fn run(
|
||||
dir: &Path,
|
||||
slug_arg: Option<&str>,
|
||||
name_arg: Option<&str>,
|
||||
force: bool,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let slug = resolve_slug(dir, slug_arg)?;
|
||||
let name = name_arg.map_or_else(|| title_from_slug(&slug), str::to_string);
|
||||
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
if manifest_path.exists() && !force {
|
||||
bail!(
|
||||
"{} already exists; refusing to overwrite (use --force)",
|
||||
manifest_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
let manifest = scaffold_manifest(&slug, &name);
|
||||
// Build the file as a commented header + the (valid, round-trippable)
|
||||
// active manifest + commented examples. Rendering the active part through
|
||||
// `to_toml` guarantees it parses and matches the wire model.
|
||||
let body = format!("{HEADER}{}{EXAMPLES}", manifest.to_toml()?);
|
||||
|
||||
fs::create_dir_all(dir.join("scripts")).context("creating scripts/ directory")?;
|
||||
let hello_path = dir.join("scripts/hello.rhai");
|
||||
let wrote_hello = !hello_path.exists();
|
||||
if wrote_hello {
|
||||
fs::write(&hello_path, HELLO_RHAI).context("writing scripts/hello.rhai")?;
|
||||
}
|
||||
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
|
||||
ensure_gitignored(dir)?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", slug)
|
||||
.field(
|
||||
"scripts",
|
||||
if wrote_hello {
|
||||
"scripts/hello.rhai"
|
||||
} else {
|
||||
"scripts/hello.rhai (kept existing)"
|
||||
}
|
||||
.to_string(),
|
||||
)
|
||||
.field("next", "pic plan then pic apply".to_string());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
|
||||
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
},
|
||||
scripts: vec![ManifestScript {
|
||||
name: "hello".into(),
|
||||
file: "scripts/hello.rhai".into(),
|
||||
kind: ScriptKind::Endpoint,
|
||||
description: None,
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
}],
|
||||
routes: vec![ManifestRoute {
|
||||
script: "hello".into(),
|
||||
method: Some("GET".into()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/hello".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Use the explicit slug if given, else derive one from the target
|
||||
/// directory's name. Either way it must satisfy the app-slug rule.
|
||||
fn resolve_slug(dir: &Path, slug_arg: Option<&str>) -> Result<String> {
|
||||
if let Some(s) = slug_arg {
|
||||
if !is_valid_slug(s) {
|
||||
bail!("invalid app slug `{s}`: use lowercase letters, digits, and dashes (start alphanumeric, max 63)");
|
||||
}
|
||||
return Ok(s.to_string());
|
||||
}
|
||||
// Derive from the directory's own name. Use the path as given first —
|
||||
// `canonicalize` requires the dir to already exist, which breaks the
|
||||
// natural `pic init --dir new-project` flow. Fall back to canonicalizing
|
||||
// only when the path has no final component of its own (e.g. `.`).
|
||||
let base = dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.or_else(|| {
|
||||
dir.canonicalize()
|
||||
.ok()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let derived = slugify(&base);
|
||||
if !is_valid_slug(&derived) {
|
||||
bail!(
|
||||
"could not derive a valid app slug from directory `{base}`; \
|
||||
pass one explicitly, e.g. `pic init my-app`"
|
||||
);
|
||||
}
|
||||
Ok(derived)
|
||||
}
|
||||
|
||||
/// Lowercase, map runs of non-`[a-z0-9]` to a single `-`, trim dashes.
|
||||
fn slugify(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut prev_dash = false;
|
||||
for c in s.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c.to_ascii_lowercase());
|
||||
prev_dash = false;
|
||||
} else if !prev_dash {
|
||||
out.push('-');
|
||||
prev_dash = true;
|
||||
}
|
||||
}
|
||||
out.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
/// The canonical app-slug rule (mirrors the server): `^[a-z0-9][a-z0-9-]{0,62}$`.
|
||||
fn is_valid_slug(s: &str) -> bool {
|
||||
if s.is_empty() || s.len() > 63 {
|
||||
return false;
|
||||
}
|
||||
let mut chars = s.chars();
|
||||
let first = chars.next().expect("non-empty checked above");
|
||||
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
|
||||
}
|
||||
|
||||
/// Title-case a slug for a default display name: `my-blog` → `My Blog`.
|
||||
fn title_from_slug(slug: &str) -> String {
|
||||
slug.split('-')
|
||||
.filter(|w| !w.is_empty())
|
||||
.map(|w| {
|
||||
let mut c = w.chars();
|
||||
c.next().map_or_else(String::new, |f| {
|
||||
f.to_ascii_uppercase().to_string() + c.as_str()
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
/// Ensure `.gitignore` ignores `.picloud/` (the project tool's link state).
|
||||
/// Appends the line if missing; creates the file if absent. A repo without
|
||||
/// git still gets a correct `.gitignore` for when it's initialized.
|
||||
fn ensure_gitignored(dir: &Path) -> Result<()> {
|
||||
let path = dir.join(".gitignore");
|
||||
// Only a genuinely-absent file is treated as empty; an existing-but-
|
||||
// unreadable `.gitignore` must error rather than be silently clobbered.
|
||||
let existing = match fs::read_to_string(&path) {
|
||||
Ok(s) => s,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e).context("reading .gitignore"),
|
||||
};
|
||||
if existing.lines().any(|l| l.trim() == GITIGNORE_LINE) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut next = existing;
|
||||
if !next.is_empty() && !next.ends_with('\n') {
|
||||
next.push('\n');
|
||||
}
|
||||
next.push_str(GITIGNORE_LINE);
|
||||
next.push('\n');
|
||||
fs::write(&path, next).context("updating .gitignore")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scaffold_is_valid_and_round_trips() {
|
||||
let m = scaffold_manifest("blog", "Blog");
|
||||
let body = format!("{HEADER}{}{EXAMPLES}", m.to_toml().unwrap());
|
||||
// The active manifest (header/examples are comments) must parse back
|
||||
// to exactly the scaffold — the commented examples are inert.
|
||||
let parsed = Manifest::parse(&body).expect("scaffold must be valid TOML");
|
||||
assert_eq!(parsed, m);
|
||||
// And it's a deployable project: one endpoint + its route.
|
||||
assert_eq!(parsed.scripts.len(), 1);
|
||||
assert_eq!(parsed.routes.len(), 1);
|
||||
assert_eq!(parsed.routes[0].script, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slugify_and_validation() {
|
||||
assert_eq!(slugify("My Blog!"), "my-blog");
|
||||
assert_eq!(slugify(" weird__name "), "weird-name");
|
||||
assert_eq!(slugify("Project (2026)"), "project-2026");
|
||||
assert!(is_valid_slug("blog"));
|
||||
assert!(is_valid_slug("a1-b2"));
|
||||
assert!(!is_valid_slug(""));
|
||||
assert!(!is_valid_slug("-leading"));
|
||||
assert!(!is_valid_slug(&"a".repeat(64)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_from_slug_humanizes() {
|
||||
assert_eq!(title_from_slug("my-blog"), "My Blog");
|
||||
assert_eq!(title_from_slug("api"), "Api");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,20 @@
|
||||
pub mod admins;
|
||||
pub mod api_keys;
|
||||
pub mod apply;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod config;
|
||||
pub mod dead_letters;
|
||||
pub mod files;
|
||||
pub mod groups;
|
||||
pub mod init;
|
||||
pub mod kv;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod members;
|
||||
pub mod plan;
|
||||
pub mod pull;
|
||||
pub mod queues;
|
||||
pub mod routes;
|
||||
pub mod scripts;
|
||||
@@ -16,4 +22,5 @@ pub mod secrets;
|
||||
pub mod topics;
|
||||
pub mod triggers;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
pub mod whoami;
|
||||
|
||||
134
crates/picloud-cli/src/cmds/plan.rs
Normal file
134
crates/picloud-cli/src/cmds/plan.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! `pic plan [--file picloud.toml]` — diff the manifest's desired state
|
||||
//! against the live app and print the per-resource changes. Read-only:
|
||||
//! builds a bundle (manifest + script sources) and POSTs it to the
|
||||
//! server's plan endpoint, which computes the diff.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::client::{ChangeDto, Client, PlanDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
||||
// Record the bound-plan token so a subsequent `pic apply` can detect the
|
||||
// app changing underneath the reviewed plan (best-effort — a read-only
|
||||
// plan still succeeds if the project dir isn't writable).
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) =
|
||||
crate::linkstate::write_plan(base_dir, &manifest.app.slug, &plan.state_token)
|
||||
{
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
}
|
||||
}
|
||||
render(&plan, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||
/// their `file`), routes pass through, triggers flatten into a tagged
|
||||
/// array, secrets are names only.
|
||||
pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
let mut scripts = Vec::with_capacity(manifest.scripts.len());
|
||||
for s in &manifest.scripts {
|
||||
let source = std::fs::read_to_string(base_dir.join(&s.file))
|
||||
.with_context(|| format!("reading script source {}", s.file))?;
|
||||
let mut obj = Map::new();
|
||||
obj.insert("name".into(), json!(s.name));
|
||||
obj.insert("source".into(), json!(source));
|
||||
obj.insert("kind".into(), serde_json::to_value(s.kind)?);
|
||||
if let Some(d) = &s.description {
|
||||
obj.insert("description".into(), json!(d));
|
||||
}
|
||||
if let Some(t) = s.timeout_seconds {
|
||||
obj.insert("timeout_seconds".into(), json!(t));
|
||||
}
|
||||
if let Some(m) = s.memory_limit_mb {
|
||||
obj.insert("memory_limit_mb".into(), json!(m));
|
||||
}
|
||||
if let Some(sb) = &s.sandbox {
|
||||
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
|
||||
}
|
||||
obj.insert("enabled".into(), json!(s.enabled));
|
||||
scripts.push(Value::Object(obj));
|
||||
}
|
||||
|
||||
let routes = manifest
|
||||
.routes
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let t = &manifest.triggers;
|
||||
let mut triggers = Vec::new();
|
||||
for s in &t.kv {
|
||||
triggers.push(tagged("kv", s)?);
|
||||
}
|
||||
for s in &t.docs {
|
||||
triggers.push(tagged("docs", s)?);
|
||||
}
|
||||
for s in &t.files {
|
||||
triggers.push(tagged("files", s)?);
|
||||
}
|
||||
for s in &t.cron {
|
||||
triggers.push(tagged("cron", s)?);
|
||||
}
|
||||
for s in &t.pubsub {
|
||||
triggers.push(tagged("pubsub", s)?);
|
||||
}
|
||||
for s in &t.email {
|
||||
triggers.push(tagged("email", s)?);
|
||||
}
|
||||
for s in &t.queue {
|
||||
triggers.push(tagged("queue", s)?);
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Serialize a trigger spec and stamp its `kind` discriminator.
|
||||
fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
let mut v = serde_json::to_value(spec)?;
|
||||
if let Value::Object(map) = &mut v {
|
||||
map.insert("kind".into(), Value::String(kind.to_string()));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 4] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
table.row([
|
||||
kind.to_string(),
|
||||
c.op.clone(),
|
||||
c.key.clone(),
|
||||
c.detail.clone().unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
348
crates/picloud-cli/src/cmds/pull.rs
Normal file
348
crates/picloud-cli/src/cmds/pull.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
//! `pic pull <app> [--dir .]` — export an app's current server state into
|
||||
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
|
||||
//! declarative management with `pic plan` / `pic apply`.
|
||||
//!
|
||||
//! Read-only: issues `GET`s only and writes local files. Every trigger
|
||||
//! kind is exported except `email` — the server stores the *sealed secret
|
||||
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
|
||||
//! can't be reconstructed (email triggers must be set up by hand).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
||||
QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
// Refuse to clobber an existing project (mirrors `pic init`). `pull`
|
||||
// overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a
|
||||
// stray `pic pull <wrong-app>` in a populated dir would destroy local
|
||||
// edits. Fail fast — before any network call or file write — unless the
|
||||
// operator opted in with `--force`.
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
if !force && manifest_path.exists() {
|
||||
anyhow::bail!(
|
||||
"{} already exists; refusing to overwrite. Re-run with --force to \
|
||||
replace it (and any colliding scripts/*.rhai).",
|
||||
manifest_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// One GET per resource kind (routes are per-script, below).
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
||||
let triggers = client.triggers_list(app_ident).await?.triggers;
|
||||
let secrets = client
|
||||
.secrets_list(VarOwnerArg::App(app_ident), None)
|
||||
.await?
|
||||
.secrets;
|
||||
|
||||
let name_by_id: HashMap<ScriptId, String> =
|
||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||||
|
||||
// Routes: the admin surface lists them per script.
|
||||
let mut routes = Vec::new();
|
||||
for s in &scripts {
|
||||
for r in client.routes_list_for_script(&s.id.to_string()).await? {
|
||||
routes.push(ManifestRoute {
|
||||
script: s.name.clone(),
|
||||
method: r.method,
|
||||
host_kind: r.host_kind,
|
||||
host: r.host,
|
||||
host_param_name: r.host_param_name,
|
||||
path_kind: r.path_kind,
|
||||
path: r.path,
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
enabled: r.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The server does not constrain script names to a filesystem-safe
|
||||
// charset, so a name containing a path separator or `..` would let `pull`
|
||||
// write outside the project dir. Validate ALL names up front, before any
|
||||
// file is written, so a single bad name can't leave a half-written dir.
|
||||
// Reject rather than sanitize: a silent rename would desync the manifest
|
||||
// `name` from its `file`.
|
||||
for s in &scripts {
|
||||
if !is_safe_filename(&s.name) {
|
||||
anyhow::bail!(
|
||||
"script name {:?} is not filesystem-safe (a path separator, \
|
||||
`..`, a leading dot, a control character, or longer than 200 \
|
||||
bytes); cannot pull",
|
||||
s.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scripts: write each source next to the manifest and record a path ref.
|
||||
let scripts_dir = dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir)
|
||||
.with_context(|| format!("creating {}", scripts_dir.display()))?;
|
||||
let mut manifest_scripts = Vec::with_capacity(scripts.len());
|
||||
for s in &scripts {
|
||||
let rel = format!("scripts/{}.rhai", s.name);
|
||||
std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?;
|
||||
manifest_scripts.push(ManifestScript {
|
||||
name: s.name.clone(),
|
||||
file: rel,
|
||||
kind: s.kind,
|
||||
description: s.description.clone(),
|
||||
timeout_seconds: i32::try_from(s.timeout_seconds).ok(),
|
||||
memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(),
|
||||
sandbox: if s.sandbox.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.sandbox)
|
||||
},
|
||||
enabled: s.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// Triggers: map the five settled kinds; warn + skip the rest.
|
||||
let mut manifest_triggers = ManifestTriggers::default();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for t in &triggers {
|
||||
let script = name_by_id
|
||||
.get(&t.script_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| t.script_id.to_string());
|
||||
let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
|
||||
let retry_max_attempts = Some(t.retry_max_attempts);
|
||||
match t.kind.as_str() {
|
||||
"kv" => {
|
||||
let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.kv.push(KvTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"docs" => {
|
||||
let d: CollectionDetails<DocsEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.docs.push(DocsTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"files" => {
|
||||
let d: CollectionDetails<FilesEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.files.push(FilesTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"cron" => {
|
||||
let d: CronDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.cron.push(CronTriggerSpec {
|
||||
script,
|
||||
schedule: d.schedule,
|
||||
timezone: d.timezone,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"pubsub" => {
|
||||
let d: PubsubDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.pubsub.push(PubsubTriggerSpec {
|
||||
script,
|
||||
topic_pattern: d.topic_pattern,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"queue" => {
|
||||
let d: QueueDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.queue.push(QueueTriggerSpec {
|
||||
script,
|
||||
queue_name: d.queue_name,
|
||||
visibility_timeout_secs: Some(d.visibility_timeout_secs),
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
// `email` is skipped: the server stores the sealed secret value,
|
||||
// not the secret *name*, so the manifest's `inbound_secret_ref`
|
||||
// can't be reconstructed — set it up by hand.
|
||||
other => skipped.push(format!("{other} ({})", t.id)),
|
||||
}
|
||||
}
|
||||
for s in &skipped {
|
||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
app: ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
},
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
secrets: ManifestSecrets {
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
.with_context(|| format!("writing {}", manifest_path.display()))?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field("scripts", manifest.scripts.len().to_string())
|
||||
.field("routes", manifest.routes.len().to_string())
|
||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
||||
.field("secrets", manifest.secrets.names.len().to_string());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_count(t: &ManifestTriggers) -> usize {
|
||||
t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len()
|
||||
}
|
||||
|
||||
/// True if `name` is safe to use as a single path component in `scripts/`.
|
||||
/// Rejects empty/over-long names, path separators, `.`/`..`, leading dots,
|
||||
/// and any deceptive display character — a server-returned name is otherwise
|
||||
/// written verbatim as a filename and printed to the operator's terminal.
|
||||
fn is_safe_filename(name: &str) -> bool {
|
||||
// Leave headroom under NAME_MAX (255 bytes on common filesystems) for the
|
||||
// `.rhai` suffix.
|
||||
const MAX_LEN: usize = 200;
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_LEN
|
||||
&& !name.starts_with('.')
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& name != ".."
|
||||
&& !name.chars().any(is_deceptive_char)
|
||||
}
|
||||
|
||||
/// Control characters (NUL, newlines, ANSI escapes) plus the Unicode
|
||||
/// bidirectional-override and zero-width/format characters used to spoof how a
|
||||
/// name renders in a terminal — both classes are unsafe to print verbatim.
|
||||
fn is_deceptive_char(c: char) -> bool {
|
||||
c.is_control()
|
||||
|| matches!(c,
|
||||
'\u{200B}'..='\u{200F}' // zero-width space … LTR/RTL marks
|
||||
| '\u{2028}'..='\u{2029}' // line / paragraph separators
|
||||
| '\u{202A}'..='\u{202E}' // bidi embeddings / overrides
|
||||
| '\u{2060}' // word joiner
|
||||
| '\u{2066}'..='\u{2069}' // bidi isolates
|
||||
| '\u{FEFF}' // BOM / zero-width no-break space
|
||||
)
|
||||
}
|
||||
|
||||
/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
|
||||
/// The server tags details with a `kind` field which these structs ignore.
|
||||
fn decode_details<T: for<'de> Deserialize<'de>>(
|
||||
details: &serde_json::Value,
|
||||
kind: &str,
|
||||
) -> Result<T> {
|
||||
serde_json::from_value(details.clone())
|
||||
.with_context(|| format!("decoding {kind} trigger details"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionDetails<Op> {
|
||||
collection_glob: String,
|
||||
#[serde(default = "Vec::new")]
|
||||
ops: Vec<Op>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CronDetails {
|
||||
schedule: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
timezone: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PubsubDetails {
|
||||
topic_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueueDetails {
|
||||
queue_name: String,
|
||||
visibility_timeout_secs: u32,
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_safe_filename;
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_and_separators() {
|
||||
for bad in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../etc/passwd",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
".hidden",
|
||||
"with\0nul",
|
||||
] {
|
||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_control_chars_and_overlong() {
|
||||
for bad in [
|
||||
"a\nb",
|
||||
"a\tb",
|
||||
"line\rdrop",
|
||||
"esc\x1b[2Jseq",
|
||||
"rtl\u{202E}gpj.exe", // bidi override (filename spoof)
|
||||
"zero\u{200B}width", // zero-width space
|
||||
"bom\u{FEFF}name", // BOM
|
||||
] {
|
||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
||||
}
|
||||
assert!(
|
||||
!is_safe_filename(&"a".repeat(201)),
|
||||
"expected an over-long name to be rejected"
|
||||
);
|
||||
assert!(
|
||||
is_safe_filename(&"a".repeat(200)),
|
||||
"a 200-char name is at the limit and allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_names() {
|
||||
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
|
||||
assert!(is_safe_filename(ok), "expected {ok:?} to be accepted");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,56 @@
|
||||
//! `pic secrets` subcommands: `ls`, `set`, `rm`.
|
||||
//! `pic secrets ls | set | rm | read` — manage Phase-3 group/app secrets.
|
||||
//!
|
||||
//! Set reads the secret value from stdin (the only safe channel —
|
||||
//! inline values would leak into shell history). The value is sent
|
||||
//! as a JSON string; pass `--json` to interpret stdin as raw JSON
|
||||
//! (numbers, maps, …) instead.
|
||||
//! Exactly one of `--group` / `--app` selects the owner (mirroring
|
||||
//! `pic vars`). Set reads the secret value from stdin (the only safe
|
||||
//! channel — inline values would leak into shell history). The value is
|
||||
//! sent as a JSON string; pass `--json` to interpret stdin as raw JSON
|
||||
//! (numbers, maps, …) instead. `--env` is only meaningful for group
|
||||
//! owners (app secrets are env-agnostic).
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
|
||||
/// Resolve the `--group`/`--app` pair into exactly one owner.
|
||||
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
||||
match (group, app) {
|
||||
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
|
||||
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
|
||||
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
|
||||
(None, None) => Err(anyhow!("pass one of --group / --app")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ls(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let resp = client.secrets_list(app).await?;
|
||||
let mut table = Table::new(["name", "updated_at"]);
|
||||
let resp = client.secrets_list(owner, env).await?;
|
||||
let mut table = Table::new(["name", "env", "updated_at"]);
|
||||
for s in resp.secrets {
|
||||
table.row([s.name, s.updated_at.to_rfc3339()]);
|
||||
table.row([s.name, s.env, s.updated_at.to_rfc3339()]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
||||
pub async fn set(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
as_json: bool,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let mut buf = String::new();
|
||||
@@ -41,15 +66,36 @@ pub async fn set(app: &str, name: &str, as_json: bool) -> Result<()> {
|
||||
} else {
|
||||
serde_json::Value::String(trimmed.to_string())
|
||||
};
|
||||
client.secrets_set(app, name, value).await?;
|
||||
client.secrets_set(owner, name, value, env).await?;
|
||||
println!("Set secret {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(app: &str, name: &str) -> Result<()> {
|
||||
pub async fn rm(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
name: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.secrets_delete(app, name).await?;
|
||||
client.secrets_delete(owner, name, env).await?;
|
||||
println!("Deleted secret {name}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic secrets read --group <slug> <name>` — fetch and print a secret's
|
||||
/// PLAINTEXT value. This is the ONLY command that reveals a secret value,
|
||||
/// and it is gated server-side at the owning group (there is no app-secret
|
||||
/// equivalent). String values print raw; JSON values print pretty.
|
||||
pub async fn read(group: &str, name: &str, env: Option<&str>) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let resp = client.group_secret_read_value(group, name, env).await?;
|
||||
match resp.value {
|
||||
serde_json::Value::String(s) => println!("{s}"),
|
||||
other => println!("{}", serde_json::to_string_pretty(&other)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
85
crates/picloud-cli/src/cmds/vars.rs
Normal file
85
crates/picloud-cli/src/cmds/vars.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! `pic vars ls | set | rm` — manage Phase-3 group/app config vars.
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/{apps,groups}/{id}/vars*`. Exactly one of
|
||||
//! `--group` / `--app` selects the owner. `ls` shows the owner's OWN rows
|
||||
//! (not the resolved/inherited view). Set values are JSON strings by
|
||||
//! default; `--json` parses the value as raw JSON.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Resolve the `--group`/`--app` pair into exactly one owner.
|
||||
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
||||
match (group, app) {
|
||||
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
|
||||
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
|
||||
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
|
||||
(None, None) => Err(anyhow!("pass one of --group / --app")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ls(group: Option<&str>, app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let resp = client.vars_list(owner).await?;
|
||||
let mut table = Table::new(["key", "env", "value", "tombstone", "updated_at"]);
|
||||
for v in resp.vars {
|
||||
table.row([
|
||||
v.key,
|
||||
v.env,
|
||||
v.value.to_string(),
|
||||
v.is_tombstone.to_string(),
|
||||
v.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
key: &str,
|
||||
value: &str,
|
||||
env: Option<&str>,
|
||||
as_json: bool,
|
||||
tombstone: bool,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// A tombstone carries no value (the server stores JSON null + the
|
||||
// deletion marker); otherwise parse per `--json`.
|
||||
let parsed = if tombstone {
|
||||
serde_json::Value::Null
|
||||
} else if as_json {
|
||||
serde_json::from_str(value).map_err(|e| anyhow!("parse value as JSON: {e}"))?
|
||||
} else {
|
||||
serde_json::Value::String(value.to_string())
|
||||
};
|
||||
client.vars_set(owner, key, parsed, env, tombstone).await?;
|
||||
if tombstone {
|
||||
println!("Set tombstone for {key}");
|
||||
} else {
|
||||
println!("Set var {key}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.vars_delete(owner, key, env).await?;
|
||||
println!("Deleted var {key}");
|
||||
Ok(())
|
||||
}
|
||||
67
crates/picloud-cli/src/linkstate.rs
Normal file
67
crates/picloud-cli/src/linkstate.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! `.picloud/` link state — gitignored, per-project metadata the project tool
|
||||
//! carries between CLI invocations. Today it holds just the bound-plan token:
|
||||
//! `pic plan` records the fingerprint of the live state it diffed against, and
|
||||
//! `pic apply` replays it so the server can refuse if the app moved underneath.
|
||||
//!
|
||||
//! All paths are relative to the manifest's directory (the project root).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const DIR: &str = ".picloud";
|
||||
const PLAN_FILE: &str = "plan.json";
|
||||
|
||||
/// The recorded result of the last `pic plan`, scoped to the app it was for.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanLink {
|
||||
/// App slug the token belongs to — guards against replaying a token from a
|
||||
/// different app if the manifest's `slug` changed.
|
||||
pub app: String,
|
||||
pub state_token: String,
|
||||
}
|
||||
|
||||
fn plan_path(base: &Path) -> PathBuf {
|
||||
base.join(DIR).join(PLAN_FILE)
|
||||
}
|
||||
|
||||
/// Record the bound-plan token for `app` under `base/.picloud/`.
|
||||
pub fn write_plan(base: &Path, app: &str, state_token: &str) -> Result<()> {
|
||||
let dir = base.join(DIR);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
// Self-ignore: a `.gitignore` of `*` inside `.picloud/` keeps the whole
|
||||
// dir out of git regardless of the project root's `.gitignore` — so this
|
||||
// is safe even when reached via `pic plan`/`pull` (which, unlike `init`,
|
||||
// don't touch the root `.gitignore`).
|
||||
let ignore = dir.join(".gitignore");
|
||||
if !ignore.exists() {
|
||||
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
|
||||
}
|
||||
let link = PlanLink {
|
||||
app: app.to_string(),
|
||||
state_token: state_token.to_string(),
|
||||
};
|
||||
let body = serde_json::to_vec_pretty(&link).context("encoding .picloud/plan.json")?;
|
||||
fs::write(plan_path(base), body).context("writing .picloud/plan.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the recorded plan token, if any. Returns `None` when absent or
|
||||
/// unreadable (treated as "no prior plan" — never an error).
|
||||
#[must_use]
|
||||
pub fn read_plan(base: &Path) -> Option<PlanLink> {
|
||||
let body = fs::read(plan_path(base)).ok()?;
|
||||
serde_json::from_slice(&body).ok()
|
||||
}
|
||||
|
||||
/// Remove the recorded plan token (best-effort) **iff it belongs to `app`**.
|
||||
/// Called after a successful apply consumes it, so the next apply requires a
|
||||
/// fresh plan — without clobbering a token recorded for a different app that
|
||||
/// happens to share the directory.
|
||||
pub fn clear_plan(base: &Path, app: &str) {
|
||||
if read_plan(base).is_some_and(|l| l.app == app) {
|
||||
let _ = fs::remove_file(plan_path(base));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod linkstate;
|
||||
mod manifest;
|
||||
mod output;
|
||||
|
||||
use crate::output::OutputMode;
|
||||
@@ -49,6 +51,12 @@ enum Cmd {
|
||||
cmd: AppsCmd,
|
||||
},
|
||||
|
||||
/// Group (org-tree) management.
|
||||
Groups {
|
||||
#[command(subcommand)]
|
||||
cmd: GroupsCmd,
|
||||
},
|
||||
|
||||
/// Script management.
|
||||
Scripts {
|
||||
#[command(subcommand)]
|
||||
@@ -136,6 +144,14 @@ enum Cmd {
|
||||
cmd: MembersCmd,
|
||||
},
|
||||
|
||||
/// Config vars (Phase 3) — set / list / delete group- or app-owned
|
||||
/// env-scoped vars. Values inherit down the group tree; an app value
|
||||
/// overrides an inherited one (proximity wins).
|
||||
Vars {
|
||||
#[command(subcommand)]
|
||||
cmd: VarsCmd,
|
||||
},
|
||||
|
||||
/// Files inspection — list a collection's blobs, download bytes, or
|
||||
/// delete a file. Read + delete only; writes go through scripts.
|
||||
Files {
|
||||
@@ -156,6 +172,109 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
cmd: KvCmd,
|
||||
},
|
||||
|
||||
/// Reconcile the live app to a `picloud.toml` manifest in one
|
||||
/// transaction (creates + updates; `--prune` also deletes resources
|
||||
/// absent from the manifest).
|
||||
Apply(ApplyArgs),
|
||||
|
||||
/// Diff a `picloud.toml` manifest against the live app and print the
|
||||
/// changes (create / update / no-op / delete). Read-only.
|
||||
Plan(PlanArgs),
|
||||
|
||||
/// Export an app's current server state into a `picloud.toml` manifest
|
||||
/// (+ `scripts/<name>.rhai` sources) for declarative management with
|
||||
/// `pic plan` / `pic apply`.
|
||||
Pull(PullArgs),
|
||||
|
||||
/// Scaffold a new declarative project: a `picloud.toml` (minimal working
|
||||
/// app), `scripts/hello.rhai`, and a `.gitignore`. Offline; deploy with
|
||||
/// `pic plan` then `pic apply`.
|
||||
Init(InitArgs),
|
||||
|
||||
/// Show an app's resolved configuration. `--effective` lists secrets with
|
||||
/// values masked (`<set>`/`<unset>`), cross-referenced against the manifest.
|
||||
Config(ConfigArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct ApplyArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Delete live scripts/routes/triggers absent from the manifest.
|
||||
/// Secrets are never pruned.
|
||||
#[arg(long)]
|
||||
prune: bool,
|
||||
/// Skip the `--prune` confirmation prompt. Required to prune
|
||||
/// non-interactively (CI).
|
||||
#[arg(long)]
|
||||
yes: bool,
|
||||
/// Skip the bound-plan staleness check (apply even if the app changed
|
||||
/// since the last `pic plan`).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before applying.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct PlanArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before diffing.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct PullArgs {
|
||||
/// App slug or id to export.
|
||||
app: String,
|
||||
/// Directory to write `picloud.toml` + `scripts/` into.
|
||||
#[arg(long, default_value = ".")]
|
||||
dir: PathBuf,
|
||||
/// Overwrite an existing `picloud.toml` (and colliding `scripts/*.rhai`).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct InitArgs {
|
||||
/// App slug for the new project. Defaults to a slug derived from the
|
||||
/// target directory's name.
|
||||
slug: Option<String>,
|
||||
/// Directory to scaffold into.
|
||||
#[arg(long, default_value = ".")]
|
||||
dir: PathBuf,
|
||||
/// Display name for the app. Defaults to a title-cased slug.
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
/// Overwrite an existing `picloud.toml`.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct ConfigArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Show the effective (resolved) config with secrets masked.
|
||||
#[arg(long)]
|
||||
effective: bool,
|
||||
/// With `--effective`, also print each resolved var's `merged_from`
|
||||
/// provenance — the ordered (depth, scope) layers it merged from.
|
||||
#[arg(long)]
|
||||
explain: bool,
|
||||
/// Resolve against the `picloud.<env>.toml` overlay (per-env slug +
|
||||
/// secrets), matching `pic plan --env` / `pic apply --env`.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -300,6 +419,9 @@ enum AppsCmd {
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Parent group (slug or id). Defaults to the instance root.
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
},
|
||||
|
||||
/// Show a single app, including the caller's role in it.
|
||||
@@ -322,6 +444,75 @@ enum AppsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum GroupsCmd {
|
||||
/// List all groups (flat).
|
||||
Ls,
|
||||
/// Render the group hierarchy as an indented tree.
|
||||
Tree,
|
||||
/// Create a new group. Omit `--parent` for a root-level group.
|
||||
Create {
|
||||
slug: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
/// Parent group (slug or id).
|
||||
#[arg(long)]
|
||||
parent: Option<String>,
|
||||
},
|
||||
/// Show a group with its path, subgroups, and apps.
|
||||
Show { ident: String },
|
||||
/// Rename a group (name/description only — the slug is frozen).
|
||||
Rename {
|
||||
ident: String,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
/// Move a group under a new parent (`--to` slug/id, or omit for root).
|
||||
Reparent {
|
||||
ident: String,
|
||||
#[arg(long)]
|
||||
to: Option<String>,
|
||||
},
|
||||
/// Delete a group. Refused (409) if non-empty unless `--recursive`,
|
||||
/// which deletes child groups leaf-first (apps are never auto-deleted).
|
||||
Rm {
|
||||
ident: String,
|
||||
#[arg(long)]
|
||||
recursive: bool,
|
||||
},
|
||||
/// Manage a group's members (inherited down the tree).
|
||||
Members {
|
||||
#[command(subcommand)]
|
||||
cmd: GroupMembersCmd,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum GroupMembersCmd {
|
||||
/// List a group's members.
|
||||
Ls { group: String },
|
||||
/// Grant a member a role on the group.
|
||||
Add {
|
||||
group: String,
|
||||
user_id: String,
|
||||
#[arg(long, default_value = "viewer")]
|
||||
role: String,
|
||||
},
|
||||
/// Change a member's role.
|
||||
Set {
|
||||
group: String,
|
||||
user_id: String,
|
||||
#[arg(long)]
|
||||
role: String,
|
||||
},
|
||||
/// Remove a member from the group.
|
||||
Rm { group: String, user_id: String },
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DomainsCmd {
|
||||
/// List the app's domain claims.
|
||||
@@ -955,31 +1146,114 @@ enum DeadLettersCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum SecretsCmd {
|
||||
/// List secret names + last-modified for an app. Values never
|
||||
/// leave the server.
|
||||
/// List secret names + last-modified for the owner. Values never
|
||||
/// leave the server. `--env` filters group secrets (app secrets are
|
||||
/// env-agnostic; the `env` column shows the scope for groups).
|
||||
Ls {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
/// Environment scope (group owners only).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
},
|
||||
|
||||
/// Set a secret. Reads the value from stdin (the only safe
|
||||
/// channel — inline values would land in shell history). Pipe
|
||||
/// the value in: `echo -n "mysecret" | pic secrets set --app foo my_key`.
|
||||
/// For group owners, `--env` scopes the secret.
|
||||
Set {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
name: String,
|
||||
/// Environment scope (group owners only).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// Treat stdin as raw JSON (numbers, maps, …) instead of a
|
||||
/// string literal.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
},
|
||||
|
||||
/// Delete a secret by name.
|
||||
/// Delete a secret by name (optionally env-scoped for groups).
|
||||
Rm {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
name: String,
|
||||
/// Environment scope (group owners only).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
},
|
||||
|
||||
/// Fetch and print a secret's PLAINTEXT value. This is the ONLY
|
||||
/// command that reveals a secret value, and it is gated server-side
|
||||
/// at the owning group — hence `--group` only, no `--app`.
|
||||
Read {
|
||||
/// Owning group (slug or id).
|
||||
#[arg(long)]
|
||||
group: String,
|
||||
name: String,
|
||||
/// Environment scope.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum VarsCmd {
|
||||
/// List the owner's OWN vars (not the resolved/inherited view).
|
||||
Ls {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
},
|
||||
|
||||
/// Set a var. The value is stored as a JSON string by default; pass
|
||||
/// `--json` to parse it as raw JSON. `--tombstone` writes a deletion
|
||||
/// marker that suppresses an inherited key.
|
||||
Set {
|
||||
key: String,
|
||||
/// Ignored (but accepted) when `--tombstone` is set.
|
||||
#[arg(default_value = "")]
|
||||
value: String,
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
/// Environment scope (`*` = env-agnostic, the default).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// Parse `value` as raw JSON instead of a string literal.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
/// Write a tombstone (suppress an inherited key) instead of a value.
|
||||
#[arg(long)]
|
||||
tombstone: bool,
|
||||
},
|
||||
|
||||
/// Delete a var by key (optionally scoped to one environment).
|
||||
Rm {
|
||||
key: String,
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1045,6 +1319,36 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
Cmd::Logout => cmds::logout::run().await,
|
||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||
Cmd::Apply(args) => {
|
||||
cmds::apply::run(
|
||||
&args.file,
|
||||
args.env.as_deref(),
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
|
||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
|
||||
Cmd::Config(args) => {
|
||||
cmds::config::run(
|
||||
&args.file,
|
||||
args.effective,
|
||||
args.explain,
|
||||
args.env.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Init(args) => cmds::init::run(
|
||||
&args.dir,
|
||||
args.slug.as_deref(),
|
||||
args.name.as_deref(),
|
||||
args.force,
|
||||
mode,
|
||||
),
|
||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||
Cmd::Apps {
|
||||
cmd:
|
||||
@@ -1052,8 +1356,18 @@ async fn main() -> ExitCode {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
group,
|
||||
},
|
||||
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref(), mode).await,
|
||||
} => {
|
||||
cmds::apps::create(
|
||||
&slug,
|
||||
name.as_deref(),
|
||||
description.as_deref(),
|
||||
group.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Apps {
|
||||
cmd: AppsCmd::Show { ident },
|
||||
} => cmds::apps::show(&ident, mode).await,
|
||||
@@ -1077,6 +1391,79 @@ async fn main() -> ExitCode {
|
||||
cmd: DomainsCmd::Rm { app, domain_id },
|
||||
},
|
||||
} => cmds::apps_domains::rm(&app, &domain_id).await,
|
||||
Cmd::Groups { cmd: GroupsCmd::Ls } => cmds::groups::ls(mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Tree,
|
||||
} => cmds::groups::tree(mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Create {
|
||||
slug,
|
||||
name,
|
||||
description,
|
||||
parent,
|
||||
},
|
||||
} => {
|
||||
cmds::groups::create(
|
||||
&slug,
|
||||
name.as_deref(),
|
||||
description.as_deref(),
|
||||
parent.as_deref(),
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Show { ident },
|
||||
} => cmds::groups::show(&ident, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Rename {
|
||||
ident,
|
||||
name,
|
||||
description,
|
||||
},
|
||||
} => cmds::groups::rename(&ident, name.as_deref(), description.as_deref(), mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Reparent { ident, to },
|
||||
} => cmds::groups::reparent(&ident, to.as_deref(), mode).await,
|
||||
Cmd::Groups {
|
||||
cmd: GroupsCmd::Rm { ident, recursive },
|
||||
} => cmds::groups::rm(&ident, recursive).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd: GroupMembersCmd::Ls { group },
|
||||
},
|
||||
} => cmds::groups::members_ls(&group, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd:
|
||||
GroupMembersCmd::Add {
|
||||
group,
|
||||
user_id,
|
||||
role,
|
||||
},
|
||||
},
|
||||
} => cmds::groups::members_add(&group, &user_id, &role, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd:
|
||||
GroupMembersCmd::Set {
|
||||
group,
|
||||
user_id,
|
||||
role,
|
||||
},
|
||||
},
|
||||
} => cmds::groups::members_set(&group, &user_id, &role, mode).await,
|
||||
Cmd::Groups {
|
||||
cmd:
|
||||
GroupsCmd::Members {
|
||||
cmd: GroupMembersCmd::Rm { group, user_id },
|
||||
},
|
||||
} => cmds::groups::members_rm(&group, &user_id).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Ls { app },
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
@@ -1456,14 +1843,39 @@ async fn main() -> ExitCode {
|
||||
cmd: DeadLettersCmd::Resolve { app, dl_id, reason },
|
||||
} => cmds::dead_letters::resolve(&app, &dl_id, &reason).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Ls { app },
|
||||
} => cmds::secrets::ls(&app, mode).await,
|
||||
cmd: SecretsCmd::Ls { group, app, env },
|
||||
} => cmds::secrets::ls(group.as_deref(), app.as_deref(), env.as_deref(), mode).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Set { app, name, json },
|
||||
} => cmds::secrets::set(&app, &name, json).await,
|
||||
cmd:
|
||||
SecretsCmd::Set {
|
||||
group,
|
||||
app,
|
||||
name,
|
||||
env,
|
||||
json,
|
||||
},
|
||||
} => {
|
||||
cmds::secrets::set(
|
||||
group.as_deref(),
|
||||
app.as_deref(),
|
||||
&name,
|
||||
env.as_deref(),
|
||||
json,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Rm { app, name },
|
||||
} => cmds::secrets::rm(&app, &name).await,
|
||||
cmd:
|
||||
SecretsCmd::Rm {
|
||||
group,
|
||||
app,
|
||||
name,
|
||||
env,
|
||||
},
|
||||
} => cmds::secrets::rm(group.as_deref(), app.as_deref(), &name, env.as_deref()).await,
|
||||
Cmd::Secrets {
|
||||
cmd: SecretsCmd::Read { group, name, env },
|
||||
} => cmds::secrets::read(&group, &name, env.as_deref()).await,
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Ls { app },
|
||||
} => cmds::members::ls(&app, mode).await,
|
||||
@@ -1476,6 +1888,41 @@ async fn main() -> ExitCode {
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Rm { app, user_id },
|
||||
} => cmds::members::rm(&app, &user_id).await,
|
||||
Cmd::Vars {
|
||||
cmd: VarsCmd::Ls { group, app },
|
||||
} => cmds::vars::ls(group.as_deref(), app.as_deref(), mode).await,
|
||||
Cmd::Vars {
|
||||
cmd:
|
||||
VarsCmd::Set {
|
||||
key,
|
||||
value,
|
||||
group,
|
||||
app,
|
||||
env,
|
||||
json,
|
||||
tombstone,
|
||||
},
|
||||
} => {
|
||||
cmds::vars::set(
|
||||
group.as_deref(),
|
||||
app.as_deref(),
|
||||
&key,
|
||||
&value,
|
||||
env.as_deref(),
|
||||
json,
|
||||
tombstone,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Vars {
|
||||
cmd:
|
||||
VarsCmd::Rm {
|
||||
key,
|
||||
group,
|
||||
app,
|
||||
env,
|
||||
},
|
||||
} => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Ls {
|
||||
|
||||
523
crates/picloud-cli/src/manifest.rs
Normal file
523
crates/picloud-cli/src/manifest.rs
Normal file
@@ -0,0 +1,523 @@
|
||||
//! Declarative project manifest (`picloud.toml`).
|
||||
//!
|
||||
//! One manifest describes the desired state of a **single app** — its
|
||||
//! scripts, routes, triggers, and the *names* of the secrets it expects
|
||||
//! (values are pushed out-of-band via `pic secret set`, never committed).
|
||||
//!
|
||||
//! This is the foundation of the declarative project tool (`pic pull` /
|
||||
//! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared`
|
||||
//! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`,
|
||||
//! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays
|
||||
//! identical to the admin API — the CLI never depends on `manager-core`.
|
||||
//!
|
||||
//! All eight trigger kinds are representable except `dead_letter` (not
|
||||
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
|
||||
//! (a secret name) resolved server-side at apply.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::{
|
||||
DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Conventional manifest filename at a project root.
|
||||
pub const MANIFEST_FILE: &str = "picloud.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub app: ManifestApp,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub routes: Vec<ManifestRoute>,
|
||||
#[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")]
|
||||
pub triggers: ManifestTriggers,
|
||||
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
|
||||
pub secrets: ManifestSecrets,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Parse a manifest from TOML text.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
toml::from_str(text).context("parsing manifest TOML")
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let body =
|
||||
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
Self::parse(&body)
|
||||
}
|
||||
|
||||
/// Render to TOML text. Tables are emitted after scalars (the struct
|
||||
/// field order already satisfies TOML's "values before tables" rule).
|
||||
pub fn to_toml(&self) -> Result<String> {
|
||||
toml::to_string_pretty(self).context("serializing manifest TOML")
|
||||
}
|
||||
|
||||
/// Load the base manifest, then (if `env` is set) merge the sparse
|
||||
/// `picloud.<env>.toml` overlay on top — the §4.1 base+overlay model
|
||||
/// where "an environment is an app". The overlay carries per-env slug +
|
||||
/// secrets; scripts/routes/triggers stay in the shared base. (Rich
|
||||
/// per-key `vars` resolution is Phase 3.)
|
||||
pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result<Self> {
|
||||
let mut base = Self::load(base_path)?;
|
||||
if let Some(env) = env {
|
||||
let path = overlay_path(base_path, env);
|
||||
let body = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"reading overlay {} for env `{env}` (expected next to the base manifest)",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let overlay: ManifestOverlay = toml::from_str(&body)
|
||||
.with_context(|| format!("parsing overlay {}", path.display()))?;
|
||||
base.apply_overlay(overlay);
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
/// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name`
|
||||
/// replace the base's; overlay secret names union into the base set.
|
||||
fn apply_overlay(&mut self, overlay: ManifestOverlay) {
|
||||
if let Some(slug) = overlay.app.slug {
|
||||
self.app.slug = slug;
|
||||
}
|
||||
if let Some(name) = overlay.app.name {
|
||||
self.app.name = name;
|
||||
}
|
||||
for n in overlay.secrets.names {
|
||||
if !self.secrets.names.contains(&n) {
|
||||
self.secrets.names.push(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `picloud.toml` → `picloud.<env>.toml`, alongside the base (works for a
|
||||
/// custom `--file` too: `custom.toml` → `custom.<env>.toml`).
|
||||
fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
|
||||
let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let name = base_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| MANIFEST_FILE.to_string());
|
||||
let stem = name.strip_suffix(".toml").unwrap_or(&name);
|
||||
parent.join(format!("{stem}.{env}.toml"))
|
||||
}
|
||||
|
||||
/// A sparse per-environment overlay (`picloud.<env>.toml`). Only the fields
|
||||
/// that vary per environment today — slug/name and secret names.
|
||||
///
|
||||
/// `deny_unknown_fields`: an overlay can carry *only* `[app]` and `[secrets]`.
|
||||
/// Scripts/routes/triggers belong in the shared base manifest, so a
|
||||
/// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an
|
||||
/// overlay is a mistake — error loudly rather than silently dropping it.
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestOverlay {
|
||||
#[serde(default)]
|
||||
pub app: OverlayApp,
|
||||
#[serde(default)]
|
||||
pub secrets: ManifestSecrets,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct OverlayApp {
|
||||
#[serde(default)]
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestApp {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestScript {
|
||||
pub name: String,
|
||||
/// Path to the `.rhai` source, relative to the manifest's directory.
|
||||
pub file: String,
|
||||
#[serde(default, skip_serializing_if = "is_endpoint")]
|
||||
pub kind: ScriptKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
/// Per-script sandbox overrides; omitted entirely when no knob is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
/// Three-state lifecycle (§4.3): `false` deploys the script inert (not
|
||||
/// invocable). Omitted ⇒ active; only serialized when disabled.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestRoute {
|
||||
/// Name of the script this route binds to.
|
||||
pub script: String,
|
||||
/// HTTP method; omit for ANY.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub host: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "is_sync")]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
|
||||
/// Omitted ⇒ active; only serialized when disabled.
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggers {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub kv: Vec<KvTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub docs: Vec<DocsTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub files: Vec<FilesTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cron: Vec<CronTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pubsub: Vec<PubsubTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub email: Vec<EmailTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub queue: Vec<QueueTriggerSpec>,
|
||||
}
|
||||
|
||||
impl ManifestTriggers {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.kv.is_empty()
|
||||
&& self.docs.is_empty()
|
||||
&& self.files.is_empty()
|
||||
&& self.cron.is_empty()
|
||||
&& self.pubsub.is_empty()
|
||||
&& self.email.is_empty()
|
||||
&& self.queue.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KvTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<KvEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DocsTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<DocsEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FilesTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<FilesEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CronTriggerSpec {
|
||||
pub script: String,
|
||||
/// 6-field cron expression (with seconds).
|
||||
pub schedule: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PubsubTriggerSpec {
|
||||
pub script: String,
|
||||
pub topic_pattern: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmailTriggerSpec {
|
||||
pub script: String,
|
||||
/// Name of the secret (set via `pic secret set`) holding the inbound
|
||||
/// HMAC value — resolved + sealed server-side at apply. Never the value.
|
||||
pub inbound_secret_ref: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct QueueTriggerSpec {
|
||||
pub script: String,
|
||||
pub queue_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visibility_timeout_secs: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
/// `[secrets] names = [...]` — declares which secrets the app expects.
|
||||
/// Values are never in the manifest; `pic secret set` pushes them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestSecrets {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
impl ManifestSecrets {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.names.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
|
||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||
*kind == ScriptKind::Endpoint
|
||||
}
|
||||
|
||||
fn is_sync(mode: &DispatchMode) -> bool {
|
||||
*mode == DispatchMode::Sync
|
||||
}
|
||||
|
||||
/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
|
||||
fn is_true(b: &bool) -> bool {
|
||||
*b
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
},
|
||||
scripts: vec![
|
||||
ManifestScript {
|
||||
name: "create-post".into(),
|
||||
file: "scripts/create-post.rhai".into(),
|
||||
kind: ScriptKind::Endpoint,
|
||||
description: None,
|
||||
timeout_seconds: Some(10),
|
||||
memory_limit_mb: Some(256),
|
||||
sandbox: None,
|
||||
enabled: true,
|
||||
},
|
||||
ManifestScript {
|
||||
name: "lib".into(),
|
||||
file: "scripts/lib.rhai".into(),
|
||||
kind: ScriptKind::Module,
|
||||
description: None,
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: Some(ScriptSandbox {
|
||||
max_operations: Some(5_000_000),
|
||||
..ScriptSandbox::empty()
|
||||
}),
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
routes: vec![ManifestRoute {
|
||||
script: "create-post".into(),
|
||||
method: Some("POST".into()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/posts".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
enabled: true,
|
||||
}],
|
||||
triggers: ManifestTriggers {
|
||||
cron: vec![CronTriggerSpec {
|
||||
script: "create-post".into(),
|
||||
schedule: "0 6 * * * *".into(),
|
||||
timezone: "UTC".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
}],
|
||||
kv: vec![KvTriggerSpec {
|
||||
script: "create-post".into(),
|
||||
collection_glob: "users".into(),
|
||||
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
||||
dispatch_mode: Some(DispatchMode::Async),
|
||||
retry_max_attempts: Some(5),
|
||||
}],
|
||||
..ManifestTriggers::default()
|
||||
},
|
||||
secrets: ManifestSecrets {
|
||||
names: vec!["STRIPE_KEY".into()],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_toml() {
|
||||
let m = sample();
|
||||
let text = m.to_toml().expect("serialize");
|
||||
let back = Manifest::parse(&text).expect("parse");
|
||||
assert_eq!(m, back, "manifest must survive a TOML round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_defaulted_fields() {
|
||||
let text = sample().to_toml().unwrap();
|
||||
// Endpoint kind + sync dispatch are defaults → not emitted.
|
||||
assert!(
|
||||
!text.contains("kind = \"endpoint\""),
|
||||
"default kind should be omitted:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("dispatch_mode = \"sync\""),
|
||||
"default route dispatch should be omitted:\n{text}"
|
||||
);
|
||||
// Module kind IS non-default → emitted.
|
||||
assert!(text.contains("kind = \"module\""), "got:\n{text}");
|
||||
// `enabled` defaults true → omitted for the active script/route, but
|
||||
// the disabled `lib` script emits `enabled = false`.
|
||||
assert!(
|
||||
text.contains("enabled = false"),
|
||||
"a disabled entity must emit enabled:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("enabled = true"),
|
||||
"active entities must omit the default:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_merges_slug_and_unions_secrets() {
|
||||
let mut m = sample(); // slug "blog", secrets ["STRIPE_KEY"]
|
||||
let overlay: ManifestOverlay = toml::from_str(
|
||||
"[app]\nslug = \"blog-staging\"\n\n[secrets]\nnames = [\"STRIPE_KEY\", \"STAGING_ONLY\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
m.apply_overlay(overlay);
|
||||
assert_eq!(m.app.slug, "blog-staging", "overlay slug wins");
|
||||
assert_eq!(
|
||||
m.app.name, "My Blog",
|
||||
"base name kept when overlay omits it"
|
||||
);
|
||||
assert_eq!(
|
||||
m.secrets.names,
|
||||
vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()],
|
||||
"secrets union, no dupes"
|
||||
);
|
||||
// Scripts/routes come from the base, untouched by the overlay.
|
||||
assert_eq!(m.scripts.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_rejects_non_overlay_tables() {
|
||||
// An overlay carries only [app]/[secrets]. Scripts/routes/triggers
|
||||
// belong in the shared base, so a `[[scripts]]` table (or a typo'd
|
||||
// key) in an overlay must error loudly, not be silently dropped.
|
||||
let err = toml::from_str::<ManifestOverlay>(
|
||||
"[app]\nslug = \"blog-staging\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n",
|
||||
)
|
||||
.expect_err("overlay with [[scripts]] must be rejected");
|
||||
assert!(
|
||||
err.to_string().contains("scripts") || err.to_string().contains("unknown"),
|
||||
"error should point at the offending table: {err}"
|
||||
);
|
||||
|
||||
// Typo'd key inside [app] is likewise rejected.
|
||||
toml::from_str::<ManifestOverlay>("[app]\nslag = \"oops\"\n")
|
||||
.expect_err("overlay with a typo'd [app] key must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_path_derivation() {
|
||||
assert_eq!(
|
||||
overlay_path(Path::new("proj/picloud.toml"), "staging"),
|
||||
Path::new("proj/picloud.staging.toml")
|
||||
);
|
||||
assert_eq!(
|
||||
overlay_path(Path::new("custom.toml"), "prod"),
|
||||
Path::new("custom.prod.toml")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_optional_sections_omitted() {
|
||||
let m = Manifest {
|
||||
app: ManifestApp {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
},
|
||||
scripts: vec![],
|
||||
routes: vec![],
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
}
|
||||
153
crates/picloud-cli/tests/apply.rs
Normal file
153
crates/picloud-cli/tests/apply.rs
Normal file
@@ -0,0 +1,153 @@
|
||||
//! `pic apply` journey: apply a manifest to an empty app (atomic create),
|
||||
//! re-apply is an idempotent no-op, and a bundle containing any invalid
|
||||
//! resource applies nothing (all-or-nothing).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_creates_then_noop() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apply");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/greet.rhai"),
|
||||
"let body = #{ ok: true }; body",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Apply Test\"\n\n\
|
||||
[[scripts]]\nname = \"greet\"\nfile = \"scripts/greet.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"greet\"\nmethod = \"POST\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n\n\
|
||||
[[triggers.cron]]\nscript = \"greet\"\nschedule = \"0 0 * * * *\"\ntimezone = \"UTC\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// First apply: creates script + route + trigger.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
stdout.contains("+1"),
|
||||
"expected creations in report:\n{stdout}"
|
||||
);
|
||||
|
||||
// The resources now exist.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(s.contains("greet"), "script not created:\n{s}");
|
||||
|
||||
// Plan is now clean (apply reached desired state).
|
||||
let p = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!p.contains("create") && !p.contains("update"),
|
||||
"expected clean plan after apply:\n{p}"
|
||||
);
|
||||
|
||||
// Re-apply: idempotent — nothing created/updated.
|
||||
let r = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_rejects_bad_bundle_atomically() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apply-atomic");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/good.rhai"), "let x = 1; x").unwrap();
|
||||
// Invalid Rhai — fails validation, so the whole apply must abort.
|
||||
fs::write(dir.path().join("scripts/bad.rhai"), "let x = ;").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Atomic Test\"\n\n\
|
||||
[[scripts]]\nname = \"good\"\nfile = \"scripts/good.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"apply with an invalid script should fail"
|
||||
);
|
||||
|
||||
// Atomic: the valid script must NOT have been created.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.contains("good"),
|
||||
"a failed apply must leave nothing behind:\n{s}"
|
||||
);
|
||||
}
|
||||
@@ -15,14 +15,27 @@ mod common;
|
||||
|
||||
mod admins;
|
||||
mod api_keys;
|
||||
mod apply;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod config;
|
||||
mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod group_secrets;
|
||||
mod groups;
|
||||
mod init;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
mod output;
|
||||
mod plan;
|
||||
mod prune;
|
||||
mod pull;
|
||||
mod roles;
|
||||
mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod triggers;
|
||||
mod vars;
|
||||
|
||||
@@ -44,6 +44,35 @@ pub struct UserGuard {
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
/// Deletes a group on drop (best-effort). The group must be empty by then
|
||||
/// — register an `AppGuard`/child `GroupGuard` *after* this one so the
|
||||
/// child drops (deletes) first, leaving an empty node here.
|
||||
pub struct GroupGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
slug: String,
|
||||
}
|
||||
|
||||
impl GroupGuard {
|
||||
pub fn new(url: &str, token: &str, slug: &str) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
token: token.to_string(),
|
||||
slug: slug.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GroupGuard {
|
||||
fn drop(&mut self) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let _ = client
|
||||
.delete(format!("{}/api/v1/admin/groups/{}", self.url, self.slug))
|
||||
.bearer_auth(&self.token)
|
||||
.send();
|
||||
}
|
||||
}
|
||||
|
||||
impl UserGuard {
|
||||
pub fn new(url: &str, token: &str, user_id: &str) -> Self {
|
||||
Self {
|
||||
|
||||
57
crates/picloud-cli/tests/config.rs
Normal file
57
crates/picloud-cli/tests/config.rs
Normal file
@@ -0,0 +1,57 @@
|
||||
//! `pic config --effective` — masked secret resolution against the manifest.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use predicates::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn config_effective_masks_and_classifies_secrets() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("config");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
format!("[app]\nslug = \"{slug}\"\nname = \"Cfg\"\n\n[secrets]\nnames = [\"API_KEY\"]\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Declared but not pushed → masked + flagged unset; value never shown.
|
||||
common::pic_as(&env)
|
||||
.args(["config", "--effective", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("API_KEY"))
|
||||
.stdout(predicate::str::contains("<unset>"))
|
||||
.stdout(predicate::str::contains("not pushed"));
|
||||
|
||||
// Push it → now masked as <set> / managed, still never the value.
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "API_KEY"])
|
||||
.write_stdin("super-secret-value")
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["config", "--effective", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("<set>"))
|
||||
.stdout(predicate::str::contains("managed"))
|
||||
.stdout(predicate::str::contains("super-secret-value").not());
|
||||
}
|
||||
222
crates/picloud-cli/tests/email_queue.rs
Normal file
222
crates/picloud-cli/tests/email_queue.rs
Normal file
@@ -0,0 +1,222 @@
|
||||
//! M5: `pic apply` creates email + queue triggers. The email trigger's
|
||||
//! inbound secret is referenced by name (pushed via `pic secret set`) and
|
||||
//! resolved + re-sealed server-side — never written into the manifest.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_email_and_queue_triggers() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// The email trigger references this secret by name; push its value
|
||||
// out-of-band first.
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "email-hmac"])
|
||||
.write_stdin("super-secret-hmac")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[secrets]\nnames = [\"email-hmac\"]\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.queue]]\nscript = \"handler\"\nqueue_name = \"jobs\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// Both triggers exist.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
s.lines().any(|l| l.contains("queue")),
|
||||
"queue trigger missing:\n{s}"
|
||||
);
|
||||
assert!(
|
||||
s.lines().any(|l| l.contains("email")),
|
||||
"email trigger missing:\n{s}"
|
||||
);
|
||||
|
||||
// Re-apply is a no-op (both triggers match by identity).
|
||||
let r = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!r.contains("+1"), "re-apply should be a no-op:\n{r}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_refuses_to_orphan_email_trigger() {
|
||||
// `pull` can't represent email triggers, so a manifest that omits the
|
||||
// script owning one would, under `--prune`, cascade-delete the trigger
|
||||
// (and its sealed secret) when the script is dropped. Apply must refuse.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5-orphan");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &slug, "email-hmac"])
|
||||
.write_stdin("super-secret-hmac")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// v1: a script with an email trigger.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[secrets]\nnames = [\"email-hmac\"]\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"email-hmac\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// v2: drop the script (and, implicitly, its un-representable email
|
||||
// trigger). A prune apply must REFUSE rather than cascade-destroy it.
|
||||
let v2 = format!("[app]\nslug = \"{slug}\"\nname = \"M5\"\n");
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"prune must refuse to orphan an email trigger"
|
||||
);
|
||||
|
||||
// The script and its email trigger both survive the refused apply.
|
||||
let scripts = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
scripts.contains("handler"),
|
||||
"script must survive:\n{scripts}"
|
||||
);
|
||||
let triggers = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
triggers.lines().any(|l| l.contains("email")),
|
||||
"email trigger must survive:\n{triggers}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_email_unset_secret_fails() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("m5-nosecret");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/handler.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"M5\"\n\n\
|
||||
[[scripts]]\nname = \"handler\"\nfile = \"scripts/handler.rhai\"\n\n\
|
||||
[[triggers.email]]\nscript = \"handler\"\ninbound_secret_ref = \"never-set\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// The referenced secret was never set → apply must fail atomically.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("apply");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"apply must fail when an email secret is unset"
|
||||
);
|
||||
|
||||
// Atomic: neither the script nor the email trigger was created.
|
||||
let s = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!s.contains("handler"),
|
||||
"failed apply must leave nothing behind:\n{s}"
|
||||
);
|
||||
}
|
||||
226
crates/picloud-cli/tests/enabled.rs
Normal file
226
crates/picloud-cli/tests/enabled.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! `enabled` three-state lifecycle, end to end: disabling a script via the
|
||||
//! manifest makes it non-invocable (404 on the execute-by-id bypass), and
|
||||
//! re-enabling restores it — proving the data path + runtime honoring.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn disabling_a_script_makes_it_uninvocable_then_reenable() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enabled");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = |enabled_line: &str| {
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Enabled\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n{enabled_line}"
|
||||
)
|
||||
};
|
||||
|
||||
// Active → invocable.
|
||||
fs::write(&manifest_path, manifest("")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
let id = script_id(&env, &slug, "hello");
|
||||
assert_eq!(invoke_status(&env, &id), 200, "active script must invoke");
|
||||
|
||||
// Disabled → 404 (not invocable), but still deployed (re-pull would show it).
|
||||
fs::write(&manifest_path, manifest("enabled = false\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
invoke_status(&env, &id),
|
||||
404,
|
||||
"disabled script must 404 on execute-by-id"
|
||||
);
|
||||
|
||||
// Re-enabled → invocable again.
|
||||
fs::write(&manifest_path, manifest("enabled = true\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
invoke_status(&env, &id),
|
||||
200,
|
||||
"re-enabled script must invoke"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn disabling_a_route_makes_it_404_then_reenable() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enbl-route");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// The app must claim a Host before its routes are reachable (two-phase
|
||||
// dispatch: Host → app → route). A unique strict host avoids colliding
|
||||
// with other tests' instance-global claims.
|
||||
let host = format!("{slug}.test");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "domains", "add", &slug, &host])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = |enabled_line: &str| {
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"EnabledRoute\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"hello\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/hello\"\n{enabled_line}"
|
||||
)
|
||||
};
|
||||
|
||||
// Active → the route serves.
|
||||
fs::write(&manifest_path, manifest("")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
200,
|
||||
"active route serves"
|
||||
);
|
||||
|
||||
// Disabled → 404, indistinguishable from absent.
|
||||
fs::write(&manifest_path, manifest("enabled = false\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
404,
|
||||
"disabled route must 404"
|
||||
);
|
||||
|
||||
// Re-enabled → serves again.
|
||||
fs::write(&manifest_path, manifest("enabled = true\n")).unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
assert_eq!(
|
||||
route_status(&env, &host, "/hello"),
|
||||
200,
|
||||
"re-enabled route serves"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn enabled_route_to_disabled_script_404s_flatly() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("enbl-os");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
let host = format!("{slug}.test");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "domains", "add", &slug, &host])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
// Route stays enabled; the SCRIPT it binds is disabled (route-on/script-off).
|
||||
fs::write(
|
||||
&manifest_path,
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"OS\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\nenabled = false\n\n\
|
||||
[[routes]]\nscript = \"hello\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/hello\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
apply(&env, &manifest_path);
|
||||
|
||||
// 404, and the body must be the flat "no route matches" form — never the
|
||||
// internal script id (no info leak; indistinguishable from absent).
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.get(format!("{}/hello", env.url))
|
||||
.header(reqwest::header::HOST, &host)
|
||||
.send()
|
||||
.unwrap();
|
||||
assert_eq!(resp.status().as_u16(), 404);
|
||||
let body = resp.text().unwrap();
|
||||
assert!(body.contains("no route matches"), "flat 404 body: {body}");
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, manifest_path: &Path) {
|
||||
common::pic_as(env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
/// GET a user route under an explicit `Host` (the claimed domain) and return
|
||||
/// the HTTP status code.
|
||||
fn route_status(env: &common::TestEnv, host: &str, path: &str) -> u16 {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
client
|
||||
.get(format!("{}{path}", env.url))
|
||||
.header(reqwest::header::HOST, host)
|
||||
.send()
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
|
||||
/// Resolve a script's id via the admin API (the manifest carries no ids).
|
||||
fn script_id(env: &common::TestEnv, slug: &str, name: &str) -> String {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let scripts: Vec<Value> = client
|
||||
.get(format!("{}/api/v1/admin/scripts?app={slug}", env.url))
|
||||
.bearer_auth(&env.token)
|
||||
.send()
|
||||
.unwrap()
|
||||
.json()
|
||||
.unwrap();
|
||||
scripts
|
||||
.into_iter()
|
||||
.find(|s| s["name"] == name)
|
||||
.and_then(|s| s["id"].as_str().map(String::from))
|
||||
.expect("script id")
|
||||
}
|
||||
|
||||
/// POST the execute-by-id bypass and return the HTTP status code.
|
||||
fn invoke_status(env: &common::TestEnv, id: &str) -> u16 {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
client
|
||||
.post(format!("{}/api/v1/execute/{id}", env.url))
|
||||
.bearer_auth(&env.token)
|
||||
.body("{}")
|
||||
.send()
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16()
|
||||
}
|
||||
76
crates/picloud-cli/tests/env_overlay.rs
Normal file
76
crates/picloud-cli/tests/env_overlay.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! Env-scoped overlays (§4.1): `pic apply --env <E>` merges the sparse
|
||||
//! `picloud.<env>.toml` (per-env slug) onto the base and deploys to that
|
||||
//! environment's app — leaving the base app untouched.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_env_overlay_targets_the_env_app() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let base_slug = common::unique_slug("ovl");
|
||||
let staging_slug = format!("{base_slug}-staging");
|
||||
for s in [&base_slug, &staging_slug] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", s])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
let _g1 = AppGuard::new(&env.url, &env.token, &base_slug);
|
||||
let _g2 = AppGuard::new(&env.url, &env.token, &staging_slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "\"hi\"").unwrap();
|
||||
let base = dir.path().join("picloud.toml");
|
||||
fs::write(
|
||||
&base,
|
||||
format!(
|
||||
"[app]\nslug = \"{base_slug}\"\nname = \"Ovl\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// Overlay redirects to the staging app.
|
||||
fs::write(
|
||||
dir.path().join("picloud.staging.toml"),
|
||||
format!("[app]\nslug = \"{staging_slug}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Apply to staging only.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&base)
|
||||
.args(["--env", "staging"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
assert!(
|
||||
scripts_ls(&env, &staging_slug).contains("hello"),
|
||||
"overlay apply must deploy to the staging app"
|
||||
);
|
||||
assert!(
|
||||
!scripts_ls(&env, &base_slug).contains("hello"),
|
||||
"the base app must be untouched by an --env apply"
|
||||
);
|
||||
}
|
||||
5
crates/picloud-cli/tests/fixtures/read-secret.rhai
vendored
Normal file
5
crates/picloud-cli/tests/fixtures/read-secret.rhai
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
// Phase-3 group-secrets journey fixture: returns the resolved `stripe-key`
|
||||
// secret verbatim so the test can assert runtime injection across the group
|
||||
// chain (inherited group value) vs an app-owned proximity override. The
|
||||
// value is decrypted under the resolved owner's AAD before injection.
|
||||
secrets::get("stripe-key")
|
||||
4
crates/picloud-cli/tests/fixtures/read-var.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/read-var.rhai
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
// Phase-3 vars journey fixture: returns the resolved `region` config var
|
||||
// verbatim so the test can assert on inheritance (group value) vs an app
|
||||
// proximity override.
|
||||
vars::get("region")
|
||||
187
crates/picloud-cli/tests/group_secrets.rs
Normal file
187
crates/picloud-cli/tests/group_secrets.rs
Normal file
@@ -0,0 +1,187 @@
|
||||
//! Phase-3 group secrets, end to end via `pic`:
|
||||
//!
|
||||
//! 1. **Inheritance + proximity** — a group-owned secret is injected into a
|
||||
//! descendant app's script via `secrets::get` (decrypted under the
|
||||
//! group AAD), and an app-owned secret of the same name shadows it
|
||||
//! (decrypted under the app AAD). Exercises the resolver + the dual
|
||||
//! owner-AAD open path against real Postgres.
|
||||
//! 2. **Masked-read boundary** — a `group_admin` reads the secret VALUE,
|
||||
//! an app-only dev is denied the value (403) yet still sees the secret
|
||||
//! EXISTS (masked) in `config/effective`. That is the headline §5.3
|
||||
//! property: an app runs with config its own devs cannot read.
|
||||
|
||||
use predicates::prelude::*;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_secret_is_injected_then_app_value_overrides() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("gs-acme");
|
||||
let app = common::unique_slug("gs-app");
|
||||
|
||||
// Group `acme` with a `stripe-key` group secret (value via stdin).
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--group", &acme, "stripe-key"])
|
||||
.write_stdin("sk_group")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App under acme with a script that reads + returns the resolved secret.
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let fixture = common::fixture_path("read-secret.rhai");
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
let ls = common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &app])
|
||||
.output()
|
||||
.expect("scripts ls");
|
||||
let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("scripts ls should produce one row");
|
||||
|
||||
// Inherited: the app has no own `stripe-key`, so the group's value is
|
||||
// injected (decrypted under the GROUP AAD).
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::json!("sk_group"),
|
||||
"inherited group secret"
|
||||
);
|
||||
|
||||
// Proximity override: an app-owned `stripe-key` shadows the group value
|
||||
// (decrypted under the APP AAD — proving both AAD namespaces open).
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &app, "stripe-key"])
|
||||
.write_stdin("sk_app")
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::json!("sk_app"),
|
||||
"app override"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_secret_value_is_masked_from_app_devs() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("gsm-acme");
|
||||
let app = common::unique_slug("gsm-app");
|
||||
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--group", &acme, "stripe-key"])
|
||||
.write_stdin("sk_live_masked")
|
||||
.assert()
|
||||
.success();
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// An app dev: a Member granted `editor` on the app, but NO group role.
|
||||
let dev = member::member_user(fx, &common::unique_username("appdev"));
|
||||
let dev_env = common::custom_env(&fx.url, &dev.token);
|
||||
common::seed_credentials(&dev_env, &dev.username);
|
||||
member::grant_membership(fx, &app, &dev.id, "editor");
|
||||
|
||||
// Denied the VALUE: the value endpoint is gated GroupSecretsRead at the
|
||||
// owning group, which the app dev does not hold.
|
||||
common::pic_as(&dev_env)
|
||||
.args(["secrets", "read", "--group", &acme, "stripe-key"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 403"));
|
||||
|
||||
// But the dev DOES see it EXISTS (masked) in the app's effective config —
|
||||
// status `set`, owner group, never the value. Asserted directly against
|
||||
// the endpoint to avoid the CLI's manifest requirement.
|
||||
let effective = reqwest::blocking::Client::new()
|
||||
.get(format!(
|
||||
"{}/api/v1/admin/apps/{}/config/effective",
|
||||
fx.url, app
|
||||
))
|
||||
.bearer_auth(&dev.token)
|
||||
.send()
|
||||
.expect("config effective");
|
||||
assert!(
|
||||
effective.status().is_success(),
|
||||
"dev can read effective config"
|
||||
);
|
||||
let body: serde_json::Value = effective.json().expect("effective json");
|
||||
let masked = &body["secrets"]["stripe-key"];
|
||||
assert_eq!(masked["status"], "set", "secret shown as set");
|
||||
assert_eq!(masked["owner"]["kind"], "group", "owned by the group");
|
||||
assert!(
|
||||
masked.get("value").is_none(),
|
||||
"value must never appear in effective config: {masked}"
|
||||
);
|
||||
|
||||
// A group_admin CAN read the value.
|
||||
let gadmin = member::member_user(fx, &common::unique_username("gadmin"));
|
||||
let gadmin_env = common::custom_env(&fx.url, &gadmin.token);
|
||||
common::seed_credentials(&gadmin_env, &gadmin.username);
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"groups",
|
||||
"members",
|
||||
"add",
|
||||
&acme,
|
||||
&gadmin.id,
|
||||
"--role",
|
||||
"app_admin",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&gadmin_env)
|
||||
.args(["secrets", "read", "--group", &acme, "stripe-key"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("sk_live_masked"));
|
||||
}
|
||||
|
||||
/// Invoke a script via `pic scripts invoke <id>` (→ `/api/v1/execute/{id}`)
|
||||
/// and parse its JSON body.
|
||||
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||
let out = common::pic_as(env)
|
||||
.args(["scripts", "invoke", id])
|
||||
.output()
|
||||
.expect("scripts invoke");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"invoke failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||
}
|
||||
179
crates/picloud-cli/tests/groups.rs
Normal file
179
crates/picloud-cli/tests/groups.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Phase-2 groups, end to end via `pic`: tree CRUD, delete=RESTRICT,
|
||||
//! reparent cycle rejection, and the headline invariant — a `group_admin`
|
||||
//! on an ancestor group can act on an app it is NOT a direct member of
|
||||
//! (inherited membership), and loses that access the instant the group
|
||||
//! grant is revoked.
|
||||
|
||||
use predicates::prelude::*;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_tree_create_show_and_delete_restrict() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("g-acme");
|
||||
let team = common::unique_slug("g-team");
|
||||
let app = common::unique_slug("g-app");
|
||||
|
||||
// Root-level group, then a subgroup under it.
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let _g_team = GroupGuard::new(&env.url, &env.token, &team);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &team, "--parent", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// An app under the subgroup.
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &team])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// `groups show team` lists the app.
|
||||
let out = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "show", &team])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
out.contains(&app),
|
||||
"group detail should list its app:\n{out}"
|
||||
);
|
||||
|
||||
// delete=RESTRICT: acme has a subgroup → refused.
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "rm", &acme])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("409").or(predicate::str::contains("subgroup")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn reparent_into_own_descendant_is_rejected() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("g-cyc-p");
|
||||
let child = common::unique_slug("g-cyc-c");
|
||||
|
||||
let _g_parent = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
let _g_child = GroupGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &child, "--parent", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Moving the parent under its own child would form a cycle → refused.
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "reparent", &parent, "--to", &child])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("409").or(predicate::str::contains("descendant")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn inherited_group_admin_can_deploy_then_revoke() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("g-inh");
|
||||
let app = common::unique_slug("g-inh-app");
|
||||
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A fresh Member with NO app membership.
|
||||
let m = member::member_user(fx, &common::unique_username("inh"));
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
|
||||
// Baseline: without any grant, deploy is forbidden.
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 403"));
|
||||
|
||||
// Grant group_admin on the ANCESTOR group (no app_members row).
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"groups",
|
||||
"members",
|
||||
"add",
|
||||
&acme,
|
||||
&m.id,
|
||||
"--role",
|
||||
"app_admin",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Inherited: the member can now deploy to the app it never joined.
|
||||
// (Deploy prints a KvBlock — assert on the script name + create action,
|
||||
// not a prose string.)
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
|
||||
|
||||
// Revoke the group grant → access drops immediately (no cache lag).
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "members", "rm", &acme, &m.id])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&member_env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("HTTP 403"));
|
||||
}
|
||||
99
crates/picloud-cli/tests/init.rs
Normal file
99
crates/picloud-cli/tests/init.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
//! `pic init` journey — offline scaffolding, no server/DB needed (so these
|
||||
//! tests are NOT gated on `DATABASE_URL` and never touch `common::fixture`).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use assert_cmd::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn pic() -> Command {
|
||||
Command::cargo_bin("pic").expect("pic binary")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_scaffolds_a_deployable_project() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let toml = fs::read_to_string(dir.path().join("picloud.toml")).unwrap();
|
||||
assert!(toml.contains("slug = \"demo-app\""), "got:\n{toml}");
|
||||
assert!(
|
||||
toml.contains("name = \"Demo App\""),
|
||||
"name defaults to title-cased slug:\n{toml}"
|
||||
);
|
||||
assert!(
|
||||
dir.path().join("scripts/hello.rhai").exists(),
|
||||
"scaffold writes the example script"
|
||||
);
|
||||
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(
|
||||
gitignore.lines().any(|l| l.trim() == ".picloud/"),
|
||||
"init must gitignore .picloud/:\n{gitignore}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_derives_slug_from_a_not_yet_created_dir() {
|
||||
// The natural `pic init --dir new-project` flow: the target doesn't exist
|
||||
// yet, and the slug is derived from its name (not via canonicalize, which
|
||||
// would fail on a missing path).
|
||||
let parent = TempDir::new().unwrap();
|
||||
let target = parent.path().join("new-project");
|
||||
pic()
|
||||
.args(["init", "--dir"])
|
||||
.arg(&target)
|
||||
.assert()
|
||||
.success();
|
||||
let toml = fs::read_to_string(target.join("picloud.toml")).unwrap();
|
||||
assert!(
|
||||
toml.contains("slug = \"new-project\""),
|
||||
"slug should derive from the dir name:\n{toml}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_refuses_to_overwrite_without_force() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
// A second run must refuse rather than clobber edits.
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.failure();
|
||||
// `--force` overrides.
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app", "--force"])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn init_appends_to_an_existing_gitignore_once() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::write(dir.path().join(".gitignore"), "target/\n").unwrap();
|
||||
pic()
|
||||
.current_dir(dir.path())
|
||||
.args(["init", "demo-app"])
|
||||
.assert()
|
||||
.success();
|
||||
let gitignore = fs::read_to_string(dir.path().join(".gitignore")).unwrap();
|
||||
assert!(
|
||||
gitignore.contains("target/"),
|
||||
"must preserve existing rules"
|
||||
);
|
||||
assert_eq!(
|
||||
gitignore.matches(".picloud/").count(),
|
||||
1,
|
||||
"must add the ignore exactly once:\n{gitignore}"
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use predicates::prelude::*;
|
||||
use crate::common;
|
||||
|
||||
/// Pick out the data rows from `pic logs` TSV output — the header line
|
||||
/// (`created_at\tstatus\tsummary`) is now always present, so the old
|
||||
/// (`created_at\tsource\tstatus\tsummary`) is now always present, so the old
|
||||
/// "no non-empty lines means no logs" check needs to skip it.
|
||||
fn data_rows(stdout: &str) -> Vec<&str> {
|
||||
stdout
|
||||
@@ -63,10 +63,10 @@ fn logs_after_invoke_records_success_row() {
|
||||
let cols: Vec<&str> = rows[0].split('\t').map(str::trim).collect();
|
||||
assert_eq!(
|
||||
cols.len(),
|
||||
3,
|
||||
"row should be 3 tab-delimited cells: {rows:?}"
|
||||
4,
|
||||
"row should be 4 tab-delimited cells (created_at, source, status, summary): {rows:?}"
|
||||
);
|
||||
assert_eq!(cols[1], "success");
|
||||
assert_eq!(cols[2], "success");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
@@ -95,7 +95,7 @@ fn logs_records_error_for_throwing_script() {
|
||||
.next()
|
||||
.expect("at least one data row");
|
||||
let cols: Vec<&str> = row.split('\t').map(str::trim).collect();
|
||||
assert_eq!(cols[1], "error", "expected error status, got row: {row}");
|
||||
assert_eq!(cols[2], "error", "expected error status, got row: {row}");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
@@ -166,7 +166,7 @@ fn logs_truncates_long_summary() {
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("at least one data row");
|
||||
let summary = row.split('\t').nth(2).expect("summary column");
|
||||
let summary = row.split('\t').nth(3).expect("summary column");
|
||||
assert!(
|
||||
summary.ends_with('…'),
|
||||
"summary should be truncated with `…`, got: {summary}"
|
||||
|
||||
81
crates/picloud-cli/tests/plan.rs
Normal file
81
crates/picloud-cli/tests/plan.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
//! `pic plan` journey: a freshly-pulled manifest must diff to all-no-op
|
||||
//! (pull→plan is idempotent), and editing a script source must surface
|
||||
//! as an update.
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn plan_roundtrips_then_detects_change() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let (script_id, guard) = common::deploy_fixture(&env, "plan", "hello.rhai");
|
||||
let app = guard.slug().to_string();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"routes", "create", "--script", &script_id, "--path", "/p", "--method", "GET",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull the live state, then plan it back — must be a clean no-op.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let manifest = dir.path().join("picloud.toml");
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest)
|
||||
.output()
|
||||
.expect("plan");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"plan failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let hello = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("hello"))
|
||||
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
|
||||
assert!(
|
||||
hello.contains("noop"),
|
||||
"expected hello no-op, got:\n{stdout}"
|
||||
);
|
||||
assert!(
|
||||
!stdout.contains("create") && !stdout.contains("delete"),
|
||||
"fresh pull should diff clean, got:\n{stdout}"
|
||||
);
|
||||
|
||||
// Edit the script source on disk → plan must report an update.
|
||||
std::fs::write(
|
||||
dir.path().join("scripts/hello.rhai"),
|
||||
"let body = #{ ok: false }; body",
|
||||
)
|
||||
.expect("rewrite source");
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest)
|
||||
.output()
|
||||
.expect("plan after edit");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let hello = stdout
|
||||
.lines()
|
||||
.find(|l| l.contains("hello"))
|
||||
.unwrap_or_else(|| panic!("no hello row in plan:\n{stdout}"));
|
||||
assert!(
|
||||
hello.contains("update"),
|
||||
"expected hello update after source edit, got:\n{stdout}"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
177
crates/picloud-cli/tests/prune.rs
Normal file
177
crates/picloud-cli/tests/prune.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
//! `pic apply --prune` journey: a resource dropped from the manifest
|
||||
//! survives a plain (additive) apply but is deleted with `--prune`.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", slug])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_deletes_stale_resources() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("prune");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
||||
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// v1: two scripts + a route on `drop`.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"drop\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/drop\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// v2: drop `drop` and its route.
|
||||
let v2 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
|
||||
// Plain apply is additive — `drop` survives.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
scripts_ls(&env, &slug).contains("drop"),
|
||||
"additive apply must not delete"
|
||||
);
|
||||
|
||||
// Prune apply removes `drop` and its route. `--yes` skips the
|
||||
// confirmation prompt (this test runs non-interactively).
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.args(["--prune", "--yes"])
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"prune failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let report = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
report.contains("-1"),
|
||||
"expected deletions in report:\n{report}"
|
||||
);
|
||||
|
||||
let s = scripts_ls(&env, &slug);
|
||||
assert!(!s.contains("drop"), "prune should delete `drop`:\n{s}");
|
||||
assert!(s.contains("keep"), "prune must keep `keep`:\n{s}");
|
||||
|
||||
// Plan is clean after prune.
|
||||
let p = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!p.contains("delete"),
|
||||
"plan should be clean after prune:\n{p}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The prune confirmation gate: `--prune` without `--yes`, run
|
||||
/// non-interactively (no TTY, as in CI), must refuse and delete nothing.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_without_yes_refuses_noninteractively() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("prune-gate");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
||||
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// Deploy two scripts, then drop one from the manifest.
|
||||
let v1 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
||||
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v1).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
let v2 = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
|
||||
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &v2).unwrap();
|
||||
|
||||
// `--prune` with no `--yes` and no TTY → refuse, non-zero exit.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--prune")
|
||||
.output()
|
||||
.expect("apply --prune");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"prune without --yes must refuse non-interactively"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("--yes"),
|
||||
"refusal should mention --yes:\n{err}"
|
||||
);
|
||||
|
||||
// The dropped script must still be there — the gate blocked the delete.
|
||||
let s = scripts_ls(&env, &slug);
|
||||
assert!(
|
||||
s.contains("drop"),
|
||||
"refused prune must not delete anything:\n{s}"
|
||||
);
|
||||
}
|
||||
91
crates/picloud-cli/tests/pull.rs
Normal file
91
crates/picloud-cli/tests/pull.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
//! `pic pull` journey: stand up an app with a script, route, cron trigger,
|
||||
//! and a secret, then export it and assert the manifest + script file.
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn pull_exports_manifest_and_sources() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
|
||||
// App + script "hello" (deploy derives the name from the file stem).
|
||||
let (script_id, guard) = common::deploy_fixture(&env, "pull", "hello.rhai");
|
||||
let app = guard.slug().to_string();
|
||||
|
||||
// Route → script.
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"routes", "create", "--script", &script_id, "--path", "/hook", "--method", "POST",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Cron trigger → script.
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"triggers",
|
||||
"create-cron",
|
||||
"--app",
|
||||
&app,
|
||||
"--script",
|
||||
&script_id,
|
||||
"--schedule",
|
||||
"0 0 * * * *",
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Secret (name only ends up in the manifest; value stays server-side).
|
||||
common::pic_as(&env)
|
||||
.args(["secrets", "set", "--app", &app, "api_key"])
|
||||
.write_stdin("xyzzy")
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull into a scratch dir.
|
||||
let out_dir = TempDir::new().expect("pull tempdir");
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(out_dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Manifest exists and captures every resource.
|
||||
let manifest = std::fs::read_to_string(out_dir.path().join("picloud.toml"))
|
||||
.expect("picloud.toml should be written");
|
||||
assert!(
|
||||
manifest.contains(&format!("slug = \"{app}\"")),
|
||||
"manifest missing app slug:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("name = \"hello\"") && manifest.contains("scripts/hello.rhai"),
|
||||
"manifest missing script entry:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("[[routes]]") && manifest.contains("path = \"/hook\""),
|
||||
"manifest missing route:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("[[triggers.cron]]") && manifest.contains("schedule = \"0 0 * * * *\""),
|
||||
"manifest missing cron trigger:\n{manifest}"
|
||||
);
|
||||
assert!(
|
||||
manifest.contains("api_key"),
|
||||
"manifest missing secret name:\n{manifest}"
|
||||
);
|
||||
|
||||
// Script source was written out faithfully.
|
||||
let src = std::fs::read_to_string(out_dir.path().join("scripts/hello.rhai"))
|
||||
.expect("scripts/hello.rhai should be written");
|
||||
assert!(
|
||||
src.contains("hello from pic"),
|
||||
"exported source mismatch:\n{src}"
|
||||
);
|
||||
|
||||
drop(guard);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ fn viewer_cannot_deploy_but_editor_can() {
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Created hello v1"));
|
||||
.stdout(predicate::str::contains("hello").and(predicate::str::contains("created")));
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
|
||||
@@ -7,6 +7,16 @@ use predicates::prelude::*;
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
/// Extract a field value from a `pic` KvBlock (`key<pad>\tvalue` per line).
|
||||
/// `pic scripts deploy` prints `name`/`version`/`action` rows, not a prose
|
||||
/// "Created X vN" line, so version-bump tests assert on these fields.
|
||||
fn kv_field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> {
|
||||
stdout.lines().find_map(|l| {
|
||||
let (k, v) = l.split_once('\t')?;
|
||||
(k.trim() == key).then(|| v.trim())
|
||||
})
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn deploy_against_unknown_app_errors() {
|
||||
@@ -48,7 +58,7 @@ fn deploy_with_name_override() {
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
common::pic_as(&env)
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -58,11 +68,14 @@ fn deploy_with_name_override() {
|
||||
"--name",
|
||||
"custom-name",
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Created custom-name v1"));
|
||||
.output()
|
||||
.expect("deploy v1");
|
||||
let v1 = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(kv_field(&v1, "name"), Some("custom-name"), "v1 name: {v1}");
|
||||
assert_eq!(kv_field(&v1, "version"), Some("1"), "v1 version: {v1}");
|
||||
assert_eq!(kv_field(&v1, "action"), Some("created"), "v1 action: {v1}");
|
||||
|
||||
common::pic_as(&env)
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -72,9 +85,11 @@ fn deploy_with_name_override() {
|
||||
"--name",
|
||||
"custom-name",
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Updated custom-name v2"));
|
||||
.output()
|
||||
.expect("deploy v2");
|
||||
let v2 = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(kv_field(&v2, "version"), Some("2"), "v2 version: {v2}");
|
||||
assert_eq!(kv_field(&v2, "action"), Some("updated"), "v2 action: {v2}");
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &slug])
|
||||
@@ -106,8 +121,8 @@ fn deploy_bumps_version_each_redeploy() {
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let fixture = common::fixture_path("hello.rhai");
|
||||
for expected in ["Created hello v1", "Updated hello v2", "Updated hello v3"] {
|
||||
common::pic_as(&env)
|
||||
for (version, action) in [("1", "created"), ("2", "updated"), ("3", "updated")] {
|
||||
let out = common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
@@ -115,9 +130,20 @@ fn deploy_bumps_version_each_redeploy() {
|
||||
"--app",
|
||||
&slug,
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains(expected));
|
||||
.output()
|
||||
.expect("deploy");
|
||||
assert!(out.status.success(), "deploy v{version} failed: {out:?}");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert_eq!(
|
||||
kv_field(&stdout, "version"),
|
||||
Some(version),
|
||||
"version: {stdout}"
|
||||
);
|
||||
assert_eq!(
|
||||
kv_field(&stdout, "action"),
|
||||
Some(action),
|
||||
"action: {stdout}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ fn set_ls_rm_round_trip() {
|
||||
.expect("secrets ls");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let header = stdout.lines().next().expect("header");
|
||||
assert_eq!(common::cells(header), vec!["name", "updated_at"]);
|
||||
assert_eq!(common::cells(header), vec!["name", "env", "updated_at"]);
|
||||
assert!(
|
||||
stdout.lines().skip(1).any(|l| l.starts_with("api_key")),
|
||||
"api_key missing from ls: {stdout}"
|
||||
|
||||
82
crates/picloud-cli/tests/staleness.rs
Normal file
82
crates/picloud-cli/tests/staleness.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
//! Bound-plan staleness: `pic plan` records a state token under `.picloud/`,
|
||||
//! and a later `pic apply` refuses (without `--force`) if the app changed
|
||||
//! out-of-band since the plan was reviewed.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::AppGuard;
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_refuses_when_state_moved_since_plan() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("stale");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), "let x = 1; x").unwrap();
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Stale\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// Establish hello, then plan — the plan records the current state token.
|
||||
apply(&env, &manifest_path).assert().success();
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
dir.path().join(".picloud/plan.json").exists(),
|
||||
"plan must record the bound-plan token under .picloud/"
|
||||
);
|
||||
|
||||
// Out-of-band change: deploy an extra script the manifest doesn't mention.
|
||||
fs::write(dir.path().join("scripts/sneaky.rhai"), "let y = 2; y").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/sneaky.rhai"))
|
||||
.args(["--app", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply must now refuse — the live state no longer matches the plan.
|
||||
let refused = apply(&env, &manifest_path).output().expect("apply");
|
||||
assert!(
|
||||
!refused.status.success(),
|
||||
"apply must refuse after out-of-band change"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&refused.stderr);
|
||||
assert!(
|
||||
err.contains("changed since") || err.contains("pic plan"),
|
||||
"refusal should explain the staleness:\n{err}"
|
||||
);
|
||||
|
||||
// `--force` bypasses the check and applies anyway.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, manifest_path: &std::path::Path) -> assert_cmd::Command {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--file"]).arg(manifest_path);
|
||||
cmd
|
||||
}
|
||||
84
crates/picloud-cli/tests/vars.rs
Normal file
84
crates/picloud-cli/tests/vars.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! Phase-3 config `vars`, end to end via `pic`: a group-owned var is
|
||||
//! inherited by an app underneath it, and an app-owned var of the same key
|
||||
//! overrides the inherited value (proximity wins, §3).
|
||||
//!
|
||||
//! Drives the real resolution path: a script does `vars::get("region")`
|
||||
//! and returns it; we invoke it via `/api/v1/execute/{id}` and assert on
|
||||
//! the body. First the group value flows down (inheritance), then an app
|
||||
//! value shadows it (override).
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn group_var_is_inherited_then_app_value_overrides() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let acme = common::unique_slug("v-acme");
|
||||
let app = common::unique_slug("v-app");
|
||||
|
||||
// Group `acme`, then a `region` var on it (a JSON string "eu").
|
||||
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["vars", "set", "region", "eu", "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// App under acme with a script that reads + returns the resolved var.
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &acme])
|
||||
.assert()
|
||||
.success();
|
||||
let fixture = common::fixture_path("read-var.rhai");
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"scripts",
|
||||
"deploy",
|
||||
fixture.to_str().unwrap(),
|
||||
"--app",
|
||||
&app,
|
||||
])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Resolve the deployed script's id.
|
||||
let ls = common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--app", &app])
|
||||
.output()
|
||||
.expect("scripts ls");
|
||||
let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("scripts ls should produce one row");
|
||||
|
||||
// Inherited: the app has no own `region`, so the group's "eu" resolves.
|
||||
assert_eq!(invoke_body(&env, &id), serde_json::json!("eu"), "inherited");
|
||||
|
||||
// Proximity override: an app-owned `region` shadows the group value.
|
||||
common::pic_as(&env)
|
||||
.args(["vars", "set", "region", "us", "--app", &app])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(invoke_body(&env, &id), serde_json::json!("us"), "override");
|
||||
}
|
||||
|
||||
/// Invoke a script via `pic scripts invoke <id>` (→ `/api/v1/execute/{id}`)
|
||||
/// and parse its JSON body.
|
||||
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||
let out = common::pic_as(env)
|
||||
.args(["scripts", "invoke", id])
|
||||
.output()
|
||||
.expect("scripts invoke");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"invoke failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||
}
|
||||
@@ -10,28 +10,31 @@ use axum::middleware::from_fn_with_state;
|
||||
use axum::{routing::get, Json, Router};
|
||||
use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apps_api, apps_router,
|
||||
attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||
dev_emails_router, email_inbound_router, files_admin_router, kv_admin_router, migrations,
|
||||
require_authenticated, route_admin_router, secrets_router, topics_router, triggers_router,
|
||||
AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, AdminUserRepository,
|
||||
AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository,
|
||||
AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, DeadLetterRepo,
|
||||
DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState,
|
||||
EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, FsFilesRepo, HttpConfig,
|
||||
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
|
||||
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
|
||||
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
|
||||
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
|
||||
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
|
||||
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
|
||||
PostgresExecutionLogSink, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
admin_router, admins_router, api_keys_router, app_members_router, apply_router, apps_api,
|
||||
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
|
||||
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
|
||||
migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
|
||||
triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
|
||||
AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
|
||||
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
|
||||
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
|
||||
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
|
||||
FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState,
|
||||
HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl,
|
||||
OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
|
||||
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
|
||||
PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
|
||||
PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
|
||||
PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
|
||||
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository,
|
||||
PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
|
||||
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
|
||||
PostgresTriggerRepo, PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver,
|
||||
RouteAdminState, RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig,
|
||||
SecretsServiceImpl, SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig,
|
||||
TriggerRepo, TriggersState, UsersServiceConfig, UsersServiceImpl,
|
||||
PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl,
|
||||
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
|
||||
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig,
|
||||
UsersServiceImpl, VarsApiState, VarsServiceImpl,
|
||||
};
|
||||
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
@@ -42,8 +45,8 @@ use picloud_orchestrator_core::{
|
||||
use picloud_shared::{
|
||||
DeadLetterService, DocsService, EmailService, ExecutionLogSink, FilesService, HttpService,
|
||||
InboxResolver, KvService, MasterKey, OutboxWriter, PubsubService, RealtimeAuthority,
|
||||
RealtimeBroadcaster, ScriptValidator, SecretsService, ServiceEventEmitter, Services,
|
||||
UsersService, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
||||
RealtimeBroadcaster, SecretsService, ServiceEventEmitter, Services, UsersService, API_VERSION,
|
||||
PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
||||
};
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
@@ -109,6 +112,10 @@ pub async fn build_app(
|
||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||
let groups_repo: Arc<dyn GroupRepository> =
|
||||
Arc::new(PostgresGroupRepository::new(pool.clone()));
|
||||
let group_members_repo: Arc<dyn GroupMembersRepository> =
|
||||
Arc::new(PostgresGroupMembersRepository::new(pool.clone()));
|
||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||
Arc::new(PostgresAppDomainRepository::new(pool.clone()));
|
||||
// The Postgres app_members repo implements both `AppMembersRepository`
|
||||
@@ -316,6 +323,8 @@ pub async fn build_app(
|
||||
// under script-as-gate semantics.
|
||||
.with_authz(authz.clone()),
|
||||
);
|
||||
let vars: Arc<dyn picloud_shared::VarsService> =
|
||||
Arc::new(VarsServiceImpl::new(pool.clone(), authz.clone()));
|
||||
let services = Services::new(
|
||||
kv,
|
||||
docs,
|
||||
@@ -330,6 +339,7 @@ pub async fn build_app(
|
||||
users.clone(),
|
||||
queue,
|
||||
invoke,
|
||||
vars,
|
||||
);
|
||||
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
|
||||
// trigger-depth bound (same counter under the hood).
|
||||
@@ -399,7 +409,7 @@ pub async fn build_app(
|
||||
logs: log_repo,
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine as Arc<dyn ScriptValidator>,
|
||||
validator: engine.clone(),
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let route_admin = RouteAdminState {
|
||||
@@ -414,7 +424,7 @@ pub async fn build_app(
|
||||
resolver,
|
||||
log_sink,
|
||||
app_domains: app_domain_table.clone(),
|
||||
routes: route_table,
|
||||
routes: route_table.clone(),
|
||||
inbox: inbox_registry,
|
||||
outbox: outbox_writer,
|
||||
};
|
||||
@@ -440,7 +450,7 @@ pub async fn build_app(
|
||||
// v1.1.4: cron scheduler. Polls cron_trigger_details on a tick and
|
||||
// enqueues due triggers into the outbox; the dispatcher above
|
||||
// delivers them like any other async trigger.
|
||||
picloud_manager_core::spawn_cron_scheduler(pool, trigger_config.cron_tick_interval_ms);
|
||||
picloud_manager_core::spawn_cron_scheduler(pool.clone(), trigger_config.cron_tick_interval_ms);
|
||||
// v1.1.6: GC empty realtime broadcast channels (one-shot subscribers)
|
||||
// and sweep orphaned `*.tmp.*` blobs left by crashed file writes.
|
||||
spawn_realtime_gc(broadcaster_concrete, DEFAULT_GC_INTERVAL_SECS);
|
||||
@@ -453,6 +463,23 @@ pub async fn build_app(
|
||||
config: trigger_config,
|
||||
master_key: master_key.clone(),
|
||||
};
|
||||
// Declarative reconcile engine (pic plan / apply). Trait-object repos
|
||||
// for the read/diff path; shares the same handles as the CRUD routers.
|
||||
let apply_service = ApplyService {
|
||||
pool: pool.clone(),
|
||||
scripts: script_repo.clone(),
|
||||
routes: route_repo.clone(),
|
||||
triggers: trigger_repo.clone(),
|
||||
secrets: secrets_repo.clone(),
|
||||
apps: apps_repo.clone(),
|
||||
domains: domains_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine.clone(),
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
trigger_config,
|
||||
route_table: route_table.clone(),
|
||||
master_key: master_key.clone(),
|
||||
};
|
||||
// v1.1.9: keep a clone for the queues-api state (built later).
|
||||
let trigger_repo_for_queues = trigger_repo.clone();
|
||||
// v1.1.7 public inbound-email receiver. Outside the admin auth layer
|
||||
@@ -486,6 +513,7 @@ pub async fn build_app(
|
||||
let secrets_state = SecretsState {
|
||||
repo: secrets_repo,
|
||||
apps: apps_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
master_key,
|
||||
max_value_bytes: secrets_max_value_bytes,
|
||||
@@ -496,6 +524,7 @@ pub async fn build_app(
|
||||
routes: route_repo,
|
||||
domain_table: app_domain_table.clone(),
|
||||
authz: authz.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
};
|
||||
|
||||
// Audit 2026-06-11 H-B1 — login DoS defenses. PICLOUD_LOGIN_ARGON2_PARALLELISM
|
||||
@@ -535,6 +564,24 @@ pub async fn build_app(
|
||||
members,
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let groups_state = GroupsState {
|
||||
groups: groups_repo.clone(),
|
||||
group_members: group_members_repo.clone(),
|
||||
apps: apps_state.apps.clone(),
|
||||
users: auth.users.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let vars_state = VarsApiState {
|
||||
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
|
||||
apps: apps_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let config_state = picloud_manager_core::ConfigApiState {
|
||||
pool: pool.clone(),
|
||||
apps: apps_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let app_users_admin_state = picloud_manager_core::AppUsersState {
|
||||
apps: apps_state.apps.clone(),
|
||||
authz: authz.clone(),
|
||||
@@ -557,11 +604,15 @@ pub async fn build_app(
|
||||
.merge(admins_router(admins_state))
|
||||
.merge(apps_router(apps_state))
|
||||
.merge(app_members_router(app_members_state))
|
||||
.merge(groups_router(groups_state))
|
||||
.merge(vars_router(vars_state))
|
||||
.merge(picloud_manager_core::config_router(config_state))
|
||||
.merge(picloud_manager_core::app_users_router(
|
||||
app_users_admin_state,
|
||||
))
|
||||
.merge(api_keys_router(api_keys_state))
|
||||
.merge(triggers_router(triggers_state))
|
||||
.merge(apply_router(apply_service))
|
||||
.merge(picloud_manager_core::queues_api::queues_router(
|
||||
picloud_manager_core::queues_api::QueuesState {
|
||||
queues: queue_repo.clone(),
|
||||
|
||||
@@ -9,7 +9,7 @@ use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::AppId;
|
||||
use crate::{AppId, GroupId};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct App {
|
||||
@@ -20,6 +20,9 @@ pub struct App {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Parent group in the org tree (Phase 2). Every app has a parent
|
||||
/// from day one (§9 backfill seeds the instance root group).
|
||||
pub group_id: GroupId,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -98,6 +98,30 @@ impl AppRole {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authority rank: higher = more authority. Used to fold the highest
|
||||
/// effective role across an app's own membership and every ancestor
|
||||
/// group membership (hierarchy-aware RBAC). Defined explicitly rather
|
||||
/// than via a derived `Ord` because the variant declaration order
|
||||
/// (`AppAdmin` first) is the reverse of authority order.
|
||||
#[must_use]
|
||||
pub const fn precedence(self) -> u8 {
|
||||
match self {
|
||||
Self::AppAdmin => 3,
|
||||
Self::Editor => 2,
|
||||
Self::Viewer => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The more-authoritative of two roles. `app_admin > editor > viewer`.
|
||||
#[must_use]
|
||||
pub fn max(self, other: Self) -> Self {
|
||||
if self.precedence() >= other.precedence() {
|
||||
self
|
||||
} else {
|
||||
other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// API-key scope. Exactly seven values; new scopes need a blueprint
|
||||
|
||||
31
crates/shared/src/group.rs
Normal file
31
crates/shared/src/group.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
//! Groups: a GitLab-like, single-parent org tree ABOVE apps (Phase 2).
|
||||
//!
|
||||
//! Groups are a pure org / RBAC / UI container — they own no resources
|
||||
//! (scripts/secrets stay app-owned). A `group_admin` on any ancestor is
|
||||
//! implicitly app_admin on every app/subgroup beneath it; resolution
|
||||
//! takes the highest effective role across the app's own membership and
|
||||
//! all ancestor group memberships.
|
||||
//!
|
||||
//! See docs/design/groups-and-project-tool.md §5.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::GroupId;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Group {
|
||||
pub id: GroupId,
|
||||
/// `None` for a root node. Single-parent keeps the tree acyclic.
|
||||
pub parent_id: Option<GroupId>,
|
||||
/// Instance-global identifier, frozen at creation (a rename/reparent
|
||||
/// never rewrites it — the deployment key stays stable).
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Per-subtree structure version, bumped on every structural mutation
|
||||
/// (reparent/rename/delete). Not an authz input.
|
||||
pub structure_version: i64,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -57,3 +57,4 @@ id_type!(TriggerId);
|
||||
id_type!(AppUserId);
|
||||
id_type!(InvitationId);
|
||||
id_type!(QueueMessageId);
|
||||
id_type!(GroupId);
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod events;
|
||||
pub mod exec_summary;
|
||||
pub mod execution_log;
|
||||
pub mod files;
|
||||
pub mod group;
|
||||
pub mod http;
|
||||
pub mod ids;
|
||||
pub mod inbox;
|
||||
@@ -37,8 +38,17 @@ pub mod subscriber_token;
|
||||
pub mod trigger_event;
|
||||
pub mod users;
|
||||
pub mod validator;
|
||||
pub mod vars;
|
||||
pub mod version;
|
||||
|
||||
/// serde `default` for `enabled`-style boolean fields that should default to
|
||||
/// `true` when absent from the wire (bool's own `Default` is `false`). Shared
|
||||
/// by `Script`/`Route`, the apply `Bundle` types, and the CLI manifest.
|
||||
#[must_use]
|
||||
pub fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
pub use app::{App, AppDomain, DomainShape};
|
||||
pub use auth::{AppRole, InstanceRole, Principal, Scope, UserId};
|
||||
pub use crypto::{decrypt, encrypt, CryptoError, EncryptResult, MasterKey, MasterKeyError};
|
||||
@@ -54,10 +64,11 @@ pub use files::{
|
||||
FileUpdate, FilesError, FilesListPage, FilesService, NewFile, NoopFilesService,
|
||||
SAFE_RENDER_FALLBACK,
|
||||
};
|
||||
pub use group::Group;
|
||||
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
|
||||
pub use ids::{
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, InvitationId, QueueMessageId, RequestId,
|
||||
ScriptId, TriggerId,
|
||||
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
|
||||
RequestId, ScriptId, TriggerId,
|
||||
};
|
||||
pub use inbox::{
|
||||
InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult, NoopInboxResolver,
|
||||
@@ -91,4 +102,5 @@ pub use users::{
|
||||
UsersService,
|
||||
};
|
||||
pub use validator::{ScriptValidator, ValidatedScript, ValidationError};
|
||||
pub use vars::{NoopVarsService, VarsError, VarsService};
|
||||
pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION};
|
||||
|
||||
@@ -99,5 +99,11 @@ pub struct Route {
|
||||
#[serde(default)]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
|
||||
/// Three-state lifecycle (§4.3): `false` means the route is deployed but
|
||||
/// inert — it is dropped from the compiled match table, so a request 404s
|
||||
/// indistinguishably from an absent route. Defaults `true`.
|
||||
#[serde(default = "crate::default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -122,6 +122,12 @@ pub struct Script {
|
||||
/// have to add it back when that's built.
|
||||
pub memory_limit_mb: u32,
|
||||
|
||||
/// Three-state lifecycle (§4.3): `false` means deployed-but-inert — the
|
||||
/// script is not invocable (route 404s, trigger doesn't fire) but stays
|
||||
/// as desired state (not pruned). Defaults `true`.
|
||||
#[serde(default = "crate::default_true")]
|
||||
pub enabled: bool,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
@@ -24,7 +24,8 @@ use crate::{
|
||||
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
|
||||
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
|
||||
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
|
||||
PubsubService, QueueService, SecretsService, ServiceEventEmitter, UsersService,
|
||||
NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
|
||||
UsersService, VarsService,
|
||||
};
|
||||
|
||||
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
|
||||
@@ -107,6 +108,12 @@ pub struct Services {
|
||||
/// and `invoke_async()` (fire-and-forget through the outbox).
|
||||
/// Cross-app invokes are rejected at the service entry point.
|
||||
pub invoke: Arc<dyn InvokeService>,
|
||||
|
||||
/// Group-inherited, env-scoped config (Phase 3). Scripts get
|
||||
/// read-only `vars::{get,all}`; values resolve down the group tree
|
||||
/// (§3). Backed by `config_resolver` over Postgres in the picloud
|
||||
/// binary; `NoopVarsService` in tests that don't read config.
|
||||
pub vars: Arc<dyn VarsService>,
|
||||
}
|
||||
|
||||
impl Services {
|
||||
@@ -129,6 +136,7 @@ impl Services {
|
||||
users: Arc<dyn UsersService>,
|
||||
queue: Arc<dyn QueueService>,
|
||||
invoke: Arc<dyn InvokeService>,
|
||||
vars: Arc<dyn VarsService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
kv,
|
||||
@@ -144,6 +152,7 @@ impl Services {
|
||||
users,
|
||||
queue,
|
||||
invoke,
|
||||
vars,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +177,7 @@ impl Services {
|
||||
Arc::new(NoopUsersService),
|
||||
Arc::new(NoopQueueService),
|
||||
Arc::new(NoopInvokeService),
|
||||
Arc::new(NoopVarsService),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
48
crates/shared/src/vars.rs
Normal file
48
crates/shared/src/vars.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! `vars::*` — read-only access from scripts to the app's resolved
|
||||
//! configuration (Phase 3). Values are inherited down the group tree and
|
||||
//! env-filtered per the §3 resolution rule; the resolution happens in
|
||||
//! manager-core (`config_resolver`). Writes go through the admin API, not
|
||||
//! the SDK — scripts only read.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::SdkCallCx;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VarsError {
|
||||
/// Caller principal lacked `AppVarsRead`. Only raised when
|
||||
/// `cx.principal.is_some()` (public-HTTP scripts skip the check —
|
||||
/// script-as-gate).
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
/// Postgres unavailable, malformed row, etc.
|
||||
#[error("vars backend error: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait VarsService: Send + Sync {
|
||||
/// Resolve a single config key for the calling app's environment.
|
||||
/// `None` if no level defines it (or a tombstone suppresses it).
|
||||
async fn get(&self, cx: &SdkCallCx, key: &str) -> Result<Option<Value>, VarsError>;
|
||||
|
||||
/// The app's fully-resolved config map (every inherited + own key).
|
||||
async fn all(&self, cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError>;
|
||||
}
|
||||
|
||||
/// All-noop fallback for engines that don't wire vars (tests). Every call
|
||||
/// surfaces an explicit error rather than silently returning empty.
|
||||
pub struct NoopVarsService;
|
||||
|
||||
#[async_trait]
|
||||
impl VarsService for NoopVarsService {
|
||||
async fn get(&self, _cx: &SdkCallCx, _key: &str) -> Result<Option<Value>, VarsError> {
|
||||
Err(VarsError::Backend("vars is not wired in".into()))
|
||||
}
|
||||
async fn all(&self, _cx: &SdkCallCx) -> Result<BTreeMap<String, Value>, VarsError> {
|
||||
Err(VarsError::Backend("vars is not wired in".into()))
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,47 @@ export interface App {
|
||||
|
||||
export type AppRole = 'app_admin' | 'editor' | 'viewer';
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
structure_version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface GroupDetail extends Group {
|
||||
/** Root → … → this node breadcrumb. */
|
||||
path: Group[];
|
||||
subgroups: Group[];
|
||||
apps: App[];
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
instance_role: InstanceRole;
|
||||
is_active: boolean;
|
||||
role: AppRole;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateGroupInput {
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
/** Parent group slug or id; omit (or null) for a root group. */
|
||||
parent?: string | null;
|
||||
}
|
||||
|
||||
export interface PatchGroupInput {
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export type DomainShape = 'exact' | 'wildcard' | 'parameterized';
|
||||
|
||||
export interface AppDomain {
|
||||
@@ -89,6 +130,8 @@ export interface CreateAppInput {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
force_takeover?: boolean;
|
||||
/** Parent group slug or id; omit for the root group. */
|
||||
group?: string | null;
|
||||
}
|
||||
|
||||
export interface PatchAppInput {
|
||||
@@ -778,6 +821,52 @@ export const api = {
|
||||
)
|
||||
},
|
||||
|
||||
groups: {
|
||||
list: () => adminRequest<Group[]>('/api/v1/admin/groups'),
|
||||
get: (idOrSlug: string) =>
|
||||
adminRequest<GroupDetail>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`),
|
||||
create: (input: CreateGroupInput) =>
|
||||
adminRequest<Group>('/api/v1/admin/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
update: (idOrSlug: string, input: PatchGroupInput) =>
|
||||
adminRequest<Group>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(input)
|
||||
}),
|
||||
reparent: (idOrSlug: string, parent: string | null) =>
|
||||
adminRequest<Group>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/reparent`,
|
||||
{ method: 'POST', body: JSON.stringify({ parent }) }
|
||||
),
|
||||
delete: (idOrSlug: string) =>
|
||||
adminRequest<null>(`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
members: {
|
||||
list: (idOrSlug: string) =>
|
||||
adminRequest<GroupMember[]>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`
|
||||
),
|
||||
grant: (idOrSlug: string, input: GrantAppMemberInput) =>
|
||||
adminRequest<GroupMember>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members`,
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
),
|
||||
update: (idOrSlug: string, userId: string, role: AppRole) =>
|
||||
adminRequest<GroupMember>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify({ role }) }
|
||||
),
|
||||
remove: (idOrSlug: string, userId: string) =>
|
||||
adminRequest<null>(
|
||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
domains: {
|
||||
listForApp: (idOrSlug: string) =>
|
||||
adminRequest<AppDomain[]>(
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<a href={base + '/'} class="brand">PiCloud</a>
|
||||
<nav>
|
||||
<a href={base + '/apps'}>Apps</a>
|
||||
<a href={base + '/groups'}>Groups</a>
|
||||
{#if user && user.instance_role !== 'member'}
|
||||
<a href={base + '/users'}>Users</a>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { api, ApiError, type App } from '$lib/api';
|
||||
import { api, ApiError, type App, type Group } from '$lib/api';
|
||||
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||
import { canCreateApp } from '$lib/capabilities';
|
||||
import { currentUser } from '$lib/auth';
|
||||
@@ -36,6 +36,17 @@
|
||||
let createSlug = $state('');
|
||||
let createName = $state('');
|
||||
let createDescription = $state('');
|
||||
// Optional parent group for the new app (defaults to root). Loaded
|
||||
// lazily; failure leaves the picker empty (root-only).
|
||||
let createGroup = $state('');
|
||||
let groups = $state<Group[]>([]);
|
||||
async function loadGroups() {
|
||||
try {
|
||||
groups = await api.groups.list();
|
||||
} catch {
|
||||
groups = [];
|
||||
}
|
||||
}
|
||||
// Auto-derive slug from name until the user takes manual control of
|
||||
// the slug field. Clearing the slug input releases the lock so the
|
||||
// auto-derive resumes — matches the GitLab project-create UX.
|
||||
@@ -69,6 +80,7 @@
|
||||
listError = null;
|
||||
try {
|
||||
apps = await api.apps.list();
|
||||
void loadGroups();
|
||||
if (apps && apps.length > 0) {
|
||||
void loadDlCounts(apps);
|
||||
}
|
||||
@@ -84,6 +96,7 @@
|
||||
createSlug = '';
|
||||
createName = '';
|
||||
createDescription = '';
|
||||
createGroup = '';
|
||||
createError = null;
|
||||
createHistoricalConflict = null;
|
||||
slugTouched = false;
|
||||
@@ -99,7 +112,8 @@
|
||||
slug: createSlug.trim(),
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || null,
|
||||
force_takeover: forceTakeover || undefined
|
||||
force_takeover: forceTakeover || undefined,
|
||||
group: createGroup.trim() || undefined
|
||||
});
|
||||
showCreate = false;
|
||||
resetCreate();
|
||||
@@ -172,6 +186,15 @@
|
||||
<span>Description</span>
|
||||
<input bind:value={createDescription} placeholder="optional" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Group (optional)</span>
|
||||
<select bind:value={createGroup}>
|
||||
<option value="">— root —</option>
|
||||
{#each groups as g (g.id)}
|
||||
<option value={g.slug}>{g.name} (/{g.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if createHistoricalConflict}
|
||||
<div class="warning">
|
||||
<strong>Slug previously redirected.</strong>
|
||||
|
||||
366
dashboard/src/routes/groups/+page.svelte
Normal file
366
dashboard/src/routes/groups/+page.svelte
Normal file
@@ -0,0 +1,366 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { api, ApiError, type Group } from '$lib/api';
|
||||
import { slugify, SLUG_MAX } from '$lib/slugify';
|
||||
import { canCreateApp } from '$lib/capabilities';
|
||||
import { currentUser } from '$lib/auth';
|
||||
|
||||
const me = $derived($currentUser);
|
||||
// Group creation mirrors app creation's gate — owner/admin only.
|
||||
const canCreate = $derived(canCreateApp(me));
|
||||
|
||||
let groups = $state<Group[] | null>(null);
|
||||
let listError = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
// Tree assembly: index children by parent_id so we can render the
|
||||
// flat list as a nested tree. Roots have parent_id === null.
|
||||
interface TreeNode {
|
||||
group: Group;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
const tree = $derived.by<TreeNode[]>(() => {
|
||||
if (!groups) return [];
|
||||
const byParent = new Map<string | null, Group[]>();
|
||||
for (const g of groups) {
|
||||
const key = g.parent_id;
|
||||
const bucket = byParent.get(key) ?? [];
|
||||
bucket.push(g);
|
||||
byParent.set(key, bucket);
|
||||
}
|
||||
const build = (parentId: string | null): TreeNode[] =>
|
||||
(byParent.get(parentId) ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((group) => ({ group, children: build(group.id) }));
|
||||
return build(null);
|
||||
});
|
||||
|
||||
// Collapse state, keyed by group id. Default: expanded.
|
||||
let collapsed = $state<Record<string, boolean>>({});
|
||||
function toggle(id: string) {
|
||||
collapsed = { ...collapsed, [id]: !collapsed[id] };
|
||||
}
|
||||
|
||||
// Create form
|
||||
let showCreate = $state(false);
|
||||
let createSlug = $state('');
|
||||
let createName = $state('');
|
||||
let createDescription = $state('');
|
||||
let createParent = $state('');
|
||||
let slugTouched = $state(false);
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
function onNameInput(event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
createName = value;
|
||||
if (!slugTouched) {
|
||||
createSlug = slugify(value);
|
||||
}
|
||||
}
|
||||
|
||||
function onSlugInput(event: Event) {
|
||||
const raw = (event.target as HTMLInputElement).value;
|
||||
const normalized = slugify(raw);
|
||||
createSlug = normalized;
|
||||
if (raw !== normalized) {
|
||||
(event.target as HTMLInputElement).value = normalized;
|
||||
}
|
||||
slugTouched = normalized.length > 0;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
listError = null;
|
||||
try {
|
||||
groups = await api.groups.list();
|
||||
} catch (e) {
|
||||
listError = e instanceof Error ? e.message : String(e);
|
||||
groups = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreate() {
|
||||
createSlug = '';
|
||||
createName = '';
|
||||
createDescription = '';
|
||||
createParent = '';
|
||||
createError = null;
|
||||
slugTouched = false;
|
||||
}
|
||||
|
||||
async function submitCreate(event: Event) {
|
||||
event.preventDefault();
|
||||
creating = true;
|
||||
createError = null;
|
||||
try {
|
||||
await api.groups.create({
|
||||
slug: createSlug.trim(),
|
||||
name: createName.trim(),
|
||||
description: createDescription.trim() || null,
|
||||
parent: createParent.trim() || undefined
|
||||
});
|
||||
showCreate = false;
|
||||
resetCreate();
|
||||
await load();
|
||||
} catch (e) {
|
||||
createError =
|
||||
e instanceof ApiError ? e.message : e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<header class="page-header">
|
||||
<h1>Groups</h1>
|
||||
{#if canCreate}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showCreate = !showCreate;
|
||||
if (!showCreate) resetCreate();
|
||||
}}
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New group'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showCreate && canCreate}
|
||||
<form class="create-form" onsubmit={submitCreate}>
|
||||
<div class="row">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input value={createName} oninput={onNameInput} required placeholder="My Group" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Slug</span>
|
||||
<input
|
||||
value={createSlug}
|
||||
oninput={onSlugInput}
|
||||
required
|
||||
pattern="[a-z0-9][a-z0-9-]*"
|
||||
maxlength={SLUG_MAX}
|
||||
placeholder="my-group"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
<span>Parent group (optional)</span>
|
||||
<select bind:value={createParent}>
|
||||
<option value="">— root —</option>
|
||||
{#each groups ?? [] as g (g.id)}
|
||||
<option value={g.slug}>{g.name} (/{g.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Description</span>
|
||||
<input bind:value={createDescription} placeholder="optional" />
|
||||
</label>
|
||||
{#if createError}
|
||||
<div class="error">{createError}</div>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button type="submit" disabled={creating}>
|
||||
{creating ? 'Creating…' : 'Create group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p class="muted">Loading…</p>
|
||||
{:else if listError}
|
||||
<div class="error">
|
||||
<strong>Could not load groups.</strong>
|
||||
<p>{listError}</p>
|
||||
<button type="button" onclick={() => void load()}>Retry</button>
|
||||
</div>
|
||||
{:else if groups && groups.length === 0}
|
||||
<p class="muted">No groups yet. Create one above to get started.</p>
|
||||
{:else if groups}
|
||||
<ul class="tree">
|
||||
{#each tree as node (node.group.id)}
|
||||
{@render branch(node, 0)}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#snippet branch(node: TreeNode, depth: number)}
|
||||
<li>
|
||||
<div class="node" style="padding-left: {depth * 1.25}rem">
|
||||
{#if node.children.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="twisty"
|
||||
aria-label={collapsed[node.group.id] ? 'Expand' : 'Collapse'}
|
||||
onclick={() => toggle(node.group.id)}
|
||||
>
|
||||
{collapsed[node.group.id] ? '▸' : '▾'}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="twisty-spacer"></span>
|
||||
{/if}
|
||||
<a href="{base}/groups/{node.group.slug}">
|
||||
<strong>{node.group.name}</strong>
|
||||
<span class="muted">/{node.group.slug}</span>
|
||||
</a>
|
||||
</div>
|
||||
{#if node.children.length > 0 && !collapsed[node.group.id]}
|
||||
<ul>
|
||||
{#each node.children as child (child.group.id)}
|
||||
{@render branch(child, depth + 1)}
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<style>
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #38bdf8;
|
||||
color: #0b1220;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error {
|
||||
border: 1px solid #b91c1c;
|
||||
background: #450a0a;
|
||||
color: #fecaca;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
background: #1e293b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form .row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.create-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.create-form input {
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tree {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tree ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.node a {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: baseline;
|
||||
padding: 0.55rem 0.75rem;
|
||||
background: #1e293b;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.node a:hover {
|
||||
background: #283549;
|
||||
}
|
||||
|
||||
.twisty {
|
||||
background: transparent;
|
||||
color: #94a3b8;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 1.25rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 400;
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.twisty-spacer {
|
||||
width: 1.25rem;
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user