feat(hierarchies): route templates — group-declared, per-app fan-out (M4a)
§4.5 of the groups/project-tool design. A group declares a route ONCE; the
tree apply fans it out into one concrete per-app_id route on every descendant
app — instantiation, not inheritance (the cross-app isolation boundary is
unchanged; expanded rows stay app-owned).
- Migration 0053: `route_templates` table (group-owned) + `routes.from_template`
provenance column + index.
- `template_repo.rs`: tx-aware CRUD + chain-load (mirrors group_repo/route_repo).
- Manifest: `[group]` `[[route_templates]]`; build_bundle emits them; rejected
on an app node (422).
- Apply: group nodes reconcile template rows (create/update/delete via the
plan); tree Phase B expands each chain template into a concrete route per
descendant app node — placeholders {app_slug}/{env}/{var:NAME} resolved per
app (unknown var/placeholder = hard error), script bound nearest-owner-wins.
Idempotent via from_template (diffed create/update-as-replace/delete; re-apply
is no-churn, --prune reaps expansions). Collision with a hand-declared route,
or between two templates, is a hard error. Each RESOLVED expansion is
validated like a hand-declared route (reserved-path, path/host parse,
host-claim) so a {var:}-injected path or unclaimed strict host can't slip in.
- Plan: route-template diff at the group node + blast-radius (descendant app
count in this apply); state_token folds each template (edit trips StateMoved).
- Authz: declaring → GroupScriptsWrite; an app receiving expansions →
AppWriteRoute (gated even with no hand-declared routes).
- Tests: tests/templates.rs (fan-out + per-slug + idempotent-stable-ids +
prune-reap + collision-rejected); placeholder/validation unit tests; schema
golden reblessed.
Reviewed (no CRITICAL/HIGH; isolation core verified sound). Closed the two
validation-parity MEDIUMs (host-claim + reserved-path/pattern on expansions)
and added an out-of-apply-descendant prune warning. Scope: expansion targets
app nodes in the tree apply (not yet cross-repo descendants); {var:NAME} uses
committed vars. M4b (trigger templates) reuses this engine next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
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);
|
||||||
@@ -88,6 +88,7 @@ async fn seed_into(
|
|||||||
method: None,
|
method: None,
|
||||||
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
dispatch_mode: picloud_shared::DispatchMode::Sync,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
from_template: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,10 @@ struct TreeApplyRequest {
|
|||||||
/// Take over declared groups owned by another project (group-admin gated).
|
/// Take over declared groups owned by another project (group-admin gated).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
allow_takeover: bool,
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tree_plan_handler(
|
async fn tree_plan_handler(
|
||||||
@@ -237,6 +241,7 @@ async fn tree_apply_handler(
|
|||||||
req.expected_token.as_deref(),
|
req.expected_token.as_deref(),
|
||||||
req.project_key.as_deref(),
|
req.project_key.as_deref(),
|
||||||
req.allow_takeover,
|
req.allow_takeover,
|
||||||
|
req.env.as_deref(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(Json(report))
|
Ok(Json(report))
|
||||||
@@ -261,6 +266,20 @@ async fn authz_tree(
|
|||||||
Some(prune) => {
|
Some(prune) => {
|
||||||
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
|
||||||
.await?;
|
.await?;
|
||||||
|
// Template expansion (§4.5, M4a) writes routes INTO this
|
||||||
|
// app, so the actor needs AppWriteRoute when the app's
|
||||||
|
// chain carries route templates — even if the app itself
|
||||||
|
// declares no routes. Without this, expanding routes into
|
||||||
|
// an app a principal can't write would slip the gate.
|
||||||
|
if app_receives_template_expansions(svc, app_id, bundle).await? {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
principal,
|
||||||
|
Capability::AppWriteRoute(app_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
|
||||||
@@ -467,8 +486,13 @@ async fn require_group_node_writes(
|
|||||||
bundle: &Bundle,
|
bundle: &Bundle,
|
||||||
prune: bool,
|
prune: bool,
|
||||||
) -> Result<(), ApplyError> {
|
) -> Result<(), ApplyError> {
|
||||||
// Extension points gate on the script-write tier (see the app variant).
|
// Extension points and route templates gate on the script-write tier (see
|
||||||
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
|
// the app variant) — both are code-binding declarations, not viewer-tier.
|
||||||
|
if prune
|
||||||
|
|| !bundle.scripts.is_empty()
|
||||||
|
|| !bundle.extension_points.is_empty()
|
||||||
|
|| !bundle.route_templates.is_empty()
|
||||||
|
{
|
||||||
require(
|
require(
|
||||||
svc.authz.as_ref(),
|
svc.authz.as_ref(),
|
||||||
principal,
|
principal,
|
||||||
@@ -489,6 +513,59 @@ async fn require_group_node_writes(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Will route-template expansion (§4.5, M4a) write routes into `app_id` during
|
||||||
|
/// this tree apply? True iff the app's ancestor chain carries a route template,
|
||||||
|
/// counting both committed templates and ones declared by a group node in THIS
|
||||||
|
/// bundle. Drives the `AppWriteRoute` gate for expansion recipients.
|
||||||
|
async fn app_receives_template_expansions(
|
||||||
|
svc: &ApplyService,
|
||||||
|
app_id: AppId,
|
||||||
|
bundle: &TreeBundle,
|
||||||
|
) -> Result<bool, ApplyError> {
|
||||||
|
let Some(app) = svc
|
||||||
|
.apps
|
||||||
|
.get_by_id(app_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
|
||||||
|
// Committed templates on any ancestor group.
|
||||||
|
for gid in &ancestor_ids {
|
||||||
|
if !crate::template_repo::list_for_group(&svc.pool, *gid)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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 && !node.bundle.route_templates.is_empty() {
|
||||||
|
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) {
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
||||||
async fn resolve_group_id(
|
async fn resolve_group_id(
|
||||||
groups: &dyn GroupRepository,
|
groups: &dyn GroupRepository,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,7 @@ pub mod secrets_api;
|
|||||||
pub mod secrets_repo;
|
pub mod secrets_repo;
|
||||||
pub mod secrets_service;
|
pub mod secrets_service;
|
||||||
pub mod ssrf;
|
pub mod ssrf;
|
||||||
|
pub mod template_repo;
|
||||||
pub mod topic_repo;
|
pub mod topic_repo;
|
||||||
pub mod topics_api;
|
pub mod topics_api;
|
||||||
pub mod trigger_config;
|
pub mod trigger_config;
|
||||||
|
|||||||
@@ -241,6 +241,8 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
|||||||
dispatch_mode: input.dispatch_mode,
|
dispatch_mode: input.dispatch_mode,
|
||||||
// Routes are created active; toggling is a dedicated path.
|
// Routes are created active; toggling is a dedicated path.
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
// Hand-declared via the interactive API — not a template expansion.
|
||||||
|
from_template: None,
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
refresh_table(&state).await?;
|
refresh_table(&state).await?;
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ pub struct NewRoute {
|
|||||||
pub dispatch_mode: DispatchMode,
|
pub dispatch_mode: DispatchMode,
|
||||||
/// Three-state lifecycle (§4.3). Create active by default.
|
/// Three-state lifecycle (§4.3). Create active by default.
|
||||||
pub enabled: bool,
|
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]
|
#[async_trait]
|
||||||
@@ -175,8 +179,8 @@ pub(crate) async fn insert_route_tx(
|
|||||||
let res = sqlx::query_as::<_, RouteRow>(
|
let res = sqlx::query_as::<_, RouteRow>(
|
||||||
"INSERT INTO routes ( \
|
"INSERT INTO routes ( \
|
||||||
app_id, script_id, host_kind, host, host_param_name, \
|
app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, dispatch_mode, enabled \
|
path_kind, path, method, dispatch_mode, enabled, from_template \
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
|
||||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||||
path_kind, path, method, dispatch_mode, enabled, created_at",
|
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.method.as_deref())
|
||||||
.bind(input.dispatch_mode.as_str())
|
.bind(input.dispatch_mode.as_str())
|
||||||
.bind(input.enabled)
|
.bind(input.enabled)
|
||||||
|
.bind(input.from_template)
|
||||||
.fetch_one(&mut **tx)
|
.fetch_one(&mut **tx)
|
||||||
.await;
|
.await;
|
||||||
match res {
|
match res {
|
||||||
|
|||||||
200
crates/manager-core/src/template_repo.rs
Normal file
200
crates/manager-core/src/template_repo.rs
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
//! 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(())
|
||||||
|
}
|
||||||
@@ -298,6 +298,22 @@ table: queue_trigger_details
|
|||||||
visibility_timeout_secs: integer NOT NULL default=30
|
visibility_timeout_secs: integer NOT NULL default=30
|
||||||
last_fired_at: timestamp with time zone NULL
|
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
|
table: routes
|
||||||
id: uuid NOT NULL default=gen_random_uuid()
|
id: uuid NOT NULL default=gen_random_uuid()
|
||||||
script_id: uuid NOT NULL
|
script_id: uuid NOT NULL
|
||||||
@@ -311,6 +327,7 @@ table: routes
|
|||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
dispatch_mode: text NOT NULL default='sync'::text
|
dispatch_mode: text NOT NULL default='sync'::text
|
||||||
enabled: boolean NOT NULL default=true
|
enabled: boolean NOT NULL default=true
|
||||||
|
from_template: uuid NULL
|
||||||
|
|
||||||
table: script_imports
|
table: script_imports
|
||||||
app_id: uuid NOT NULL
|
app_id: uuid NOT NULL
|
||||||
@@ -531,8 +548,13 @@ indexes on queue_trigger_details:
|
|||||||
idx_queue_trigger_details_queue_name: public.queue_trigger_details USING btree (queue_name)
|
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)
|
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:
|
indexes on routes:
|
||||||
routes_app_id_idx: public.routes USING btree (app_id)
|
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_lookup_idx: public.routes USING btree (host_kind, host)
|
||||||
routes_pkey: public.routes USING btree (id)
|
routes_pkey: public.routes USING btree (id)
|
||||||
routes_script_id_idx: public.routes USING btree (script_id)
|
routes_script_id_idx: public.routes USING btree (script_id)
|
||||||
@@ -739,6 +761,13 @@ constraints on queue_trigger_details:
|
|||||||
[FOREIGN KEY] queue_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
[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)
|
[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:
|
constraints on routes:
|
||||||
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
|
[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])))
|
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||||
@@ -841,3 +870,4 @@ constraints on vars:
|
|||||||
0050: group scripts
|
0050: group scripts
|
||||||
0051: extension points
|
0051: extension points
|
||||||
0052: owner project
|
0052: owner project
|
||||||
|
0053: templates
|
||||||
|
|||||||
@@ -1272,6 +1272,7 @@ impl Client {
|
|||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
project_key: Option<&str>,
|
project_key: Option<&str>,
|
||||||
allow_takeover: bool,
|
allow_takeover: bool,
|
||||||
|
env: Option<&str>,
|
||||||
) -> Result<ApplyReportDto> {
|
) -> Result<ApplyReportDto> {
|
||||||
let body = serde_json::json!({
|
let body = serde_json::json!({
|
||||||
"bundle": bundle,
|
"bundle": bundle,
|
||||||
@@ -1279,6 +1280,7 @@ impl Client {
|
|||||||
"expected_token": expected_token,
|
"expected_token": expected_token,
|
||||||
"project_key": project_key,
|
"project_key": project_key,
|
||||||
"allow_takeover": allow_takeover,
|
"allow_takeover": allow_takeover,
|
||||||
|
"env": env,
|
||||||
});
|
});
|
||||||
let resp = self
|
let resp = self
|
||||||
.request(Method::POST, "/api/v1/admin/tree/apply")
|
.request(Method::POST, "/api/v1/admin/tree/apply")
|
||||||
@@ -1387,6 +1389,9 @@ pub struct TreePlanDto {
|
|||||||
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
|
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub structural_prunes: Vec<String>,
|
pub structural_prunes: Vec<String>,
|
||||||
|
/// Route-template fan-out per declaring group (§4.5, M4a).
|
||||||
|
#[serde(default)]
|
||||||
|
pub template_blast_radius: Vec<TemplateBlastRadiusDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single ownership conflict (`pic plan --dir`).
|
/// A single ownership conflict (`pic plan --dir`).
|
||||||
@@ -1396,6 +1401,13 @@ pub struct OwnershipConflictDto {
|
|||||||
pub owner_key: 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct NodePlanDto {
|
pub struct NodePlanDto {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
@@ -1412,6 +1424,8 @@ pub struct NodePlanDto {
|
|||||||
pub vars: Vec<ChangeDto>,
|
pub vars: Vec<ChangeDto>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub extension_points: Vec<ChangeDto>,
|
pub extension_points: Vec<ChangeDto>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub route_templates: Vec<ChangeDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Response of `POST .../apply`: counts of what changed.
|
/// Response of `POST .../apply`: counts of what changed.
|
||||||
@@ -1456,6 +1470,12 @@ pub struct ApplyReportDto {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub groups_pruned: u32,
|
pub groups_pruned: u32,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub route_templates_created: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub route_templates_updated: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub route_templates_deleted: u32,
|
||||||
|
#[serde(default)]
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ pub async fn run_tree(
|
|||||||
expected_token.as_deref(),
|
expected_token.as_deref(),
|
||||||
Some(&project_key),
|
Some(&project_key),
|
||||||
takeover,
|
takeover,
|
||||||
|
env,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
crate::linkstate::clear_plan(dir, token_key);
|
crate::linkstate::clear_plan(dir, token_key);
|
||||||
@@ -216,6 +217,22 @@ pub async fn run_tree(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
for w in &report.warnings {
|
for w in &report.warnings {
|
||||||
block.field("warning", w.clone());
|
block.field("warning", w.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
|
|||||||
secrets: crate::manifest::ManifestSecrets::default(),
|
secrets: crate::manifest::ManifestSecrets::default(),
|
||||||
vars: std::collections::BTreeMap::new(),
|
vars: std::collections::BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
|
route_templates: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,13 +67,14 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
|
||||||
for n in &plan.nodes {
|
for n in &plan.nodes {
|
||||||
let node = format!("{}:{}", n.kind, n.slug);
|
let node = format!("{}:{}", n.kind, n.slug);
|
||||||
let groups: [(&str, &Vec<ChangeDto>); 6] = [
|
let groups: [(&str, &Vec<ChangeDto>); 7] = [
|
||||||
("script", &n.scripts),
|
("script", &n.scripts),
|
||||||
("route", &n.routes),
|
("route", &n.routes),
|
||||||
("trigger", &n.triggers),
|
("trigger", &n.triggers),
|
||||||
("secret", &n.secrets),
|
("secret", &n.secrets),
|
||||||
("var", &n.vars),
|
("var", &n.vars),
|
||||||
("extension-point", &n.extension_points),
|
("extension-point", &n.extension_points),
|
||||||
|
("route-template", &n.route_templates),
|
||||||
];
|
];
|
||||||
for (rk, changes) in groups {
|
for (rk, changes) in groups {
|
||||||
for c in changes {
|
for c in changes {
|
||||||
@@ -104,6 +105,12 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
|
|||||||
eprintln!(" - {slug}");
|
eprintln!(" - {slug}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !plan.template_blast_radius.is_empty() {
|
||||||
|
eprintln!("\nroute-template blast radius (apps IN THIS apply that get expansions):");
|
||||||
|
for b in &plan.template_blast_radius {
|
||||||
|
eprintln!(" - {} → {} app(s)", b.group, b.affected_apps);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +197,15 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|||||||
})
|
})
|
||||||
.collect();
|
.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<_>, _>>()?;
|
||||||
|
|
||||||
Ok(json!({
|
Ok(json!({
|
||||||
"scripts": scripts,
|
"scripts": scripts,
|
||||||
"routes": routes,
|
"routes": routes,
|
||||||
@@ -197,6 +213,7 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
|||||||
"secrets": manifest.secrets.names,
|
"secrets": manifest.secrets.names,
|
||||||
"vars": vars,
|
"vars": vars,
|
||||||
"extension_points": extension_points,
|
"extension_points": extension_points,
|
||||||
|
"route_templates": route_templates,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -237,6 +237,8 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
},
|
},
|
||||||
vars: manifest_vars,
|
vars: manifest_vars,
|
||||||
extension_points,
|
extension_points,
|
||||||
|
// `pull` is app-scoped; route templates are group-owned (§4.5, M4a).
|
||||||
|
route_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ pub struct Manifest {
|
|||||||
/// resolution (§5.5). Allowed on both app and group manifests.
|
/// resolution (§5.5). Allowed on both app and group manifests.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub extension_points: Vec<ManifestExtensionPoint>,
|
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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Manifest {
|
impl Manifest {
|
||||||
@@ -275,6 +280,32 @@ pub struct ManifestRoute {
|
|||||||
pub enabled: bool,
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct ManifestTriggers {
|
pub struct ManifestTriggers {
|
||||||
@@ -511,6 +542,7 @@ mod tests {
|
|||||||
name: "theme".into(),
|
name: "theme".into(),
|
||||||
default: Some("default-theme".into()),
|
default: Some("default-theme".into()),
|
||||||
}],
|
}],
|
||||||
|
route_templates: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,6 +647,7 @@ mod tests {
|
|||||||
secrets: ManifestSecrets::default(),
|
secrets: ManifestSecrets::default(),
|
||||||
vars: BTreeMap::new(),
|
vars: BTreeMap::new(),
|
||||||
extension_points: Vec::new(),
|
extension_points: Vec::new(),
|
||||||
|
route_templates: Vec::new(),
|
||||||
};
|
};
|
||||||
let text = m.to_toml().unwrap();
|
let text = m.to_toml().unwrap();
|
||||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ mod routes;
|
|||||||
mod scripts;
|
mod scripts;
|
||||||
mod secrets;
|
mod secrets;
|
||||||
mod staleness;
|
mod staleness;
|
||||||
|
mod templates;
|
||||||
mod tree;
|
mod tree;
|
||||||
mod tree_shape;
|
mod tree_shape;
|
||||||
mod triggers;
|
mod triggers;
|
||||||
|
|||||||
218
crates/picloud-cli/tests/templates.rs
Normal file
218
crates/picloud-cli/tests/templates.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
//! 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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -375,11 +375,31 @@ Two distinct constraints:
|
|||||||
identity token (collection / queue / topic; for cron/email/dead-letter fall back to `{kind}-{n}`),
|
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.
|
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
|
> **Ergonomic debt (being paid down):** because triggers/routes don't inherit, 100 tenant apps each
|
||||||
> needing the same 5 triggers = 500 declarations. The fix is group trigger/route **templates** that
|
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
|
||||||
> fan out per descendant (a *template/instantiation* mechanism, not inheritance) — deferred, but it
|
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
|
||||||
> bites early if tenant cardinality is high. Pressure-test against real tenant counts before
|
>
|
||||||
> committing to the narrow-inheritance choice (§5.1).
|
> **Status: ROUTE templates ✅ shipped (M4a, 2026-06-28).** Migration `0053_templates.sql` adds a
|
||||||
|
> `route_templates` table (owned by a group) and a `routes.from_template` provenance column. A
|
||||||
|
> `[group]` manifest declares `[[route_templates]]`; `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` 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). Expansion is **idempotent via `from_template`** (diffed create/update-as-replace/delete, so
|
||||||
|
> re-apply doesn't churn and a removed/disabled template reaps its routes under `--prune`); an
|
||||||
|
> expansion colliding with a hand-declared route, 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` (gated even when the app declares no routes
|
||||||
|
> of its own). Each RESOLVED expansion is validated with the same rules a hand-declared route gets —
|
||||||
|
> reserved-path rejection, structural path/host parse, and host-claim check — so a `{var:…}`-injected
|
||||||
|
> reserved path or an unclaimed strict host can't slip in. **Scope:** expansion targets app NODES
|
||||||
|
> present in the tree apply (the common `pic apply --dir` case), not yet every descendant in another
|
||||||
|
> repo — deleting a template leaves expansions in out-of-apply descendants until they're re-applied
|
||||||
|
> (the apply warns); `{var:NAME}` resolves against *committed* vars (a var set in the same apply lands
|
||||||
|
> next apply). **Trigger templates (M4b) remain** — they reuse this engine over the `triggers` insert
|
||||||
|
> path (the seven kinds + email-secret sealing).
|
||||||
|
|
||||||
### 4.6 Secrets & `pull`
|
### 4.6 Secrets & `pull`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user