Compare commits
10 Commits
feat/owner
...
feat/hiera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa2831da90 | ||
|
|
22d6c33d0b | ||
|
|
c04c684a2e | ||
|
|
7c17d6e363 | ||
|
|
4d2eed4e81 | ||
|
|
0396866698 | ||
|
|
193336a8a6 | ||
|
|
c18ce7c2c4 | ||
|
|
dc8c69ac6f | ||
|
|
4dcb8d1136 |
@@ -14,6 +14,14 @@
|
||||
//! (a leaf can't shadow them — the trust boundary), while every SDK call
|
||||
//! *inside* a module still scopes to the inheriting app via `cx.app_id`.
|
||||
//!
|
||||
//! **Extension points (§5.5).** A node may opt a module name OUT of sealing
|
||||
//! by declaring it an extension point: an importer on that node's chain then
|
||||
//! resolves the name against the *inheriting app's* effective view
|
||||
//! (`App(cx.app_id)`), with the declared default body as fallback — so each
|
||||
//! descendant app supplies its own impl. The "is this an extension point"
|
||||
//! check keys on the importer's `origin` (trusted), so only the declaring
|
||||
//! parent can open the inversion; a descendant can't hijack a sealed import.
|
||||
//!
|
||||
//! Three runtime invariants are enforced:
|
||||
//!
|
||||
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
|
||||
@@ -377,8 +385,44 @@ impl ModuleResolver for PicloudModuleResolver {
|
||||
let origin = importer_source
|
||||
.and_then(decode_origin)
|
||||
.unwrap_or(self.default_origin);
|
||||
|
||||
// Extension-point inversion (§5.5): if `path`'s nearest declaration on
|
||||
// the importer's chain is an extension point (not a concrete module),
|
||||
// resolve it against the INHERITING app's effective view instead of the
|
||||
// sealed lexical chain — each descendant app supplies its own impl,
|
||||
// falling back to the declared default body. The decision keys on
|
||||
// `origin` (the trusted importer node), so only a declaration on the
|
||||
// importer's own ancestry can open this; a descendant can never make a
|
||||
// parent's sealed `import` dynamic, and a non-ext-point import never
|
||||
// consults `App(cx.app_id)`. All branches funnel any backend error into
|
||||
// the single redaction arm below.
|
||||
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
|
||||
tokio::task::block_in_place(|| handle.block_on(self.source.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 {
|
||||
Ok(Some(m)) => m,
|
||||
|
||||
@@ -34,6 +34,22 @@ impl ModuleSource for FailingSource {
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_script_id: picloud_shared::ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// `MakeWriter` that appends to a shared buffer.
|
||||
|
||||
@@ -95,6 +95,30 @@ impl ModuleSource for CountingModuleSource {
|
||||
.get(&(app_id, name.to_string()))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
|
||||
// This flat fake has no extension points; the inversion is covered by
|
||||
// `LexicalModuleSource` below and the Postgres journey tests.
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(self
|
||||
.table
|
||||
.lock()
|
||||
.await
|
||||
.values()
|
||||
.find(|m| m.script_id == script_id)
|
||||
.cloned())
|
||||
}
|
||||
}
|
||||
|
||||
fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
|
||||
@@ -623,6 +647,10 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
|
||||
#[derive(Default)]
|
||||
struct LexicalModuleSource {
|
||||
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
|
||||
/// Extension-point declarations, keyed by exact declaring owner →
|
||||
/// optional default module id. Flat (exact-owner) — the chain-walk +
|
||||
/// shadowing tie-break is covered by the Postgres journey tests.
|
||||
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
|
||||
}
|
||||
|
||||
impl LexicalModuleSource {
|
||||
@@ -630,15 +658,16 @@ impl LexicalModuleSource {
|
||||
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 {
|
||||
ScriptOwner::App(a) => (Some(a), None),
|
||||
ScriptOwner::Group(g) => (None, Some(g)),
|
||||
};
|
||||
let script_id = ScriptId::new();
|
||||
self.table.lock().await.insert(
|
||||
(owner, name.to_string()),
|
||||
ModuleScript {
|
||||
script_id: ScriptId::new(),
|
||||
script_id,
|
||||
app_id,
|
||||
group_id,
|
||||
name: name.to_string(),
|
||||
@@ -646,6 +675,21 @@ impl LexicalModuleSource {
|
||||
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()))
|
||||
.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 {
|
||||
@@ -736,3 +809,174 @@ async fn lexical_entry_origin_selects_app_module() {
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!(999));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
|
||||
// a module name an extension point; an importer on the group's chain resolves
|
||||
// it against the INHERITING APP, not the sealed lexical chain.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A group script importing a declared extension point resolves it against the
|
||||
/// executing app's own module — each tenant supplies its own body.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_resolves_to_app_module() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// The group opens "theme" as an extension point (no default).
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
// The app provides its own "theme".
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::App(app),
|
||||
"theme",
|
||||
r#"fn name() { "app-theme" }"#,
|
||||
)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
// Entry runs as the inherited GROUP script (default_origin = group), but
|
||||
// the executing app is `app` (cx.app_id).
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!("app-theme"),
|
||||
"extension point must resolve to the inheriting app's module"
|
||||
);
|
||||
}
|
||||
|
||||
/// When the app provides no module for the extension point, the declared
|
||||
/// default body is used.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_falls_back_to_default() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// The group's default "theme" body, registered as a group module.
|
||||
let default_id = source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"default-theme",
|
||||
r#"fn name() { "default-theme" }"#,
|
||||
)
|
||||
.await;
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
|
||||
.await;
|
||||
// The app provides NO "theme".
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(resp.body, serde_json::json!("default-theme"));
|
||||
}
|
||||
|
||||
/// An extension point with neither an app provider nor a default is a clean
|
||||
/// module-not-found at import time.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_no_provider_no_default_errors() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let err = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect_err("ext point with no provider/default should fail");
|
||||
let msg = format!("{err:?}").to_lowercase();
|
||||
assert!(
|
||||
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
|
||||
"expected module-not-found-flavoured error, got {msg}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
|
||||
/// module of the same name — the inversion only fires for declared extension
|
||||
/// points, so the trust boundary holds.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app = AppId::new();
|
||||
let group = GroupId::new();
|
||||
// "auth" is a concrete group module — NOT an extension point.
|
||||
source
|
||||
.put(
|
||||
ScriptOwner::Group(group),
|
||||
"auth",
|
||||
r#"fn who() { "group-auth" }"#,
|
||||
)
|
||||
.await;
|
||||
// The app has a trap "auth" that must NOT be selected.
|
||||
source
|
||||
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let resp = engine
|
||||
.execute(
|
||||
r#"import "auth" as a; a::who()"#,
|
||||
req_with_owner(app, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("should execute");
|
||||
assert_eq!(
|
||||
resp.body,
|
||||
serde_json::json!("group-auth"),
|
||||
"a non-ext-point import must seal to the group, never the leaf's trap"
|
||||
);
|
||||
}
|
||||
|
||||
/// Two apps inheriting the same extension point get their OWN bodies — the
|
||||
/// id-keyed module cache does not bleed one tenant's body into another.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn ext_point_two_apps_get_distinct_bodies() {
|
||||
let source = LexicalModuleSource::new();
|
||||
let app1 = AppId::new();
|
||||
let app2 = AppId::new();
|
||||
let group = GroupId::new();
|
||||
source
|
||||
.put_ext_point(ScriptOwner::Group(group), "theme", None)
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
|
||||
.await;
|
||||
source
|
||||
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
|
||||
.await;
|
||||
|
||||
let engine = engine_with(source.clone());
|
||||
let r1 = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app1, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("app1 executes");
|
||||
let r2 = engine
|
||||
.execute(
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
req_with_owner(app2, ScriptOwner::Group(group)),
|
||||
)
|
||||
.expect("app2 executes");
|
||||
assert_eq!(r1.body, serde_json::json!("one"));
|
||||
assert_eq!(
|
||||
r2.body,
|
||||
serde_json::json!("two"),
|
||||
"no cross-tenant cache bleed"
|
||||
);
|
||||
}
|
||||
|
||||
49
crates/manager-core/migrations/0051_extension_points.sql
Normal file
49
crates/manager-core/migrations/0051_extension_points.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- Phase 4b+1 (v1.2 Hierarchies): extension points (§5.5).
|
||||
--
|
||||
-- An extension point opts a MODULE NAME into dynamic, per-tenant resolution.
|
||||
-- Normally a group/parent script's `import "x"` resolves sealed-lexically
|
||||
-- against the importer's own defining node — a leaf app cannot shadow it
|
||||
-- (the trust boundary). When a node declares `x` an extension point, an
|
||||
-- importer whose defining node is on that node's chain instead resolves `x`
|
||||
-- against the INHERITING app's effective view, so each descendant app may
|
||||
-- supply its own `x`. Only the declaring (parent) node can open the hole; a
|
||||
-- descendant cannot make one of its parent's sealed imports dynamic, because
|
||||
-- the declaration must live on the importer's own ancestry.
|
||||
--
|
||||
-- Owner-polymorphic exactly like vars (0048): exactly one of group_id/app_id.
|
||||
-- Optional `default_script_id` is the fallback module body used when the
|
||||
-- inheriting app provides no module of `name`.
|
||||
|
||||
CREATE TABLE extension_points (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Polymorphic owner: exactly one FK set (real FKs so ON DELETE CASCADE
|
||||
-- works per owner kind and a dangling owner is impossible).
|
||||
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||
CONSTRAINT extension_points_owner_exactly_one
|
||||
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||
name TEXT NOT NULL,
|
||||
-- Optional fallback module (a module owned by the declaring node). SET
|
||||
-- NULL on delete so deleting the module can never block — the fallback is
|
||||
-- then absent and a non-providing app errors at import time (the next plan
|
||||
-- shows the now-null default as an Update). A valid manifest apply can't
|
||||
-- reach this: validation rejects a kept ext point whose default names a
|
||||
-- module not in the bundle.
|
||||
default_script_id UUID REFERENCES scripts(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- One declaration per (owner, name), case-insensitive to match the module
|
||||
-- name indexes (0050). Partial because the owner is split across two columns.
|
||||
CREATE UNIQUE INDEX extension_points_group_uidx
|
||||
ON extension_points (group_id, LOWER(name)) WHERE group_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX extension_points_app_uidx
|
||||
ON extension_points (app_id, LOWER(name)) WHERE app_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX extension_points_group_id_idx
|
||||
ON extension_points (group_id) WHERE group_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_app_id_idx
|
||||
ON extension_points (app_id) WHERE app_id IS NOT NULL;
|
||||
CREATE INDEX extension_points_default_script_idx
|
||||
ON extension_points (default_script_id) WHERE default_script_id IS NOT NULL;
|
||||
39
crates/manager-core/migrations/0052_owner_project.sql
Normal file
39
crates/manager-core/migrations/0052_owner_project.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Phase 5 / M3 (v1.2 Hierarchies): multi-repo single-owner ownership (§7).
|
||||
--
|
||||
-- A group node is authoritatively MANAGED by at most one project-root (the
|
||||
-- repo whose manifest declares it as something it applies, not merely
|
||||
-- references). `groups.owner_project` has carried this seam since 0047 as an
|
||||
-- inert, FK-less UUID. M3 makes it a real reference: a `projects` table keyed
|
||||
-- by a stable, gitignored project key the CLI mints in `.picloud/`, and a
|
||||
-- foreign key from `groups.owner_project` to it.
|
||||
--
|
||||
-- Ownership is recorded at GROUP granularity; apps inherit their owning project
|
||||
-- from their group (apps are never claimed directly). A NULL `owner_project`
|
||||
-- means the node is UI/API-owned — no manifest fights it (§7.5). First apply
|
||||
-- claims; a second project's apply to an owned node is refused unless
|
||||
-- `--takeover` (group-admin gated). Ownership ⟂ RBAC: owning the manifest does
|
||||
-- NOT grant write — the actor still needs the usual group capabilities (§7.4).
|
||||
--
|
||||
-- ON DELETE SET NULL: deleting a project row (not something the platform does
|
||||
-- today) reverts its nodes to UI-owned rather than cascading away real groups.
|
||||
|
||||
CREATE TABLE projects (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- Stable, opaque key minted by `pic init` and persisted (gitignored) in the
|
||||
-- repo's `.picloud/`. The CLI presents it on every tree apply; the server
|
||||
-- maps it to this row (upsert-by-key), so the same repo keeps the same
|
||||
-- project identity across clones/CI without committing any server id.
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Promote the inert `groups.owner_project` (0047:38) to a real FK now that the
|
||||
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
|
||||
-- no validation burden.
|
||||
ALTER TABLE groups
|
||||
ADD CONSTRAINT groups_owner_project_fkey
|
||||
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
|
||||
|
||||
-- The ownership reconcile reads "which groups does project P own?" (to find
|
||||
-- owned-but-undeclared nodes for structural prune) and "who owns group G?".
|
||||
CREATE INDEX groups_owner_project_idx ON groups (owner_project);
|
||||
49
crates/manager-core/migrations/0053_templates.sql
Normal file
49
crates/manager-core/migrations/0053_templates.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- Phase 5 / M4a (v1.2 Hierarchies): ROUTE templates (§4.5).
|
||||
--
|
||||
-- Triggers and routes are app-scoped (never group-inherited — §5.1). At high
|
||||
-- tenant cardinality that means 100 apps × N routes = 100N hand declarations.
|
||||
-- A TEMPLATE fixes that: declare a route ONCE on a group, and the apply engine
|
||||
-- fans it out into one concrete per-`app_id` row for every descendant app. This
|
||||
-- is INSTANTIATION, not inheritance — expanded rows stay app-owned (the
|
||||
-- isolation boundary is unchanged); the template is just a stamp.
|
||||
--
|
||||
-- Placeholders in template fields are a small, inert, documented set resolved
|
||||
-- per descendant at expansion: `{app_slug}`, `{env}`, `{var:NAME}` (the app's
|
||||
-- effective var). Provenance: each expanded `routes` row carries `from_template`
|
||||
-- (the template id it was stamped from), so re-apply diffs idempotently and
|
||||
-- `--prune` removes expansions WITHOUT touching a hand-declared route of the
|
||||
-- same identity.
|
||||
--
|
||||
-- (Trigger templates reuse this engine in a follow-up: `trigger_templates` +
|
||||
-- `triggers.from_template`.)
|
||||
|
||||
-- Route templates — one per (group, name). Mirrors the `routes` column shape
|
||||
-- minus `app_id` (filled per descendant) plus `script_name` (resolved to the
|
||||
-- nearest inherited endpoint at expansion, like declarative route bindings).
|
||||
CREATE TABLE route_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
-- Explicit identity/upsert key, unique per group (case-insensitive).
|
||||
name TEXT NOT NULL,
|
||||
script_name TEXT NOT NULL,
|
||||
method TEXT,
|
||||
host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')),
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
host_param_name TEXT,
|
||||
path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')),
|
||||
path TEXT NOT NULL,
|
||||
dispatch_mode TEXT NOT NULL DEFAULT 'sync' CHECK (dispatch_mode IN ('sync', 'async')),
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX route_templates_group_name_idx
|
||||
ON route_templates (group_id, LOWER(name));
|
||||
|
||||
-- Provenance on the expanded rows. NULL = hand-declared (the app's own
|
||||
-- manifest); non-NULL = stamped from this template, managed by template
|
||||
-- expansion/prune. No FK: the apply engine owns the expansion lifecycle (it
|
||||
-- deletes orphaned expansions explicitly), and a dangling id after a raw
|
||||
-- template delete is harmless (treated as "template gone → prune the row").
|
||||
ALTER TABLE routes ADD COLUMN from_template UUID;
|
||||
CREATE INDEX routes_from_template_idx ON routes (app_id, from_template);
|
||||
33
crates/manager-core/migrations/0054_trigger_templates.sql
Normal file
33
crates/manager-core/migrations/0054_trigger_templates.sql
Normal file
@@ -0,0 +1,33 @@
|
||||
-- Phase 5 / M4b (v1.2 Hierarchies): TRIGGER templates (§4.5).
|
||||
--
|
||||
-- The trigger half of templates (M4a shipped routes). A `[group]` manifest
|
||||
-- declares `[[trigger_templates.<kind>]]`; the apply fans each one out into a
|
||||
-- concrete per-`app_id` trigger on every descendant app, reusing the same
|
||||
-- expansion engine, placeholder set (`{app_slug}`/`{env}`/`{var:NAME}`), and
|
||||
-- `from_template` provenance as routes.
|
||||
--
|
||||
-- A trigger's parameters vary by kind (collection_glob/ops, schedule/timezone,
|
||||
-- topic_pattern, queue_name, inbound_secret_ref, …), so the template stores the
|
||||
-- whole `BundleTrigger` wire object as `spec` JSONB (kind tag included). The
|
||||
-- expansion resolves placeholders in the spec's string leaves, then rebuilds the
|
||||
-- typed trigger and inserts it through the normal trigger path (email secrets
|
||||
-- are resolved + re-sealed per app, exactly like a hand-declared email trigger).
|
||||
|
||||
CREATE TABLE trigger_templates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
-- Identity/upsert key, unique per group (case-insensitive).
|
||||
name TEXT NOT NULL,
|
||||
-- The full `BundleTrigger` wire object (internally tagged by `kind`), with
|
||||
-- placeholders left unresolved. Rebuilt + resolved per descendant at apply.
|
||||
spec JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
CREATE UNIQUE INDEX trigger_templates_group_name_idx
|
||||
ON trigger_templates (group_id, LOWER(name));
|
||||
|
||||
-- Provenance on expanded triggers (mirrors routes.from_template, 0053). NULL =
|
||||
-- hand-declared; non-NULL = stamped from this template, managed by expansion.
|
||||
ALTER TABLE triggers ADD COLUMN from_template UUID;
|
||||
CREATE INDEX triggers_from_template_idx ON triggers (app_id, from_template);
|
||||
@@ -88,6 +88,7 @@ async fn seed_into(
|
||||
method: None,
|
||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||
enabled: true,
|
||||
from_template: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, GroupId, Principal};
|
||||
@@ -17,8 +17,8 @@ use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, NodeKind, PlanResult,
|
||||
TreeBundle, TreePlanResult,
|
||||
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
|
||||
NodeKind, PlanResult, TreeBundle, TreePlanResult,
|
||||
};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
use crate::group_repo::GroupRepository;
|
||||
@@ -30,11 +30,53 @@ pub fn apply_router(service: ApplyService) -> Router {
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||
.route("/apps/{id}/extension-points", get(app_ext_points_handler))
|
||||
.route(
|
||||
"/groups/{id}/extension-points",
|
||||
get(group_ext_points_handler),
|
||||
)
|
||||
.route("/tree/plan", post(tree_plan_handler))
|
||||
.route("/tree/apply", post(tree_apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
|
||||
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
|
||||
async fn app_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
|
||||
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
|
||||
async fn group_ext_points_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::GroupScriptsRead(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
Ok(Json(
|
||||
svc.list_extension_points(ApplyOwner::Group(group_id))
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
@@ -136,6 +178,16 @@ async fn group_apply_handler(
|
||||
// must hold the relevant read/write caps for EVERY node touched.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreePlanRequest {
|
||||
bundle: TreeBundle,
|
||||
/// Stable project key from the repo's `.picloud/` link state (§7, M3).
|
||||
/// Lets `plan` surface ownership conflicts and prune candidates. Optional
|
||||
/// for legacy callers (then no ownership diagnosis is reported).
|
||||
#[serde(default)]
|
||||
project_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct TreeApplyRequest {
|
||||
bundle: TreeBundle,
|
||||
@@ -143,15 +195,34 @@ struct TreeApplyRequest {
|
||||
prune: bool,
|
||||
#[serde(default)]
|
||||
expected_token: Option<String>,
|
||||
/// Stable project key (§7, M3): the apply claims unclaimed declared groups
|
||||
/// for this project and refuses ones another project owns.
|
||||
#[serde(default)]
|
||||
project_key: Option<String>,
|
||||
/// Take over declared groups owned by another project (group-admin gated).
|
||||
#[serde(default)]
|
||||
allow_takeover: bool,
|
||||
/// Selected environment (§4.5, M4a): the value substituted for the `{env}`
|
||||
/// placeholder when expanding route templates. The CLI passes `--env`.
|
||||
#[serde(default)]
|
||||
env: Option<String>,
|
||||
/// Environments the actor explicitly approved this apply for (§4.2, M5).
|
||||
/// A confirm-required env (per the bundle's `[project]` policy) is refused
|
||||
/// unless it appears here; `--yes` alone does NOT add it.
|
||||
#[serde(default)]
|
||||
approved_envs: Vec<String>,
|
||||
}
|
||||
|
||||
async fn tree_plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(bundle): Json<TreeBundle>,
|
||||
Json(req): Json<TreePlanRequest>,
|
||||
) -> Result<Json<TreePlanResult>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &bundle, None).await?;
|
||||
Ok(Json(svc.plan_tree(&bundle).await?))
|
||||
authz_tree(&svc, &principal, &req.bundle, None, false).await?;
|
||||
Ok(Json(
|
||||
svc.plan_tree(&req.bundle, req.project_key.as_deref())
|
||||
.await?,
|
||||
))
|
||||
}
|
||||
|
||||
async fn tree_apply_handler(
|
||||
@@ -159,13 +230,31 @@ async fn tree_apply_handler(
|
||||
Extension(principal): Extension<Principal>,
|
||||
Json(req): Json<TreeApplyRequest>,
|
||||
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
|
||||
authz_tree(
|
||||
&svc,
|
||||
&principal,
|
||||
&req.bundle,
|
||||
Some(req.prune),
|
||||
req.allow_takeover,
|
||||
)
|
||||
.await?;
|
||||
enforce_env_approval(
|
||||
&svc,
|
||||
&principal,
|
||||
&req.bundle,
|
||||
req.env.as_deref(),
|
||||
&req.approved_envs,
|
||||
)
|
||||
.await?;
|
||||
let report = svc
|
||||
.apply_tree(
|
||||
&req.bundle,
|
||||
req.prune,
|
||||
principal.user_id,
|
||||
&principal,
|
||||
req.expected_token.as_deref(),
|
||||
req.project_key.as_deref(),
|
||||
req.allow_takeover,
|
||||
req.env.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
@@ -174,11 +263,13 @@ async fn tree_apply_handler(
|
||||
/// Per-node capability check for a tree plan/apply. `prune` widens an apply's
|
||||
/// write requirement to every kind. A plan (`prune` ignored, no writes) needs
|
||||
/// only the per-node read cap.
|
||||
#[allow(clippy::too_many_lines)]
|
||||
async fn authz_tree(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
apply_prune: Option<bool>,
|
||||
allow_takeover: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
@@ -188,6 +279,42 @@ async fn authz_tree(
|
||||
Some(prune) => {
|
||||
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Template expansion (§4.5) writes routes/triggers INTO
|
||||
// this app, so the actor needs the matching write cap
|
||||
// when the app's chain carries templates — even if the
|
||||
// app itself declares none. Without this, expanding into
|
||||
// an app a principal can't write would slip the gate.
|
||||
let recv = app_receives_template_expansions(svc, app_id, bundle).await?;
|
||||
if recv.routes {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if recv.triggers {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email-trigger expansion resolves + seals the recipient
|
||||
// app's secret server-side — same `AppSecretsRead` gate a
|
||||
// hand-declared email trigger requires.
|
||||
if recv.email {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
@@ -197,11 +324,109 @@ async fn authz_tree(
|
||||
}
|
||||
}
|
||||
NodeKind::Group => {
|
||||
let group_id = resolve_group_id(svc.groups.as_ref(), &node.slug).await?;
|
||||
let existing = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let Some(g) = existing else {
|
||||
// To-create group (M2): mirror the interactive create gate
|
||||
// (`groups_api::create_group`). A root-level group (no
|
||||
// resolvable parent) needs `InstanceCreateGroup`; a subgroup
|
||||
// under an EXISTING parent needs only `GroupAdmin(parent)`.
|
||||
// A parent that is itself to-create has no id yet → fall
|
||||
// back to `InstanceCreateGroup`.
|
||||
let parent = match &node.parent_slug {
|
||||
Some(p) => svc
|
||||
.groups
|
||||
.get_by_slug(p)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||
None => None,
|
||||
};
|
||||
match parent {
|
||||
Some(pg) => {
|
||||
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(pg.id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::InstanceCreateGroup,
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let group_id = g.id;
|
||||
match apply_prune {
|
||||
Some(prune) => {
|
||||
require_group_node_writes(svc, principal, group_id, &node.bundle, prune)
|
||||
.await?;
|
||||
// Takeover gate (§7.4): seizing a group that another
|
||||
// project owns needs GroupAdmin specifically — a second,
|
||||
// independent gate on top of the write caps. We gate it
|
||||
// for any already-claimed node under `--takeover` (a
|
||||
// superset of genuinely-contested, never laxer); claiming
|
||||
// an unclaimed node, or applying without `--takeover`,
|
||||
// does not require admin. The authoritative owner rewrite
|
||||
// happens in-tx in `apply_tree` (which knows our project).
|
||||
if allow_takeover
|
||||
&& crate::group_repo::get_group_owner(&svc.pool, group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_some()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Reparent: an explicit parent differing from the live
|
||||
// one needs GroupAdmin at the group, the SOURCE parent,
|
||||
// and the DESTINATION parent — mirroring the interactive
|
||||
// `reparent_group` (§5.6 triply-gated).
|
||||
if let Some(parent) = &node.parent_slug {
|
||||
if let Some(pg) = svc
|
||||
.groups
|
||||
.get_by_slug(parent)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
if Some(pg.id) != g.parent_id {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(group_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if let Some(src) = g.parent_id {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(src),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
Capability::GroupAdmin(pg.id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
require(
|
||||
@@ -216,6 +441,71 @@ async fn authz_tree(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE (§4.5, M7): templates also fan out to descendant apps NOT declared in
|
||||
// this bundle (the cross-repo subtree). Those per-recipient write caps are
|
||||
// gated AUTHORITATIVELY IN-TRANSACTION in `apply_tree` Phase B2 — against the
|
||||
// post-reparent app set, each app already locked — so the checked set is by
|
||||
// construction the written set (no pre-tx TOCTOU, and a reparent that moves
|
||||
// apps under a templated group in the same apply can't slip the gate).
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
|
||||
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
|
||||
/// enforcement is convenience, this is authoritative), it must be explicitly
|
||||
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
|
||||
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
|
||||
/// authority on every declared node — the "override a gate" capability (§4.2),
|
||||
/// a deliberate step up from the editor-level write caps an ordinary apply
|
||||
/// needs — and is audited.
|
||||
async fn enforce_env_approval(
|
||||
svc: &ApplyService,
|
||||
principal: &Principal,
|
||||
bundle: &TreeBundle,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<(), ApplyError> {
|
||||
let (Some(policy), Some(env)) = (&bundle.project, env) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !policy.confirm_required(env) {
|
||||
return Ok(());
|
||||
}
|
||||
if !approved_envs.iter().any(|e| e == env) {
|
||||
return Err(ApplyError::ApprovalRequired(env.to_string()));
|
||||
}
|
||||
// Approved: require admin authority on every declared node.
|
||||
for node in &bundle.nodes {
|
||||
match node.kind {
|
||||
NodeKind::App => {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
|
||||
require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
NodeKind::Group => {
|
||||
// A to-create group has no id yet; its creation already required
|
||||
// InstanceCreateGroup / GroupAdmin(parent) in `authz_tree`.
|
||||
if let Some(g) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(g.id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(
|
||||
user_id = ?principal.user_id,
|
||||
env = %env,
|
||||
nodes = bundle.nodes.len(),
|
||||
"apply: approved gated-environment apply (audit)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -232,7 +522,11 @@ async fn require_app_node_writes(
|
||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.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(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
@@ -292,7 +586,14 @@ async fn require_group_node_writes(
|
||||
bundle: &Bundle,
|
||||
prune: bool,
|
||||
) -> Result<(), ApplyError> {
|
||||
if prune || !bundle.scripts.is_empty() {
|
||||
// Extension points and route/trigger templates gate on the script-write
|
||||
// tier (see the app variant) — all are code-binding declarations.
|
||||
if prune
|
||||
|| !bundle.scripts.is_empty()
|
||||
|| !bundle.extension_points.is_empty()
|
||||
|| !bundle.route_templates.is_empty()
|
||||
|| !bundle.trigger_templates.is_empty()
|
||||
{
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
principal,
|
||||
@@ -313,6 +614,95 @@ async fn require_group_node_writes(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What template expansion (§4.5) will write into `app_id` during this tree
|
||||
/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's
|
||||
/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed,
|
||||
/// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` /
|
||||
/// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion
|
||||
/// resolves + seals the recipient app's secret server-side, like a hand-declared
|
||||
/// email trigger).
|
||||
#[derive(Default)]
|
||||
struct Recipient {
|
||||
routes: bool,
|
||||
triggers: bool,
|
||||
email: bool,
|
||||
}
|
||||
|
||||
fn spec_is_email(spec: &serde_json::Value) -> bool {
|
||||
spec.get("kind").and_then(serde_json::Value::as_str) == Some("email")
|
||||
}
|
||||
|
||||
async fn app_receives_template_expansions(
|
||||
svc: &ApplyService,
|
||||
app_id: AppId,
|
||||
bundle: &TreeBundle,
|
||||
) -> Result<Recipient, ApplyError> {
|
||||
let Some(app) = svc
|
||||
.apps
|
||||
.get_by_id(app_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
else {
|
||||
return Ok(Recipient::default());
|
||||
};
|
||||
let ancestors = svc
|
||||
.groups
|
||||
.ancestors(app.group_id)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
|
||||
|
||||
let mut r = Recipient::default();
|
||||
// Committed templates on any ancestor group.
|
||||
for gid in &ancestor_ids {
|
||||
if !r.routes
|
||||
&& !crate::template_repo::list_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.is_empty()
|
||||
{
|
||||
r.routes = true;
|
||||
}
|
||||
for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
r.triggers = true;
|
||||
r.email = r.email || spec_is_email(&tt.spec);
|
||||
}
|
||||
}
|
||||
// Templates newly declared by a group node in this bundle that is an ancestor
|
||||
// of the app (existing groups only — a to-create group has no apps).
|
||||
for node in &bundle.nodes {
|
||||
if node.kind != NodeKind::Group {
|
||||
continue;
|
||||
}
|
||||
let declares_routes = !node.bundle.route_templates.is_empty();
|
||||
let declares_triggers = !node.bundle.trigger_templates.is_empty();
|
||||
if !declares_routes && !declares_triggers {
|
||||
continue;
|
||||
}
|
||||
if let Some(g) = svc
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
if ancestor_ids.contains(&g.id) {
|
||||
r.routes = r.routes || declares_routes;
|
||||
r.triggers = r.triggers || declares_triggers;
|
||||
r.email = r.email
|
||||
|| node
|
||||
.bundle
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.any(|t| spec_is_email(&t.spec));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
||||
async fn resolve_group_id(
|
||||
groups: &dyn GroupRepository,
|
||||
@@ -360,6 +750,9 @@ impl IntoResponse for ApplyError {
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
|
||||
Self::OwnershipConflict(_) | Self::ApprovalRequired(_) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
|
||||
Self::AuthzRepo(e) => {
|
||||
tracing::error!(error = %e, "apply authz repo error");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -226,8 +226,38 @@ pub async fn fetch_var_candidates(
|
||||
pool: &PgPool,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||
let sql = format!(
|
||||
"{CHAIN_LEVELS_CTE} \
|
||||
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Transaction-scoped twin of [`fetch_var_candidates`]: runs the identical
|
||||
/// `CHAIN_LEVELS_CTE` query against an open transaction, so it sees vars
|
||||
/// **written earlier in the same transaction** (not just committed state).
|
||||
/// Used by template expansion so a `{var:NAME}` placeholder resolves against
|
||||
/// a var set in the *same* `pic apply` rather than landing one apply late.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn fetch_var_candidates_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
app_id: AppId,
|
||||
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// The `SELECT … FROM chain …` tail appended after [`CHAIN_LEVELS_CTE`] to
|
||||
/// pull env-eligible `vars` candidates, nearest-first. Shared by the pool and
|
||||
/// transaction variants so the two can't drift.
|
||||
const VAR_CANDIDATES_TAIL: &str = "\
|
||||
SELECT c.depth, \
|
||||
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||
COALESCE(v.app_id, v.group_id) AS owner_id, \
|
||||
@@ -235,14 +265,7 @@ pub async fn fetch_var_candidates(
|
||||
FROM chain c \
|
||||
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
|
||||
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
|
||||
ORDER BY c.depth ASC"
|
||||
);
|
||||
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
ORDER BY c.depth ASC";
|
||||
|
||||
/// The masked, resolved view of one inherited secret for `config/effective`:
|
||||
/// which owner/level/scope supplies it — **never** the value.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
//! future CLI/orchestrator can detect structural drift (§6).
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{Group, GroupId};
|
||||
use picloud_shared::{AppId, Group, GroupId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -191,27 +191,11 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Interactive create: UI/API-owned (no project claim — §7.5).
|
||||
let g = create_group_tx(&mut tx, slug, name, description, parent_id, None).await?;
|
||||
tx.commit().await?;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
async fn rename(
|
||||
@@ -247,21 +231,93 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
// Coarse structural lock: serialize all structural mutations so the
|
||||
// cycle guard + parent write can't interleave with a concurrent
|
||||
// reparent and race into a cycle.
|
||||
// cycle guard + parent write can't race a concurrent reparent.
|
||||
acquire_structural_lock_tx(&mut tx).await?;
|
||||
let g = reparent_group_tx(&mut tx, id, new_parent).await?;
|
||||
tx.commit().await?;
|
||||
Ok(g)
|
||||
}
|
||||
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
delete_group_tx(&mut tx, id).await?;
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Transaction-aware structural mutations (Phase 5+ declarative tree apply).
|
||||
//
|
||||
// The declarative `apply_tree` reconciles a whole subtree in ONE transaction,
|
||||
// so group create/reparent/delete must run inside the caller's tx (not on the
|
||||
// pool) to stay all-or-nothing. These free functions hold the SQL; the trait
|
||||
// methods above delegate to them (begin → fn → commit) so there is one SQL
|
||||
// definition each. The caller takes [`acquire_structural_lock_tx`] ONCE before
|
||||
// the structure phase, so the cycle guard + parent writes serialize against
|
||||
// concurrent reparents exactly as the single-shot `reparent` does.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Take the coarse structural lock inside the caller's transaction. Held until
|
||||
/// the tx commits/rolls back. Call once before any `*_group_tx` mutation.
|
||||
pub(crate) async fn acquire_structural_lock_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||
.bind(GROUP_STRUCTURAL_LOCK_KEY)
|
||||
.execute(&mut *tx)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a group inside the caller's tx. Maps unique/FK violations to a clean
|
||||
/// conflict. (The structural lock is not required for a pure insert, but the
|
||||
/// apply path holds it for the whole phase anyway.)
|
||||
pub(crate) async fn create_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
parent_id: Option<GroupId>,
|
||||
owner_project: Option<Uuid>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
let res = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"INSERT INTO groups (slug, name, description, parent_id, owner_project) \
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.bind(parent_id.map(GroupId::into_inner))
|
||||
.bind(owner_project)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
|
||||
GroupRepositoryError::Conflict("parent group does not exist".into()),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reparent a group inside the caller's tx. Runs the ancestor-walk cycle guard
|
||||
/// against the IN-TX tree (so it sees parent writes made earlier in the same
|
||||
/// apply). The caller must already hold [`acquire_structural_lock_tx`].
|
||||
pub(crate) async fn reparent_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: GroupId,
|
||||
new_parent: Option<GroupId>,
|
||||
) -> Result<Group, GroupRepositoryError> {
|
||||
if let Some(parent) = new_parent {
|
||||
if parent == id {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"a group cannot be its own parent".into(),
|
||||
));
|
||||
}
|
||||
// Cycle guard: walk from the destination up to the root; if we
|
||||
// reach `id`, the move would place `id` beneath itself.
|
||||
let mut cursor = Some(parent);
|
||||
let mut hops = 0u32;
|
||||
while let Some(node) = cursor {
|
||||
@@ -279,11 +335,10 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
let parent_of: Option<(Option<Uuid>,)> =
|
||||
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
|
||||
.bind(node.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
match parent_of {
|
||||
Some((p,)) => cursor = p.map(GroupId::from),
|
||||
// Destination parent doesn't exist.
|
||||
None => {
|
||||
return Err(GroupRepositoryError::Conflict(
|
||||
"destination parent group does not exist".into(),
|
||||
@@ -292,44 +347,45 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let row = sqlx::query_as::<_, GroupRow>(&format!(
|
||||
"UPDATE groups SET \
|
||||
parent_id = $2, \
|
||||
structure_version = structure_version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING {GROUP_COLS}"
|
||||
"UPDATE groups SET parent_id = $2, structure_version = structure_version + 1, \
|
||||
updated_at = NOW() WHERE id = $1 RETURNING {GROUP_COLS}"
|
||||
))
|
||||
.bind(id.into_inner())
|
||||
.bind(new_parent.map(GroupId::into_inner))
|
||||
.fetch_optional(&mut *tx)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Err(GroupRepositoryError::NotFound(id));
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
row.map(Into::into)
|
||||
.ok_or(GroupRepositoryError::NotFound(id))
|
||||
}
|
||||
|
||||
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
|
||||
// Pre-check for a clean message; the FK RESTRICT is the real guard.
|
||||
let counts = self.child_counts(id).await?;
|
||||
if !counts.is_empty() {
|
||||
/// Delete an empty group inside the caller's tx (delete = RESTRICT). Refused
|
||||
/// with a clean conflict if it still has child groups or apps.
|
||||
pub(crate) async fn delete_group_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
id: GroupId,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
let counts: (i64, i64) = sqlx::query_as(
|
||||
"SELECT (SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
|
||||
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
if counts.0 != 0 || counts.1 != 0 {
|
||||
return Err(GroupRepositoryError::Conflict(format!(
|
||||
"group still has {} subgroup(s) and {} app(s); move or delete them first",
|
||||
counts.subgroups, counts.apps
|
||||
counts.0, counts.1
|
||||
)));
|
||||
}
|
||||
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.execute(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// Lost a race with a concurrent child insert.
|
||||
Err(GroupRepositoryError::Conflict(
|
||||
"group still has descendants; move or delete them first".into(),
|
||||
))
|
||||
@@ -337,6 +393,180 @@ impl GroupRepository for PostgresGroupRepository {
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Project ownership (§7, M3). A `projects` row is keyed by the stable, opaque
|
||||
// key the CLI mints in `.picloud/`; `groups.owner_project` references it. These
|
||||
// helpers are free functions (pool + tx variants) so the apply path can claim
|
||||
// ownership inside the single apply transaction (first-commit-wins), while the
|
||||
// read-only plan path can surface conflicts without writing.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Resolve a project key to its id, if the project has ever been persisted.
|
||||
/// `None` means no apply has claimed under this key yet (so `plan` treats every
|
||||
/// node as claimable). Read-only — used by the plan path.
|
||||
pub(crate) async fn get_project_id_by_key(
|
||||
pool: &PgPool,
|
||||
key: &str,
|
||||
) -> Result<Option<Uuid>, GroupRepositoryError> {
|
||||
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM projects WHERE key = $1")
|
||||
.bind(key)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| r.0))
|
||||
}
|
||||
|
||||
/// Upsert the project keyed by `key` inside the apply tx and return its id.
|
||||
/// Idempotent: re-applying the same repo reuses the same project identity.
|
||||
pub(crate) async fn upsert_project_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
key: &str,
|
||||
) -> Result<Uuid, GroupRepositoryError> {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO projects (key) VALUES ($1) \
|
||||
ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key RETURNING id",
|
||||
)
|
||||
.bind(key)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
Ok(row.0)
|
||||
}
|
||||
|
||||
/// The project that owns `group_id`, as `(project_id, project_key)`. `None` when
|
||||
/// the node is UI/API-owned (no claim). Read-only — used by plan + the in-tx
|
||||
/// authoritative re-check. Joins through `projects` so the caller gets the
|
||||
/// human-facing key for a conflict message.
|
||||
pub(crate) async fn get_group_owner(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
|
||||
let row: Option<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT p.id, p.key FROM groups g \
|
||||
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// The same owner lookup, but inside the apply tx — the authoritative read that
|
||||
/// the claim/conflict decision keys on (so a concurrent claim that committed
|
||||
/// after `plan` is seen under this tx's per-node advisory lock).
|
||||
pub(crate) async fn get_group_owner_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
|
||||
let row: Option<(Uuid, String)> = sqlx::query_as(
|
||||
"SELECT p.id, p.key FROM groups g \
|
||||
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Stamp (claim or take over) `group_id`'s owning project inside the apply tx.
|
||||
/// Bumps `structure_version` so an ownership flip trips a bound plan token.
|
||||
pub(crate) async fn set_group_owner_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
project_id: Uuid,
|
||||
) -> Result<(), GroupRepositoryError> {
|
||||
let res = sqlx::query(
|
||||
"UPDATE groups SET owner_project = $2, \
|
||||
structure_version = structure_version + 1, updated_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(project_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(GroupRepositoryError::NotFound(group_id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every group `(id, slug)` owned by `project_id` — the candidate set for
|
||||
/// structural prune (those absent from the manifest are the ones to delete).
|
||||
/// Read-only; the apply path filters and deletes leaf-first under `delete_tx`.
|
||||
pub(crate) async fn list_owned_groups(
|
||||
pool: &PgPool,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<(GroupId, String)>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid, String)> =
|
||||
sqlx::query_as("SELECT id, slug FROM groups WHERE owner_project = $1")
|
||||
.bind(project_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, slug)| (id.into(), slug))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The same owned-group enumeration inside the apply tx, with each node's
|
||||
/// `parent_id` so the prune can delete leaf-first (children before parents).
|
||||
pub(crate) async fn list_owned_groups_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
project_id: Uuid,
|
||||
) -> Result<Vec<(GroupId, String, Option<GroupId>)>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid, String, Option<Uuid>)> =
|
||||
sqlx::query_as("SELECT id, slug, parent_id FROM groups WHERE owner_project = $1")
|
||||
.bind(project_id)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, slug, parent)| (id.into(), slug, parent.map(GroupId::from)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Recursive descendant-app query, the inverse of the ancestor `CHAIN_LEVELS_CTE`
|
||||
/// in `config_resolver`: walk `groups.parent_id` DOWN from `$1` to collect the
|
||||
/// whole subtree of group ids, then every app whose `group_id` falls in it.
|
||||
/// Ordered by `app_id` so callers lock/iterate deterministically. Depth-bounded
|
||||
/// `< 64` as a runaway guard (the cycle guard already forbids real cycles).
|
||||
const DESCENDANT_APPS_SQL: &str = "\
|
||||
WITH RECURSIVE subtree AS ( \
|
||||
SELECT id, 0 AS depth FROM groups WHERE id = $1 \
|
||||
UNION ALL \
|
||||
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id \
|
||||
WHERE s.depth < 64 \
|
||||
) \
|
||||
SELECT a.id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree) ORDER BY a.id";
|
||||
|
||||
/// Every app in the subtree rooted at `group_id` (the group itself and all
|
||||
/// descendant groups), `app_id`-ordered. The read-only pool variant — used by
|
||||
/// `plan` to size a template's true blast radius across the whole DB subtree,
|
||||
/// not just the apps present in the current apply.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates sqlx errors.
|
||||
pub async fn descendant_app_ids(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<AppId>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
|
||||
}
|
||||
|
||||
/// The same subtree enumeration inside the apply tx, so groups/apps created
|
||||
/// earlier in this transaction (M2 structural reconcile) are included when
|
||||
/// fanning a template out to its descendants (§4.5, M7).
|
||||
pub(crate) async fn descendant_app_ids_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<AppId>, GroupRepositoryError> {
|
||||
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -80,6 +80,7 @@ pub mod secrets_api;
|
||||
pub mod secrets_repo;
|
||||
pub mod secrets_service;
|
||||
pub mod ssrf;
|
||||
pub mod template_repo;
|
||||
pub mod topic_repo;
|
||||
pub mod topics_api;
|
||||
pub mod trigger_config;
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner};
|
||||
use picloud_shared::{
|
||||
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptId, ScriptOwner,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
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()))?;
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
dispatch_mode: input.dispatch_mode,
|
||||
// Routes are created active; toggling is a dedicated path.
|
||||
enabled: true,
|
||||
// Hand-declared via the interactive API — not a template expansion.
|
||||
from_template: None,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
|
||||
@@ -23,6 +23,10 @@ pub struct NewRoute {
|
||||
pub dispatch_mode: DispatchMode,
|
||||
/// Three-state lifecycle (§4.3). Create active by default.
|
||||
pub enabled: bool,
|
||||
/// Provenance (§4.5, M4): the route-template id this row was expanded from,
|
||||
/// or `None` for a hand-declared route. Defaults `None` everywhere except
|
||||
/// template expansion.
|
||||
pub from_template: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -175,8 +179,8 @@ pub(crate) async fn insert_route_tx(
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
|
||||
path_kind, path, method, dispatch_mode, enabled, from_template \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
||||
)
|
||||
@@ -190,6 +194,7 @@ pub(crate) async fn insert_route_tx(
|
||||
.bind(input.method.as_deref())
|
||||
.bind(input.dispatch_mode.as_str())
|
||||
.bind(input.enabled)
|
||||
.bind(input.from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await;
|
||||
match res {
|
||||
|
||||
318
crates/manager-core/src/template_repo.rs
Normal file
318
crates/manager-core/src/template_repo.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! CRUD over `route_templates` (§4.5, M4a) — route declarations owned by a
|
||||
//! group that the apply engine fans out into one concrete `routes` row per
|
||||
//! descendant app. Mirrors the tx-accepting free-function pattern of
|
||||
//! `route_repo`/`group_repo`: pool variants for the read/diff path, `_tx`
|
||||
//! variants for the transactional apply (upsert/delete and chain expansion).
|
||||
|
||||
use picloud_shared::{DispatchMode, HostKind, PathKind};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::apply_service::RouteTemplateSpec;
|
||||
use crate::group_repo::GroupRepositoryError as Error;
|
||||
use picloud_shared::GroupId;
|
||||
|
||||
/// A persisted route template, decoded with its enum fields parsed — ready for
|
||||
/// the diff (by name) and for expansion (synthesizing a concrete route).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RouteTemplate {
|
||||
pub id: Uuid,
|
||||
pub group_id: GroupId,
|
||||
pub name: String,
|
||||
pub script_name: String,
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteTemplateRow {
|
||||
id: Uuid,
|
||||
group_id: Uuid,
|
||||
name: String,
|
||||
script_name: String,
|
||||
method: Option<String>,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
host_param_name: Option<String>,
|
||||
path_kind: String,
|
||||
path: String,
|
||||
dispatch_mode: String,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl From<RouteTemplateRow> for RouteTemplate {
|
||||
fn from(r: RouteTemplateRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
group_id: r.group_id.into(),
|
||||
name: r.name,
|
||||
script_name: r.script_name,
|
||||
method: r.method,
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
"wildcard" => HostKind::Wildcard,
|
||||
_ => HostKind::Any,
|
||||
},
|
||||
host: r.host,
|
||||
host_param_name: r.host_param_name,
|
||||
path_kind: match r.path_kind.as_str() {
|
||||
"prefix" => PathKind::Prefix,
|
||||
"param" => PathKind::Param,
|
||||
_ => PathKind::Exact,
|
||||
},
|
||||
path: r.path,
|
||||
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
|
||||
enabled: r.enabled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const COLS: &str = "id, group_id, name, script_name, method, host_kind, host, \
|
||||
host_param_name, path_kind, path, dispatch_mode, enabled";
|
||||
|
||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||
match k {
|
||||
HostKind::Any => "any",
|
||||
HostKind::Strict => "strict",
|
||||
HostKind::Wildcard => "wildcard",
|
||||
}
|
||||
}
|
||||
|
||||
const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
match k {
|
||||
PathKind::Exact => "exact",
|
||||
PathKind::Prefix => "prefix",
|
||||
PathKind::Param => "param",
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's OWN route templates (read/diff path).
|
||||
pub(crate) async fn list_for_group(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<RouteTemplate>, Error> {
|
||||
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
|
||||
"SELECT {COLS} FROM route_templates WHERE group_id = $1 ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Every route template owned by any group in `group_ids` (expansion path,
|
||||
/// in-tx so it sees templates just reconciled earlier in this apply).
|
||||
pub(crate) async fn list_for_groups_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_ids: &[GroupId],
|
||||
) -> Result<Vec<RouteTemplate>, Error> {
|
||||
if group_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
|
||||
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
|
||||
"SELECT {COLS} FROM route_templates WHERE group_id = ANY($1) ORDER BY LOWER(name)"
|
||||
))
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Insert a new route template (diff Op::Create).
|
||||
pub(crate) async fn insert_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
spec: &RouteTemplateSpec,
|
||||
) -> Result<(), Error> {
|
||||
let r = &spec.route;
|
||||
sqlx::query(
|
||||
"INSERT INTO route_templates ( \
|
||||
group_id, name, script_name, method, host_kind, host, \
|
||||
host_param_name, path_kind, path, dispatch_mode, enabled \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(&spec.name)
|
||||
.bind(&r.script)
|
||||
.bind(r.method.as_deref())
|
||||
.bind(host_kind_str(r.host_kind))
|
||||
.bind(&r.host)
|
||||
.bind(r.host_param_name.as_deref())
|
||||
.bind(path_kind_str(r.path_kind))
|
||||
.bind(&r.path)
|
||||
.bind(r.dispatch_mode.as_str())
|
||||
.bind(r.enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace an existing route template's fields, keyed by `(group, lower(name))`
|
||||
/// (diff Op::Update).
|
||||
pub(crate) async fn update_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
spec: &RouteTemplateSpec,
|
||||
) -> Result<(), Error> {
|
||||
let r = &spec.route;
|
||||
sqlx::query(
|
||||
"UPDATE route_templates SET \
|
||||
script_name = $3, method = $4, host_kind = $5, host = $6, \
|
||||
host_param_name = $7, path_kind = $8, path = $9, dispatch_mode = $10, \
|
||||
enabled = $11, updated_at = NOW() \
|
||||
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(&spec.name)
|
||||
.bind(&r.script)
|
||||
.bind(r.method.as_deref())
|
||||
.bind(host_kind_str(r.host_kind))
|
||||
.bind(&r.host)
|
||||
.bind(r.host_param_name.as_deref())
|
||||
.bind(path_kind_str(r.path_kind))
|
||||
.bind(&r.path)
|
||||
.bind(r.dispatch_mode.as_str())
|
||||
.bind(r.enabled)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a route template by name (diff Op::Delete under `--prune`).
|
||||
pub(crate) async fn delete_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("DELETE FROM route_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Trigger templates (§4.5, M4b). The kind-specific params vary, so the whole
|
||||
// `BundleTrigger` wire object is stored as `spec` JSONB (placeholders
|
||||
// unresolved); the apply rebuilds + resolves it per descendant.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// A persisted trigger template: name + the raw (unresolved) `BundleTrigger`
|
||||
/// wire object.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TriggerTemplate {
|
||||
pub id: Uuid,
|
||||
pub group_id: GroupId,
|
||||
pub name: String,
|
||||
pub spec: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct TriggerTemplateRow {
|
||||
id: Uuid,
|
||||
group_id: Uuid,
|
||||
name: String,
|
||||
spec: serde_json::Value,
|
||||
}
|
||||
|
||||
impl From<TriggerTemplateRow> for TriggerTemplate {
|
||||
fn from(r: TriggerTemplateRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
group_id: r.group_id.into(),
|
||||
name: r.name,
|
||||
spec: r.spec,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group's OWN trigger templates (read/diff path).
|
||||
pub(crate) async fn list_triggers_for_group(
|
||||
pool: &PgPool,
|
||||
group_id: GroupId,
|
||||
) -> Result<Vec<TriggerTemplate>, Error> {
|
||||
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
|
||||
"SELECT id, group_id, name, spec FROM trigger_templates \
|
||||
WHERE group_id = $1 ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Every trigger template owned by any group in `group_ids` (expansion path).
|
||||
pub(crate) async fn list_triggers_for_groups_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_ids: &[GroupId],
|
||||
) -> Result<Vec<TriggerTemplate>, Error> {
|
||||
if group_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
|
||||
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
|
||||
"SELECT id, group_id, name, spec FROM trigger_templates \
|
||||
WHERE group_id = ANY($1) ORDER BY LOWER(name)",
|
||||
)
|
||||
.bind(&ids)
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// Insert a new trigger template (diff Op::Create).
|
||||
pub(crate) async fn insert_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
spec: &serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("INSERT INTO trigger_templates (group_id, name, spec) VALUES ($1, $2, $3)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(spec)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a trigger template's spec, keyed by `(group, lower(name))` (Update).
|
||||
pub(crate) async fn update_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
spec: &serde_json::Value,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query(
|
||||
"UPDATE trigger_templates SET spec = $3, updated_at = NOW() \
|
||||
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
|
||||
)
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.bind(spec)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a trigger template by name (Delete under `--prune`).
|
||||
pub(crate) async fn delete_trigger_tx(
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
group_id: GroupId,
|
||||
name: &str,
|
||||
) -> Result<(), Error> {
|
||||
sqlx::query("DELETE FROM trigger_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
|
||||
.bind(group_id.into_inner())
|
||||
.bind(name)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -519,6 +519,7 @@ pub(crate) async fn insert_trigger_tx(
|
||||
retry_backoff: BackoffShape,
|
||||
retry_base_ms: u32,
|
||||
details: &TriggerDetails,
|
||||
from_template: Option<Uuid>,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let kind = match details {
|
||||
TriggerDetails::Kv { .. } => "kv",
|
||||
@@ -562,8 +563,8 @@ pub(crate) async fn insert_trigger_tx(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
|
||||
registered_by_principal, from_template \
|
||||
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
@@ -573,6 +574,7 @@ pub(crate) async fn insert_trigger_tx(
|
||||
.bind(retry_backoff.as_str())
|
||||
.bind(i32::try_from(retry_base_ms).unwrap_or(1000))
|
||||
.bind(registered_by.into_inner())
|
||||
.bind(from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
let tid = row.0;
|
||||
@@ -679,17 +681,19 @@ pub(crate) async fn insert_email_trigger_tx(
|
||||
registered_by: AdminUserId,
|
||||
inbound_secret_encrypted: &[u8],
|
||||
inbound_secret_nonce: &[u8],
|
||||
from_template: Option<Uuid>,
|
||||
) -> Result<TriggerId, TriggerRepoError> {
|
||||
let row: (Uuid,) = sqlx::query_as(
|
||||
"INSERT INTO triggers ( \
|
||||
app_id, script_id, kind, enabled, dispatch_mode, \
|
||||
retry_max_attempts, retry_backoff, retry_base_ms, \
|
||||
registered_by_principal \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
|
||||
registered_by_principal, from_template \
|
||||
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(script_id.into_inner())
|
||||
.bind(registered_by.into_inner())
|
||||
.bind(from_template)
|
||||
.fetch_one(&mut **tx)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
|
||||
@@ -198,6 +198,15 @@ table: execution_logs
|
||||
app_id: uuid NOT NULL
|
||||
source: text NOT NULL default='http'::text
|
||||
|
||||
table: extension_points
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
default_script_id: uuid NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: files
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
@@ -261,6 +270,11 @@ table: outbox
|
||||
claimed_by: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: projects
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
key: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: pubsub_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
topic_pattern: text NOT NULL
|
||||
@@ -284,6 +298,22 @@ table: queue_trigger_details
|
||||
visibility_timeout_secs: integer NOT NULL default=30
|
||||
last_fired_at: timestamp with time zone NULL
|
||||
|
||||
table: route_templates
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
script_name: text NOT NULL
|
||||
method: text NULL
|
||||
host_kind: text NOT NULL
|
||||
host: text NOT NULL default=''::text
|
||||
host_param_name: text NULL
|
||||
path_kind: text NOT NULL
|
||||
path: text NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: routes
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -297,6 +327,7 @@ table: routes
|
||||
app_id: uuid NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
from_template: uuid NULL
|
||||
|
||||
table: script_imports
|
||||
app_id: uuid NOT NULL
|
||||
@@ -339,6 +370,14 @@ table: topics
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: trigger_templates
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NOT NULL
|
||||
name: text NOT NULL
|
||||
spec: jsonb NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: triggers
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
@@ -353,6 +392,7 @@ table: triggers
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
name: text NOT NULL default=(gen_random_uuid())::text
|
||||
from_template: uuid NULL
|
||||
|
||||
table: vars
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -463,6 +503,14 @@ indexes on execution_logs:
|
||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||
|
||||
indexes on extension_points:
|
||||
extension_points_app_id_idx: public.extension_points USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
extension_points_app_uidx: public.extension_points USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||
extension_points_default_script_idx: public.extension_points USING btree (default_script_id) WHERE (default_script_id IS NOT NULL)
|
||||
extension_points_group_id_idx: public.extension_points USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
extension_points_group_uidx: public.extension_points USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||
extension_points_pkey: public.extension_points USING btree (id)
|
||||
|
||||
indexes on files:
|
||||
files_pkey: public.files USING btree (app_id, collection, id)
|
||||
idx_files_app_collection: public.files USING btree (app_id, collection)
|
||||
@@ -475,6 +523,7 @@ indexes on group_members:
|
||||
group_members_user_id_idx: public.group_members USING btree (user_id)
|
||||
|
||||
indexes on groups:
|
||||
groups_owner_project_idx: public.groups USING btree (owner_project)
|
||||
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||
groups_pkey: public.groups USING btree (id)
|
||||
groups_slug_key: public.groups USING btree (slug)
|
||||
@@ -491,6 +540,10 @@ indexes on outbox:
|
||||
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
|
||||
outbox_pkey: public.outbox USING btree (id)
|
||||
|
||||
indexes on projects:
|
||||
projects_key_key: public.projects USING btree (key)
|
||||
projects_pkey: public.projects USING btree (id)
|
||||
|
||||
indexes on pubsub_trigger_details:
|
||||
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
|
||||
|
||||
@@ -504,8 +557,13 @@ indexes on queue_trigger_details:
|
||||
idx_queue_trigger_details_queue_name: public.queue_trigger_details USING btree (queue_name)
|
||||
queue_trigger_details_pkey: public.queue_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on route_templates:
|
||||
route_templates_group_name_idx: public.route_templates USING btree (group_id, lower(name))
|
||||
route_templates_pkey: public.route_templates USING btree (id)
|
||||
|
||||
indexes on routes:
|
||||
routes_app_id_idx: public.routes USING btree (app_id)
|
||||
routes_from_template_idx: public.routes USING btree (app_id, from_template)
|
||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||
routes_pkey: public.routes USING btree (id)
|
||||
routes_script_id_idx: public.routes USING btree (script_id)
|
||||
@@ -533,11 +591,16 @@ indexes on secrets:
|
||||
indexes on topics:
|
||||
topics_pkey: public.topics USING btree (app_id, name)
|
||||
|
||||
indexes on trigger_templates:
|
||||
trigger_templates_group_name_idx: public.trigger_templates USING btree (group_id, lower(name))
|
||||
trigger_templates_pkey: public.trigger_templates USING btree (id)
|
||||
|
||||
indexes on triggers:
|
||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
||||
triggers_from_template_idx: public.triggers USING btree (app_id, from_template)
|
||||
triggers_pkey: public.triggers USING btree (id)
|
||||
|
||||
indexes on vars:
|
||||
@@ -656,6 +719,13 @@ constraints on execution_logs:
|
||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on extension_points:
|
||||
[CHECK] extension_points_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] extension_points_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] extension_points_default_script_id_fkey: FOREIGN KEY (default_script_id) REFERENCES scripts(id) ON DELETE SET NULL
|
||||
[FOREIGN KEY] extension_points_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] extension_points_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on files:
|
||||
[FOREIGN KEY] files_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_pkey: PRIMARY KEY (app_id, collection, id)
|
||||
@@ -671,6 +741,7 @@ constraints on group_members:
|
||||
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
|
||||
|
||||
constraints on groups:
|
||||
[FOREIGN KEY] groups_owner_project_fkey: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
|
||||
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
||||
@@ -688,6 +759,10 @@ constraints on outbox:
|
||||
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on projects:
|
||||
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] projects_key_key: UNIQUE (key)
|
||||
|
||||
constraints on pubsub_trigger_details:
|
||||
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
@@ -700,6 +775,13 @@ constraints on queue_trigger_details:
|
||||
[FOREIGN KEY] queue_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] queue_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on route_templates:
|
||||
[CHECK] route_templates_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] route_templates_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] route_templates_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] route_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] route_templates_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on routes:
|
||||
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
@@ -734,6 +816,10 @@ constraints on topics:
|
||||
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
|
||||
|
||||
constraints on trigger_templates:
|
||||
[FOREIGN KEY] trigger_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] trigger_templates_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on triggers:
|
||||
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
||||
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
|
||||
@@ -800,3 +886,7 @@ constraints on vars:
|
||||
0048: vars
|
||||
0049: group secrets
|
||||
0050: group scripts
|
||||
0051: extension points
|
||||
0052: owner project
|
||||
0053: templates
|
||||
0054: trigger templates
|
||||
|
||||
@@ -34,6 +34,7 @@ toml = "0.8"
|
||||
directories = "5"
|
||||
rpassword = "7"
|
||||
anyhow = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
|
||||
@@ -123,6 +123,19 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
|
||||
/// extension-point declarations (for `pic pull`).
|
||||
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts` — every script the caller can see
|
||||
/// (server filters by membership for `Member`). Lets `pic scripts ls`
|
||||
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
|
||||
@@ -1229,27 +1242,48 @@ impl Client {
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
|
||||
pub async fn plan_tree(&self, bundle: &serde_json::Value) -> Result<TreePlanDto> {
|
||||
/// `project_key` (§7, M3) lets the server report ownership conflicts and
|
||||
/// structural-prune candidates for this repo.
|
||||
pub async fn plan_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
project_key: Option<&str>,
|
||||
) -> Result<TreePlanDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"project_key": project_key,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/plan")
|
||||
.json(bundle)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
|
||||
/// transaction (Phase 5).
|
||||
/// transaction (Phase 5). `project_key`/`allow_takeover` drive ownership
|
||||
/// (§7, M3): the apply claims unclaimed declared groups for this project and
|
||||
/// refuses (or, with `allow_takeover`, seizes) ones another project owns.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn apply_tree(
|
||||
&self,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
expected_token: Option<&str>,
|
||||
project_key: Option<&str>,
|
||||
allow_takeover: bool,
|
||||
env: Option<&str>,
|
||||
approved_envs: &[String],
|
||||
) -> Result<ApplyReportDto> {
|
||||
let body = serde_json::json!({
|
||||
"bundle": bundle,
|
||||
"prune": prune,
|
||||
"expected_token": expected_token,
|
||||
"project_key": project_key,
|
||||
"allow_takeover": allow_takeover,
|
||||
"env": env,
|
||||
"approved_envs": approved_envs,
|
||||
});
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||
@@ -1257,12 +1291,11 @@ impl Client {
|
||||
.send()
|
||||
.await?;
|
||||
if resp.status() == reqwest::StatusCode::CONFLICT {
|
||||
// 409 covers both bound-plan staleness AND an ownership conflict; the
|
||||
// server's message is self-explanatory for each, so surface it raw.
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = parse_error_body(&body).unwrap_or(body);
|
||||
return Err(anyhow!(
|
||||
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
|
||||
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
|
||||
));
|
||||
return Err(anyhow!("{msg}"));
|
||||
}
|
||||
decode(resp).await
|
||||
}
|
||||
@@ -1320,6 +1353,8 @@ pub struct PlanDto {
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
/// Fingerprint of the live state this plan was computed against; carried
|
||||
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
|
||||
#[serde(default)]
|
||||
@@ -1334,6 +1369,14 @@ pub struct ChangeDto {
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// One of an owner's OWN extension-point declarations (`pic pull`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ExtPointDto {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
|
||||
/// bound-plan token for the whole subtree.
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1342,6 +1385,33 @@ pub struct TreePlanDto {
|
||||
pub nodes: Vec<NodePlanDto>,
|
||||
#[serde(default)]
|
||||
pub state_token: String,
|
||||
/// Declared groups owned by another project (§7, M3) — surfaced so CI sees
|
||||
/// the conflict before `apply` refuses it.
|
||||
#[serde(default)]
|
||||
pub conflicts: Vec<OwnershipConflictDto>,
|
||||
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
|
||||
#[serde(default)]
|
||||
pub structural_prunes: Vec<String>,
|
||||
/// Route-template fan-out per declaring group (§4.5, M4a).
|
||||
#[serde(default)]
|
||||
pub template_blast_radius: Vec<TemplateBlastRadiusDto>,
|
||||
/// Environments the project marks confirm-required (§4.2, M5).
|
||||
#[serde(default)]
|
||||
pub approvals_required: Vec<String>,
|
||||
}
|
||||
|
||||
/// A single ownership conflict (`pic plan --dir`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OwnershipConflictDto {
|
||||
pub slug: String,
|
||||
pub owner_key: String,
|
||||
}
|
||||
|
||||
/// One group's route-template blast radius (`pic plan --dir`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct TemplateBlastRadiusDto {
|
||||
pub group: String,
|
||||
pub affected_apps: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -1358,6 +1428,12 @@ pub struct NodePlanDto {
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub vars: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub extension_points: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub route_templates: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub trigger_templates: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
@@ -1386,6 +1462,34 @@ pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub vars_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_created: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_updated: u32,
|
||||
#[serde(default)]
|
||||
pub extension_points_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub groups_created: u32,
|
||||
#[serde(default)]
|
||||
pub groups_reparented: u32,
|
||||
#[serde(default)]
|
||||
pub groups_claimed: u32,
|
||||
#[serde(default)]
|
||||
pub groups_taken_over: u32,
|
||||
#[serde(default)]
|
||||
pub groups_pruned: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_created: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_updated: u32,
|
||||
#[serde(default)]
|
||||
pub route_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_created: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_updated: u32,
|
||||
#[serde(default)]
|
||||
pub trigger_templates_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,26 @@ pub async fn run(
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
||||
|
||||
// Env approval gating (§4.2, M5) is a project-tree (`--dir`) feature: the
|
||||
// admin-gated, audited per-env approval is enforced server-side only on the
|
||||
// tree apply path. Single-node `apply --file` cannot provide that guarantee,
|
||||
// so rather than silently bypass a confirm-required env, refuse and direct
|
||||
// the user to `--dir` (which carries the policy + `--approve` to the server).
|
||||
if let (Some(env), Some(project)) = (env, &manifest.project) {
|
||||
if project
|
||||
.environments
|
||||
.iter()
|
||||
.any(|e| e.name == env && e.confirm)
|
||||
{
|
||||
anyhow::bail!(
|
||||
"environment `{env}` is confirm-required by this project's [project] policy; \
|
||||
single-node `pic apply --file` cannot provide the admin-gated, audited approval. \
|
||||
Use `pic apply --dir <root> --env {env} --approve {env}` instead."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
let kind = if manifest.is_group() {
|
||||
@@ -84,7 +104,26 @@ pub async fn run(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension-points",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.extension_points_created,
|
||||
report.extension_points_updated,
|
||||
report.extension_points_deleted
|
||||
),
|
||||
);
|
||||
// Structural changes only happen on a tree apply; omit the line otherwise.
|
||||
if report.groups_created > 0 || report.groups_reparented > 0 {
|
||||
block.field(
|
||||
"groups",
|
||||
format!(
|
||||
"+{} reparented {}",
|
||||
report.groups_created, report.groups_reparented
|
||||
),
|
||||
);
|
||||
}
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
@@ -94,22 +133,32 @@ pub async fn run(
|
||||
|
||||
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
|
||||
/// `picloud.toml` under `root` — in ONE atomic server transaction.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn run_tree(
|
||||
dir: &Path,
|
||||
prune: bool,
|
||||
yes: bool,
|
||||
force: bool,
|
||||
takeover: bool,
|
||||
env: Option<&str>,
|
||||
approve: &[String],
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let (bundle, node_count) = crate::discover::build_tree(dir, env)?;
|
||||
let (bundle, node_count, envs) = crate::discover::build_tree(dir, env)?;
|
||||
|
||||
if prune && !yes {
|
||||
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
|
||||
}
|
||||
|
||||
// Per-env approval gate (§4.2, M5): if the selected env is confirm-required,
|
||||
// it needs an explicit `--approve <env>` (a TTY may confirm interactively;
|
||||
// `--yes` does NOT cover it). The server re-checks this authoritatively — the
|
||||
// local check just fails fast with a clear message. `approved` is the set
|
||||
// sent on the wire.
|
||||
let approved = resolve_approvals(env, &envs, approve)?;
|
||||
|
||||
let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
|
||||
let expected_token = if force {
|
||||
None
|
||||
@@ -119,8 +168,21 @@ pub async fn run_tree(
|
||||
.map(|l| l.state_token)
|
||||
};
|
||||
|
||||
// This repo's stable project key (§7, M3): the server claims declared
|
||||
// groups for it and refuses ones another project owns (unless --takeover).
|
||||
let project_key = crate::linkstate::ensure_project_key(dir)
|
||||
.context("establishing project identity in .picloud/")?;
|
||||
|
||||
let report = client
|
||||
.apply_tree(&bundle, prune, expected_token.as_deref())
|
||||
.apply_tree(
|
||||
&bundle,
|
||||
prune,
|
||||
expected_token.as_deref(),
|
||||
Some(&project_key),
|
||||
takeover,
|
||||
env,
|
||||
&approved,
|
||||
)
|
||||
.await?;
|
||||
crate::linkstate::clear_plan(dir, token_key);
|
||||
|
||||
@@ -151,7 +213,70 @@ pub async fn run_tree(
|
||||
"+{} ~{} -{}",
|
||||
report.vars_created, report.vars_updated, report.vars_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"extension-points",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.extension_points_created,
|
||||
report.extension_points_updated,
|
||||
report.extension_points_deleted
|
||||
),
|
||||
);
|
||||
// Structural changes only happen on a tree apply; omit the line otherwise.
|
||||
if report.groups_created > 0
|
||||
|| report.groups_reparented > 0
|
||||
|| report.groups_pruned > 0
|
||||
|| report.groups_claimed > 0
|
||||
|| report.groups_taken_over > 0
|
||||
{
|
||||
block.field(
|
||||
"groups",
|
||||
format!(
|
||||
"+{} reparented {} pruned {}",
|
||||
report.groups_created, report.groups_reparented, report.groups_pruned
|
||||
),
|
||||
);
|
||||
if report.groups_claimed > 0 || report.groups_taken_over > 0 {
|
||||
block.field(
|
||||
"ownership",
|
||||
format!(
|
||||
"claimed {} taken-over {}",
|
||||
report.groups_claimed, report.groups_taken_over
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// Route templates (§4.5, M4a) — the template rows; their per-app route
|
||||
// expansions are folded into the `routes` line above.
|
||||
if report.route_templates_created > 0
|
||||
|| report.route_templates_updated > 0
|
||||
|| report.route_templates_deleted > 0
|
||||
{
|
||||
block.field(
|
||||
"route-templates",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.route_templates_created,
|
||||
report.route_templates_updated,
|
||||
report.route_templates_deleted
|
||||
),
|
||||
);
|
||||
}
|
||||
if report.trigger_templates_created > 0
|
||||
|| report.trigger_templates_updated > 0
|
||||
|| report.trigger_templates_deleted > 0
|
||||
{
|
||||
block.field(
|
||||
"trigger-templates",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.trigger_templates_created,
|
||||
report.trigger_templates_updated,
|
||||
report.trigger_templates_deleted
|
||||
),
|
||||
);
|
||||
}
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
@@ -159,6 +284,49 @@ pub async fn run_tree(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the set of environments the actor approves for this apply (§4.2, M5).
|
||||
/// If the selected `env` is confirm-required (per the root manifest's
|
||||
/// `[project]` policy) it must be approved — via `--approve <env>`, or an
|
||||
/// interactive TTY confirmation that requires re-typing the env name. A blanket
|
||||
/// `--yes` does NOT satisfy it. Returns the full approved set to send on the
|
||||
/// wire (the server re-checks authoritatively). A non-gated env passes through.
|
||||
fn resolve_approvals(
|
||||
env: Option<&str>,
|
||||
policy: &[crate::manifest::ManifestEnvironment],
|
||||
approve: &[String],
|
||||
) -> Result<Vec<String>> {
|
||||
let mut approved: Vec<String> = approve.to_vec();
|
||||
let Some(env) = env else {
|
||||
return Ok(approved); // no env selected → nothing env-specific to gate
|
||||
};
|
||||
let gated = policy.iter().any(|e| e.name == env && e.confirm);
|
||||
if !gated || approved.iter().any(|e| e == env) {
|
||||
return Ok(approved);
|
||||
}
|
||||
// Gated and not pre-approved: prompt on a TTY, else refuse (CI must pass
|
||||
// --approve explicitly; --yes is deliberately insufficient).
|
||||
if std::io::stdin().is_terminal() {
|
||||
eprint!(
|
||||
"environment `{env}` requires explicit approval to apply. \
|
||||
Type the environment name to approve, or anything else to abort: "
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut answer = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut answer)
|
||||
.context("read approval")?;
|
||||
if answer.trim() == env {
|
||||
approved.push(env.to_string());
|
||||
return Ok(approved);
|
||||
}
|
||||
anyhow::bail!("aborted: environment `{env}` not approved");
|
||||
}
|
||||
anyhow::bail!(
|
||||
"environment `{env}` requires explicit approval: re-run with `--approve {env}` \
|
||||
(a blanket `--yes` does not cover a confirm-required environment)"
|
||||
);
|
||||
}
|
||||
|
||||
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
|
||||
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
|
||||
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
|
||||
@@ -173,7 +341,8 @@ fn confirm_prune(slug: &str) -> Result<()> {
|
||||
}
|
||||
eprint!(
|
||||
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
|
||||
absent from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
absent from the manifest — and, for a tree apply, entire owned groups absent \
|
||||
from the manifest. This cannot be undone. Continue? [y/N] "
|
||||
);
|
||||
std::io::stderr().flush().ok();
|
||||
let mut answer = String::new();
|
||||
|
||||
@@ -87,6 +87,9 @@ pub fn run(
|
||||
}
|
||||
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
|
||||
ensure_gitignored(dir)?;
|
||||
// Mint the repo's stable project identity (§7, M3) so the first tree apply
|
||||
// claims its groups under a key that survives clones/CI. Gitignored.
|
||||
crate::linkstate::ensure_project_key(dir).context("minting project key in .picloud/")?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
@@ -115,6 +118,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
description: None,
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
scripts: vec![ManifestScript {
|
||||
name: "hello".into(),
|
||||
file: "scripts/hello.rhai".into(),
|
||||
@@ -139,6 +143,9 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
||||
triggers: crate::manifest::ManifestTriggers::default(),
|
||||
secrets: crate::manifest::ManifestSecrets::default(),
|
||||
vars: std::collections::BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,8 +49,11 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
|
||||
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let (bundle, _count) = crate::discover::build_tree(dir, env)?;
|
||||
let plan = client.plan_tree(&bundle).await?;
|
||||
let (bundle, _count, _envs) = crate::discover::build_tree(dir, env)?;
|
||||
// Mint/read this repo's project key so the server can report ownership
|
||||
// (§7, M3). Best-effort: a read-only dir still plans (ownership unreported).
|
||||
let project_key = crate::linkstate::ensure_project_key(dir).ok();
|
||||
let plan = client.plan_tree(&bundle, project_key.as_deref()).await?;
|
||||
if !plan.state_token.is_empty() {
|
||||
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
|
||||
eprintln!("warning: could not record plan state for `pic apply`: {e}");
|
||||
@@ -64,12 +67,15 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||
for n in &plan.nodes {
|
||||
let node = format!("{}:{}", n.kind, n.slug);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 8] = [
|
||||
("script", &n.scripts),
|
||||
("route", &n.routes),
|
||||
("trigger", &n.triggers),
|
||||
("secret", &n.secrets),
|
||||
("var", &n.vars),
|
||||
("extension-point", &n.extension_points),
|
||||
("route-template", &n.route_templates),
|
||||
("trigger-template", &n.trigger_templates),
|
||||
];
|
||||
for (rk, changes) in groups {
|
||||
for c in changes {
|
||||
@@ -84,6 +90,35 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
||||
}
|
||||
}
|
||||
table.print(mode);
|
||||
|
||||
// Ownership diagnostics (§7, M3) — surfaced to stderr in non-JSON output
|
||||
// (JSON consumers read `conflicts`/`structural_prunes` from the payload).
|
||||
if mode != OutputMode::Json {
|
||||
if !plan.conflicts.is_empty() {
|
||||
eprintln!("\nownership conflicts (apply blocked without --takeover):");
|
||||
for c in &plan.conflicts {
|
||||
eprintln!(" - {} is owned by project {}", c.slug, c.owner_key);
|
||||
}
|
||||
}
|
||||
if !plan.structural_prunes.is_empty() {
|
||||
eprintln!("\ngroups absent from the manifest (deleted only with --prune):");
|
||||
for slug in &plan.structural_prunes {
|
||||
eprintln!(" - {slug}");
|
||||
}
|
||||
}
|
||||
if !plan.template_blast_radius.is_empty() {
|
||||
eprintln!("\nroute-template blast radius (every descendant app in the DB subtree):");
|
||||
for b in &plan.template_blast_radius {
|
||||
eprintln!(" - {} → {} app(s)", b.group, b.affected_apps);
|
||||
}
|
||||
}
|
||||
if !plan.approvals_required.is_empty() {
|
||||
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
|
||||
for e in &plan.approvals_required {
|
||||
eprintln!(" - {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||
@@ -154,12 +189,48 @@ 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();
|
||||
|
||||
// Route templates (§4.5, M4a): name + the flattened route fields the
|
||||
// server's `RouteTemplateSpec` (name + `#[serde(flatten)] BundleRoute`)
|
||||
// deserializes. Group manifests only; the server rejects them on an app.
|
||||
let route_templates = manifest
|
||||
.route_templates
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
|
||||
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
|
||||
// `{ name, <BundleTrigger> }`. Group manifests only.
|
||||
let trigger_templates = manifest
|
||||
.trigger_templates
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
"vars": vars,
|
||||
"extension_points": extension_points,
|
||||
"route_templates": route_templates,
|
||||
"trigger_templates": trigger_templates,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -174,12 +245,13 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 5] = [
|
||||
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
("var", &plan.vars),
|
||||
("extension-point", &plan.extension_points),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
||||
QueueTriggerSpec, MANIFEST_FILE,
|
||||
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
|
||||
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
// Extension points (§5.5): the app's own declarations, exported so a
|
||||
// re-applied pulled manifest doesn't prune them.
|
||||
let extension_points: Vec<ManifestExtensionPoint> = client
|
||||
.extension_points_list(app_ident)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|e| ManifestExtensionPoint {
|
||||
name: e.name,
|
||||
default: e.default,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let manifest = Manifest {
|
||||
app: Some(ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
@@ -217,6 +229,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
description: app.app.description.clone(),
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
@@ -224,6 +237,10 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
vars: manifest_vars,
|
||||
extension_points,
|
||||
// `pull` is app-scoped; templates are group-owned (§4.5).
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! the server resolves ancestry from its own group tree, so the on-disk
|
||||
//! nesting is organizational, not authoritative here.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
@@ -45,10 +45,15 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
|
||||
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree
|
||||
/// that names the same (kind, slug) twice.
|
||||
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
||||
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}], project }`)
|
||||
/// from every manifest under `root`. Returns the JSON, the node count, and the
|
||||
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
|
||||
/// tree that names the same (kind, slug) twice, or declares `[project]` anywhere
|
||||
/// but the root manifest.
|
||||
pub fn build_tree(
|
||||
root: &Path,
|
||||
env: Option<&str>,
|
||||
) -> Result<(Value, usize, Vec<crate::manifest::ManifestEnvironment>)> {
|
||||
let paths = find_manifests(root)?;
|
||||
if paths.is_empty() {
|
||||
bail!(
|
||||
@@ -56,20 +61,91 @@ pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
let mut nodes = Vec::with_capacity(paths.len());
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
let root_manifest = root.join(MANIFEST_FILE);
|
||||
// First pass: load every manifest and index its DIRECTORY → (slug, is_group)
|
||||
// so a group node's parent can be inferred from the enclosing directory.
|
||||
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
|
||||
let mut group_dirs: HashMap<PathBuf, String> = HashMap::new();
|
||||
let mut project: Option<crate::manifest::ManifestProject> = None;
|
||||
for path in &paths {
|
||||
let manifest = Manifest::load_with_env(path, env)
|
||||
.with_context(|| format!("loading {}", path.display()))?;
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
// `[project]` is project-level — valid only on the tree's root manifest.
|
||||
if let Some(p) = &manifest.project {
|
||||
if path == &root_manifest {
|
||||
project = Some(p.clone());
|
||||
} else {
|
||||
bail!(
|
||||
"{} declares [project], which is only valid on the root manifest ({})",
|
||||
path.display(),
|
||||
root_manifest.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
let dir = path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_path_buf();
|
||||
if manifest.is_group() {
|
||||
group_dirs.insert(dir.clone(), manifest.slug().to_string());
|
||||
}
|
||||
loaded.push((dir, manifest));
|
||||
}
|
||||
|
||||
let mut nodes = Vec::with_capacity(loaded.len());
|
||||
let mut seen: HashSet<(String, String)> = HashSet::new();
|
||||
for (dir, manifest) in &loaded {
|
||||
let bundle = build_bundle(manifest, dir)?;
|
||||
let kind = if manifest.is_group() { "group" } else { "app" };
|
||||
let slug = manifest.slug().to_string();
|
||||
if !seen.insert((kind.to_string(), slug.clone())) {
|
||||
bail!("project tree declares {kind} `{slug}` more than once");
|
||||
}
|
||||
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
|
||||
let mut node = json!({ "kind": kind, "slug": slug, "bundle": bundle });
|
||||
// Group nodes carry their parent (explicit `[group] parent`, else the
|
||||
// nearest ancestor directory's group) + display name, so the server can
|
||||
// create/reparent them (M2). App nodes ignore both server-side.
|
||||
if let Some(g) = &manifest.group {
|
||||
let parent = g
|
||||
.parent
|
||||
.clone()
|
||||
.or_else(|| nearest_ancestor_group(dir, &group_dirs));
|
||||
let obj = node.as_object_mut().expect("json object");
|
||||
obj.insert("name".into(), json!(g.name));
|
||||
if let Some(p) = parent {
|
||||
obj.insert("parent_slug".into(), json!(p));
|
||||
}
|
||||
}
|
||||
nodes.push(node);
|
||||
}
|
||||
let count = nodes.len();
|
||||
Ok((json!({ "nodes": nodes }), count))
|
||||
let envs = project
|
||||
.as_ref()
|
||||
.map(|p| p.environments.clone())
|
||||
.unwrap_or_default();
|
||||
let mut bundle = json!({ "nodes": nodes });
|
||||
if let Some(p) = &project {
|
||||
bundle.as_object_mut().expect("json object").insert(
|
||||
"project".into(),
|
||||
json!({
|
||||
"environments": p.environments.iter()
|
||||
.map(|e| json!({ "name": e.name, "confirm": e.confirm }))
|
||||
.collect::<Vec<_>>(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok((bundle, count, envs))
|
||||
}
|
||||
|
||||
/// The slug of the nearest ancestor directory (above `dir`) that holds a group
|
||||
/// manifest — the directory-derived parent for a nested group node.
|
||||
fn nearest_ancestor_group(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
|
||||
let mut cur = dir.parent();
|
||||
while let Some(d) = cur {
|
||||
if let Some(slug) = group_dirs.get(d) {
|
||||
return Some(slug.clone());
|
||||
}
|
||||
cur = d.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -13,6 +13,59 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
const DIR: &str = ".picloud";
|
||||
const PLAN_FILE: &str = "plan.json";
|
||||
const PROJECT_FILE: &str = "project.json";
|
||||
|
||||
/// The repo's stable project identity (§7, M3): a random, opaque key minted on
|
||||
/// first need and persisted (gitignored) in `.picloud/`. It is the server-side
|
||||
/// ownership handle — the same repo keeps the same project across clones/CI
|
||||
/// because the key travels in the working tree, never in a committed file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectLink {
|
||||
pub key: String,
|
||||
}
|
||||
|
||||
fn project_path(base: &Path) -> PathBuf {
|
||||
base.join(DIR).join(PROJECT_FILE)
|
||||
}
|
||||
|
||||
/// Read the project key, if one has been minted under `base/.picloud/`.
|
||||
#[must_use]
|
||||
pub fn read_project_key(base: &Path) -> Option<String> {
|
||||
let body = fs::read(project_path(base)).ok()?;
|
||||
serde_json::from_slice::<ProjectLink>(&body)
|
||||
.ok()
|
||||
.map(|l| l.key)
|
||||
}
|
||||
|
||||
/// Read the project key, minting and persisting a fresh one if absent. Used by
|
||||
/// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets
|
||||
/// a stable ownership identity on first use. The new key is a random v4 UUID.
|
||||
pub fn ensure_project_key(base: &Path) -> Result<String> {
|
||||
if let Some(k) = read_project_key(base) {
|
||||
return Ok(k);
|
||||
}
|
||||
let key = uuid::Uuid::new_v4().to_string();
|
||||
write_project_key(base, &key)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Persist `key` as the project identity, creating `.picloud/` (self-ignored)
|
||||
/// if needed. Idempotent overwrite — callers usually go through
|
||||
/// [`ensure_project_key`].
|
||||
pub fn write_project_key(base: &Path, key: &str) -> Result<()> {
|
||||
let dir = base.join(DIR);
|
||||
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
|
||||
let ignore = dir.join(".gitignore");
|
||||
if !ignore.exists() {
|
||||
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
|
||||
}
|
||||
let body = serde_json::to_vec_pretty(&ProjectLink {
|
||||
key: key.to_string(),
|
||||
})
|
||||
.context("encoding .picloud/project.json")?;
|
||||
fs::write(project_path(base), body).context("writing .picloud/project.json")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The recorded result of the last `pic plan`, scoped to the app it was for.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -220,10 +220,19 @@ struct ApplyArgs {
|
||||
/// since the last `pic plan`).
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
/// Tree apply only (`--dir`): seize declared groups currently owned by
|
||||
/// another project (§7). Requires group-admin on each contested node.
|
||||
#[arg(long, requires = "dir")]
|
||||
takeover: bool,
|
||||
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
|
||||
/// top of the base manifest before applying.
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// Tree apply only (`--dir`): explicitly approve applying to a
|
||||
/// confirm-required environment (per the root manifest's `[project]`
|
||||
/// policy, §4.2). Repeatable. A blanket `--yes` does NOT cover it.
|
||||
#[arg(long = "approve", requires = "dir")]
|
||||
approve: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
@@ -1346,7 +1355,9 @@ async fn main() -> ExitCode {
|
||||
args.prune,
|
||||
args.yes,
|
||||
args.force,
|
||||
args.takeover,
|
||||
args.env.as_deref(),
|
||||
&args.approve,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct Manifest {
|
||||
pub app: Option<ManifestApp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub group: Option<ManifestGroup>,
|
||||
/// `[project]` — project-level policy (M5), consumed only by `apply --dir`
|
||||
/// and only on the tree's ROOT manifest (enforced in `build_tree`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project: Option<ManifestProject>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -49,6 +53,21 @@ pub struct Manifest {
|
||||
/// The overlay merges per-env, last-write-wins by key.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub vars: BTreeMap<String, toml::Value>,
|
||||
/// `[[extension_points]]` — module names this node opens for per-tenant
|
||||
/// resolution (§5.5). Allowed on both app and group manifests.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub extension_points: Vec<ManifestExtensionPoint>,
|
||||
/// `[[route_templates]]` — GROUP-only route declarations that fan out into a
|
||||
/// concrete route on every descendant app at apply time (§4.5, M4a). String
|
||||
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub route_templates: Vec<ManifestRouteTemplate>,
|
||||
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
|
||||
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
|
||||
/// `script = …`, plus the kind's params (string fields may carry
|
||||
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub trigger_templates: Vec<ManifestTriggerTemplate>,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -205,16 +224,40 @@ pub struct ManifestApp {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
|
||||
/// and `[vars]`. The group must already exist on the server (created with
|
||||
/// `pic groups create`); the manifest reconciles its content, not the tree
|
||||
/// shape. In a nested project the parent is inferred from the directory tree.
|
||||
/// A `[group]` node (Phase 5/M2): a group's own declarative content — its
|
||||
/// scripts and `[vars]`. With M2, `pic apply --dir` also creates the group if
|
||||
/// it doesn't exist and reparents it under `parent`. When `parent` is omitted,
|
||||
/// the parent is inferred from the enclosing directory's group manifest (or the
|
||||
/// instance root for a top-level group).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestGroup {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Explicit parent group slug (M2). Overrides the directory-derived parent.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
/// `[project]` — project-level policy (§4.2/§6, M5). Valid ONLY on the root
|
||||
/// manifest of a `pic apply --dir` tree; rejected elsewhere by [`build_tree`].
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestProject {
|
||||
/// `[[project.environments]]` — per-env apply policy.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub environments: Vec<ManifestEnvironment>,
|
||||
}
|
||||
|
||||
/// One `[[project.environments]]` entry. `confirm = true` gates applying to this
|
||||
/// env behind an explicit `pic apply --approve <name>` (M5).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ManifestEnvironment {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub confirm: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@@ -267,6 +310,43 @@ pub struct ManifestRoute {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `[[route_templates]]` — a route declared once on a group, fanned out per
|
||||
/// descendant app (§4.5, M4a). Same shape as [`ManifestRoute`] plus a `name`
|
||||
/// (the per-group identity/upsert key).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestRouteTemplate {
|
||||
/// Per-group template name (identity/upsert key).
|
||||
pub name: String,
|
||||
pub script: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub host: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "is_sync")]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
#[serde(
|
||||
default = "picloud_shared::default_true",
|
||||
skip_serializing_if = "is_true"
|
||||
)]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
|
||||
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
|
||||
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
|
||||
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggerTemplate {
|
||||
pub name: String,
|
||||
#[serde(flatten)]
|
||||
pub spec: toml::Value,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggers {
|
||||
@@ -397,6 +477,17 @@ impl ManifestSecrets {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[[extension_points]]` — a module name opened for per-tenant resolution
|
||||
/// (§5.5). A parent/group script importing `name` resolves it against the
|
||||
/// inheriting app's module instead of the sealed lexical chain; `default`
|
||||
/// names a local module used when an app provides none.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestExtensionPoint {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default: Option<String>,
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
|
||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||
@@ -428,6 +519,7 @@ mod tests {
|
||||
description: Some("demo".into()),
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
scripts: vec![
|
||||
ManifestScript {
|
||||
name: "create-post".into(),
|
||||
@@ -488,6 +580,12 @@ mod tests {
|
||||
("region".to_string(), toml::Value::String("eu".into())),
|
||||
("max-retries".to_string(), toml::Value::Integer(3)),
|
||||
]),
|
||||
extension_points: vec![ManifestExtensionPoint {
|
||||
name: "theme".into(),
|
||||
default: Some("default-theme".into()),
|
||||
}],
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -586,17 +684,22 @@ mod tests {
|
||||
description: None,
|
||||
}),
|
||||
group: None,
|
||||
project: None,
|
||||
scripts: vec![],
|
||||
routes: vec![],
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
vars: BTreeMap::new(),
|
||||
extension_points: Vec::new(),
|
||||
route_templates: Vec::new(),
|
||||
trigger_templates: Vec::new(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||
assert!(!text.contains("vars"), "got:\n{text}");
|
||||
assert!(!text.contains("extension_points"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
@@ -628,6 +731,23 @@ mod tests {
|
||||
.expect_err("both [app] and [group]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_block_parses_environment_policy() {
|
||||
let m = Manifest::parse(
|
||||
"[app]\nslug = \"a\"\nname = \"A\"\n\n\
|
||||
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
|
||||
[[project.environments]]\nname = \"staging\"\n",
|
||||
)
|
||||
.expect("manifest with [project] parses");
|
||||
let p = m.project.expect("project present");
|
||||
assert_eq!(p.environments.len(), 2);
|
||||
assert_eq!(p.environments[0].name, "production");
|
||||
assert!(p.environments[0].confirm);
|
||||
// `confirm` defaults to false when omitted.
|
||||
assert_eq!(p.environments[1].name, "staging");
|
||||
assert!(!p.environments[1].confirm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vars_override_base_per_key() {
|
||||
let mut base = sample();
|
||||
|
||||
169
crates/picloud-cli/tests/approval.rs
Normal file
169
crates/picloud-cli/tests/approval.rs
Normal file
@@ -0,0 +1,169 @@
|
||||
//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`:
|
||||
//! * a root manifest `[project]` block marks an environment confirm-required,
|
||||
//! * applying to that env WITHOUT `--approve` is refused (a blanket `--yes`
|
||||
//! does not cover it) — refused non-interactively at the CLI,
|
||||
//! * `--approve <env>` (as an admin) lets it through,
|
||||
//! * a non-gated environment applies with plain `--yes`,
|
||||
//! * approving a gated apply needs ADMIN authority on the node — a non-admin
|
||||
//! editor with `--approve` is refused server-side (403).
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard};
|
||||
use crate::common::member;
|
||||
|
||||
/// A single-app project dir whose root manifest declares a `[project]` policy:
|
||||
/// `production` is confirm-required, `staging` is not.
|
||||
fn project_dir(app: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
// A `[vars]` entry so the app bundle has write-requiring content: an `editor`
|
||||
// member must hold (and exercise) AppVarsWrite to pass authz_tree — which
|
||||
// makes the admin-gate test prove the approval gate is ABOVE editor-write,
|
||||
// not merely "any non-admin is refused". (Vars cascade-delete with the app,
|
||||
// unlike scripts which are ON DELETE RESTRICT, so AppGuard teardown is clean.)
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\
|
||||
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
|
||||
[[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\
|
||||
[vars]\nregion = \"eu\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
// `--env <e>` requires the overlay file to exist; empty overlays keep the
|
||||
// base slug (same app across envs — we're testing the gate, not env routing).
|
||||
fs::write(dir.path().join("picloud.production.toml"), "").unwrap();
|
||||
fs::write(dir.path().join("picloud.staging.toml"), "").unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
|
||||
cmd.output().expect("apply --dir")
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn confirm_required_env_needs_explicit_approval() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("appr-g");
|
||||
let app = common::unique_slug("appr-a");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = project_dir(&app);
|
||||
|
||||
// --- production is confirm-required: --yes alone is refused. ---
|
||||
let out = apply(&env, dir.path(), &["--env", "production", "--yes"]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"production apply without --approve must be refused"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("approve"),
|
||||
"refusal should mention --approve:\n{err}"
|
||||
);
|
||||
|
||||
// --- with --approve production, it applies. ---
|
||||
let ok = apply(
|
||||
&env,
|
||||
dir.path(),
|
||||
&["--env", "production", "--approve", "production"],
|
||||
);
|
||||
assert!(
|
||||
ok.status.success(),
|
||||
"approved production apply should succeed: {}",
|
||||
String::from_utf8_lossy(&ok.stderr)
|
||||
);
|
||||
|
||||
// --- staging is NOT gated: plain --yes applies. ---
|
||||
let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]);
|
||||
assert!(
|
||||
ok2.status.success(),
|
||||
"non-gated staging apply should succeed: {}",
|
||||
String::from_utf8_lossy(&ok2.stderr)
|
||||
);
|
||||
|
||||
// --- single-node `apply --file` to a gated env is refused (no silent
|
||||
// bypass): the admin-gated approval is a `--dir` feature. ---
|
||||
let single = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(dir.path().join("picloud.toml"))
|
||||
.args(["--env", "production", "--yes"])
|
||||
.output()
|
||||
.expect("apply --file");
|
||||
assert!(
|
||||
!single.status.success(),
|
||||
"single-node apply to a confirm-required env must be refused"
|
||||
);
|
||||
let serr = String::from_utf8_lossy(&single.stderr).to_lowercase();
|
||||
assert!(
|
||||
serr.contains("confirm-required") || serr.contains("--dir"),
|
||||
"single-node refusal should point at --dir:\n{serr}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn approving_a_gated_apply_requires_admin() {
|
||||
// §4.2: approving a confirm-required env is admin-gated — a second gate on
|
||||
// top of the editor-level write caps an ordinary apply needs. An editor who
|
||||
// CAN write the app still cannot approve a gated apply.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("appr2-g");
|
||||
let app = common::unique_slug("appr2-a");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// A member with `editor` (write) on the app — enough for an ordinary apply,
|
||||
// not enough to approve a gated environment.
|
||||
let m = member::member_user(fx, &common::unique_username("appr"));
|
||||
member::grant_membership(fx, &app, &m.id, "editor");
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
|
||||
let dir = project_dir(&app);
|
||||
let out = apply(
|
||||
&member_env,
|
||||
dir.path(),
|
||||
&["--env", "production", "--approve", "production"],
|
||||
);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"a non-admin editor must not be able to approve a gated apply"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("forbidden") || err.contains("403"),
|
||||
"approval denial should be an authz error:\n{err}"
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ mod common;
|
||||
mod admins;
|
||||
mod api_keys;
|
||||
mod apply;
|
||||
mod approval;
|
||||
mod apps;
|
||||
mod auth;
|
||||
mod config;
|
||||
@@ -23,6 +24,7 @@ mod dead_letters;
|
||||
mod email_queue;
|
||||
mod enabled;
|
||||
mod env_overlay;
|
||||
mod extension_points;
|
||||
mod group_modules;
|
||||
mod group_scripts;
|
||||
mod group_secrets;
|
||||
@@ -31,6 +33,7 @@ mod init;
|
||||
mod invoke;
|
||||
mod logs;
|
||||
mod output;
|
||||
mod ownership;
|
||||
mod plan;
|
||||
mod prune;
|
||||
mod pull;
|
||||
@@ -39,6 +42,8 @@ mod routes;
|
||||
mod scripts;
|
||||
mod secrets;
|
||||
mod staleness;
|
||||
mod templates;
|
||||
mod tree;
|
||||
mod tree_shape;
|
||||
mod triggers;
|
||||
mod vars;
|
||||
|
||||
@@ -78,6 +78,26 @@ pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str)
|
||||
);
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` on a group.
|
||||
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/api/v1/admin/groups/{}/members",
|
||||
fx.url, group_slug
|
||||
))
|
||||
.bearer_auth(&fx.admin_token)
|
||||
.json(&json!({ "user_id": user_id, "role": role }))
|
||||
.send()
|
||||
.expect("grant group membership");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"grant group membership failed: {} {}",
|
||||
resp.status(),
|
||||
resp.text().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
|
||||
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
|
||||
243
crates/picloud-cli/tests/extension_points.rs
Normal file
243
crates/picloud-cli/tests/extension_points.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
|
||||
//! * a GROUP declares a module name `theme` an extension point (with a
|
||||
//! default body) and a group endpoint `render` that imports it,
|
||||
//! * an app under the group provides its OWN `theme`; invoking the inherited
|
||||
//! `render` in that app's context resolves the APP's `theme` (the
|
||||
//! inversion) — NOT sealed to the group,
|
||||
//! * an app that provides no `theme` falls back to the group's default body,
|
||||
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
|
||||
//! no-op, so `--prune` never silently drops it).
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
fn manifest_dir() -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn extension_point_resolves_per_app_with_default_fallback() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("ep-grp");
|
||||
let app_a = common::unique_slug("ep-a");
|
||||
let app_b = common::unique_slug("ep-b");
|
||||
|
||||
// GroupGuard first so it drops LAST (after apps + group scripts).
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
let dir = manifest_dir();
|
||||
|
||||
// --- Group manifest: a default `theme` body, a `render` endpoint that
|
||||
// imports `theme`, and the `[[extension_points]]` declaration. ---
|
||||
fs::write(
|
||||
dir.path().join("scripts/defaulttheme.rhai"),
|
||||
r#"fn name() { "default" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/render.rhai"),
|
||||
r#"import "theme" as t; t::name()"#,
|
||||
)
|
||||
.unwrap();
|
||||
let group_manifest = format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
|
||||
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
|
||||
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
|
||||
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
|
||||
);
|
||||
let group_path = dir.path().join("group.toml");
|
||||
fs::write(&group_path, &group_manifest).unwrap();
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&group_path)
|
||||
.output()
|
||||
.expect("group apply");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"group apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
// Group scripts block group deletion (RESTRICT) — guard them.
|
||||
let _gr = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "render"),
|
||||
);
|
||||
let _gd = ScriptGuard::new(
|
||||
&env.url,
|
||||
&env.token,
|
||||
&group_script_id(&env, &group, "defaulttheme"),
|
||||
);
|
||||
|
||||
// --- App A under the group: provides its OWN `theme` + a caller that
|
||||
// invokes the inherited `render`. ---
|
||||
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
fs::write(
|
||||
dir.path().join("scripts/theme-a.rhai"),
|
||||
r#"fn name() { "app-a" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/caller.rhai"),
|
||||
r#"invoke("render", #{})"#,
|
||||
)
|
||||
.unwrap();
|
||||
let app_a_manifest = format!(
|
||||
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
|
||||
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
|
||||
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
||||
);
|
||||
let app_a_path = dir.path().join("a.toml");
|
||||
fs::write(&app_a_path, &app_a_manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&app_a_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The inherited `render` imports the ext point `theme`; in App A's context
|
||||
// it resolves App A's OWN `theme`.
|
||||
let caller_a = app_script_id(&env, &app_a, "caller");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_a),
|
||||
serde_json::json!("app-a"),
|
||||
"extension point must resolve the inheriting app's module"
|
||||
);
|
||||
|
||||
// --- App B under the group: provides NO `theme` → render falls back to the
|
||||
// group's default body. ---
|
||||
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app_b, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let app_b_manifest = format!(
|
||||
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
|
||||
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
|
||||
);
|
||||
let app_b_path = dir.path().join("b.toml");
|
||||
fs::write(&app_b_path, &app_b_manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&app_b_path)
|
||||
.assert()
|
||||
.success();
|
||||
let caller_b = app_script_id(&env, &app_b, "caller");
|
||||
assert_eq!(
|
||||
invoke_body(&env, &caller_b),
|
||||
serde_json::json!("default"),
|
||||
"an app providing no module must fall back to the ext point's default"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn extension_point_round_trips_through_pull() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let app = common::unique_slug("ep-pull");
|
||||
|
||||
let _a = AppGuard::new(&env.url, &env.token, &app);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &app])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply an app manifest that declares an extension point with a default.
|
||||
let dir = manifest_dir();
|
||||
fs::write(
|
||||
dir.path().join("scripts/theme.rhai"),
|
||||
r#"fn name() { "x" }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = format!(
|
||||
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
|
||||
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
|
||||
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
|
||||
);
|
||||
let manifest_path = dir.path().join("picloud.toml");
|
||||
fs::write(&manifest_path, &manifest).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--file"])
|
||||
.arg(&manifest_path)
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Pull into a fresh dir, then re-plan: the extension point must round-trip
|
||||
// (no Create/Delete), so `--prune` would never drop it.
|
||||
let pulled = manifest_dir();
|
||||
common::pic_as(&env)
|
||||
.args(["pull", &app, "--dir"])
|
||||
.arg(pulled.path())
|
||||
.assert()
|
||||
.success();
|
||||
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
|
||||
assert!(
|
||||
toml.contains("theme_slot"),
|
||||
"pulled manifest must export the extension point:\n{toml}"
|
||||
);
|
||||
let plan = common::pic_as(&env)
|
||||
.args(["plan", "--file"])
|
||||
.arg(pulled.path().join("picloud.toml"))
|
||||
.output()
|
||||
.expect("plan");
|
||||
let stdout = String::from_utf8_lossy(&plan.stdout);
|
||||
assert!(
|
||||
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
|
||||
"re-planning a pulled manifest must be a no-op:\n{stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Id of the named script in a group (`pic scripts ls --group <g>`).
|
||||
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--group", group], name)
|
||||
}
|
||||
|
||||
/// Id of the named script in an app (`pic scripts ls --app <a>`).
|
||||
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
|
||||
named_id(env, &["scripts", "ls", "--app", app], name)
|
||||
}
|
||||
|
||||
fn named_id(env: &common::TestEnv, args: &[&str], name: &str) -> String {
|
||||
let ls = common::pic_as(env).args(args).output().expect("scripts ls");
|
||||
let table = String::from_utf8(ls.stdout).unwrap();
|
||||
table
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&name))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
|
||||
}
|
||||
|
||||
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
|
||||
let out = common::pic_as(env)
|
||||
.args(["scripts", "invoke", id])
|
||||
.output()
|
||||
.expect("scripts invoke");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"invoke failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
|
||||
}
|
||||
207
crates/picloud-cli/tests/ownership.rs
Normal file
207
crates/picloud-cli/tests/ownership.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! M3 multi-repo single-owner ownership (§7) via `pic apply --dir`:
|
||||
//! * the first repo to apply a group CLAIMS it (its `.picloud/` project key
|
||||
//! becomes the owner),
|
||||
//! * a SECOND repo (different project key) applying the same group is
|
||||
//! REJECTED with an ownership conflict,
|
||||
//! * `--takeover` lets the second repo seize it (admin-gated; the fixture
|
||||
//! token is instance owner), flipping ownership — proven by the first repo
|
||||
//! now being the one rejected,
|
||||
//! * `--prune` deletes an owned, now-undeclared, empty group; a group owned
|
||||
//! by another repo is never touched.
|
||||
//!
|
||||
//! Each project lives in its own `TempDir`, so `pic` mints a distinct project
|
||||
//! key per repo (`.picloud/project.json`) — exactly the two-repo topology §7
|
||||
//! governs.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::GroupGuard;
|
||||
use crate::common::member;
|
||||
|
||||
/// A single-group project dir: group `slug` under `parent`, no scripts (so a
|
||||
/// structural prune can delete it — a group holding scripts is RESTRICT-pinned).
|
||||
fn group_dir(slug: &str, parent: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!("[group]\nslug = \"{slug}\"\nname = \"Owned Group\"\nparent = \"{parent}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
|
||||
let mut cmd = common::pic_as(env);
|
||||
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
|
||||
cmd.output().expect("apply --dir")
|
||||
}
|
||||
|
||||
fn ls_groups(env: &common::TestEnv) -> String {
|
||||
String::from_utf8(
|
||||
common::pic_as(env)
|
||||
.args(["groups", "ls"])
|
||||
.output()
|
||||
.expect("groups ls")
|
||||
.stdout,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn first_repo_claims_second_is_rejected_then_takeover_flips_ownership() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("own-g");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// Repo A claims the group on first apply (it does not pre-exist → created
|
||||
// AND claimed for project A in one tx).
|
||||
let repo_a = group_dir(&slug, "root");
|
||||
let a1 = apply(&env, repo_a.path(), &[]);
|
||||
assert!(
|
||||
a1.status.success(),
|
||||
"repo A claim apply failed: {}",
|
||||
String::from_utf8_lossy(&a1.stderr)
|
||||
);
|
||||
assert!(
|
||||
ls_groups(&env).contains(&slug),
|
||||
"group should exist after claim"
|
||||
);
|
||||
|
||||
// Repo B (a different dir → a different project key) is rejected: the group
|
||||
// is owned by project A.
|
||||
let repo_b = group_dir(&slug, "root");
|
||||
let b1 = apply(&env, repo_b.path(), &[]);
|
||||
assert!(
|
||||
!b1.status.success(),
|
||||
"repo B apply must be rejected (group owned by A)"
|
||||
);
|
||||
let b1_err = String::from_utf8_lossy(&b1.stderr).to_lowercase();
|
||||
assert!(
|
||||
b1_err.contains("owned by another project") || b1_err.contains("takeover"),
|
||||
"conflict message should name the ownership clash:\n{b1_err}"
|
||||
);
|
||||
|
||||
// Repo B with --takeover succeeds (the fixture token is instance owner, so
|
||||
// it holds GroupAdmin on every node) and flips ownership to project B.
|
||||
let b2 = apply(&env, repo_b.path(), &["--takeover"]);
|
||||
assert!(
|
||||
b2.status.success(),
|
||||
"repo B --takeover should succeed: {}",
|
||||
String::from_utf8_lossy(&b2.stderr)
|
||||
);
|
||||
|
||||
// Proof the flip took: repo A — formerly the owner — is now the one
|
||||
// rejected without --takeover.
|
||||
let a2 = apply(&env, repo_a.path(), &[]);
|
||||
assert!(
|
||||
!a2.status.success(),
|
||||
"after takeover, repo A must now be rejected (B owns the group)"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn takeover_without_group_admin_is_forbidden() {
|
||||
// §7.4 — ownership ⟂ RBAC: `--takeover` needs GroupAdmin specifically, a
|
||||
// second gate beyond the write caps. A non-admin member (editor) of the
|
||||
// contested group cannot seize it for their repo.
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("own-ta");
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
// Admin (repo A) claims the group.
|
||||
let repo_a = group_dir(&slug, "root");
|
||||
assert!(
|
||||
apply(&env, repo_a.path(), &[]).status.success(),
|
||||
"admin claim apply should succeed"
|
||||
);
|
||||
|
||||
// A member with `editor` (not group_admin) on the group.
|
||||
let m = member::member_user(fx, &common::unique_username("ta"));
|
||||
member::grant_group_membership(fx, &slug, &m.id, "editor");
|
||||
let member_env = common::custom_env(&fx.url, &m.token);
|
||||
common::seed_credentials(&member_env, &m.username);
|
||||
|
||||
// The member's repo B (different project key) attempts a takeover → 403.
|
||||
let repo_b = group_dir(&slug, "root");
|
||||
let out = apply(&member_env, repo_b.path(), &["--takeover"]);
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"non-admin --takeover must be forbidden"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("forbidden") || err.contains("403"),
|
||||
"takeover denial should be an authz error:\n{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn prune_deletes_owned_undeclared_group_only() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("own-par");
|
||||
let child = common::unique_slug("own-child");
|
||||
// Drop order: child (registered last → dropped first), then parent.
|
||||
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
let _gc = GroupGuard::new(&env.url, &env.token, &child);
|
||||
|
||||
// Repo declares parent + child (both empty); apply claims both.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\nparent = \"root\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(dir.path().join("sub")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("sub/picloud.toml"),
|
||||
format!("[group]\nslug = \"{child}\"\nname = \"Child\"\nparent = \"{parent}\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let out = apply(&env, dir.path(), &[]);
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"initial claim apply failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let groups = ls_groups(&env);
|
||||
assert!(
|
||||
groups.contains(&parent) && groups.contains(&child),
|
||||
"both groups should exist"
|
||||
);
|
||||
|
||||
// Drop the child manifest from the SAME repo (keeping its project key) so
|
||||
// the child becomes owned-but-undeclared, then apply --prune: the empty
|
||||
// child is deleted; the parent (still declared) is kept.
|
||||
fs::remove_dir_all(dir.path().join("sub")).unwrap();
|
||||
let out2 = apply(&env, dir.path(), &["--prune", "--yes"]);
|
||||
assert!(
|
||||
out2.status.success(),
|
||||
"prune apply failed: {}",
|
||||
String::from_utf8_lossy(&out2.stderr)
|
||||
);
|
||||
let groups = ls_groups(&env);
|
||||
assert!(
|
||||
groups.contains(&parent),
|
||||
"parent must survive prune:\n{groups}"
|
||||
);
|
||||
assert!(
|
||||
!groups.contains(&child),
|
||||
"owned, undeclared, empty child must be pruned:\n{groups}"
|
||||
);
|
||||
}
|
||||
562
crates/picloud-cli/tests/templates.rs
Normal file
562
crates/picloud-cli/tests/templates.rs
Normal file
@@ -0,0 +1,562 @@
|
||||
//! M4a route templates (§4.5) via `pic apply --dir`:
|
||||
//! * a group declares ONE route template (`/t/{app_slug}`) bound to its
|
||||
//! inherited script; the apply fans it out into a concrete route on every
|
||||
//! descendant app, with `{app_slug}` resolved per app,
|
||||
//! * re-apply is idempotent (no duplicate expansions — provenance),
|
||||
//! * removing the template + `--prune` reaps the expansions,
|
||||
//! * an expansion colliding with a hand-declared route is a hard error,
|
||||
//! * `pic plan --dir` reports the blast radius.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use postgres::{Client as PgClient, NoTls};
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
|
||||
|
||||
/// Template-expanded routes (`from_template IS NOT NULL`) for the two app slugs
|
||||
/// under test, as `(path, id)` — read straight from Postgres since the route
|
||||
/// admin endpoint only lists app-owned-script routes (a group script isn't
|
||||
/// addressable there). Scoped to `/t/<a>` and `/t/<b>` so parallel tests don't
|
||||
/// interfere.
|
||||
fn expansion_rows(a: &str, b: &str) -> Vec<(String, String)> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let want = [format!("/t/{a}"), format!("/t/{b}")];
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path, id::text FROM routes \
|
||||
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
||||
&[&&want[..]],
|
||||
)
|
||||
.expect("query expansions");
|
||||
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
||||
}
|
||||
|
||||
/// A tree: a root group declaring a `shared` endpoint + a `greet` route template
|
||||
/// `/t/{app_slug}`, and `extra_app_toml` appended to app `a`'s manifest. Apps
|
||||
/// must pre-exist under the group (created in the test before applying).
|
||||
fn tree_dir(group: &str, a: &str, b: &str, template: &str, extra_app_a: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("scripts/shared.rhai"),
|
||||
r#""hi from template""#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{template}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for (slug, extra) in [(a, extra_app_a), (b, "")] {
|
||||
fs::create_dir_all(dir.path().join(slug)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(slug).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n{extra}"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
dir
|
||||
}
|
||||
|
||||
const TEMPLATE: &str = "[[route_templates]]\nname = \"greet\"\nscript = \"shared\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/t/{app_slug}\"\n";
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn route_template_fans_out_per_app_idempotently_then_prunes() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tpl-g");
|
||||
let a = common::unique_slug("tpl-a");
|
||||
let b = common::unique_slug("tpl-b");
|
||||
|
||||
// group ← (app a, app b). Guards drop apps before the group (RESTRICT).
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
let _ab = AppGuard::new(&env.url, &env.token, &b);
|
||||
for slug in [&a, &b] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", slug, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// --- Apply: the template fans out to both apps. ---
|
||||
let dir = tree_dir(&group, &a, &b, TEMPLATE, "");
|
||||
// Plan reports the blast radius (both descendant apps).
|
||||
let plan = common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("plan --dir");
|
||||
let plan_err = String::from_utf8_lossy(&plan.stderr);
|
||||
assert!(
|
||||
plan_err.contains("blast radius") && plan_err.contains("2 app(s)"),
|
||||
"plan should report the 2-app blast radius:\n{plan_err}"
|
||||
);
|
||||
|
||||
// --- Apply: the template fans out to both apps, one route each. ---
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
// The group script must drop before its group at teardown (RESTRICT).
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
|
||||
let first = expansion_rows(&a, &b);
|
||||
let paths: Vec<&str> = first.iter().map(|(p, _)| p.as_str()).collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![format!("/t/{a}").as_str(), format!("/t/{b}").as_str()],
|
||||
"each app gets its own `{{app_slug}}`-resolved route"
|
||||
);
|
||||
|
||||
// --- Idempotent re-apply: same rows, same ids (no churn — provenance). ---
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let second = expansion_rows(&a, &b);
|
||||
assert_eq!(
|
||||
first, second,
|
||||
"re-apply must not churn expansions (stable ids)"
|
||||
);
|
||||
|
||||
// --- Remove the template + --prune: expansions are reaped. Rewrite the
|
||||
// SAME repo's group manifest (a fresh dir would be a different project and
|
||||
// conflict on the owned group, §7). ---
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
expansion_rows(&a, &b).is_empty(),
|
||||
"removing the template must reap its expansions"
|
||||
);
|
||||
}
|
||||
|
||||
/// M6 (§4.5): a `{var:NAME}` placeholder resolves against a var set in the SAME
|
||||
/// apply (in-transaction), so a var and a template referencing it converge in
|
||||
/// one `pic apply`. Before M6 the var (written earlier in the tx) was invisible
|
||||
/// to expansion — the route would only pick it up on a *second* apply.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn route_template_var_resolves_in_same_apply() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tplv-g");
|
||||
let a = common::unique_slug("tplv-a");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// The group sets `region` AND declares a template referencing it — one
|
||||
// manifest, one apply.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
||||
[vars]\nregion = \"eu\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
||||
[[route_templates]]\nname = \"geo\"\nscript = \"shared\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/tv/{{app_slug}}/{{var:region}}\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(dir.path().join(&a)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(&a).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
|
||||
// The expanded route resolved `{var:region}` → `eu` in THIS apply.
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let want = format!("/tv/{a}/eu");
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path FROM routes WHERE from_template IS NOT NULL AND path = $1",
|
||||
&[&want],
|
||||
)
|
||||
.expect("query");
|
||||
assert_eq!(
|
||||
rows.len(),
|
||||
1,
|
||||
"`{{var:region}}` must resolve to `eu` in the same apply (expected path {want})"
|
||||
);
|
||||
}
|
||||
|
||||
/// Template-expanded routes (`from_template IS NOT NULL`) whose path is one of
|
||||
/// `paths`, as `path` strings — for asserting which descendant apps received an
|
||||
/// expansion regardless of which apply declared them.
|
||||
fn expansion_paths(paths: &[String]) -> Vec<String> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path FROM routes \
|
||||
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
||||
&[&paths],
|
||||
)
|
||||
.expect("query expansion paths");
|
||||
rows.iter().map(|r| r.get(0)).collect()
|
||||
}
|
||||
|
||||
/// `(path, id)` of template-expanded routes among `paths` — for asserting an
|
||||
/// expansion is STABLE (same row id) across a re-apply, i.e. not churned.
|
||||
fn expansion_rows_for(paths: &[String]) -> Vec<(String, String)> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT path, id::text FROM routes \
|
||||
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
|
||||
&[&paths],
|
||||
)
|
||||
.expect("query expansion rows");
|
||||
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
||||
}
|
||||
|
||||
/// M7 (§4.5): a route template fans out to EVERY descendant app in the DB
|
||||
/// subtree, not only the app nodes present in the apply. An app created under
|
||||
/// the group out-of-band (absent from the manifest) still receives the
|
||||
/// expansion on the next apply, and removing the template reaps it there too.
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn route_template_reaches_out_of_tree_descendant() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("xrepo-g");
|
||||
let sg = common::unique_slug("xrepo-sg"); // subgroup (depth-2)
|
||||
let a = common::unique_slug("xrepo-a");
|
||||
let c = common::unique_slug("xrepo-c"); // out-of-tree descendant (depth-1)
|
||||
let d = common::unique_slug("xrepo-d"); // out-of-tree descendant (depth-2)
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &a, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Apply a tree of just (group ← app a) with an `/x/{app_slug}` template.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
|
||||
let group_toml = |with_template: bool| {
|
||||
let tmpl = if with_template {
|
||||
"\n[[route_templates]]\nname = \"x\"\nscript = \"shared\"\n\
|
||||
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x/{app_slug}\"\n"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n{tmpl}"
|
||||
)
|
||||
};
|
||||
fs::write(dir.path().join("picloud.toml"), group_toml(true)).unwrap();
|
||||
fs::create_dir_all(dir.path().join(&a)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(&a).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
assert_eq!(
|
||||
expansion_paths(&[format!("/x/{a}")]).len(),
|
||||
1,
|
||||
"in-tree app a gets its expansion"
|
||||
);
|
||||
|
||||
// Create app c (direct child) and a nested subgroup ← app d (depth-2),
|
||||
// all OUTSIDE the manifest tree, to exercise the recursive descendant CTE.
|
||||
let _ac = AppGuard::new(&env.url, &env.token, &c);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &c, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _sg = GroupGuard::new(&env.url, &env.token, &sg);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &sg, "--parent", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _ad = AppGuard::new(&env.url, &env.token, &d);
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &d, "--group", &sg])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// Re-apply the same tree (still only group + a). M7: c (depth-1) AND d
|
||||
// (depth-2, under the subgroup) both receive the expansion. `--force`
|
||||
// waives the bound token (the out-of-band creates bumped structure version).
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
let want = [format!("/x/{c}"), format!("/x/{d}")];
|
||||
assert_eq!(
|
||||
expansion_paths(&want),
|
||||
want.to_vec(),
|
||||
"out-of-tree descendants at depth 1 AND 2 receive expansions"
|
||||
);
|
||||
|
||||
// Idempotent re-apply: descendants must not churn (same row ids — provenance
|
||||
// by `from_template`, not delete+recreate), same as the in-tree path.
|
||||
let before = expansion_rows_for(&want);
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.arg("--force")
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
before,
|
||||
expansion_rows_for(&want),
|
||||
"re-apply must not churn descendant expansions (stable ids)"
|
||||
);
|
||||
|
||||
// Remove the template + prune: every descendant's expansion is reaped,
|
||||
// in-tree or not, at any depth.
|
||||
fs::write(dir.path().join("picloud.toml"), group_toml(false)).unwrap();
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.args(["--prune", "--yes", "--force"])
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
expansion_paths(&[format!("/x/{a}"), format!("/x/{c}"), format!("/x/{d}")]).is_empty(),
|
||||
"removing the template reaps expansions on ALL descendants, any depth"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn expansion_colliding_with_hand_declared_route_is_rejected() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("tplc-g");
|
||||
let a = common::unique_slug("tplc-a");
|
||||
let b = common::unique_slug("tplc-b");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
let _ab = AppGuard::new(&env.url, &env.token, &b);
|
||||
for slug in [&a, &b] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", slug, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// App `a` hand-declares the EXACT route the template would expand to (the
|
||||
// template path resolves to `/t/<a>` for app a). That collision is a hard
|
||||
// error — neither side silently wins.
|
||||
let collide = format!(
|
||||
"\n[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\n\
|
||||
path_kind = \"exact\"\npath = \"/t/{a}\"\n"
|
||||
);
|
||||
let dir = tree_dir(&group, &a, &b, TEMPLATE, &collide);
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("apply --dir");
|
||||
assert!(
|
||||
!out.status.success(),
|
||||
"an expansion colliding with a hand-declared route must be rejected"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
||||
assert!(
|
||||
err.contains("template") && (err.contains("hand") || err.contains("declare")),
|
||||
"error should name the template/hand-declared collision:\n{err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Template-expanded kv triggers for the two apps, as `(collection_glob, id)`,
|
||||
/// read straight from Postgres (a group-script trigger isn't listable via the
|
||||
/// app trigger API). Scoped to the test's two slugs.
|
||||
fn trigger_rows(a: &str, b: &str) -> Vec<(String, String)> {
|
||||
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
|
||||
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
|
||||
let want = [format!("{a}-data"), format!("{b}-data")];
|
||||
let rows = pg
|
||||
.query(
|
||||
"SELECT d.collection_glob, t.id::text FROM triggers t \
|
||||
JOIN kv_trigger_details d ON d.trigger_id = t.id \
|
||||
WHERE t.from_template IS NOT NULL AND d.collection_glob = ANY($1) \
|
||||
ORDER BY d.collection_glob",
|
||||
&[&&want[..]],
|
||||
)
|
||||
.expect("query trigger expansions");
|
||||
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
|
||||
}
|
||||
|
||||
const TRIGGER_TEMPLATE: &str = "[[trigger_templates]]\nname = \"ev\"\nkind = \"kv\"\n\
|
||||
script = \"shared\"\ncollection_glob = \"{app_slug}-data\"\n\
|
||||
ops = [\"insert\"]\n";
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn trigger_template_fans_out_per_app_idempotently_then_prunes() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let group = common::unique_slug("ttpl-g");
|
||||
let a = common::unique_slug("ttpl-a");
|
||||
let b = common::unique_slug("ttpl-b");
|
||||
|
||||
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &group])
|
||||
.assert()
|
||||
.success();
|
||||
let _aa = AppGuard::new(&env.url, &env.token, &a);
|
||||
let _ab = AppGuard::new(&env.url, &env.token, &b);
|
||||
for slug in [&a, &b] {
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", slug, "--group", &group])
|
||||
.assert()
|
||||
.success();
|
||||
}
|
||||
|
||||
// group manifest: a `shared` script + a kv trigger template using {app_slug}.
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/shared.rhai"), r#""x""#).unwrap();
|
||||
let write_group = |with_tmpl: bool| {
|
||||
let tmpl = if with_tmpl { TRIGGER_TEMPLATE } else { "" };
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{group}\"\nname = \"TT\"\n\n\
|
||||
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{tmpl}"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
write_group(true);
|
||||
for slug in [&a, &b] {
|
||||
fs::create_dir_all(dir.path().join(slug)).unwrap();
|
||||
fs::write(
|
||||
dir.path().join(slug).join("picloud.toml"),
|
||||
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
|
||||
|
||||
let first = trigger_rows(&a, &b);
|
||||
assert_eq!(
|
||||
first.iter().map(|(g, _)| g.as_str()).collect::<Vec<_>>(),
|
||||
vec![format!("{a}-data").as_str(), format!("{b}-data").as_str()],
|
||||
"each app gets its own {{app_slug}}-resolved kv trigger"
|
||||
);
|
||||
|
||||
// Idempotent re-apply: same rows, same ids.
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.assert()
|
||||
.success();
|
||||
assert_eq!(
|
||||
trigger_rows(&a, &b),
|
||||
first,
|
||||
"re-apply must not churn triggers"
|
||||
);
|
||||
|
||||
// Remove the template + --prune: trigger expansions reaped.
|
||||
write_group(false);
|
||||
common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.args(["--prune", "--yes"])
|
||||
.assert()
|
||||
.success();
|
||||
assert!(
|
||||
trigger_rows(&a, &b).is_empty(),
|
||||
"removing the trigger template must reap its expansions"
|
||||
);
|
||||
}
|
||||
|
||||
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
|
||||
let ls = common::pic_as(env)
|
||||
.args(["scripts", "ls", "--group", group])
|
||||
.output()
|
||||
.expect("scripts ls --group");
|
||||
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
|
||||
.expect("group should have one script")
|
||||
}
|
||||
123
crates/picloud-cli/tests/tree_shape.rs
Normal file
123
crates/picloud-cli/tests/tree_shape.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! M2 declarative tree SHAPE via `pic apply --dir`:
|
||||
//! * a group manifest for a group that does NOT exist yet is CREATED under
|
||||
//! its declared parent, with its content, in one atomic apply,
|
||||
//! * re-applying the same tree is a no-op (the group now exists),
|
||||
//! * changing the declared parent REPARENTS the group on the next apply.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
use crate::common;
|
||||
use crate::common::cleanup::{GroupGuard, ScriptGuard};
|
||||
|
||||
/// Write a single-group project dir declaring group `slug` under `parent` with
|
||||
/// one endpoint script.
|
||||
fn group_dir(slug: &str, parent: &str) -> TempDir {
|
||||
let dir = TempDir::new().expect("tempdir");
|
||||
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||
fs::write(dir.path().join("scripts/hello.rhai"), r#""hi""#).unwrap();
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{slug}\"\nname = \"Child Group\"\nparent = \"{parent}\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn tree_apply_creates_then_reparents_a_group() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let parent = common::unique_slug("ts-par");
|
||||
let child = common::unique_slug("ts-child");
|
||||
|
||||
// Guards drop in reverse order: parent (last) ← child (mid) ← script (first).
|
||||
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
|
||||
let _gc = GroupGuard::new(&env.url, &env.token, &child);
|
||||
common::pic_as(&env)
|
||||
.args(["groups", "create", &parent])
|
||||
.assert()
|
||||
.success();
|
||||
|
||||
// --- Create: the child group does NOT exist; apply --dir creates it. ---
|
||||
let dir = group_dir(&child, &parent);
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("apply --dir");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"tree apply (create) failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
|
||||
// The group now exists with its script.
|
||||
let groups = ls(&env, &["groups", "ls"]);
|
||||
assert!(groups.contains(&child), "created group missing:\n{groups}");
|
||||
let scripts = ls(&env, &["scripts", "ls", "--group", &child]);
|
||||
let script_id = scripts
|
||||
.lines()
|
||||
.map(common::cells)
|
||||
.find(|c| c.get(2) == Some(&"hello"))
|
||||
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||
.unwrap_or_else(|| panic!("group script `hello` should exist:\n{scripts}"));
|
||||
let _gs = ScriptGuard::new(&env.url, &env.token, &script_id);
|
||||
|
||||
// Re-applying the same tree is a no-op (group + content unchanged).
|
||||
let plan_out = common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir.path())
|
||||
.output()
|
||||
.expect("plan --dir");
|
||||
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
|
||||
assert!(
|
||||
!plan_txt.contains("create") && !plan_txt.contains("delete"),
|
||||
"re-plan of an applied tree must be a no-op:\n{plan_txt}"
|
||||
);
|
||||
|
||||
// --- Reparent: move the child under the instance root. Rewrite the SAME
|
||||
// repo's manifest (M3: a stable `.picloud/` project key per repo — a fresh
|
||||
// dir would be a different project and conflict on the owned group). ---
|
||||
fs::write(
|
||||
dir.path().join("picloud.toml"),
|
||||
format!(
|
||||
"[group]\nslug = \"{child}\"\nname = \"Child Group\"\nparent = \"root\"\n\n\
|
||||
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let dir2 = &dir;
|
||||
let out = common::pic_as(&env)
|
||||
.args(["apply", "--dir"])
|
||||
.arg(dir2.path())
|
||||
.output()
|
||||
.expect("apply --dir reparent");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"tree apply (reparent) failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
// Re-plan is a no-op now that the parent matches.
|
||||
let plan_out = common::pic_as(&env)
|
||||
.args(["plan", "--dir"])
|
||||
.arg(dir2.path())
|
||||
.output()
|
||||
.expect("plan --dir after reparent");
|
||||
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
|
||||
assert!(
|
||||
!plan_txt.contains("create") && !plan_txt.contains("delete"),
|
||||
"re-plan after reparent must be a no-op:\n{plan_txt}"
|
||||
);
|
||||
}
|
||||
|
||||
fn ls(env: &common::TestEnv, args: &[&str]) -> String {
|
||||
String::from_utf8(common::pic_as(env).args(args).output().expect("ls").stdout).unwrap()
|
||||
}
|
||||
@@ -76,7 +76,9 @@ pub use inbox::{
|
||||
pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, ResolvedScript};
|
||||
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
|
||||
pub use log_sink::{ExecutionLogSink, LogSinkError};
|
||||
pub use modules::{ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource};
|
||||
pub use modules::{
|
||||
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
|
||||
};
|
||||
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
|
||||
pub use pubsub::{
|
||||
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
|
||||
|
||||
@@ -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
|
||||
/// module set up the chain rooted at `origin` (the importing script's
|
||||
/// defining node), nearest-owner-wins. The `origin` is trusted
|
||||
@@ -80,6 +91,32 @@ pub trait ModuleSource: Send + Sync {
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
|
||||
/// Extension-point check (§5.5). Returns `Some` iff the **nearest**
|
||||
/// declaration of `name` on `origin`'s chain is an extension point (not
|
||||
/// a concrete module). On a tie in depth a concrete module wins (so a
|
||||
/// nearer/peer real module shadows the extension-point declaration). The
|
||||
/// `origin` is the trusted importer node, so the inversion can only be
|
||||
/// opened by a declaration on the importer's own ancestry — a descendant
|
||||
/// can never make a parent's sealed import dynamic.
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
name: &str,
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError>;
|
||||
|
||||
/// Load the extension-point fallback module `script_id`, but ONLY if it is
|
||||
/// a `kind = 'module'` script reachable on `origin`'s chain. The `origin`
|
||||
/// is the trusted importer node; constraining the lookup to its chain makes
|
||||
/// the resolver self-defending — a `default_script_id` that (through a bug
|
||||
/// or DB-direct write) pointed at another tenant's module resolves to
|
||||
/// `None` rather than executing cross-tenant code. Returns `None` if the id
|
||||
/// is absent, names an endpoint, or is off-chain.
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
origin: ScriptOwner,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError>;
|
||||
}
|
||||
|
||||
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
|
||||
@@ -109,4 +146,20 @@ impl ModuleSource for NoopModuleSource {
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_extension_point(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_name: &str,
|
||||
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn resolve_by_id(
|
||||
&self,
|
||||
_origin: ScriptOwner,
|
||||
_script_id: ScriptId,
|
||||
) -> Result<Option<ModuleScript>, ModuleSourceError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,11 +375,54 @@ Two distinct constraints:
|
||||
identity token (collection / queue / topic; for cron/email/dead-letter fall back to `{kind}-{n}`),
|
||||
sanitized to the kebab regex, with `-{n}` (discovery order) guaranteeing per-app uniqueness.
|
||||
|
||||
> **Ergonomic debt (accepted, watch it):** because triggers don't inherit, 100 tenant apps each
|
||||
> needing the same 5 triggers = 500 declarations. The fix is group trigger/route **templates** that
|
||||
> fan out per descendant (a *template/instantiation* mechanism, not inheritance) — deferred, but it
|
||||
> bites early if tenant cardinality is high. Pressure-test against real tenant counts before
|
||||
> committing to the narrow-inheritance choice (§5.1).
|
||||
> **Ergonomic debt (being paid down):** because triggers/routes don't inherit, 100 tenant apps each
|
||||
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
|
||||
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
|
||||
>
|
||||
> **Status: ✅ shipped — routes (M4a) AND triggers (M4b), 2026-06-28.** Migrations `0053_templates.sql`
|
||||
> (`route_templates` + `routes.from_template`) and `0054_trigger_templates.sql` (`trigger_templates` +
|
||||
> `triggers.from_template`). A `[group]` manifest declares `[[route_templates]]` and/or
|
||||
> `[[trigger_templates]]` (the latter a flat block: `name`, `kind`, `script`, + kind params, covering
|
||||
> all seven kinds); `pic apply --dir` reconciles the template rows
|
||||
> (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one
|
||||
> concrete `routes`/`triggers` row per descendant app node** in the apply, resolving the script binding
|
||||
> nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the
|
||||
> apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard
|
||||
> error). For triggers the placeholders resolve in every string leaf of the spec, then the typed
|
||||
> trigger is rebuilt and inserted through the normal path (email secrets are resolved + re-sealed per
|
||||
> app — the value never travels in a manifest). Expansion is **idempotent via `from_template`** (routes
|
||||
> diff by identity create/update-as-replace/delete; triggers key one-per-template-per-app and compare
|
||||
> semantic identity, so re-apply doesn't churn and a removed/disabled template reaps its expansions
|
||||
> under `--prune`); an expansion colliding with a hand-declared route/trigger, or two templates
|
||||
> colliding, is a hard error. `plan` reports the **blast radius** (descendant app count) and the
|
||||
> `state_token` folds each template, so an edit between plan and apply trips `StateMoved`. Authz:
|
||||
> declaring a template needs `GroupScriptsWrite`; an app that *receives* expansions needs
|
||||
> `AppWriteRoute` / `AppManageTriggers` (and `AppSecretsRead` for an email-trigger template, which
|
||||
> seals the recipient's secret) — gated even when the app declares none of its own. Each RESOLVED
|
||||
> expansion is validated with the same rules its hand-declared counterpart gets — routes:
|
||||
> reserved-path rejection + structural path/host parse + host-claim; triggers: the per-kind shape
|
||||
> check (cron schedule, queue timeout, …) — so a `{var:…}`-injected reserved path, unclaimed strict
|
||||
> host, or malformed schedule/timeout can't slip in. **Scope:** expansion targets **every descendant app
|
||||
> in the DB subtree** (M7, 2026-06-28), not only the app nodes present in the apply — a template change at
|
||||
> group N fans out to `subtree(N)` across repos, and removing a template reaps its expansions on those
|
||||
> descendants too (the apply locks each extra descendant in `app_id` order). The actor must hold the
|
||||
> per-recipient write cap (`AppWriteRoute`/`AppManageTriggers`/`AppSecretsRead`) on each descendant; the
|
||||
> hierarchy-aware `effective_app_role` makes a group admin/editor implicitly authorized on its subtree, so
|
||||
> this is sound rather than an escalation, and a narrowly-scoped principal is refused (naming the app).
|
||||
> `{var:NAME}` resolves **in-transaction** (M6, 2026-06-28) — a var set in the *same* apply is visible to
|
||||
> the template immediately, so var + template converge in one apply. A
|
||||
> trigger template whose only change is a non-identity field (e.g. dispatch_mode) is a no-op on
|
||||
> re-apply, mirroring the existing trigger-identity limitation (recreate to change those).
|
||||
>
|
||||
> **Known limitations (both "one more apply", no data risk):** (1) Template fan-out resolves against a
|
||||
> node's **committed** ancestry — `apply` computes app chains before the transaction's structural
|
||||
> reconcile — so a template gained or lost by a **reparent performed in the *same* apply** takes effect
|
||||
> on the next apply (the same shape as "reparenting into a freshly-created group needs a second apply").
|
||||
> (2) The `{env}` placeholder resolves to the apply's `--env` for **declared** apps but to the app's own
|
||||
> `apps.environment` column for **cross-repo descendants**; if a template uses `{env}` and those two
|
||||
> disagree, that descendant's expansion can churn between applies (and a descendant with a NULL
|
||||
> `environment` + an `{env}` template aborts the apply, naming the app). Set the descendant's environment,
|
||||
> or avoid `{env}` in templates fanned across heterogeneous-env subtrees.
|
||||
|
||||
### 4.6 Secrets & `pull`
|
||||
|
||||
@@ -552,9 +595,18 @@ trust inversion). The rule:
|
||||
> site; the `PicloudModuleResolver` reads the importing node from Rhai's `_source` (set to each module's
|
||||
> owner via `AST::set_source(encode(owner))` before `eval_ast_as_new` — the lexical chaining), and the
|
||||
> cache is re-keyed by resolved `ScriptId`. Group modules + group-script imports are now allowed
|
||||
> (`group_scripts_api`), and the single-node apply runs the §5.5 dangling-import `plan` check. **Opt-in
|
||||
> extension points** (the dynamic-resolution branch + `[extension_points]` manifest declaration) remain
|
||||
> the one deferred piece of §5.5 — a clean additive follow-up on top of the now-origin-aware resolver.
|
||||
> (`group_scripts_api`), and the single-node apply runs the §5.5 dangling-import `plan` check.
|
||||
>
|
||||
> **Extension points (✅ shipped, 2026-06-26).** The opt-in dynamic-resolution branch landed: migration
|
||||
> `0051` adds an owner-polymorphic `extension_points` table (optional `default_script_id` fallback);
|
||||
> `ModuleSource` gained `resolve_extension_point(origin, name)` (Some only when the NEAREST declaration
|
||||
> of `name` on the importer's chain is an extension point — a peer/nearer concrete module shadows it)
|
||||
> and a chain-constrained `resolve_by_id(origin, id)`. The resolver checks the importer's chain first
|
||||
> and, on a hit, resolves against `App(cx.app_id)` (the inheriting app), falling back to the default
|
||||
> body. The decision keys on the trusted importer `origin`, so only the declaring parent can open the
|
||||
> inversion — a descendant can't make a parent's sealed import dynamic or hijack one. Declared via
|
||||
> `[[extension_points]] name = "..." default = "<local module>"` (app or group manifests); reconciled
|
||||
> like `vars`, gated on the script-write capability, exported by `pic pull`.
|
||||
|
||||
### 5.6 Tree lifecycle: delete, reparent, rename
|
||||
|
||||
@@ -625,6 +677,26 @@ non-orphaning:
|
||||
|
||||
## 7. Ownership of shared nodes
|
||||
|
||||
> **Status: ✅ shipped (M3, 2026-06-26).** Migration `0052_owner_project.sql` adds a `projects(id, key
|
||||
> UNIQUE)` table and promotes the inert `groups.owner_project` (0047) to a real FK. The CLI mints a
|
||||
> stable, gitignored project key in `.picloud/project.json` (`pic init`, or lazily on first tree
|
||||
> plan/apply) and presents it on every tree request. **First apply claims** each declared group for the
|
||||
> project (NULL owner → stamped, in-tx, first-commit-wins under the per-node advisory lock). A second
|
||||
> project applying an owned group is **refused** (`pic plan --dir` surfaces the conflict for CI;
|
||||
> `pic apply --dir` returns 409) unless `pic apply --dir --takeover`, which is **GroupAdmin-gated** per
|
||||
> contested node (ownership ⟂ RBAC: two independent gates, §7.4) and flips the owner. With ownership in
|
||||
> place, `--prune` now also reaps **structural** nodes: a group this project owns that the manifest no
|
||||
> longer declares is deleted **leaf-first** (RESTRICT-guarded, so a still-populated group aborts the
|
||||
> apply rather than orphaning data) — scoped to project-owned nodes, so one repo never reaps another's
|
||||
> or a UI-owned (unclaimed) node. The bound-plan token folds each declared group's owner key, so a
|
||||
> claim/takeover by another repo between plan and apply trips `StateMoved`. **Attach-point ceiling**
|
||||
> (§7.2) is implicit: a manifest only ever writes the nodes it declares; an ancestor parent is
|
||||
> referenced read-only, never claimed or written. The GroupAdmin takeover gate is enforced **twice**:
|
||||
> in `authz_tree` (pre-tx) and again **in-tx** at the ownership decision (so `--force`, which waives the
|
||||
> staleness token, can't open a takeover-without-admin window). The attacker-supplied project key is
|
||||
> length/charset-validated server-side before use. Journey coverage: claim → conflict → takeover → flip
|
||||
> → prune, plus a non-admin `--takeover` → 403.
|
||||
|
||||
**Single-owner-per-node, ceilinged by the attach point.**
|
||||
|
||||
1. Each group node is owned by exactly one **project-root** (the repo that *manages* it — contains
|
||||
@@ -989,8 +1061,8 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
> can't shadow it; verified e2e). Mechanism: owner-polymorphic `ModuleScript`, origin-rooted
|
||||
> `ModuleSource::resolve`, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site,
|
||||
> `_source`-driven lexical chaining in the resolver (cache re-keyed by `ScriptId`), and the
|
||||
> single-node dangling-import `plan` check. Still deferred: **opt-in extension points** (the only
|
||||
> remaining §5.5 piece) and **invoke-by-id** staying app-scoped (inheritance is by-name only); CoW
|
||||
> single-node dangling-import `plan` check. **Opt-in extension points shipped (§5.5, 2026-06-26).**
|
||||
> Still deferred: **invoke-by-id** staying app-scoped (inheritance is by-name only); CoW
|
||||
> is **redefine-in-app + re-apply** (no live auto-rebinding). Sharp edge to track: `routes.script_id`
|
||||
> is `ON DELETE CASCADE`, so deleting a
|
||||
> group script removes descendant apps' bound routes (within group-editor authority; the *group*
|
||||
@@ -998,14 +1070,22 @@ Resolved items now live inline next to their topic. What genuinely remains:
|
||||
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
|
||||
tree plan, per-env approval gating.
|
||||
|
||||
> **Status (Phase 5): ✅ shipped — single-repo nested tree apply, atomic.** A directory tree of
|
||||
> `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as ONE
|
||||
> server-computed plan in ONE Postgres transaction. Per the §11.1 review, the **multi-repo
|
||||
> single-owner / attach-point / takeover** layer (§7) is **deferred** for the solo-dev / single-repo
|
||||
> start, as is **per-env approval-policy gating** (`[project.environments]`); env overlays
|
||||
> (`picloud.<env>.toml`) already exist. Groups must **pre-exist** (`pic groups create`) — a manifest
|
||||
> owns each node's *content*, not the tree *shape* (declarative group create/reparent is a later
|
||||
> add).
|
||||
> **Status (Phase 5): ✅ shipped — nested tree apply, tree shape, AND multi-repo ownership.** A
|
||||
> directory tree of `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as
|
||||
> ONE server-computed plan in ONE Postgres transaction. **Declarative group create/reparent shipped
|
||||
> (M2, 2026-06-26)** — a manifest now owns the tree *shape*, not just node content. **Multi-repo
|
||||
> single-owner / takeover + structural prune shipped (§7, M3, 2026-06-26)** — see §7 below.
|
||||
> **Per-env approval-policy gating shipped (`[project.environments]`, M5, 2026-06-28)** — the root
|
||||
> manifest declares which environments are confirm-required; `pic apply --dir --env <e>` to such an
|
||||
> env needs an explicit `--approve <e>` (a blanket `--yes` does NOT cover it), the approval is
|
||||
> admin-gated (the actor needs admin on every declared node, not just write) and audited, and the
|
||||
> policy folds into the bound-plan token. The server re-derives the policy from the bundle — the CLI's
|
||||
> check is convenience, the server is authoritative. Env overlays (`picloud.<env>.toml`) already exist.
|
||||
> Gating is a **`--dir` (project-tree) feature**: single-node `pic apply --file --env <e>` cannot
|
||||
> carry the admin-gated approval, so it **refuses** a confirm-required env and directs the user to
|
||||
> `--dir` (rather than silently bypassing the gate). Like `--takeover`/blast-radius, the policy lives
|
||||
> in the manifest (not persisted server-side), so whoever controls desired state can weaken it — an
|
||||
> accepted trust boundary, not a privilege bug.
|
||||
>
|
||||
> Shipped surface:
|
||||
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
|
||||
@@ -1053,10 +1133,10 @@ to take, not yet taken:
|
||||
- *Trigger/route templates (§4.5):* bundled into Phase 6 as "much later," but the per-app binding
|
||||
tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations).
|
||||
Decide against **actual** target tenant counts before defaulting it late.
|
||||
- *Multi-repo ownership (§7):* the single-owner / attach-point / takeover machinery is substantial
|
||||
and only earns its keep with a **second** managing repo. For a solo-dev / single-repo start, a
|
||||
"one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists
|
||||
before building the ownership layer.
|
||||
- *Multi-repo ownership (§7):* ~~the single-owner / attach-point / takeover machinery is substantial
|
||||
and only earns its keep with a **second** managing repo~~ — **shipped (M3, 2026-06-26)**; see the
|
||||
§7 status note. Project-keyed single-owner claim, GroupAdmin-gated `--takeover`, and project-scoped
|
||||
structural prune all landed.
|
||||
|
||||
---
|
||||
|
||||
@@ -1070,3 +1150,7 @@ to take, not yet taken:
|
||||
§11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)*
|
||||
- The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage
|
||||
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
|
||||
> `[project]` (M5, root manifest only) shipped: `[[project.environments]]` entries carry
|
||||
> `name` + `confirm` (default `false`). A `confirm = true` env is gated — `pic apply --dir --env <name>`
|
||||
> requires `--approve <name>` (admin-gated + audited; `--yes` is insufficient). The block is rejected on
|
||||
> any non-root manifest. Remaining unspecified: storage config, domains, the full secret-ref shape.
|
||||
|
||||
Reference in New Issue
Block a user