Compare commits
32 Commits
feat/group
...
aa2831da90
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2831da90 | ||
|
|
22d6c33d0b | ||
|
|
c04c684a2e | ||
|
|
7c17d6e363 | ||
|
|
4d2eed4e81 | ||
|
|
0396866698 | ||
|
|
193336a8a6 | ||
|
|
c18ce7c2c4 | ||
|
|
dc8c69ac6f | ||
|
|
4dcb8d1136 | ||
|
|
52da8a8704 | ||
|
|
c8acac1d20 | ||
|
|
b53eabb786 | ||
|
|
1844bef132 | ||
|
|
7fef098b48 | ||
|
|
3ef3038eb7 | ||
|
|
1662d7806a | ||
|
|
8a559ea178 | ||
|
|
da03fda1d6 | ||
|
|
65049a6262 | ||
|
|
99e7100f0b | ||
|
|
9598db149b | ||
|
|
c29e4b9c53 | ||
|
|
701ec90b56 | ||
|
|
719a17432f | ||
|
|
43ed4e8e7f | ||
|
|
48178c5f60 | ||
|
|
27dc04819f | ||
|
|
181622a84a | ||
|
|
34dcae98a2 | ||
|
|
e517f6dc8c | ||
|
|
b2822c8435 |
@@ -10,9 +10,9 @@ Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint
|
||||
|
||||
**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).
|
||||
**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), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them). Next: §5.5 **opt-in extension points** (the one deferred §5.5 piece) or §11.6 (group-level collections, v1.3).
|
||||
|
||||
**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).
|
||||
**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 tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary).
|
||||
|
||||
## Three-Service Architecture
|
||||
|
||||
|
||||
@@ -300,11 +300,18 @@ impl Engine {
|
||||
// installed with the real per-call resolver. The resolver owns
|
||||
// `cx.clone()` so cross-app isolation derives from this exact
|
||||
// call's context, not from any script-passed argument.
|
||||
// The lexical origin for `import` resolution (§5.5): the top-level
|
||||
// script's defining node. Falls back to the executing app when the
|
||||
// request predates Phase 4b (old payloads / cluster wire).
|
||||
let default_origin = req
|
||||
.script_owner
|
||||
.unwrap_or(picloud_shared::ScriptOwner::App(req.app_id));
|
||||
let resolver = PicloudModuleResolver::new(
|
||||
self.services.modules.clone(),
|
||||
cx.clone(),
|
||||
self.module_cache.clone(),
|
||||
effective_limits.module_import_depth_max,
|
||||
default_origin,
|
||||
);
|
||||
engine.set_module_resolver(resolver);
|
||||
let self_engine = self.self_arc();
|
||||
|
||||
@@ -1,32 +1,50 @@
|
||||
//! `PicloudModuleResolver` — the v1.1.3 per-app Rhai module resolver.
|
||||
//! `PicloudModuleResolver` — the Rhai module resolver (v1.1.3; made
|
||||
//! origin-aware / lexical in v1.2 Phase 4b).
|
||||
//!
|
||||
//! Replaces `DummyModuleResolver` in `Engine::build_engine`. Constructed
|
||||
//! fresh per `Engine::execute` call: holds an `Arc<SdkCallCx>` so every
|
||||
//! `import "<name>"` request resolves against the calling app
|
||||
//! (`cx.app_id`). The script-side `name` argument carries no `app_id`
|
||||
//! — that's the load-bearing cross-app isolation property.
|
||||
//! fresh per `Engine::execute` call.
|
||||
//!
|
||||
//! **Lexical resolution (§5.5).** An `import "<name>"` resolves against the
|
||||
//! module set visible at the **importing script's own defining node**,
|
||||
//! walking up its group chain — not the inheriting app's effective view.
|
||||
//! The importing node is read from Rhai's `_source` (the importing AST's
|
||||
//! source tag, which the resolver sets to each module's owner) and falls
|
||||
//! back to `default_origin` (the entry script's defining node) for the
|
||||
//! top-level AST. So an inherited group script's imports seal to the group
|
||||
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
||||
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||
//!
|
||||
//! **Extension points (§5.5).** A node may opt a module name OUT of sealing
|
||||
//! by declaring it an extension point: an importer on that node's chain then
|
||||
//! resolves the name against the *inheriting app's* effective view
|
||||
//! (`App(cx.app_id)`), with the declared default body as fallback — so each
|
||||
//! descendant app supplies its own impl. The "is this an extension point"
|
||||
//! check keys on the importer's `origin` (trusted), so only the declaring
|
||||
//! parent can open the inversion; a descendant can't hijack a sealed import.
|
||||
//!
|
||||
//! Three runtime invariants are enforced:
|
||||
//!
|
||||
//! 1. **Cross-app isolation** — `ModuleSource::lookup` is called with
|
||||
//! `&cx`; the Postgres impl scopes by `cx.app_id` (never by a
|
||||
//! script-passed argument).
|
||||
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||
//! importing node's owner (a trusted dispatch-derived value), never a
|
||||
//! script-passed argument.
|
||||
//! 2. **Cycle detection** — an in-progress-imports stack rejects
|
||||
//! `A → B → A` with `ErrorInModule(... circular import detected ...)`.
|
||||
//! 3. **Depth limit** — guards against deep but acyclic chains
|
||||
//! (default 8, override via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`).
|
||||
//!
|
||||
//! Compiled modules are cached per `(app_id, name)` and invalidated by
|
||||
//! `updated_at` change — no explicit pub/sub. The cache is owned by
|
||||
//! `Engine` and shared across calls; only the resolver state (stack,
|
||||
//! depth) is per-call.
|
||||
//! Compiled modules are cached by resolved `ScriptId` (a compiled module
|
||||
//! is lexically self-contained) and invalidated by `updated_at` change —
|
||||
//! no explicit pub/sub. The cache is owned by `Engine` and shared across
|
||||
//! calls; only the resolver state (stack, depth) is per-call.
|
||||
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use lru::LruCache;
|
||||
use picloud_shared::{AppId, ModuleSource, ModuleSourceError, SdkCallCx, ValidatedScript};
|
||||
use picloud_shared::{
|
||||
ModuleSource, ModuleSourceError, ScriptId, ScriptOwner, SdkCallCx, ValidatedScript,
|
||||
};
|
||||
use rhai::module_resolvers::ModuleResolver;
|
||||
use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||
|
||||
@@ -35,10 +53,38 @@ use rhai::{Engine as RhaiEngine, EvalAltResult, Module, Position, Shared, AST};
|
||||
/// `sync` feature that the workspace pins.
|
||||
type SharedRhaiModule = Shared<Module>;
|
||||
|
||||
/// Cache key: `(app_id, module name)`. v1.1.3 enforces module names as
|
||||
/// a conservative identifier shape (migration 0015 `scripts_module_name_shape`
|
||||
/// CHECK) so the `String` here is bounded by ~64 bytes.
|
||||
pub type ModuleCacheKey = (AppId, String);
|
||||
/// Cache key: the resolved module's `ScriptId` (Phase 4b). A compiled
|
||||
/// module is lexically self-contained (its nested imports resolve from
|
||||
/// its own defining node, baked in at compile time), so the *body* is a
|
||||
/// pure function of `(script_id, updated_at)` — independent of which
|
||||
/// script imported it. Keying by id also dedupes a shared group module
|
||||
/// across every inheriting app, and keeps cross-app modules of the same
|
||||
/// name distinct (distinct ids).
|
||||
pub type ModuleCacheKey = ScriptId;
|
||||
|
||||
/// Encode a defining node as an AST `source` tag (`app:<uuid>` /
|
||||
/// `group:<uuid>`). Set on a resolved module's AST so its own nested
|
||||
/// `import`s carry *its* node as Rhai's `_source` — the lexical chaining
|
||||
/// that seals each module's imports to where it was authored (§5.5).
|
||||
fn encode_origin(owner: ScriptOwner) -> String {
|
||||
match owner {
|
||||
ScriptOwner::App(a) => format!("app:{a}"),
|
||||
ScriptOwner::Group(g) => format!("group:{g}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of [`encode_origin`]. `None` for an unrecognised tag (e.g. a
|
||||
/// source set by something other than this resolver) — the caller then
|
||||
/// falls back to the resolver's `default_origin`.
|
||||
fn decode_origin(s: &str) -> Option<ScriptOwner> {
|
||||
let (kind, id) = s.split_once(':')?;
|
||||
let uuid = uuid::Uuid::parse_str(id).ok()?;
|
||||
match kind {
|
||||
"app" => Some(ScriptOwner::App(uuid.into())),
|
||||
"group" => Some(ScriptOwner::Group(uuid.into())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache value: the freshness comparator + the compiled module Rhai
|
||||
/// hands to importing scripts. Cloning the `Shared<Module>` is an Arc bump.
|
||||
@@ -65,17 +111,19 @@ pub fn new_module_cache(capacity: usize) -> Arc<ModuleCache> {
|
||||
|
||||
/// The v1.1.3 module resolver. One per `Engine::execute` call.
|
||||
pub struct PicloudModuleResolver {
|
||||
/// Backend the resolver consults for `(app_id, name)`. The bridge
|
||||
/// runs Rhai's sync `resolve()` and the async `lookup()` together
|
||||
/// via `tokio::runtime::Handle::block_on(...)` — safe because
|
||||
/// Backend the resolver consults to resolve a module name against an
|
||||
/// origin node's chain. The bridge runs Rhai's sync `resolve()` and the
|
||||
/// async `ModuleSource::resolve()` together via
|
||||
/// `tokio::runtime::Handle::block_on(...)` — safe because
|
||||
/// `LocalExecutorClient` runs `Engine::execute` inside
|
||||
/// `spawn_blocking`, which puts us on a Tokio blocking thread
|
||||
/// that still carries a `Handle`.
|
||||
source: Arc<dyn ModuleSource>,
|
||||
|
||||
/// Calling context. `cx.app_id` is the cross-app isolation
|
||||
/// boundary; the resolver passes `&cx` to every `ModuleSource`
|
||||
/// call so the backend can scope its queries.
|
||||
/// Calling context. `cx.app_id` is the execution boundary every SDK
|
||||
/// call inside a module scopes to; the resolver keeps it for diagnostic
|
||||
/// logging. (Module *resolution* is keyed by the importing node's owner,
|
||||
/// not `cx.app_id` — see the module docs.)
|
||||
cx: Arc<SdkCallCx>,
|
||||
|
||||
/// Compiled-module cache. Shared across executions; invalidated
|
||||
@@ -96,6 +144,12 @@ pub struct PicloudModuleResolver {
|
||||
/// via `PICLOUD_MODULE_IMPORT_DEPTH_MAX`. Read from `Limits` at
|
||||
/// resolver construction.
|
||||
depth_limit: u32,
|
||||
|
||||
/// Lexical origin for a top-level `import` — the defining node of the
|
||||
/// script being executed (Phase 4b, §5.5). Used when Rhai gives no
|
||||
/// `_source` (the entry AST). Nested imports inside a resolved module
|
||||
/// override this via the module AST's source (C3).
|
||||
default_origin: ScriptOwner,
|
||||
}
|
||||
|
||||
impl PicloudModuleResolver {
|
||||
@@ -105,6 +159,7 @@ impl PicloudModuleResolver {
|
||||
cx: Arc<SdkCallCx>,
|
||||
cache: Arc<ModuleCache>,
|
||||
depth_limit: u32,
|
||||
default_origin: ScriptOwner,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
@@ -113,6 +168,7 @@ impl PicloudModuleResolver {
|
||||
in_progress: Mutex::new(Vec::new()),
|
||||
depth: Mutex::new(0),
|
||||
depth_limit,
|
||||
default_origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +280,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
fn resolve(
|
||||
&self,
|
||||
engine: &RhaiEngine,
|
||||
_source: Option<&str>,
|
||||
importer_source: Option<&str>,
|
||||
path: &str,
|
||||
pos: Position,
|
||||
) -> Result<SharedRhaiModule, Box<EvalAltResult>> {
|
||||
@@ -319,8 +375,54 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Lexical resolution (§5.5): resolve `path` against the *importing
|
||||
// node's* chain. Rhai gives `_source` = the importing AST's source
|
||||
// tag — set to the module's defining node for a nested import, or
|
||||
// unset (`None`) for the top-level entry script, where we fall back
|
||||
// to `default_origin` (the executing script's node). An app-rooted
|
||||
// walk reaches ancestor group modules; a group-rooted walk is sealed
|
||||
// from app modules below it (the trust boundary).
|
||||
let origin = importer_source
|
||||
.and_then(decode_origin)
|
||||
.unwrap_or(self.default_origin);
|
||||
|
||||
// Extension-point inversion (§5.5): if `path`'s nearest declaration on
|
||||
// the importer's chain is an extension point (not a concrete module),
|
||||
// resolve it against the INHERITING app's effective view instead of the
|
||||
// sealed lexical chain — each descendant app supplies its own impl,
|
||||
// falling back to the declared default body. The decision keys on
|
||||
// `origin` (the trusted importer node), so only a declaration on the
|
||||
// importer's own ancestry can open this; a descendant can never make a
|
||||
// parent's sealed `import` dynamic, and a non-ext-point import never
|
||||
// consults `App(cx.app_id)`. All branches funnel any backend error into
|
||||
// the single redaction arm below.
|
||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
|
||||
(|| {
|
||||
let ep = tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve_extension_point(origin, path))
|
||||
})?;
|
||||
let Some(ep) = ep else {
|
||||
// Sealed lexical resolution (the default, unchanged).
|
||||
return tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve(origin, path))
|
||||
});
|
||||
};
|
||||
let app_origin = ScriptOwner::App(self.cx.app_id);
|
||||
if let Some(m) = tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve(app_origin, path))
|
||||
})? {
|
||||
return Ok(Some(m));
|
||||
}
|
||||
match ep.default_script_id {
|
||||
// The default body is a module declared at the node that
|
||||
// opened the ext point, i.e. on the importer's (`origin`)
|
||||
// chain — resolve it there, not against the app.
|
||||
Some(id) => tokio::task::block_in_place(|| {
|
||||
handle.block_on(self.source.resolve_by_id(origin, id))
|
||||
}),
|
||||
None => Ok(None),
|
||||
}
|
||||
})();
|
||||
|
||||
let module_row = match lookup_result {
|
||||
Ok(Some(m)) => m,
|
||||
@@ -355,8 +457,10 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
};
|
||||
|
||||
// Cache lookup: hit only if both key matches AND updated_at
|
||||
// matches (cache is invalidated lazily on version change).
|
||||
let cache_key = (self.cx.app_id, path.to_string());
|
||||
// matches (cache is invalidated lazily on version change). Keyed by
|
||||
// the resolved module's id (lexically self-contained — see
|
||||
// `ModuleCacheKey`).
|
||||
let cache_key = module_row.script_id;
|
||||
{
|
||||
let mut cache = self.cache.lock().expect("module cache lock poisoned");
|
||||
if let Some(cached) = cache.get(&cache_key) {
|
||||
@@ -389,7 +493,7 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
// already been gated at create-time (admin endpoint runs
|
||||
// `validate_module`), but we revalidate here to catch DB-direct
|
||||
// inserts that bypass the API surface.
|
||||
let ast = engine.compile(&module_row.source).map_err(|e| {
|
||||
let mut ast = engine.compile(&module_row.source).map_err(|e| {
|
||||
// Wrap as an ErrorRuntime to preserve the parse message
|
||||
// text without trying to reconstruct rhai's internal
|
||||
// ParseErrorType variant (which would require matching on
|
||||
@@ -404,6 +508,15 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Seal this module's own imports to its defining node (§5.5): tag
|
||||
// the AST source with the resolved module's owner so that when
|
||||
// `eval_ast_as_new` evaluates its nested `import`s, Rhai hands us
|
||||
// that tag as `_source` and we resolve them lexically from *here*,
|
||||
// not from the original importer. Fall back to the lookup origin
|
||||
// for a corrupt owner row (never reached under the DB CHECK).
|
||||
let module_owner = module_row.owner().unwrap_or(origin);
|
||||
ast.set_source(encode_origin(module_owner));
|
||||
|
||||
if let Err(msg) = Self::check_module_shape(&ast, path) {
|
||||
return Err(Box::new(EvalAltResult::ErrorInModule(
|
||||
path.to_string(),
|
||||
|
||||
@@ -179,6 +179,10 @@ fn invoke_blocking(
|
||||
rest: String::new(),
|
||||
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||
app_id: cx.app_id,
|
||||
// Lexical origin (§5.5): the callee's defining node — a group
|
||||
// script's imports resolve from the group even when invoked by an
|
||||
// app. `None` falls back to `App(cx.app_id)` in the engine.
|
||||
script_owner: resolved.owner,
|
||||
// Same-app invoke is a function call, not a re-auth boundary —
|
||||
// inherit the caller's principal.
|
||||
principal: cx.principal.clone(),
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ExecutionLog, ExecutionSource, ExecutionStatus, Principal, RequestId,
|
||||
ScriptId, ScriptSandbox, TriggerEvent,
|
||||
ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
@@ -69,6 +69,16 @@ pub struct ExecRequest {
|
||||
/// Internal-only; not surfaced via `ctx` (which the script sees).
|
||||
pub app_id: AppId,
|
||||
|
||||
/// The **defining node** of the top-level script being executed —
|
||||
/// the resolved `Script`'s owner (Phase 4b). This is the lexical
|
||||
/// origin its `import` statements resolve against (§5.5): an inherited
|
||||
/// group endpoint's imports seal to the **group**, not the inheriting
|
||||
/// app, so a leaf can't shadow them. Distinct from `app_id`, which is
|
||||
/// always the execution boundary (where SDK calls scope). `None` (old
|
||||
/// payloads / cluster wire) falls back to `App(app_id)` in the engine.
|
||||
#[serde(default)]
|
||||
pub script_owner: Option<ScriptOwner>,
|
||||
|
||||
/// Caller identity, when authenticated. `None` for unauthenticated
|
||||
/// data-plane HTTP requests (the common case for public scripts);
|
||||
/// `Some` when a bearer token or session cookie was resolved.
|
||||
|
||||
@@ -23,6 +23,7 @@ fn req(body: serde_json::Value) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -16,7 +16,7 @@ use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||
ScriptSandbox, SdkCallCx, Services,
|
||||
ScriptOwner, ScriptSandbox, Services,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use tracing_subscriber::fmt::MakeWriter;
|
||||
@@ -27,13 +27,29 @@ struct FailingSource;
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for FailingSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_script_id: picloud_shared::ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `MakeWriter` that appends to a shared buffer.
|
||||
@@ -74,6 +90,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -16,9 +16,9 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_executor_core::{Engine, ExecRequest, InvocationType, Limits};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionId, ModuleScript, ModuleSource, ModuleSourceError, NoopDeadLetterService,
|
||||
NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService, RequestId, ScriptId,
|
||||
ScriptSandbox, SdkCallCx, Services,
|
||||
AppId, ExecutionId, GroupId, ModuleScript, ModuleSource, ModuleSourceError,
|
||||
NoopDeadLetterService, NoopDocsService, NoopEventEmitter, NoopHttpService, NoopKvService,
|
||||
RequestId, ScriptId, ScriptOwner, ScriptSandbox, Services,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -55,7 +55,8 @@ impl CountingModuleSource {
|
||||
(app_id, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id,
|
||||
app_id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
updated_at,
|
||||
@@ -71,20 +72,51 @@ impl CountingModuleSource {
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for CountingModuleSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
self.lookups.fetch_add(1, Ordering::SeqCst);
|
||||
if let Some(err) = self.fail_with.lock().await.as_ref() {
|
||||
return Err(ModuleSourceError::Backend(err.clone()));
|
||||
}
|
||||
// This fake is flat/app-scoped — the inheritance + lexical
|
||||
// resolution semantics are covered by the CLI journey tests
|
||||
// against real Postgres. A group origin has no entries here.
|
||||
let app_id = match origin {
|
||||
ScriptOwner::App(a) => a,
|
||||
ScriptOwner::Group(_) => return Ok(None),
|
||||
};
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.get(&(cx.app_id, name.to_string()))
|
||||
.get(&(app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
// This flat fake has no extension points; the inversion is covered by
|
||||
// `LexicalModuleSource` below and the Postgres journey tests.
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|m| m.script_id == script_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
@@ -129,6 +161,7 @@ fn req(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
@@ -600,3 +633,350 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
||||
v.imports
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Phase 4b — lexical (origin-aware) resolution.
|
||||
//
|
||||
// `LexicalModuleSource` keys modules by their *exact* defining node, with no
|
||||
// chain walk. That's enough to prove the resolver passes the RIGHT origin:
|
||||
// `default_origin` for the entry AST, and each module's own owner (decoded
|
||||
// from Rhai's `_source`) for its nested imports. The chain-walk + group-tree
|
||||
// semantics are covered end-to-end by the CLI journey tests against Postgres.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct LexicalModuleSource {
|
||||
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||
/// Extension-point declarations, keyed by exact declaring owner →
|
||||
/// optional default module id. Flat (exact-owner) — the chain-walk +
|
||||
/// shadowing tie-break is covered by the Postgres journey tests.
|
||||
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
|
||||
}
|
||||
|
||||
impl LexicalModuleSource {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) -> ScriptId {
|
||||
let (app_id, group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g)),
|
||||
};
|
||||
let script_id = ScriptId::new();
|
||||
self.table.lock().await.insert(
|
||||
(owner, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id,
|
||||
app_id,
|
||||
group_id,
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
script_id
|
||||
}
|
||||
|
||||
/// Declare `name` an extension point at `owner`, with an optional default
|
||||
/// module body id.
|
||||
async fn put_ext_point(
|
||||
self: &Arc<Self>,
|
||||
owner: ScriptOwner,
|
||||
name: &str,
|
||||
default_script_id: Option<ScriptId>,
|
||||
) {
|
||||
self.ext_points
|
||||
.lock()
|
||||
.await
|
||||
.insert((owner, name.to_string()), default_script_id);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for LexicalModuleSource {
|
||||
async fn resolve(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.get(&(origin, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.ext_points
|
||||
.lock()
|
||||
.await
|
||||
.get(&(origin, name.to_string()))
|
||||
.map(|default_script_id| picloud_shared::ExtPointResolution {
|
||||
default_script_id: *default_script_id,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|m| m.script_id == script_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||
let mut r = req(app_id);
|
||||
r.script_owner = Some(owner);
|
||||
r
|
||||
}
|
||||
|
||||
/// A group module's `import` resolves from the GROUP (its own defining
|
||||
/// node), not the inheriting app — even when an app-owned module of the
|
||||
/// same name exists. This is the §5.5 trust boundary: a leaf can't shadow
|
||||
/// a module an inherited group script depends on.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lexical_nested_import_seals_to_module_owner() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
|
||||
// Two "inner" modules: the group's (real) and the app's (a trap).
|
||||
source
|
||||
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||
.await;
|
||||
// The group's "outer" imports "inner" — must bind the group's inner.
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"outer",
|
||||
r#"import "inner" as i; fn val() { i::v() }"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
// Entry script runs as the inherited GROUP script (default_origin = group).
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "outer" as o; o::val()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!(42),
|
||||
"group module's import must seal to the group's `inner`, not the app's trap"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same registry, entered as an APP-owned script, reaches the app's
|
||||
/// module — proving the fake distinguishes origins and the entry uses
|
||||
/// `default_origin`.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn lexical_entry_origin_selects_app_module() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put(ScriptOwner::Group(group), "inner", "fn v() { 42 }")
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app), "inner", "fn v() { 999 }")
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "inner" as i; i::v()"#,
|
||||
req_with_owner(app, ScriptOwner::App(app)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!(999));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
|
||||
// a module name an extension point; an importer on the group's chain resolves
|
||||
// it against the INHERITING APP, not the sealed lexical chain.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A group script importing a declared extension point resolves it against the
|
||||
/// executing app's own module — each tenant supplies its own body.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_resolves_to_app_module() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// The group opens "theme" as an extension point (no default).
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
// The app provides its own "theme".
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::App(app),
|
||||
"theme",
|
||||
r#"fn name() { "app-theme" }"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
// Entry runs as the inherited GROUP script (default_origin = group), but
|
||||
// the executing app is `app` (cx.app_id).
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!("app-theme"),
|
||||
"extension point must resolve to the inheriting app's module"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the app provides no module for the extension point, the declared
|
||||
/// default body is used.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_falls_back_to_default() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// The group's default "theme" body, registered as a group module.
|
||||
let default_id = source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"default-theme",
|
||||
r#"fn name() { "default-theme" }"#,
|
||||
)
|
||||
.await;
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
|
||||
.await;
|
||||
// The app provides NO "theme".
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!("default-theme"));
|
||||
}
|
||||
|
||||
/// An extension point with neither an app provider nor a default is a clean
|
||||
/// module-not-found at import time.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_no_provider_no_default_errors() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let err = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect_err("ext point with no provider/default should fail");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
|
||||
"expected module-not-found-flavoured error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
|
||||
/// module of the same name — the inversion only fires for declared extension
|
||||
/// points, so the trust boundary holds.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// "auth" is a concrete group module — NOT an extension point.
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"auth",
|
||||
r#"fn who() { "group-auth" }"#,
|
||||
)
|
||||
.await;
|
||||
// The app has a trap "auth" that must NOT be selected.
|
||||
source
|
||||
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "auth" as a; a::who()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!("group-auth"),
|
||||
"a non-ext-point import must seal to the group, never the leaf's trap"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two apps inheriting the same extension point get their OWN bodies — the
|
||||
/// id-keyed module cache does not bleed one tenant's body into another.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_two_apps_get_distinct_bodies() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app1 = AppId::new();
|
||||
let app2 = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let r1 = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app1, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("app1 executes");
|
||||
let r2 = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app2, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("app2 executes");
|
||||
assert_eq!(r1.body, serde_json::json!("one"));
|
||||
assert_eq!(
|
||||
r2.body,
|
||||
serde_json::json!("two"),
|
||||
"no cross-tenant cache bleed"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fn baseline_request() -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -257,6 +257,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -66,6 +66,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -194,6 +194,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -117,6 +117,7 @@ fn baseline_request(app_id: AppId, script_id: ScriptId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -58,6 +58,7 @@ impl InvokeService for FakeInvokeService {
|
||||
Ok(ResolvedScript {
|
||||
script_id: id,
|
||||
app_id: app,
|
||||
owner: None,
|
||||
source,
|
||||
updated_at: Utc::now(),
|
||||
name,
|
||||
@@ -114,6 +115,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -136,6 +136,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -74,6 +74,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -88,6 +88,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -52,6 +52,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -127,6 +127,7 @@ fn baseline_request(app_id: AppId) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
@@ -121,6 +121,7 @@ fn request(app_id: AppId, with_principal: bool) -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id,
|
||||
script_owner: None,
|
||||
principal: with_principal.then(|| Principal {
|
||||
user_id: AdminUserId::new(),
|
||||
instance_role: InstanceRole::Owner,
|
||||
|
||||
@@ -37,6 +37,7 @@ fn baseline_request() -> ExecRequest {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: ScriptSandbox::default(),
|
||||
app_id: AppId::new(),
|
||||
script_owner: None,
|
||||
principal: None,
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
|
||||
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
40
crates/manager-core/migrations/0050_group_scripts.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- Phase 4 (v1.2 Hierarchies): group-owned scripts.
|
||||
--
|
||||
-- Until now every script was owned by exactly one app (`app_id NOT NULL`,
|
||||
-- ON DELETE RESTRICT). Phase 4 lets a script be owned by a GROUP instead, so
|
||||
-- it is shared by every descendant app — resolved by name, nearest-owner
|
||||
-- wins (CoW: an app's own script of the same name shadows the inherited one),
|
||||
-- exactly like `vars`/`secrets` (0048/0049).
|
||||
--
|
||||
-- Phase 4-LITE scope: group-owned ENDPOINT scripts only. Group modules and
|
||||
-- the origin-aware (lexical) import resolver are deferred to Phase 4b, so a
|
||||
-- group-owned script must be self-contained (enforced in the service layer).
|
||||
--
|
||||
-- Reshape (mirrors vars/secrets, but RESTRICT not CASCADE — code is not data,
|
||||
-- a group can't be deleted out from under the scripts it owns, matching the
|
||||
-- existing app_id RESTRICT and the §5.6 delete=RESTRICT rule):
|
||||
-- * `group_id` — nullable FK→groups, ON DELETE RESTRICT.
|
||||
-- * `app_id` — made nullable (was NOT NULL); the exactly-one CHECK now
|
||||
-- enforces "owned by an app XOR a group".
|
||||
-- * the per-app unique name index becomes two partial indexes, one per owner.
|
||||
|
||||
ALTER TABLE scripts
|
||||
ADD COLUMN group_id UUID REFERENCES groups(id) ON DELETE RESTRICT;
|
||||
|
||||
ALTER TABLE scripts
|
||||
ALTER COLUMN app_id DROP NOT NULL;
|
||||
|
||||
ALTER TABLE scripts
|
||||
ADD CONSTRAINT scripts_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL));
|
||||
|
||||
-- Per-owner, case-insensitive name uniqueness. Partial so each owner column
|
||||
-- only constrains its own rows; existing app rows (group_id NULL) keep the
|
||||
-- exact same (app_id, LOWER(name)) uniqueness they had under the old index.
|
||||
DROP INDEX scripts_name_uidx;
|
||||
CREATE UNIQUE INDEX scripts_app_name_uidx
|
||||
ON scripts (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX scripts_group_name_uidx
|
||||
ON scripts (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX scripts_group_id_idx ON scripts (group_id) WHERE group_id IS NOT NULL;
|
||||
49
crates/manager-core/migrations/0051_extension_points.sql
Normal file
49
crates/manager-core/migrations/0051_extension_points.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- Phase 4b+1 (v1.2 Hierarchies): extension points (§5.5).
|
||||
--
|
||||
-- An extension point opts a MODULE NAME into dynamic, per-tenant resolution.
|
||||
-- Normally a group/parent script's `import "x"` resolves sealed-lexically
|
||||
-- against the importer's own defining node — a leaf app cannot shadow it
|
||||
-- (the trust boundary). When a node declares `x` an extension point, an
|
||||
-- importer whose defining node is on that node's chain instead resolves `x`
|
||||
-- against the INHERITING app's effective view, so each descendant app may
|
||||
-- supply its own `x`. Only the declaring (parent) node can open the hole; a
|
||||
-- descendant cannot make one of its parent's sealed imports dynamic, because
|
||||
-- the declaration must live on the importer's own ancestry.
|
||||
--
|
||||
-- Owner-polymorphic exactly like vars (0048): exactly one of group_id/app_id.
|
||||
-- Optional `default_script_id` is the fallback module body used when the
|
||||
-- inheriting app provides no module of `name`.
|
||||
|
||||
CREATE TABLE extension_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Polymorphic owner: exactly one FK set (real FKs 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 extension_points_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
name TEXT NOT NULL,
|
||||
-- Optional fallback module (a module owned by the declaring node). SET
|
||||
-- NULL on delete so deleting the module can never block — the fallback is
|
||||
-- then absent and a non-providing app errors at import time (the next plan
|
||||
-- shows the now-null default as an Update). A valid manifest apply can't
|
||||
-- reach this: validation rejects a kept ext point whose default names a
|
||||
-- module not in the bundle.
|
||||
default_script_id UUID REFERENCES scripts(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One declaration per (owner, name), case-insensitive to match the module
|
||||
-- name indexes (0050). Partial because the owner is split across two columns.
|
||||
CREATE UNIQUE INDEX extension_points_group_uidx
|
||||
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX extension_points_app_uidx
|
||||
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX extension_points_group_id_idx
|
||||
ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_app_id_idx
|
||||
ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_default_script_idx
|
||||
ON extension_points (default_script_id) WHERE default_script_id IS NOT NULL;
|
||||
39
crates/manager-core/migrations/0052_owner_project.sql
Normal file
39
crates/manager-core/migrations/0052_owner_project.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Phase 5 / M3 (v1.2 Hierarchies): multi-repo single-owner ownership (§7).
|
||||
--
|
||||
-- A group node is authoritatively MANAGED by at most one project-root (the
|
||||
-- repo whose manifest declares it as something it applies, not merely
|
||||
-- references). `groups.owner_project` has carried this seam since 0047 as an
|
||||
-- inert, FK-less UUID. M3 makes it a real reference: a `projects` table keyed
|
||||
-- by a stable, gitignored project key the CLI mints in `.picloud/`, and a
|
||||
-- foreign key from `groups.owner_project` to it.
|
||||
--
|
||||
-- Ownership is recorded at GROUP granularity; apps inherit their owning project
|
||||
-- from their group (apps are never claimed directly). A NULL `owner_project`
|
||||
-- means the node is UI/API-owned — no manifest fights it (§7.5). First apply
|
||||
-- claims; a second project's apply to an owned node is refused unless
|
||||
-- `--takeover` (group-admin gated). Ownership ⟂ RBAC: owning the manifest does
|
||||
-- NOT grant write — the actor still needs the usual group capabilities (§7.4).
|
||||
--
|
||||
-- ON DELETE SET NULL: deleting a project row (not something the platform does
|
||||
-- today) reverts its nodes to UI-owned rather than cascading away real groups.
|
||||
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Stable, opaque key minted by `pic init` and persisted (gitignored) in the
|
||||
-- repo's `.picloud/`. The CLI presents it on every tree apply; the server
|
||||
-- maps it to this row (upsert-by-key), so the same repo keeps the same
|
||||
-- project identity across clones/CI without committing any server id.
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Promote the inert `groups.owner_project` (0047:38) to a real FK now that the
|
||||
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
|
||||
-- no validation burden.
|
||||
ALTER TABLE groups
|
||||
ADD CONSTRAINT groups_owner_project_fkey
|
||||
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
-- The ownership reconcile reads "which groups does project P own?" (to find
|
||||
-- owned-but-undeclared nodes for structural prune) and "who owns group G?".
|
||||
CREATE INDEX groups_owner_project_idx ON groups (owner_project);
|
||||
49
crates/manager-core/migrations/0053_templates.sql
Normal file
49
crates/manager-core/migrations/0053_templates.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- Phase 5 / M4a (v1.2 Hierarchies): ROUTE templates (§4.5).
|
||||
--
|
||||
-- Triggers and routes are app-scoped (never group-inherited — §5.1). At high
|
||||
-- tenant cardinality that means 100 apps × N routes = 100N hand declarations.
|
||||
-- A TEMPLATE fixes that: declare a route ONCE on a group, and the apply engine
|
||||
-- fans it out into one concrete per-`app_id` row for every descendant app. This
|
||||
-- is INSTANTIATION, not inheritance — expanded rows stay app-owned (the
|
||||
-- isolation boundary is unchanged); the template is just a stamp.
|
||||
--
|
||||
-- Placeholders in template fields are a small, inert, documented set resolved
|
||||
-- per descendant at expansion: `{app_slug}`, `{env}`, `{var:NAME}` (the app's
|
||||
-- effective var). Provenance: each expanded `routes` row carries `from_template`
|
||||
-- (the template id it was stamped from), so re-apply diffs idempotently and
|
||||
-- `--prune` removes expansions WITHOUT touching a hand-declared route of the
|
||||
-- same identity.
|
||||
--
|
||||
-- (Trigger templates reuse this engine in a follow-up: `trigger_templates` +
|
||||
-- `triggers.from_template`.)
|
||||
|
||||
-- Route templates — one per (group, name). Mirrors the `routes` column shape
|
||||
-- minus `app_id` (filled per descendant) plus `script_name` (resolved to the
|
||||
-- nearest inherited endpoint at expansion, like declarative route bindings).
|
||||
CREATE TABLE route_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
-- Explicit identity/upsert key, unique per group (case-insensitive).
|
||||
name TEXT NOT NULL,
|
||||
script_name TEXT NOT NULL,
|
||||
method TEXT,
|
||||
host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')),
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
host_param_name TEXT,
|
||||
path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')),
|
||||
path TEXT NOT NULL,
|
||||
dispatch_mode TEXT NOT NULL DEFAULT 'sync' CHECK (dispatch_mode IN ('sync', 'async')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX route_templates_group_name_idx
|
||||
ON route_templates (group_id, LOWER(name));
|
||||
|
||||
-- Provenance on the expanded rows. NULL = hand-declared (the app's own
|
||||
-- manifest); non-NULL = stamped from this template, managed by template
|
||||
-- expansion/prune. No FK: the apply engine owns the expansion lifecycle (it
|
||||
-- deletes orphaned expansions explicitly), and a dangling id after a raw
|
||||
-- template delete is harmless (treated as "template gone → prune the row").
|
||||
ALTER TABLE routes ADD COLUMN from_template UUID;
|
||||
CREATE INDEX routes_from_template_idx ON routes (app_id, from_template);
|
||||
33
crates/manager-core/migrations/0054_trigger_templates.sql
Normal file
33
crates/manager-core/migrations/0054_trigger_templates.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Phase 5 / M4b (v1.2 Hierarchies): TRIGGER templates (§4.5).
|
||||
--
|
||||
-- The trigger half of templates (M4a shipped routes). A `[group]` manifest
|
||||
-- declares `[[trigger_templates.<kind>]]`; the apply fans each one out into a
|
||||
-- concrete per-`app_id` trigger on every descendant app, reusing the same
|
||||
-- expansion engine, placeholder set (`{app_slug}`/`{env}`/`{var:NAME}`), and
|
||||
-- `from_template` provenance as routes.
|
||||
--
|
||||
-- A trigger's parameters vary by kind (collection_glob/ops, schedule/timezone,
|
||||
-- topic_pattern, queue_name, inbound_secret_ref, …), so the template stores the
|
||||
-- whole `BundleTrigger` wire object as `spec` JSONB (kind tag included). The
|
||||
-- expansion resolves placeholders in the spec's string leaves, then rebuilds the
|
||||
-- typed trigger and inserts it through the normal trigger path (email secrets
|
||||
-- are resolved + re-sealed per app, exactly like a hand-declared email trigger).
|
||||
|
||||
CREATE TABLE trigger_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
-- Identity/upsert key, unique per group (case-insensitive).
|
||||
name TEXT NOT NULL,
|
||||
-- The full `BundleTrigger` wire object (internally tagged by `kind`), with
|
||||
-- placeholders left unresolved. Rebuilt + resolved per descendant at apply.
|
||||
spec JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX trigger_templates_group_name_idx
|
||||
ON trigger_templates (group_id, LOWER(name));
|
||||
|
||||
-- Provenance on expanded triggers (mirrors routes.from_template, 0053). NULL =
|
||||
-- hand-declared; non-NULL = stamped from this template, managed by expansion.
|
||||
ALTER TABLE triggers ADD COLUMN from_template UUID;
|
||||
CREATE INDEX triggers_from_template_idx ON triggers (app_id, from_template);
|
||||
@@ -12,8 +12,8 @@ use axum::{
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionLog, ExecutionSource, InstanceRole, Principal, Script, ScriptId, ScriptKind,
|
||||
ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
|
||||
AppId, ExecutionLog, ExecutionSource, GroupId, InstanceRole, Principal, Script, ScriptId,
|
||||
ScriptKind, ScriptOwner, ScriptSandbox, ScriptValidator, ValidatedScript, ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -181,6 +181,27 @@ async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppI
|
||||
Ok(lookup.app.id)
|
||||
}
|
||||
|
||||
/// Capability authorizing an operation on a script of either owner (Phase 4).
|
||||
/// App-owned scripts map to their `App*` capability exactly as before; a
|
||||
/// group-owned script maps to the group-scoped capability, resolved against
|
||||
/// the group ancestor walk. An orphan row (neither owner — impossible under
|
||||
/// the DB CHECK) reads as a missing id.
|
||||
///
|
||||
/// The `app`/`group` arguments are the capability constructors for each
|
||||
/// owner, so one helper serves read / write / admin / log-read by passing
|
||||
/// the matching pair.
|
||||
fn script_cap(
|
||||
script: &Script,
|
||||
app: fn(AppId) -> Capability,
|
||||
group: fn(GroupId) -> Capability,
|
||||
) -> Result<Capability, ApiError> {
|
||||
match script.owner() {
|
||||
Some(ScriptOwner::App(a)) => Ok(app(a)),
|
||||
Some(ScriptOwner::Group(g)) => Ok(group(g)),
|
||||
None => Err(ApiError::NotFound(script.id)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
@@ -190,7 +211,7 @@ async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
script_cap(&script, Capability::AppRead, Capability::GroupScriptsRead)?,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(script))
|
||||
@@ -234,7 +255,8 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
let created = state
|
||||
.repo
|
||||
.create(NewScript {
|
||||
app_id: input.app_id,
|
||||
app_id: Some(input.app_id),
|
||||
group_id: None,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
@@ -291,7 +313,11 @@ async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(script.app_id),
|
||||
script_cap(
|
||||
&script,
|
||||
Capability::AppWriteScript,
|
||||
Capability::GroupScriptsWrite,
|
||||
)?,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -361,13 +387,15 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
Path(id): Path<ScriptId>,
|
||||
) -> Result<StatusCode, ApiError> {
|
||||
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
|
||||
// Delete is gated tighter than Save: editors can edit scripts but
|
||||
// only app_admin / instance admin / owner can remove them. See
|
||||
// blueprint §11.6.
|
||||
// Delete is gated tighter than Save for app-owned scripts: editors can
|
||||
// edit but only app_admin / instance admin / owner can remove them (§11.6).
|
||||
// A group-owned script (Phase 4) has no group-admin-tier script cap, so it
|
||||
// is gated at `GroupScriptsWrite` (editor+ on the group) — the same tier
|
||||
// that created it; group deletion itself stays group_admin-gated elsewhere.
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppAdmin(script.app_id),
|
||||
script_cap(&script, Capability::AppAdmin, Capability::GroupScriptsWrite)?,
|
||||
)
|
||||
.await?;
|
||||
state.repo.delete(id).await?;
|
||||
@@ -408,7 +436,11 @@ async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppLogRead(script.app_id),
|
||||
script_cap(
|
||||
&script,
|
||||
Capability::AppLogRead,
|
||||
Capability::GroupScriptsRead,
|
||||
)?,
|
||||
)
|
||||
.await?;
|
||||
// Cap to keep the dashboard responsive; the data plane writes are
|
||||
|
||||
@@ -60,7 +60,8 @@ async fn seed_into(
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
let script = scripts
|
||||
.create(NewScript {
|
||||
app_id: default.id,
|
||||
app_id: Some(default.id),
|
||||
group_id: None,
|
||||
name: "hello".to_string(),
|
||||
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||
source: HELLO_RHAI_SOURCE.to_string(),
|
||||
@@ -87,6 +88,7 @@ async fn seed_into(
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
from_template: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -8,27 +8,75 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
|
||||
NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
|
||||
/// 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))
|
||||
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||
.route("/apps/{id}/extension-points", get(app_ext_points_handler))
|
||||
.route(
|
||||
"/groups/{id}/extension-points",
|
||||
get(group_ext_points_handler),
|
||||
)
|
||||
.route("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
|
||||
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
|
||||
async fn app_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
|
||||
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
|
||||
async fn group_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::Group(group_id))
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
@@ -47,53 +95,7 @@ async fn apply_handler(
|
||||
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)?;
|
||||
}
|
||||
require_app_node_writes(&svc, &principal, app_id, &req.bundle, req.prune).await?;
|
||||
let report = svc
|
||||
.apply(
|
||||
app_id,
|
||||
@@ -126,6 +128,602 @@ async fn plan_handler(
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Group node apply (Phase 5): a group declares only scripts + vars (+ secret
|
||||
// names). The bundle reuses the app `Bundle` shape with empty routes/triggers;
|
||||
// the service rejects routes/triggers on a group node.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn group_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 group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
// Plan discloses the group's script + var + secret names; viewer-tier reads.
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
async fn group_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 group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require_group_node_writes(&svc, &principal, group_id, &req.bundle, req.prune).await?;
|
||||
let report = svc
|
||||
.apply_owner(
|
||||
ApplyOwner::Group(group_id),
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
req.expected_token.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tree apply (Phase 5): a whole project subtree in one transaction. The actor
|
||||
// must hold the relevant read/write caps for EVERY node touched.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreePlanRequest {
|
||||
bundle: TreeBundle,
|
||||
/// Stable project key from the repo's `.picloud/` link state (§7, M3).
|
||||
/// Lets `plan` surface ownership conflicts and prune candidates. Optional
|
||||
/// for legacy callers (then no ownership diagnosis is reported).
|
||||
#[serde(default)]
|
||||
project_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreeApplyRequest {
|
||||
bundle: TreeBundle,
|
||||
#[serde(default)]
|
||||
prune: bool,
|
||||
#[serde(default)]
|
||||
expected_token: Option<String>,
|
||||
/// Stable project key (§7, M3): the apply claims unclaimed declared groups
|
||||
/// for this project and refuses ones another project owns.
|
||||
#[serde(default)]
|
||||
project_key: Option<String>,
|
||||
/// Take over declared groups owned by another project (group-admin gated).
|
||||
#[serde(default)]
|
||||
allow_takeover: bool,
|
||||
/// Selected environment (§4.5, M4a): the value substituted for the `{env}`
|
||||
/// placeholder when expanding route templates. The CLI passes `--env`.
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
/// Environments the actor explicitly approved this apply for (§4.2, M5).
|
||||
/// A confirm-required env (per the bundle's `[project]` policy) is refused
|
||||
/// unless it appears here; `--yes` alone does NOT add it.
|
||||
#[serde(default)]
|
||||
approved_envs: Vec<String>,
|
||||
}
|
||||
|
||||
async fn tree_plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(req): Json<TreePlanRequest>,
|
||||
) -> Result<Json<TreePlanResult>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &req.bundle, None, false).await?;
|
||||
Ok(Json(
|
||||
svc.plan_tree(&req.bundle, req.project_key.as_deref())
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn tree_apply_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(req): Json<TreeApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
authz_tree(
|
||||
&svc,
|
||||
&principal,
|
||||
&req.bundle,
|
||||
Some(req.prune),
|
||||
req.allow_takeover,
|
||||
)
|
||||
.await?;
|
||||
enforce_env_approval(
|
||||
&svc,
|
||||
&principal,
|
||||
&req.bundle,
|
||||
req.env.as_deref(),
|
||||
&req.approved_envs,
|
||||
)
|
||||
.await?;
|
||||
let report = svc
|
||||
.apply_tree(
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
&principal,
|
||||
req.expected_token.as_deref(),
|
||||
req.project_key.as_deref(),
|
||||
req.allow_takeover,
|
||||
req.env.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
|
||||
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||
/// only the per-node read cap.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn authz_tree(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
apply_prune: Option<bool>,
|
||||
allow_takeover: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
NodeKind::App => {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Template expansion (§4.5) writes routes/triggers INTO
|
||||
// this app, so the actor needs the matching write cap
|
||||
// when the app's chain carries templates — even if the
|
||||
// app itself declares none. Without this, expanding into
|
||||
// an app a principal can't write would slip the gate.
|
||||
let recv = app_receives_template_expansions(svc, app_id, bundle).await?;
|
||||
if recv.routes {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if recv.triggers {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email-trigger expansion resolves + seals the recipient
|
||||
// app's secret server-side — same `AppSecretsRead` gate a
|
||||
// hand-declared email trigger requires.
|
||||
if recv.email {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let existing = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let Some(g) = existing else {
|
||||
// To-create group (M2): mirror the interactive create gate
|
||||
// (`groups_api::create_group`). A root-level group (no
|
||||
// resolvable parent) needs `InstanceCreateGroup`; a subgroup
|
||||
// under an EXISTING parent needs only `GroupAdmin(parent)`.
|
||||
// A parent that is itself to-create has no id yet → fall
|
||||
// back to `InstanceCreateGroup`.
|
||||
let parent = match &node.parent_slug {
|
||||
Some(p) => svc
|
||||
.groups
|
||||
.get_by_slug(p)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||
None => None,
|
||||
};
|
||||
match parent {
|
||||
Some(pg) => {
|
||||
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(pg.id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::InstanceCreateGroup,
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let group_id = g.id;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Takeover gate (§7.4): seizing a group that another
|
||||
// project owns needs GroupAdmin specifically — a second,
|
||||
// independent gate on top of the write caps. We gate it
|
||||
// for any already-claimed node under `--takeover` (a
|
||||
// superset of genuinely-contested, never laxer); claiming
|
||||
// an unclaimed node, or applying without `--takeover`,
|
||||
// does not require admin. The authoritative owner rewrite
|
||||
// happens in-tx in `apply_tree` (which knows our project).
|
||||
if allow_takeover
|
||||
&& crate::group_repo::get_group_owner(&svc.pool, group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_some()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Reparent: an explicit parent differing from the live
|
||||
// one needs GroupAdmin at the group, the SOURCE parent,
|
||||
// and the DESTINATION parent — mirroring the interactive
|
||||
// `reparent_group` (§5.6 triply-gated).
|
||||
if let Some(parent) = &node.parent_slug {
|
||||
if let Some(pg) = svc
|
||||
.groups
|
||||
.get_by_slug(parent)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
if Some(pg.id) != g.parent_id {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if let Some(src) = g.parent_id {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(src),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(pg.id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE (§4.5, M7): templates also fan out to descendant apps NOT declared in
|
||||
// this bundle (the cross-repo subtree). Those per-recipient write caps are
|
||||
// gated AUTHORITATIVELY IN-TRANSACTION in `apply_tree` Phase B2 — against the
|
||||
// post-reparent app set, each app already locked — so the checked set is by
|
||||
// construction the written set (no pre-tx TOCTOU, and a reparent that moves
|
||||
// apps under a templated group in the same apply can't slip the gate).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
|
||||
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
|
||||
/// enforcement is convenience, this is authoritative), it must be explicitly
|
||||
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
|
||||
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
|
||||
/// authority on every declared node — the "override a gate" capability (§4.2),
|
||||
/// a deliberate step up from the editor-level write caps an ordinary apply
|
||||
/// needs — and is audited.
|
||||
async fn enforce_env_approval(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<(), ApplyError> {
|
||||
let (Some(policy), Some(env)) = (&bundle.project, env) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !policy.confirm_required(env) {
|
||||
return Ok(());
|
||||
}
|
||||
if !approved_envs.iter().any(|e| e == env) {
|
||||
return Err(ApplyError::ApprovalRequired(env.to_string()));
|
||||
}
|
||||
// Approved: require admin authority on every declared node.
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
NodeKind::App => {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||
require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
NodeKind::Group => {
|
||||
// A to-create group has no id yet; its creation already required
|
||||
// InstanceCreateGroup / GroupAdmin(parent) in `authz_tree`.
|
||||
if let Some(g) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(g.id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
user_id = ?principal.user_id,
|
||||
env = %env,
|
||||
nodes = bundle.nodes.len(),
|
||||
"apply: approved gated-environment apply (audit)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// App-node write caps: per resource kind the bundle touches, widened to all
|
||||
/// kinds when `prune` (which deletes empty-section resources + cascades). Read
|
||||
/// is always needed. Shared by the single-app and tree apply paths.
|
||||
async fn require_app_node_writes(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
app_id: picloud_shared::AppId,
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
// Extension points are module-resolution declarations, so they gate on the
|
||||
// same script-write tier as modules — without this, an ext-point-only
|
||||
// bundle (no `default`, so no script) would pass with viewer-tier `AppRead`
|
||||
// and let a reader open the §5.5 import trust boundary.
|
||||
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppWriteScript(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.routes.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.triggers.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.vars.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppVarsWrite(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email triggers resolve + decrypt a stored secret by name server-side
|
||||
// (`AppSecretsRead`), so require it so apply can't bind a secret the
|
||||
// principal couldn't otherwise read.
|
||||
if bundle.triggers.iter().any(BundleTrigger::is_email) {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Group-node write caps (editor-tier): scripts → `GroupScriptsWrite`, vars →
|
||||
/// `GroupVarsWrite`, both on prune.
|
||||
async fn require_group_node_writes(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
group_id: GroupId,
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
// Extension points and route/trigger templates gate on the script-write
|
||||
// tier (see the app variant) — all are code-binding declarations.
|
||||
if prune
|
||||
|| !bundle.scripts.is_empty()
|
||||
|| !bundle.extension_points.is_empty()
|
||||
|| !bundle.route_templates.is_empty()
|
||||
|| !bundle.trigger_templates.is_empty()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsWrite(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if prune || !bundle.vars.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupVarsWrite(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What template expansion (§4.5) will write into `app_id` during this tree
|
||||
/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's
|
||||
/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed,
|
||||
/// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` /
|
||||
/// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion
|
||||
/// resolves + seals the recipient app's secret server-side, like a hand-declared
|
||||
/// email trigger).
|
||||
#[derive(Default)]
|
||||
struct Recipient {
|
||||
routes: bool,
|
||||
triggers: bool,
|
||||
email: bool,
|
||||
}
|
||||
|
||||
fn spec_is_email(spec: &serde_json::Value) -> bool {
|
||||
spec.get("kind").and_then(serde_json::Value::as_str) == Some("email")
|
||||
}
|
||||
|
||||
async fn app_receives_template_expansions(
|
||||
svc: &ApplyService,
|
||||
app_id: AppId,
|
||||
bundle: &TreeBundle,
|
||||
) -> Result<Recipient, ApplyError> {
|
||||
let Some(app) = svc
|
||||
.apps
|
||||
.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
else {
|
||||
return Ok(Recipient::default());
|
||||
};
|
||||
let ancestors = svc
|
||||
.groups
|
||||
.ancestors(app.group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
|
||||
|
||||
let mut r = Recipient::default();
|
||||
// Committed templates on any ancestor group.
|
||||
for gid in &ancestor_ids {
|
||||
if !r.routes
|
||||
&& !crate::template_repo::list_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_empty()
|
||||
{
|
||||
r.routes = true;
|
||||
}
|
||||
for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
r.triggers = true;
|
||||
r.email = r.email || spec_is_email(&tt.spec);
|
||||
}
|
||||
}
|
||||
// Templates newly declared by a group node in this bundle that is an ancestor
|
||||
// of the app (existing groups only — a to-create group has no apps).
|
||||
for node in &bundle.nodes {
|
||||
if node.kind != NodeKind::Group {
|
||||
continue;
|
||||
}
|
||||
let declares_routes = !node.bundle.route_templates.is_empty();
|
||||
let declares_triggers = !node.bundle.trigger_templates.is_empty();
|
||||
if !declares_routes && !declares_triggers {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
if ancestor_ids.contains(&g.id) {
|
||||
r.routes = r.routes || declares_routes;
|
||||
r.triggers = r.triggers || declares_triggers;
|
||||
r.email = r.email
|
||||
|| node
|
||||
.bundle
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.any(|t| spec_is_email(&t.spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
||||
async fn resolve_group_id(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: &str,
|
||||
) -> Result<GroupId, ApplyError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
};
|
||||
found
|
||||
.map(|g| g.id)
|
||||
.ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -152,6 +750,9 @@ impl IntoResponse for ApplyError {
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::OwnershipConflict(_) | Self::ApprovalRequired(_) => {
|
||||
(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");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -135,6 +135,13 @@ pub enum Capability {
|
||||
/// Write (set/delete) a group-owned secret (Phase 3). editor+ on the
|
||||
/// group.
|
||||
GroupSecretsWrite(GroupId),
|
||||
/// Read (get / list) group-owned scripts (Phase 4). Resolved via the
|
||||
/// group ancestor walk; viewer+ on the group. Maps to `script:read`.
|
||||
GroupScriptsRead(GroupId),
|
||||
/// Create / update / delete a group-owned script (Phase 4). editor+ on
|
||||
/// the group; maps to `script:write` — the same tier as `AppWriteScript`
|
||||
/// for an app, lifted to the group owner.
|
||||
GroupScriptsWrite(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`+.
|
||||
@@ -199,7 +206,9 @@ impl Capability {
|
||||
| Self::GroupVarsRead(_)
|
||||
| Self::GroupVarsWrite(_)
|
||||
| Self::GroupSecretsRead(_)
|
||||
| Self::GroupSecretsWrite(_) => None,
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::GroupScriptsRead(_)
|
||||
| Self::GroupScriptsWrite(_) => None,
|
||||
Self::AppRead(id)
|
||||
| Self::AppWriteScript(id)
|
||||
| Self::AppWriteRoute(id)
|
||||
@@ -250,7 +259,8 @@ impl Capability {
|
||||
| Self::AppUsersRead(_)
|
||||
| Self::AppVarsRead(_)
|
||||
| Self::GroupRead(_)
|
||||
| Self::GroupVarsRead(_) => Scope::ScriptRead,
|
||||
| Self::GroupVarsRead(_)
|
||||
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
|
||||
Self::AppWriteScript(_)
|
||||
| Self::AppKvWrite(_)
|
||||
| Self::AppDocsWrite(_)
|
||||
@@ -268,6 +278,7 @@ impl Capability {
|
||||
// not app:admin. Only the VALUE READ (GroupSecretsRead) sits at
|
||||
// the admin tier below.
|
||||
| Self::GroupSecretsWrite(_)
|
||||
| Self::GroupScriptsWrite(_)
|
||||
| Self::AppInvoke(_) => Scope::ScriptWrite,
|
||||
Self::AppWriteRoute(_) => Scope::RouteWrite,
|
||||
Self::AppManageDomains(_) => Scope::DomainManage,
|
||||
@@ -453,7 +464,9 @@ async fn role_grants(
|
||||
| Capability::GroupVarsRead(g)
|
||||
| Capability::GroupVarsWrite(g)
|
||||
| Capability::GroupSecretsRead(g)
|
||||
| Capability::GroupSecretsWrite(g) => {
|
||||
| Capability::GroupSecretsWrite(g)
|
||||
| Capability::GroupScriptsRead(g)
|
||||
| Capability::GroupScriptsWrite(g) => {
|
||||
group_member_grants(repo, principal.user_id, cap, g).await
|
||||
}
|
||||
// Creating a root-level group is an instance act — members
|
||||
@@ -513,12 +526,15 @@ async fn group_member_grants(
|
||||
/// 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.
|
||||
// viewer+ reads group metadata, config vars, and scripts.
|
||||
Capability::GroupRead(_)
|
||||
| Capability::GroupVarsRead(_)
|
||||
| Capability::GroupScriptsRead(_) => true,
|
||||
// editor+ writes config vars/secrets/scripts.
|
||||
Capability::GroupWrite(_)
|
||||
| Capability::GroupVarsWrite(_)
|
||||
| Capability::GroupSecretsWrite(_) => {
|
||||
| Capability::GroupSecretsWrite(_)
|
||||
| Capability::GroupScriptsWrite(_) => {
|
||||
matches!(role, AppRole::Editor | AppRole::AppAdmin)
|
||||
}
|
||||
// group_admin manages the group + reads secret VALUES (the
|
||||
|
||||
@@ -42,6 +42,22 @@ pub(crate) const CHAIN_LEVELS_CTE: &str = "\
|
||||
WHERE c.depth < 64 \
|
||||
)";
|
||||
|
||||
/// Group-rooted variant of [`CHAIN_LEVELS_CTE`]: the chain starts at a
|
||||
/// **group** (depth 0 = the group itself, `group_owner` set), then walks
|
||||
/// its ancestor groups nearest-first. There is no `app_owner` level — a
|
||||
/// group origin never sees app-owned rows below it (the §5.5 trust
|
||||
/// boundary). Bind `$1 = group_id`. Used for the lexical module resolver
|
||||
/// when the importing script's defining node is a group.
|
||||
pub(crate) const GROUP_CHAIN_LEVELS_CTE: &str = "\
|
||||
WITH RECURSIVE chain AS ( \
|
||||
SELECT g.id AS group_owner, g.parent_id AS next_group, 0 AS depth \
|
||||
FROM groups g WHERE g.id = $1 \
|
||||
UNION ALL \
|
||||
SELECT g.id, g.parent_id, c.depth + 1 \
|
||||
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 {
|
||||
@@ -210,17 +226,7 @@ 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 sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
@@ -228,6 +234,39 @@ pub async fn fetch_var_candidates(
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Transaction-scoped twin of [`fetch_var_candidates`]: runs the identical
|
||||
/// `CHAIN_LEVELS_CTE` query against an open transaction, so it sees vars
|
||||
/// **written earlier in the same transaction** (not just committed state).
|
||||
/// Used by template expansion so a `{var:NAME}` placeholder resolves against
|
||||
/// a var set in the *same* `pic apply` rather than landing one apply late.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn fetch_var_candidates_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// The `SELECT … FROM chain …` tail appended after [`CHAIN_LEVELS_CTE`] to
|
||||
/// pull env-eligible `vars` candidates, nearest-first. Shared by the pool and
|
||||
/// transaction variants so the two can't drift.
|
||||
const VAR_CANDIDATES_TAIL: &str = "\
|
||||
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";
|
||||
|
||||
/// The masked, resolved view of one inherited secret for `config/effective`:
|
||||
/// which owner/level/scope supplies it — **never** the value.
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -29,9 +29,9 @@ use picloud_executor_core::{
|
||||
};
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
|
||||
AppId, DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
|
||||
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
|
||||
RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
RequestId, Script, ScriptId, ScriptOwner, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use rand::Rng;
|
||||
use uuid::Uuid;
|
||||
@@ -191,6 +191,32 @@ impl ExecLogContext {
|
||||
}
|
||||
|
||||
impl Dispatcher {
|
||||
/// Phase 4 isolation backstop: may `script` run under `app_id`'s context?
|
||||
/// True when the script is owned by `app_id` directly, or by a group on
|
||||
/// its ancestor chain (an inherited group script). App-owned is the
|
||||
/// in-memory fast path — only a group-owned script pays the chain query,
|
||||
/// and a query error fails closed (not invocable). This generalizes the
|
||||
/// pre-Phase-4 same-app guard (`script.app_id == row.app_id`) to the
|
||||
/// hierarchy without changing behavior for app-owned scripts.
|
||||
async fn script_invocable(&self, script: &Script, app_id: AppId) -> bool {
|
||||
if script.is_owned_by_app(app_id) {
|
||||
return true;
|
||||
}
|
||||
if script.group_id.is_none() {
|
||||
return false;
|
||||
}
|
||||
match self.scripts.is_invocable_by_app(script.id, app_id).await {
|
||||
Ok(ok) => ok,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
error = %e, script_id = %script.id, %app_id,
|
||||
"chain-membership check failed; treating script as not invocable"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
|
||||
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
|
||||
/// run for the process lifetime; returned `JoinHandle`s are
|
||||
@@ -359,9 +385,14 @@ impl Dispatcher {
|
||||
// existing consumers. Dead-letter the message so the misfire is
|
||||
// observable rather than executing one app's script under
|
||||
// another app's SdkCallCx.
|
||||
if script.app_id != claimed.app_id {
|
||||
// Phase 4: the target must be invocable in the claimed message's app
|
||||
// context — owned by that app or an inherited group script on its
|
||||
// chain. App-owned scripts take the in-memory fast path; only a group
|
||||
// script pays the chain query, and a non-member fails closed
|
||||
// (dead-lettered, not silently run under another app's SdkCallCx).
|
||||
if !self.script_invocable(&script, claimed.app_id).await {
|
||||
tracing::error!(
|
||||
script_app = %script.app_id,
|
||||
script_app = ?script.app_id,
|
||||
claim_app = %claimed.app_id,
|
||||
script_id = %consumer.script_id,
|
||||
"queue consumer script belongs to a different app; dead-lettering"
|
||||
@@ -440,6 +471,7 @@ impl Dispatcher {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: script.sandbox,
|
||||
app_id: claimed.app_id,
|
||||
script_owner: script.owner(),
|
||||
principal: Some(principal),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: execution_id,
|
||||
@@ -786,7 +818,9 @@ impl Dispatcher {
|
||||
// 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 {
|
||||
// Phase 4: invocable in the outbox row's app context (app-owned or an
|
||||
// inherited group script on the chain); a non-member fails closed.
|
||||
if !self.script_invocable(&script, row.app_id).await {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"trigger outbox target belongs to a different app".into(),
|
||||
));
|
||||
@@ -797,6 +831,7 @@ impl Dispatcher {
|
||||
is_dead_letter_handler: matches!(trigger.kind, TriggerKind::DeadLetter),
|
||||
active: trigger.enabled && script.enabled,
|
||||
script_id: script.id,
|
||||
script_owner: script.owner(),
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
script_updated_at: script.updated_at,
|
||||
@@ -839,6 +874,7 @@ impl Dispatcher {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: resolved.sandbox_overrides,
|
||||
app_id: row.app_id,
|
||||
script_owner: resolved.script_owner,
|
||||
principal: Some(principal),
|
||||
trigger_depth: row.trigger_depth,
|
||||
root_execution_id: row.root_execution_id.unwrap_or(execution_id),
|
||||
@@ -876,7 +912,8 @@ impl Dispatcher {
|
||||
// build_invoke_request and dispatch_one_queue. A hand-edited
|
||||
// outbox row could otherwise execute one app's script under
|
||||
// another app's SdkCallCx.
|
||||
if script.app_id != row.app_id {
|
||||
// Phase 4: invocable in this app's context (see queue/trigger arms).
|
||||
if !self.script_invocable(&script, row.app_id).await {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"http outbox target belongs to a different app".into(),
|
||||
));
|
||||
@@ -901,6 +938,7 @@ impl Dispatcher {
|
||||
rest: payload.rest,
|
||||
sandbox_overrides: script.sandbox,
|
||||
app_id: row.app_id,
|
||||
script_owner: script.owner(),
|
||||
// HTTP outbox rows don't run as the trigger registrant —
|
||||
// they run with no principal (public ingress) or the
|
||||
// attached one (origin_principal forensic field is not
|
||||
@@ -919,6 +957,7 @@ impl Dispatcher {
|
||||
// enqueue is dropped at fire time by the post-match active check.
|
||||
active: script.enabled,
|
||||
script_id,
|
||||
script_owner: script.owner(),
|
||||
script_source: script.source,
|
||||
script_name: payload.script_name,
|
||||
script_updated_at: script.updated_at,
|
||||
@@ -969,7 +1008,8 @@ impl Dispatcher {
|
||||
})?;
|
||||
// Same-app guard — the script could have been re-assigned or
|
||||
// the row could have been hand-edited. Reject mismatches.
|
||||
if script.app_id != row.app_id {
|
||||
// Phase 4: invocable in this app's context (app-owned or inherited).
|
||||
if !self.script_invocable(&script, row.app_id).await {
|
||||
return Err(DispatcherError::ResolveTrigger(
|
||||
"invoke target belongs to a different app".into(),
|
||||
));
|
||||
@@ -1000,6 +1040,7 @@ impl Dispatcher {
|
||||
rest: String::new(),
|
||||
sandbox_overrides: script.sandbox,
|
||||
app_id: row.app_id,
|
||||
script_owner: script.owner(),
|
||||
// invoke_async runs with no principal — same as HTTP outbox.
|
||||
principal: None,
|
||||
trigger_depth,
|
||||
@@ -1019,6 +1060,7 @@ impl Dispatcher {
|
||||
// enqueue is dropped at fire time by the post-match active check.
|
||||
active: script.enabled,
|
||||
script_id: script.id,
|
||||
script_owner: script.owner(),
|
||||
script_source: script.source,
|
||||
script_name: script.name,
|
||||
script_updated_at: script.updated_at,
|
||||
@@ -1321,6 +1363,10 @@ pub struct ResolvedTrigger {
|
||||
pub script_id: ScriptId,
|
||||
pub script_source: String,
|
||||
pub script_name: String,
|
||||
/// Phase 4b: the resolved script's defining node — the lexical origin
|
||||
/// for its `import`s (§5.5). `None` only for a corrupt owner row; the
|
||||
/// executor then falls back to `App(app_id)`.
|
||||
pub script_owner: Option<ScriptOwner>,
|
||||
/// v1.1.3: freshness comparator for the orchestrator's top-level
|
||||
/// script cache. The dispatcher hands `(script_id, updated_at)`
|
||||
/// in alongside the source so cached ASTs can be reused across
|
||||
@@ -1640,6 +1686,20 @@ mod tests {
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
_app_id: AppId,
|
||||
_name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
_script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
Ok(self.script.is_owned_by_app(app_id))
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
@@ -1649,6 +1709,12 @@ mod tests {
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!("not used by this test")
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
@@ -2097,7 +2163,8 @@ mod tests {
|
||||
pub(super) fn disabled_script(app_id: AppId) -> Script {
|
||||
Script {
|
||||
id: ScriptId::new(),
|
||||
app_id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
name: "worker".into(),
|
||||
description: None,
|
||||
version: 1,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! future CLI/orchestrator can detect structural drift (§6).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{Group, GroupId};
|
||||
use picloud_shared::{AppId, Group, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -191,27 +191,11 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
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()),
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Interactive create: UI/API-owned (no project claim — §7.5).
|
||||
let g = create_group_tx(&mut tx, slug, name, description, parent_id, None).await?;
|
||||
tx.commit().await?;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
@@ -247,96 +231,342 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
) -> 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));
|
||||
};
|
||||
// cycle guard + parent write can't race a concurrent reparent.
|
||||
acquire_structural_lock_tx(&mut tx).await?;
|
||||
let g = reparent_group_tx(&mut tx, id, new_parent).await?;
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
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 mut tx = self.pool.begin().await?;
|
||||
delete_group_tx(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Transaction-aware structural mutations (Phase 5+ declarative tree apply).
|
||||
//
|
||||
// The declarative `apply_tree` reconciles a whole subtree in ONE transaction,
|
||||
// so group create/reparent/delete must run inside the caller's tx (not on the
|
||||
// pool) to stay all-or-nothing. These free functions hold the SQL; the trait
|
||||
// methods above delegate to them (begin → fn → commit) so there is one SQL
|
||||
// definition each. The caller takes [`acquire_structural_lock_tx`] ONCE before
|
||||
// the structure phase, so the cycle guard + parent writes serialize against
|
||||
// concurrent reparents exactly as the single-shot `reparent` does.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Take the coarse structural lock inside the caller's transaction. Held until
|
||||
/// the tx commits/rolls back. Call once before any `*_group_tx` mutation.
|
||||
pub(crate) async fn acquire_structural_lock_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a group inside the caller's tx. Maps unique/FK violations to a clean
|
||||
/// conflict. (The structural lock is not required for a pure insert, but the
|
||||
/// apply path holds it for the whole phase anyway.)
|
||||
pub(crate) async fn create_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
owner_project: Option<Uuid>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id, owner_project) \
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.bind(owner_project)
|
||||
.fetch_one(&mut **tx)
|
||||
.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()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reparent a group inside the caller's tx. Runs the ancestor-walk cycle guard
|
||||
/// against the IN-TX tree (so it sees parent writes made earlier in the same
|
||||
/// apply). The caller must already hold [`acquire_structural_lock_tx`].
|
||||
pub(crate) async fn reparent_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
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(),
|
||||
))
|
||||
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),
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(e) => Err(e.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?;
|
||||
row.map(Into::into)
|
||||
.ok_or(GroupRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
/// Delete an empty group inside the caller's tx (delete = RESTRICT). Refused
|
||||
/// with a clean conflict if it still has child groups or apps.
|
||||
pub(crate) async fn delete_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: GroupId,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
let counts: (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(&mut **tx)
|
||||
.await?;
|
||||
if counts.0 != 0 || counts.1 != 0 {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.0, counts.1
|
||||
)));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut **tx)
|
||||
.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() => {
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Project ownership (§7, M3). A `projects` row is keyed by the stable, opaque
|
||||
// key the CLI mints in `.picloud/`; `groups.owner_project` references it. These
|
||||
// helpers are free functions (pool + tx variants) so the apply path can claim
|
||||
// ownership inside the single apply transaction (first-commit-wins), while the
|
||||
// read-only plan path can surface conflicts without writing.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a project key to its id, if the project has ever been persisted.
|
||||
/// `None` means no apply has claimed under this key yet (so `plan` treats every
|
||||
/// node as claimable). Read-only — used by the plan path.
|
||||
pub(crate) async fn get_project_id_by_key(
|
||||
pool: &PgPool,
|
||||
key: &str,
|
||||
) -> Result<Option<Uuid>, GroupRepositoryError> {
|
||||
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM projects WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.0))
|
||||
}
|
||||
|
||||
/// Upsert the project keyed by `key` inside the apply tx and return its id.
|
||||
/// Idempotent: re-applying the same repo reuses the same project identity.
|
||||
pub(crate) async fn upsert_project_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
key: &str,
|
||||
) -> Result<Uuid, GroupRepositoryError> {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO projects (key) VALUES ($1) \
|
||||
ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key RETURNING id",
|
||||
)
|
||||
.bind(key)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
/// The project that owns `group_id`, as `(project_id, project_key)`. `None` when
|
||||
/// the node is UI/API-owned (no claim). Read-only — used by plan + the in-tx
|
||||
/// authoritative re-check. Joins through `projects` so the caller gets the
|
||||
/// human-facing key for a conflict message.
|
||||
pub(crate) async fn get_group_owner(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
|
||||
let row: Option<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT p.id, p.key FROM groups g \
|
||||
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// The same owner lookup, but inside the apply tx — the authoritative read that
|
||||
/// the claim/conflict decision keys on (so a concurrent claim that committed
|
||||
/// after `plan` is seen under this tx's per-node advisory lock).
|
||||
pub(crate) async fn get_group_owner_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
|
||||
let row: Option<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT p.id, p.key FROM groups g \
|
||||
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Stamp (claim or take over) `group_id`'s owning project inside the apply tx.
|
||||
/// Bumps `structure_version` so an ownership flip trips a bound plan token.
|
||||
pub(crate) async fn set_group_owner_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
project_id: Uuid,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE groups SET owner_project = $2, \
|
||||
structure_version = structure_version + 1, updated_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(project_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(GroupRepositoryError::NotFound(group_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every group `(id, slug)` owned by `project_id` — the candidate set for
|
||||
/// structural prune (those absent from the manifest are the ones to delete).
|
||||
/// Read-only; the apply path filters and deletes leaf-first under `delete_tx`.
|
||||
pub(crate) async fn list_owned_groups(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<(GroupId, String)>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid, String)> =
|
||||
sqlx::query_as("SELECT id, slug FROM groups WHERE owner_project = $1")
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, slug)| (id.into(), slug))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The same owned-group enumeration inside the apply tx, with each node's
|
||||
/// `parent_id` so the prune can delete leaf-first (children before parents).
|
||||
pub(crate) async fn list_owned_groups_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<(GroupId, String, Option<GroupId>)>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid, String, Option<Uuid>)> =
|
||||
sqlx::query_as("SELECT id, slug, parent_id FROM groups WHERE owner_project = $1")
|
||||
.bind(project_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, slug, parent)| (id.into(), slug, parent.map(GroupId::from)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Recursive descendant-app query, the inverse of the ancestor `CHAIN_LEVELS_CTE`
|
||||
/// in `config_resolver`: walk `groups.parent_id` DOWN from `$1` to collect the
|
||||
/// whole subtree of group ids, then every app whose `group_id` falls in it.
|
||||
/// Ordered by `app_id` so callers lock/iterate deterministically. Depth-bounded
|
||||
/// `< 64` as a runaway guard (the cycle guard already forbids real cycles).
|
||||
const DESCENDANT_APPS_SQL: &str = "\
|
||||
WITH RECURSIVE subtree AS ( \
|
||||
SELECT id, 0 AS depth FROM groups WHERE id = $1 \
|
||||
UNION ALL \
|
||||
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id \
|
||||
WHERE s.depth < 64 \
|
||||
) \
|
||||
SELECT a.id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree) ORDER BY a.id";
|
||||
|
||||
/// Every app in the subtree rooted at `group_id` (the group itself and all
|
||||
/// descendant groups), `app_id`-ordered. The read-only pool variant — used by
|
||||
/// `plan` to size a template's true blast radius across the whole DB subtree,
|
||||
/// not just the apps present in the current apply.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn descendant_app_ids(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<AppId>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
|
||||
}
|
||||
|
||||
/// The same subtree enumeration inside the apply tx, so groups/apps created
|
||||
/// earlier in this transaction (M2 structural reconcile) are included when
|
||||
/// fanning a template out to its descendants (§4.5, M7).
|
||||
pub(crate) async fn descendant_app_ids_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<AppId>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
225
crates/manager-core/src/group_scripts_api.rs
Normal file
225
crates/manager-core/src/group_scripts_api.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! `/api/v1/admin/groups/{id}/scripts*` — group-owned script admin (Phase 4).
|
||||
//!
|
||||
//! * `GET /groups/{id}/scripts` — list the group's own scripts (not
|
||||
//! inherited; just rows owned directly by this group). Gated by
|
||||
//! `GroupScriptsRead` (viewer+ on the group).
|
||||
//! * `POST /groups/{id}/scripts` — create a group-owned script. Gated by
|
||||
//! `GroupScriptsWrite` (editor+ on the group).
|
||||
//!
|
||||
//! A group script is a **template** inherited by every descendant app,
|
||||
//! resolved by name with nearest-owner-wins (CoW). Get / update / delete of an
|
||||
//! existing group script go through the by-id `/scripts/{id}` endpoints, which
|
||||
//! are owner-polymorphic — a group script there gates on `GroupScripts*`.
|
||||
//!
|
||||
//! **Phase 4-lite scope:** group ENDPOINT scripts only, and they must be
|
||||
//! self-contained — `kind=module` and any `import` are rejected here, because
|
||||
//! the origin-aware (lexical) module resolver is Phase 4b. The owner is
|
||||
//! resolved FIRST (slug-or-uuid) and `authz::require` binds the capability to
|
||||
//! the resolved group id, never to a caller-controlled path param.
|
||||
|
||||
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::{
|
||||
GroupId, Principal, Script, ScriptKind, ScriptSandbox, ScriptValidator, ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||
use crate::sandbox::SandboxCeiling;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GroupScriptsState {
|
||||
pub scripts: Arc<dyn ScriptRepository>,
|
||||
pub groups: Arc<dyn GroupRepository>,
|
||||
pub authz: Arc<dyn AuthzRepo>,
|
||||
pub validator: Arc<dyn ScriptValidator>,
|
||||
pub sandbox_ceiling: SandboxCeiling,
|
||||
}
|
||||
|
||||
pub fn group_scripts_router(state: GroupScriptsState) -> Router {
|
||||
Router::new()
|
||||
.route(
|
||||
"/groups/{group_id}/scripts",
|
||||
get(list_group_scripts).post(create_group_script),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateGroupScriptRequest {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
/// Phase 4-lite accepts only `endpoint`. A `module` is rejected (group
|
||||
/// modules + the lexical import resolver are Phase 4b).
|
||||
#[serde(default)]
|
||||
pub kind: ScriptKind,
|
||||
pub timeout_seconds: Option<i32>,
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub sandbox: ScriptSandbox,
|
||||
}
|
||||
|
||||
async fn list_group_scripts(
|
||||
State(s): State<GroupScriptsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<Script>>, GroupScriptsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(s.scripts.list_for_group(group_id).await?))
|
||||
}
|
||||
|
||||
async fn create_group_script(
|
||||
State(s): State<GroupScriptsState>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<CreateGroupScriptRequest>,
|
||||
) -> Result<(StatusCode, Json<Script>), GroupScriptsApiError> {
|
||||
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
|
||||
require(
|
||||
s.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsWrite(group_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Phase 4b: group modules + imports are allowed. Imports resolve
|
||||
// lexically from this group's chain at runtime (§5.5); the recorded
|
||||
// edges feed the declarative dangling-import plan check.
|
||||
let validated = s.validator.validate(&input.source)?;
|
||||
s.sandbox_ceiling
|
||||
.check(&input.sandbox)
|
||||
.map_err(|e| GroupScriptsApiError::Invalid(e.to_string()))?;
|
||||
|
||||
let created = s
|
||||
.scripts
|
||||
.create(NewScript {
|
||||
app_id: None,
|
||||
group_id: Some(group_id),
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
kind: input.kind,
|
||||
timeout_seconds: input.timeout_seconds,
|
||||
memory_limit_mb: input.memory_limit_mb,
|
||||
sandbox: if input.sandbox.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(input.sandbox)
|
||||
},
|
||||
enabled: true,
|
||||
imports: validated.imports,
|
||||
})
|
||||
.await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn resolve_group(
|
||||
groups: &dyn GroupRepository,
|
||||
ident: &str,
|
||||
) -> Result<GroupId, GroupScriptsApiError> {
|
||||
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
groups
|
||||
.get_by_id(uuid.into())
|
||||
.await
|
||||
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
|
||||
} else {
|
||||
groups
|
||||
.get_by_slug(ident)
|
||||
.await
|
||||
.map_err(|e| GroupScriptsApiError::Backend(e.to_string()))?
|
||||
};
|
||||
found
|
||||
.map(|g| g.id)
|
||||
.ok_or(GroupScriptsApiError::GroupNotFound)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum GroupScriptsApiError {
|
||||
#[error("group not found")]
|
||||
GroupNotFound,
|
||||
#[error("invalid request: {0}")]
|
||||
Invalid(String),
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("authorization repo error: {0}")]
|
||||
AuthzRepo(String),
|
||||
#[error("scripts backend: {0}")]
|
||||
Backend(String),
|
||||
}
|
||||
|
||||
impl From<AuthzDenied> for GroupScriptsApiError {
|
||||
fn from(d: AuthzDenied) -> Self {
|
||||
match d {
|
||||
AuthzDenied::Denied => Self::Forbidden,
|
||||
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthzError> for GroupScriptsApiError {
|
||||
fn from(e: AuthzError) -> Self {
|
||||
Self::AuthzRepo(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ValidationError> for GroupScriptsApiError {
|
||||
fn from(e: ValidationError) -> Self {
|
||||
Self::Invalid(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScriptRepositoryError> for GroupScriptsApiError {
|
||||
fn from(e: ScriptRepositoryError) -> Self {
|
||||
match e {
|
||||
ScriptRepositoryError::Conflict(m) => Self::Conflict(m),
|
||||
ScriptRepositoryError::NotFound(id) => Self::Invalid(format!("script {id} not found")),
|
||||
ScriptRepositoryError::Db(e) => Self::Backend(e.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for GroupScriptsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::GroupNotFound => (StatusCode::NOT_FOUND, json!({ "error": self.to_string() })),
|
||||
Self::Invalid(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "group-scripts admin authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "group-scripts admin backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,11 @@ impl InvokeServiceImpl {
|
||||
.await
|
||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| InvokeError::NotFound(format!("id {script_id}")))?;
|
||||
if script.app_id != cx.app_id {
|
||||
// Phase 4: group scripts (`app_id: None`) fail closed here — invoke()
|
||||
// by-id stays app-owned until chain-aware resolution lands. The
|
||||
// execution context is the *caller's* app (`cx.app_id`), never read
|
||||
// off the target script.
|
||||
if !script.is_owned_by_app(cx.app_id) {
|
||||
return Err(InvokeError::CrossApp);
|
||||
}
|
||||
if !script.enabled {
|
||||
@@ -90,7 +94,8 @@ impl InvokeServiceImpl {
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
app_id: cx.app_id,
|
||||
owner: script.owner(),
|
||||
source: script.source,
|
||||
updated_at: script.updated_at,
|
||||
name: script.name,
|
||||
@@ -102,9 +107,13 @@ impl InvokeServiceImpl {
|
||||
cx: &SdkCallCx,
|
||||
name: &str,
|
||||
) -> Result<ResolvedScript, InvokeError> {
|
||||
// Phase 4: resolve down the app's chain — an inherited group script
|
||||
// of this name is reachable, nearest-owner-wins (an app's own script
|
||||
// shadows it). The chain only contains the app + its ancestors, so the
|
||||
// resolved script is invocable by `cx.app_id` by construction.
|
||||
let script = self
|
||||
.scripts
|
||||
.get_by_name(cx.app_id, name)
|
||||
.get_by_name_inherited(cx.app_id, name)
|
||||
.await
|
||||
.map_err(|e| InvokeError::Backend(e.to_string()))?
|
||||
.ok_or_else(|| InvokeError::NotFound(format!("name {name:?}")))?;
|
||||
@@ -113,7 +122,12 @@ impl InvokeServiceImpl {
|
||||
}
|
||||
Ok(ResolvedScript {
|
||||
script_id: script.id,
|
||||
app_id: script.app_id,
|
||||
// The execution-context app is always the caller's app — a group
|
||||
// script runs under the inheriting app, never its owner.
|
||||
app_id: cx.app_id,
|
||||
// …but its `import`s resolve from its *defining node* (the group
|
||||
// for an inherited script) — the lexical origin (§5.5).
|
||||
owner: script.owner(),
|
||||
source: script.source,
|
||||
updated_at: script.updated_at,
|
||||
name: script.name,
|
||||
@@ -227,19 +241,40 @@ mod tests {
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
Ok(
|
||||
if app_id == self.script.app_id && name == self.script.name {
|
||||
if self.script.app_id == Some(app_id) && name == self.script.name {
|
||||
Some(self.script.clone())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
// This one-script repo has no hierarchy; inherited == own-scope.
|
||||
self.get_by_name(app_id, name).await
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
_app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
Ok(script_id == self.script.id)
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
Ok(vec![self.script.clone()])
|
||||
}
|
||||
async fn list_for_app(&self, _app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
@@ -312,7 +347,8 @@ mod tests {
|
||||
fn make_script(app_id: AppId, name: &str) -> Script {
|
||||
Script {
|
||||
id: ScriptId::new(),
|
||||
app_id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
name: name.into(),
|
||||
description: None,
|
||||
version: 1,
|
||||
|
||||
@@ -51,6 +51,7 @@ pub mod files_sweep;
|
||||
pub mod gc;
|
||||
pub mod group_members_repo;
|
||||
pub mod group_repo;
|
||||
pub mod group_scripts_api;
|
||||
pub mod groups_api;
|
||||
pub mod http_service;
|
||||
pub mod invoke_service;
|
||||
@@ -79,6 +80,7 @@ pub mod secrets_api;
|
||||
pub mod secrets_repo;
|
||||
pub mod secrets_service;
|
||||
pub mod ssrf;
|
||||
pub mod template_repo;
|
||||
pub mod topic_repo;
|
||||
pub mod topics_api;
|
||||
pub mod trigger_config;
|
||||
@@ -182,6 +184,7 @@ pub use group_repo::{
|
||||
GroupChildCounts, GroupRepository, GroupRepositoryError, PostgresGroupRepository,
|
||||
ROOT_GROUP_SLUG,
|
||||
};
|
||||
pub use group_scripts_api::{group_scripts_router, GroupScriptsApiError, GroupScriptsState};
|
||||
pub use groups_api::{groups_router, GroupsApiError, GroupsState};
|
||||
pub use http_service::{HttpConfig, HttpServiceImpl};
|
||||
pub use kv_api::{kv_admin_router, KvAdminState};
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
|
||||
//!
|
||||
//! Mirrors the structure of [`crate::kv_repo::PostgresKvRepo`] /
|
||||
//! [`crate::docs_repo::PostgresDocsRepo`]: thin wrapper around a
|
||||
//! `PgPool` that owns a single statement returning the module by
|
||||
//! `(cx.app_id, name, kind = 'module')`. The resolver lives in
|
||||
//! `executor-core` and consumes this trait through the `Services`
|
||||
//! bundle, so manager-core stays the only crate that touches
|
||||
//! Postgres.
|
||||
//! Resolution is **lexical** (§5.5): a module is looked up by walking the
|
||||
//! group chain rooted at the **importing script's defining node**
|
||||
//! (`origin`), nearest-owner-wins — *not* the inheriting app's effective
|
||||
//! view. An `App` origin walks `app → its group → ancestors` (reusing
|
||||
//! [`CHAIN_LEVELS_CTE`]); a `Group` origin walks `group → ancestors`
|
||||
//! ([`GROUP_CHAIN_LEVELS_CTE`]) and never sees app-owned modules below it
|
||||
//! (the trust boundary). The resolver lives in `executor-core` and consumes
|
||||
//! this trait through the `Services` bundle, so manager-core stays the only
|
||||
//! crate that touches Postgres.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, SdkCallCx};
|
||||
use picloud_shared::{
|
||||
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptId, ScriptOwner,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
|
||||
|
||||
pub struct PostgresModuleSource {
|
||||
pool: PgPool,
|
||||
}
|
||||
@@ -27,7 +33,8 @@ impl PostgresModuleSource {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ModuleRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
app_id: Option<uuid::Uuid>,
|
||||
group_id: Option<uuid::Uuid>,
|
||||
name: String,
|
||||
source: String,
|
||||
updated_at: DateTime<Utc>,
|
||||
@@ -37,7 +44,8 @@ impl From<ModuleRow> for ModuleScript {
|
||||
fn from(r: ModuleRow) -> Self {
|
||||
Self {
|
||||
script_id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
app_id: r.app_id.map(Into::into),
|
||||
group_id: r.group_id.map(Into::into),
|
||||
name: r.name,
|
||||
source: r.source,
|
||||
updated_at: r.updated_at,
|
||||
@@ -47,25 +55,132 @@ impl From<ModuleRow> for ModuleScript {
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for PostgresModuleSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
// The query is the cross-app isolation boundary: app_id comes
|
||||
// from cx (never from the script-passed argument), and the
|
||||
// CHECK constraint `kind IN ('endpoint','module')` plus the
|
||||
// `kind = 'module'` filter together guarantee endpoint scripts
|
||||
// are never importable. The `(app_id, kind)` index from
|
||||
// migration 0015 makes this an index scan returning at most
|
||||
// one row (per-app uniqueness on `name`).
|
||||
let row: Option<ModuleRow> = sqlx::query_as(
|
||||
"SELECT id, app_id, name, source, updated_at \
|
||||
FROM scripts \
|
||||
WHERE app_id = $1 AND kind = 'module' AND name = $2",
|
||||
)
|
||||
.bind(cx.app_id.into_inner())
|
||||
.bind(name)
|
||||
// Lexical lookup: JOIN module scripts against the chain rooted at
|
||||
// `origin`, take the nearest level (lowest depth). The `kind =
|
||||
// 'module'` filter + the CHECK constraint guarantee endpoint
|
||||
// scripts are never importable. Case-insensitive to match the
|
||||
// per-owner `LOWER(name)` partial-unique indexes (0050). `origin`
|
||||
// is a trusted dispatch-derived value, so the chain it roots is
|
||||
// the script's true ancestry — the cross-app isolation boundary
|
||||
// is preserved (a group origin can't reach an app's modules).
|
||||
let query = match origin {
|
||||
ScriptOwner::App(_) => format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
||||
FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
),
|
||||
ScriptOwner::Group(_) => format!(
|
||||
"{GROUP_CHAIN_LEVELS_CTE} \
|
||||
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
||||
FROM scripts s \
|
||||
JOIN chain c ON s.group_id = c.group_owner \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
),
|
||||
};
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let row: Option<ModuleRow> = sqlx::query_as(&query)
|
||||
.bind(root)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
|
||||
// Find the nearest extension-point declaration of `name` on `origin`'s
|
||||
// chain that is NOT shadowed by a concrete module of the same name at
|
||||
// depth <= its own (tie → concrete wins). The NOT EXISTS encodes that
|
||||
// shadowing rule; ORDER BY depth ASC LIMIT 1 picks the nearest
|
||||
// surviving declaration. An App origin can see ext points declared on
|
||||
// an ancestor group (or on the app itself); a Group origin only walks
|
||||
// groups — same trust boundary as `resolve`.
|
||||
let (cte, ep_join, mod_join) = match origin {
|
||||
ScriptOwner::App(_) => (
|
||||
CHAIN_LEVELS_CTE,
|
||||
"(e.group_id = c.group_owner OR e.app_id = c.app_owner)",
|
||||
"(s.app_id = c2.app_owner OR s.group_id = c2.group_owner)",
|
||||
),
|
||||
ScriptOwner::Group(_) => (
|
||||
GROUP_CHAIN_LEVELS_CTE,
|
||||
"e.group_id = c.group_owner",
|
||||
"s.group_id = c2.group_owner",
|
||||
),
|
||||
};
|
||||
let query = format!(
|
||||
"{cte} \
|
||||
SELECT e.default_script_id \
|
||||
FROM extension_points e \
|
||||
JOIN chain c ON {ep_join} \
|
||||
WHERE LOWER(e.name) = LOWER($2) \
|
||||
AND NOT EXISTS ( \
|
||||
SELECT 1 FROM scripts s \
|
||||
JOIN chain c2 ON {mod_join} \
|
||||
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
||||
AND c2.depth <= c.depth \
|
||||
) \
|
||||
ORDER BY c.depth ASC LIMIT 1",
|
||||
);
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(&query)
|
||||
.bind(root)
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
Ok(row.map(|(default_script_id,)| ExtPointResolution {
|
||||
default_script_id: default_script_id.map(Into::into),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
// Load a module body by id (the ext-point fallback), but ONLY if it is
|
||||
// owned by a node on `origin`'s chain — the same trust boundary as
|
||||
// `resolve`. This keeps the resolver self-defending: a `default_script_id`
|
||||
// that ever pointed off-chain resolves to None instead of executing
|
||||
// cross-tenant code. Endpoints are never importable (kind filter).
|
||||
let (cte, join) = match origin {
|
||||
ScriptOwner::App(_) => (
|
||||
CHAIN_LEVELS_CTE,
|
||||
"(s.app_id = c.app_owner OR s.group_id = c.group_owner)",
|
||||
),
|
||||
ScriptOwner::Group(_) => (GROUP_CHAIN_LEVELS_CTE, "s.group_id = c.group_owner"),
|
||||
};
|
||||
let root = match origin {
|
||||
ScriptOwner::App(a) => a.into_inner(),
|
||||
ScriptOwner::Group(g) => g.into_inner(),
|
||||
};
|
||||
let row: Option<ModuleRow> = sqlx::query_as(&format!(
|
||||
"{cte} \
|
||||
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
||||
FROM scripts s JOIN chain c ON {join} \
|
||||
WHERE s.id = $2 AND s.kind = 'module' LIMIT 1"
|
||||
))
|
||||
.bind(root)
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::collections::BTreeMap;
|
||||
use async_trait::async_trait;
|
||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||
use picloud_shared::{
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, RequestId, Script,
|
||||
AdminUserId, AppId, ExecutionLog, ExecutionSource, ExecutionStatus, GroupId, RequestId, Script,
|
||||
ScriptId, ScriptKind, ScriptSandbox,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
@@ -34,10 +34,34 @@ pub trait ScriptRepository: Send + Sync {
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Phase 4: resolve `name → Script` down `app_id`'s ownership chain —
|
||||
/// the app itself, then its ancestor groups nearest-first. Nearest owner
|
||||
/// wins (an app's own script shadows an inherited group one of the same
|
||||
/// name: CoW). Returns `None` if no script in the chain has that name.
|
||||
/// Backs inherited `invoke("name")` and declarative name→id binding.
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Phase 4: is `script_id` invocable in `app_id`'s context? True iff the
|
||||
/// script is owned by `app_id` directly, OR owned by a group on `app_id`'s
|
||||
/// ancestor chain. The cross-app isolation predicate generalized to the
|
||||
/// hierarchy — the runtime backstop for trigger/queue/invoke dispatch and
|
||||
/// trigger-bind validation. A missing script returns `false` (fail closed).
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError>;
|
||||
/// Every script across all apps. Mostly for tests and admin
|
||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
/// Phase 4: every script owned directly by `group_id` (not inherited —
|
||||
/// just the group's own rows). Backs the group-script admin list.
|
||||
async fn list_for_group(&self, group_id: GroupId)
|
||||
-> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
/// Every script in any app the user is a member of. Drives
|
||||
/// `GET /admin/scripts` for `member` instance-role callers so the
|
||||
/// API never returns scripts they shouldn't see — even before the
|
||||
@@ -93,12 +117,32 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
(**self).get_by_name(app_id, name).await
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
(**self).get_by_name_inherited(app_id, name).await
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
(**self).is_invocable_by_app(script_id, app_id).await
|
||||
}
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
(**self).list().await
|
||||
}
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
(**self).list_for_app(app_id).await
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
(**self).list_for_group(group_id).await
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
@@ -142,7 +186,13 @@ impl<T: ScriptRepository + ?Sized> ScriptRepository for std::sync::Arc<T> {
|
||||
/// constraints; the repo enforces them in the DB regardless.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewScript {
|
||||
pub app_id: AppId,
|
||||
/// App owner (the common case). Exactly one of `app_id`/`group_id` must
|
||||
/// be `Some` — the DB CHECK is the backstop, but callers should uphold
|
||||
/// it. App-owned creation continues to pass `Some(app_id)`, `group_id:
|
||||
/// None`; group-owned creation (Phase 4) inverts that.
|
||||
pub app_id: Option<AppId>,
|
||||
/// Group owner (Phase 4). See [`NewScript::app_id`].
|
||||
pub group_id: Option<GroupId>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -206,7 +256,7 @@ impl PostgresScriptRepository {
|
||||
/// Columns selected from `scripts` everywhere — kept in one constant so
|
||||
/// 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, \
|
||||
const SCRIPT_SELECT_COLS: &str = "id, app_id, group_id, name, description, version, source, kind, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, enabled, \
|
||||
created_at, updated_at";
|
||||
|
||||
@@ -237,6 +287,58 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
// Walk app → ancestor groups (CHAIN_LEVELS_CTE binds $1 = app_id),
|
||||
// join scripts owned at each level, and take the nearest (lowest
|
||||
// depth) row named `name`. Case-insensitive to match the per-owner
|
||||
// `LOWER(name)` unique indexes. Mirrors the vars/secrets resolvers.
|
||||
let cols = SCRIPT_SELECT_COLS
|
||||
.split(", ")
|
||||
.map(|c| format!("s.{c}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let row = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"{cte} SELECT {cols} FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE LOWER(s.name) = LOWER($2) \
|
||||
ORDER BY c.depth ASC \
|
||||
LIMIT 1",
|
||||
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(name)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, ScriptRepositoryError> {
|
||||
// EXISTS over the same chain: the script's owner (app or group) must
|
||||
// appear on `app_id`'s app→ancestor-group chain. CHAIN_LEVELS_CTE
|
||||
// binds $1 = app_id; $2 = script_id.
|
||||
let exists: (bool,) = sqlx::query_as(&format!(
|
||||
"{cte} SELECT EXISTS ( \
|
||||
SELECT 1 FROM scripts s \
|
||||
JOIN chain c ON (s.app_id = c.app_owner OR s.group_id = c.group_owner) \
|
||||
WHERE s.id = $2 \
|
||||
)",
|
||||
cte = crate::config_resolver::CHAIN_LEVELS_CTE,
|
||||
))
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(exists.0)
|
||||
}
|
||||
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts ORDER BY name"
|
||||
@@ -256,6 +358,19 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"SELECT {SCRIPT_SELECT_COLS} FROM scripts 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 list_for_user(
|
||||
&self,
|
||||
user_id: AdminUserId,
|
||||
@@ -397,12 +512,13 @@ pub(crate) async fn insert_script_tx(
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(&format!(
|
||||
"INSERT INTO scripts ( \
|
||||
app_id, name, description, source, kind, \
|
||||
app_id, group_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) \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 30), COALESCE($8, 256), $9, $10) \
|
||||
RETURNING {SCRIPT_SELECT_COLS}"
|
||||
))
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.app_id.map(AppId::into_inner))
|
||||
.bind(input.group_id.map(GroupId::into_inner))
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
@@ -417,13 +533,18 @@ pub(crate) async fn insert_script_tx(
|
||||
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",
|
||||
"a script named {:?} already exists in this owner",
|
||||
input.name
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
replace_imports_tx(tx, script.id, script.app_id, &input.imports).await?;
|
||||
// Module imports are resolved within the owning app's script set. Group
|
||||
// scripts must be self-contained in Phase 4-lite (the origin-aware import
|
||||
// resolver is Phase 4b), so only app-owned scripts wire up import edges.
|
||||
if let Some(app_id) = script.app_id {
|
||||
replace_imports_tx(tx, script.id, app_id, &input.imports).await?;
|
||||
}
|
||||
Ok(script)
|
||||
}
|
||||
|
||||
@@ -470,13 +591,15 @@ pub(crate) async fn update_script_tx(
|
||||
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(),
|
||||
"a script with that name already exists in this owner".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?;
|
||||
if let Some(app_id) = script.app_id {
|
||||
replace_imports_tx(tx, script.id, app_id, imports).await?;
|
||||
}
|
||||
}
|
||||
Ok(script)
|
||||
}
|
||||
@@ -501,7 +624,10 @@ pub(crate) async fn delete_script_tx(
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
/// Polymorphic owner (Phase 4): exactly one of `app_id`/`group_id` is
|
||||
/// non-NULL (DB CHECK). App-owned rows keep `app_id` set as before.
|
||||
app_id: Option<uuid::Uuid>,
|
||||
group_id: Option<uuid::Uuid>,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
version: i32,
|
||||
@@ -531,7 +657,8 @@ impl From<ScriptRow> for Script {
|
||||
let kind = ScriptKind::parse_str(&r.kind).unwrap_or(ScriptKind::Endpoint);
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
app_id: r.app_id.map(Into::into),
|
||||
group_id: r.group_id.map(Into::into),
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
version: r.version,
|
||||
|
||||
@@ -148,10 +148,16 @@ async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
// Phase 4: routes bind to app-owned scripts; a group-owned script
|
||||
// (`app_id: None`) is not addressable here (binding lands in C3), so it
|
||||
// reads as a missing script.
|
||||
let app_id = script
|
||||
.app_id
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppRead(script.app_id),
|
||||
Capability::AppRead(app_id),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||
@@ -176,7 +182,11 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
let app_id = script.app_id;
|
||||
// Phase 4: only app-owned scripts are route-bindable for now (group
|
||||
// binding is C3); a group script reads as missing here.
|
||||
let app_id = script
|
||||
.app_id
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
require(
|
||||
state.authz.as_ref(),
|
||||
&principal,
|
||||
@@ -231,6 +241,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
// Routes are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
// Hand-declared via the interactive API — not a template expansion.
|
||||
from_template: None,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
|
||||
@@ -23,6 +23,10 @@ pub struct NewRoute {
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
/// Provenance (§4.5, M4): the route-template id this row was expanded from,
|
||||
/// or `None` for a hand-declared route. Defaults `None` everywhere except
|
||||
/// template expansion.
|
||||
pub from_template: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -175,8 +179,8 @@ pub(crate) async fn insert_route_tx(
|
||||
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) \
|
||||
path_kind, path, method, dispatch_mode, enabled, from_template \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
||||
)
|
||||
@@ -190,6 +194,7 @@ pub(crate) async fn insert_route_tx(
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.bind(input.enabled)
|
||||
.bind(input.from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
|
||||
318
crates/manager-core/src/template_repo.rs
Normal file
318
crates/manager-core/src/template_repo.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! CRUD over `route_templates` (§4.5, M4a) — route declarations owned by a
|
||||
//! group that the apply engine fans out into one concrete `routes` row per
|
||||
//! descendant app. Mirrors the tx-accepting free-function pattern of
|
||||
//! `route_repo`/`group_repo`: pool variants for the read/diff path, `_tx`
|
||||
//! variants for the transactional apply (upsert/delete and chain expansion).
|
||||
|
||||
use picloud_shared::{DispatchMode, HostKind, PathKind};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::apply_service::RouteTemplateSpec;
|
||||
use crate::group_repo::GroupRepositoryError as Error;
|
||||
use picloud_shared::GroupId;
|
||||
|
||||
/// A persisted route template, decoded with its enum fields parsed — ready for
|
||||
/// the diff (by name) and for expansion (synthesizing a concrete route).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteTemplate {
|
||||
pub id: Uuid,
|
||||
pub group_id: GroupId,
|
||||
pub name: String,
|
||||
pub script_name: String,
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteTemplateRow {
|
||||
id: Uuid,
|
||||
group_id: Uuid,
|
||||
name: String,
|
||||
script_name: String,
|
||||
method: Option<String>,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
host_param_name: Option<String>,
|
||||
path_kind: String,
|
||||
path: String,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl From<RouteTemplateRow> for RouteTemplate {
|
||||
fn from(r: RouteTemplateRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
group_id: r.group_id.into(),
|
||||
name: r.name,
|
||||
script_name: r.script_name,
|
||||
method: r.method,
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
"wildcard" => HostKind::Wildcard,
|
||||
_ => HostKind::Any,
|
||||
},
|
||||
host: r.host,
|
||||
host_param_name: r.host_param_name,
|
||||
path_kind: match r.path_kind.as_str() {
|
||||
"prefix" => PathKind::Prefix,
|
||||
"param" => PathKind::Param,
|
||||
_ => PathKind::Exact,
|
||||
},
|
||||
path: r.path,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const COLS: &str = "id, group_id, name, script_name, method, host_kind, host, \
|
||||
host_param_name, path_kind, path, dispatch_mode, enabled";
|
||||
|
||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||
match k {
|
||||
HostKind::Any => "any",
|
||||
HostKind::Strict => "strict",
|
||||
HostKind::Wildcard => "wildcard",
|
||||
}
|
||||
}
|
||||
|
||||
const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
match k {
|
||||
PathKind::Exact => "exact",
|
||||
PathKind::Prefix => "prefix",
|
||||
PathKind::Param => "param",
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's OWN route templates (read/diff path).
|
||||
pub(crate) async fn list_for_group(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<RouteTemplate>, Error> {
|
||||
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
|
||||
"SELECT {COLS} FROM route_templates WHERE group_id = $1 ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Every route template owned by any group in `group_ids` (expansion path,
|
||||
/// in-tx so it sees templates just reconciled earlier in this apply).
|
||||
pub(crate) async fn list_for_groups_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_ids: &[GroupId],
|
||||
) -> Result<Vec<RouteTemplate>, Error> {
|
||||
if group_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
|
||||
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
|
||||
"SELECT {COLS} FROM route_templates WHERE group_id = ANY($1) ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Insert a new route template (diff Op::Create).
|
||||
pub(crate) async fn insert_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
spec: &RouteTemplateSpec,
|
||||
) -> Result<(), Error> {
|
||||
let r = &spec.route;
|
||||
sqlx::query(
|
||||
"INSERT INTO route_templates ( \
|
||||
group_id, name, script_name, method, host_kind, host, \
|
||||
host_param_name, path_kind, path, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(&spec.name)
|
||||
.bind(&r.script)
|
||||
.bind(r.method.as_deref())
|
||||
.bind(host_kind_str(r.host_kind))
|
||||
.bind(&r.host)
|
||||
.bind(r.host_param_name.as_deref())
|
||||
.bind(path_kind_str(r.path_kind))
|
||||
.bind(&r.path)
|
||||
.bind(r.dispatch_mode.as_str())
|
||||
.bind(r.enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace an existing route template's fields, keyed by `(group, lower(name))`
|
||||
/// (diff Op::Update).
|
||||
pub(crate) async fn update_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
spec: &RouteTemplateSpec,
|
||||
) -> Result<(), Error> {
|
||||
let r = &spec.route;
|
||||
sqlx::query(
|
||||
"UPDATE route_templates SET \
|
||||
script_name = $3, method = $4, host_kind = $5, host = $6, \
|
||||
host_param_name = $7, path_kind = $8, path = $9, dispatch_mode = $10, \
|
||||
enabled = $11, updated_at = NOW() \
|
||||
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(&spec.name)
|
||||
.bind(&r.script)
|
||||
.bind(r.method.as_deref())
|
||||
.bind(host_kind_str(r.host_kind))
|
||||
.bind(&r.host)
|
||||
.bind(r.host_param_name.as_deref())
|
||||
.bind(path_kind_str(r.path_kind))
|
||||
.bind(&r.path)
|
||||
.bind(r.dispatch_mode.as_str())
|
||||
.bind(r.enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a route template by name (diff Op::Delete under `--prune`).
|
||||
pub(crate) async fn delete_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("DELETE FROM route_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Trigger templates (§4.5, M4b). The kind-specific params vary, so the whole
|
||||
// `BundleTrigger` wire object is stored as `spec` JSONB (placeholders
|
||||
// unresolved); the apply rebuilds + resolves it per descendant.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// A persisted trigger template: name + the raw (unresolved) `BundleTrigger`
|
||||
/// wire object.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TriggerTemplate {
|
||||
pub id: Uuid,
|
||||
pub group_id: GroupId,
|
||||
pub name: String,
|
||||
pub spec: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TriggerTemplateRow {
|
||||
id: Uuid,
|
||||
group_id: Uuid,
|
||||
name: String,
|
||||
spec: serde_json::Value,
|
||||
}
|
||||
|
||||
impl From<TriggerTemplateRow> for TriggerTemplate {
|
||||
fn from(r: TriggerTemplateRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
group_id: r.group_id.into(),
|
||||
name: r.name,
|
||||
spec: r.spec,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's OWN trigger templates (read/diff path).
|
||||
pub(crate) async fn list_triggers_for_group(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<TriggerTemplate>, Error> {
|
||||
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
|
||||
"SELECT id, group_id, name, spec FROM trigger_templates \
|
||||
WHERE group_id = $1 ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Every trigger template owned by any group in `group_ids` (expansion path).
|
||||
pub(crate) async fn list_triggers_for_groups_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_ids: &[GroupId],
|
||||
) -> Result<Vec<TriggerTemplate>, Error> {
|
||||
if group_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
|
||||
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
|
||||
"SELECT id, group_id, name, spec FROM trigger_templates \
|
||||
WHERE group_id = ANY($1) ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Insert a new trigger template (diff Op::Create).
|
||||
pub(crate) async fn insert_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
spec: &serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("INSERT INTO trigger_templates (group_id, name, spec) VALUES ($1, $2, $3)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(spec)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a trigger template's spec, keyed by `(group, lower(name))` (Update).
|
||||
pub(crate) async fn update_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
spec: &serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query(
|
||||
"UPDATE trigger_templates SET spec = $3, updated_at = NOW() \
|
||||
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(spec)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a trigger template by name (Delete under `--prune`).
|
||||
pub(crate) async fn delete_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("DELETE FROM trigger_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -519,6 +519,7 @@ pub(crate) async fn insert_trigger_tx(
|
||||
retry_backoff: BackoffShape,
|
||||
retry_base_ms: u32,
|
||||
details: &TriggerDetails,
|
||||
from_template: Option<Uuid>,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let kind = match details {
|
||||
TriggerDetails::Kv { .. } => "kv",
|
||||
@@ -562,8 +563,8 @@ pub(crate) async fn insert_trigger_tx(
|
||||
"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",
|
||||
registered_by_principal, from_template \
|
||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
@@ -573,6 +574,7 @@ pub(crate) async fn insert_trigger_tx(
|
||||
.bind(retry_backoff.as_str())
|
||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||
.bind(registered_by.into_inner())
|
||||
.bind(from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let tid = row.0;
|
||||
@@ -679,17 +681,19 @@ pub(crate) async fn insert_email_trigger_tx(
|
||||
registered_by: AdminUserId,
|
||||
inbound_secret_encrypted: &[u8],
|
||||
inbound_secret_nonce: &[u8],
|
||||
from_template: Option<Uuid>,
|
||||
) -> 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",
|
||||
registered_by_principal, from_template \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(registered_by.into_inner())
|
||||
.bind(from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
|
||||
@@ -289,7 +289,17 @@ async fn validate_trigger_target(
|
||||
.ok_or_else(|| {
|
||||
TriggersApiError::Invalid(format!("script {script_id} not found in this app"))
|
||||
})?;
|
||||
if script.app_id != app_id {
|
||||
// Phase 4: a trigger target must be invocable in this app's context —
|
||||
// owned by the app, or by a group on the app's ancestor chain (inherited).
|
||||
// App-owned is the in-memory fast path (no query); only a group-owned
|
||||
// candidate pays the chain lookup.
|
||||
let invocable = script.is_owned_by_app(app_id)
|
||||
|| (script.group_id.is_some()
|
||||
&& scripts
|
||||
.is_invocable_by_app(script_id, app_id)
|
||||
.await
|
||||
.map_err(map_script_repo_err)?);
|
||||
if !invocable {
|
||||
return Err(TriggersApiError::Invalid(format!(
|
||||
"script {script_id} does not belong to this app"
|
||||
)));
|
||||
@@ -1348,7 +1358,8 @@ mod tests {
|
||||
script_id,
|
||||
picloud_shared::Script {
|
||||
id: script_id,
|
||||
app_id,
|
||||
app_id: Some(app_id),
|
||||
group_id: None,
|
||||
name: format!(
|
||||
"{}_{}",
|
||||
match kind {
|
||||
@@ -1393,9 +1404,29 @@ mod tests {
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|s| s.app_id == app_id && s.name == name)
|
||||
.find(|s| s.app_id == Some(app_id) && s.name == name)
|
||||
.cloned())
|
||||
}
|
||||
async fn get_by_name_inherited(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
name: &str,
|
||||
) -> Result<Option<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
// No hierarchy in this in-memory repo; inherited == own-scope.
|
||||
self.get_by_name(app_id, name).await
|
||||
}
|
||||
async fn is_invocable_by_app(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
app_id: AppId,
|
||||
) -> Result<bool, crate::repo::ScriptRepositoryError> {
|
||||
Ok(self
|
||||
.existing
|
||||
.lock()
|
||||
.await
|
||||
.get(&script_id)
|
||||
.is_some_and(|s| s.is_owned_by_app(app_id)))
|
||||
}
|
||||
async fn list(
|
||||
&self,
|
||||
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
@@ -1407,6 +1438,12 @@ mod tests {
|
||||
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_group(
|
||||
&self,
|
||||
_group_id: picloud_shared::GroupId,
|
||||
) -> Result<Vec<picloud_shared::Script>, crate::repo::ScriptRepositoryError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn list_for_user(
|
||||
&self,
|
||||
_user_id: AdminUserId,
|
||||
|
||||
@@ -132,6 +132,8 @@ table: apps
|
||||
description: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
group_id: uuid NOT NULL
|
||||
environment: text NULL
|
||||
|
||||
table: cron_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
@@ -196,6 +198,15 @@ table: execution_logs
|
||||
app_id: uuid NOT NULL
|
||||
source: text NOT NULL default='http'::text
|
||||
|
||||
table: extension_points
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
default_script_id: uuid NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: files
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
@@ -212,6 +223,23 @@ table: files_trigger_details
|
||||
collection_glob: text NOT NULL
|
||||
ops: ARRAY NOT NULL
|
||||
|
||||
table: group_members
|
||||
group_id: uuid NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
role: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: groups
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
parent_id: uuid NULL
|
||||
slug: text NOT NULL
|
||||
name: text NOT NULL
|
||||
description: text NULL
|
||||
structure_version: bigint NOT NULL default=1
|
||||
owner_project: uuid NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: kv_entries
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
@@ -242,6 +270,11 @@ table: outbox
|
||||
claimed_by: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: projects
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
key: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: pubsub_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
topic_pattern: text NOT NULL
|
||||
@@ -265,6 +298,22 @@ table: queue_trigger_details
|
||||
visibility_timeout_secs: integer NOT NULL default=30
|
||||
last_fired_at: timestamp with time zone NULL
|
||||
|
||||
table: route_templates
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
script_name: text NOT NULL
|
||||
method: text NULL
|
||||
host_kind: text NOT NULL
|
||||
host: text NOT NULL default=''::text
|
||||
host_param_name: text NULL
|
||||
path_kind: text NOT NULL
|
||||
path: text NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: routes
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -277,6 +326,8 @@ table: routes
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
from_template: uuid NULL
|
||||
|
||||
table: script_imports
|
||||
app_id: uuid NOT NULL
|
||||
@@ -295,17 +346,21 @@ table: scripts
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||
app_id: uuid NOT NULL
|
||||
app_id: uuid NULL
|
||||
kind: text NOT NULL default='endpoint'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
group_id: uuid NULL
|
||||
|
||||
table: secrets
|
||||
app_id: uuid NOT NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
encrypted_value: bytea NOT NULL
|
||||
nonce: bytea NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
version: smallint NOT NULL default=0
|
||||
group_id: uuid NULL
|
||||
environment_scope: text NOT NULL default='*'::text
|
||||
|
||||
table: topics
|
||||
app_id: uuid NOT NULL
|
||||
@@ -315,6 +370,14 @@ table: topics
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: trigger_templates
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
spec: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: triggers
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
@@ -328,6 +391,19 @@ table: triggers
|
||||
registered_by_principal: uuid NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
name: text NOT NULL default=(gen_random_uuid())::text
|
||||
from_template: uuid NULL
|
||||
|
||||
table: vars
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
environment_scope: text NOT NULL default='*'::text
|
||||
key: text NOT NULL
|
||||
value: jsonb NOT NULL
|
||||
is_tombstone: boolean NOT NULL default=false
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
## indexes
|
||||
|
||||
@@ -395,6 +471,7 @@ indexes on app_users:
|
||||
idx_app_users_app_email_lower: public.app_users USING btree (app_id, lower(email))
|
||||
|
||||
indexes on apps:
|
||||
apps_group_id_idx: public.apps USING btree (group_id)
|
||||
apps_pkey: public.apps USING btree (id)
|
||||
apps_slug_key: public.apps USING btree (slug)
|
||||
|
||||
@@ -426,6 +503,14 @@ indexes on execution_logs:
|
||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||
|
||||
indexes on extension_points:
|
||||
extension_points_app_id_idx: public.extension_points USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
extension_points_app_uidx: public.extension_points USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||
extension_points_default_script_idx: public.extension_points USING btree (default_script_id) WHERE (default_script_id IS NOT NULL)
|
||||
extension_points_group_id_idx: public.extension_points USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
extension_points_group_uidx: public.extension_points USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||
extension_points_pkey: public.extension_points USING btree (id)
|
||||
|
||||
indexes on files:
|
||||
files_pkey: public.files USING btree (app_id, collection, id)
|
||||
idx_files_app_collection: public.files USING btree (app_id, collection)
|
||||
@@ -433,6 +518,16 @@ indexes on files:
|
||||
indexes on files_trigger_details:
|
||||
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on group_members:
|
||||
group_members_pkey: public.group_members USING btree (group_id, user_id)
|
||||
group_members_user_id_idx: public.group_members USING btree (user_id)
|
||||
|
||||
indexes on groups:
|
||||
groups_owner_project_idx: public.groups USING btree (owner_project)
|
||||
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||
groups_pkey: public.groups USING btree (id)
|
||||
groups_slug_key: public.groups USING btree (slug)
|
||||
|
||||
indexes on kv_entries:
|
||||
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
|
||||
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
|
||||
@@ -445,6 +540,10 @@ indexes on outbox:
|
||||
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
|
||||
outbox_pkey: public.outbox USING btree (id)
|
||||
|
||||
indexes on projects:
|
||||
projects_key_key: public.projects USING btree (key)
|
||||
projects_pkey: public.projects USING btree (id)
|
||||
|
||||
indexes on pubsub_trigger_details:
|
||||
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
|
||||
|
||||
@@ -458,8 +557,13 @@ indexes on queue_trigger_details:
|
||||
idx_queue_trigger_details_queue_name: public.queue_trigger_details USING btree (queue_name)
|
||||
queue_trigger_details_pkey: public.queue_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on route_templates:
|
||||
route_templates_group_name_idx: public.route_templates USING btree (group_id, lower(name))
|
||||
route_templates_pkey: public.route_templates USING btree (id)
|
||||
|
||||
indexes on routes:
|
||||
routes_app_id_idx: public.routes USING btree (app_id)
|
||||
routes_from_template_idx: public.routes USING btree (app_id, from_template)
|
||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||
routes_pkey: public.routes USING btree (id)
|
||||
routes_script_id_idx: public.routes USING btree (script_id)
|
||||
@@ -473,22 +577,39 @@ indexes on script_imports:
|
||||
indexes on scripts:
|
||||
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
|
||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||
scripts_app_name_uidx: public.scripts USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||
scripts_group_id_idx: public.scripts USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
scripts_group_name_uidx: public.scripts USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||
scripts_pkey: public.scripts USING btree (id)
|
||||
|
||||
indexes on secrets:
|
||||
idx_secrets_app: public.secrets USING btree (app_id)
|
||||
secrets_pkey: public.secrets USING btree (app_id, name)
|
||||
idx_secrets_group: public.secrets USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
secrets_app_uidx: public.secrets USING btree (app_id, environment_scope, name) WHERE (app_id IS NOT NULL)
|
||||
secrets_group_uidx: public.secrets USING btree (group_id, environment_scope, name) WHERE (group_id IS NOT NULL)
|
||||
|
||||
indexes on topics:
|
||||
topics_pkey: public.topics USING btree (app_id, name)
|
||||
|
||||
indexes on trigger_templates:
|
||||
trigger_templates_group_name_idx: public.trigger_templates USING btree (group_id, lower(name))
|
||||
trigger_templates_pkey: public.trigger_templates USING btree (id)
|
||||
|
||||
indexes on triggers:
|
||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
||||
triggers_from_template_idx: public.triggers USING btree (app_id, from_template)
|
||||
triggers_pkey: public.triggers USING btree (id)
|
||||
|
||||
indexes on vars:
|
||||
vars_app_id_idx: public.vars USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
vars_app_uidx: public.vars USING btree (app_id, environment_scope, key) WHERE (app_id IS NOT NULL)
|
||||
vars_group_id_idx: public.vars USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
vars_group_uidx: public.vars USING btree (group_id, environment_scope, key) WHERE (group_id IS NOT NULL)
|
||||
vars_pkey: public.vars USING btree (id)
|
||||
|
||||
## constraints
|
||||
|
||||
constraints on abandoned_executions:
|
||||
@@ -561,6 +682,7 @@ constraints on app_users:
|
||||
[PRIMARY KEY] app_users_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on apps:
|
||||
[FOREIGN KEY] apps_group_id_fk: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||
|
||||
@@ -597,6 +719,13 @@ constraints on execution_logs:
|
||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on extension_points:
|
||||
[CHECK] extension_points_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] extension_points_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] extension_points_default_script_id_fkey: FOREIGN KEY (default_script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||
[FOREIGN KEY] extension_points_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] extension_points_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on files:
|
||||
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
|
||||
@@ -605,6 +734,18 @@ constraints on files_trigger_details:
|
||||
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on group_members:
|
||||
[CHECK] group_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||
[FOREIGN KEY] group_members_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] group_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
|
||||
|
||||
constraints on groups:
|
||||
[FOREIGN KEY] groups_owner_project_fkey: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
|
||||
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
||||
|
||||
constraints on kv_entries:
|
||||
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
|
||||
@@ -618,6 +759,10 @@ constraints on outbox:
|
||||
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on projects:
|
||||
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] projects_key_key: UNIQUE (key)
|
||||
|
||||
constraints on pubsub_trigger_details:
|
||||
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
@@ -630,6 +775,13 @@ constraints on queue_trigger_details:
|
||||
[FOREIGN KEY] queue_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] queue_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on route_templates:
|
||||
[CHECK] route_templates_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] route_templates_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] route_templates_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] route_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] route_templates_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on routes:
|
||||
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
@@ -648,19 +800,26 @@ constraints on scripts:
|
||||
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
|
||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
|
||||
[CHECK] scripts_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||
[FOREIGN KEY] scripts_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on secrets:
|
||||
[CHECK] secrets_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] secrets_pkey: PRIMARY KEY (app_id, name)
|
||||
[FOREIGN KEY] secrets_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
|
||||
constraints on topics:
|
||||
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text, 'session'::text])))
|
||||
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
|
||||
|
||||
constraints on trigger_templates:
|
||||
[FOREIGN KEY] trigger_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] trigger_templates_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on triggers:
|
||||
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
|
||||
@@ -670,6 +829,12 @@ constraints on triggers:
|
||||
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on vars:
|
||||
[CHECK] vars_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] vars_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] vars_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] vars_pkey: PRIMARY KEY (id)
|
||||
|
||||
## applied migrations
|
||||
0001: init
|
||||
0002: sandbox
|
||||
@@ -715,3 +880,13 @@ constraints on triggers:
|
||||
0042: secrets envelope version
|
||||
0043: execution logs source
|
||||
0044: delete reserved path routes
|
||||
0045: scripts routes enabled
|
||||
0046: trigger name
|
||||
0047: groups
|
||||
0048: vars
|
||||
0049: group secrets
|
||||
0050: group scripts
|
||||
0051: extension points
|
||||
0052: owner project
|
||||
0053: templates
|
||||
0054: trigger templates
|
||||
|
||||
@@ -126,9 +126,16 @@ where
|
||||
if !script.enabled {
|
||||
return Err(ApiError::NotFound(id));
|
||||
}
|
||||
// The direct-execute bypass runs a script under its *owning app's*
|
||||
// context. A group-owned script (Phase 4) is a template with no single
|
||||
// app, so it cannot be invoked through this id-addressed bypass — it
|
||||
// runs only via a descendant app's route/trigger, which supplies the
|
||||
// execution-context app. Fail closed (404) rather than guess an app.
|
||||
let app_id = script.app_id.ok_or(ApiError::NotFound(id))?;
|
||||
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body, script.app_id, principal)?;
|
||||
let mut req = build_exec_request(id, &script.name, &headers, &body, app_id, principal)?;
|
||||
req.sandbox_overrides = script.sandbox;
|
||||
req.script_owner = script.owner();
|
||||
let request_id = req.request_id;
|
||||
let request_path = req.path.clone();
|
||||
let request_headers = req.headers.clone();
|
||||
@@ -151,7 +158,7 @@ where
|
||||
// audit-visible platform — but a sink failure must not mask the
|
||||
// user-facing result, so we only log a warning if it fails.
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
app_id,
|
||||
id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -622,6 +629,10 @@ fn build_exec_request(
|
||||
// Overwritten by the handler after the script is resolved.
|
||||
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||
app_id,
|
||||
// Set by the handler from the resolved script's owner. The
|
||||
// id-bypass runs app-owned scripts only (group scripts 404 here),
|
||||
// so this is always `App(app_id)`; `None` falls back to that.
|
||||
script_owner: None,
|
||||
principal,
|
||||
// Direct invocations are at depth 0 with a self-referential
|
||||
// root. The triggers framework (v1.1.1) increments depth and
|
||||
|
||||
@@ -34,6 +34,7 @@ toml = "0.8"
|
||||
directories = "5"
|
||||
rpassword = "7"
|
||||
anyhow = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
|
||||
@@ -123,6 +123,19 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
|
||||
/// extension-point declarations (for `pic pull`).
|
||||
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
||||
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
||||
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
||||
@@ -309,6 +322,33 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/groups/{id_or_slug}/scripts` — a group's own
|
||||
/// scripts (Phase 4). Not inherited; just the group's rows.
|
||||
pub async fn group_scripts_list(&self, group_ident: &str) -> Result<Vec<Script>> {
|
||||
let g = seg(group_ident);
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/groups/{g}/scripts"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{id_or_slug}/scripts` — create a
|
||||
/// group-owned script (Phase 4).
|
||||
pub async fn group_scripts_create(
|
||||
&self,
|
||||
group_ident: &str,
|
||||
body: &CreateGroupScriptBody<'_>,
|
||||
) -> Result<Script> {
|
||||
let g = seg(group_ident);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/groups/{g}/scripts"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
|
||||
/// uses PUT despite the field-level update semantics. `cfg` carries
|
||||
/// optional per-script runtime overrides (G3); unset fields are
|
||||
@@ -1141,33 +1181,47 @@ impl Client {
|
||||
|
||||
/// `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);
|
||||
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/plan` — diff a bundle
|
||||
/// against an app OR group node (Phase 5).
|
||||
pub async fn plan_node(
|
||||
&self,
|
||||
kind: NodeKind,
|
||||
slug: &str,
|
||||
bundle: &serde_json::Value,
|
||||
) -> Result<PlanDto> {
|
||||
let slug = seg(slug);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/{}/{slug}/plan", kind.path()),
|
||||
)
|
||||
.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(
|
||||
/// `POST /api/v1/admin/{apps|groups}/{id_or_slug}/apply` — reconcile an
|
||||
/// app OR group node in one transaction (Phase 5).
|
||||
pub async fn apply_node(
|
||||
&self,
|
||||
app: &str,
|
||||
kind: NodeKind,
|
||||
slug: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let slug = seg(slug);
|
||||
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"))
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/{}/{slug}/apply", kind.path()),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
@@ -1179,13 +1233,72 @@ impl Client {
|
||||
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 \
|
||||
"{msg}\nThe live state 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/tree/plan` — diff a whole project tree (Phase 5).
|
||||
/// `project_key` (§7, M3) lets the server report ownership conflicts and
|
||||
/// structural-prune candidates for this repo.
|
||||
pub async fn plan_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
project_key: Option<&str>,
|
||||
) -> Result<TreePlanDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"project_key": project_key,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/plan")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
|
||||
/// transaction (Phase 5). `project_key`/`allow_takeover` drive ownership
|
||||
/// (§7, M3): the apply claims unclaimed declared groups for this project and
|
||||
/// refuses (or, with `allow_takeover`, seizes) ones another project owns.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn apply_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
project_key: Option<&str>,
|
||||
allow_takeover: bool,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<ApplyReportDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
"project_key": project_key,
|
||||
"allow_takeover": allow_takeover,
|
||||
"env": env,
|
||||
"approved_envs": approved_envs,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
// 409 covers both bound-plan staleness AND an ownership conflict; the
|
||||
// server's message is self-explanatory for each, so surface it raw.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!("{msg}"));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -1208,6 +1321,23 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// Which kind of node a plan/apply targets (Phase 5) — selects the API path
|
||||
/// prefix (`apps` vs `groups`).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NodeKind {
|
||||
App,
|
||||
Group,
|
||||
}
|
||||
|
||||
impl NodeKind {
|
||||
fn path(self) -> &'static str {
|
||||
match self {
|
||||
Self::App => "apps",
|
||||
Self::Group => "groups",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
@@ -1221,6 +1351,10 @@ pub struct PlanDto {
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: 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)]
|
||||
@@ -1235,6 +1369,73 @@ pub struct ChangeDto {
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// One of an owner's OWN extension-point declarations (`pic pull`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtPointDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
|
||||
/// bound-plan token for the whole subtree.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TreePlanDto {
|
||||
#[serde(default)]
|
||||
pub nodes: Vec<NodePlanDto>,
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
/// Declared groups owned by another project (§7, M3) — surfaced so CI sees
|
||||
/// the conflict before `apply` refuses it.
|
||||
#[serde(default)]
|
||||
pub conflicts: Vec<OwnershipConflictDto>,
|
||||
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
|
||||
#[serde(default)]
|
||||
pub structural_prunes: Vec<String>,
|
||||
/// Route-template fan-out per declaring group (§4.5, M4a).
|
||||
#[serde(default)]
|
||||
pub template_blast_radius: Vec<TemplateBlastRadiusDto>,
|
||||
/// Environments the project marks confirm-required (§4.2, M5).
|
||||
#[serde(default)]
|
||||
pub approvals_required: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single ownership conflict (`pic plan --dir`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OwnershipConflictDto {
|
||||
pub slug: String,
|
||||
pub owner_key: String,
|
||||
}
|
||||
|
||||
/// One group's route-template blast radius (`pic plan --dir`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TemplateBlastRadiusDto {
|
||||
pub group: String,
|
||||
pub affected_apps: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NodePlanDto {
|
||||
pub kind: String,
|
||||
pub slug: String,
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub route_templates: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub trigger_templates: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ApplyReportDto {
|
||||
@@ -1255,6 +1456,40 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub triggers_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub vars_created: u32,
|
||||
#[serde(default)]
|
||||
pub vars_updated: u32,
|
||||
#[serde(default)]
|
||||
pub vars_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_created: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_updated: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub groups_created: u32,
|
||||
#[serde(default)]
|
||||
pub groups_reparented: u32,
|
||||
#[serde(default)]
|
||||
pub groups_claimed: u32,
|
||||
#[serde(default)]
|
||||
pub groups_taken_over: u32,
|
||||
#[serde(default)]
|
||||
pub groups_pruned: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_created: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_updated: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_created: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_updated: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1629,6 +1864,24 @@ pub struct CreateScriptBody<'a> {
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
/// Phase 4: body for `POST /groups/{id}/scripts`. Like `CreateScriptBody`
|
||||
/// minus `app_id` — the owning group comes from the path.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateGroupScriptBody<'a> {
|
||||
pub name: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<&'a str>,
|
||||
pub source: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<ScriptKind>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct UpdateScriptBody<'a> {
|
||||
source: &'a str,
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{Client, NodeKind};
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
@@ -26,38 +26,60 @@ pub async fn run(
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
|
||||
// Env approval gating (§4.2, M5) is a project-tree (`--dir`) feature: the
|
||||
// admin-gated, audited per-env approval is enforced server-side only on the
|
||||
// tree apply path. Single-node `apply --file` cannot provide that guarantee,
|
||||
// so rather than silently bypass a confirm-required env, refuse and direct
|
||||
// the user to `--dir` (which carries the policy + `--approve` to the server).
|
||||
if let (Some(env), Some(project)) = (env, &manifest.project) {
|
||||
if project
|
||||
.environments
|
||||
.iter()
|
||||
.any(|e| e.name == env && e.confirm)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"environment `{env}` is confirm-required by this project's [project] policy; \
|
||||
single-node `pic apply --file` cannot provide the admin-gated, audited approval. \
|
||||
Use `pic apply --dir <root> --env {env} --approve {env}` instead."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
let kind = if manifest.is_group() {
|
||||
NodeKind::Group
|
||||
} else {
|
||||
NodeKind::App
|
||||
};
|
||||
let slug = manifest.slug().to_string();
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&manifest.app.slug)?;
|
||||
confirm_prune(&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.
|
||||
// node) so the server refuses if it 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)
|
||||
.filter(|l| l.app == slug)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply(
|
||||
&manifest.app.slug,
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
)
|
||||
.apply_node(kind, &slug, &bundle, prune, expected_token.as_deref())
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(base_dir, &manifest.app.slug);
|
||||
crate::linkstate::clear_plan(base_dir, &slug);
|
||||
|
||||
let label = if manifest.is_group() { "group" } else { "app" };
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field(label, slug.clone())
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
@@ -75,7 +97,33 @@ pub async fn run(
|
||||
.field(
|
||||
"triggers",
|
||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
||||
)
|
||||
.field(
|
||||
"vars",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension-points",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.extension_points_created,
|
||||
report.extension_points_updated,
|
||||
report.extension_points_deleted
|
||||
),
|
||||
);
|
||||
// Structural changes only happen on a tree apply; omit the line otherwise.
|
||||
if report.groups_created > 0 || report.groups_reparented > 0 {
|
||||
block.field(
|
||||
"groups",
|
||||
format!(
|
||||
"+{} reparented {}",
|
||||
report.groups_created, report.groups_reparented
|
||||
),
|
||||
);
|
||||
}
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
@@ -83,6 +131,202 @@ pub async fn run(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
|
||||
/// `picloud.toml` under `root` — in ONE atomic server transaction.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_tree(
|
||||
dir: &Path,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
takeover: bool,
|
||||
env: Option<&str>,
|
||||
approve: &[String],
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let (bundle, node_count, envs) = crate::discover::build_tree(dir, env)?;
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
|
||||
}
|
||||
|
||||
// Per-env approval gate (§4.2, M5): if the selected env is confirm-required,
|
||||
// it needs an explicit `--approve <env>` (a TTY may confirm interactively;
|
||||
// `--yes` does NOT cover it). The server re-checks this authoritatively — the
|
||||
// local check just fails fast with a clear message. `approved` is the set
|
||||
// sent on the wire.
|
||||
let approved = resolve_approvals(env, &envs, approve)?;
|
||||
|
||||
let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
|
||||
let expected_token = if force {
|
||||
None
|
||||
} else {
|
||||
crate::linkstate::read_plan(dir)
|
||||
.filter(|l| l.app == token_key)
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
// This repo's stable project key (§7, M3): the server claims declared
|
||||
// groups for it and refuses ones another project owns (unless --takeover).
|
||||
let project_key = crate::linkstate::ensure_project_key(dir)
|
||||
.context("establishing project identity in .picloud/")?;
|
||||
|
||||
let report = client
|
||||
.apply_tree(
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
Some(&project_key),
|
||||
takeover,
|
||||
env,
|
||||
&approved,
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(dir, token_key);
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("nodes", node_count.to_string())
|
||||
.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),
|
||||
)
|
||||
.field(
|
||||
"vars",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension-points",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.extension_points_created,
|
||||
report.extension_points_updated,
|
||||
report.extension_points_deleted
|
||||
),
|
||||
);
|
||||
// Structural changes only happen on a tree apply; omit the line otherwise.
|
||||
if report.groups_created > 0
|
||||
|| report.groups_reparented > 0
|
||||
|| report.groups_pruned > 0
|
||||
|| report.groups_claimed > 0
|
||||
|| report.groups_taken_over > 0
|
||||
{
|
||||
block.field(
|
||||
"groups",
|
||||
format!(
|
||||
"+{} reparented {} pruned {}",
|
||||
report.groups_created, report.groups_reparented, report.groups_pruned
|
||||
),
|
||||
);
|
||||
if report.groups_claimed > 0 || report.groups_taken_over > 0 {
|
||||
block.field(
|
||||
"ownership",
|
||||
format!(
|
||||
"claimed {} taken-over {}",
|
||||
report.groups_claimed, report.groups_taken_over
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Route templates (§4.5, M4a) — the template rows; their per-app route
|
||||
// expansions are folded into the `routes` line above.
|
||||
if report.route_templates_created > 0
|
||||
|| report.route_templates_updated > 0
|
||||
|| report.route_templates_deleted > 0
|
||||
{
|
||||
block.field(
|
||||
"route-templates",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.route_templates_created,
|
||||
report.route_templates_updated,
|
||||
report.route_templates_deleted
|
||||
),
|
||||
);
|
||||
}
|
||||
if report.trigger_templates_created > 0
|
||||
|| report.trigger_templates_updated > 0
|
||||
|| report.trigger_templates_deleted > 0
|
||||
{
|
||||
block.field(
|
||||
"trigger-templates",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.trigger_templates_created,
|
||||
report.trigger_templates_updated,
|
||||
report.trigger_templates_deleted
|
||||
),
|
||||
);
|
||||
}
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the set of environments the actor approves for this apply (§4.2, M5).
|
||||
/// If the selected `env` is confirm-required (per the root manifest's
|
||||
/// `[project]` policy) it must be approved — via `--approve <env>`, or an
|
||||
/// interactive TTY confirmation that requires re-typing the env name. A blanket
|
||||
/// `--yes` does NOT satisfy it. Returns the full approved set to send on the
|
||||
/// wire (the server re-checks authoritatively). A non-gated env passes through.
|
||||
fn resolve_approvals(
|
||||
env: Option<&str>,
|
||||
policy: &[crate::manifest::ManifestEnvironment],
|
||||
approve: &[String],
|
||||
) -> Result<Vec<String>> {
|
||||
let mut approved: Vec<String> = approve.to_vec();
|
||||
let Some(env) = env else {
|
||||
return Ok(approved); // no env selected → nothing env-specific to gate
|
||||
};
|
||||
let gated = policy.iter().any(|e| e.name == env && e.confirm);
|
||||
if !gated || approved.iter().any(|e| e == env) {
|
||||
return Ok(approved);
|
||||
}
|
||||
// Gated and not pre-approved: prompt on a TTY, else refuse (CI must pass
|
||||
// --approve explicitly; --yes is deliberately insufficient).
|
||||
if std::io::stdin().is_terminal() {
|
||||
eprint!(
|
||||
"environment `{env}` requires explicit approval to apply. \
|
||||
Type the environment name to approve, or anything else to abort: "
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("read approval")?;
|
||||
if answer.trim() == env {
|
||||
approved.push(env.to_string());
|
||||
return Ok(approved);
|
||||
}
|
||||
anyhow::bail!("aborted: environment `{env}` not approved");
|
||||
}
|
||||
anyhow::bail!(
|
||||
"environment `{env}` requires explicit approval: re-run with `--approve {env}` \
|
||||
(a blanket `--yes` does not cover a confirm-required environment)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `--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`
|
||||
@@ -97,7 +341,8 @@ fn confirm_prune(slug: &str) -> Result<()> {
|
||||
}
|
||||
eprint!(
|
||||
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
|
||||
absent from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
absent from the manifest — and, for a tree apply, entire owned groups absent \
|
||||
from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut answer = String::new();
|
||||
|
||||
@@ -45,8 +45,14 @@ pub async fn run(
|
||||
// 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)?;
|
||||
if manifest.is_group() {
|
||||
bail!(
|
||||
"`pic config --effective` resolves an APP's inherited config; \
|
||||
a [group] manifest has no single effective view"
|
||||
);
|
||||
}
|
||||
|
||||
let eff = client.config_effective(&manifest.app.slug).await?;
|
||||
let eff = client.config_effective(manifest.slug()).await?;
|
||||
|
||||
// --- vars: the resolved view, with winning owner + provenance. ---
|
||||
let mut vars_table = Table::new(["key", "value", "owner", "scope"]);
|
||||
|
||||
@@ -87,6 +87,9 @@ pub fn run(
|
||||
}
|
||||
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
|
||||
ensure_gitignored(dir)?;
|
||||
// Mint the repo's stable project identity (§7, M3) so the first tree apply
|
||||
// claims its groups under a key that survives clones/CI. Gitignored.
|
||||
crate::linkstate::ensure_project_key(dir).context("minting project key in .picloud/")?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
@@ -109,11 +112,13 @@ pub fn run(
|
||||
/// The minimal working project: one `hello` endpoint bound to `GET /hello`.
|
||||
fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: slug.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
scripts: vec![ManifestScript {
|
||||
name: "hello".into(),
|
||||
file: "scripts/hello.rhai".into(),
|
||||
@@ -137,6 +142,10 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,15 @@ use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::client::{ChangeDto, Client, PlanDto};
|
||||
use crate::client::{ChangeDto, Client, NodeKind, PlanDto, TreePlanDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Linkstate key for a whole-tree bound plan (Phase 5) — a tree has no single
|
||||
/// slug, so its token is stored under this reserved marker.
|
||||
pub const TREE_TOKEN_KEY: &str = "<tree>";
|
||||
|
||||
pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
@@ -21,15 +25,18 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
|
||||
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 kind = if manifest.is_group() {
|
||||
NodeKind::Group
|
||||
} else {
|
||||
NodeKind::App
|
||||
};
|
||||
|
||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
||||
let plan = client.plan_node(kind, manifest.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
|
||||
// node 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)
|
||||
{
|
||||
if let Err(e) = crate::linkstate::write_plan(base_dir, manifest.slug(), &plan.state_token) {
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
}
|
||||
}
|
||||
@@ -37,6 +44,83 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic plan --dir <root>` (Phase 5): diff a whole project tree — every
|
||||
/// `picloud.toml` under `root` — against the live group/app subtree.
|
||||
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let (bundle, _count, _envs) = crate::discover::build_tree(dir, env)?;
|
||||
// Mint/read this repo's project key so the server can report ownership
|
||||
// (§7, M3). Best-effort: a read-only dir still plans (ownership unreported).
|
||||
let project_key = crate::linkstate::ensure_project_key(dir).ok();
|
||||
let plan = client.plan_tree(&bundle, project_key.as_deref()).await?;
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
}
|
||||
}
|
||||
render_tree(&plan, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||
for n in &plan.nodes {
|
||||
let node = format!("{}:{}", n.kind, n.slug);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
("secret", &n.secrets),
|
||||
("var", &n.vars),
|
||||
("extension-point", &n.extension_points),
|
||||
("route-template", &n.route_templates),
|
||||
("trigger-template", &n.trigger_templates),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
table.row([
|
||||
node.clone(),
|
||||
rk.to_string(),
|
||||
c.op.clone(),
|
||||
c.key.clone(),
|
||||
c.detail.clone().unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
table.print(mode);
|
||||
|
||||
// Ownership diagnostics (§7, M3) — surfaced to stderr in non-JSON output
|
||||
// (JSON consumers read `conflicts`/`structural_prunes` from the payload).
|
||||
if mode != OutputMode::Json {
|
||||
if !plan.conflicts.is_empty() {
|
||||
eprintln!("\nownership conflicts (apply blocked without --takeover):");
|
||||
for c in &plan.conflicts {
|
||||
eprintln!(" - {} is owned by project {}", c.slug, c.owner_key);
|
||||
}
|
||||
}
|
||||
if !plan.structural_prunes.is_empty() {
|
||||
eprintln!("\ngroups absent from the manifest (deleted only with --prune):");
|
||||
for slug in &plan.structural_prunes {
|
||||
eprintln!(" - {slug}");
|
||||
}
|
||||
}
|
||||
if !plan.template_blast_radius.is_empty() {
|
||||
eprintln!("\nroute-template blast radius (every descendant app in the DB subtree):");
|
||||
for b in &plan.template_blast_radius {
|
||||
eprintln!(" - {} → {} app(s)", b.group, b.affected_apps);
|
||||
}
|
||||
}
|
||||
if !plan.approvals_required.is_empty() {
|
||||
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
|
||||
for e in &plan.approvals_required {
|
||||
eprintln!(" - {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -95,11 +179,58 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
triggers.push(tagged("queue", s)?);
|
||||
}
|
||||
|
||||
// Vars: key → JSON value. TOML values round-trip to JSON via serde so the
|
||||
// wire shape matches the server's `serde_json::Value`.
|
||||
let mut vars = Map::new();
|
||||
for (k, v) in &manifest.vars {
|
||||
vars.insert(
|
||||
k.clone(),
|
||||
serde_json::to_value(v).with_context(|| format!("encoding var `{k}`"))?,
|
||||
);
|
||||
}
|
||||
|
||||
// Extension points: name + optional default module. The server's
|
||||
// `ExtPointSpec` deserializes this shape directly.
|
||||
let extension_points: Vec<Value> = manifest
|
||||
.extension_points
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let mut o = Map::new();
|
||||
o.insert("name".into(), json!(e.name));
|
||||
if let Some(d) = &e.default {
|
||||
o.insert("default".into(), json!(d));
|
||||
}
|
||||
Value::Object(o)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Route templates (§4.5, M4a): name + the flattened route fields the
|
||||
// server's `RouteTemplateSpec` (name + `#[serde(flatten)] BundleRoute`)
|
||||
// deserializes. Group manifests only; the server rejects them on an app.
|
||||
let route_templates = manifest
|
||||
.route_templates
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
|
||||
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
|
||||
// `{ name, <BundleTrigger> }`. Group manifests only.
|
||||
let trigger_templates = manifest
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
"extension_points": extension_points,
|
||||
"route_templates": route_templates,
|
||||
"trigger_templates": trigger_templates,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -114,11 +245,13 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 4] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
("var", &plan.vars),
|
||||
("extension-point", &plan.extension_points),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -18,8 +18,8 @@ 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,
|
||||
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
|
||||
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
@@ -49,6 +49,24 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
.secrets_list(VarOwnerArg::App(app_ident), None)
|
||||
.await?
|
||||
.secrets;
|
||||
// App's OWN env-agnostic vars become the manifest `[vars]` block. Inherited
|
||||
// group vars and tombstones are excluded (pull exports own rows only, §4.6);
|
||||
// a value TOML can't represent (e.g. JSON null) is skipped with a warning.
|
||||
let mut manifest_vars = std::collections::BTreeMap::new();
|
||||
for v in client.vars_list(VarOwnerArg::App(app_ident)).await?.vars {
|
||||
if v.env != "*" || v.is_tombstone {
|
||||
continue;
|
||||
}
|
||||
match toml::Value::try_from(&v.value) {
|
||||
Ok(tv) => {
|
||||
manifest_vars.insert(v.key, tv);
|
||||
}
|
||||
Err(_) => eprintln!(
|
||||
"warning: skipping var `{}` — its value can't be represented in TOML",
|
||||
v.key
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
let name_by_id: HashMap<ScriptId, String> =
|
||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||||
@@ -192,18 +210,37 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||
}
|
||||
|
||||
// Extension points (§5.5): the app's own declarations, exported so a
|
||||
// re-applied pulled manifest doesn't prune them.
|
||||
let extension_points: Vec<ManifestExtensionPoint> = client
|
||||
.extension_points_list(app_ident)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|e| ManifestExtensionPoint {
|
||||
name: e.name,
|
||||
default: e.default,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manifest = Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
secrets: ManifestSecrets {
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
vars: manifest_vars,
|
||||
extension_points,
|
||||
// `pull` is app-scoped; templates are group-owned (§4.5).
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
@@ -212,7 +249,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field("app", manifest.slug().to_string())
|
||||
.field("scripts", manifest.scripts.len().to_string())
|
||||
.field("routes", manifest.routes.len().to_string())
|
||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
||||
|
||||
@@ -8,16 +8,33 @@ use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::AppId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, CreateScriptBody, ScriptConfig};
|
||||
use crate::client::{Client, CreateGroupScriptBody, CreateScriptBody, ScriptConfig};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
||||
|
||||
if let Some(group_ident) = group {
|
||||
// Phase 4: a group's own (non-inherited) scripts. The owner column
|
||||
// shows the group, not an app.
|
||||
let scripts = client.group_scripts_list(group_ident).await?;
|
||||
for s in scripts {
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
format!("group:{group_ident}"),
|
||||
s.name,
|
||||
s.version.to_string(),
|
||||
s.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(ident) = app {
|
||||
let app = client.apps_get(ident).await?;
|
||||
let scripts = client.scripts_list_by_app(&app.app.slug).await?;
|
||||
@@ -40,10 +57,15 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
|
||||
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
|
||||
for s in scripts {
|
||||
let app_slug = slug_by_id
|
||||
.get(&s.app_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
// Group-owned scripts (Phase 4) have no app; show a marker rather
|
||||
// than a slug. App-owned scripts resolve their slug as before.
|
||||
let app_slug = match s.app_id {
|
||||
Some(app_id) => slug_by_id
|
||||
.get(&app_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
None => "(group)".to_string(),
|
||||
};
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
app_slug,
|
||||
@@ -59,7 +81,8 @@ pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
|
||||
pub async fn deploy(
|
||||
file: &Path,
|
||||
app_ident: &str,
|
||||
app_ident: Option<&str>,
|
||||
group_ident: Option<&str>,
|
||||
name_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
cfg: &ScriptConfig,
|
||||
@@ -84,28 +107,61 @@ pub async fn deploy(
|
||||
})?,
|
||||
};
|
||||
|
||||
// Slug-or-id resolution: a single GET satisfies both lookups and
|
||||
// gives us the canonical app_id needed for create.
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateScriptBody {
|
||||
app_id: app.app.id,
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
// Deploy is create-or-update by name within the chosen owner. Exactly
|
||||
// one owner — clap marks `--group` as conflicting with `--app`, so this
|
||||
// only rejects the both-absent case.
|
||||
let (script, action) = match (app_ident, group_ident) {
|
||||
(Some(app_ident), None) => {
|
||||
// Slug-or-id resolution: a single GET gives the canonical app_id.
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let existing = client.scripts_list_by_app(app_ident).await?;
|
||||
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateScriptBody {
|
||||
app_id: app.app.id,
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(client.scripts_create(&body).await?, "created")
|
||||
}
|
||||
}
|
||||
(None, Some(group_ident)) => {
|
||||
// Group scripts update through the owner-polymorphic by-id
|
||||
// endpoint; create through the group endpoint.
|
||||
let existing = client.group_scripts_list(group_ident).await?;
|
||||
if let Some(s) = existing.into_iter().find(|s| s.name == name) {
|
||||
let updated = client
|
||||
.scripts_update_source(&s.id.to_string(), &source, cfg)
|
||||
.await?;
|
||||
(updated, "updated")
|
||||
} else {
|
||||
let body = CreateGroupScriptBody {
|
||||
name: &name,
|
||||
description,
|
||||
source: &source,
|
||||
timeout_seconds: cfg.timeout_seconds,
|
||||
memory_limit_mb: cfg.memory_limit_mb,
|
||||
kind: cfg.kind,
|
||||
sandbox: cfg.sandbox,
|
||||
};
|
||||
(
|
||||
client.group_scripts_create(group_ident, &body).await?,
|
||||
"created",
|
||||
)
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
return Err(anyhow!("deploy requires exactly one of --app or --group"));
|
||||
}
|
||||
};
|
||||
// Emit the script object so `--output json` callers can capture the
|
||||
// id for the follow-up routes/triggers calls.
|
||||
|
||||
151
crates/picloud-cli/src/discover.rs
Normal file
151
crates/picloud-cli/src/discover.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! Phase 5: discover a project *tree* — every `picloud.toml` under a root
|
||||
//! directory — and assemble the wire tree bundle the server's `/tree/{plan,
|
||||
//! apply}` endpoints consume. Each manifest becomes one node (app or group);
|
||||
//! the server resolves ancestry from its own group tree, so the on-disk
|
||||
//! nesting is organizational, not authoritative here.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::manifest::{Manifest, MANIFEST_FILE};
|
||||
|
||||
/// Find every `picloud.toml` under `root` (recursively), skipping the
|
||||
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
|
||||
/// overlays (`picloud.<env>.toml`) are not matched — only the base filename.
|
||||
pub fn find_manifests(root: &Path) -> Result<Vec<PathBuf>> {
|
||||
let mut out = Vec::new();
|
||||
walk(root, &mut out)?;
|
||||
out.sort();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
let entries =
|
||||
std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let ft = entry.file_type()?;
|
||||
if ft.is_dir() {
|
||||
if matches!(
|
||||
name.to_str(),
|
||||
Some(".picloud" | ".git" | "scripts" | "target")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
walk(&entry.path(), out)?;
|
||||
} else if name == MANIFEST_FILE {
|
||||
out.push(entry.path());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}], project }`)
|
||||
/// from every manifest under `root`. Returns the JSON, the node count, and the
|
||||
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
|
||||
/// tree that names the same (kind, slug) twice, or declares `[project]` anywhere
|
||||
/// but the root manifest.
|
||||
pub fn build_tree(
|
||||
root: &Path,
|
||||
env: Option<&str>,
|
||||
) -> Result<(Value, usize, Vec<crate::manifest::ManifestEnvironment>)> {
|
||||
let paths = find_manifests(root)?;
|
||||
if paths.is_empty() {
|
||||
bail!(
|
||||
"no {MANIFEST_FILE} found under {} — nothing to apply",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
let root_manifest = root.join(MANIFEST_FILE);
|
||||
// First pass: load every manifest and index its DIRECTORY → (slug, is_group)
|
||||
// so a group node's parent can be inferred from the enclosing directory.
|
||||
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
|
||||
let mut group_dirs: HashMap<PathBuf, String> = HashMap::new();
|
||||
let mut project: Option<crate::manifest::ManifestProject> = None;
|
||||
for path in &paths {
|
||||
let manifest = Manifest::load_with_env(path, env)
|
||||
.with_context(|| format!("loading {}", path.display()))?;
|
||||
// `[project]` is project-level — valid only on the tree's root manifest.
|
||||
if let Some(p) = &manifest.project {
|
||||
if path == &root_manifest {
|
||||
project = Some(p.clone());
|
||||
} else {
|
||||
bail!(
|
||||
"{} declares [project], which is only valid on the root manifest ({})",
|
||||
path.display(),
|
||||
root_manifest.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
let dir = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf();
|
||||
if manifest.is_group() {
|
||||
group_dirs.insert(dir.clone(), manifest.slug().to_string());
|
||||
}
|
||||
loaded.push((dir, manifest));
|
||||
}
|
||||
|
||||
let mut nodes = Vec::with_capacity(loaded.len());
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
for (dir, manifest) in &loaded {
|
||||
let bundle = build_bundle(manifest, dir)?;
|
||||
let kind = if manifest.is_group() { "group" } else { "app" };
|
||||
let slug = manifest.slug().to_string();
|
||||
if !seen.insert((kind.to_string(), slug.clone())) {
|
||||
bail!("project tree declares {kind} `{slug}` more than once");
|
||||
}
|
||||
let mut node = json!({ "kind": kind, "slug": slug, "bundle": bundle });
|
||||
// Group nodes carry their parent (explicit `[group] parent`, else the
|
||||
// nearest ancestor directory's group) + display name, so the server can
|
||||
// create/reparent them (M2). App nodes ignore both server-side.
|
||||
if let Some(g) = &manifest.group {
|
||||
let parent = g
|
||||
.parent
|
||||
.clone()
|
||||
.or_else(|| nearest_ancestor_group(dir, &group_dirs));
|
||||
let obj = node.as_object_mut().expect("json object");
|
||||
obj.insert("name".into(), json!(g.name));
|
||||
if let Some(p) = parent {
|
||||
obj.insert("parent_slug".into(), json!(p));
|
||||
}
|
||||
}
|
||||
nodes.push(node);
|
||||
}
|
||||
let count = nodes.len();
|
||||
let envs = project
|
||||
.as_ref()
|
||||
.map(|p| p.environments.clone())
|
||||
.unwrap_or_default();
|
||||
let mut bundle = json!({ "nodes": nodes });
|
||||
if let Some(p) = &project {
|
||||
bundle.as_object_mut().expect("json object").insert(
|
||||
"project".into(),
|
||||
json!({
|
||||
"environments": p.environments.iter()
|
||||
.map(|e| json!({ "name": e.name, "confirm": e.confirm }))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok((bundle, count, envs))
|
||||
}
|
||||
|
||||
/// The slug of the nearest ancestor directory (above `dir`) that holds a group
|
||||
/// manifest — the directory-derived parent for a nested group node.
|
||||
fn nearest_ancestor_group(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
|
||||
let mut cur = dir.parent();
|
||||
while let Some(d) = cur {
|
||||
if let Some(slug) = group_dirs.get(d) {
|
||||
return Some(slug.clone());
|
||||
}
|
||||
cur = d.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -13,6 +13,59 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
const DIR: &str = ".picloud";
|
||||
const PLAN_FILE: &str = "plan.json";
|
||||
const PROJECT_FILE: &str = "project.json";
|
||||
|
||||
/// The repo's stable project identity (§7, M3): a random, opaque key minted on
|
||||
/// first need and persisted (gitignored) in `.picloud/`. It is the server-side
|
||||
/// ownership handle — the same repo keeps the same project across clones/CI
|
||||
/// because the key travels in the working tree, never in a committed file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectLink {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
fn project_path(base: &Path) -> PathBuf {
|
||||
base.join(DIR).join(PROJECT_FILE)
|
||||
}
|
||||
|
||||
/// Read the project key, if one has been minted under `base/.picloud/`.
|
||||
#[must_use]
|
||||
pub fn read_project_key(base: &Path) -> Option<String> {
|
||||
let body = fs::read(project_path(base)).ok()?;
|
||||
serde_json::from_slice::<ProjectLink>(&body)
|
||||
.ok()
|
||||
.map(|l| l.key)
|
||||
}
|
||||
|
||||
/// Read the project key, minting and persisting a fresh one if absent. Used by
|
||||
/// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets
|
||||
/// a stable ownership identity on first use. The new key is a random v4 UUID.
|
||||
pub fn ensure_project_key(base: &Path) -> Result<String> {
|
||||
if let Some(k) = read_project_key(base) {
|
||||
return Ok(k);
|
||||
}
|
||||
let key = uuid::Uuid::new_v4().to_string();
|
||||
write_project_key(base, &key)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Persist `key` as the project identity, creating `.picloud/` (self-ignored)
|
||||
/// if needed. Idempotent overwrite — callers usually go through
|
||||
/// [`ensure_project_key`].
|
||||
pub fn write_project_key(base: &Path, key: &str) -> Result<()> {
|
||||
let dir = base.join(DIR);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
let ignore = dir.join(".gitignore");
|
||||
if !ignore.exists() {
|
||||
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
|
||||
}
|
||||
let body = serde_json::to_vec_pretty(&ProjectLink {
|
||||
key: key.to_string(),
|
||||
})
|
||||
.context("encoding .picloud/project.json")?;
|
||||
fs::write(project_path(base), body).context("writing .picloud/project.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The recorded result of the last `pic plan`, scoped to the app it was for.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod discover;
|
||||
mod linkstate;
|
||||
mod manifest;
|
||||
mod output;
|
||||
@@ -202,6 +203,11 @@ struct ApplyArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Phase 5: apply a whole project TREE — every `picloud.toml` under this
|
||||
/// directory (groups + apps) reconciled in one atomic transaction.
|
||||
/// Mutually exclusive with `--file`.
|
||||
#[arg(long, conflicts_with = "file")]
|
||||
dir: Option<PathBuf>,
|
||||
/// Delete live scripts/routes/triggers absent from the manifest.
|
||||
/// Secrets are never pruned.
|
||||
#[arg(long)]
|
||||
@@ -214,10 +220,19 @@ struct ApplyArgs {
|
||||
/// since the last `pic plan`).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Tree apply only (`--dir`): seize declared groups currently owned by
|
||||
/// another project (§7). Requires group-admin on each contested node.
|
||||
#[arg(long, requires = "dir")]
|
||||
takeover: bool,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before applying.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// Tree apply only (`--dir`): explicitly approve applying to a
|
||||
/// confirm-required environment (per the root manifest's `[project]`
|
||||
/// policy, §4.2). Repeatable. A blanket `--yes` does NOT cover it.
|
||||
#[arg(long = "approve", requires = "dir")]
|
||||
approve: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -225,6 +240,10 @@ struct PlanArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Phase 5: plan a whole project TREE — every `picloud.toml` under this
|
||||
/// directory (groups + apps). Mutually exclusive with `--file`.
|
||||
#[arg(long, conflicts_with = "file")]
|
||||
dir: Option<PathBuf>,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before diffing.
|
||||
#[arg(long)]
|
||||
@@ -529,15 +548,19 @@ enum DomainsCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ScriptsCmd {
|
||||
/// List scripts. With `--app`, scoped to one app; without, one
|
||||
/// List scripts. With `--app`, scoped to one app; with `--group`, a
|
||||
/// group's own scripts (Phase 4); without either, one
|
||||
/// `GET /admin/scripts` for everything the caller can see.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
/// Phase 4: list a group's own (non-inherited) scripts.
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
},
|
||||
|
||||
/// Upload a `.rhai` file. Patches the existing script with the
|
||||
/// matching name in `--app` if one exists, otherwise creates it.
|
||||
/// matching name in `--app`/`--group` if one exists, otherwise creates it.
|
||||
Deploy(DeployArgs),
|
||||
|
||||
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
||||
@@ -551,8 +574,14 @@ enum ScriptsCmd {
|
||||
#[derive(Args)]
|
||||
struct DeployArgs {
|
||||
file: PathBuf,
|
||||
/// Owning app (slug or id). Exactly one of `--app` / `--group`.
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
app: Option<String>,
|
||||
/// Phase 4: deploy a group-owned script (template inherited by
|
||||
/// descendant apps) instead of an app-owned one. Exactly one of
|
||||
/// `--app` / `--group`.
|
||||
#[arg(long, conflicts_with = "app")]
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
#[arg(long)]
|
||||
@@ -1319,18 +1348,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::Apply(args) => match &args.dir {
|
||||
Some(dir) => {
|
||||
cmds::apply::run_tree(
|
||||
dir,
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
args.takeover,
|
||||
args.env.as_deref(),
|
||||
&args.approve,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => {
|
||||
cmds::apply::run(
|
||||
&args.file,
|
||||
args.env.as_deref(),
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
Cmd::Plan(args) => match &args.dir {
|
||||
Some(dir) => cmds::plan::run_tree(dir, args.env.as_deref(), mode).await,
|
||||
None => 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(
|
||||
@@ -1465,15 +1512,16 @@ async fn main() -> ExitCode {
|
||||
},
|
||||
} => cmds::groups::members_rm(&group, &user_id).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Ls { app },
|
||||
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
||||
cmd: ScriptsCmd::Ls { app, group },
|
||||
} => cmds::scripts::ls(app.as_deref(), group.as_deref(), mode).await,
|
||||
Cmd::Scripts {
|
||||
cmd: ScriptsCmd::Deploy(args),
|
||||
} => match args.script_config() {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.app.as_deref(),
|
||||
args.group.as_deref(),
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
@@ -1516,7 +1564,8 @@ async fn main() -> ExitCode {
|
||||
Ok(cfg) => {
|
||||
cmds::scripts::deploy(
|
||||
&args.file,
|
||||
&args.app,
|
||||
args.app.as_deref(),
|
||||
args.group.as_deref(),
|
||||
args.name.as_deref(),
|
||||
args.description.as_deref(),
|
||||
&cfg,
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
|
||||
//! (a secret name) resolved server-side at apply.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -29,7 +30,16 @@ pub const MANIFEST_FILE: &str = "picloud.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub app: ManifestApp,
|
||||
/// An app node declares `[app]`; a group node declares `[group]` (Phase 5).
|
||||
/// Exactly one is present (enforced by [`Manifest::parse`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub app: Option<ManifestApp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<ManifestGroup>,
|
||||
/// `[project]` — project-level policy (M5), consumed only by `apply --dir`
|
||||
/// and only on the tree's ROOT manifest (enforced in `build_tree`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<ManifestProject>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -38,12 +48,74 @@ pub struct Manifest {
|
||||
pub triggers: ManifestTriggers,
|
||||
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
|
||||
pub secrets: ManifestSecrets,
|
||||
/// `[vars]` — app config key → value, reconciled at app scope `*`. Values
|
||||
/// are non-secret and live inline (unlike `[secrets]`, which is name-only).
|
||||
/// The overlay merges per-env, last-write-wins by key.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub vars: BTreeMap<String, toml::Value>,
|
||||
/// `[[extension_points]]` — module names this node opens for per-tenant
|
||||
/// resolution (§5.5). Allowed on both app and group manifests.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<ManifestExtensionPoint>,
|
||||
/// `[[route_templates]]` — GROUP-only route declarations that fan out into a
|
||||
/// concrete route on every descendant app at apply time (§4.5, M4a). String
|
||||
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub route_templates: Vec<ManifestRouteTemplate>,
|
||||
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
|
||||
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
|
||||
/// `script = …`, plus the kind's params (string fields may carry
|
||||
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub trigger_templates: Vec<ManifestTriggerTemplate>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Parse a manifest from TOML text.
|
||||
/// Parse a manifest from TOML text. Enforces the app-XOR-group invariant.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
toml::from_str(text).context("parsing manifest TOML")
|
||||
let m: Self = toml::from_str(text).context("parsing manifest TOML")?;
|
||||
match (&m.app, &m.group) {
|
||||
(Some(_), None) | (None, Some(_)) => {}
|
||||
(Some(_), Some(_)) => {
|
||||
anyhow::bail!(
|
||||
"manifest declares both [app] and [group]; a node is one or the other"
|
||||
)
|
||||
}
|
||||
(None, None) => {
|
||||
anyhow::bail!("manifest declares neither [app] nor [group]")
|
||||
}
|
||||
}
|
||||
// A group node owns only scripts + vars (+ secret names) — routes and
|
||||
// triggers are app concerns. Reject them early with a clear message.
|
||||
if m.group.is_some() {
|
||||
if !m.routes.is_empty() {
|
||||
anyhow::bail!(
|
||||
"a [group] manifest cannot declare [[routes]] — routes bind to an app"
|
||||
);
|
||||
}
|
||||
if !m.triggers.is_empty() {
|
||||
anyhow::bail!(
|
||||
"a [group] manifest cannot declare [[triggers]] — triggers belong to an app"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// This node's slug (app or group).
|
||||
#[must_use]
|
||||
pub fn slug(&self) -> &str {
|
||||
match (&self.app, &self.group) {
|
||||
(Some(a), _) => &a.slug,
|
||||
(_, Some(g)) => &g.slug,
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this manifest declares a `[group]` node (Phase 5).
|
||||
#[must_use]
|
||||
pub fn is_group(&self) -> bool {
|
||||
self.group.is_some()
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
@@ -61,9 +133,9 @@ impl Manifest {
|
||||
|
||||
/// 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.)
|
||||
/// where "an environment is an app". The overlay carries per-env slug,
|
||||
/// secrets, and vars (overlay vars win per key); scripts/routes/triggers
|
||||
/// stay in the shared base.
|
||||
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 {
|
||||
@@ -84,17 +156,24 @@ impl Manifest {
|
||||
/// 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;
|
||||
if let Some(app) = &mut self.app {
|
||||
if let Some(slug) = overlay.app.slug {
|
||||
app.slug = slug;
|
||||
}
|
||||
if let Some(name) = overlay.app.name {
|
||||
app.name = name;
|
||||
}
|
||||
}
|
||||
for n in overlay.secrets.names {
|
||||
if !self.secrets.names.contains(&n) {
|
||||
self.secrets.names.push(n);
|
||||
}
|
||||
}
|
||||
// Overlay vars override base vars per key (the env-specific value of
|
||||
// an env-agnostic default); keys only in the base are kept.
|
||||
for (k, v) in overlay.vars {
|
||||
self.vars.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +192,10 @@ fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
|
||||
/// 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.
|
||||
/// `deny_unknown_fields`: an overlay can carry *only* `[app]`, `[secrets]`,
|
||||
/// and `[vars]`. 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 {
|
||||
@@ -124,6 +203,8 @@ pub struct ManifestOverlay {
|
||||
pub app: OverlayApp,
|
||||
#[serde(default)]
|
||||
pub secrets: ManifestSecrets,
|
||||
#[serde(default)]
|
||||
pub vars: BTreeMap<String, toml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize)]
|
||||
@@ -143,6 +224,42 @@ pub struct ManifestApp {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// A `[group]` node (Phase 5/M2): a group's own declarative content — its
|
||||
/// scripts and `[vars]`. With M2, `pic apply --dir` also creates the group if
|
||||
/// it doesn't exist and reparents it under `parent`. When `parent` is omitted,
|
||||
/// the parent is inferred from the enclosing directory's group manifest (or the
|
||||
/// instance root for a top-level group).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestGroup {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Explicit parent group slug (M2). Overrides the directory-derived parent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
/// `[project]` — project-level policy (§4.2/§6, M5). Valid ONLY on the root
|
||||
/// manifest of a `pic apply --dir` tree; rejected elsewhere by [`build_tree`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestProject {
|
||||
/// `[[project.environments]]` — per-env apply policy.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub environments: Vec<ManifestEnvironment>,
|
||||
}
|
||||
|
||||
/// One `[[project.environments]]` entry. `confirm = true` gates applying to this
|
||||
/// env behind an explicit `pic apply --approve <name>` (M5).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestEnvironment {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub confirm: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestScript {
|
||||
pub name: String,
|
||||
@@ -193,6 +310,43 @@ pub struct ManifestRoute {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `[[route_templates]]` — a route declared once on a group, fanned out per
|
||||
/// descendant app (§4.5, M4a). Same shape as [`ManifestRoute`] plus a `name`
|
||||
/// (the per-group identity/upsert key).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestRouteTemplate {
|
||||
/// Per-group template name (identity/upsert key).
|
||||
pub name: String,
|
||||
pub script: String,
|
||||
#[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,
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
|
||||
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
|
||||
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
|
||||
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggerTemplate {
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub spec: toml::Value,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggers {
|
||||
@@ -323,6 +477,17 @@ impl ManifestSecrets {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[[extension_points]]` — a module name opened for per-tenant resolution
|
||||
/// (§5.5). A parent/group script importing `name` resolves it against the
|
||||
/// inheriting app's module instead of the sealed lexical chain; `default`
|
||||
/// names a local module used when an app provides none.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestExtensionPoint {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
|
||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||
@@ -348,11 +513,13 @@ mod tests {
|
||||
|
||||
fn sample() -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
scripts: vec![
|
||||
ManifestScript {
|
||||
name: "create-post".into(),
|
||||
@@ -409,6 +576,16 @@ mod tests {
|
||||
secrets: ManifestSecrets {
|
||||
names: vec!["STRIPE_KEY".into()],
|
||||
},
|
||||
vars: BTreeMap::from([
|
||||
("region".to_string(), toml::Value::String("eu".into())),
|
||||
("max-retries".to_string(), toml::Value::Integer(3)),
|
||||
]),
|
||||
extension_points: vec![ManifestExtensionPoint {
|
||||
name: "theme".into(),
|
||||
default: Some("default-theme".into()),
|
||||
}],
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,11 +631,9 @@ mod tests {
|
||||
)
|
||||
.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"
|
||||
);
|
||||
let app = m.app.as_ref().unwrap();
|
||||
assert_eq!(app.slug, "blog-staging", "overlay slug wins");
|
||||
assert_eq!(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()],
|
||||
@@ -503,21 +678,89 @@ mod tests {
|
||||
#[test]
|
||||
fn empty_optional_sections_omitted() {
|
||||
let m = Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
scripts: vec![],
|
||||
routes: vec![],
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
vars: BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
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}");
|
||||
assert!(!text.contains("vars"), "got:\n{text}");
|
||||
assert!(!text.contains("extension_points"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
||||
// A [group] node: scripts + vars, no [app].
|
||||
let m = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
||||
[vars]\nregion = \"eu\"\n",
|
||||
)
|
||||
.expect("group manifest parses");
|
||||
assert!(m.is_group());
|
||||
assert_eq!(m.slug(), "acme");
|
||||
assert_eq!(m.scripts.len(), 1);
|
||||
|
||||
// A group cannot carry routes/triggers.
|
||||
let err = Manifest::parse(
|
||||
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
||||
[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n",
|
||||
)
|
||||
.expect_err("group with routes is rejected");
|
||||
assert!(err.to_string().contains("routes"), "got: {err}");
|
||||
|
||||
// Neither / both is rejected.
|
||||
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
|
||||
Manifest::parse("[app]\nslug=\"a\"\nname=\"A\"\n[group]\nslug=\"g\"\nname=\"G\"\n")
|
||||
.expect_err("both [app] and [group]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_block_parses_environment_policy() {
|
||||
let m = Manifest::parse(
|
||||
"[app]\nslug = \"a\"\nname = \"A\"\n\n\
|
||||
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
|
||||
[[project.environments]]\nname = \"staging\"\n",
|
||||
)
|
||||
.expect("manifest with [project] parses");
|
||||
let p = m.project.expect("project present");
|
||||
assert_eq!(p.environments.len(), 2);
|
||||
assert_eq!(p.environments[0].name, "production");
|
||||
assert!(p.environments[0].confirm);
|
||||
// `confirm` defaults to false when omitted.
|
||||
assert_eq!(p.environments[1].name, "staging");
|
||||
assert!(!p.environments[1].confirm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vars_override_base_per_key() {
|
||||
let mut base = sample();
|
||||
base.vars
|
||||
.insert("region".into(), toml::Value::String("eu".into()));
|
||||
base.vars
|
||||
.insert("tier".into(), toml::Value::String("base".into()));
|
||||
let overlay: ManifestOverlay =
|
||||
toml::from_str("[vars]\nregion = \"us\"\nextra = true\n").unwrap();
|
||||
base.apply_overlay(overlay);
|
||||
// overlay wins for `region`, base-only `tier` survives, overlay adds `extra`.
|
||||
assert_eq!(base.vars["region"], toml::Value::String("us".into()));
|
||||
assert_eq!(base.vars["tier"], toml::Value::String("base".into()));
|
||||
assert_eq!(base.vars["extra"], toml::Value::Boolean(true));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,3 +151,105 @@ fn apply_rejects_bad_bundle_atomically() {
|
||||
"a failed apply must leave nothing behind:\n{s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn apply_reconciles_app_vars() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("apply-vars");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let dir = manifest_dir();
|
||||
// The script returns the resolved `region` var verbatim, so an invoke
|
||||
// reflects exactly what `apply` wrote.
|
||||
fs::write(
|
||||
dir.path().join("scripts/read.rhai"),
|
||||
"vars::get(\"region\")",
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = |vars_block: &str| {
|
||||
format!(
|
||||
"[app]\nslug = \"{slug}\"\nname = \"Vars Test\"\n\n\
|
||||
[[scripts]]\nname = \"read\"\nfile = \"scripts/read.rhai\"\n\n\
|
||||
[[routes]]\nscript = \"read\"\nmethod = \"POST\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/read\"\n\n\
|
||||
{vars_block}"
|
||||
)
|
||||
};
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
|
||||
// 1. Apply with `region = "eu"` → the var is created and injected.
|
||||
fs::write(&manifest_path, manifest("[vars]\nregion = \"eu\"\n")).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)
|
||||
);
|
||||
let id = script_id(&env, &slug);
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::json!("eu"),
|
||||
"var applied"
|
||||
);
|
||||
|
||||
// 2. Change the value → Update (not Create).
|
||||
fs::write(&manifest_path, manifest("[vars]\nregion = \"us\"\n")).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::json!("us"),
|
||||
"var updated"
|
||||
);
|
||||
|
||||
// 3. Drop the var + `--prune` → Delete; `vars::get` now returns `()`.
|
||||
fs::write(&manifest_path, manifest("")).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &id),
|
||||
serde_json::Value::Null,
|
||||
"var pruned → unresolved"
|
||||
);
|
||||
}
|
||||
|
||||
fn script_id(env: &common::TestEnv, slug: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", slug])
|
||||
.output()
|
||||
.expect("scripts ls");
|
||||
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("scripts ls should produce one row")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
169
crates/picloud-cli/tests/approval.rs
Normal file
169
crates/picloud-cli/tests/approval.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`:
|
||||
//! * a root manifest `[project]` block marks an environment confirm-required,
|
||||
//! * applying to that env WITHOUT `--approve` is refused (a blanket `--yes`
|
||||
//! does not cover it) — refused non-interactively at the CLI,
|
||||
//! * `--approve <env>` (as an admin) lets it through,
|
||||
//! * a non-gated environment applies with plain `--yes`,
|
||||
//! * approving a gated apply needs ADMIN authority on the node — a non-admin
|
||||
//! editor with `--approve` is refused server-side (403).
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
/// A single-app project dir whose root manifest declares a `[project]` policy:
|
||||
/// `production` is confirm-required, `staging` is not.
|
||||
fn project_dir(app: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
// A `[vars]` entry so the app bundle has write-requiring content: an `editor`
|
||||
// member must hold (and exercise) AppVarsWrite to pass authz_tree — which
|
||||
// makes the admin-gate test prove the approval gate is ABOVE editor-write,
|
||||
// not merely "any non-admin is refused". (Vars cascade-delete with the app,
|
||||
// unlike scripts which are ON DELETE RESTRICT, so AppGuard teardown is clean.)
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\
|
||||
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
|
||||
[[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\
|
||||
[vars]\nregion = \"eu\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// `--env <e>` requires the overlay file to exist; empty overlays keep the
|
||||
// base slug (same app across envs — we're testing the gate, not env routing).
|
||||
fs::write(dir.path().join("picloud.production.toml"), "").unwrap();
|
||||
fs::write(dir.path().join("picloud.staging.toml"), "").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
|
||||
cmd.output().expect("apply --dir")
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn confirm_required_env_needs_explicit_approval() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("appr-g");
|
||||
let app = common::unique_slug("appr-a");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = project_dir(&app);
|
||||
|
||||
// --- production is confirm-required: --yes alone is refused. ---
|
||||
let out = apply(&env, dir.path(), &["--env", "production", "--yes"]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"production apply without --approve must be refused"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("approve"),
|
||||
"refusal should mention --approve:\n{err}"
|
||||
);
|
||||
|
||||
// --- with --approve production, it applies. ---
|
||||
let ok = apply(
|
||||
&env,
|
||||
dir.path(),
|
||||
&["--env", "production", "--approve", "production"],
|
||||
);
|
||||
assert!(
|
||||
ok.status.success(),
|
||||
"approved production apply should succeed: {}",
|
||||
String::from_utf8_lossy(&ok.stderr)
|
||||
);
|
||||
|
||||
// --- staging is NOT gated: plain --yes applies. ---
|
||||
let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]);
|
||||
assert!(
|
||||
ok2.status.success(),
|
||||
"non-gated staging apply should succeed: {}",
|
||||
String::from_utf8_lossy(&ok2.stderr)
|
||||
);
|
||||
|
||||
// --- single-node `apply --file` to a gated env is refused (no silent
|
||||
// bypass): the admin-gated approval is a `--dir` feature. ---
|
||||
let single = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
.args(["--env", "production", "--yes"])
|
||||
.output()
|
||||
.expect("apply --file");
|
||||
assert!(
|
||||
!single.status.success(),
|
||||
"single-node apply to a confirm-required env must be refused"
|
||||
);
|
||||
let serr = String::from_utf8_lossy(&single.stderr).to_lowercase();
|
||||
assert!(
|
||||
serr.contains("confirm-required") || serr.contains("--dir"),
|
||||
"single-node refusal should point at --dir:\n{serr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn approving_a_gated_apply_requires_admin() {
|
||||
// §4.2: approving a confirm-required env is admin-gated — a second gate on
|
||||
// top of the editor-level write caps an ordinary apply needs. An editor who
|
||||
// CAN write the app still cannot approve a gated apply.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("appr2-g");
|
||||
let app = common::unique_slug("appr2-a");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A member with `editor` (write) on the app — enough for an ordinary apply,
|
||||
// not enough to approve a gated environment.
|
||||
let m = member::member_user(fx, &common::unique_username("appr"));
|
||||
member::grant_membership(fx, &app, &m.id, "editor");
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
|
||||
let dir = project_dir(&app);
|
||||
let out = apply(
|
||||
&member_env,
|
||||
dir.path(),
|
||||
&["--env", "production", "--approve", "production"],
|
||||
);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a non-admin editor must not be able to approve a gated apply"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("forbidden") || err.contains("403"),
|
||||
"approval denial should be an authz error:\n{err}"
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod common;
|
||||
mod admins;
|
||||
mod api_keys;
|
||||
mod apply;
|
||||
mod approval;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod config;
|
||||
@@ -23,12 +24,16 @@ mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod extension_points;
|
||||
mod group_modules;
|
||||
mod group_scripts;
|
||||
mod group_secrets;
|
||||
mod groups;
|
||||
mod init;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
mod output;
|
||||
mod ownership;
|
||||
mod plan;
|
||||
mod prune;
|
||||
mod pull;
|
||||
@@ -37,5 +42,8 @@ mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod templates;
|
||||
mod tree;
|
||||
mod tree_shape;
|
||||
mod triggers;
|
||||
mod vars;
|
||||
|
||||
@@ -38,6 +38,36 @@ impl Drop for AppGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a script by id on drop (best-effort). Phase 4: a group-owned
|
||||
/// script blocks its group's deletion (`ON DELETE RESTRICT`), so register the
|
||||
/// `ScriptGuard` *after* the owning `GroupGuard` — the script drops (deletes)
|
||||
/// first, leaving the group removable.
|
||||
pub struct ScriptGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
impl ScriptGuard {
|
||||
pub fn new(url: &str, token: &str, id: &str) -> Self {
|
||||
Self {
|
||||
url: url.to_string(),
|
||||
token: token.to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScriptGuard {
|
||||
fn drop(&mut self) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let _ = client
|
||||
.delete(format!("{}/api/v1/admin/scripts/{}", self.url, self.id))
|
||||
.bearer_auth(&self.token)
|
||||
.send();
|
||||
}
|
||||
}
|
||||
|
||||
pub struct UserGuard {
|
||||
url: String,
|
||||
token: String,
|
||||
|
||||
@@ -78,6 +78,26 @@ pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str)
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` on a group.
|
||||
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/v1/admin/groups/{}/members",
|
||||
fx.url, group_slug
|
||||
))
|
||||
.bearer_auth(&fx.admin_token)
|
||||
.json(&json!({ "user_id": user_id, "role": role }))
|
||||
.send()
|
||||
.expect("grant group membership");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"grant group membership failed: {} {}",
|
||||
resp.status(),
|
||||
resp.text().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
|
||||
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
243
crates/picloud-cli/tests/extension_points.rs
Normal file
243
crates/picloud-cli/tests/extension_points.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
|
||||
//! * a GROUP declares a module name `theme` an extension point (with a
|
||||
//! default body) and a group endpoint `render` that imports it,
|
||||
//! * an app under the group provides its OWN `theme`; invoking the inherited
|
||||
//! `render` in that app's context resolves the APP's `theme` (the
|
||||
//! inversion) — NOT sealed to the group,
|
||||
//! * an app that provides no `theme` falls back to the group's default body,
|
||||
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
|
||||
//! no-op, so `--prune` never silently drops it).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
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 extension_point_resolves_per_app_with_default_fallback() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("ep-grp");
|
||||
let app_a = common::unique_slug("ep-a");
|
||||
let app_b = common::unique_slug("ep-b");
|
||||
|
||||
// GroupGuard first so it drops LAST (after apps + group scripts).
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
|
||||
// --- Group manifest: a default `theme` body, a `render` endpoint that
|
||||
// imports `theme`, and the `[[extension_points]]` declaration. ---
|
||||
fs::write(
|
||||
dir.path().join("scripts/defaulttheme.rhai"),
|
||||
r#"fn name() { "default" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/render.rhai"),
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
)
|
||||
.unwrap();
|
||||
let group_manifest = format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
|
||||
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
|
||||
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
|
||||
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
|
||||
);
|
||||
let group_path = dir.path().join("group.toml");
|
||||
fs::write(&group_path, &group_manifest).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&group_path)
|
||||
.output()
|
||||
.expect("group apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"group apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
// Group scripts block group deletion (RESTRICT) — guard them.
|
||||
let _gr = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "render"),
|
||||
);
|
||||
let _gd = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "defaulttheme"),
|
||||
);
|
||||
|
||||
// --- App A under the group: provides its OWN `theme` + a caller that
|
||||
// invokes the inherited `render`. ---
|
||||
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/theme-a.rhai"),
|
||||
r#"fn name() { "app-a" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/caller.rhai"),
|
||||
r#"invoke("render", #{})"#,
|
||||
)
|
||||
.unwrap();
|
||||
let app_a_manifest = format!(
|
||||
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
|
||||
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
|
||||
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
||||
);
|
||||
let app_a_path = dir.path().join("a.toml");
|
||||
fs::write(&app_a_path, &app_a_manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&app_a_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The inherited `render` imports the ext point `theme`; in App A's context
|
||||
// it resolves App A's OWN `theme`.
|
||||
let caller_a = app_script_id(&env, &app_a, "caller");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_a),
|
||||
serde_json::json!("app-a"),
|
||||
"extension point must resolve the inheriting app's module"
|
||||
);
|
||||
|
||||
// --- App B under the group: provides NO `theme` → render falls back to the
|
||||
// group's default body. ---
|
||||
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_b, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let app_b_manifest = format!(
|
||||
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
|
||||
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
||||
);
|
||||
let app_b_path = dir.path().join("b.toml");
|
||||
fs::write(&app_b_path, &app_b_manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&app_b_path)
|
||||
.assert()
|
||||
.success();
|
||||
let caller_b = app_script_id(&env, &app_b, "caller");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_b),
|
||||
serde_json::json!("default"),
|
||||
"an app providing no module must fall back to the ext point's default"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn extension_point_round_trips_through_pull() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("ep-pull");
|
||||
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply an app manifest that declares an extension point with a default.
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/theme.rhai"),
|
||||
r#"fn name() { "x" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
|
||||
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
|
||||
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull into a fresh dir, then re-plan: the extension point must round-trip
|
||||
// (no Create/Delete), so `--prune` would never drop it.
|
||||
let pulled = manifest_dir();
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(pulled.path())
|
||||
.assert()
|
||||
.success();
|
||||
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
|
||||
assert!(
|
||||
toml.contains("theme_slot"),
|
||||
"pulled manifest must export the extension point:\n{toml}"
|
||||
);
|
||||
let plan = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(pulled.path().join("picloud.toml"))
|
||||
.output()
|
||||
.expect("plan");
|
||||
let stdout = String::from_utf8_lossy(&plan.stdout);
|
||||
assert!(
|
||||
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
|
||||
"re-planning a pulled manifest must be a no-op:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Id of the named script in a group (`pic scripts ls --group <g>`).
|
||||
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--group", group], name)
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--app", app], name)
|
||||
}
|
||||
|
||||
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
|
||||
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
218
crates/picloud-cli/tests/group_modules.rs
Normal file
218
crates/picloud-cli/tests/group_modules.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
//! Phase 4b group-owned modules + the lexical import resolver (§5.5), e2e via `pic`:
|
||||
//! * a group owns a `module` and an endpoint that imports it; an app under the
|
||||
//! group inherits the endpoint and resolves the module down the chain,
|
||||
//! * **trust boundary** — the inheriting app defines a same-named module of
|
||||
//! its own; the inherited group endpoint's import must STILL bind the
|
||||
//! group's module (a leaf cannot shadow it),
|
||||
//! * **CoW / app origin** — an app-owned endpoint importing that name resolves
|
||||
//! the app's module,
|
||||
//! * a manifest whose script imports a non-existent module is a `plan` error.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
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 group_module_is_lexically_sealed_under_inheritance() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gm-grp");
|
||||
let child = common::unique_slug("gm-child");
|
||||
|
||||
// GroupGuard first so it drops LAST — after the app + group scripts.
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
|
||||
// Group module `util` and a group endpoint `welcome` that imports it.
|
||||
fs::write(
|
||||
dir.path().join("scripts/util.rhai"),
|
||||
r#"fn greet(n) { "group:" + n }"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/util.rhai"))
|
||||
.args(["--group", &group, "--name", "util", "--kind", "module"])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
fs::write(
|
||||
dir.path().join("scripts/welcome.rhai"),
|
||||
r#"import "util" as u; u::greet("x")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/welcome.rhai"))
|
||||
.args(["--group", &group, "--name", "welcome"])
|
||||
.assert()
|
||||
.success();
|
||||
// Group scripts block group deletion (ON DELETE RESTRICT) — guard both.
|
||||
let _gu = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group, "util"));
|
||||
let _gw = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "welcome"),
|
||||
);
|
||||
|
||||
// App under the group; a `caller` invokes the inherited `welcome`.
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/caller.rhai"),
|
||||
r#"invoke("welcome", #{})"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/caller.rhai"))
|
||||
.args(["--app", &child, "--name", "caller"])
|
||||
.assert()
|
||||
.success();
|
||||
let caller_id = app_script_id(&env, &child, "caller");
|
||||
|
||||
// Inheritance: welcome resolves `util` from the group → "group:x".
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("group:x"),
|
||||
"inherited endpoint must resolve the group's module"
|
||||
);
|
||||
|
||||
// TRUST BOUNDARY: the app defines its OWN `util` module. The group
|
||||
// endpoint's import must STILL bind the GROUP's util (sealed from below).
|
||||
fs::write(
|
||||
dir.path().join("scripts/apputil.rhai"),
|
||||
r#"fn greet(n) { "app:" + n }"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/apputil.rhai"))
|
||||
.args(["--app", &child, "--name", "util", "--kind", "module"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("group:x"),
|
||||
"a leaf's same-named module must NOT shadow an inherited group endpoint's import"
|
||||
);
|
||||
|
||||
// CoW / app origin: an app-owned endpoint importing `util` gets the APP's.
|
||||
fs::write(
|
||||
dir.path().join("scripts/appcaller.rhai"),
|
||||
r#"import "util" as u; u::greet("y")"#,
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/appcaller.rhai"))
|
||||
.args(["--app", &child, "--name", "appcaller"])
|
||||
.assert()
|
||||
.success();
|
||||
let appcaller_id = app_script_id(&env, &child, "appcaller");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &appcaller_id),
|
||||
serde_json::json!("app:y"),
|
||||
"an app-owned script's import must resolve the app's own module"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn dangling_import_is_rejected_by_plan() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("gm-dangle");
|
||||
|
||||
let _app = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A manifest whose endpoint imports a module that exists nowhere on the
|
||||
// chain. `pic plan` must refuse it (the §5.5 dangling-import check) rather
|
||||
// than apply a script that 404s its import at runtime.
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/broken.rhai"),
|
||||
r#"import "ghost" as g; g::run()"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"Dangle\"\n\n\
|
||||
[[scripts]]\nname = \"broken\"\nfile = \"scripts/broken.rhai\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.expect("plan");
|
||||
assert!(!out.status.success(), "plan must reject a dangling import");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
stderr.contains("ghost") || stderr.contains("unknown module") || stderr.contains("import"),
|
||||
"plan error should name the missing module:\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Id of the named script in a group (`pic scripts ls --group <g>`).
|
||||
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--group", group], name)
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--app", app], name)
|
||||
}
|
||||
|
||||
/// Run a `scripts ls` and pick the id of the row whose name column matches.
|
||||
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
|
||||
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
// Rows are `id\t<owner_slug>\tname\tversion\tupdated_at`.
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
320
crates/picloud-cli/tests/group_scripts.rs
Normal file
320
crates/picloud-cli/tests/group_scripts.rs
Normal file
@@ -0,0 +1,320 @@
|
||||
//! Phase 4-lite group-owned scripts, end to end via `pic`:
|
||||
//! * a group owns an endpoint script; an app *under* the group inherits it,
|
||||
//! resolving by name down the chain (`invoke`),
|
||||
//! * an app's own script of the same name shadows the inherited one (CoW),
|
||||
//! * an app NOT under the group cannot resolve it (isolation),
|
||||
//! * a manifest binds a route to the inherited script declaratively, and
|
||||
//! re-applying is an idempotent no-op.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
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 group_script_is_inherited_with_cow_and_isolation() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gs-grp");
|
||||
let child = common::unique_slug("gs-child");
|
||||
let orphan = common::unique_slug("gs-orphan");
|
||||
|
||||
// Group with an endpoint script `shared` returning a marker.
|
||||
// GroupGuard first so it drops LAST — after the app + group script below.
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), "\"from-group\"").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/shared.rhai"))
|
||||
.args(["--group", &group, "--name", "shared"])
|
||||
.assert()
|
||||
.success();
|
||||
// The group script blocks group deletion (ON DELETE RESTRICT) — guard it
|
||||
// so it drops before the GroupGuard.
|
||||
let gscript_id = group_script_id(&env, &group);
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
|
||||
|
||||
// An app under the group inherits `shared`. Its `caller` script invokes it.
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/caller.rhai"),
|
||||
"invoke(\"shared\", #{})",
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/caller.rhai"))
|
||||
.args(["--app", &child, "--name", "caller"])
|
||||
.assert()
|
||||
.success();
|
||||
let caller_id = app_script_id(&env, &child, "caller");
|
||||
|
||||
// Inheritance: caller resolves `shared` down its chain → the group's value.
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("from-group"),
|
||||
"inherited group script should resolve by name"
|
||||
);
|
||||
|
||||
// CoW: the app defines its OWN `shared`; the same caller now sees it.
|
||||
fs::write(dir.path().join("scripts/own.rhai"), "\"from-app\"").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/own.rhai"))
|
||||
.args(["--app", &child, "--name", "shared"])
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_id),
|
||||
serde_json::json!("from-app"),
|
||||
"an app's own script must shadow the inherited group one (CoW)"
|
||||
);
|
||||
|
||||
// Isolation: an app NOT under the group cannot resolve `shared`.
|
||||
let _orphan = AppGuard::new(&env.url, &env.token, &orphan);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &orphan])
|
||||
.assert()
|
||||
.success();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/caller.rhai"))
|
||||
.args(["--app", &orphan, "--name", "caller"])
|
||||
.assert()
|
||||
.success();
|
||||
let orphan_caller = app_script_id(&env, &orphan, "caller");
|
||||
// `shared` is not in the orphan's chain → invoke fails (the script errors).
|
||||
let out = common::pic_as(&env)
|
||||
.args(["scripts", "invoke", &orphan_caller])
|
||||
.output()
|
||||
.expect("invoke");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an app outside the group must NOT reach the group's `shared`"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn manifest_binds_route_to_inherited_group_script_idempotently() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gsr-grp");
|
||||
let child = common::unique_slug("gsr-child");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/greet.rhai"), "\"hi\"").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/greet.rhai"))
|
||||
.args(["--group", &group, "--name", "greet"])
|
||||
.assert()
|
||||
.success();
|
||||
let gscript_id = group_script_id(&env, &group);
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
|
||||
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Manifest declares NO `greet` script — only a route binding to it. The
|
||||
// apply engine must resolve `greet` to the inherited group endpoint.
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{child}\"\nname = \"GSR\"\n\n\
|
||||
[[routes]]\nscript = \"greet\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
|
||||
// Plan: a route create that binds to `greet`, and NO script create.
|
||||
let plan = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
plan.contains("greet") && plan.contains("route"),
|
||||
"plan should bind a route to the inherited `greet`:\n{plan}"
|
||||
);
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Re-plan is a clean no-op — the diff resolved the group-bound route's id
|
||||
// back to `greet`, so it is not a perpetual rebind.
|
||||
let replan = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!replan.contains("create") && !replan.contains("update"),
|
||||
"re-plan after binding an inherited route must be a no-op:\n{replan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn inherited_bound_trigger_is_pruned_when_dropped() {
|
||||
// Regression: a trigger bound to an inherited group script must still
|
||||
// resolve its identity in the diff/prune path even after the manifest
|
||||
// stops referencing it — otherwise `--prune` silently orphans it.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("gst-grp");
|
||||
let child = common::unique_slug("gst-child");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
fs::write(dir.path().join("scripts/work.rhai"), "\"ok\"").unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "deploy"])
|
||||
.arg(dir.path().join("scripts/work.rhai"))
|
||||
.args(["--group", &group, "--name", "work"])
|
||||
.assert()
|
||||
.success();
|
||||
let gscript_id = group_script_id(&env, &group);
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
|
||||
|
||||
let _child = AppGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &child, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
// 1. Manifest binds a cron trigger to the INHERITED `work`.
|
||||
let with_trigger = format!(
|
||||
"[app]\nslug = \"{child}\"\nname = \"GST\"\n\n\
|
||||
[[triggers.cron]]\nscript = \"work\"\nschedule = \"0 0 * * * *\"\ntimezone = \"UTC\"\n"
|
||||
);
|
||||
fs::write(&manifest_path, &with_trigger).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
let listed = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &child])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
listed.contains("cron"),
|
||||
"cron trigger bound to inherited `work` should exist:\n{listed}"
|
||||
);
|
||||
|
||||
// 2. Drop the trigger + `--prune` → it must be removed (not orphaned).
|
||||
let empty = format!("[app]\nslug = \"{child}\"\nname = \"GST\"\n");
|
||||
fs::write(&manifest_path, &empty).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
let after = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["triggers", "ls", "--app", &child])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!after.contains("cron"),
|
||||
"prune must remove the inherited-bound cron trigger:\n{after}"
|
||||
);
|
||||
}
|
||||
|
||||
/// First script id from `pic scripts ls --group <g>`.
|
||||
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--group", group])
|
||||
.output()
|
||||
.expect("scripts ls --group");
|
||||
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("group should have one script")
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--app", app])
|
||||
.output()
|
||||
.expect("scripts ls --app");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
// Rows are `id\tapp_slug\tname\tversion\tupdated_at`; pick the wanted name.
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found in app `{app}`:\n{table}"))
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
207
crates/picloud-cli/tests/ownership.rs
Normal file
207
crates/picloud-cli/tests/ownership.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! M3 multi-repo single-owner ownership (§7) via `pic apply --dir`:
|
||||
//! * the first repo to apply a group CLAIMS it (its `.picloud/` project key
|
||||
//! becomes the owner),
|
||||
//! * a SECOND repo (different project key) applying the same group is
|
||||
//! REJECTED with an ownership conflict,
|
||||
//! * `--takeover` lets the second repo seize it (admin-gated; the fixture
|
||||
//! token is instance owner), flipping ownership — proven by the first repo
|
||||
//! now being the one rejected,
|
||||
//! * `--prune` deletes an owned, now-undeclared, empty group; a group owned
|
||||
//! by another repo is never touched.
|
||||
//!
|
||||
//! Each project lives in its own `TempDir`, so `pic` mints a distinct project
|
||||
//! key per repo (`.picloud/project.json`) — exactly the two-repo topology §7
|
||||
//! governs.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::GroupGuard;
|
||||
use crate::common::member;
|
||||
|
||||
/// A single-group project dir: group `slug` under `parent`, no scripts (so a
|
||||
/// structural prune can delete it — a group holding scripts is RESTRICT-pinned).
|
||||
fn group_dir(slug: &str, parent: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!("[group]\nslug = \"{slug}\"\nname = \"Owned Group\"\nparent = \"{parent}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
|
||||
cmd.output().expect("apply --dir")
|
||||
}
|
||||
|
||||
fn ls_groups(env: &common::TestEnv) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["groups", "ls"])
|
||||
.output()
|
||||
.expect("groups ls")
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn first_repo_claims_second_is_rejected_then_takeover_flips_ownership() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("own-g");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// Repo A claims the group on first apply (it does not pre-exist → created
|
||||
// AND claimed for project A in one tx).
|
||||
let repo_a = group_dir(&slug, "root");
|
||||
let a1 = apply(&env, repo_a.path(), &[]);
|
||||
assert!(
|
||||
a1.status.success(),
|
||||
"repo A claim apply failed: {}",
|
||||
String::from_utf8_lossy(&a1.stderr)
|
||||
);
|
||||
assert!(
|
||||
ls_groups(&env).contains(&slug),
|
||||
"group should exist after claim"
|
||||
);
|
||||
|
||||
// Repo B (a different dir → a different project key) is rejected: the group
|
||||
// is owned by project A.
|
||||
let repo_b = group_dir(&slug, "root");
|
||||
let b1 = apply(&env, repo_b.path(), &[]);
|
||||
assert!(
|
||||
!b1.status.success(),
|
||||
"repo B apply must be rejected (group owned by A)"
|
||||
);
|
||||
let b1_err = String::from_utf8_lossy(&b1.stderr).to_lowercase();
|
||||
assert!(
|
||||
b1_err.contains("owned by another project") || b1_err.contains("takeover"),
|
||||
"conflict message should name the ownership clash:\n{b1_err}"
|
||||
);
|
||||
|
||||
// Repo B with --takeover succeeds (the fixture token is instance owner, so
|
||||
// it holds GroupAdmin on every node) and flips ownership to project B.
|
||||
let b2 = apply(&env, repo_b.path(), &["--takeover"]);
|
||||
assert!(
|
||||
b2.status.success(),
|
||||
"repo B --takeover should succeed: {}",
|
||||
String::from_utf8_lossy(&b2.stderr)
|
||||
);
|
||||
|
||||
// Proof the flip took: repo A — formerly the owner — is now the one
|
||||
// rejected without --takeover.
|
||||
let a2 = apply(&env, repo_a.path(), &[]);
|
||||
assert!(
|
||||
!a2.status.success(),
|
||||
"after takeover, repo A must now be rejected (B owns the group)"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn takeover_without_group_admin_is_forbidden() {
|
||||
// §7.4 — ownership ⟂ RBAC: `--takeover` needs GroupAdmin specifically, a
|
||||
// second gate beyond the write caps. A non-admin member (editor) of the
|
||||
// contested group cannot seize it for their repo.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("own-ta");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// Admin (repo A) claims the group.
|
||||
let repo_a = group_dir(&slug, "root");
|
||||
assert!(
|
||||
apply(&env, repo_a.path(), &[]).status.success(),
|
||||
"admin claim apply should succeed"
|
||||
);
|
||||
|
||||
// A member with `editor` (not group_admin) on the group.
|
||||
let m = member::member_user(fx, &common::unique_username("ta"));
|
||||
member::grant_group_membership(fx, &slug, &m.id, "editor");
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
|
||||
// The member's repo B (different project key) attempts a takeover → 403.
|
||||
let repo_b = group_dir(&slug, "root");
|
||||
let out = apply(&member_env, repo_b.path(), &["--takeover"]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"non-admin --takeover must be forbidden"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("forbidden") || err.contains("403"),
|
||||
"takeover denial should be an authz error:\n{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_deletes_owned_undeclared_group_only() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("own-par");
|
||||
let child = common::unique_slug("own-child");
|
||||
// Drop order: child (registered last → dropped first), then parent.
|
||||
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
let _gc = GroupGuard::new(&env.url, &env.token, &child);
|
||||
|
||||
// Repo declares parent + child (both empty); apply claims both.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\nparent = \"root\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(dir.path().join("sub")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("sub/picloud.toml"),
|
||||
format!("[group]\nslug = \"{child}\"\nname = \"Child\"\nparent = \"{parent}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let out = apply(&env, dir.path(), &[]);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"initial claim apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let groups = ls_groups(&env);
|
||||
assert!(
|
||||
groups.contains(&parent) && groups.contains(&child),
|
||||
"both groups should exist"
|
||||
);
|
||||
|
||||
// Drop the child manifest from the SAME repo (keeping its project key) so
|
||||
// the child becomes owned-but-undeclared, then apply --prune: the empty
|
||||
// child is deleted; the parent (still declared) is kept.
|
||||
fs::remove_dir_all(dir.path().join("sub")).unwrap();
|
||||
let out2 = apply(&env, dir.path(), &["--prune", "--yes"]);
|
||||
assert!(
|
||||
out2.status.success(),
|
||||
"prune apply failed: {}",
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
let groups = ls_groups(&env);
|
||||
assert!(
|
||||
groups.contains(&parent),
|
||||
"parent must survive prune:\n{groups}"
|
||||
);
|
||||
assert!(
|
||||
!groups.contains(&child),
|
||||
"owned, undeclared, empty child must be pruned:\n{groups}"
|
||||
);
|
||||
}
|
||||
562
crates/picloud-cli/tests/templates.rs
Normal file
562
crates/picloud-cli/tests/templates.rs
Normal file
@@ -0,0 +1,562 @@
|
||||
//! M4a route templates (§4.5) via `pic apply --dir`:
|
||||
//! * a group declares ONE route template (`/t/{app_slug}`) bound to its
|
||||
//! inherited script; the apply fans it out into a concrete route on every
|
||||
//! descendant app, with `{app_slug}` resolved per app,
|
||||
//! * re-apply is idempotent (no duplicate expansions — provenance),
|
||||
//! * removing the template + `--prune` reaps the expansions,
|
||||
//! * an expansion colliding with a hand-declared route is a hard error,
|
||||
//! * `pic plan --dir` reports the blast radius.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use postgres::{Client as PgClient, NoTls};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
/// Template-expanded routes (`from_template IS NOT NULL`) for the two app slugs
|
||||
/// under test, as `(path, id)` — read straight from Postgres since the route
|
||||
/// admin endpoint only lists app-owned-script routes (a group script isn't
|
||||
/// addressable there). Scoped to `/t/<a>` and `/t/<b>` so parallel tests don't
|
||||
/// interfere.
|
||||
fn expansion_rows(a: &str, b: &str) -> Vec<(String, String)> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let want = [format!("/t/{a}"), format!("/t/{b}")];
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path, id::text FROM routes \
|
||||
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
||||
&[&&want[..]],
|
||||
)
|
||||
.expect("query expansions");
|
||||
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
||||
}
|
||||
|
||||
/// A tree: a root group declaring a `shared` endpoint + a `greet` route template
|
||||
/// `/t/{app_slug}`, and `extra_app_toml` appended to app `a`'s manifest. Apps
|
||||
/// must pre-exist under the group (created in the test before applying).
|
||||
fn tree_dir(group: &str, a: &str, b: &str, template: &str, extra_app_a: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/shared.rhai"),
|
||||
r#""hi from template""#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{template}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for (slug, extra) in [(a, extra_app_a), (b, "")] {
|
||||
fs::create_dir_all(dir.path().join(slug)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(slug).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n{extra}"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
const TEMPLATE: &str = "[[route_templates]]\nname = \"greet\"\nscript = \"shared\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/t/{app_slug}\"\n";
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn route_template_fans_out_per_app_idempotently_then_prunes() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tpl-g");
|
||||
let a = common::unique_slug("tpl-a");
|
||||
let b = common::unique_slug("tpl-b");
|
||||
|
||||
// group ← (app a, app b). Guards drop apps before the group (RESTRICT).
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
let _ab = AppGuard::new(&env.url, &env.token, &b);
|
||||
for slug in [&a, &b] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", slug, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// --- Apply: the template fans out to both apps. ---
|
||||
let dir = tree_dir(&group, &a, &b, TEMPLATE, "");
|
||||
// Plan reports the blast radius (both descendant apps).
|
||||
let plan = common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("plan --dir");
|
||||
let plan_err = String::from_utf8_lossy(&plan.stderr);
|
||||
assert!(
|
||||
plan_err.contains("blast radius") && plan_err.contains("2 app(s)"),
|
||||
"plan should report the 2-app blast radius:\n{plan_err}"
|
||||
);
|
||||
|
||||
// --- Apply: the template fans out to both apps, one route each. ---
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
// The group script must drop before its group at teardown (RESTRICT).
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
|
||||
let first = expansion_rows(&a, &b);
|
||||
let paths: Vec<&str> = first.iter().map(|(p, _)| p.as_str()).collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![format!("/t/{a}").as_str(), format!("/t/{b}").as_str()],
|
||||
"each app gets its own `{{app_slug}}`-resolved route"
|
||||
);
|
||||
|
||||
// --- Idempotent re-apply: same rows, same ids (no churn — provenance). ---
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let second = expansion_rows(&a, &b);
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"re-apply must not churn expansions (stable ids)"
|
||||
);
|
||||
|
||||
// --- Remove the template + --prune: expansions are reaped. Rewrite the
|
||||
// SAME repo's group manifest (a fresh dir would be a different project and
|
||||
// conflict on the owned group, §7). ---
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
expansion_rows(&a, &b).is_empty(),
|
||||
"removing the template must reap its expansions"
|
||||
);
|
||||
}
|
||||
|
||||
/// M6 (§4.5): a `{var:NAME}` placeholder resolves against a var set in the SAME
|
||||
/// apply (in-transaction), so a var and a template referencing it converge in
|
||||
/// one `pic apply`. Before M6 the var (written earlier in the tx) was invisible
|
||||
/// to expansion — the route would only pick it up on a *second* apply.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn route_template_var_resolves_in_same_apply() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tplv-g");
|
||||
let a = common::unique_slug("tplv-a");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group sets `region` AND declares a template referencing it — one
|
||||
// manifest, one apply.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
||||
[vars]\nregion = \"eu\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
||||
[[route_templates]]\nname = \"geo\"\nscript = \"shared\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/tv/{{app_slug}}/{{var:region}}\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(dir.path().join(&a)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(&a).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
|
||||
// The expanded route resolved `{var:region}` → `eu` in THIS apply.
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let want = format!("/tv/{a}/eu");
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path FROM routes WHERE from_template IS NOT NULL AND path = $1",
|
||||
&[&want],
|
||||
)
|
||||
.expect("query");
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
1,
|
||||
"`{{var:region}}` must resolve to `eu` in the same apply (expected path {want})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Template-expanded routes (`from_template IS NOT NULL`) whose path is one of
|
||||
/// `paths`, as `path` strings — for asserting which descendant apps received an
|
||||
/// expansion regardless of which apply declared them.
|
||||
fn expansion_paths(paths: &[String]) -> Vec<String> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path FROM routes \
|
||||
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
||||
&[&paths],
|
||||
)
|
||||
.expect("query expansion paths");
|
||||
rows.iter().map(|r| r.get(0)).collect()
|
||||
}
|
||||
|
||||
/// `(path, id)` of template-expanded routes among `paths` — for asserting an
|
||||
/// expansion is STABLE (same row id) across a re-apply, i.e. not churned.
|
||||
fn expansion_rows_for(paths: &[String]) -> Vec<(String, String)> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path, id::text FROM routes \
|
||||
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
||||
&[&paths],
|
||||
)
|
||||
.expect("query expansion rows");
|
||||
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
||||
}
|
||||
|
||||
/// M7 (§4.5): a route template fans out to EVERY descendant app in the DB
|
||||
/// subtree, not only the app nodes present in the apply. An app created under
|
||||
/// the group out-of-band (absent from the manifest) still receives the
|
||||
/// expansion on the next apply, and removing the template reaps it there too.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn route_template_reaches_out_of_tree_descendant() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("xrepo-g");
|
||||
let sg = common::unique_slug("xrepo-sg"); // subgroup (depth-2)
|
||||
let a = common::unique_slug("xrepo-a");
|
||||
let c = common::unique_slug("xrepo-c"); // out-of-tree descendant (depth-1)
|
||||
let d = common::unique_slug("xrepo-d"); // out-of-tree descendant (depth-2)
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply a tree of just (group ← app a) with an `/x/{app_slug}` template.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
|
||||
let group_toml = |with_template: bool| {
|
||||
let tmpl = if with_template {
|
||||
"\n[[route_templates]]\nname = \"x\"\nscript = \"shared\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x/{app_slug}\"\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n{tmpl}"
|
||||
)
|
||||
};
|
||||
fs::write(dir.path().join("picloud.toml"), group_toml(true)).unwrap();
|
||||
fs::create_dir_all(dir.path().join(&a)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(&a).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
assert_eq!(
|
||||
expansion_paths(&[format!("/x/{a}")]).len(),
|
||||
1,
|
||||
"in-tree app a gets its expansion"
|
||||
);
|
||||
|
||||
// Create app c (direct child) and a nested subgroup ← app d (depth-2),
|
||||
// all OUTSIDE the manifest tree, to exercise the recursive descendant CTE.
|
||||
let _ac = AppGuard::new(&env.url, &env.token, &c);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &c, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _sg = GroupGuard::new(&env.url, &env.token, &sg);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sg, "--parent", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _ad = AppGuard::new(&env.url, &env.token, &d);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &d, "--group", &sg])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Re-apply the same tree (still only group + a). M7: c (depth-1) AND d
|
||||
// (depth-2, under the subgroup) both receive the expansion. `--force`
|
||||
// waives the bound token (the out-of-band creates bumped structure version).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
let want = [format!("/x/{c}"), format!("/x/{d}")];
|
||||
assert_eq!(
|
||||
expansion_paths(&want),
|
||||
want.to_vec(),
|
||||
"out-of-tree descendants at depth 1 AND 2 receive expansions"
|
||||
);
|
||||
|
||||
// Idempotent re-apply: descendants must not churn (same row ids — provenance
|
||||
// by `from_template`, not delete+recreate), same as the in-tree path.
|
||||
let before = expansion_rows_for(&want);
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
before,
|
||||
expansion_rows_for(&want),
|
||||
"re-apply must not churn descendant expansions (stable ids)"
|
||||
);
|
||||
|
||||
// Remove the template + prune: every descendant's expansion is reaped,
|
||||
// in-tree or not, at any depth.
|
||||
fs::write(dir.path().join("picloud.toml"), group_toml(false)).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.args(["--prune", "--yes", "--force"])
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
expansion_paths(&[format!("/x/{a}"), format!("/x/{c}"), format!("/x/{d}")]).is_empty(),
|
||||
"removing the template reaps expansions on ALL descendants, any depth"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn expansion_colliding_with_hand_declared_route_is_rejected() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tplc-g");
|
||||
let a = common::unique_slug("tplc-a");
|
||||
let b = common::unique_slug("tplc-b");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
let _ab = AppGuard::new(&env.url, &env.token, &b);
|
||||
for slug in [&a, &b] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", slug, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// App `a` hand-declares the EXACT route the template would expand to (the
|
||||
// template path resolves to `/t/<a>` for app a). That collision is a hard
|
||||
// error — neither side silently wins.
|
||||
let collide = format!(
|
||||
"\n[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\n\
|
||||
path_kind = \"exact\"\npath = \"/t/{a}\"\n"
|
||||
);
|
||||
let dir = tree_dir(&group, &a, &b, TEMPLATE, &collide);
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("apply --dir");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an expansion colliding with a hand-declared route must be rejected"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("template") && (err.contains("hand") || err.contains("declare")),
|
||||
"error should name the template/hand-declared collision:\n{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Template-expanded kv triggers for the two apps, as `(collection_glob, id)`,
|
||||
/// read straight from Postgres (a group-script trigger isn't listable via the
|
||||
/// app trigger API). Scoped to the test's two slugs.
|
||||
fn trigger_rows(a: &str, b: &str) -> Vec<(String, String)> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let want = [format!("{a}-data"), format!("{b}-data")];
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT d.collection_glob, t.id::text FROM triggers t \
|
||||
JOIN kv_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.from_template IS NOT NULL AND d.collection_glob = ANY($1) \
|
||||
ORDER BY d.collection_glob",
|
||||
&[&&want[..]],
|
||||
)
|
||||
.expect("query trigger expansions");
|
||||
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
||||
}
|
||||
|
||||
const TRIGGER_TEMPLATE: &str = "[[trigger_templates]]\nname = \"ev\"\nkind = \"kv\"\n\
|
||||
script = \"shared\"\ncollection_glob = \"{app_slug}-data\"\n\
|
||||
ops = [\"insert\"]\n";
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn trigger_template_fans_out_per_app_idempotently_then_prunes() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("ttpl-g");
|
||||
let a = common::unique_slug("ttpl-a");
|
||||
let b = common::unique_slug("ttpl-b");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
let _ab = AppGuard::new(&env.url, &env.token, &b);
|
||||
for slug in [&a, &b] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", slug, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// group manifest: a `shared` script + a kv trigger template using {app_slug}.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), r#""x""#).unwrap();
|
||||
let write_group = |with_tmpl: bool| {
|
||||
let tmpl = if with_tmpl { TRIGGER_TEMPLATE } else { "" };
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"TT\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{tmpl}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
write_group(true);
|
||||
for slug in [&a, &b] {
|
||||
fs::create_dir_all(dir.path().join(slug)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(slug).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
|
||||
let first = trigger_rows(&a, &b);
|
||||
assert_eq!(
|
||||
first.iter().map(|(g, _)| g.as_str()).collect::<Vec<_>>(),
|
||||
vec![format!("{a}-data").as_str(), format!("{b}-data").as_str()],
|
||||
"each app gets its own {{app_slug}}-resolved kv trigger"
|
||||
);
|
||||
|
||||
// Idempotent re-apply: same rows, same ids.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
trigger_rows(&a, &b),
|
||||
first,
|
||||
"re-apply must not churn triggers"
|
||||
);
|
||||
|
||||
// Remove the template + --prune: trigger expansions reaped.
|
||||
write_group(false);
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
trigger_rows(&a, &b).is_empty(),
|
||||
"removing the trigger template must reap its expansions"
|
||||
);
|
||||
}
|
||||
|
||||
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--group", group])
|
||||
.output()
|
||||
.expect("scripts ls --group");
|
||||
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("group should have one script")
|
||||
}
|
||||
254
crates/picloud-cli/tests/tree.rs
Normal file
254
crates/picloud-cli/tests/tree.rs
Normal file
@@ -0,0 +1,254 @@
|
||||
//! Phase 5 project-tree apply, end to end via `pic plan/apply --dir`:
|
||||
//! * a group + a nested app under it apply atomically as one tree; the app
|
||||
//! binds a route to the group's inherited script (created in the SAME
|
||||
//! apply); re-plan is a no-op,
|
||||
//! * one invalid node aborts the whole tree (nothing written),
|
||||
//! * a `pic groups reparent` between plan and apply trips the bound-plan
|
||||
//! (StateMoved) check via the tree's structure version.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use predicates::prelude::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
/// A project tree on disk: root group manifest + a nested app manifest that
|
||||
/// binds `GET /greet` to the group's (inherited) `shared` script.
|
||||
fn tree_dir(group: &str, app: &str, group_script_src: &str, app_extra: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::create_dir_all(dir.path().join(app).join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), group_script_src).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"Tree Group\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
||||
[vars]\nregion = \"eu\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join(app).join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"Tree App\"\n\n\
|
||||
[[routes]]\nscript = \"shared\"\nmethod = \"GET\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/greet\"\n{app_extra}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn tree_applies_group_and_app_atomically_then_noop() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tr-grp");
|
||||
let app = common::unique_slug("tr-app");
|
||||
|
||||
// Group + app must pre-exist (the manifest owns content, not tree shape).
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = tree_dir(&group, &app, "\"hi from the group\"", "");
|
||||
|
||||
// Plan the whole tree: a group script+var create and an app route create.
|
||||
let plan = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
plan.contains("shared") && plan.contains("region") && plan.contains("/greet"),
|
||||
"tree plan should cover both nodes:\n{plan}"
|
||||
);
|
||||
|
||||
// Apply the whole tree in one transaction.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
// The group node created its `shared` script (it must drop before its
|
||||
// group at teardown — ON DELETE RESTRICT).
|
||||
let listed = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
listed.contains("shared"),
|
||||
"the group node's script should exist after the tree apply:\n{listed}"
|
||||
);
|
||||
let gscript_id = group_script_id(&env, &group);
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &gscript_id);
|
||||
|
||||
// Re-plan is a clean no-op (idempotent across both nodes — incl. the app's
|
||||
// route bound to the inherited group script).
|
||||
let replan = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!replan.contains("create") && !replan.contains("update"),
|
||||
"re-plan of the tree must be a no-op:\n{replan}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn tree_apply_is_atomic_on_an_invalid_node() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tra-grp");
|
||||
let app = common::unique_slug("tra-app");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The app node carries an INVALID Rhai script → the whole tree must abort.
|
||||
let dir = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::create_dir_all(dir.path().join(&app).join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), "\"ok\"").unwrap();
|
||||
fs::write(dir.path().join(&app).join("scripts/bad.rhai"), "let x = ;").unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join(&app).join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"A\"\n\n\
|
||||
[[scripts]]\nname = \"bad\"\nfile = \"scripts/bad.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.failure();
|
||||
|
||||
// Atomic: the valid group node's `shared` must NOT have been created.
|
||||
let listed = String::from_utf8(
|
||||
common::pic_as(&env)
|
||||
.args(["scripts", "ls", "--group", &group])
|
||||
.output()
|
||||
.unwrap()
|
||||
.stdout,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!listed.contains("shared"),
|
||||
"a failed tree apply must leave nothing behind:\n{listed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn tree_reparent_between_plan_and_apply_trips_state_moved() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("trp-par");
|
||||
let group = common::unique_slug("trp-grp");
|
||||
let app = common::unique_slug("trp-app");
|
||||
|
||||
// parent → group → app.
|
||||
let _p = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group, "--parent", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = tree_dir(&group, &app, "\"x\"", "");
|
||||
|
||||
// Record a bound plan for the whole tree.
|
||||
common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Reparent the group to the instance root — bumps its structure version.
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "reparent", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply the recorded plan: the structure moved underneath it → refuse.
|
||||
// (The apply aborts before any write, so no group script is created and the
|
||||
// GroupGuards tear down cleanly.)
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(
|
||||
predicate::str::contains("changed")
|
||||
.or(predicate::str::contains("plan"))
|
||||
.or(predicate::str::contains("409")),
|
||||
);
|
||||
}
|
||||
|
||||
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--group", group])
|
||||
.output()
|
||||
.expect("scripts ls --group");
|
||||
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("group should have one script")
|
||||
}
|
||||
123
crates/picloud-cli/tests/tree_shape.rs
Normal file
123
crates/picloud-cli/tests/tree_shape.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! M2 declarative tree SHAPE via `pic apply --dir`:
|
||||
//! * a group manifest for a group that does NOT exist yet is CREATED under
|
||||
//! its declared parent, with its content, in one atomic apply,
|
||||
//! * re-applying the same tree is a no-op (the group now exists),
|
||||
//! * changing the declared parent REPARENTS the group on the next apply.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{GroupGuard, ScriptGuard};
|
||||
|
||||
/// Write a single-group project dir declaring group `slug` under `parent` with
|
||||
/// one endpoint script.
|
||||
fn group_dir(slug: &str, parent: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), r#""hi""#).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{slug}\"\nname = \"Child Group\"\nparent = \"{parent}\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn tree_apply_creates_then_reparents_a_group() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("ts-par");
|
||||
let child = common::unique_slug("ts-child");
|
||||
|
||||
// Guards drop in reverse order: parent (last) ← child (mid) ← script (first).
|
||||
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
let _gc = GroupGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// --- Create: the child group does NOT exist; apply --dir creates it. ---
|
||||
let dir = group_dir(&child, &parent);
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("apply --dir");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"tree apply (create) failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// The group now exists with its script.
|
||||
let groups = ls(&env, &["groups", "ls"]);
|
||||
assert!(groups.contains(&child), "created group missing:\n{groups}");
|
||||
let scripts = ls(&env, &["scripts", "ls", "--group", &child]);
|
||||
let script_id = scripts
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&"hello"))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("group script `hello` should exist:\n{scripts}"));
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &script_id);
|
||||
|
||||
// Re-applying the same tree is a no-op (group + content unchanged).
|
||||
let plan_out = common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("plan --dir");
|
||||
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
|
||||
assert!(
|
||||
!plan_txt.contains("create") && !plan_txt.contains("delete"),
|
||||
"re-plan of an applied tree must be a no-op:\n{plan_txt}"
|
||||
);
|
||||
|
||||
// --- Reparent: move the child under the instance root. Rewrite the SAME
|
||||
// repo's manifest (M3: a stable `.picloud/` project key per repo — a fresh
|
||||
// dir would be a different project and conflict on the owned group). ---
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{child}\"\nname = \"Child Group\"\nparent = \"root\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let dir2 = &dir;
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir2.path())
|
||||
.output()
|
||||
.expect("apply --dir reparent");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"tree apply (reparent) failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
// Re-plan is a no-op now that the parent matches.
|
||||
let plan_out = common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir2.path())
|
||||
.output()
|
||||
.expect("plan --dir after reparent");
|
||||
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
|
||||
assert!(
|
||||
!plan_txt.contains("create") && !plan_txt.contains("delete"),
|
||||
"re-plan after reparent must be a no-op:\n{plan_txt}"
|
||||
);
|
||||
}
|
||||
|
||||
fn ls(env: &common::TestEnv, args: &[&str]) -> String {
|
||||
String::from_utf8(common::pic_as(env).args(args).output().expect("ls").stdout).unwrap()
|
||||
}
|
||||
@@ -330,7 +330,7 @@ pub async fn build_app(
|
||||
docs,
|
||||
dl_service.clone(),
|
||||
events,
|
||||
modules,
|
||||
modules.clone(),
|
||||
http,
|
||||
files,
|
||||
pubsub,
|
||||
@@ -471,7 +471,10 @@ pub async fn build_app(
|
||||
routes: route_repo.clone(),
|
||||
triggers: trigger_repo.clone(),
|
||||
secrets: secrets_repo.clone(),
|
||||
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
|
||||
apps: apps_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
modules: modules.clone(),
|
||||
domains: domains_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine.clone(),
|
||||
@@ -577,6 +580,13 @@ pub async fn build_app(
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
};
|
||||
let group_scripts_state = picloud_manager_core::GroupScriptsState {
|
||||
scripts: script_repo.clone(),
|
||||
groups: groups_repo.clone(),
|
||||
authz: authz.clone(),
|
||||
validator: engine.clone(),
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let config_state = picloud_manager_core::ConfigApiState {
|
||||
pool: pool.clone(),
|
||||
apps: apps_repo.clone(),
|
||||
@@ -605,6 +615,9 @@ pub async fn build_app(
|
||||
.merge(apps_router(apps_state))
|
||||
.merge(app_members_router(app_members_state))
|
||||
.merge(groups_router(groups_state))
|
||||
.merge(picloud_manager_core::group_scripts_router(
|
||||
group_scripts_state,
|
||||
))
|
||||
.merge(vars_router(vars_state))
|
||||
.merge(picloud_manager_core::config_router(config_state))
|
||||
.merge(picloud_manager_core::app_users_router(
|
||||
|
||||
@@ -18,7 +18,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{AppId, ExecutionId, ScriptId, SdkCallCx};
|
||||
use crate::{AppId, ExecutionId, ScriptId, ScriptOwner, SdkCallCx};
|
||||
|
||||
/// Identifies the script to invoke. Accepted by `invoke()` and
|
||||
/// `invoke_async()` script-side.
|
||||
@@ -52,6 +52,12 @@ impl InvokeTarget {
|
||||
pub struct ResolvedScript {
|
||||
pub script_id: ScriptId,
|
||||
pub app_id: AppId,
|
||||
/// The resolved script's **defining node** (Phase 4b) — the lexical
|
||||
/// origin its `import`s resolve against (§5.5). For an inherited group
|
||||
/// script this is the **group**, even though `app_id` (the execution
|
||||
/// boundary) is the caller's app. `None` only for a corrupt owner row;
|
||||
/// the executor falls back to `App(app_id)`.
|
||||
pub owner: Option<ScriptOwner>,
|
||||
pub source: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// Script.name — surfaced to the callee as `ctx.script_name`.
|
||||
|
||||
@@ -76,7 +76,9 @@ pub use inbox::{
|
||||
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
||||
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
|
||||
pub use modules::{
|
||||
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
|
||||
};
|
||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||
pub use pubsub::{
|
||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||
@@ -86,7 +88,7 @@ pub use realtime::{BroadcasterError, NoopRealtimeBroadcaster, RealtimeBroadcaste
|
||||
pub use realtime_authority::{DenyAllRealtimeAuthority, RealtimeAuthority, SubscribeDenied};
|
||||
pub use route::{DispatchMode, HostKind, PathKind, Route};
|
||||
pub use sandbox::ScriptSandbox;
|
||||
pub use script::{Script, ScriptKind};
|
||||
pub use script::{Script, ScriptKind, ScriptOwner};
|
||||
pub use sdk_cx::SdkCallCx;
|
||||
pub use secrets::{
|
||||
validate_secret_name, NoopSecretsService, SecretsError, SecretsListPage, SecretsService,
|
||||
|
||||
@@ -1,51 +1,125 @@
|
||||
//! `ModuleSource` — the v1.1.3 Rhai module-loading contract.
|
||||
//! `ModuleSource` — the Rhai module-loading contract (v1.1.3; made
|
||||
//! origin-aware in v1.2 Phase 4b).
|
||||
//!
|
||||
//! The executor-core `PicloudModuleResolver` calls into this trait to
|
||||
//! load `kind = 'module'` scripts referenced by `import "<name>" as <alias>;`
|
||||
//! statements. The Postgres impl in `manager-core` reads from the
|
||||
//! `scripts` table; tests pin in-memory fakes.
|
||||
//!
|
||||
//! Implementations MUST derive `app_id` from `cx.app_id` and pass it
|
||||
//! to every backend query. The `name` argument carries only the
|
||||
//! script's name (the literal between the import quotes); the trait
|
||||
//! has no way to express a cross-app lookup. That asymmetry is the
|
||||
//! load-bearing cross-app isolation boundary — see `docs/sdk-shape.md`.
|
||||
//! **Resolution is lexical (§5.5).** A module is resolved against the
|
||||
//! module set visible at the **importing script's own defining node**
|
||||
//! (`origin`), walking up the group chain from there — *not* the
|
||||
//! inheriting app's effective view. So `resolve` is keyed by a
|
||||
//! [`ScriptOwner`] origin, not by `cx.app_id`. This deliberately differs
|
||||
//! from the SDK-call isolation boundary: a group script's `import`
|
||||
//! resolves from the **group** (sealed from below — a leaf app cannot
|
||||
//! shadow it), while every SDK call *inside* that module still scopes to
|
||||
//! the inheriting app via `cx.app_id`. The `origin` argument is a trusted
|
||||
//! dispatch-derived value (the resolved script's owner), never a
|
||||
//! script-passed argument, so the cross-app isolation boundary holds.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::{AppId, ScriptId, SdkCallCx};
|
||||
use crate::{AppId, GroupId, ScriptId, ScriptOwner};
|
||||
|
||||
/// A module script as returned by `ModuleSource::lookup`. Carries only
|
||||
/// the fields the resolver needs: the id (for diagnostics), the source
|
||||
/// (to compile), and `updated_at` (the cache-staleness comparator).
|
||||
/// A module script as returned by `ModuleSource::resolve`. Carries the
|
||||
/// fields the resolver needs: the id (cache key + diagnostics), the
|
||||
/// resolved module's **owner** (so nested imports inside it resolve from
|
||||
/// *its* defining node), the source (to compile), and `updated_at` (the
|
||||
/// cache-staleness comparator).
|
||||
///
|
||||
/// Ownership is polymorphic (Phase 4): exactly one of `app_id` / `group_id`
|
||||
/// is set — mirroring [`crate::Script`] and the DB CHECK.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModuleScript {
|
||||
pub script_id: ScriptId,
|
||||
pub app_id: AppId,
|
||||
/// Owning app, when app-owned. Mutually exclusive with `group_id`.
|
||||
#[serde(default)]
|
||||
pub app_id: Option<AppId>,
|
||||
/// Owning group, when group-owned (Phase 4b group modules).
|
||||
#[serde(default)]
|
||||
pub group_id: Option<GroupId>,
|
||||
pub name: String,
|
||||
pub source: String,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Lookup contract used by `PicloudModuleResolver`. `lookup` MUST
|
||||
/// scope by `cx.app_id`; cross-app reads must be unreachable.
|
||||
impl ModuleScript {
|
||||
/// The resolved module's defining node, reconstructed from the
|
||||
/// polymorphic columns. Used by the resolver to seal *this* module's
|
||||
/// own nested imports to its node (lexical chaining). Prefers the app
|
||||
/// if a corrupt row sets both, so the hot path never panics.
|
||||
#[must_use]
|
||||
pub fn owner(&self) -> Option<ScriptOwner> {
|
||||
match (self.app_id, self.group_id) {
|
||||
(Some(a), _) => Some(ScriptOwner::App(a)),
|
||||
(None, Some(g)) => Some(ScriptOwner::Group(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of an extension-point lookup (§5.5). Present when `name` is
|
||||
/// declared an extension point on `origin`'s chain *and* is not shadowed by
|
||||
/// a nearer concrete module of the same name — i.e. the nearest declaration
|
||||
/// of `name` is the extension point, so resolution inverts to the inheriting
|
||||
/// app. `default_script_id` is the optional fallback module body to use when
|
||||
/// the app provides no module of `name`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExtPointResolution {
|
||||
pub default_script_id: Option<ScriptId>,
|
||||
}
|
||||
|
||||
/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
|
||||
/// module set up the chain rooted at `origin` (the importing script's
|
||||
/// defining node), nearest-owner-wins. The `origin` is trusted
|
||||
/// (dispatch-derived); the `name` is the literal between the import
|
||||
/// quotes. See the module docs for why this is lexical, not `cx`-scoped.
|
||||
#[async_trait]
|
||||
pub trait ModuleSource: Send + Sync {
|
||||
/// Resolve a module script by `(cx.app_id, name)`. Returns `None`
|
||||
/// when no row exists, or when a row exists but its `kind` is
|
||||
/// `'endpoint'` (endpoints are never importable). The resolver
|
||||
/// surfaces `None` as `ErrorModuleNotFound` to Rhai.
|
||||
async fn lookup(
|
||||
/// Resolve a `kind = 'module'` script named `name`, visible from the
|
||||
/// `origin` node walking up its group chain (nearest depth wins).
|
||||
/// Returns `None` when no module of that name exists anywhere on the
|
||||
/// chain (or a row exists but its `kind` is `'endpoint'` — endpoints
|
||||
/// are never importable). The resolver surfaces `None` as
|
||||
/// `ErrorModuleNotFound` to Rhai.
|
||||
async fn resolve(
|
||||
&self,
|
||||
cx: &SdkCallCx,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
|
||||
/// Extension-point check (§5.5). Returns `Some` iff the **nearest**
|
||||
/// declaration of `name` on `origin`'s chain is an extension point (not
|
||||
/// a concrete module). On a tie in depth a concrete module wins (so a
|
||||
/// nearer/peer real module shadows the extension-point declaration). The
|
||||
/// `origin` is the trusted importer node, so the inversion can only be
|
||||
/// opened by a declaration on the importer's own ancestry — a descendant
|
||||
/// can never make a parent's sealed import dynamic.
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError>;
|
||||
|
||||
/// Load the extension-point fallback module `script_id`, but ONLY if it is
|
||||
/// a `kind = 'module'` script reachable on `origin`'s chain. The `origin`
|
||||
/// is the trusted importer node; constraining the lookup to its chain makes
|
||||
/// the resolver self-defending — a `default_script_id` that (through a bug
|
||||
/// or DB-direct write) pointed at another tenant's module resolves to
|
||||
/// `None` rather than executing cross-tenant code. Returns `None` if the id
|
||||
/// is absent, names an endpoint, or is off-chain.
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
}
|
||||
|
||||
/// Failure modes surfaced from `ModuleSource::lookup`. "Not found" is
|
||||
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
|
||||
/// not exceptional — it's `Ok(None)`.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ModuleSourceError {
|
||||
@@ -57,7 +131,7 @@ pub enum ModuleSourceError {
|
||||
}
|
||||
|
||||
/// Stub used by the executor-core test harness so engine integration
|
||||
/// tests don't need a real DB-backed source. Every lookup returns
|
||||
/// tests don't need a real DB-backed source. Every resolve returns
|
||||
/// `Ok(None)` — `import "x"` always errors as "module not found"
|
||||
/// under this impl.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
@@ -65,11 +139,27 @@ pub struct NoopModuleSource;
|
||||
|
||||
#[async_trait]
|
||||
impl ModuleSource for NoopModuleSource {
|
||||
async fn lookup(
|
||||
async fn resolve(
|
||||
&self,
|
||||
_cx: &SdkCallCx,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{AppId, ScriptId, ScriptSandbox};
|
||||
use crate::{AppId, GroupId, ScriptId, ScriptSandbox};
|
||||
|
||||
/// Semantic role of a script (v1.1.3).
|
||||
///
|
||||
@@ -94,10 +94,26 @@ mod kind_tests {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Script {
|
||||
pub id: ScriptId,
|
||||
/// Owning app. Set on create, immutable thereafter — a "move to
|
||||
/// another app" is a copy+delete, not an in-place edit (snapshot
|
||||
/// semantics — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
/// Owning app, when app-owned. Set on create, immutable thereafter — a
|
||||
/// "move to another app" is a copy+delete, not an in-place edit
|
||||
/// (snapshot semantics — see blueprint §11.5).
|
||||
///
|
||||
/// Phase 4 (v1.2 Hierarchies) made script ownership **polymorphic**:
|
||||
/// exactly one of `app_id` / `group_id` is set (DB CHECK + [`ScriptOwner`]).
|
||||
/// A group-owned script (`app_id: None`, `group_id: Some`) is a template
|
||||
/// inherited by every descendant app — it has no single app, so the
|
||||
/// **execution context** app is supplied by the route/trigger/caller that
|
||||
/// invoked it, never read off the script. Reading `app_id` to mean "the
|
||||
/// app this runs under" is therefore a bug for group scripts; use the
|
||||
/// invoking surface's app_id instead.
|
||||
#[serde(default)]
|
||||
pub app_id: Option<AppId>,
|
||||
/// Owning group, when group-owned (Phase 4). Mutually exclusive with
|
||||
/// `app_id`. The script is resolved by name, nearest-owner-wins, down the
|
||||
/// `apps.group_id → groups.parent_id` chain (CoW: an app's own script of
|
||||
/// the same name shadows the inherited one).
|
||||
#[serde(default)]
|
||||
pub group_id: Option<GroupId>,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub version: i32,
|
||||
@@ -131,3 +147,36 @@ pub struct Script {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Who owns a script (Phase 4). Exactly one owner — the DB enforces it with
|
||||
/// a `CHECK ((group_id IS NULL) <> (app_id IS NULL))`; this enum is the
|
||||
/// in-memory witness of that invariant so call sites can `match` exhaustively
|
||||
/// instead of juggling two `Option`s.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ScriptOwner {
|
||||
App(AppId),
|
||||
Group(GroupId),
|
||||
}
|
||||
|
||||
impl Script {
|
||||
/// The script's owner, reconstructed from the polymorphic columns.
|
||||
/// A row that violates the exactly-one invariant (both/neither set —
|
||||
/// impossible under the CHECK) is reported as whichever is present,
|
||||
/// preferring the app, so a corrupt row never panics on the hot path.
|
||||
#[must_use]
|
||||
pub fn owner(&self) -> Option<ScriptOwner> {
|
||||
match (self.app_id, self.group_id) {
|
||||
(Some(a), _) => Some(ScriptOwner::App(a)),
|
||||
(None, Some(g)) => Some(ScriptOwner::Group(g)),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True iff this script is owned by `app` directly (not via a group).
|
||||
/// The cheap, app-owned-only ownership check — group inheritance is
|
||||
/// resolved separately (chain-membership), never by this method.
|
||||
#[must_use]
|
||||
pub fn is_owned_by_app(&self, app: AppId) -> bool {
|
||||
self.app_id == Some(app)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,11 +375,54 @@ Two distinct constraints:
|
||||
identity token (collection / queue / topic; for cron/email/dead-letter fall back to `{kind}-{n}`),
|
||||
sanitized to the kebab regex, with `-{n}` (discovery order) guaranteeing per-app uniqueness.
|
||||
|
||||
> **Ergonomic debt (accepted, watch it):** because triggers don't inherit, 100 tenant apps each
|
||||
> needing the same 5 triggers = 500 declarations. The fix is group trigger/route **templates** that
|
||||
> fan out per descendant (a *template/instantiation* mechanism, not inheritance) — deferred, but it
|
||||
> bites early if tenant cardinality is high. Pressure-test against real tenant counts before
|
||||
> committing to the narrow-inheritance choice (§5.1).
|
||||
> **Ergonomic debt (being paid down):** because triggers/routes don't inherit, 100 tenant apps each
|
||||
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
|
||||
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
|
||||
>
|
||||
> **Status: ✅ shipped — routes (M4a) AND triggers (M4b), 2026-06-28.** Migrations `0053_templates.sql`
|
||||
> (`route_templates` + `routes.from_template`) and `0054_trigger_templates.sql` (`trigger_templates` +
|
||||
> `triggers.from_template`). A `[group]` manifest declares `[[route_templates]]` and/or
|
||||
> `[[trigger_templates]]` (the latter a flat block: `name`, `kind`, `script`, + kind params, covering
|
||||
> all seven kinds); `pic apply --dir` reconciles the template rows
|
||||
> (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one
|
||||
> concrete `routes`/`triggers` row per descendant app node** in the apply, resolving the script binding
|
||||
> nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the
|
||||
> apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard
|
||||
> error). For triggers the placeholders resolve in every string leaf of the spec, then the typed
|
||||
> trigger is rebuilt and inserted through the normal path (email secrets are resolved + re-sealed per
|
||||
> app — the value never travels in a manifest). Expansion is **idempotent via `from_template`** (routes
|
||||
> diff by identity create/update-as-replace/delete; triggers key one-per-template-per-app and compare
|
||||
> semantic identity, so re-apply doesn't churn and a removed/disabled template reaps its expansions
|
||||
> under `--prune`); an expansion colliding with a hand-declared route/trigger, or two templates
|
||||
> colliding, is a hard error. `plan` reports the **blast radius** (descendant app count) and the
|
||||
> `state_token` folds each template, so an edit between plan and apply trips `StateMoved`. Authz:
|
||||
> declaring a template needs `GroupScriptsWrite`; an app that *receives* expansions needs
|
||||
> `AppWriteRoute` / `AppManageTriggers` (and `AppSecretsRead` for an email-trigger template, which
|
||||
> seals the recipient's secret) — gated even when the app declares none of its own. Each RESOLVED
|
||||
> expansion is validated with the same rules its hand-declared counterpart gets — routes:
|
||||
> reserved-path rejection + structural path/host parse + host-claim; triggers: the per-kind shape
|
||||
> check (cron schedule, queue timeout, …) — so a `{var:…}`-injected reserved path, unclaimed strict
|
||||
> host, or malformed schedule/timeout can't slip in. **Scope:** expansion targets **every descendant app
|
||||
> in the DB subtree** (M7, 2026-06-28), not only the app nodes present in the apply — a template change at
|
||||
> group N fans out to `subtree(N)` across repos, and removing a template reaps its expansions on those
|
||||
> descendants too (the apply locks each extra descendant in `app_id` order). The actor must hold the
|
||||
> per-recipient write cap (`AppWriteRoute`/`AppManageTriggers`/`AppSecretsRead`) on each descendant; the
|
||||
> hierarchy-aware `effective_app_role` makes a group admin/editor implicitly authorized on its subtree, so
|
||||
> this is sound rather than an escalation, and a narrowly-scoped principal is refused (naming the app).
|
||||
> `{var:NAME}` resolves **in-transaction** (M6, 2026-06-28) — a var set in the *same* apply is visible to
|
||||
> the template immediately, so var + template converge in one apply. A
|
||||
> trigger template whose only change is a non-identity field (e.g. dispatch_mode) is a no-op on
|
||||
> re-apply, mirroring the existing trigger-identity limitation (recreate to change those).
|
||||
>
|
||||
> **Known limitations (both "one more apply", no data risk):** (1) Template fan-out resolves against a
|
||||
> node's **committed** ancestry — `apply` computes app chains before the transaction's structural
|
||||
> reconcile — so a template gained or lost by a **reparent performed in the *same* apply** takes effect
|
||||
> on the next apply (the same shape as "reparenting into a freshly-created group needs a second apply").
|
||||
> (2) The `{env}` placeholder resolves to the apply's `--env` for **declared** apps but to the app's own
|
||||
> `apps.environment` column for **cross-repo descendants**; if a template uses `{env}` and those two
|
||||
> disagree, that descendant's expansion can churn between applies (and a descendant with a NULL
|
||||
> `environment` + an `{env}` template aborts the apply, naming the app). Set the descendant's environment,
|
||||
> or avoid `{env}` in templates fanned across heterogeneous-env subtrees.
|
||||
|
||||
### 4.6 Secrets & `pull`
|
||||
|
||||
@@ -440,6 +483,10 @@ Two distinct constraints:
|
||||
> **deferred plan for *script-body* inheritance (Phase 4)** — where a hot per-request path may
|
||||
> justify a cache — and as a future optimization for config if a deep tree ever demands it. Read the
|
||||
> rest of §5.1 as "the materialization design, if/when we need it," not as Phase 3's runtime.
|
||||
> **Update — Phase 4-lite (§11.4 status) also shipped live, not materialized:** group scripts are
|
||||
> referenced by id on the runtime hot path and resolved by name only at bind/invoke time, so even
|
||||
> script inheritance needed no materialized view or invalidation fan-out. The materialization design
|
||||
> now stands purely as a future optimization, gated on a *measured* cost.
|
||||
- manager-core **resolves-at-write into a materialized per-app effective view** (§3 rule applied:
|
||||
sparse merge, env filter, proximity, CoW, `enabled`).
|
||||
- The orchestrator/executor serve from that view, **keyed by `app_id` + a generation/version**. The
|
||||
@@ -541,11 +588,25 @@ trust inversion). The rule:
|
||||
extension point with no provider in a given app is an error for that app (a hard failure, joining
|
||||
§4.7).
|
||||
|
||||
> **Residual (verified):** executor-core's `PicloudModuleResolver` is app-scoped today and ignores the
|
||||
> importing script's origin (`module_resolver.rs` passes `_source` unused). Rhai *does* expose that
|
||||
> origin, so the lexical-vs-dynamic split is expressible — but it requires re-keying the resolver cache
|
||||
> by owner identity and adding per-import policy (sealed vs. extension point), i.e. a real
|
||||
> resolver+cache redesign, not a parameter tweak. Lands with phasing step 4.
|
||||
> **Resolved (Phase 4b ✅).** The **lexical (sealed-by-default)** core shipped: `ModuleScript` carries
|
||||
> a polymorphic owner; `ModuleSource::resolve(origin, name)` walks the chain rooted at the importing
|
||||
> node (app-rooted `CHAIN_LEVELS_CTE` or the new group-rooted CTE); the resolved script's owner threads
|
||||
> through `ExecRequest.script_owner` (the executor's `default_origin`) and every dispatch + `invoke()`
|
||||
> site; the `PicloudModuleResolver` reads the importing node from Rhai's `_source` (set to each module's
|
||||
> owner via `AST::set_source(encode(owner))` before `eval_ast_as_new` — the lexical chaining), and the
|
||||
> cache is re-keyed by resolved `ScriptId`. Group modules + group-script imports are now allowed
|
||||
> (`group_scripts_api`), and the single-node apply runs the §5.5 dangling-import `plan` check.
|
||||
>
|
||||
> **Extension points (✅ shipped, 2026-06-26).** The opt-in dynamic-resolution branch landed: migration
|
||||
> `0051` adds an owner-polymorphic `extension_points` table (optional `default_script_id` fallback);
|
||||
> `ModuleSource` gained `resolve_extension_point(origin, name)` (Some only when the NEAREST declaration
|
||||
> of `name` on the importer's chain is an extension point — a peer/nearer concrete module shadows it)
|
||||
> and a chain-constrained `resolve_by_id(origin, id)`. The resolver checks the importer's chain first
|
||||
> and, on a hit, resolves against `App(cx.app_id)` (the inheriting app), falling back to the default
|
||||
> body. The decision keys on the trusted importer `origin`, so only the declaring parent can open the
|
||||
> inversion — a descendant can't make a parent's sealed import dynamic or hijack one. Declared via
|
||||
> `[[extension_points]] name = "..." default = "<local module>"` (app or group manifests); reconciled
|
||||
> like `vars`, gated on the script-write capability, exported by `pic pull`.
|
||||
|
||||
### 5.6 Tree lifecycle: delete, reparent, rename
|
||||
|
||||
@@ -616,6 +677,26 @@ non-orphaning:
|
||||
|
||||
## 7. Ownership of shared nodes
|
||||
|
||||
> **Status: ✅ shipped (M3, 2026-06-26).** Migration `0052_owner_project.sql` adds a `projects(id, key
|
||||
> UNIQUE)` table and promotes the inert `groups.owner_project` (0047) to a real FK. The CLI mints a
|
||||
> stable, gitignored project key in `.picloud/project.json` (`pic init`, or lazily on first tree
|
||||
> plan/apply) and presents it on every tree request. **First apply claims** each declared group for the
|
||||
> project (NULL owner → stamped, in-tx, first-commit-wins under the per-node advisory lock). A second
|
||||
> project applying an owned group is **refused** (`pic plan --dir` surfaces the conflict for CI;
|
||||
> `pic apply --dir` returns 409) unless `pic apply --dir --takeover`, which is **GroupAdmin-gated** per
|
||||
> contested node (ownership ⟂ RBAC: two independent gates, §7.4) and flips the owner. With ownership in
|
||||
> place, `--prune` now also reaps **structural** nodes: a group this project owns that the manifest no
|
||||
> longer declares is deleted **leaf-first** (RESTRICT-guarded, so a still-populated group aborts the
|
||||
> apply rather than orphaning data) — scoped to project-owned nodes, so one repo never reaps another's
|
||||
> or a UI-owned (unclaimed) node. The bound-plan token folds each declared group's owner key, so a
|
||||
> claim/takeover by another repo between plan and apply trips `StateMoved`. **Attach-point ceiling**
|
||||
> (§7.2) is implicit: a manifest only ever writes the nodes it declares; an ancestor parent is
|
||||
> referenced read-only, never claimed or written. The GroupAdmin takeover gate is enforced **twice**:
|
||||
> in `authz_tree` (pre-tx) and again **in-tx** at the ownership decision (so `--force`, which waives the
|
||||
> staleness token, can't open a takeover-without-admin window). The attacker-supplied project key is
|
||||
> length/charset-validated server-side before use. Journey coverage: claim → conflict → takeover → flip
|
||||
> → prune, plus a non-admin `--takeover` → 403.
|
||||
|
||||
**Single-owner-per-node, ceilinged by the attach point.**
|
||||
|
||||
1. Each group node is owned by exactly one **project-root** (the repo that *manages* it — contains
|
||||
@@ -942,8 +1023,91 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
> apply reconciliation (Phase 5 — Phase 3 ships CRUD API + CLI), and materialization (above).
|
||||
4. **Group-inherited scripts/modules.** CoW overrides; the **scope-aware module/import resolver +
|
||||
extension points** (§5.5); cache-invalidation fan-out hardening; versioning/pinning if needed.
|
||||
|
||||
> **Status (Phase 4-lite): ✅ shipped — group-owned ENDPOINT scripts, live-resolved (no
|
||||
> materialization).** Like Phase 3's config, group scripts resolve **live**, not via a cache: a
|
||||
> script is referenced by id on the runtime hot path (routes/triggers store the resolved id; the
|
||||
> orchestrator's per-app route trie already scopes dispatch to one app and never reads
|
||||
> `script.app_id`), and by **name** down the chain only at bind/invoke time. No materialized
|
||||
> per-app body view, no generation counter, no fan-out invalidation — so §11.4's
|
||||
> "cache-invalidation fan-out hardening" is **moot for Phase 4-lite** (revisit only if a measured
|
||||
> hot-path cost appears).
|
||||
>
|
||||
> Shipped surface:
|
||||
> - **Schema:** `0050_group_scripts.sql` makes `scripts` polymorphic-owned (`group_id` XOR
|
||||
> `app_id`, `scripts_owner_exactly_one` CHECK, per-owner partial-unique `LOWER(name)` indexes),
|
||||
> `ON DELETE RESTRICT` (code is not data — a group can't be deleted out from under its scripts).
|
||||
> `Script.app_id` became `Option<AppId>` + `group_id`; the cross-app isolation backstops
|
||||
> (dispatcher, invoke, trigger-bind, orchestrator `/execute/{id}`) generalized from
|
||||
> `app_id == row.app_id` to **chain-membership**, fail-closed for app-owned (zero-cost in-memory
|
||||
> fast path; only a group candidate pays a chain query).
|
||||
> - **Resolver (reuses `CHAIN_LEVELS_CTE`):** `get_by_name_inherited(app_id, name)` (nearest-owner
|
||||
> wins; app's own script shadows the inherited group one — CoW) and `is_invocable_by_app(script_id,
|
||||
> app_id)` (the isolation predicate lifted to the hierarchy; sibling apps' *own* scripts are NOT
|
||||
> cross-invocable — only group scripts inherit).
|
||||
> - **Admin + CLI:** `GroupScripts{Read,Write}` caps (viewer+/editor+ on the group, resolved via the
|
||||
> group-ancestor walk); `POST/GET /groups/{id}/scripts` (endpoint-only, self-contained — modules +
|
||||
> imports rejected); the by-id `/scripts/{id}` handlers are owner-polymorphic; `pic scripts ls
|
||||
> --group` / `deploy --group` (create-or-update; `--app`/`--group` mutually exclusive).
|
||||
> - **`invoke("name", …)`** resolves inherited (a script runs a shared group endpoint; it always
|
||||
> executes under the *caller's* app context, never the owner's).
|
||||
> - **Declarative apply:** a manifest route/trigger `script = "name"` resolves to an inherited group
|
||||
> endpoint (`resolve_inherited_targets` → `name_to_id`), with the diff resolving the group-bound
|
||||
> route's id → name so **re-apply is idempotent**.
|
||||
>
|
||||
> **Phase 4b: ✅ shipped — group modules + the lexical (sealed-by-default) import resolver (§5.5).**
|
||||
> Group `kind = module` scripts and group-script imports are now allowed; `import` resolves lexically
|
||||
> against the importing script's **own defining node** (the group for an inherited script — a leaf
|
||||
> can't shadow it; verified e2e). Mechanism: owner-polymorphic `ModuleScript`, origin-rooted
|
||||
> `ModuleSource::resolve`, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site,
|
||||
> `_source`-driven lexical chaining in the resolver (cache re-keyed by `ScriptId`), and the
|
||||
> single-node dangling-import `plan` check. **Opt-in extension points shipped (§5.5, 2026-06-26).**
|
||||
> Still deferred: **invoke-by-id** staying app-scoped (inheritance is by-name only); CoW
|
||||
> is **redefine-in-app + re-apply** (no live auto-rebinding). Sharp edge to track: `routes.script_id`
|
||||
> is `ON DELETE CASCADE`, so deleting a
|
||||
> group script removes descendant apps' bound routes (within group-editor authority; the *group*
|
||||
> delete itself stays `RESTRICT`).
|
||||
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
|
||||
tree plan, per-env approval gating.
|
||||
|
||||
> **Status (Phase 5): ✅ shipped — nested tree apply, tree shape, AND multi-repo ownership.** A
|
||||
> directory tree of `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as
|
||||
> ONE server-computed plan in ONE Postgres transaction. **Declarative group create/reparent shipped
|
||||
> (M2, 2026-06-26)** — a manifest now owns the tree *shape*, not just node content. **Multi-repo
|
||||
> single-owner / takeover + structural prune shipped (§7, M3, 2026-06-26)** — see §7 below.
|
||||
> **Per-env approval-policy gating shipped (`[project.environments]`, M5, 2026-06-28)** — the root
|
||||
> manifest declares which environments are confirm-required; `pic apply --dir --env <e>` to such an
|
||||
> env needs an explicit `--approve <e>` (a blanket `--yes` does NOT cover it), the approval is
|
||||
> admin-gated (the actor needs admin on every declared node, not just write) and audited, and the
|
||||
> policy folds into the bound-plan token. The server re-derives the policy from the bundle — the CLI's
|
||||
> check is convenience, the server is authoritative. Env overlays (`picloud.<env>.toml`) already exist.
|
||||
> Gating is a **`--dir` (project-tree) feature**: single-node `pic apply --file --env <e>` cannot
|
||||
> carry the admin-gated approval, so it **refuses** a confirm-required env and directs the user to
|
||||
> `--dir` (rather than silently bypassing the gate). Like `--takeover`/blast-radius, the policy lives
|
||||
> in the manifest (not persisted server-side), so whoever controls desired state can weaken it — an
|
||||
> accepted trust boundary, not a privilege bug.
|
||||
>
|
||||
> Shipped surface:
|
||||
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
|
||||
> (a group node has only scripts + vars; routes/triggers/secret-values stay app-only). The in-tx
|
||||
> reconcile is `reconcile_node_tx`, shared by single-node and tree apply. `apply_tree` locks every
|
||||
> node key (sorted), reconciles **groups first** (recording each group's `name → id`), then apps —
|
||||
> so an app route/trigger can bind a group script **created in the same transaction** (resolved
|
||||
> nearest-ancestor-wins across the in-tree index + pre-existing ancestors; the pool resolver can't
|
||||
> see uncommitted rows). One commit, one post-commit route refresh.
|
||||
> - **Bound plan covers structure:** the tree token folds every node's `state_token` **plus every
|
||||
> in-scope group's `structure_version`**, so a reparent (or content edit) between plan and apply
|
||||
> trips `StateMoved` — the §4.2 "content **and** tree-structure version" check.
|
||||
> - **API:** `POST /api/v1/admin/{groups/{id},tree}/{plan,apply}`; per-node capability gating (the
|
||||
> actor must hold the read/write caps for **every** node touched).
|
||||
> - **CLI:** a `[group]` manifest kind; `pic plan/apply` on one node (app or group); `pic
|
||||
> plan/apply --dir <root>` discovers + applies the whole tree, with a single `<tree>` bound token.
|
||||
>
|
||||
> Live- and journey-validated: a group + nested app apply atomically (app route binds the group's
|
||||
> same-apply script); re-plan is all-noop; one invalid node aborts the whole tree (nothing written);
|
||||
> a `pic groups reparent` between plan and apply is refused. The §5.1 materialized effective-view +
|
||||
> fan-out invalidation was **not** built — group config/scripts resolve **live** (Phase 3/4),
|
||||
> so the tree apply needs no cache-invalidation protocol.
|
||||
6. **(Much later) group-level collections/topics** — the v1.3 cross-app data-sharing problem, with a
|
||||
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
|
||||
demands.
|
||||
@@ -969,10 +1133,10 @@ to take, not yet taken:
|
||||
- *Trigger/route templates (§4.5):* bundled into Phase 6 as "much later," but the per-app binding
|
||||
tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations).
|
||||
Decide against **actual** target tenant counts before defaulting it late.
|
||||
- *Multi-repo ownership (§7):* the single-owner / attach-point / takeover machinery is substantial
|
||||
and only earns its keep with a **second** managing repo. For a solo-dev / single-repo start, a
|
||||
"one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists
|
||||
before building the ownership layer.
|
||||
- *Multi-repo ownership (§7):* ~~the single-owner / attach-point / takeover machinery is substantial
|
||||
and only earns its keep with a **second** managing repo~~ — **shipped (M3, 2026-06-26)**; see the
|
||||
§7 status note. Project-keyed single-owner claim, GroupAdmin-gated `--takeover`, and project-scoped
|
||||
structural prune all landed.
|
||||
|
||||
---
|
||||
|
||||
@@ -986,3 +1150,7 @@ to take, not yet taken:
|
||||
§11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)*
|
||||
- The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage
|
||||
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
|
||||
> `[project]` (M5, root manifest only) shipped: `[[project.environments]]` entries carry
|
||||
> `name` + `confirm` (default `false`). A `confirm = true` env is gated — `pic apply --dir --env <name>`
|
||||
> requires `--approve <name>` (admin-gated + audited; `--yes` is insufficient). The block is rejected on
|
||||
> any non-root manifest. Remaining unspecified: storage config, domains, the full secret-ref shape.
|
||||
|
||||
Reference in New Issue
Block a user