Compare commits
22 Commits
feat/group
...
52da8a8704
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,42 @@
|
||||
//! `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`.
|
||||
//!
|
||||
//! 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 +45,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 +103,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 +136,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 +151,7 @@ impl PicloudModuleResolver {
|
||||
cx: Arc<SdkCallCx>,
|
||||
cache: Arc<ModuleCache>,
|
||||
depth_limit: u32,
|
||||
default_origin: ScriptOwner,
|
||||
) -> Self {
|
||||
Self {
|
||||
source,
|
||||
@@ -113,6 +160,7 @@ impl PicloudModuleResolver {
|
||||
in_progress: Mutex::new(Vec::new()),
|
||||
depth: Mutex::new(0),
|
||||
depth_limit,
|
||||
default_origin,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +272,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 +367,18 @@ 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);
|
||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.lookup(&self.cx, path)));
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.resolve(origin, path)));
|
||||
|
||||
let module_row = match lookup_result {
|
||||
Ok(Some(m)) => m,
|
||||
@@ -355,8 +413,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 +449,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 +464,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,9 +27,9 @@ 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()))
|
||||
@@ -74,6 +74,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,27 @@ 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())
|
||||
}
|
||||
}
|
||||
@@ -129,6 +137,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 +609,130 @@ 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>>,
|
||||
}
|
||||
|
||||
impl LexicalModuleSource {
|
||||
fn new() -> Arc<Self> {
|
||||
Arc::new(Self::default())
|
||||
}
|
||||
|
||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
||||
let (app_id, group_id) = match owner {
|
||||
ScriptOwner::App(a) => (Some(a), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g)),
|
||||
};
|
||||
self.table.lock().await.insert(
|
||||
(owner, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id: ScriptId::new(),
|
||||
app_id,
|
||||
group_id,
|
||||
name: name.to_string(),
|
||||
source: source.to_string(),
|
||||
updated_at: Utc::now(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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())
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
|
||||
@@ -11,21 +11,27 @@ use axum::{
|
||||
routing::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, 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("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
@@ -47,53 +53,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 +86,254 @@ 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 TreeApplyRequest {
|
||||
bundle: TreeBundle,
|
||||
#[serde(default)]
|
||||
prune: bool,
|
||||
#[serde(default)]
|
||||
expected_token: Option<String>,
|
||||
}
|
||||
|
||||
async fn tree_plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(bundle): Json<TreeBundle>,
|
||||
) -> Result<Json<TreePlanResult>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &bundle, None).await?;
|
||||
Ok(Json(svc.plan_tree(&bundle).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)).await?;
|
||||
let report = svc
|
||||
.apply_tree(
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
req.expected_token.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.
|
||||
async fn authz_tree(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
apply_prune: Option<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?;
|
||||
}
|
||||
None => {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
||||
.await?;
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
if prune || !bundle.scripts.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> {
|
||||
if prune || !bundle.scripts.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(())
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
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;
|
||||
@@ -182,6 +183,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,22 @@
|
||||
//! `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::{ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
|
||||
|
||||
pub struct PostgresModuleSource {
|
||||
pool: PgPool,
|
||||
}
|
||||
@@ -27,7 +31,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 +42,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,28 +53,47 @@ 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)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -212,6 +214,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
|
||||
@@ -277,6 +296,7 @@ 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
|
||||
|
||||
table: script_imports
|
||||
app_id: uuid NOT NULL
|
||||
@@ -295,17 +315,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
|
||||
@@ -328,6 +352,18 @@ 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
|
||||
|
||||
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 +431,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)
|
||||
|
||||
@@ -433,6 +470,15 @@ 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_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)
|
||||
@@ -473,12 +519,16 @@ 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)
|
||||
@@ -487,8 +537,16 @@ 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_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 +619,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)
|
||||
|
||||
@@ -605,6 +664,17 @@ 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_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)
|
||||
@@ -648,13 +718,16 @@ 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])))
|
||||
@@ -670,6 +743,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 +794,9 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -309,6 +309,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 +1168,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 +1220,52 @@ 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).
|
||||
pub async fn plan_tree(&self, bundle: &serde_json::Value) -> Result<TreePlanDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/plan")
|
||||
.json(bundle)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
|
||||
/// transaction (Phase 5).
|
||||
pub async fn apply_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
|
||||
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
|
||||
));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -1208,6 +1288,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 +1318,8 @@ pub struct PlanDto {
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: 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 +1334,32 @@ pub struct ChangeDto {
|
||||
pub detail: 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,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ApplyReportDto {
|
||||
@@ -1255,6 +1380,12 @@ 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 warnings: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1629,6 +1760,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;
|
||||
@@ -28,36 +28,38 @@ pub async fn run(
|
||||
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 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,6 +77,80 @@ 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
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
|
||||
/// `picloud.toml` under `root` — in ONE atomic server transaction.
|
||||
pub async fn run_tree(
|
||||
dir: &Path,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
env: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let (bundle, node_count) = crate::discover::build_tree(dir, env)?;
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
let report = client
|
||||
.apply_tree(&bundle, prune, expected_token.as_deref())
|
||||
.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
|
||||
),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -109,11 +109,12 @@ 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,
|
||||
scripts: vec![ManifestScript {
|
||||
name: "hello".into(),
|
||||
file: "scripts/hello.rhai".into(),
|
||||
@@ -137,6 +138,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
}],
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::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,48 @@ 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) = crate::discover::build_tree(dir, env)?;
|
||||
let plan = client.plan_tree(&bundle).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>); 5] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
("secret", &n.secrets),
|
||||
("var", &n.vars),
|
||||
];
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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 +144,22 @@ 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}`"))?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -114,11 +174,12 @@ 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>); 5] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
("var", &plan.vars),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -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();
|
||||
@@ -193,17 +211,19 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
}
|
||||
|
||||
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,
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
secrets: ManifestSecrets {
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
vars: manifest_vars,
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
@@ -212,7 +232,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.
|
||||
|
||||
75
crates/picloud-cli/src/discover.rs
Normal file
75
crates/picloud-cli/src/discover.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
//! 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::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}] }`) from every
|
||||
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree
|
||||
/// that names the same (kind, slug) twice.
|
||||
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
||||
let paths = find_manifests(root)?;
|
||||
if paths.is_empty() {
|
||||
bail!(
|
||||
"no {MANIFEST_FILE} found under {} — nothing to apply",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
let mut nodes = Vec::with_capacity(paths.len());
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
for path in &paths {
|
||||
let manifest = Manifest::load_with_env(path, env)
|
||||
.with_context(|| format!("loading {}", path.display()))?;
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_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");
|
||||
}
|
||||
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
|
||||
}
|
||||
let count = nodes.len();
|
||||
Ok((json!({ "nodes": nodes }), count))
|
||||
}
|
||||
@@ -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)]
|
||||
@@ -225,6 +231,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 +539,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 +565,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 +1339,34 @@ 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.env.as_deref(),
|
||||
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 +1501,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 +1553,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,12 @@ 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>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -38,12 +44,59 @@ 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>,
|
||||
}
|
||||
|
||||
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 +114,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 +137,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 +173,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 +184,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 +205,18 @@ pub struct ManifestApp {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
|
||||
/// and `[vars]`. The group must already exist on the server (created with
|
||||
/// `pic groups create`); the manifest reconciles its content, not the tree
|
||||
/// shape. In a nested project the parent is inferred from the directory tree.
|
||||
#[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>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestScript {
|
||||
pub name: String,
|
||||
@@ -348,11 +422,12 @@ mod tests {
|
||||
|
||||
fn sample() -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
app: Some(ManifestApp {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
},
|
||||
}),
|
||||
group: None,
|
||||
scripts: vec![
|
||||
ManifestScript {
|
||||
name: "create-post".into(),
|
||||
@@ -409,6 +484,10 @@ 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)),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,11 +533,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 +580,67 @@ 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,
|
||||
scripts: vec![],
|
||||
routes: vec![],
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
vars: BTreeMap::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}");
|
||||
// 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 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")
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod group_modules;
|
||||
mod group_scripts;
|
||||
mod group_secrets;
|
||||
mod groups;
|
||||
mod init;
|
||||
@@ -37,5 +39,6 @@ mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod tree;
|
||||
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,
|
||||
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
@@ -86,7 +86,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,88 @@
|
||||
//! `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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>;
|
||||
}
|
||||
|
||||
/// 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 +94,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,9 +102,9 @@ 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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,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 +545,16 @@ 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. **Opt-in
|
||||
> extension points** (the dynamic-resolution branch + `[extension_points]` manifest declaration) remain
|
||||
> the one deferred piece of §5.5 — a clean additive follow-up on top of the now-origin-aware resolver.
|
||||
|
||||
### 5.6 Tree lifecycle: delete, reparent, rename
|
||||
|
||||
@@ -942,8 +951,83 @@ 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. Still deferred: **opt-in extension points** (the only
|
||||
> remaining §5.5 piece) and **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 — single-repo nested tree apply, atomic.** A directory tree of
|
||||
> `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as ONE
|
||||
> server-computed plan in ONE Postgres transaction. Per the §11.1 review, the **multi-repo
|
||||
> single-owner / attach-point / takeover** layer (§7) is **deferred** for the solo-dev / single-repo
|
||||
> start, as is **per-env approval-policy gating** (`[project.environments]`); env overlays
|
||||
> (`picloud.<env>.toml`) already exist. Groups must **pre-exist** (`pic groups create`) — a manifest
|
||||
> owns each node's *content*, not the tree *shape* (declarative group create/reparent is a later
|
||||
> add).
|
||||
>
|
||||
> 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.
|
||||
|
||||
Reference in New Issue
Block a user