feat(hierarchies): extension points — opt-in dynamic module resolution (§5.5)
M1 of the remaining-hierarchies work. An extension point lets a PARENT/group
node opt a module name out of sealed lexical resolution: a parent script's
`import "x"` then resolves against the INHERITING app's module (with a declared
default body as fallback) instead of the parent's sealed chain — so each
descendant app supplies its own `x`. Only the declaring parent can open the
inversion; a descendant can never make a parent's sealed import dynamic or
hijack one (the "is this an extension point" decision keys on the importer's
trusted defining node, never a script-passed value).
Resolver (executor-core):
- `ModuleSource` gains `resolve_extension_point(origin, name)` and a
chain-constrained `resolve_by_id(origin, id)`. The resolve seam checks for an
ext point on the importer's chain first; on a hit it resolves against
`App(cx.app_id)`, else the declared default, else ModuleNotFound. The Postgres
query returns Some only when the NEAREST declaration of the name is an ext
point (a peer/nearer concrete module shadows it). A group origin can't reach
app-owned rows — the trust boundary holds. Cache stays id-keyed (no bleed).
Apply (manager-core, mirrors the `vars` pattern):
- migration 0051: owner-polymorphic `extension_points` table (group XOR app),
optional `default_script_id` FK ON DELETE SET NULL, per-owner LOWER(name)
unique indexes.
- Bundle/Plan/CurrentState gain extension_points; `diff_extension_points`
(name-based, default change = Update), reconcile (upsert after scripts so the
default resolves) + prune, `validate_bundle` (module-name shape; default must
be a local module), `check_imports_resolve` (a declared/on-chain ext point
satisfies an import), and the state_token fold. Writes gate on the
script-write capability (AppWriteScript / GroupScriptsWrite).
- GET /apps|groups/{id}/extension-points so `pic pull` round-trips them — a
re-applied pulled manifest is all-NoOp, so `--prune` can't silently drop one.
CLI: `[[extension_points]]` manifest table; plan/apply build + render; pull
exports them.
Tests: 5 resolver units (inversion, default fallback, no-provider error,
no-hijack of a sealed import, no cross-tenant cache bleed), 2 diff/state-token
units, 2 journeys (per-app resolution + default fallback via invoke; pull
round-trip). Schema golden re-blessed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,14 @@
|
|||||||
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
//! (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`.
|
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||||
//!
|
//!
|
||||||
|
//! **Extension points (§5.5).** A node may opt a module name OUT of sealing
|
||||||
|
//! by declaring it an extension point: an importer on that node's chain then
|
||||||
|
//! resolves the name against the *inheriting app's* effective view
|
||||||
|
//! (`App(cx.app_id)`), with the declared default body as fallback — so each
|
||||||
|
//! descendant app supplies its own impl. The "is this an extension point"
|
||||||
|
//! check keys on the importer's `origin` (trusted), so only the declaring
|
||||||
|
//! parent can open the inversion; a descendant can't hijack a sealed import.
|
||||||
|
//!
|
||||||
//! Three runtime invariants are enforced:
|
//! Three runtime invariants are enforced:
|
||||||
//!
|
//!
|
||||||
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||||
@@ -377,8 +385,44 @@ impl ModuleResolver for PicloudModuleResolver {
|
|||||||
let origin = importer_source
|
let origin = importer_source
|
||||||
.and_then(decode_origin)
|
.and_then(decode_origin)
|
||||||
.unwrap_or(self.default_origin);
|
.unwrap_or(self.default_origin);
|
||||||
|
|
||||||
|
// Extension-point inversion (§5.5): if `path`'s nearest declaration on
|
||||||
|
// the importer's chain is an extension point (not a concrete module),
|
||||||
|
// resolve it against the INHERITING app's effective view instead of the
|
||||||
|
// sealed lexical chain — each descendant app supplies its own impl,
|
||||||
|
// falling back to the declared default body. The decision keys on
|
||||||
|
// `origin` (the trusted importer node), so only a declaration on the
|
||||||
|
// importer's own ancestry can open this; a descendant can never make a
|
||||||
|
// parent's sealed `import` dynamic, and a non-ext-point import never
|
||||||
|
// consults `App(cx.app_id)`. All branches funnel any backend error into
|
||||||
|
// the single redaction arm below.
|
||||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||||
tokio::task::block_in_place(|| handle.block_on(self.source.resolve(origin, path)));
|
(|| {
|
||||||
|
let ep = tokio::task::block_in_place(|| {
|
||||||
|
handle.block_on(self.source.resolve_extension_point(origin, path))
|
||||||
|
})?;
|
||||||
|
let Some(ep) = ep else {
|
||||||
|
// Sealed lexical resolution (the default, unchanged).
|
||||||
|
return tokio::task::block_in_place(|| {
|
||||||
|
handle.block_on(self.source.resolve(origin, path))
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let app_origin = ScriptOwner::App(self.cx.app_id);
|
||||||
|
if let Some(m) = tokio::task::block_in_place(|| {
|
||||||
|
handle.block_on(self.source.resolve(app_origin, path))
|
||||||
|
})? {
|
||||||
|
return Ok(Some(m));
|
||||||
|
}
|
||||||
|
match ep.default_script_id {
|
||||||
|
// The default body is a module declared at the node that
|
||||||
|
// opened the ext point, i.e. on the importer's (`origin`)
|
||||||
|
// chain — resolve it there, not against the app.
|
||||||
|
Some(id) => tokio::task::block_in_place(|| {
|
||||||
|
handle.block_on(self.source.resolve_by_id(origin, id))
|
||||||
|
}),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
let module_row = match lookup_result {
|
let module_row = match lookup_result {
|
||||||
Ok(Some(m)) => m,
|
Ok(Some(m)) => m,
|
||||||
|
|||||||
@@ -34,6 +34,22 @@ impl ModuleSource for FailingSource {
|
|||||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_extension_point(
|
||||||
|
&self,
|
||||||
|
_origin: ScriptOwner,
|
||||||
|
_name: &str,
|
||||||
|
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||||
|
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_by_id(
|
||||||
|
&self,
|
||||||
|
_origin: ScriptOwner,
|
||||||
|
_script_id: picloud_shared::ScriptId,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `MakeWriter` that appends to a shared buffer.
|
/// `MakeWriter` that appends to a shared buffer.
|
||||||
|
|||||||
@@ -95,6 +95,30 @@ impl ModuleSource for CountingModuleSource {
|
|||||||
.get(&(app_id, name.to_string()))
|
.get(&(app_id, name.to_string()))
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_extension_point(
|
||||||
|
&self,
|
||||||
|
_origin: ScriptOwner,
|
||||||
|
_name: &str,
|
||||||
|
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||||
|
// This flat fake has no extension points; the inversion is covered by
|
||||||
|
// `LexicalModuleSource` below and the Postgres journey tests.
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_by_id(
|
||||||
|
&self,
|
||||||
|
_origin: ScriptOwner,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(self
|
||||||
|
.table
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.find(|m| m.script_id == script_id)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||||
@@ -623,6 +647,10 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct LexicalModuleSource {
|
struct LexicalModuleSource {
|
||||||
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||||
|
/// Extension-point declarations, keyed by exact declaring owner →
|
||||||
|
/// optional default module id. Flat (exact-owner) — the chain-walk +
|
||||||
|
/// shadowing tie-break is covered by the Postgres journey tests.
|
||||||
|
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LexicalModuleSource {
|
impl LexicalModuleSource {
|
||||||
@@ -630,15 +658,16 @@ impl LexicalModuleSource {
|
|||||||
Arc::new(Self::default())
|
Arc::new(Self::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) {
|
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) -> ScriptId {
|
||||||
let (app_id, group_id) = match owner {
|
let (app_id, group_id) = match owner {
|
||||||
ScriptOwner::App(a) => (Some(a), None),
|
ScriptOwner::App(a) => (Some(a), None),
|
||||||
ScriptOwner::Group(g) => (None, Some(g)),
|
ScriptOwner::Group(g) => (None, Some(g)),
|
||||||
};
|
};
|
||||||
|
let script_id = ScriptId::new();
|
||||||
self.table.lock().await.insert(
|
self.table.lock().await.insert(
|
||||||
(owner, name.to_string()),
|
(owner, name.to_string()),
|
||||||
ModuleScript {
|
ModuleScript {
|
||||||
script_id: ScriptId::new(),
|
script_id,
|
||||||
app_id,
|
app_id,
|
||||||
group_id,
|
group_id,
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
@@ -646,6 +675,21 @@ impl LexicalModuleSource {
|
|||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
script_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Declare `name` an extension point at `owner`, with an optional default
|
||||||
|
/// module body id.
|
||||||
|
async fn put_ext_point(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
owner: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
default_script_id: Option<ScriptId>,
|
||||||
|
) {
|
||||||
|
self.ext_points
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert((owner, name.to_string()), default_script_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,6 +707,35 @@ impl ModuleSource for LexicalModuleSource {
|
|||||||
.get(&(origin, name.to_string()))
|
.get(&(origin, name.to_string()))
|
||||||
.cloned())
|
.cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_extension_point(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||||
|
Ok(self
|
||||||
|
.ext_points
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.get(&(origin, name.to_string()))
|
||||||
|
.map(|default_script_id| picloud_shared::ExtPointResolution {
|
||||||
|
default_script_id: *default_script_id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_by_id(
|
||||||
|
&self,
|
||||||
|
_origin: ScriptOwner,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(self
|
||||||
|
.table
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.values()
|
||||||
|
.find(|m| m.script_id == script_id)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
|
||||||
@@ -736,3 +809,174 @@ async fn lexical_entry_origin_selects_app_module() {
|
|||||||
.expect("should execute");
|
.expect("should execute");
|
||||||
assert_eq!(resp.body, serde_json::json!(999));
|
assert_eq!(resp.body, serde_json::json!(999));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
|
||||||
|
// a module name an extension point; an importer on the group's chain resolves
|
||||||
|
// it against the INHERITING APP, not the sealed lexical chain.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// A group script importing a declared extension point resolves it against the
|
||||||
|
/// executing app's own module — each tenant supplies its own body.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn ext_point_resolves_to_app_module() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
// The group opens "theme" as an extension point (no default).
|
||||||
|
source
|
||||||
|
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||||
|
.await;
|
||||||
|
// The app provides its own "theme".
|
||||||
|
source
|
||||||
|
.put(
|
||||||
|
ScriptOwner::App(app),
|
||||||
|
"theme",
|
||||||
|
r#"fn name() { "app-theme" }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
// Entry runs as the inherited GROUP script (default_origin = group), but
|
||||||
|
// the executing app is `app` (cx.app_id).
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::name()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(
|
||||||
|
resp.body,
|
||||||
|
serde_json::json!("app-theme"),
|
||||||
|
"extension point must resolve to the inheriting app's module"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the app provides no module for the extension point, the declared
|
||||||
|
/// default body is used.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn ext_point_falls_back_to_default() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
// The group's default "theme" body, registered as a group module.
|
||||||
|
let default_id = source
|
||||||
|
.put(
|
||||||
|
ScriptOwner::Group(group),
|
||||||
|
"default-theme",
|
||||||
|
r#"fn name() { "default-theme" }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
|
||||||
|
.await;
|
||||||
|
// The app provides NO "theme".
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::name()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(resp.body, serde_json::json!("default-theme"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An extension point with neither an app provider nor a default is a clean
|
||||||
|
/// module-not-found at import time.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn ext_point_no_provider_no_default_errors() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source
|
||||||
|
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let err = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::name()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect_err("ext point with no provider/default should fail");
|
||||||
|
let msg = format!("{err:?}").to_lowercase();
|
||||||
|
assert!(
|
||||||
|
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
|
||||||
|
"expected module-not-found-flavoured error, got {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
|
||||||
|
/// module of the same name — the inversion only fires for declared extension
|
||||||
|
/// points, so the trust boundary holds.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
// "auth" is a concrete group module — NOT an extension point.
|
||||||
|
source
|
||||||
|
.put(
|
||||||
|
ScriptOwner::Group(group),
|
||||||
|
"auth",
|
||||||
|
r#"fn who() { "group-auth" }"#,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
// The app has a trap "auth" that must NOT be selected.
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let resp = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "auth" as a; a::who()"#,
|
||||||
|
req_with_owner(app, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("should execute");
|
||||||
|
assert_eq!(
|
||||||
|
resp.body,
|
||||||
|
serde_json::json!("group-auth"),
|
||||||
|
"a non-ext-point import must seal to the group, never the leaf's trap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two apps inheriting the same extension point get their OWN bodies — the
|
||||||
|
/// id-keyed module cache does not bleed one tenant's body into another.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
async fn ext_point_two_apps_get_distinct_bodies() {
|
||||||
|
let source = LexicalModuleSource::new();
|
||||||
|
let app1 = AppId::new();
|
||||||
|
let app2 = AppId::new();
|
||||||
|
let group = GroupId::new();
|
||||||
|
source
|
||||||
|
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
|
||||||
|
.await;
|
||||||
|
source
|
||||||
|
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let engine = engine_with(source.clone());
|
||||||
|
let r1 = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::name()"#,
|
||||||
|
req_with_owner(app1, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("app1 executes");
|
||||||
|
let r2 = engine
|
||||||
|
.execute(
|
||||||
|
r#"import "theme" as t; t::name()"#,
|
||||||
|
req_with_owner(app2, ScriptOwner::Group(group)),
|
||||||
|
)
|
||||||
|
.expect("app2 executes");
|
||||||
|
assert_eq!(r1.body, serde_json::json!("one"));
|
||||||
|
assert_eq!(
|
||||||
|
r2.body,
|
||||||
|
serde_json::json!("two"),
|
||||||
|
"no cross-tenant cache bleed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
49
crates/manager-core/migrations/0051_extension_points.sql
Normal file
49
crates/manager-core/migrations/0051_extension_points.sql
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
-- Phase 4b+1 (v1.2 Hierarchies): extension points (§5.5).
|
||||||
|
--
|
||||||
|
-- An extension point opts a MODULE NAME into dynamic, per-tenant resolution.
|
||||||
|
-- Normally a group/parent script's `import "x"` resolves sealed-lexically
|
||||||
|
-- against the importer's own defining node — a leaf app cannot shadow it
|
||||||
|
-- (the trust boundary). When a node declares `x` an extension point, an
|
||||||
|
-- importer whose defining node is on that node's chain instead resolves `x`
|
||||||
|
-- against the INHERITING app's effective view, so each descendant app may
|
||||||
|
-- supply its own `x`. Only the declaring (parent) node can open the hole; a
|
||||||
|
-- descendant cannot make one of its parent's sealed imports dynamic, because
|
||||||
|
-- the declaration must live on the importer's own ancestry.
|
||||||
|
--
|
||||||
|
-- Owner-polymorphic exactly like vars (0048): exactly one of group_id/app_id.
|
||||||
|
-- Optional `default_script_id` is the fallback module body used when the
|
||||||
|
-- inheriting app provides no module of `name`.
|
||||||
|
|
||||||
|
CREATE TABLE extension_points (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Polymorphic owner: exactly one FK set (real FKs so ON DELETE CASCADE
|
||||||
|
-- works per owner kind and a dangling owner is impossible).
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT extension_points_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
-- Optional fallback module (a module owned by the declaring node). SET
|
||||||
|
-- NULL on delete so deleting the module can never block — the fallback is
|
||||||
|
-- then absent and a non-providing app errors at import time (the next plan
|
||||||
|
-- shows the now-null default as an Update). A valid manifest apply can't
|
||||||
|
-- reach this: validation rejects a kept ext point whose default names a
|
||||||
|
-- module not in the bundle.
|
||||||
|
default_script_id UUID REFERENCES scripts(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One declaration per (owner, name), case-insensitive to match the module
|
||||||
|
-- name indexes (0050). Partial because the owner is split across two columns.
|
||||||
|
CREATE UNIQUE INDEX extension_points_group_uidx
|
||||||
|
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX extension_points_app_uidx
|
||||||
|
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX extension_points_group_id_idx
|
||||||
|
ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX extension_points_app_id_idx
|
||||||
|
ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
||||||
|
CREATE INDEX extension_points_default_script_idx
|
||||||
|
ON extension_points (default_script_id) WHERE default_script_id IS NOT NULL;
|
||||||
@@ -8,7 +8,7 @@ use axum::{
|
|||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::post,
|
routing::{get, post},
|
||||||
Extension, Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{AppId, GroupId, Principal};
|
use picloud_shared::{AppId, GroupId, Principal};
|
||||||
@@ -17,8 +17,8 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::apply_service::{
|
use crate::apply_service::{
|
||||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, NodeKind, PlanResult,
|
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
|
||||||
TreeBundle, TreePlanResult,
|
NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
||||||
};
|
};
|
||||||
use crate::authz::{require, AuthzDenied, Capability};
|
use crate::authz::{require, AuthzDenied, Capability};
|
||||||
use crate::group_repo::GroupRepository;
|
use crate::group_repo::GroupRepository;
|
||||||
@@ -30,11 +30,53 @@ pub fn apply_router(service: ApplyService) -> Router {
|
|||||||
.route("/apps/{id}/apply", post(apply_handler))
|
.route("/apps/{id}/apply", post(apply_handler))
|
||||||
.route("/groups/{id}/plan", post(group_plan_handler))
|
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||||
.route("/groups/{id}/apply", post(group_apply_handler))
|
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||||
|
.route("/apps/{id}/extension-points", get(app_ext_points_handler))
|
||||||
|
.route(
|
||||||
|
"/groups/{id}/extension-points",
|
||||||
|
get(group_ext_points_handler),
|
||||||
|
)
|
||||||
.route("/tree/plan", post(tree_plan_handler))
|
.route("/tree/plan", post(tree_plan_handler))
|
||||||
.route("/tree/apply", post(tree_apply_handler))
|
.route("/tree/apply", post(tree_apply_handler))
|
||||||
.with_state(service)
|
.with_state(service)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
|
||||||
|
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
|
||||||
|
async fn app_ext_points_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||||
|
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||||
|
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
Ok(Json(
|
||||||
|
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
|
||||||
|
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
|
||||||
|
async fn group_ext_points_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
Ok(Json(
|
||||||
|
svc.list_extension_points(ApplyOwner::Group(group_id))
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ApplyRequest {
|
pub struct ApplyRequest {
|
||||||
pub bundle: Bundle,
|
pub bundle: Bundle,
|
||||||
@@ -232,7 +274,11 @@ async fn require_app_node_writes(
|
|||||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||||
.await
|
.await
|
||||||
.map_err(map_authz)?;
|
.map_err(map_authz)?;
|
||||||
if prune || !bundle.scripts.is_empty() {
|
// Extension points are module-resolution declarations, so they gate on the
|
||||||
|
// same script-write tier as modules — without this, an ext-point-only
|
||||||
|
// bundle (no `default`, so no script) would pass with viewer-tier `AppRead`
|
||||||
|
// and let a reader open the §5.5 import trust boundary.
|
||||||
|
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
||||||
require(
|
require(
|
||||||
svc.authz.as_ref(),
|
svc.authz.as_ref(),
|
||||||
principal,
|
principal,
|
||||||
@@ -292,7 +338,8 @@ async fn require_group_node_writes(
|
|||||||
bundle: &Bundle,
|
bundle: &Bundle,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
if prune || !bundle.scripts.is_empty() {
|
// Extension points gate on the script-write tier (see the app variant).
|
||||||
|
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
||||||
require(
|
require(
|
||||||
svc.authz.as_ref(),
|
svc.authz.as_ref(),
|
||||||
principal,
|
principal,
|
||||||
|
|||||||
@@ -83,6 +83,32 @@ pub struct Bundle {
|
|||||||
/// not expressible here; use `pic vars set --tombstone`.
|
/// not expressible here; use `pic vars set --tombstone`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vars: std::collections::BTreeMap<String, serde_json::Value>,
|
pub vars: std::collections::BTreeMap<String, serde_json::Value>,
|
||||||
|
/// Declared extension points (§5.5): module names this node opens for
|
||||||
|
/// per-tenant resolution by inheriting apps. Reconciled at the node that
|
||||||
|
/// owns the importing script.
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points: Vec<ExtPointSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desired extension point — a module name opened for dynamic, per-tenant
|
||||||
|
/// resolution, with an optional default body (a module declared at this node).
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct ExtPointSpec {
|
||||||
|
pub name: String,
|
||||||
|
/// Name of a module declared at this node to use when an inheriting app
|
||||||
|
/// provides none. Omitted ⇒ no fallback (a non-providing app errors at
|
||||||
|
/// import time).
|
||||||
|
#[serde(default)]
|
||||||
|
pub default: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One of a node's OWN extension-point declarations, for the read/list
|
||||||
|
/// surface (`pic pull`). The default is its module NAME (not the raw id).
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ExtPointView {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
@@ -315,6 +341,8 @@ pub struct Plan {
|
|||||||
pub secrets: Vec<ResourceChange>,
|
pub secrets: Vec<ResourceChange>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vars: Vec<ResourceChange>,
|
pub vars: Vec<ResourceChange>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points: Vec<ResourceChange>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plan {
|
impl Plan {
|
||||||
@@ -327,6 +355,7 @@ impl Plan {
|
|||||||
.chain(&self.triggers)
|
.chain(&self.triggers)
|
||||||
.chain(&self.secrets)
|
.chain(&self.secrets)
|
||||||
.chain(&self.vars)
|
.chain(&self.vars)
|
||||||
|
.chain(&self.extension_points)
|
||||||
.all(|c| c.op == Op::NoOp)
|
.all(|c| c.op == Op::NoOp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,6 +432,10 @@ pub struct CurrentState {
|
|||||||
/// the manifest reconciles. Inherited group vars and tombstones are
|
/// the manifest reconciles. Inherited group vars and tombstones are
|
||||||
/// excluded (the manifest manages only the app's own real values).
|
/// excluded (the manifest manages only the app's own real values).
|
||||||
pub vars: Vec<(String, serde_json::Value)>,
|
pub vars: Vec<(String, serde_json::Value)>,
|
||||||
|
/// The owner's OWN extension-point declarations: `(name, default module
|
||||||
|
/// name)`. The default is resolved back to its module name (not the raw
|
||||||
|
/// id) so the diff is name-based and stable across re-applies.
|
||||||
|
pub extension_points: Vec<(String, Option<String>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
@@ -773,6 +806,43 @@ impl ApplyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3c. Extension points (§5.5) — reconcile after scripts so a `default`
|
||||||
|
// module name resolves to its just-reconciled id. Upsert every
|
||||||
|
// Create/Update; the default (if named) must be a local module.
|
||||||
|
let bundle_ext: HashMap<String, &ExtPointSpec> = bundle
|
||||||
|
.extension_points
|
||||||
|
.iter()
|
||||||
|
.map(|e| (e.name.to_lowercase(), e))
|
||||||
|
.collect();
|
||||||
|
for ch in &plan.extension_points {
|
||||||
|
match ch.op {
|
||||||
|
Op::Create | Op::Update => {
|
||||||
|
let spec = bundle_ext[&ch.key.to_lowercase()];
|
||||||
|
let default_id = match &spec.default {
|
||||||
|
Some(name) => {
|
||||||
|
Some(*name_to_id.get(&name.to_lowercase()).ok_or_else(|| {
|
||||||
|
ApplyError::Invalid(format!(
|
||||||
|
"extension point `{}` default `{name}` is not a \
|
||||||
|
module declared here",
|
||||||
|
spec.name
|
||||||
|
))
|
||||||
|
})?)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
owner
|
||||||
|
.set_ext_point_tx(&mut *tx, &spec.name, default_id)
|
||||||
|
.await?;
|
||||||
|
if ch.op == Op::Create {
|
||||||
|
report.extension_points_created += 1;
|
||||||
|
} else {
|
||||||
|
report.extension_points_updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Op::NoOp | Op::Delete => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Prune (only with --prune): delete stale triggers, then scripts
|
// 4. Prune (only with --prune): delete stale triggers, then scripts
|
||||||
// (route removals already happened in the route delete-pass above).
|
// (route removals already happened in the route delete-pass above).
|
||||||
// Secret pruning is deliberately deferred (destructive + irreversible):
|
// Secret pruning is deliberately deferred (destructive + irreversible):
|
||||||
@@ -848,6 +918,15 @@ impl ApplyService {
|
|||||||
report.vars_deleted += 1;
|
report.vars_deleted += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extension points are prunable declarations (like vars): a live
|
||||||
|
// declaration dropped from the manifest is removed.
|
||||||
|
for ch in &plan.extension_points {
|
||||||
|
if ch.op == Op::Delete {
|
||||||
|
owner.delete_ext_point_tx(&mut *tx, &ch.key).await?;
|
||||||
|
report.extension_points_deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(name_to_id)
|
Ok(name_to_id)
|
||||||
}
|
}
|
||||||
@@ -1570,6 +1649,13 @@ impl ApplyService {
|
|||||||
.filter(|s| s.kind == ScriptKind::Module)
|
.filter(|s| s.kind == ScriptKind::Module)
|
||||||
.map(|s| s.name.to_lowercase())
|
.map(|s| s.name.to_lowercase())
|
||||||
.collect();
|
.collect();
|
||||||
|
// Extension points declared in THIS bundle also satisfy imports — the
|
||||||
|
// concrete body is supplied by an inheriting app at runtime (§5.5).
|
||||||
|
let local_ext: HashSet<String> = bundle
|
||||||
|
.extension_points
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.name.to_lowercase())
|
||||||
|
.collect();
|
||||||
let origin = match owner {
|
let origin = match owner {
|
||||||
ApplyOwner::App(a) => picloud_shared::ScriptOwner::App(a),
|
ApplyOwner::App(a) => picloud_shared::ScriptOwner::App(a),
|
||||||
ApplyOwner::Group(g) => picloud_shared::ScriptOwner::Group(g),
|
ApplyOwner::Group(g) => picloud_shared::ScriptOwner::Group(g),
|
||||||
@@ -1579,7 +1665,7 @@ impl ApplyService {
|
|||||||
for s in &bundle.scripts {
|
for s in &bundle.scripts {
|
||||||
for imp in self.script_imports(s)? {
|
for imp in self.script_imports(s)? {
|
||||||
let key = imp.to_lowercase();
|
let key = imp.to_lowercase();
|
||||||
if local.contains(&key) || !checked.insert(key) {
|
if local.contains(&key) || local_ext.contains(&key) || !checked.insert(key) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let found = self
|
let found = self
|
||||||
@@ -1587,7 +1673,18 @@ impl ApplyService {
|
|||||||
.resolve(origin, &imp)
|
.resolve(origin, &imp)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
if found.is_none() {
|
if found.is_some() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Not a concrete module — but an extension point declared on an
|
||||||
|
// ancestor also satisfies the import (resolved per-app at run
|
||||||
|
// time). Only a name that is neither is a dangling import.
|
||||||
|
let ep = self
|
||||||
|
.modules
|
||||||
|
.resolve_extension_point(origin, &imp)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
if ep.is_none() {
|
||||||
return Err(ApplyError::Invalid(format!(
|
return Err(ApplyError::Invalid(format!(
|
||||||
"script `{}` imports unknown module `{imp}` \
|
"script `{}` imports unknown module `{imp}` \
|
||||||
(not declared here and not inherited from an ancestor)",
|
(not declared here and not inherited from an ancestor)",
|
||||||
@@ -1603,6 +1700,7 @@ impl ApplyService {
|
|||||||
/// scripts reachable from the app's chain that a route/trigger may bind to
|
/// scripts reachable from the app's chain that a route/trigger may bind to
|
||||||
/// even though the manifest doesn't declare them (Phase 4). They count as
|
/// even though the manifest doesn't declare them (Phase 4). They count as
|
||||||
/// known endpoints for the binding checks below.
|
/// known endpoints for the binding checks below.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
fn validate_bundle(
|
fn validate_bundle(
|
||||||
&self,
|
&self,
|
||||||
bundle: &Bundle,
|
bundle: &Bundle,
|
||||||
@@ -1722,6 +1820,33 @@ impl ApplyService {
|
|||||||
for key in bundle.vars.keys() {
|
for key in bundle.vars.keys() {
|
||||||
validate_var_key(key)?;
|
validate_var_key(key)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extension points (§5.5): unique kebab names; a `default` must name a
|
||||||
|
// module declared in this node (an endpoint is never importable).
|
||||||
|
let module_names: HashSet<String> = bundle
|
||||||
|
.scripts
|
||||||
|
.iter()
|
||||||
|
.filter(|s| s.kind == ScriptKind::Module)
|
||||||
|
.map(|s| s.name.to_lowercase())
|
||||||
|
.collect();
|
||||||
|
let mut ext_names: HashSet<String> = HashSet::new();
|
||||||
|
for e in &bundle.extension_points {
|
||||||
|
validate_ext_point_name(&e.name)?;
|
||||||
|
if !ext_names.insert(e.name.to_lowercase()) {
|
||||||
|
return Err(ApplyError::Invalid(format!(
|
||||||
|
"duplicate extension point `{}`",
|
||||||
|
e.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if let Some(default) = &e.default {
|
||||||
|
if !module_names.contains(&default.to_lowercase()) {
|
||||||
|
return Err(ApplyError::Invalid(format!(
|
||||||
|
"extension point `{}` default `{default}` is not a module declared here",
|
||||||
|
e.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1769,15 +1894,58 @@ impl ApplyService {
|
|||||||
.filter(|r| r.environment_scope == "*" && !r.is_tombstone)
|
.filter(|r| r.environment_scope == "*" && !r.is_tombstone)
|
||||||
.map(|r| (r.key, r.value))
|
.map(|r| (r.key, r.value))
|
||||||
.collect();
|
.collect();
|
||||||
|
let extension_points = self.load_extension_points(owner).await?;
|
||||||
Ok(CurrentState {
|
Ok(CurrentState {
|
||||||
scripts,
|
scripts,
|
||||||
routes,
|
routes,
|
||||||
triggers,
|
triggers,
|
||||||
secret_names,
|
secret_names,
|
||||||
vars,
|
vars,
|
||||||
|
extension_points,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The owner's OWN extension-point declarations, each with its default
|
||||||
|
/// module's NAME (resolved via the FK) so the diff is name-based. Read
|
||||||
|
/// directly off the pool (no repo trait — the pure `compute_diff` tests
|
||||||
|
/// bypass `load_current`, and this is integration-tested).
|
||||||
|
async fn load_extension_points(
|
||||||
|
&self,
|
||||||
|
owner: ApplyOwner,
|
||||||
|
) -> Result<Vec<(String, Option<String>)>, ApplyError> {
|
||||||
|
let (col, val) = match owner {
|
||||||
|
ApplyOwner::App(a) => ("app_id", a.into_inner()),
|
||||||
|
ApplyOwner::Group(g) => ("group_id", g.into_inner()),
|
||||||
|
};
|
||||||
|
let rows: Vec<(String, Option<String>)> = sqlx::query_as(&format!(
|
||||||
|
"SELECT e.name, ds.name AS default_name \
|
||||||
|
FROM extension_points e \
|
||||||
|
LEFT JOIN scripts ds ON ds.id = e.default_script_id \
|
||||||
|
WHERE e.{col} = $1 ORDER BY LOWER(e.name)"
|
||||||
|
))
|
||||||
|
.bind(val)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The owner's OWN extension-point declarations, for the `pic pull` export.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// `Backend` on a repo failure.
|
||||||
|
pub async fn list_extension_points(
|
||||||
|
&self,
|
||||||
|
owner: ApplyOwner,
|
||||||
|
) -> Result<Vec<ExtPointView>, ApplyError> {
|
||||||
|
Ok(self
|
||||||
|
.load_extension_points(owner)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(name, default)| ExtPointView { name, default })
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
async fn all_secret_names(&self, app_id: AppId) -> Result<Vec<String>, ApplyError> {
|
async fn all_secret_names(&self, app_id: AppId) -> Result<Vec<String>, ApplyError> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
let mut cursor: Option<String> = None;
|
let mut cursor: Option<String> = None;
|
||||||
@@ -1838,6 +2006,66 @@ fn compute_diff_with_names(
|
|||||||
triggers: diff_triggers(current, bundle, &script_name_by_id),
|
triggers: diff_triggers(current, bundle, &script_name_by_id),
|
||||||
secrets: diff_secrets(current, bundle),
|
secrets: diff_secrets(current, bundle),
|
||||||
vars: diff_vars(current, bundle),
|
vars: diff_vars(current, bundle),
|
||||||
|
extension_points: diff_extension_points(current, bundle),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diff extension points by name. The "value" is the default module name, so a
|
||||||
|
/// changed default is an `Update` (like vars, the declaration lives in the
|
||||||
|
/// manifest). Live declarations absent from the manifest are `Delete` (applied
|
||||||
|
/// only under `--prune`). Name comparison is case-insensitive to match the
|
||||||
|
/// per-owner `LOWER(name)` unique index.
|
||||||
|
fn diff_extension_points(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||||
|
let live: HashMap<String, Option<&str>> = current
|
||||||
|
.extension_points
|
||||||
|
.iter()
|
||||||
|
.map(|(name, default)| (name.to_lowercase(), default.as_deref()))
|
||||||
|
.collect();
|
||||||
|
let desired: HashSet<String> = bundle
|
||||||
|
.extension_points
|
||||||
|
.iter()
|
||||||
|
.map(|e| e.name.to_lowercase())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in &bundle.extension_points {
|
||||||
|
let want = e.default.as_deref();
|
||||||
|
match live.get(&e.name.to_lowercase()) {
|
||||||
|
Some(cur) if eq_ci_opt(*cur, want) => out.push(ResourceChange {
|
||||||
|
op: Op::NoOp,
|
||||||
|
key: e.name.clone(),
|
||||||
|
detail: None,
|
||||||
|
}),
|
||||||
|
Some(_) => out.push(ResourceChange {
|
||||||
|
op: Op::Update,
|
||||||
|
key: e.name.clone(),
|
||||||
|
detail: Some("default changed".into()),
|
||||||
|
}),
|
||||||
|
None => out.push(ResourceChange {
|
||||||
|
op: Op::Create,
|
||||||
|
key: e.name.clone(),
|
||||||
|
detail: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (name, _) in ¤t.extension_points {
|
||||||
|
if !desired.contains(&name.to_lowercase()) {
|
||||||
|
out.push(ResourceChange {
|
||||||
|
op: Op::Delete,
|
||||||
|
key: name.clone(),
|
||||||
|
detail: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive equality for two optional module names.
|
||||||
|
fn eq_ci_opt(a: Option<&str>, b: Option<&str>) -> bool {
|
||||||
|
match (a, b) {
|
||||||
|
(Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
|
||||||
|
(None, None) => true,
|
||||||
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1883,6 +2111,28 @@ fn diff_vars(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
|||||||
|
|
||||||
/// Kebab var key (`^[a-z0-9][a-z0-9-]{0,127}$`) — mirrors `vars_api`'s
|
/// Kebab var key (`^[a-z0-9][a-z0-9-]{0,127}$`) — mirrors `vars_api`'s
|
||||||
/// `validate_key` so the manifest and the interactive surface agree.
|
/// `validate_key` so the manifest and the interactive surface agree.
|
||||||
|
/// Extension-point name rule: the SAME identifier shape as a module name
|
||||||
|
/// (`^[a-zA-Z_][a-zA-Z0-9_]{0,63}$`, migration 0015). An extension point is
|
||||||
|
/// resolved by `import "<name>"`, and the concrete module that satisfies it
|
||||||
|
/// must be a real module script — whose name is identifier-shaped — so a
|
||||||
|
/// hyphenated extension point could never be provided. Reject early.
|
||||||
|
fn validate_ext_point_name(name: &str) -> Result<(), ApplyError> {
|
||||||
|
let mut chars = name.chars();
|
||||||
|
let ok = name.len() <= 64
|
||||||
|
&& chars
|
||||||
|
.next()
|
||||||
|
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
|
||||||
|
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||||
|
if ok {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ApplyError::Invalid(format!(
|
||||||
|
"extension point name `{name}` must be 1–64 chars of letters, digits, and \
|
||||||
|
underscores, starting with a letter or underscore (a module-name shape)"
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_var_key(key: &str) -> Result<(), ApplyError> {
|
fn validate_var_key(key: &str) -> Result<(), ApplyError> {
|
||||||
let ok = !key.is_empty()
|
let ok = !key.is_empty()
|
||||||
&& key.len() <= 128
|
&& key.len() <= 128
|
||||||
@@ -2034,6 +2284,55 @@ impl ApplyOwner {
|
|||||||
Self::Group(g) => delete_group_var_tx(tx, g, key).await,
|
Self::Group(g) => delete_group_var_tx(tx, g, key).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn set_ext_point_tx(
|
||||||
|
self,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
name: &str,
|
||||||
|
default_script_id: Option<ScriptId>,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
let default = default_script_id.map(ScriptId::into_inner);
|
||||||
|
let (col, val) = match self {
|
||||||
|
Self::App(a) => ("app_id", a.into_inner()),
|
||||||
|
Self::Group(g) => ("group_id", g.into_inner()),
|
||||||
|
};
|
||||||
|
// Owner-kind-specific upsert; the conflict target is the matching
|
||||||
|
// partial-unique index, so its predicate is restated. Update both the
|
||||||
|
// default and updated_at so a re-applied default change takes effect.
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"INSERT INTO extension_points ({col}, name, default_script_id) \
|
||||||
|
VALUES ($1, $2, $3) \
|
||||||
|
ON CONFLICT ({col}, LOWER(name)) WHERE {col} IS NOT NULL DO UPDATE \
|
||||||
|
SET default_script_id = EXCLUDED.default_script_id, updated_at = NOW()"
|
||||||
|
))
|
||||||
|
.bind(val)
|
||||||
|
.bind(name)
|
||||||
|
.bind(default)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_ext_point_tx(
|
||||||
|
self,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
let (col, val) = match self {
|
||||||
|
Self::App(a) => ("app_id", a.into_inner()),
|
||||||
|
Self::Group(g) => ("group_id", g.into_inner()),
|
||||||
|
};
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"DELETE FROM extension_points WHERE {col} = $1 AND LOWER(name) = LOWER($2)"
|
||||||
|
))
|
||||||
|
.bind(val)
|
||||||
|
.bind(name)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn diff_scripts(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
fn diff_scripts(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||||
@@ -2594,6 +2893,12 @@ pub struct ApplyReport {
|
|||||||
pub vars_updated: u32,
|
pub vars_updated: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vars_deleted: u32,
|
pub vars_deleted: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points_created: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points_updated: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points_deleted: u32,
|
||||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -2818,6 +3123,15 @@ fn state_token_with_names(
|
|||||||
serde_json::to_string(value).unwrap_or_default()
|
serde_json::to_string(value).unwrap_or_default()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Extension points: name + default module name. A changed default moves
|
||||||
|
// the token so the bound-plan check catches an edit between plan and apply.
|
||||||
|
for (name, default) in ¤t.extension_points {
|
||||||
|
parts.push(format!(
|
||||||
|
"e|{}|{}",
|
||||||
|
name.to_lowercase(),
|
||||||
|
default.as_deref().unwrap_or("")
|
||||||
|
));
|
||||||
|
}
|
||||||
// Order-independent: sort the per-resource tokens before hashing.
|
// Order-independent: sort the per-resource tokens before hashing.
|
||||||
parts.sort_unstable();
|
parts.sort_unstable();
|
||||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||||
@@ -2898,6 +3212,7 @@ mod tests {
|
|||||||
triggers: vec![],
|
triggers: vec![],
|
||||||
secrets: vec!["S".into()],
|
secrets: vec!["S".into()],
|
||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
|
extension_points: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts.len(), 1);
|
assert_eq!(plan.scripts.len(), 1);
|
||||||
@@ -2920,6 +3235,7 @@ mod tests {
|
|||||||
triggers: vec![],
|
triggers: vec![],
|
||||||
secrets: vec!["S".into()],
|
secrets: vec!["S".into()],
|
||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
|
extension_points: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
assert!(plan.is_noop(), "expected all no-op, got {plan:?}");
|
||||||
@@ -2937,6 +3253,7 @@ mod tests {
|
|||||||
triggers: vec![],
|
triggers: vec![],
|
||||||
secrets: vec![],
|
secrets: vec![],
|
||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
|
extension_points: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts[0].op, Op::Update);
|
assert_eq!(plan.scripts[0].op, Op::Update);
|
||||||
@@ -2955,6 +3272,7 @@ mod tests {
|
|||||||
triggers: vec![],
|
triggers: vec![],
|
||||||
secrets: vec![],
|
secrets: vec![],
|
||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
|
extension_points: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.scripts[0].op, Op::Delete);
|
assert_eq!(plan.scripts[0].op, Op::Delete);
|
||||||
@@ -3001,6 +3319,7 @@ mod tests {
|
|||||||
triggers: vec![],
|
triggers: vec![],
|
||||||
secrets: vec![],
|
secrets: vec![],
|
||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
|
extension_points: Vec::new(),
|
||||||
};
|
};
|
||||||
let plan = compute_diff(¤t, &bundle);
|
let plan = compute_diff(¤t, &bundle);
|
||||||
assert_eq!(plan.routes[0].op, Op::Update);
|
assert_eq!(plan.routes[0].op, Op::Update);
|
||||||
@@ -3013,6 +3332,7 @@ mod tests {
|
|||||||
triggers: vec![],
|
triggers: vec![],
|
||||||
secrets: vec![],
|
secrets: vec![],
|
||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
|
extension_points: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3259,6 +3579,70 @@ mod tests {
|
|||||||
assert_eq!(state_token(&ab), state_token(&ba));
|
assert_eq!(state_token(&ab), state_token(&ba));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diff_extension_points_create_update_noop_delete() {
|
||||||
|
let current = CurrentState {
|
||||||
|
extension_points: vec![
|
||||||
|
("theme".into(), Some("dtheme".into())),
|
||||||
|
("keep".into(), None),
|
||||||
|
("stale".into(), None),
|
||||||
|
],
|
||||||
|
..CurrentState::default()
|
||||||
|
};
|
||||||
|
let bundle = Bundle {
|
||||||
|
extension_points: vec![
|
||||||
|
ExtPointSpec {
|
||||||
|
name: "theme".into(),
|
||||||
|
default: Some("other".into()),
|
||||||
|
}, // default changed → Update
|
||||||
|
ExtPointSpec {
|
||||||
|
name: "Keep".into(),
|
||||||
|
default: None,
|
||||||
|
}, // identical (case-insensitive) → NoOp
|
||||||
|
ExtPointSpec {
|
||||||
|
name: "new".into(),
|
||||||
|
default: None,
|
||||||
|
}, // not live → Create
|
||||||
|
// "stale" is live but undeclared → Delete.
|
||||||
|
],
|
||||||
|
..empty_bundle()
|
||||||
|
};
|
||||||
|
let plan = compute_diff(¤t, &bundle);
|
||||||
|
let op = |k: &str| {
|
||||||
|
plan.extension_points
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.key.eq_ignore_ascii_case(k))
|
||||||
|
.unwrap_or_else(|| panic!("no change for {k}: {plan:?}"))
|
||||||
|
.op
|
||||||
|
};
|
||||||
|
assert_eq!(op("theme"), Op::Update);
|
||||||
|
assert_eq!(op("keep"), Op::NoOp);
|
||||||
|
assert_eq!(op("new"), Op::Create);
|
||||||
|
assert_eq!(op("stale"), Op::Delete);
|
||||||
|
assert!(!plan.is_noop());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_token_is_sensitive_to_extension_point_default() {
|
||||||
|
let base = CurrentState {
|
||||||
|
extension_points: vec![("theme".into(), Some("a".into()))],
|
||||||
|
..CurrentState::default()
|
||||||
|
};
|
||||||
|
// A default change must flip the token (bound-plan check for an
|
||||||
|
// out-of-band ext-point edit between plan and apply).
|
||||||
|
let changed = CurrentState {
|
||||||
|
extension_points: vec![("theme".into(), Some("b".into()))],
|
||||||
|
..CurrentState::default()
|
||||||
|
};
|
||||||
|
assert_ne!(state_token(&base), state_token(&changed));
|
||||||
|
// And adding/removing a declaration moves it too.
|
||||||
|
let added = CurrentState {
|
||||||
|
extension_points: vec![("theme".into(), Some("a".into())), ("extra".into(), None)],
|
||||||
|
..CurrentState::default()
|
||||||
|
};
|
||||||
|
assert_ne!(state_token(&base), state_token(&added));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn state_token_ignores_dead_letter_triggers() {
|
fn state_token_ignores_dead_letter_triggers() {
|
||||||
// The diff ignores dead-letter triggers (not manifest-representable),
|
// The diff ignores dead-letter triggers (not manifest-representable),
|
||||||
|
|||||||
@@ -12,7 +12,9 @@
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner};
|
use picloud_shared::{
|
||||||
|
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptId, ScriptOwner,
|
||||||
|
};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
|
use crate::config_resolver::{CHAIN_LEVELS_CTE, GROUP_CHAIN_LEVELS_CTE};
|
||||||
@@ -96,4 +98,92 @@ impl ModuleSource for PostgresModuleSource {
|
|||||||
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||||
Ok(row.map(Into::into))
|
Ok(row.map(Into::into))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_extension_point(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
|
||||||
|
// Find the nearest extension-point declaration of `name` on `origin`'s
|
||||||
|
// chain that is NOT shadowed by a concrete module of the same name at
|
||||||
|
// depth <= its own (tie → concrete wins). The NOT EXISTS encodes that
|
||||||
|
// shadowing rule; ORDER BY depth ASC LIMIT 1 picks the nearest
|
||||||
|
// surviving declaration. An App origin can see ext points declared on
|
||||||
|
// an ancestor group (or on the app itself); a Group origin only walks
|
||||||
|
// groups — same trust boundary as `resolve`.
|
||||||
|
let (cte, ep_join, mod_join) = match origin {
|
||||||
|
ScriptOwner::App(_) => (
|
||||||
|
CHAIN_LEVELS_CTE,
|
||||||
|
"(e.group_id = c.group_owner OR e.app_id = c.app_owner)",
|
||||||
|
"(s.app_id = c2.app_owner OR s.group_id = c2.group_owner)",
|
||||||
|
),
|
||||||
|
ScriptOwner::Group(_) => (
|
||||||
|
GROUP_CHAIN_LEVELS_CTE,
|
||||||
|
"e.group_id = c.group_owner",
|
||||||
|
"s.group_id = c2.group_owner",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let query = format!(
|
||||||
|
"{cte} \
|
||||||
|
SELECT e.default_script_id \
|
||||||
|
FROM extension_points e \
|
||||||
|
JOIN chain c ON {ep_join} \
|
||||||
|
WHERE LOWER(e.name) = LOWER($2) \
|
||||||
|
AND NOT EXISTS ( \
|
||||||
|
SELECT 1 FROM scripts s \
|
||||||
|
JOIN chain c2 ON {mod_join} \
|
||||||
|
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
|
||||||
|
AND c2.depth <= c.depth \
|
||||||
|
) \
|
||||||
|
ORDER BY c.depth ASC LIMIT 1",
|
||||||
|
);
|
||||||
|
let root = match origin {
|
||||||
|
ScriptOwner::App(a) => a.into_inner(),
|
||||||
|
ScriptOwner::Group(g) => g.into_inner(),
|
||||||
|
};
|
||||||
|
let row: Option<(Option<uuid::Uuid>,)> = sqlx::query_as(&query)
|
||||||
|
.bind(root)
|
||||||
|
.bind(name)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||||
|
Ok(row.map(|(default_script_id,)| ExtPointResolution {
|
||||||
|
default_script_id: default_script_id.map(Into::into),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_by_id(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
// Load a module body by id (the ext-point fallback), but ONLY if it is
|
||||||
|
// owned by a node on `origin`'s chain — the same trust boundary as
|
||||||
|
// `resolve`. This keeps the resolver self-defending: a `default_script_id`
|
||||||
|
// that ever pointed off-chain resolves to None instead of executing
|
||||||
|
// cross-tenant code. Endpoints are never importable (kind filter).
|
||||||
|
let (cte, join) = match origin {
|
||||||
|
ScriptOwner::App(_) => (
|
||||||
|
CHAIN_LEVELS_CTE,
|
||||||
|
"(s.app_id = c.app_owner OR s.group_id = c.group_owner)",
|
||||||
|
),
|
||||||
|
ScriptOwner::Group(_) => (GROUP_CHAIN_LEVELS_CTE, "s.group_id = c.group_owner"),
|
||||||
|
};
|
||||||
|
let root = match origin {
|
||||||
|
ScriptOwner::App(a) => a.into_inner(),
|
||||||
|
ScriptOwner::Group(g) => g.into_inner(),
|
||||||
|
};
|
||||||
|
let row: Option<ModuleRow> = sqlx::query_as(&format!(
|
||||||
|
"{cte} \
|
||||||
|
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
|
||||||
|
FROM scripts s JOIN chain c ON {join} \
|
||||||
|
WHERE s.id = $2 AND s.kind = 'module' LIMIT 1"
|
||||||
|
))
|
||||||
|
.bind(root)
|
||||||
|
.bind(script_id.into_inner())
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
|
||||||
|
Ok(row.map(Into::into))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,6 +198,15 @@ table: execution_logs
|
|||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
source: text NOT NULL default='http'::text
|
source: text NOT NULL default='http'::text
|
||||||
|
|
||||||
|
table: extension_points
|
||||||
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
|
group_id: uuid NULL
|
||||||
|
app_id: uuid NULL
|
||||||
|
name: text NOT NULL
|
||||||
|
default_script_id: uuid NULL
|
||||||
|
created_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
updated_at: timestamp with time zone NOT NULL default=now()
|
||||||
|
|
||||||
table: files
|
table: files
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
collection: text NOT NULL
|
collection: text NOT NULL
|
||||||
@@ -463,6 +472,14 @@ indexes on execution_logs:
|
|||||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||||
|
|
||||||
|
indexes on extension_points:
|
||||||
|
extension_points_app_id_idx: public.extension_points USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||||
|
extension_points_app_uidx: public.extension_points USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||||
|
extension_points_default_script_idx: public.extension_points USING btree (default_script_id) WHERE (default_script_id IS NOT NULL)
|
||||||
|
extension_points_group_id_idx: public.extension_points USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||||
|
extension_points_group_uidx: public.extension_points USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||||
|
extension_points_pkey: public.extension_points USING btree (id)
|
||||||
|
|
||||||
indexes on files:
|
indexes on files:
|
||||||
files_pkey: public.files USING btree (app_id, collection, id)
|
files_pkey: public.files USING btree (app_id, collection, id)
|
||||||
idx_files_app_collection: public.files USING btree (app_id, collection)
|
idx_files_app_collection: public.files USING btree (app_id, collection)
|
||||||
@@ -656,6 +673,13 @@ constraints on execution_logs:
|
|||||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
|
constraints on extension_points:
|
||||||
|
[CHECK] extension_points_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||||
|
[FOREIGN KEY] extension_points_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
|
[FOREIGN KEY] extension_points_default_script_id_fkey: FOREIGN KEY (default_script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||||
|
[FOREIGN KEY] extension_points_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||||
|
[PRIMARY KEY] extension_points_pkey: PRIMARY KEY (id)
|
||||||
|
|
||||||
constraints on files:
|
constraints on files:
|
||||||
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||||
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
|
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
|
||||||
@@ -800,3 +824,4 @@ constraints on vars:
|
|||||||
0048: vars
|
0048: vars
|
||||||
0049: group secrets
|
0049: group secrets
|
||||||
0050: group scripts
|
0050: group scripts
|
||||||
|
0051: extension points
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ impl Client {
|
|||||||
decode(resp).await
|
decode(resp).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
|
||||||
|
/// extension-point declarations (for `pic pull`).
|
||||||
|
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
|
||||||
|
let resp = self
|
||||||
|
.request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
|
||||||
|
)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
decode(resp).await
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
||||||
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
||||||
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
||||||
@@ -1320,6 +1333,8 @@ pub struct PlanDto {
|
|||||||
pub secrets: Vec<ChangeDto>,
|
pub secrets: Vec<ChangeDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vars: Vec<ChangeDto>,
|
pub vars: Vec<ChangeDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points: Vec<ChangeDto>,
|
||||||
/// Fingerprint of the live state this plan was computed against; carried
|
/// Fingerprint of the live state this plan was computed against; carried
|
||||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -1334,6 +1349,14 @@ pub struct ChangeDto {
|
|||||||
pub detail: Option<String>,
|
pub detail: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One of an owner's OWN extension-point declarations (`pic pull`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ExtPointDto {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
|
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
|
||||||
/// bound-plan token for the whole subtree.
|
/// bound-plan token for the whole subtree.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -1358,6 +1381,8 @@ pub struct NodePlanDto {
|
|||||||
pub secrets: Vec<ChangeDto>,
|
pub secrets: Vec<ChangeDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vars: Vec<ChangeDto>,
|
pub vars: Vec<ChangeDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points: Vec<ChangeDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response of `POST .../apply`: counts of what changed.
|
/// Response of `POST .../apply`: counts of what changed.
|
||||||
@@ -1386,6 +1411,12 @@ pub struct ApplyReportDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub vars_deleted: u32,
|
pub vars_deleted: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub extension_points_created: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points_updated: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub extension_points_deleted: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,15 @@ pub async fn run(
|
|||||||
"+{} ~{} -{}",
|
"+{} ~{} -{}",
|
||||||
report.vars_created, report.vars_updated, report.vars_deleted
|
report.vars_created, report.vars_updated, report.vars_deleted
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"extension-points",
|
||||||
|
format!(
|
||||||
|
"+{} ~{} -{}",
|
||||||
|
report.extension_points_created,
|
||||||
|
report.extension_points_updated,
|
||||||
|
report.extension_points_deleted
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for w in &report.warnings {
|
for w in &report.warnings {
|
||||||
block.field("warning", w.clone());
|
block.field("warning", w.clone());
|
||||||
@@ -151,6 +160,15 @@ pub async fn run_tree(
|
|||||||
"+{} ~{} -{}",
|
"+{} ~{} -{}",
|
||||||
report.vars_created, report.vars_updated, report.vars_deleted
|
report.vars_created, report.vars_updated, report.vars_deleted
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"extension-points",
|
||||||
|
format!(
|
||||||
|
"+{} ~{} -{}",
|
||||||
|
report.extension_points_created,
|
||||||
|
report.extension_points_updated,
|
||||||
|
report.extension_points_deleted
|
||||||
|
),
|
||||||
);
|
);
|
||||||
for w in &report.warnings {
|
for w in &report.warnings {
|
||||||
block.field("warning", w.clone());
|
block.field("warning", w.clone());
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
|||||||
triggers: crate::manifest::ManifestTriggers::default(),
|
triggers: crate::manifest::ManifestTriggers::default(),
|
||||||
secrets: crate::manifest::ManifestSecrets::default(),
|
secrets: crate::manifest::ManifestSecrets::default(),
|
||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
|
extension_points: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,12 +64,13 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||||
for n in &plan.nodes {
|
for n in &plan.nodes {
|
||||||
let node = format!("{}:{}", n.kind, n.slug);
|
let node = format!("{}:{}", n.kind, n.slug);
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
||||||
("script", &n.scripts),
|
("script", &n.scripts),
|
||||||
("route", &n.routes),
|
("route", &n.routes),
|
||||||
("trigger", &n.triggers),
|
("trigger", &n.triggers),
|
||||||
("secret", &n.secrets),
|
("secret", &n.secrets),
|
||||||
("var", &n.vars),
|
("var", &n.vars),
|
||||||
|
("extension-point", &n.extension_points),
|
||||||
];
|
];
|
||||||
for (rk, changes) in groups {
|
for (rk, changes) in groups {
|
||||||
for c in changes {
|
for c in changes {
|
||||||
@@ -154,12 +155,28 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extension points: name + optional default module. The server's
|
||||||
|
// `ExtPointSpec` deserializes this shape directly.
|
||||||
|
let extension_points: Vec<Value> = manifest
|
||||||
|
.extension_points
|
||||||
|
.iter()
|
||||||
|
.map(|e| {
|
||||||
|
let mut o = Map::new();
|
||||||
|
o.insert("name".into(), json!(e.name));
|
||||||
|
if let Some(d) = &e.default {
|
||||||
|
o.insert("default".into(), json!(d));
|
||||||
|
}
|
||||||
|
Value::Object(o)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"scripts": scripts,
|
"scripts": scripts,
|
||||||
"routes": routes,
|
"routes": routes,
|
||||||
"triggers": triggers,
|
"triggers": triggers,
|
||||||
"secrets": manifest.secrets.names,
|
"secrets": manifest.secrets.names,
|
||||||
"vars": vars,
|
"vars": vars,
|
||||||
|
"extension_points": extension_points,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,12 +191,13 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
|||||||
|
|
||||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
||||||
("script", &plan.scripts),
|
("script", &plan.scripts),
|
||||||
("route", &plan.routes),
|
("route", &plan.routes),
|
||||||
("trigger", &plan.triggers),
|
("trigger", &plan.triggers),
|
||||||
("secret", &plan.secrets),
|
("secret", &plan.secrets),
|
||||||
("var", &plan.vars),
|
("var", &plan.vars),
|
||||||
|
("extension-point", &plan.extension_points),
|
||||||
];
|
];
|
||||||
for (kind, changes) in groups {
|
for (kind, changes) in groups {
|
||||||
for c in changes {
|
for c in changes {
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ use crate::client::{Client, VarOwnerArg};
|
|||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::manifest::{
|
use crate::manifest::{
|
||||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
|
||||||
QueueTriggerSpec, MANIFEST_FILE,
|
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
|
||||||
};
|
};
|
||||||
use crate::output::{KvBlock, OutputMode};
|
use crate::output::{KvBlock, OutputMode};
|
||||||
|
|
||||||
@@ -210,6 +210,18 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extension points (§5.5): the app's own declarations, exported so a
|
||||||
|
// re-applied pulled manifest doesn't prune them.
|
||||||
|
let extension_points: Vec<ManifestExtensionPoint> = client
|
||||||
|
.extension_points_list(app_ident)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| ManifestExtensionPoint {
|
||||||
|
name: e.name,
|
||||||
|
default: e.default,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
let manifest = Manifest {
|
let manifest = Manifest {
|
||||||
app: Some(ManifestApp {
|
app: Some(ManifestApp {
|
||||||
slug: app.app.slug.clone(),
|
slug: app.app.slug.clone(),
|
||||||
@@ -224,6 +236,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||||
},
|
},
|
||||||
vars: manifest_vars,
|
vars: manifest_vars,
|
||||||
|
extension_points,
|
||||||
};
|
};
|
||||||
|
|
||||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ pub struct Manifest {
|
|||||||
/// The overlay merges per-env, last-write-wins by key.
|
/// The overlay merges per-env, last-write-wins by key.
|
||||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||||
pub vars: BTreeMap<String, toml::Value>,
|
pub vars: BTreeMap<String, toml::Value>,
|
||||||
|
/// `[[extension_points]]` — module names this node opens for per-tenant
|
||||||
|
/// resolution (§5.5). Allowed on both app and group manifests.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub extension_points: Vec<ManifestExtensionPoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Manifest {
|
impl Manifest {
|
||||||
@@ -397,6 +401,17 @@ impl ManifestSecrets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `[[extension_points]]` — a module name opened for per-tenant resolution
|
||||||
|
/// (§5.5). A parent/group script importing `name` resolves it against the
|
||||||
|
/// inheriting app's module instead of the sealed lexical chain; `default`
|
||||||
|
/// names a local module used when an app provides none.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct ManifestExtensionPoint {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub default: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// ---- serde skip/default helpers ----
|
// ---- serde skip/default helpers ----
|
||||||
|
|
||||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||||
@@ -488,6 +503,10 @@ mod tests {
|
|||||||
("region".to_string(), toml::Value::String("eu".into())),
|
("region".to_string(), toml::Value::String("eu".into())),
|
||||||
("max-retries".to_string(), toml::Value::Integer(3)),
|
("max-retries".to_string(), toml::Value::Integer(3)),
|
||||||
]),
|
]),
|
||||||
|
extension_points: vec![ManifestExtensionPoint {
|
||||||
|
name: "theme".into(),
|
||||||
|
default: Some("default-theme".into()),
|
||||||
|
}],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,12 +610,14 @@ mod tests {
|
|||||||
triggers: ManifestTriggers::default(),
|
triggers: ManifestTriggers::default(),
|
||||||
secrets: ManifestSecrets::default(),
|
secrets: ManifestSecrets::default(),
|
||||||
vars: BTreeMap::new(),
|
vars: BTreeMap::new(),
|
||||||
|
extension_points: Vec::new(),
|
||||||
};
|
};
|
||||||
let text = m.to_toml().unwrap();
|
let text = m.to_toml().unwrap();
|
||||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||||
assert!(!text.contains("vars"), "got:\n{text}");
|
assert!(!text.contains("vars"), "got:\n{text}");
|
||||||
|
assert!(!text.contains("extension_points"), "got:\n{text}");
|
||||||
// Still round-trips.
|
// Still round-trips.
|
||||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ mod dead_letters;
|
|||||||
mod email_queue;
|
mod email_queue;
|
||||||
mod enabled;
|
mod enabled;
|
||||||
mod env_overlay;
|
mod env_overlay;
|
||||||
|
mod extension_points;
|
||||||
mod group_modules;
|
mod group_modules;
|
||||||
mod group_scripts;
|
mod group_scripts;
|
||||||
mod group_secrets;
|
mod group_secrets;
|
||||||
|
|||||||
243
crates/picloud-cli/tests/extension_points.rs
Normal file
243
crates/picloud-cli/tests/extension_points.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
|
||||||
|
//! * a GROUP declares a module name `theme` an extension point (with a
|
||||||
|
//! default body) and a group endpoint `render` that imports it,
|
||||||
|
//! * an app under the group provides its OWN `theme`; invoking the inherited
|
||||||
|
//! `render` in that app's context resolves the APP's `theme` (the
|
||||||
|
//! inversion) — NOT sealed to the group,
|
||||||
|
//! * an app that provides no `theme` falls back to the group's default body,
|
||||||
|
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
|
||||||
|
//! no-op, so `--prune` never silently drops it).
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||||
|
|
||||||
|
fn manifest_dir() -> TempDir {
|
||||||
|
let dir = TempDir::new().expect("tempdir");
|
||||||
|
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn extension_point_resolves_per_app_with_default_fallback() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let group = common::unique_slug("ep-grp");
|
||||||
|
let app_a = common::unique_slug("ep-a");
|
||||||
|
let app_b = common::unique_slug("ep-b");
|
||||||
|
|
||||||
|
// GroupGuard first so it drops LAST (after apps + group scripts).
|
||||||
|
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let dir = manifest_dir();
|
||||||
|
|
||||||
|
// --- Group manifest: a default `theme` body, a `render` endpoint that
|
||||||
|
// imports `theme`, and the `[[extension_points]]` declaration. ---
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/defaulttheme.rhai"),
|
||||||
|
r#"fn name() { "default" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/render.rhai"),
|
||||||
|
r#"import "theme" as t; t::name()"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let group_manifest = format!(
|
||||||
|
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
|
||||||
|
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
|
||||||
|
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
|
||||||
|
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
|
||||||
|
);
|
||||||
|
let group_path = dir.path().join("group.toml");
|
||||||
|
fs::write(&group_path, &group_manifest).unwrap();
|
||||||
|
let out = common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(&group_path)
|
||||||
|
.output()
|
||||||
|
.expect("group apply");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"group apply failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
// Group scripts block group deletion (RESTRICT) — guard them.
|
||||||
|
let _gr = ScriptGuard::new(
|
||||||
|
&env.url,
|
||||||
|
&env.token,
|
||||||
|
&group_script_id(&env, &group, "render"),
|
||||||
|
);
|
||||||
|
let _gd = ScriptGuard::new(
|
||||||
|
&env.url,
|
||||||
|
&env.token,
|
||||||
|
&group_script_id(&env, &group, "defaulttheme"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- App A under the group: provides its OWN `theme` + a caller that
|
||||||
|
// invokes the inherited `render`. ---
|
||||||
|
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &app_a, "--group", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/theme-a.rhai"),
|
||||||
|
r#"fn name() { "app-a" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/caller.rhai"),
|
||||||
|
r#"invoke("render", #{})"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let app_a_manifest = format!(
|
||||||
|
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
|
||||||
|
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
|
||||||
|
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
||||||
|
);
|
||||||
|
let app_a_path = dir.path().join("a.toml");
|
||||||
|
fs::write(&app_a_path, &app_a_manifest).unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(&app_a_path)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// The inherited `render` imports the ext point `theme`; in App A's context
|
||||||
|
// it resolves App A's OWN `theme`.
|
||||||
|
let caller_a = app_script_id(&env, &app_a, "caller");
|
||||||
|
assert_eq!(
|
||||||
|
invoke_body(&env, &caller_a),
|
||||||
|
serde_json::json!("app-a"),
|
||||||
|
"extension point must resolve the inheriting app's module"
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- App B under the group: provides NO `theme` → render falls back to the
|
||||||
|
// group's default body. ---
|
||||||
|
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &app_b, "--group", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let app_b_manifest = format!(
|
||||||
|
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
|
||||||
|
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
||||||
|
);
|
||||||
|
let app_b_path = dir.path().join("b.toml");
|
||||||
|
fs::write(&app_b_path, &app_b_manifest).unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(&app_b_path)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let caller_b = app_script_id(&env, &app_b, "caller");
|
||||||
|
assert_eq!(
|
||||||
|
invoke_body(&env, &caller_b),
|
||||||
|
serde_json::json!("default"),
|
||||||
|
"an app providing no module must fall back to the ext point's default"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn extension_point_round_trips_through_pull() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let app = common::unique_slug("ep-pull");
|
||||||
|
|
||||||
|
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &app])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Apply an app manifest that declares an extension point with a default.
|
||||||
|
let dir = manifest_dir();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/theme.rhai"),
|
||||||
|
r#"fn name() { "x" }"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let manifest = format!(
|
||||||
|
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
|
||||||
|
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
|
||||||
|
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
|
||||||
|
);
|
||||||
|
let manifest_path = dir.path().join("picloud.toml");
|
||||||
|
fs::write(&manifest_path, &manifest).unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(&manifest_path)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Pull into a fresh dir, then re-plan: the extension point must round-trip
|
||||||
|
// (no Create/Delete), so `--prune` would never drop it.
|
||||||
|
let pulled = manifest_dir();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["pull", &app, "--dir"])
|
||||||
|
.arg(pulled.path())
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
|
||||||
|
assert!(
|
||||||
|
toml.contains("theme_slot"),
|
||||||
|
"pulled manifest must export the extension point:\n{toml}"
|
||||||
|
);
|
||||||
|
let plan = common::pic_as(&env)
|
||||||
|
.args(["plan", "--file"])
|
||||||
|
.arg(pulled.path().join("picloud.toml"))
|
||||||
|
.output()
|
||||||
|
.expect("plan");
|
||||||
|
let stdout = String::from_utf8_lossy(&plan.stdout);
|
||||||
|
assert!(
|
||||||
|
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
|
||||||
|
"re-planning a pulled manifest must be a no-op:\n{stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Id of the named script in a group (`pic scripts ls --group <g>`).
|
||||||
|
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
|
||||||
|
named_id(env, &["scripts", "ls", "--group", group], name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||||
|
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||||
|
named_id(env, &["scripts", "ls", "--app", app], name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
|
||||||
|
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
|
||||||
|
let table = String::from_utf8(ls.stdout).unwrap();
|
||||||
|
table
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.find(|c| c.get(2) == Some(&name))
|
||||||
|
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||||
|
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||||
|
let out = common::pic_as(env)
|
||||||
|
.args(["scripts", "invoke", id])
|
||||||
|
.output()
|
||||||
|
.expect("scripts invoke");
|
||||||
|
assert!(
|
||||||
|
out.status.success(),
|
||||||
|
"invoke failed: {}",
|
||||||
|
String::from_utf8_lossy(&out.stderr)
|
||||||
|
);
|
||||||
|
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||||
|
}
|
||||||
@@ -76,7 +76,9 @@ pub use inbox::{
|
|||||||
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
||||||
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||||
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
|
pub use modules::{
|
||||||
|
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
|
||||||
|
};
|
||||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||||
pub use pubsub::{
|
pub use pubsub::{
|
||||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ impl ModuleScript {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of an extension-point lookup (§5.5). Present when `name` is
|
||||||
|
/// declared an extension point on `origin`'s chain *and* is not shadowed by
|
||||||
|
/// a nearer concrete module of the same name — i.e. the nearest declaration
|
||||||
|
/// of `name` is the extension point, so resolution inverts to the inheriting
|
||||||
|
/// app. `default_script_id` is the optional fallback module body to use when
|
||||||
|
/// the app provides no module of `name`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExtPointResolution {
|
||||||
|
pub default_script_id: Option<ScriptId>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
|
/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
|
||||||
/// module set up the chain rooted at `origin` (the importing script's
|
/// module set up the chain rooted at `origin` (the importing script's
|
||||||
/// defining node), nearest-owner-wins. The `origin` is trusted
|
/// defining node), nearest-owner-wins. The `origin` is trusted
|
||||||
@@ -80,6 +91,32 @@ pub trait ModuleSource: Send + Sync {
|
|||||||
origin: ScriptOwner,
|
origin: ScriptOwner,
|
||||||
name: &str,
|
name: &str,
|
||||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||||
|
|
||||||
|
/// Extension-point check (§5.5). Returns `Some` iff the **nearest**
|
||||||
|
/// declaration of `name` on `origin`'s chain is an extension point (not
|
||||||
|
/// a concrete module). On a tie in depth a concrete module wins (so a
|
||||||
|
/// nearer/peer real module shadows the extension-point declaration). The
|
||||||
|
/// `origin` is the trusted importer node, so the inversion can only be
|
||||||
|
/// opened by a declaration on the importer's own ancestry — a descendant
|
||||||
|
/// can never make a parent's sealed import dynamic.
|
||||||
|
async fn resolve_extension_point(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
name: &str,
|
||||||
|
) -> Result<Option<ExtPointResolution>, ModuleSourceError>;
|
||||||
|
|
||||||
|
/// Load the extension-point fallback module `script_id`, but ONLY if it is
|
||||||
|
/// a `kind = 'module'` script reachable on `origin`'s chain. The `origin`
|
||||||
|
/// is the trusted importer node; constraining the lookup to its chain makes
|
||||||
|
/// the resolver self-defending — a `default_script_id` that (through a bug
|
||||||
|
/// or DB-direct write) pointed at another tenant's module resolves to
|
||||||
|
/// `None` rather than executing cross-tenant code. Returns `None` if the id
|
||||||
|
/// is absent, names an endpoint, or is off-chain.
|
||||||
|
async fn resolve_by_id(
|
||||||
|
&self,
|
||||||
|
origin: ScriptOwner,
|
||||||
|
script_id: ScriptId,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
|
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
|
||||||
@@ -109,4 +146,20 @@ impl ModuleSource for NoopModuleSource {
|
|||||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_extension_point(
|
||||||
|
&self,
|
||||||
|
_origin: ScriptOwner,
|
||||||
|
_name: &str,
|
||||||
|
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_by_id(
|
||||||
|
&self,
|
||||||
|
_origin: ScriptOwner,
|
||||||
|
_script_id: ScriptId,
|
||||||
|
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user