diff --git a/CLAUDE.md b/CLAUDE.md index 37f095f..b422e8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint **v1.1.x — SDK foundation + services — is complete.** The SDK shape (handle pattern, `::` namespaces, `Services`/`SdkCallCx`; see [docs/sdk-shape.md](docs/sdk-shape.md), stdlib at [docs/stdlib-reference.md](docs/stdlib-reference.md)) fixed in v1.1.0, then KV, docs, modules, HTTP, cron, files, pub/sub, email, users, and durable queues + `invoke()` filled it in through **v1.1.9** — blueprint §12 has the table. Earlier groundwork: blueprint Phase 3 (admin auth, multi-app scoping, Phase 3.5 capability gating — `manager-core::authz::{can, require, Capability}`, migration `0006_users_authz.sql`). -**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import), §11.6 **group-level collections — KV + DOCS + FILES slices** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` with a `kind` discriminator + a per-kind group-keyed store: `0053_group_kv_entries.sql` (`kind='kv'`), `0054_group_docs.sql` (`kind='docs'`, the queryable-JSON store), and `0055_group_files.sql` (`kind='files'`, blob metadata in Postgres + bytes on disk under `/files/groups//...`, a `groups/` infix disjoint from the per-app `files//` subtree so the existing recursive orphan sweeper covers both with zero change) — no `app_id`, a shared row belongs to the group; CASCADE on group delete, an app delete leaves the data. Scripts use the **explicit** `kv::shared_collection("name")` / `docs::shared_collection("name")` / `files::shared_collection("name")` handles (`shared` alone is a Rhai reserved word); `GroupKv`/`GroupDocs`/`GroupFilesServiceImpl` resolve the owning group from `cx.app_id`'s ancestor chain **filtered by kind** (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name are distinct stores. The docs slice reuses the `docs_filter` DSL — `build_find_query` generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`, both literals); the files slice likewise generalized the atomic-write + checksum-on-read path helpers on an owner-relative dir (one source for the security-sensitive disk mechanics). **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead/Write`, `GroupDocsRead/Write`, `GroupFilesRead/Write`, `script_gate_require_principal` fails closed on anon). Declarative authoring is the **string-or-table** form `collections = ["catalog", { name = "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = kv); reconcile keys markers by `(name, kind)`; read-only `pic collections ls --group` shows a kind column. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), topics/queue shared collections (same trigger-centric gap), per-group quotas, an operator admin API for shared blobs (scripts use the SDK; `pic collections ls` shows the marker)), and **§4.5 group TRIGGER templates** (live, event kinds — a `[group]` declares a `[[triggers.kv|docs|files|pubsub]]` template binding a group-owned handler; `triggers` gained a polymorphic owner `0056_group_triggers.sql` mirroring `0050`; the dispatcher's `list_matching_kv/docs/files` + the pubsub publish fan-out prepend `CHAIN_LEVELS_CTE` + `JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner)` so a descendant app's event matches its own triggers **plus** ancestor-group templates in one query, the handler running under the firing `app_id` — **the chain walk is the isolation boundary**, a sibling-subtree app never matches; stateful kinds cron/queue/email rejected on a group (need materialization), route templates + per-app opt-out deferred; read-only `pic triggers ls --group`). Next: multi-node cluster mode, or extend templates to routes / the stateful kinds, or multi-repo ownership. +**Current focus: v1.2 _Hierarchies_ — groups + the declarative project tool** ([docs/design/groups-and-project-tool.md](docs/design/groups-and-project-tool.md)). That doc's §11 uses its own **Phase 1–6 numbering, distinct from the blueprint product-phase numbering above — do not conflate them** (its "Phase 3" = group-inherited config, not admin auth). Implemented on `feat/groups-*` branches: §11 Phase 1 (declarative `pic plan`/`apply`/`prune` + env overlays), Phase 2 (single-parent groups tree + hierarchy-aware RBAC), Phase 3 (group-inherited, env-scoped `vars` + secrets resolved **live** via a recursive CTE — no materialized cache), Phase 4-lite (group-owned **endpoint** scripts: `scripts` polymorphic owner in `0050_group_scripts.sql`, `get_by_name_inherited`/`is_invocable_by_app` chain resolution, inherited `invoke()` + declarative route/trigger binding — all **live**, no body materialization), Phase 5 (the **declarative project tool maps onto the group tree**: the reconcile engine generalized to `ApplyOwner{App|Group}`, a `[group]` manifest kind, and a single atomic **tree apply** — `pic plan/apply --dir` reconciles a whole directory tree of `picloud.toml` nodes in one Postgres transaction, groups-before-apps so an app route can bind a group script created in the same tx; the bound token folds in each group's `structure_version`. Multi-repo single-owner/attach-point and per-env approval gating are deferred; groups pre-exist), Phase 4b (group **modules** + the **lexical (sealed-by-default) import resolver**, §5.5: owner-polymorphic `ModuleScript`, origin-rooted `ModuleSource::resolve` walking the importing node's chain, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site, `_source`-driven lexical chaining in `PicloudModuleResolver` with the compiled-module cache re-keyed by `ScriptId`, group modules/imports allowed, single-node dangling-import `plan` check — an inherited group script's imports **seal to the group**, a leaf can't shadow them), §5.5 **extension points** (opt-in polymorphism — **§5.5 now complete**: marker table `0051_extension_points.sql` (owner-polymorphic, CASCADE — structurally a `secrets` name; default body = a co-located `kind=module` script), `ModuleSource::resolve_policy` with **nearest-declaration-kind-wins** — a concrete module resolves lexically, an EP marker resolves **dynamically against the inheriting app** (its override else the default body up-chain), `NoProvider` is a hard error; declarative-only authoring via the `[app]`/`[group]` manifest key `extension_points = [...]`, reconcile mirrors `secrets`, single-node no-provider `plan` check, read-only `pic extension-points ls` + `pull` round-trip — the app can **override** a group default, the deliberate inverse of the Phase 4b sealed import), §11.6 **group-level collections — KV + DOCS + FILES slices** (full cross-app shared read/write: a group declares a collection shared via the `[group]` manifest `collections = [...]` → owner-polymorphic marker `0052_group_collections.sql` with a `kind` discriminator + a per-kind group-keyed store: `0053_group_kv_entries.sql` (`kind='kv'`), `0054_group_docs.sql` (`kind='docs'`, the queryable-JSON store), and `0055_group_files.sql` (`kind='files'`, blob metadata in Postgres + bytes on disk under `/files/groups//...`, a `groups/` infix disjoint from the per-app `files//` subtree so the existing recursive orphan sweeper covers both with zero change) — no `app_id`, a shared row belongs to the group; CASCADE on group delete, an app delete leaves the data. Scripts use the **explicit** `kv::shared_collection("name")` / `docs::shared_collection("name")` / `files::shared_collection("name")` handles (`shared` alone is a Rhai reserved word); `GroupKv`/`GroupDocs`/`GroupFilesServiceImpl` resolve the owning group from `cx.app_id`'s ancestor chain **filtered by kind** (nearest-wins) — **that walk is the isolation boundary**, a foreign app gets `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name are distinct stores. The docs slice reuses the `docs_filter` DSL — `build_find_query` generalized on its owner column (`docs`/`app_id` vs `group_docs`/`group_id`, both literals); the files slice likewise generalized the atomic-write + checksum-on-read path helpers on an owner-relative dir (one source for the security-sensitive disk mechanics). **Reads open** to any subtree script (anonymous incl. — the declaration is the grant), **writes require an authenticated editor+** on the owning group (`GroupKvRead/Write`, `GroupDocsRead/Write`, `GroupFilesRead/Write`, `script_gate_require_principal` fails closed on anon). Declarative authoring is the **string-or-table** form `collections = ["catalog", { name = "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = kv); reconcile keys markers by `(name, kind)`; read-only `pic collections ls --group` shows a kind column. Deferred: shared-collection triggers (the "group trigger has no app to watch" problem), topics/queue shared collections (same trigger-centric gap), per-group quotas, an operator admin API for shared blobs (scripts use the SDK; `pic collections ls` shows the marker)), and **§4.5 group TRIGGER templates** (live, event kinds — a `[group]` declares a `[[triggers.kv|docs|files|pubsub]]` template binding a group-owned handler; `triggers` gained a polymorphic owner `0056_group_triggers.sql` mirroring `0050`; the dispatcher's `list_matching_kv/docs/files` + the pubsub publish fan-out prepend `CHAIN_LEVELS_CTE` + `JOIN chain c ON (t.app_id = c.app_owner OR t.group_id = c.group_owner)` so a descendant app's event matches its own triggers **plus** ancestor-group templates in one query, the handler running under the firing `app_id` — **the chain walk is the isolation boundary**, a sibling-subtree app never matches; stateful kinds cron/queue/email rejected on a group (need materialization), per-app opt-out deferred; read-only `pic triggers ls --group`), and **§4.5 group ROUTE templates** (live, inherited — a `[group]` declares a `[[routes]]` template binding a group-owned endpoint; `routes` gained a polymorphic owner `0057_group_routes.sql` mirroring `0056`. Unlike triggers (per-event SQL), routes serve from the in-memory `RouteTable`, so the HTTP hot path can't resolve inheritance per request — instead the table **rebuild** expands templates into each descendant app's slice via `RouteRepository::list_effective` (all-apps generalization of `CHAIN_LEVELS_CTE`: every app × its ancestor chain ⋈ routes), and `compile_effective_routes` applies **nearest-owner-wins shadowing** (an app's own identical binding shadows the inherited template; non-identical bindings coexist under the matcher's existing precedence — a route picks one winner, unlike a fanning trigger). Because the table is a cache, inheritance is rebuilt **full-live** through the single `rebuild_route_table` chokepoint on every edge that changes it: route CRUD, apply, **and tree mutations** — app create/delete (`apps_api`) + group reparent (`groups_api`) — so a new app under a group serves its templates instantly. Host-claim validation is skipped for a group template (descendants serve it on their own host claim; templates use `host_kind = any`). **The chain expansion is the isolation boundary** — a sibling-subtree app never inherits (pinned by `tests/group_route_templates.rs` + the `group_routes` journey); read-only `pic routes ls --group`. Deferred: per-app route opt-out, multi-node snapshot propagation). Next: multi-node cluster mode, or extend templates to the stateful kinds, or multi-repo ownership. **Data-model invariant:** app-owned data-plane tables (KV, docs, files, …) start with `app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE`; the group-inheritable tables — _config_ (`vars`, `secrets`) and now group-owned _code_ (`scripts`, `0050`) — instead carry a **polymorphic owner**: nullable `group_id` and `app_id` with an exactly-one CHECK and per-owner partial-unique indexes (config is `ON DELETE CASCADE`, scripts `RESTRICT` — code is not data). Inheritance resolves **live** down `apps.group_id → groups.parent_id` via `CHAIN_LEVELS_CTE` (no materialized view); nearest-owner-wins with an app's own row shadowing the inherited one (CoW). Every Rhai SDK call resolves its app from `cx.app_id`, never a script-passed arg, and a group script always runs under the *inheriting* app's `cx.app_id` (the cross-app isolation boundary). diff --git a/crates/manager-core/src/apply_api.rs b/crates/manager-core/src/apply_api.rs index 1db8f09..3d6dd43 100644 --- a/crates/manager-core/src/apply_api.rs +++ b/crates/manager-core/src/apply_api.rs @@ -18,7 +18,8 @@ use serde_json::json; use crate::app_repo::AppRepository; use crate::apply_service::{ ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo, - ExtensionPointInfo, NodeKind, PlanResult, TreeBundle, TreePlanResult, TriggerTemplateInfo, + ExtensionPointInfo, NodeKind, PlanResult, RouteTemplateInfo, TreeBundle, TreePlanResult, + TriggerTemplateInfo, }; use crate::authz::{require, AuthzDenied, Capability}; use crate::group_repo::GroupRepository; @@ -42,9 +43,30 @@ pub fn apply_router(service: ApplyService) -> Router { ) .route("/groups/{id}/collections", get(group_collections_handler)) .route("/groups/{id}/triggers", get(group_triggers_handler)) + .route("/groups/{id}/routes", get(group_routes_handler)) .with_state(service) } +/// Read-only §11 tail route-template report for a group: its own declared route +/// templates (method, host, path, handler script, dispatch, enabled). Viewer- +/// tier read. Backs `pic routes ls --group`. +async fn group_routes_handler( + State(svc): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result>, 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)?; + let report = svc.route_report(ApplyOwner::Group(group_id)).await?; + Ok(Json(report)) +} + /// Read-only §11 tail trigger-template report for a group: its own declared /// event trigger templates (kind, target, handler script, enabled). Viewer-tier /// read. Backs `pic triggers ls --group`. diff --git a/crates/manager-core/src/apply_service.rs b/crates/manager-core/src/apply_service.rs index fcc825b..6fc70e6 100644 --- a/crates/manager-core/src/apply_service.rs +++ b/crates/manager-core/src/apply_service.rs @@ -472,6 +472,21 @@ pub struct TriggerTemplateInfo { pub enabled: bool, } +/// One row of the read-only §11 tail route-template report (`pic routes ls +/// --group`). The binding tuple a descendant app inherits, plus the handler +/// script's name. The stored route `name` is a UUID for reconcile-created +/// rows, so the semantic bits are shown instead. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RouteTemplateInfo { + pub method: String, + pub host: String, + pub path_kind: String, + pub path: String, + pub script: String, + pub dispatch: String, + pub enabled: bool, +} + // ---------------------------------------------------------------------------- // Errors // ---------------------------------------------------------------------------- @@ -1935,6 +1950,46 @@ impl ApplyService { .collect()) } + /// Read-only §11 tail report: a group's own route TEMPLATES, surfaced as + /// the inherited binding tuple + handler script name. Backs + /// `pic routes ls --group`. + pub async fn route_report( + &self, + owner: ApplyOwner, + ) -> Result, ApplyError> { + let ApplyOwner::Group(group_id) = owner else { + return Ok(Vec::new()); + }; + let routes = self + .routes + .list_for_group(group_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let scripts = self + .scripts + .list_for_group(group_id) + .await + .map_err(|e| ApplyError::Backend(e.to_string()))?; + let name_by_id: HashMap = + scripts.iter().map(|s| (s.id, s.name.clone())).collect(); + Ok(routes + .into_iter() + .map(|r| RouteTemplateInfo { + method: r.method.clone().unwrap_or_else(|| "ANY".into()), + host: match r.host_kind { + HostKind::Any => "any".to_string(), + HostKind::Strict => format!("strict:{}", r.host), + HostKind::Wildcard => format!("*.{}", r.host), + }, + path_kind: path_kind_str(r.path_kind).to_string(), + path: r.path.clone(), + script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(), + dispatch: r.dispatch_mode.as_str().to_string(), + enabled: r.enabled, + }) + .collect()) + } + pub async fn collection_report( &self, owner: ApplyOwner, diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index d08ff7b..8a8b913 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1360,6 +1360,17 @@ impl Client { .await?; decode(resp).await } + + /// `GET /api/v1/admin/groups/{ident}/routes` (§11 tail). Group-only — route + /// templates are declared on groups and inherited by descendant apps. + pub async fn group_routes_list(&self, group_ident: &str) -> Result> { + let ident = seg(group_ident); + let resp = self + .request(Method::GET, &format!("/api/v1/admin/groups/{ident}/routes")) + .send() + .await?; + decode(resp).await + } } /// One row of the §11 tail trigger-template report. @@ -1373,6 +1384,24 @@ pub struct TriggerTemplateDto { pub enabled: bool, } +/// One row of the §11 tail route-template report. +#[derive(Debug, Deserialize)] +pub struct RouteTemplateDto { + #[serde(default)] + pub method: String, + #[serde(default)] + pub host: String, + #[serde(default)] + pub path_kind: String, + #[serde(default)] + pub path: String, + #[serde(default)] + pub script: String, + #[serde(default)] + pub dispatch: String, + pub enabled: bool, +} + /// One row of the §11.6 shared-collection report. #[derive(Debug, Deserialize)] pub struct CollectionInfoDto { diff --git a/crates/picloud-cli/src/cmds/routes.rs b/crates/picloud-cli/src/cmds/routes.rs index 430a67b..ab13369 100644 --- a/crates/picloud-cli/src/cmds/routes.rs +++ b/crates/picloud-cli/src/cmds/routes.rs @@ -35,6 +35,37 @@ pub async fn ls(script_id: &str, mode: OutputMode) -> Result<()> { Ok(()) } +/// `pic routes ls --group ` — read-only view of a group's §11 tail route +/// TEMPLATES (inherited live by every descendant app). Authoring is declarative +/// via the `[group]` manifest `[[routes]]`. +pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> { + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let rows = client.group_routes_list(group).await?; + let mut table = Table::new([ + "method", + "host", + "path_kind", + "path", + "script", + "dispatch", + "enabled", + ]); + for r in rows { + table.row([ + r.method, + r.host, + r.path_kind, + r.path, + r.script, + r.dispatch, + r.enabled.to_string(), + ]); + } + table.print(mode); + Ok(()) +} + #[allow(clippy::too_many_arguments)] pub async fn create( script_id: &str, diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 2f7885d..045831b 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -821,8 +821,14 @@ impl From for picloud_shared::InstanceRole { #[derive(Subcommand)] enum RoutesCmd { - /// List routes bound to a script. - Ls { script_id: String }, + /// List routes bound to a script, or a group's route TEMPLATES (`--group`). + Ls { + #[arg(conflicts_with = "group", required_unless_present = "group")] + script_id: Option, + /// List the group's §11 tail route templates instead. + #[arg(long)] + group: Option, + }, /// Create a new route. Host defaults to `*` (any). Path-kind /// defaults to `exact`. Dispatch defaults to `sync`. @@ -1608,8 +1614,12 @@ async fn main() -> ExitCode { Err(e) => Err(e), }, Cmd::Routes { - cmd: RoutesCmd::Ls { script_id }, - } => cmds::routes::ls(&script_id, mode).await, + cmd: RoutesCmd::Ls { script_id, group }, + } => match (script_id, group) { + (_, Some(group)) => cmds::routes::ls_group(&group, mode).await, + (Some(script_id), None) => cmds::routes::ls(&script_id, mode).await, + (None, None) => Err(anyhow::anyhow!("provide a script id or --group")), + }, Cmd::Routes { cmd: RoutesCmd::Create { diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index e46ac0e..49c835e 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -762,13 +762,22 @@ mod tests { assert_eq!(m.slug(), "acme"); assert_eq!(m.scripts.len(), 1); - // A group cannot carry routes/triggers. - let err = Manifest::parse( + // §11 tail: a group MAY carry ROUTE templates (inherited by descendants). + let routed = Manifest::parse( "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ [[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n", ) - .expect_err("group with routes is rejected"); - assert!(err.to_string().contains("routes"), "got: {err}"); + .expect("group with a route template parses"); + assert!(routed.is_group()); + assert_eq!(routed.routes.len(), 1); + + // ...but a STATEFUL trigger kind (cron/queue/email) on a group is rejected. + let err = Manifest::parse( + "[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\ + [[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n", + ) + .expect_err("group with a cron trigger is rejected"); + assert!(err.to_string().contains("event kind"), "got: {err}"); // Neither / both is rejected. Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]"); diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index d4c438a..1c678f8 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -26,6 +26,7 @@ mod enabled; mod env_overlay; mod extension_points; mod group_modules; +mod group_routes; mod group_scripts; mod group_secrets; mod group_triggers; diff --git a/crates/picloud-cli/tests/group_routes.rs b/crates/picloud-cli/tests/group_routes.rs new file mode 100644 index 0000000..8ccb047 --- /dev/null +++ b/crates/picloud-cli/tests/group_routes.rs @@ -0,0 +1,154 @@ +//! §11 tail — group ROUTE templates, declarative authoring + live inheritance +//! end to end via `pic`. A group declares a `[[routes]]` template binding a +//! group-owned handler; the template is served by a **descendant** app's route +//! table (verified via `routes match`, which queries the live in-memory +//! `RouteTable`), is NOT served by a **sibling**-subtree app, `pic routes ls +//! --group` shows it, and re-apply is a NoOp. +//! +//! Nearest-wins shadowing (an app's own identical route beats the inherited +//! template) and the sibling isolation are pinned deterministically at the repo +//! layer by `manager-core/tests/group_route_templates.rs`; this journey +//! exercises the authoring + the orchestrator dispatch path the operator uses. + +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 +} + +/// Id of the named script in a group (`pic scripts ls --group `). +fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String { + let ls = common::pic_as(env) + .args(["scripts", "ls", "--group", group]) + .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!("group script `{name}` not found:\n{table}")) +} + +/// `routes match` against an app — true iff a route matched the URL. +fn route_matches(env: &common::TestEnv, app: &str, url: &str) -> bool { + let out = common::pic_as(env) + .args(["routes", "match", "--app", app, url]) + .output() + .expect("routes match"); + let stdout = String::from_utf8(out.stdout).unwrap(); + stdout.contains("matched") && stdout.contains("true") +} + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn group_route_template_serves_descendant_and_lists() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let group = common::unique_slug("grt-grp"); + + let _g = GroupGuard::new(&env.url, &env.token, &group); + common::pic_as(&env) + .args(["groups", "create", &group]) + .assert() + .success(); + + // A group-owned handler script the route template binds. + let dir = manifest_dir(); + fs::write( + dir.path().join("scripts/ghello.rhai"), + r#"log::info("group route fired"); "ok""#, + ) + .unwrap(); + common::pic_as(&env) + .args(["scripts", "deploy"]) + .arg(dir.path().join("scripts/ghello.rhai")) + .args(["--group", &group, "--name", "ghello"]) + .assert() + .success(); + let _gs = ScriptGuard::new( + &env.url, + &env.token, + &group_script_id(&env, &group, "ghello"), + ); + + // The group declares a route TEMPLATE binding the handler. + let gmanifest = format!( + "[group]\nslug = \"{group}\"\nname = \"GRoutes\"\n\n\ + [[routes]]\nscript = \"ghello\"\npath = \"/ghello\"\npath_kind = \"exact\"\n\ + host_kind = \"any\"\n" + ); + let gpath = dir.path().join("group.toml"); + fs::write(&gpath, &gmanifest).unwrap(); + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .assert() + .success(); + + // `routes ls --group` shows the template (path + handler). + let ls = String::from_utf8( + common::pic_as(&env) + .args(["routes", "ls", "--group", &group]) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + ls.contains("/ghello") && ls.contains("ghello"), + "routes ls --group should list the template binding ghello:\n{ls}" + ); + + // Re-apply is a NoOp (the template marker already exists). + let report = String::from_utf8( + common::pic_as(&env) + .args(["apply", "--file"]) + .arg(&gpath) + .output() + .unwrap() + .stdout, + ) + .unwrap(); + assert!( + !report.contains("route") + || report.to_lowercase().contains("no changes") + || report.contains('0'), + "re-apply should not create a second template:\n{report}" + ); + + // A DESCENDANT app under the group serves the inherited template. + let blog = common::unique_slug("grt-blog"); + let _ba = AppGuard::new(&env.url, &env.token, &blog); + common::pic_as(&env) + .args(["apps", "create", &blog, "--group", &group]) + .assert() + .success(); + assert!( + route_matches(&env, &blog, "http://localhost/ghello"), + "a descendant app must serve the inherited group route template" + ); + + // A SIBLING-subtree app (created at the instance root, not under the group) + // must NOT serve it — the chain expansion is the isolation boundary. + let other = common::unique_slug("grt-other"); + let _oa = AppGuard::new(&env.url, &env.token, &other); + common::pic_as(&env) + .args(["apps", "create", &other]) + .assert() + .success(); + assert!( + !route_matches(&env, &other, "http://localhost/ghello"), + "a sibling-subtree app must NOT serve another subtree's route template" + ); +} diff --git a/docs/design/groups-and-project-tool.md b/docs/design/groups-and-project-tool.md index 74d12c2..e18e289 100644 --- a/docs/design/groups-and-project-tool.md +++ b/docs/design/groups-and-project-tool.md @@ -392,10 +392,30 @@ Two distinct constraints: > sibling-subtree app never sees the template (pinned by `tests/group_trigger_templates.rs`). Authoring > is `[[triggers.kv]]` (etc.) on a `[group]`; read-only `pic triggers ls --group`. **Deferred:** the > **stateful** kinds (cron `last_fired_at`, queue advisory-lock, email sealed secret) need per-app rows -> → materialization, rejected on a `[group]`; **route templates** (same live shape, but the in-memory -> `RouteTable` would need inherited templates per app + new recompile-invalidation edges); per-app -> opt-out / cross-owner dedup (a descendant re-declaring an identical trigger double-fires — "overlapping -> triggers coexist"). +> → materialization, rejected on a `[group]`; per-app opt-out / cross-owner dedup (a descendant +> re-declaring an identical trigger double-fires — "overlapping triggers coexist"). +> +> **Shipped — group ROUTE templates (live, inherited).** The same live model, adapted to routes. A +> `[group]` declares a `[[routes]]` template binding a group-owned endpoint; `routes` gained a +> polymorphic owner (`0057_group_routes.sql`, nullable `group_id`, mirrors `0056`). Unlike triggers +> (dispatched by a per-event SQL query), routes are served from the in-memory `RouteTable`, so the HTTP +> hot path can't resolve inheritance per request — instead the table **rebuild** expands templates into +> each descendant app's slice via `RouteRepository::list_effective` (the all-apps generalization of +> `CHAIN_LEVELS_CTE`: every app × its ancestor chain ⋈ routes, tagged with the effective app + owner +> depth). `compile_effective_routes` applies **nearest-owner-wins shadowing** — for one app, an +> identical binding tuple (method+host+path) owned at multiple chain levels collapses to the nearest +> (an app's own route shadows an ancestor-group template; a route picks one winner, unlike a fanning +> trigger), while non-identical bindings coexist under the existing matcher precedence. The match + +> dispatch paths are unchanged; the group handler runs under the firing app's `app_id`. **The chain +> expansion is the isolation boundary** — a template lands only in the slices of apps whose ancestor +> chain contains the owning group (pinned by `tests/group_route_templates.rs` + the `group_routes` +> journey). Because the table is a cache, inheritance is rebuilt **full-live** on every edge that +> changes it: route CRUD, apply, **and tree mutations** — app create/delete (`apps_api`) and group +> reparent (`groups_api`) all route through the single `rebuild_route_table` chokepoint, so a new app +> under a group serves its templates instantly. Host-claim validation is skipped for a group template +> (no single app; descendants serve it on whatever host they claim, so templates use `host_kind = any`). +> Authoring is `[[routes]]` on a `[group]`; read-only `pic routes ls --group`. **Deferred:** per-app +> route opt-out, and multi-node route-snapshot propagation (cluster mode). ### 4.6 Secrets & `pull`