feat: custom routing — bind scripts to your own URLs

Scripts can now answer at user-chosen paths (e.g. /greet, /greet/:name,
/webhooks/*), on user-chosen hosts (strict or *.example.com wildcards),
on user-chosen methods. The internal /api/v1/execute/{id} endpoint
stays as the always-available ID-based bypass.

Routing rules (decided in design with the user; see chat history):

  Path kinds:
    exact   /greet              literal
    prefix  /greet/*            strict-subtree; stored as "/greet/";
                                does NOT match bare /greet (add an
                                exact route for that case)
    param   /users/:id          :name captures one whole segment;
                                mid-segment colons are rejected;
                                {name} is reserved for a future SDK

  Host kinds:
    any                         no Host header constraint
    strict  sub.example.com     literal match (case-insensitive)
    wildcard *.example.com      suffix match; multi-level subdomains OK

  Within-kind uniqueness:
    two routes of the same kind that could match the same request
    conflict at config time. Algorithm (orchestrator_core::routing::
    conflict):
      exact:  literal equality
      prefix: literal equality (longer-prefix coexists; longer wins
              at request time)
      param:  same segment count + same literals at every
              literal-vs-literal position (the user's example:
              :id vs :userId at same shape is a conflict)

  Request-time precedence:
    exact > param > prefix
    among non-exact: more leading-literal segments wins
    tie: param > prefix (more constrained)
    within prefix: longest matching prefix wins
    host bucket: strict > wildcard (longer suffix) > any; fall through
    to less specific buckets when path doesn't match

  Reserved path prefixes: /api/, /admin/, /healthz, /version

  Routes that look invalid at config time return 422 with the precise
  parse error; conflicting routes return 409 with the conflicting route
  in the body (so the dashboard can render the conflict inline).

What landed:

  * 0003_routes.sql — routes table (host_kind, host, host_param_name,
    path_kind, path, method, script_id) with UNIQUE index on the
    literal binding tuple. Schema 2 → 3.

  * shared::Route / HostKind / PathKind — flat storage shape that
    crosses wire boundaries cleanly.

  * orchestrator_core::routing — four sub-modules, all unit-tested:
      pattern.rs (16 tests)  parse + validate + display
      conflict.rs (12 tests) within-kind overlap predicate
      matcher.rs (12 tests)  runtime dispatch (specificity-aware)
      table.rs               Arc<RwLock<Vec<CompiledRoute>>>
                             shared by manager (writes) and
                             orchestrator (reads); atomic replace
                             after each admin write

  * manager-core::route_admin — five new admin endpoints under
    /api/v1/admin:
      POST   /scripts/{id}/routes      create
      GET    /scripts/{id}/routes      list per script
      DELETE /routes/{route_id}        delete (refreshes table)
      POST   /routes:check             pre-flight conflict check
                                       (powers the dashboard's
                                       live conflict warning)
      POST   /routes:match             synthetic URL → matched
                                       route + extracted params
                                       (powers the dashboard's
                                       match-preview tool)
    Stored path strings stay raw (user-typed); normalization
    happens only in the in-memory CompiledRoute so re-parses are
    idempotent.

  * orchestrator_core::api::user_routes_router — fallback handler
    mounted in picloud after the system routes. Reads Host /
    method / path / query from the request, dispatches via the
    table, builds an ExecRequest with params/query/rest filled,
    calls the executor, writes to the log sink. 10 MiB body cap.

  * executor-core::ctx (SDK 1.0 → 1.1) — adds
      ctx.request.params  (map of named-param captures)
      ctx.request.query   (parsed query string)
      ctx.request.rest    (suffix for prefix routes; "" otherwise)
    All three are always present (empty when not applicable) so
    scripts can read them unconditionally.

  * picloud::build_app — now async; loads routes at startup,
    populates the shared table, mounts route_admin_router under
    /api/v1/admin alongside the script CRUD, and the user-routes
    fallback at the app root.

  * caddy/Caddyfile + Caddyfile.prod widened: anything not
    /healthz, /version, /api/v1/admin/*, /api/v1/execute/*,
    /api/* (404 sunset), or /admin/* (dashboard) → picloud.

  * Dashboard moves to /admin/* via SvelteKit paths.base. Its
    internal Caddy strips the prefix and serves with SPA fallback.
    All in-app links use $app/paths. The dashboard URL is now
    http://localhost:8000/admin/ — one-time break for the new
    URL freedom users gained.

  * PICLOUD_PUBLIC_BASE_URL env var, exposed via /version so the
    dashboard renders full URLs for routes regardless of the
    operator's external port / TLS setup.

  * memory_limit_mb stays in the schema, still v1.3+ advisory.

Verified live through Caddy:
  /version              → schema 3, sdk 1.1, public_base_url
  GET /admin/           → 200, dashboard HTML containing "PiCloud"
  POST /api/v1/admin/scripts → 201
  POST .../scripts/{id}/routes (path=/greet/:name) → 201
  GET /greet/alice?lang=en → 200 {"name":"alice","q":"en"}
  POST conflicting route → 409 with conflicting_route body
  POST /admin/foo route → 422 "reserved"
  POST /api/v1/admin/routes:match → matched + params extracted
  GET /unbound-path → 404 JSON

Tests:
  * 40 routing unit tests (pattern + conflict + matcher tables)
  * 14 executor-core unit tests (one new for ctx.request.params/
    query/rest exposure)
  * 32 integration tests (10 new for routing CRUD + dispatch +
    conflict + reserved + specificity tie-break + match preview +
    delete invalidation + /version returns public_base_url)
  * default cargo test --workspace stays green; opt-in via
    DATABASE_URL + --include-ignored for the integration suite

Bumps: schema 2 → 3; SDK 1.0 → 1.1; product 0.3.0 → 0.4.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-23 18:18:16 +02:00
parent f51924fdbc
commit 07e2a62d98
36 changed files with 2449 additions and 111 deletions

View File

@@ -25,3 +25,7 @@ PICLOUD_POSTGRES_HOST_PORT=15432
# ---------- Orchestrator ----------
# tracing-subscriber filter; comma-separated module=level pairs.
RUST_LOG=info,picloud=debug
# Public base URL the dashboard uses to render full URLs for user routes.
# Set to the host:port (and scheme) users actually reach in their browser.
PICLOUD_PUBLIC_BASE_URL=http://localhost:8000

View File

@@ -26,12 +26,14 @@ In MVP, all three run in one process (`picloud` binary). In cluster mode, each r
Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme.
- `/api/v1/admin/*` — manager (control plane: script CRUD, logs, config)
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID)
- `/exec/*`orchestrator (data plane: invoke scripts at custom paths, v1.1+). Unversioned — the contract is user-defined per script.
- `/api/v1/admin/*` — manager (control plane: script CRUD, routes CRUD + check + match, logs, config)
- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID, always-available bypass)
- `/admin/*`dashboard SPA (SvelteKit, `paths.base = '/admin'`)
- `/healthz` — liveness (string `"ok"`)
- `/version` versions of every compatibility surface (JSON)
- `/` and static assets — dashboard (SvelteKit static build, served by Caddy)
- `/version` — every compatibility-surface version + `public_base_url` (JSON)
- **everything else** — orchestrator's user-route matcher: user scripts bind to arbitrary paths via `POST /api/v1/admin/scripts/{id}/routes`; if no route matches, picloud returns 404 with a JSON error.
Reserved path prefixes (rejected at route creation): `/api/`, `/admin/`, `/healthz`, `/version`.
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.

24
Cargo.lock generated
View File

@@ -1273,7 +1273,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "picloud"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"anyhow",
"async-trait",
@@ -1297,7 +1297,7 @@ dependencies = [
[[package]]
name = "picloud-executor"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"anyhow",
"picloud-executor-core",
@@ -1309,7 +1309,7 @@ dependencies = [
[[package]]
name = "picloud-executor-core"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"chrono",
"picloud-shared",
@@ -1323,7 +1323,7 @@ dependencies = [
[[package]]
name = "picloud-manager"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"anyhow",
"picloud-manager-core",
@@ -1335,7 +1335,7 @@ dependencies = [
[[package]]
name = "picloud-manager-core"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"async-trait",
"axum",
@@ -1347,12 +1347,13 @@ dependencies = [
"sqlx",
"thiserror 1.0.69",
"tracing",
"url",
"uuid",
]
[[package]]
name = "picloud-orchestrator"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"anyhow",
"picloud-orchestrator-core",
@@ -1364,7 +1365,7 @@ dependencies = [
[[package]]
name = "picloud-orchestrator-core"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"async-trait",
"axum",
@@ -1377,12 +1378,13 @@ dependencies = [
"thiserror 1.0.69",
"tokio",
"tracing",
"urlencoding",
"uuid",
]
[[package]]
name = "picloud-shared"
version = "0.2.0"
version = "0.4.0"
dependencies = [
"async-trait",
"chrono",
@@ -2723,6 +2725,12 @@ dependencies = [
"serde",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8_iter"
version = "1.0.4"

View File

@@ -12,7 +12,7 @@ members = [
]
[workspace.package]
version = "0.3.0"
version = "0.4.0"
edition = "2021"
rust-version = "1.92"
license = "MIT OR Apache-2.0"
@@ -62,6 +62,10 @@ figment = { version = "0.10", features = ["toml", "env"] }
# HTTP client (for RemoteExecutorClient later)
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
# URL parsing (for match-preview admin endpoint)
url = "2"
urlencoding = "2"
[workspace.lints.rust]
unsafe_code = "forbid"

View File

@@ -1,13 +1,19 @@
# PiCloud dev Caddyfile.
#
# Same routing shape as prod, with two differences:
# - bound to plain HTTP (no domain, no automatic TLS)
# - upstreams are the docker-compose service names
# Routing topology:
# /healthz, /version → picloud (liveness + version, k8s-friendly)
# /api/v1/admin/* → picloud (manager control plane)
# /api/v1/execute/{id} → picloud (orchestrator ID-based bypass)
# /api/* → 404 (unsupported / sunset API version)
# /admin/* → dashboard SPA (mounted with paths.base=/admin)
# everything else → picloud (orchestrator route table; user paths)
#
# Control plane (`/api/admin/*`) and data plane (`/api/execute/*`, `/exec/*`)
# both terminate on the `picloud` all-in-one for now; in cluster mode the
# data-plane handles will list multiple orchestrator upstreams here while
# the admin handle still points at a single manager.
# The "everything else → picloud" rule is what makes user-defined paths
# like /greet possible. picloud's matcher answers them via the routes
# table, or returns 404 with a precise JSON error.
#
# When v2 of the API ships, add `handle /api/v2/admin/* { ... }` etc.
# alongside the v1 handles, before the catch-all `/api/*` 404.
{
auto_https off
admin off
@@ -19,7 +25,6 @@
:80 {
handle /healthz {
handle /healthz {
reverse_proxy picloud:8080
}
handle /version {
@@ -27,29 +32,13 @@
}
handle /api/v1/admin/* {
# second `handle /api/v2/...` block while keeping v1 live for at
# least one product-minor deprecation window.
# Control plane → manager (single-process: picloud).
handle /api/v1/admin/* {
reverse_proxy picloud:8080
}
handle /api/v1/execute/* {
# Data plane → orchestrator (single-process: picloud).
handle /api/v1/execute/* {
reverse_proxy picloud:8080
}
handle /api/* {
# "your script runs against this body", so no API versioning applies.
handle /exec/* {
reverse_proxy picloud:8080
}
# Anything else under /api/* — old major versions that have been
# fully sunset, or typos. Fail loudly rather than silently serving
# the dashboard SPA.
handle /api/* {
respond 404 {
body "{\"error\":\"no such API version see /version for supported routes\"}"
close
@@ -57,12 +46,22 @@
}
# Dashboard SPA at /admin. Its internal Caddy serves files from /srv
# dashboard container that already runs file_server with index.html
# fallback for client-side routing).
handle {
reverse_proxy dashboard:80
# (root); we don't strip the prefix because SvelteKit was built with
# paths.base = '/admin' so the bundle's URLs already include it.
handle /admin/* {
reverse_proxy dashboard:80
}
handle /admin {
# Bare /admin (no trailing slash) — let SvelteKit's SPA handle it.
reverse_proxy dashboard:80
}
# Everything else → picloud's user-route fallback. If no route
# matches, picloud responds 404 with a JSON body.
handle {
reverse_proxy picloud:8080
}
log {
output stdout
format console

View File

@@ -24,12 +24,7 @@
reverse_proxy picloud:8080
}
reverse_proxy picloud:8080
}
handle /api/* {
# the SPA — old clients should fail loudly.
handle /api/* {
respond 404 {
body "{\"error\":\"no such API version see /version for supported routes\"}"
close
@@ -37,9 +32,16 @@
}
handle /admin/* {
reverse_proxy dashboard:80
reverse_proxy dashboard:80
}
handle /admin {
reverse_proxy dashboard:80
}
handle {
reverse_proxy picloud:8080
}
log {
output stdout
format json

View File

@@ -196,6 +196,22 @@ fn build_ctx_map(req: &ExecRequest) -> Map {
request.insert("body".into(), json_to_dynamic(req.body.clone()));
// SDK 1.1 additions — route-captured params, query string, prefix
// tail. Empty when not applicable so scripts can always read them.
let mut params = Map::new();
for (k, v) in &req.params {
params.insert(k.clone().into(), v.clone().into());
}
request.insert("params".into(), params.into());
let mut query = Map::new();
for (k, v) in &req.query {
query.insert(k.clone().into(), v.clone().into());
}
request.insert("query".into(), query.into());
request.insert("rest".into(), req.rest.clone().into());
ctx.insert("request".into(), request.into());
ctx
}

View File

@@ -28,6 +28,23 @@ pub struct ExecRequest {
pub headers: BTreeMap<String, String>,
pub body: serde_json::Value,
/// Path-parameter captures from named-param routes (`/users/:id`).
/// Exposed to scripts as `ctx.request.params`. Empty for exact and
/// prefix routes.
#[serde(default)]
pub params: BTreeMap<String, String>,
/// Query-string parameters, parsed once at request entry.
/// Exposed as `ctx.request.query`.
#[serde(default)]
pub query: BTreeMap<String, String>,
/// Suffix captured by a `prefix` route: `/greet/*` matched against
/// `/greet/alice/morning` yields `"alice/morning"`. Empty for
/// exact and param matches.
#[serde(default)]
pub rest: String,
/// Per-script sandbox overrides resolved by the manager. The
/// executor's default `Limits` get merged with these (per-field
/// override) before the Rhai engine is built.

View File

@@ -14,6 +14,9 @@ fn req(body: serde_json::Value) -> ExecRequest {
path: "/test".into(),
headers: BTreeMap::new(),
body,
params: BTreeMap::new(),
query: BTreeMap::new(),
rest: String::new(),
sandbox_overrides: ScriptSandbox::default(),
}
}
@@ -178,6 +181,29 @@ fn module_import_is_blocked() {
assert!(matches!(err, ExecError::Runtime(_) | ExecError::Parse(_)));
}
#[test]
fn ctx_exposes_params_query_rest() {
let engine = engine();
let mut r = req(json!(null));
r.params.insert("name".into(), "alice".into());
r.params.insert("post".into(), "42".into());
r.query.insert("tab".into(), "details".into());
r.rest = "extra/path".into();
let src = r"
#{ statusCode: 200, body: #{
name: ctx.request.params.name,
post: ctx.request.params.post,
tab: ctx.request.query.tab,
rest: ctx.request.rest
} }
";
let resp = engine.execute(src, r).unwrap();
assert_eq!(
resp.body,
json!({ "name": "alice", "post": "42", "tab": "details", "rest": "extra/path" })
);
}
#[test]
fn ctx_exposes_sdk_version() {
let resp = engine()

View File

@@ -21,3 +21,4 @@ tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
sqlx.workspace = true
url.workspace = true

View File

@@ -0,0 +1,41 @@
-- Custom routes bind a script to (host, method, path) tuples. The
-- orchestrator's in-memory matcher dispatches user-defined URLs to the
-- right script; the internal /api/v1/execute/{id} endpoint stays as a
-- bypass that always works.
--
-- host_kind / path_kind drive matching and conflict-detection semantics;
-- the orchestrator parses the stored TEXT into its internal pattern
-- representation on table load. See docs/versioning.md and the
-- orchestrator_core::routing module for the matching rules.
--
-- host_param_name is reserved for the future `{subdomain}.example.com`
-- syntax; the current parser only accepts `*.example.com` (wildcard,
-- no capture).
--
-- method = NULL means "any HTTP method"; NULL handling in the unique
-- index uses COALESCE so two "any" rows still conflict.
CREATE TABLE routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
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,
method TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Catch the easy case of two literally identical routes at the DB level.
-- Cross-pattern overlap (param vs exact for the same request shape) is
-- enforced in code by the conflict detector.
CREATE UNIQUE INDEX routes_unique_binding_idx
ON routes (host_kind, host, path_kind, path, COALESCE(method, ''));
CREATE INDEX routes_lookup_idx ON routes (host_kind, host);
CREATE INDEX routes_script_id_idx ON routes (script_id);

View File

@@ -8,6 +8,8 @@ pub mod api;
pub mod log_sink;
pub mod migrations;
pub mod repo;
pub mod route_admin;
pub mod route_repo;
pub mod sandbox;
pub mod scheduler;
@@ -17,4 +19,6 @@ pub use repo::{
ExecutionLogRepository, NewScript, PostgresExecutionLogRepository, PostgresScriptRepository,
RepoResolver, ScriptPatch, ScriptRepository, ScriptRepositoryError,
};
pub use route_admin::{compile_routes, route_admin_router, RouteAdminState};
pub use route_repo::{NewRoute, PostgresRouteRepository, RouteRepository};
pub use sandbox::{CeilingError, SandboxCeiling};

View File

@@ -0,0 +1,347 @@
//! Admin endpoints for routes. Mounted under `/api/v1/admin` alongside
//! the script CRUD endpoints; the picloud binary wires the
//! `RouteTable` shared with the orchestrator dispatcher in here so
//! writes invalidate the in-memory snapshot.
use std::sync::Arc;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
};
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::repo::ScriptRepositoryError;
use crate::route_repo::{NewRoute, RouteRepository};
pub struct RouteAdminState<RR> {
pub routes: Arc<RR>,
pub table: Arc<RouteTable>,
}
impl<RR> Clone for RouteAdminState<RR> {
fn clone(&self) -> Self {
Self {
routes: self.routes.clone(),
table: self.table.clone(),
}
}
}
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
where
RR: RouteRepository + 'static,
{
Router::new()
.route(
"/scripts/{id}/routes",
get(list_routes::<RR>).post(create_route::<RR>),
)
.route("/routes/{route_id}", delete(delete_route::<RR>))
.route("/routes:check", post(check_route::<RR>))
.route("/routes:match", post(match_route::<RR>))
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct CreateRouteRequest {
pub host_kind: HostKind,
#[serde(default)]
pub host: String,
#[serde(default)]
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub method: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CheckRouteRequest {
pub host_kind: HostKind,
#[serde(default)]
pub host: String,
pub path_kind: PathKind,
pub path: String,
pub method: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CheckRouteResponse {
pub ok: bool,
pub conflicting_route: Option<Route>,
pub conflict_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct MatchRouteRequest {
pub url: String,
#[serde(default = "default_method")]
pub method: String,
}
fn default_method() -> String {
"GET".into()
}
#[derive(Debug, Serialize)]
pub struct MatchRouteResponse {
pub matched: Option<MatchedRoute>,
}
#[derive(Debug, Serialize)]
pub struct MatchedRoute {
pub route_id: Uuid,
pub script_id: ScriptId,
pub params: std::collections::BTreeMap<String, String>,
pub rest: Option<String>,
pub host_param: Option<(String, String)>,
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_routes<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
Path(script_id): Path<ScriptId>,
) -> Result<Json<Vec<Route>>, RouteApiError> {
Ok(Json(state.routes.list_for_script(script_id).await?))
}
async fn create_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
Path(script_id): Path<ScriptId>,
Json(input): Json<CreateRouteRequest>,
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
pattern::parse_host(
input.host_kind,
&input.host,
input.host_param_name.as_deref(),
)?;
// Within-kind conflict check against existing routes.
let existing = state.routes.list_all().await?;
if let Some((conflicting, reason)) = first_conflict(
&existing,
input.host_kind,
&input.host,
input.path_kind,
&normalized_path,
input.method.as_deref(),
)? {
return Err(RouteApiError::Conflict {
conflicting_route: Box::new(conflicting),
reason,
});
}
let created = state
.routes
.create(NewRoute {
script_id,
host_kind: input.host_kind,
host: input.host,
host_param_name: input.host_param_name,
path_kind: input.path_kind,
path: normalized_path,
method: input.method,
})
.await?;
refresh_table(&state).await?;
Ok((StatusCode::CREATED, Json(created)))
}
async fn delete_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
Path(route_id): Path<Uuid>,
) -> Result<StatusCode, RouteApiError> {
state.routes.delete(route_id).await?;
refresh_table(&state).await?;
Ok(StatusCode::NO_CONTENT)
}
async fn check_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
Json(input): Json<CheckRouteRequest>,
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
pattern::parse_host(input.host_kind, &input.host, None)?;
let existing = state.routes.list_all().await?;
let conflict = first_conflict(
&existing,
input.host_kind,
&input.host,
input.path_kind,
&normalized_path,
input.method.as_deref(),
)?;
Ok(Json(match conflict {
None => CheckRouteResponse {
ok: true,
conflicting_route: None,
conflict_reason: None,
},
Some((route, reason)) => CheckRouteResponse {
ok: false,
conflicting_route: Some(route),
conflict_reason: Some(reason),
},
}))
}
async fn match_route<RR: RouteRepository>(
State(state): State<RouteAdminState<RR>>,
Json(input): Json<MatchRouteRequest>,
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
let parsed = url::Url::parse(&input.url)
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
let host = parsed.host_str().unwrap_or("").to_string();
let path = parsed.path().to_string();
let result = state.table.match_request(&host, &input.method, &path);
Ok(Json(MatchRouteResponse {
matched: result.map(|r| MatchedRoute {
route_id: r.matched.route_id,
script_id: r.matched.script_id,
params: r.params,
rest: r.rest,
host_param: r.host_param,
}),
}))
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
/// Validate the raw user-typed path string and return it verbatim if
/// it parses cleanly. Prefix normalization (`/echo/*` → `/echo/`)
/// happens only in memory at compile time; persisted strings stay in
/// the form the user submitted so re-parses are idempotent.
fn parse_and_normalize_path(kind: PathKind, raw: &str) -> Result<String, pattern::ParseError> {
pattern::parse_path(kind, raw)?;
Ok(raw.to_string())
}
#[allow(clippy::type_complexity)]
fn first_conflict(
existing: &[Route],
host_kind: HostKind,
host: &str,
path_kind: PathKind,
path: &str,
method: Option<&str>,
) -> Result<Option<(Route, String)>, RouteApiError> {
let new_host = pattern::parse_host(host_kind, host, None)?;
let new_path = pattern::parse_path(path_kind, path)?;
for r in existing {
let r_host = pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?;
if !conflict::hosts_overlap(&new_host, &r_host) {
continue;
}
if !conflict::methods_overlap(method, r.method.as_deref()) {
continue;
}
let r_path = pattern::parse_path(r.path_kind, &r.path)?;
if let Some(reason) = conflict::conflicts(&new_path, &r_path) {
return Ok(Some((r.clone(), format!("{reason:?}"))));
}
}
Ok(None)
}
async fn refresh_table<RR: RouteRepository>(
state: &RouteAdminState<RR>,
) -> Result<(), RouteApiError> {
let rows = state.routes.list_all().await?;
let compiled = compile_routes(&rows)?;
state.table.replace(compiled);
Ok(())
}
pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::ParseError> {
rows.iter()
.map(|r| {
Ok(CompiledRoute {
route_id: r.id,
script_id: r.script_id,
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
path: pattern::parse_path(r.path_kind, &r.path)?,
method: r.method.clone(),
})
})
.collect()
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum RouteApiError {
#[error("route conflicts with existing route ({reason})")]
Conflict {
conflicting_route: Box<Route>,
reason: String,
},
#[error("invalid route: {0}")]
Pattern(#[from] pattern::ParseError),
#[error("bad request: {0}")]
BadRequest(String),
#[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError),
}
impl IntoResponse for RouteApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::Conflict {
conflicting_route,
reason,
} => (
StatusCode::CONFLICT,
serde_json::json!({
"error": self.to_string(),
"reason": reason,
"conflicting_route": &**conflicting_route,
}),
),
Self::Pattern(_) | Self::BadRequest(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
serde_json::json!({ "error": self.to_string() }),
),
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
StatusCode::NOT_FOUND,
serde_json::json!({ "error": self.to_string() }),
),
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
StatusCode::CONFLICT,
serde_json::json!({ "error": self.to_string() }),
),
Self::Repo(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "route admin db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
serde_json::json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,168 @@
//! CRUD over the `routes` table.
//!
//! The orchestrator's `RouteTable` is repopulated from this repo after
//! every write — see the route_admin module for the binding.
use async_trait::async_trait;
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
use sqlx::PgPool;
use uuid::Uuid;
use crate::repo::ScriptRepositoryError;
#[derive(Debug, Clone)]
pub struct NewRoute {
pub script_id: ScriptId,
pub host_kind: HostKind,
pub host: String,
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub method: Option<String>,
}
#[async_trait]
pub trait RouteRepository: Send + Sync {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn list_for_script(
&self,
script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError>;
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
}
pub struct PostgresRouteRepository {
pool: PgPool,
}
impl PostgresRouteRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl RouteRepository for PostgresRouteRepository {
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
FROM routes ORDER BY created_at",
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn list_for_script(
&self,
script_id: ScriptId,
) -> Result<Vec<Route>, ScriptRepositoryError> {
let rows = sqlx::query_as::<_, RouteRow>(
"SELECT id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at \
FROM routes WHERE script_id = $1 ORDER BY created_at",
)
.bind(script_id.into_inner())
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
let res = sqlx::query_as::<_, RouteRow>(
"INSERT INTO routes ( \
script_id, host_kind, host, host_param_name, \
path_kind, path, method \
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
RETURNING id, script_id, host_kind, host, host_param_name, \
path_kind, path, method, created_at",
)
.bind(input.script_id.into_inner())
.bind(host_kind_str(input.host_kind))
.bind(&input.host)
.bind(input.host_param_name.as_deref())
.bind(path_kind_str(input.path_kind))
.bind(&input.path)
.bind(input.method.as_deref())
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
Err(ScriptRepositoryError::NotFound(input.script_id))
}
Err(e) => Err(e.into()),
}
}
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> {
let res = sqlx::query("DELETE FROM routes WHERE id = $1")
.bind(route_id)
.execute(&self.pool)
.await?;
if res.rows_affected() == 0 {
return Err(ScriptRepositoryError::NotFound(ScriptId::from(route_id)));
}
Ok(())
}
}
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",
}
}
#[derive(sqlx::FromRow)]
struct RouteRow {
id: Uuid,
script_id: Uuid,
host_kind: String,
host: String,
host_param_name: Option<String>,
path_kind: String,
path: String,
method: Option<String>,
created_at: chrono::DateTime<chrono::Utc>,
}
impl From<RouteRow> for Route {
fn from(r: RouteRow) -> Self {
Self {
id: r.id,
script_id: r.script_id.into(),
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,
method: r.method,
created_at: r.created_at,
}
}
}

View File

@@ -22,3 +22,4 @@ uuid.workspace = true
chrono.workspace = true
reqwest.workspace = true
tokio.workspace = true
urlencoding.workspace = true

View File

@@ -8,7 +8,7 @@ use std::time::Duration;
use axum::{
body::Bytes,
extract::{Path, State},
extract::{Path, Request, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::post,
@@ -24,12 +24,16 @@ use uuid::Uuid;
use crate::client::ExecutorClient;
use crate::resolver::{ResolverError, ScriptResolver};
use crate::routing::RouteTable;
/// State shared by data-plane handlers.
pub struct DataPlaneState<E, R> {
pub executor: Arc<E>,
pub resolver: Arc<R>,
pub log_sink: Arc<dyn ExecutionLogSink>,
/// Routing table for user-defined paths. Shared with the manager
/// (admin router writes; this side reads).
pub routes: Arc<RouteTable>,
}
impl<E, R> Clone for DataPlaneState<E, R> {
@@ -38,11 +42,13 @@ impl<E, R> Clone for DataPlaneState<E, R> {
executor: self.executor.clone(),
resolver: self.resolver.clone(),
log_sink: self.log_sink.clone(),
routes: self.routes.clone(),
}
}
}
/// Build the data-plane router. Handles `POST /execute/:id`.
/// Build the data-plane router. Handles `POST /execute/:id` — the
/// always-available ID-based bypass.
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
where
E: ExecutorClient + 'static,
@@ -53,6 +59,19 @@ where
.with_state(state)
}
/// Build a router that handles ALL paths via the user-defined routing
/// table. Intended to be merged into the picloud app router as a
/// fallback (after the system routes are mounted).
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
where
E: ExecutorClient + 'static,
R: ScriptResolver + 'static,
{
Router::new()
.fallback(user_route_handler::<E, R>)
.with_state(state)
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
@@ -106,6 +125,113 @@ where
Ok(exec_response_to_http(outcome?))
}
async fn user_route_handler<E, R>(
State(state): State<DataPlaneState<E, R>>,
request: Request,
) -> Result<Response, ApiError>
where
E: ExecutorClient + 'static,
R: ScriptResolver + 'static,
{
let method = request.method().as_str().to_string();
let uri = request.uri().clone();
let path = uri.path().to_string();
let query_str = uri.query().unwrap_or("").to_string();
let host = request
.headers()
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("")
.to_string();
let headers = request.headers().clone();
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
return Ok((
StatusCode::NOT_FOUND,
Json(serde_json::json!({
"error": format!("no route matches {method} {path}")
})),
)
.into_response());
};
let script = state
.resolver
.resolve(matched.matched.script_id)
.await?
.ok_or(ApiError::NotFound(matched.matched.script_id))?;
// Drain the body now that we know we'll execute. 10 MiB cap matches
// the conservative default response/request size in the blueprint.
let body_bytes = match axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024).await {
Ok(b) => b,
Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))),
};
let mut req = build_exec_request(
matched.matched.script_id,
&script.name,
&headers,
&body_bytes,
)?;
req.path = path;
req.params = matched.params;
req.query = parse_query_string(&query_str);
req.rest = matched.rest.unwrap_or_default();
req.sandbox_overrides = script.sandbox;
let request_id = req.request_id;
let request_path = req.path.clone();
let request_headers = req.headers.clone();
let request_body = req.body.clone();
let timeout = Duration::from_secs(u64::from(script.timeout_seconds));
let started = Utc::now();
let outcome = state.executor.execute(&script.source, req, timeout).await;
let finished = Utc::now();
let log = build_execution_log(
matched.matched.script_id,
request_id,
request_path,
request_headers,
request_body,
&outcome,
started,
finished,
);
if let Err(e) = state.log_sink.record(log).await {
tracing::warn!(
error = %e,
script_id = %matched.matched.script_id,
"failed to persist execution log"
);
}
Ok(exec_response_to_http(outcome?))
}
fn parse_query_string(s: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
if s.is_empty() {
return out;
}
for pair in s.split('&') {
let (k, v) = match pair.split_once('=') {
Some((k, v)) => (k, v),
None => (pair, ""),
};
let key = urlencoding::decode(k)
.map(std::borrow::Cow::into_owned)
.unwrap_or_default();
let val = urlencoding::decode(v)
.map(std::borrow::Cow::into_owned)
.unwrap_or_default();
out.insert(key, val);
}
out
}
// ----------------------------------------------------------------------------
// Marshalling
// ----------------------------------------------------------------------------
@@ -139,6 +265,9 @@ fn build_exec_request(
path: format!("/api/execute/{id}"),
headers: hmap,
body: body_json,
params: BTreeMap::new(),
query: BTreeMap::new(),
rest: String::new(),
// Overwritten by the handler after the script is resolved.
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
})

View File

@@ -11,7 +11,8 @@
pub mod api;
pub mod client;
pub mod resolver;
pub mod routing;
pub use api::{data_plane_router, DataPlaneState};
pub use api::{data_plane_router, user_routes_router, DataPlaneState};
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient};
pub use resolver::{ResolverError, ScriptResolver};

View File

@@ -0,0 +1,238 @@
//! Within-kind overlap detection.
//!
//! Two routes "conflict" if they're the same kind AND there exists at
//! least one request that both would match. Cross-kind overlap is OK —
//! the runtime matcher resolves it via the precedence rule
//! (`exact > param > prefix`, with leading-literal specificity).
//!
//! The user's design intent (see chat history): "There should only be
//! one eligible route in each domain for a path within the same kind."
//! This file is the predicate that enforces it at write time.
use super::pattern::{HostPattern, PathPattern, PathSegment};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConflictReason {
/// Two exact paths with identical literal value.
IdenticalExact,
/// Two prefix paths with identical literal value (different
/// lengths coexist; the longer wins at runtime).
IdenticalPrefix,
/// Two param paths with the same shape (same segment count and
/// matching literals at every literal-vs-literal position).
OverlappingParam,
}
/// True if these two patterns conflict (within-kind only). Callers
/// should already have decided the host/method dimensions overlap.
#[must_use]
pub fn conflicts(a: &PathPattern, b: &PathPattern) -> Option<ConflictReason> {
match (a, b) {
(PathPattern::Exact(x), PathPattern::Exact(y)) if x == y => {
Some(ConflictReason::IdenticalExact)
}
(PathPattern::Prefix(x), PathPattern::Prefix(y)) if x == y => {
Some(ConflictReason::IdenticalPrefix)
}
(PathPattern::Param(xs), PathPattern::Param(ys)) if param_overlap(xs, ys) => {
Some(ConflictReason::OverlappingParam)
}
_ => None,
}
}
/// Two host patterns "share a domain bucket" — i.e., a request to one
/// host satisfies both — exactly when their parsed forms agree on kind
/// and value. Conflict-checks across different domain buckets do not
/// apply (the runtime host precedence picks one bucket per request).
#[must_use]
pub fn hosts_overlap(a: &HostPattern, b: &HostPattern) -> bool {
match (a, b) {
(HostPattern::Any, HostPattern::Any) => true,
(HostPattern::Strict(x), HostPattern::Strict(y)) => x == y,
(HostPattern::Wildcard { suffix: x, .. }, HostPattern::Wildcard { suffix: y, .. }) => {
x == y
}
_ => false,
}
}
/// True if the two HTTP methods would dispatch to the same route.
/// `None` = "any method"; two Anys overlap, an Any overlaps any
/// specific method, and two specifics overlap iff equal.
#[must_use]
pub fn methods_overlap(a: Option<&str>, b: Option<&str>) -> bool {
match (a, b) {
(None, _) | (_, None) => true,
(Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
}
}
fn param_overlap(a: &[PathSegment], b: &[PathSegment]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).all(|(x, y)| match (x, y) {
(PathSegment::Literal(p), PathSegment::Literal(q)) => p == q,
// Any pair involving at least one Param accepts overlap.
_ => true,
})
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::super::pattern::{parse_path, PathPattern};
use super::*;
use picloud_shared::PathKind;
fn p(kind: PathKind, raw: &str) -> PathPattern {
parse_path(kind, raw).unwrap()
}
#[test]
fn identical_exacts_conflict() {
assert_eq!(
conflicts(&p(PathKind::Exact, "/x"), &p(PathKind::Exact, "/x")),
Some(ConflictReason::IdenticalExact)
);
}
#[test]
fn different_exacts_dont_conflict() {
assert_eq!(
conflicts(&p(PathKind::Exact, "/x"), &p(PathKind::Exact, "/y")),
None
);
}
#[test]
fn identical_prefixes_conflict() {
assert_eq!(
conflicts(
&p(PathKind::Prefix, "/greet/*"),
&p(PathKind::Prefix, "/greet/*")
),
Some(ConflictReason::IdenticalPrefix)
);
}
#[test]
fn different_length_prefixes_dont_conflict() {
assert_eq!(
conflicts(
&p(PathKind::Prefix, "/greet/*"),
&p(PathKind::Prefix, "/greet/sub/*")
),
None
);
}
#[test]
fn cross_kind_never_conflicts() {
assert_eq!(
conflicts(&p(PathKind::Exact, "/x"), &p(PathKind::Prefix, "/x/*")),
None
);
assert_eq!(
conflicts(
&p(PathKind::Exact, "/greet/alice"),
&p(PathKind::Param, "/greet/:name")
),
None
);
assert_eq!(
conflicts(
&p(PathKind::Param, "/greet/:name"),
&p(PathKind::Prefix, "/greet/*")
),
None
);
}
#[test]
fn same_shape_params_conflict_regardless_of_name() {
assert_eq!(
conflicts(
&p(PathKind::Param, "/users/:id"),
&p(PathKind::Param, "/users/:userId")
),
Some(ConflictReason::OverlappingParam)
);
}
#[test]
fn param_shapes_with_overlapping_literals_conflict() {
// /users/admin/posts is reachable by both.
assert_eq!(
conflicts(
&p(PathKind::Param, "/users/:id/posts"),
&p(PathKind::Param, "/users/admin/:action")
),
Some(ConflictReason::OverlappingParam)
);
}
#[test]
fn param_with_distinct_trailing_literal_no_conflict() {
assert_eq!(
conflicts(
&p(PathKind::Param, "/users/:id/posts"),
&p(PathKind::Param, "/users/:id/comments")
),
None
);
}
#[test]
fn params_of_different_lengths_no_conflict() {
assert_eq!(
conflicts(
&p(PathKind::Param, "/users/:id"),
&p(PathKind::Param, "/users/:id/posts")
),
None
);
}
#[test]
fn methods_any_overlaps_anything() {
assert!(methods_overlap(None, None));
assert!(methods_overlap(None, Some("GET")));
assert!(methods_overlap(Some("POST"), None));
}
#[test]
fn methods_case_insensitive() {
assert!(methods_overlap(Some("GET"), Some("get")));
}
#[test]
fn methods_different_specifics_no_overlap() {
assert!(!methods_overlap(Some("GET"), Some("POST")));
}
#[test]
fn hosts_strict_strict() {
let a = HostPattern::Strict("a.com".into());
let b = HostPattern::Strict("a.com".into());
let c = HostPattern::Strict("b.com".into());
assert!(hosts_overlap(&a, &b));
assert!(!hosts_overlap(&a, &c));
}
#[test]
fn hosts_strict_vs_wildcard_different_buckets() {
let s = HostPattern::Strict("sub.example.com".into());
let w = HostPattern::Wildcard {
suffix: "example.com".into(),
capture: None,
};
// They CAN match the same request at runtime; specificity
// picks one. They're not in the same conflict bucket.
assert!(!hosts_overlap(&s, &w));
}
}

View File

@@ -0,0 +1,454 @@
//! Runtime route matching.
//!
//! Given a `(host, method, path)` triple and a `RouteTable`, returns
//! the single best matching route plus extracted parameters, or `None`.
//!
//! Rules (decided in design discussion, see chat history):
//!
//! 1. Host dispatch: collect candidate routes whose host pattern
//! matches the request Host. `strict > wildcard (longer suffix
//! wins) > any`. Within the most-specific matching host bucket,
//! try path matching; if nothing matches, fall through to less
//! specific buckets.
//! 2. Within a host bucket, method must overlap (request method
//! equals route method, or route method is `any`).
//! 3. Path dispatch by kind: exact wins absolute; among non-exact
//! matches, more leading literal segments wins; tie → `param >
//! prefix`. Within prefix, longest matching prefix wins.
use std::collections::BTreeMap;
use super::pattern::{HostPattern, PathPattern, PathSegment};
#[derive(Debug, Clone)]
pub struct MatchResult {
pub matched: Matched,
pub params: BTreeMap<String, String>,
/// `Some(rest)` when the matched route is a prefix; the remainder
/// of the URL path after the matched prefix, with no leading slash.
pub rest: Option<String>,
/// Captured host parameter (future `{tenant}.example.com`); always
/// `None` for the v1.1 SDK since the brace syntax is reserved.
pub host_param: Option<(String, String)>,
}
/// Reference back into the caller's route storage. Generic so callers
/// can carry their own `Route` shape; only the patterns are required.
#[derive(Debug, Clone)]
pub struct Matched {
pub route_id: uuid::Uuid,
pub script_id: picloud_shared::ScriptId,
}
/// A single route ready for matching.
#[derive(Debug, Clone)]
pub struct CompiledRoute {
pub route_id: uuid::Uuid,
pub script_id: picloud_shared::ScriptId,
pub host: HostPattern,
pub path: PathPattern,
pub method: Option<String>,
}
/// Find the best matching route for the request. Returns `None` if no
/// route is eligible at any host-specificity level.
#[must_use]
pub fn r#match<'a, I>(
routes: I,
request_host: &str,
request_method: &str,
request_path: &str,
) -> Option<MatchResult>
where
I: IntoIterator<Item = &'a CompiledRoute>,
{
let candidates: Vec<&CompiledRoute> = routes.into_iter().collect();
// Group by host-specificity, descending. We try each bucket in
// turn — the moment we find a path match within a bucket, return.
let buckets = bucket_by_host(&candidates, request_host);
for bucket in buckets {
if let Some(result) = match_within_bucket(&bucket, request_method, request_path) {
return Some(result);
}
}
None
}
/// Optional host capture (param-name + extracted label) when a wildcard
/// match has the `{name}.example.com` capture syntax (deferred SDK
/// feature; always `None` today but the type carries through so we
/// don't have to thread it later).
type HostCapture = Option<(String, String)>;
type RouteHit<'a> = (&'a CompiledRoute, HostCapture);
/// Returns the routes whose host pattern accepts `request_host`,
/// grouped by host-specificity in descending order (most-specific
/// first). Higher-priority buckets are tried first by the matcher.
fn bucket_by_host<'a>(
candidates: &[&'a CompiledRoute],
request_host: &str,
) -> Vec<Vec<RouteHit<'a>>> {
let host_stripped = strip_port(request_host);
let mut hits: Vec<(HostBucket, &CompiledRoute, HostCapture)> = Vec::new();
for r in candidates {
if let Some(capture) = host_matches(&r.host, host_stripped) {
hits.push((host_bucket(&r.host), r, capture));
}
}
// Sort descending by bucket; group runs of equal buckets.
hits.sort_by(|a, b| b.0.cmp(&a.0));
let mut out: Vec<Vec<RouteHit<'a>>> = Vec::new();
let mut current_bucket: Option<HostBucket> = None;
for (bucket, route, capture) in hits {
if Some(bucket) == current_bucket {
out.last_mut().unwrap().push((route, capture));
} else {
current_bucket = Some(bucket);
out.push(vec![(route, capture)]);
}
}
out
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum HostBucket {
Any,
Wildcard(usize), // suffix length
Strict,
}
fn host_bucket(p: &HostPattern) -> HostBucket {
match p {
HostPattern::Any => HostBucket::Any,
HostPattern::Wildcard { suffix, .. } => HostBucket::Wildcard(suffix.len()),
HostPattern::Strict(_) => HostBucket::Strict,
}
}
#[allow(clippy::option_option)]
fn host_matches(pattern: &HostPattern, request_host: &str) -> Option<HostCapture> {
match pattern {
HostPattern::Any => Some(None),
HostPattern::Strict(value) => {
if value.eq_ignore_ascii_case(request_host) {
Some(None)
} else {
None
}
}
HostPattern::Wildcard { suffix, capture } => {
let host = request_host.to_ascii_lowercase();
let suffix = suffix.to_ascii_lowercase();
// Require: host ends with ".suffix" AND has at least one
// non-empty label before. Multi-level subdomains (a.b.suffix)
// do match — common platform convention for wildcard certs
// is one-level only, but for HTTP routing users almost
// always want "anything under this domain".
let dotted = format!(".{suffix}");
host.strip_suffix(&dotted)
.filter(|p| !p.is_empty())
.map(|label| capture.as_ref().map(|c| (c.clone(), label.to_string())))
}
}
}
fn strip_port(host: &str) -> &str {
host.split(':').next().unwrap_or(host)
}
fn match_within_bucket(
bucket: &[RouteHit<'_>],
request_method: &str,
request_path: &str,
) -> Option<MatchResult> {
// 1. Exact wins absolute.
for (route, host_param) in bucket {
if !method_matches(route.method.as_deref(), request_method) {
continue;
}
if let PathPattern::Exact(p) = &route.path {
if p == request_path {
return Some(MatchResult {
matched: Matched {
route_id: route.route_id,
script_id: route.script_id,
},
params: BTreeMap::new(),
rest: None,
host_param: host_param.clone(),
});
}
}
}
// 2. Among non-exact matches, score by leading-literal count;
// tie → param > prefix; tie among prefixes → longest wins.
let mut best: Option<(MatchScore, MatchResult)> = None;
for (route, host_param) in bucket {
if !method_matches(route.method.as_deref(), request_method) {
continue;
}
let (params, rest) = match &route.path {
PathPattern::Exact(_) => continue, // handled above
PathPattern::Prefix(prefix) => {
if let Some(rest) = request_path.strip_prefix(prefix.as_str()) {
// Successful prefix match. Empty rest is fine for
// "/*" matching "/", but for non-root the rest is
// typically a real path segment.
(BTreeMap::new(), Some(rest.to_string()))
} else {
continue;
}
}
PathPattern::Param(segs) => {
if let Some(captures) = match_param(segs, request_path) {
(captures, None)
} else {
continue;
}
}
};
let score = MatchScore {
leading_literals: route.path.leading_literal_count(),
kind_rank: kind_rank(&route.path),
prefix_len: match &route.path {
PathPattern::Prefix(p) => p.len(),
_ => 0,
},
};
let result = MatchResult {
matched: Matched {
route_id: route.route_id,
script_id: route.script_id,
},
params,
rest,
host_param: host_param.clone(),
};
match &best {
None => best = Some((score, result)),
Some((bs, _)) if score > *bs => best = Some((score, result)),
_ => {}
}
}
best.map(|(_, r)| r)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
struct MatchScore {
leading_literals: usize,
kind_rank: u8, // higher = preferred at equal leading_literals
prefix_len: usize,
}
fn kind_rank(p: &PathPattern) -> u8 {
match p {
PathPattern::Exact(_) => 3, // handled separately but kept for completeness
PathPattern::Param(_) => 2,
PathPattern::Prefix(_) => 1,
}
}
fn method_matches(route_method: Option<&str>, request_method: &str) -> bool {
match route_method {
None => true,
Some(m) => m.eq_ignore_ascii_case(request_method),
}
}
fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<String, String>> {
let req_segs: Vec<&str> = request_path
.trim_start_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.collect();
if req_segs.len() != segs.len() {
return None;
}
let mut captures = BTreeMap::new();
for (pattern_seg, req_seg) in segs.iter().zip(req_segs.iter()) {
match pattern_seg {
PathSegment::Literal(lit) => {
if lit != req_seg {
return None;
}
}
PathSegment::Param(name) => {
captures.insert(name.clone(), (*req_seg).to_string());
}
}
}
Some(captures)
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::super::pattern::parse_path;
use super::*;
use picloud_shared::{PathKind, ScriptId};
use uuid::Uuid;
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
CompiledRoute {
route_id: Uuid::new_v4(),
script_id: ScriptId::new(),
host,
path: parse_path(path_kind, raw).unwrap(),
method: None,
}
}
#[test]
fn exact_wins_over_param_and_prefix() {
let routes = vec![
route(HostPattern::Any, PathKind::Exact, "/greet"),
route(HostPattern::Any, PathKind::Param, "/:anything"),
route(HostPattern::Any, PathKind::Prefix, "/*"),
];
let r = r#match(&routes, "host", "GET", "/greet").unwrap();
assert_eq!(r.matched.route_id, routes[0].route_id);
}
#[test]
fn param_beats_prefix_at_equal_leading_literals() {
let routes = vec![
route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
route(HostPattern::Any, PathKind::Param, "/foo/users/:id"),
];
let r = r#match(&routes, "host", "GET", "/foo/users/foo").unwrap();
assert_eq!(r.matched.route_id, routes[1].route_id);
assert_eq!(r.params.get("id").map(String::as_str), Some("foo"));
}
#[test]
fn longer_prefix_wins_over_shorter_prefix() {
let routes = vec![
route(HostPattern::Any, PathKind::Prefix, "/foo/*"),
route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
];
let r = r#match(&routes, "host", "GET", "/foo/users/list").unwrap();
assert_eq!(r.matched.route_id, routes[1].route_id);
assert_eq!(r.rest.as_deref(), Some("list"));
}
#[test]
fn more_leading_literals_wins_across_kinds() {
// prefix has more leading literals than param → prefix wins.
let routes = vec![
route(HostPattern::Any, PathKind::Param, "/foo/:section"),
route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
];
let r = r#match(&routes, "host", "GET", "/foo/users/list").unwrap();
assert_eq!(r.matched.route_id, routes[1].route_id);
}
#[test]
fn prefix_does_not_match_bare_root() {
let routes = vec![route(HostPattern::Any, PathKind::Prefix, "/greet/*")];
assert!(r#match(&routes, "host", "GET", "/greet").is_none());
assert!(r#match(&routes, "host", "GET", "/greet/x").is_some());
}
#[test]
fn host_strict_beats_wildcard() {
let routes = vec![
route(
HostPattern::Wildcard {
suffix: "example.com".into(),
capture: None,
},
PathKind::Exact,
"/greet",
),
route(
HostPattern::Strict("sub.example.com".into()),
PathKind::Exact,
"/greet",
),
];
// sub.example.com matches both; strict wins.
let r = r#match(&routes, "sub.example.com", "GET", "/greet").unwrap();
assert_eq!(r.matched.route_id, routes[1].route_id);
// other.example.com only matches wildcard.
let r2 = r#match(&routes, "other.example.com", "GET", "/greet").unwrap();
assert_eq!(r2.matched.route_id, routes[0].route_id);
}
#[test]
fn wildcard_requires_subdomain_label() {
let routes = vec![route(
HostPattern::Wildcard {
suffix: "example.com".into(),
capture: None,
},
PathKind::Exact,
"/x",
)];
// Bare apex shouldn't match a *.example.com wildcard.
assert!(r#match(&routes, "example.com", "GET", "/x").is_none());
// Two-level subdomains DO match (`.label.example.com` ends with `.example.com`).
assert!(r#match(&routes, "a.b.example.com", "GET", "/x").is_some());
}
#[test]
fn fallback_to_less_specific_host_when_path_misses() {
// Strict bucket has no path that matches; should fall through
// to the wildcard bucket.
let routes = vec![
route(
HostPattern::Strict("sub.example.com".into()),
PathKind::Exact,
"/admin-only",
),
route(
HostPattern::Wildcard {
suffix: "example.com".into(),
capture: None,
},
PathKind::Exact,
"/greet",
),
];
let r = r#match(&routes, "sub.example.com", "GET", "/greet").unwrap();
assert_eq!(r.matched.route_id, routes[1].route_id);
}
#[test]
fn method_filters_apply() {
let mut get_route = route(HostPattern::Any, PathKind::Exact, "/echo");
get_route.method = Some("GET".into());
let mut post_route = route(HostPattern::Any, PathKind::Exact, "/echo");
post_route.method = Some("POST".into());
let routes = vec![get_route.clone(), post_route.clone()];
let r1 = r#match(&routes, "host", "GET", "/echo").unwrap();
assert_eq!(r1.matched.route_id, get_route.route_id);
let r2 = r#match(&routes, "host", "POST", "/echo").unwrap();
assert_eq!(r2.matched.route_id, post_route.route_id);
assert!(r#match(&routes, "host", "DELETE", "/echo").is_none());
}
#[test]
fn host_with_port_strips_port() {
let routes = vec![route(
HostPattern::Strict("sub.example.com".into()),
PathKind::Exact,
"/x",
)];
let r = r#match(&routes, "sub.example.com:8443", "GET", "/x");
assert!(r.is_some());
}
}

View File

@@ -0,0 +1,28 @@
//! Custom routing: turning a `(host, method, path)` request into the
//! script that should answer it.
//!
//! This module is the single source of truth for how PiCloud routes
//! work. The user contract:
//!
//! * **Path kinds** — `exact`, `prefix`, `param`. Validation rules
//! in `pattern`; the orchestrator parses the raw stored strings
//! into typed `PathPattern` values for matching.
//! * **Host kinds** — `any`, `strict`, `wildcard`. Same shape.
//! * **Within-kind uniqueness** — two routes of the same kind that
//! could match the same request are a configuration error.
//! See `conflict`.
//! * **Cross-kind precedence at request time** — `exact` wins
//! absolute; among non-exact, more leading-literal segments
//! wins; tie → `param > prefix`. See `matcher`.
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
//! wildcard suffix breaks ties between wildcards.
pub mod conflict;
pub mod matcher;
pub mod pattern;
pub mod table;
pub use conflict::{conflicts, ConflictReason};
pub use matcher::{MatchResult, Matched};
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
pub use table::RouteTable;

View File

@@ -0,0 +1,417 @@
//! Parsing + validation of host and path patterns.
//!
//! Both kinds round-trip via the storage form (`(kind, raw_string)`)
//! and the parsed form (`HostPattern` / `PathPattern`). The
//! orchestrator parses once at table load; matching and conflict-checks
//! work over the parsed form.
use picloud_shared::{HostKind, PathKind};
use thiserror::Error;
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum ParseError {
#[error("path must not be empty")]
EmptyPath,
#[error("path must start with '/'")]
PathMissingLeadingSlash,
#[error("prefix path must end with '/*'")]
PrefixMissingTail,
#[error(
"param path must contain at least one ':name' segment (use kind=exact for literal paths)"
)]
ParamWithoutCaptures,
#[error("':' must start a whole path segment; bad segment: {0:?}")]
MidSegmentParam(String),
#[error("empty param name in segment {0:?}")]
EmptyParamName(String),
#[error("invalid param name {0:?}: must match [a-zA-Z_][a-zA-Z0-9_]*")]
InvalidParamName(String),
#[error("duplicate param name {0:?} within a single route")]
DuplicateParamName(String),
#[error("path '{0}' is reserved for system use")]
ReservedPath(String),
#[error("'{{name}}' syntax is reserved for a future SDK release; use ':name' for whole-segment params")]
ReservedBraceSyntax,
#[error("host must not be empty")]
EmptyHost,
#[error("wildcard host must start with '*.'")]
WildcardMissingPrefix,
#[error("wildcard suffix must not be empty (got just '*')")]
EmptyWildcardSuffix,
#[error("'{{name}}.example.com' subdomain capture is reserved for a future SDK release; use '*.example.com'")]
ReservedHostBraceSyntax,
}
// ----------------------------------------------------------------------------
// Path patterns
// ----------------------------------------------------------------------------
/// Path-side prefixes that user routes are not allowed to bind to.
/// Posting a route with a path beginning with any of these returns 422.
pub const RESERVED_PATH_PREFIXES: &[&str] = &["/api/", "/admin/", "/healthz", "/version"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathPattern {
Exact(String),
/// Stored with the trailing slash (no `*`). Matches `path.starts_with(prefix)`.
Prefix(String),
Param(Vec<PathSegment>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathSegment {
Literal(String),
Param(String),
}
impl PathPattern {
/// Number of leading literal segments before the first variable segment.
/// Used as the primary specificity score at match time.
#[must_use]
pub fn leading_literal_count(&self) -> usize {
match self {
Self::Exact(_) => usize::MAX, // exact wins absolute
Self::Prefix(p) => p.split('/').filter(|s| !s.is_empty()).count(),
Self::Param(segs) => segs
.iter()
.take_while(|s| matches!(s, PathSegment::Literal(_)))
.count(),
}
}
}
/// Parse a stored path. The `kind` selects validation rules; the same
/// raw string can be valid under multiple kinds (e.g., `/greet/:name`
/// parses as `Exact` with a literal `:` or as `Param` with a capture).
pub fn parse_path(kind: PathKind, raw: &str) -> Result<PathPattern, ParseError> {
if raw.is_empty() {
return Err(ParseError::EmptyPath);
}
if !raw.starts_with('/') {
return Err(ParseError::PathMissingLeadingSlash);
}
if raw.contains('{') || raw.contains('}') {
return Err(ParseError::ReservedBraceSyntax);
}
check_reserved(raw)?;
match kind {
PathKind::Exact => Ok(PathPattern::Exact(raw.to_string())),
PathKind::Prefix => parse_prefix(raw),
PathKind::Param => parse_param(raw),
}
}
fn check_reserved(raw: &str) -> Result<(), ParseError> {
for r in RESERVED_PATH_PREFIXES {
if raw == r.trim_end_matches('/') || raw.starts_with(r) {
return Err(ParseError::ReservedPath(raw.to_string()));
}
}
Ok(())
}
fn parse_prefix(raw: &str) -> Result<PathPattern, ParseError> {
let stripped = raw
.strip_suffix("/*")
.ok_or(ParseError::PrefixMissingTail)?;
// Normalize: store with trailing `/` so starts_with matches exactly
// the "more under here" semantic. `/greet/*` → "/greet/".
let prefix = if stripped.is_empty() {
"/".to_string()
} else {
format!("{stripped}/")
};
Ok(PathPattern::Prefix(prefix))
}
fn parse_param(raw: &str) -> Result<PathPattern, ParseError> {
let mut segments = Vec::new();
let mut seen_names = Vec::new();
let mut any_param = false;
// Skip the leading '/'; trailing '/' (other than the root case)
// would create an empty segment and is rejected.
for seg in raw.trim_start_matches('/').split('/') {
if seg.is_empty() {
return Err(ParseError::MidSegmentParam(raw.to_string()));
}
if let Some(rest) = seg.strip_prefix(':') {
// The chars after `:` must be the WHOLE name and nothing
// else. Distinguish "name starts with digit" (an invalid
// identifier as such) from "name has trailing non-ident
// characters" (mixing :name with literal text — what we
// call mid-segment).
if rest.is_empty() {
return Err(ParseError::EmptyParamName(seg.to_string()));
}
let bad_first =
!(rest.chars().next().unwrap().is_ascii_alphabetic() || rest.starts_with('_'));
let all_ident_chars = rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !all_ident_chars {
return Err(ParseError::MidSegmentParam(seg.to_string()));
}
if bad_first {
return Err(ParseError::InvalidParamName(rest.to_string()));
}
if seen_names.iter().any(|n: &String| n == rest) {
return Err(ParseError::DuplicateParamName(rest.to_string()));
}
seen_names.push(rest.to_string());
segments.push(PathSegment::Param(rest.to_string()));
any_param = true;
} else if seg.contains(':') {
return Err(ParseError::MidSegmentParam(seg.to_string()));
} else {
segments.push(PathSegment::Literal(seg.to_string()));
}
}
if !any_param {
return Err(ParseError::ParamWithoutCaptures);
}
Ok(PathPattern::Param(segments))
}
// ----------------------------------------------------------------------------
// Host patterns
// ----------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostPattern {
Any,
Strict(String),
/// Suffix WITHOUT the leading `*.`; the matcher accepts any
/// non-empty label followed by `.suffix`.
Wildcard {
suffix: String,
capture: Option<String>,
},
}
impl HostPattern {
/// Specificity rank for host dispatch (higher wins).
#[must_use]
pub fn specificity(&self) -> HostSpecificity {
match self {
Self::Strict(_) => HostSpecificity::Strict,
Self::Wildcard { suffix, .. } => HostSpecificity::Wildcard(suffix.len()),
Self::Any => HostSpecificity::Any,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum HostSpecificity {
Any,
/// Longer wildcard suffix = more specific.
Wildcard(usize),
Strict,
}
pub fn parse_host(
kind: HostKind,
raw: &str,
capture: Option<&str>,
) -> Result<HostPattern, ParseError> {
match kind {
HostKind::Any => Ok(HostPattern::Any),
HostKind::Strict => {
if raw.is_empty() {
return Err(ParseError::EmptyHost);
}
if raw.contains('{') || raw.contains('}') {
return Err(ParseError::ReservedHostBraceSyntax);
}
Ok(HostPattern::Strict(raw.to_string()))
}
HostKind::Wildcard => {
if raw.is_empty() {
return Err(ParseError::EmptyHost);
}
if raw.contains('{') || raw.contains('}') {
return Err(ParseError::ReservedHostBraceSyntax);
}
let suffix = raw
.strip_prefix("*.")
.ok_or(ParseError::WildcardMissingPrefix)?;
if suffix.is_empty() {
return Err(ParseError::EmptyWildcardSuffix);
}
Ok(HostPattern::Wildcard {
suffix: suffix.to_string(),
capture: capture.map(str::to_string),
})
}
}
}
// ----------------------------------------------------------------------------
// Tests
// ----------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exact_takes_any_string() {
let p = parse_path(PathKind::Exact, "/greet").unwrap();
assert_eq!(p, PathPattern::Exact("/greet".into()));
// Even one with a colon — exact stores it literally.
let p2 = parse_path(PathKind::Exact, "/greet/:name").unwrap();
assert_eq!(p2, PathPattern::Exact("/greet/:name".into()));
}
#[test]
fn prefix_normalizes_trailing_slash() {
let p = parse_path(PathKind::Prefix, "/greet/*").unwrap();
assert_eq!(p, PathPattern::Prefix("/greet/".into()));
}
#[test]
fn prefix_rejects_no_trailing_star() {
let e = parse_path(PathKind::Prefix, "/greet").unwrap_err();
assert_eq!(e, ParseError::PrefixMissingTail);
}
#[test]
fn prefix_at_root() {
let p = parse_path(PathKind::Prefix, "/*").unwrap();
assert_eq!(p, PathPattern::Prefix("/".into()));
}
#[test]
fn param_parses_segments_and_captures_names() {
let p = parse_path(PathKind::Param, "/users/:id/posts/:post").unwrap();
match p {
PathPattern::Param(segs) => {
assert_eq!(segs.len(), 4);
assert_eq!(segs[0], PathSegment::Literal("users".into()));
assert_eq!(segs[1], PathSegment::Param("id".into()));
assert_eq!(segs[2], PathSegment::Literal("posts".into()));
assert_eq!(segs[3], PathSegment::Param("post".into()));
}
_ => panic!("expected Param"),
}
}
#[test]
fn param_rejects_mid_segment_colon() {
let e = parse_path(PathKind::Param, "/greet/my:name").unwrap_err();
assert!(matches!(e, ParseError::MidSegmentParam(_)));
let e2 = parse_path(PathKind::Param, "/greet/:name.json").unwrap_err();
assert!(matches!(e2, ParseError::MidSegmentParam(_)));
}
#[test]
fn param_rejects_duplicate_names() {
let e = parse_path(PathKind::Param, "/users/:id/posts/:id").unwrap_err();
assert_eq!(e, ParseError::DuplicateParamName("id".into()));
}
#[test]
fn param_rejects_empty_or_invalid_names() {
assert!(matches!(
parse_path(PathKind::Param, "/greet/:"),
Err(ParseError::EmptyParamName(_))
));
assert!(matches!(
parse_path(PathKind::Param, "/greet/:9id"),
Err(ParseError::InvalidParamName(_))
));
}
#[test]
fn param_requires_at_least_one_capture() {
let e = parse_path(PathKind::Param, "/just/literal").unwrap_err();
assert_eq!(e, ParseError::ParamWithoutCaptures);
}
#[test]
fn rejects_brace_syntax_reserved() {
let e = parse_path(PathKind::Exact, "/x/{name}").unwrap_err();
assert_eq!(e, ParseError::ReservedBraceSyntax);
}
#[test]
fn rejects_reserved_paths() {
for raw in [
"/api/v1/admin/scripts",
"/api/v2/foo",
"/admin/dashboard",
"/healthz",
"/version",
] {
let e = parse_path(PathKind::Exact, raw).unwrap_err();
assert!(
matches!(e, ParseError::ReservedPath(_)),
"expected reserved for {raw:?}, got {e:?}"
);
}
}
#[test]
fn rejects_missing_leading_slash() {
let e = parse_path(PathKind::Exact, "greet").unwrap_err();
assert_eq!(e, ParseError::PathMissingLeadingSlash);
}
#[test]
fn host_strict_and_wildcard() {
assert_eq!(
parse_host(HostKind::Strict, "sub.example.com", None).unwrap(),
HostPattern::Strict("sub.example.com".into())
);
assert_eq!(
parse_host(HostKind::Wildcard, "*.example.com", None).unwrap(),
HostPattern::Wildcard {
suffix: "example.com".into(),
capture: None
}
);
assert_eq!(
parse_host(HostKind::Any, "", None).unwrap(),
HostPattern::Any
);
}
#[test]
fn host_rejects_wildcard_without_star_dot() {
let e = parse_host(HostKind::Wildcard, "example.com", None).unwrap_err();
assert_eq!(e, ParseError::WildcardMissingPrefix);
}
#[test]
fn host_rejects_brace_syntax() {
let e = parse_host(HostKind::Wildcard, "{tenant}.example.com", None).unwrap_err();
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
}
#[test]
fn leading_literal_count_works() {
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
assert_eq!(exact.leading_literal_count(), usize::MAX);
let prefix2 = parse_path(PathKind::Prefix, "/foo/users/*").unwrap();
assert_eq!(prefix2.leading_literal_count(), 2);
let prefix1 = parse_path(PathKind::Prefix, "/foo/*").unwrap();
assert_eq!(prefix1.leading_literal_count(), 1);
let param2 = parse_path(PathKind::Param, "/foo/users/:id").unwrap();
assert_eq!(param2.leading_literal_count(), 2);
let param1 = parse_path(PathKind::Param, "/foo/:section").unwrap();
assert_eq!(param1.leading_literal_count(), 1);
let param_then_lit = parse_path(PathKind::Param, "/foo/:s/list").unwrap();
// Literal AFTER a param doesn't count for "leading literal".
assert_eq!(param_then_lit.leading_literal_count(), 1);
}
}

View File

@@ -0,0 +1,43 @@
//! In-memory snapshot of compiled routes, shared by manager (writes)
//! and orchestrator (reads).
//!
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
//! read without contending against the writer; in MVP-single-process
//! we just use `RwLock` and accept the cheap contention.
use std::sync::RwLock;
use super::matcher::{r#match, CompiledRoute, MatchResult};
#[derive(Default)]
pub struct RouteTable {
inner: RwLock<Vec<CompiledRoute>>,
}
impl RouteTable {
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Replace the whole table atomically. The manager calls this after
/// each successful route CRUD operation (by re-reading from DB).
pub fn replace(&self, routes: Vec<CompiledRoute>) {
let mut guard = self.inner.write().expect("route table poisoned");
*guard = routes;
}
/// Dispatch a request to a matching route, or `None`.
#[must_use]
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
let guard = self.inner.read().expect("route table poisoned");
r#match(guard.iter(), host, method, path)
}
/// Returns a clone of the currently compiled routes; intended for
/// the dashboard's "list routes" admin endpoint.
#[must_use]
pub fn snapshot(&self) -> Vec<CompiledRoute> {
self.inner.read().expect("route table poisoned").clone()
}
}

View File

@@ -9,10 +9,14 @@ use std::time::Duration;
use axum::{routing::get, Json, Router};
use picloud_executor_core::{Engine, Limits};
use picloud_manager_core::{
admin_router, migrations, AdminState, PostgresExecutionLogRepository, PostgresExecutionLogSink,
PostgresScriptRepository, RepoResolver, SandboxCeiling,
admin_router, compile_routes, migrations, route_admin_router, AdminState,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
};
use picloud_orchestrator_core::routing::RouteTable;
use picloud_orchestrator_core::{
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
};
use picloud_orchestrator_core::{data_plane_router, DataPlaneState, LocalExecutorClient};
use picloud_shared::{
ExecutionLogSink, ScriptValidator, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
};
@@ -23,16 +27,24 @@ use tower_http::trace::TraceLayer;
/// Compose the manager + orchestrator routes on top of a shared
/// Postgres pool, returning an Axum router ready to be served.
///
/// All API routes live under `/api/v{API_VERSION}/...`. New major
/// versions get a parallel nest under `/api/v{N+1}/...`; the old
/// prefix is kept live for at least one product-minor deprecation
/// window (see `docs/versioning.md`).
pub fn build_app(pool: PgPool) -> Router {
/// All API routes live under `/api/v{API_VERSION}/...`. The dashboard
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
/// falls through to the user-route table — user scripts can bind to
/// arbitrary paths (subject to the reserved-prefix list).
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
let engine = Arc::new(Engine::new(Limits::default()));
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool));
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
// Compile the routes table once at startup; admin writes refresh it.
let route_table = Arc::new(RouteTable::new());
let initial = route_repo.list_all().await?;
let compiled = compile_routes(&initial)
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
route_table.replace(compiled);
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
script_repo.clone(),
@@ -45,22 +57,28 @@ pub fn build_app(pool: PgPool) -> Router {
validator: engine as Arc<dyn ScriptValidator>,
sandbox_ceiling: SandboxCeiling::from_env(),
};
let route_admin = RouteAdminState {
routes: route_repo,
table: route_table.clone(),
};
let data_plane = DataPlaneState {
executor,
resolver,
log_sink,
routes: route_table,
};
let api_v1 = Router::new()
.nest("/admin", admin_router(admin))
.merge(data_plane_router(data_plane));
.nest("/admin", route_admin_router(route_admin))
.merge(data_plane_router(data_plane.clone()));
Router::new()
Ok(Router::new()
.route("/healthz", get(healthz))
.route("/version", get(version))
.route("/", get(root))
.nest(&format!("/api/v{API_VERSION}"), api_v1)
.layer(TraceLayer::new_for_http())
.merge(user_routes_router(data_plane))
.layer(TraceLayer::new_for_http()))
}
/// Open a Postgres pool with the binary's standard timeout settings.
@@ -78,20 +96,23 @@ async fn healthz() -> &'static str {
"ok"
}
async fn root() -> &'static str {
"picloud — see /api/v1/admin/* (manager), /api/v1/execute/* (orchestrator), /version"
}
/// Snapshot of every compatibility-surface version this process speaks.
/// Documented in `docs/versioning.md`; the source of truth is
/// `shared::version` plus the embedded migrations.
/// Snapshot of every compatibility-surface version this process speaks
/// plus the operator-configured public base URL (so the dashboard can
/// render full URLs for user routes).
///
/// Source of truth: `shared::version`, the embedded migrations, and
/// the `PICLOUD_PUBLIC_BASE_URL` env var (default
/// `http://localhost:8000`).
async fn version() -> Json<serde_json::Value> {
let public_base_url = std::env::var("PICLOUD_PUBLIC_BASE_URL")
.unwrap_or_else(|_| "http://localhost:8000".to_string());
Json(serde_json::json!({
"product": PRODUCT_VERSION,
"sdk": SDK_VERSION,
"api": API_VERSION,
"schema": migrations::latest_version(),
"wire": WIRE_VERSION,
"public_base_url": public_base_url,
}))
}

View File

@@ -22,7 +22,7 @@ async fn main() -> anyhow::Result<()> {
migrations::run(&pool).await?;
tracing::info!("migrations applied");
let app = build_app(pool);
let app = build_app(pool).await?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "picloud all-in-one listening");

View File

@@ -17,8 +17,9 @@ use axum_test::TestServer;
use serde_json::{json, Value};
use sqlx::PgPool;
fn server(pool: PgPool) -> TestServer {
TestServer::new(picloud::build_app(pool)).expect("TestServer should build")
async fn server(pool: PgPool) -> TestServer {
let app = picloud::build_app(pool).await.expect("build_app");
TestServer::new(app).expect("TestServer should build")
}
// ============================================================================
@@ -28,7 +29,7 @@ fn server(pool: PgPool) -> TestServer {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn healthz_responds_ok(pool: PgPool) {
let r = server(pool).get("/healthz").await;
let r = server(pool).await.get("/healthz").await;
r.assert_status_ok();
assert_eq!(r.text(), "ok");
}
@@ -40,7 +41,7 @@ async fn healthz_responds_ok(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_script_returns_201_with_full_record(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({
@@ -61,6 +62,7 @@ async fn create_script_returns_201_with_full_record(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
let r = server(pool)
.await
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" }))
.await;
@@ -72,7 +74,7 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn duplicate_name_returns_409(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
s.post("/api/v1/admin/scripts")
.json(&json!({ "name": "dup", "source": "42" }))
.await
@@ -87,7 +89,7 @@ async fn duplicate_name_returns_409(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn list_returns_all_scripts(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
for name in ["alpha", "bravo", "charlie"] {
s.post("/api/v1/admin/scripts")
.json(&json!({ "name": name, "source": "1" }))
@@ -105,7 +107,7 @@ async fn list_returns_all_scripts(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_bumps_version_and_persists_changes(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "u", "source": "1" }))
@@ -127,7 +129,7 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_with_invalid_source_returns_422(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "u", "source": "1" }))
@@ -145,7 +147,7 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn delete_then_get_returns_404(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "d", "source": "1" }))
@@ -166,6 +168,7 @@ async fn delete_then_get_returns_404(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn get_nonexistent_returns_404(pool: PgPool) {
let r = server(pool)
.await
.get("/api/v1/admin/scripts/00000000-0000-0000-0000-000000000000")
.await;
r.assert_status_not_found();
@@ -178,7 +181,7 @@ async fn get_nonexistent_returns_404(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_echoes_body_back(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
@@ -201,7 +204,7 @@ async fn execute_echoes_body_back(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_passes_through_status_and_headers(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
@@ -224,6 +227,7 @@ async fn execute_passes_through_status_and_headers(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execute_nonexistent_returns_404(pool: PgPool) {
let r = server(pool)
.await
.post("/api/v1/execute/00000000-0000-0000-0000-000000000000")
.json(&json!({}))
.await;
@@ -233,7 +237,7 @@ async fn execute_nonexistent_returns_404(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execution_logs_capture_invocations(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
@@ -290,7 +294,7 @@ async fn execution_logs_capture_invocations(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": "no-sandbox", "source": "1" }))
@@ -302,7 +306,7 @@ async fn create_without_sandbox_returns_empty_object(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
@@ -329,7 +333,7 @@ async fn create_with_sandbox_persists_and_returns_overrides(pool: PgPool) {
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
// Default conservative ceiling caps max_operations at 10_000_000.
let s = server(pool);
let s = server(pool).await;
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({
@@ -346,7 +350,7 @@ async fn sandbox_exceeding_ceiling_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_unknown_field_returns_422(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let r = s
.post("/api/v1/admin/scripts")
.json(&json!({
@@ -367,7 +371,7 @@ async fn sandbox_unknown_field_returns_422(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
// Tight max_operations on a loop the default would happily run.
let created: Value = s
.post("/api/v1/admin/scripts")
@@ -392,7 +396,7 @@ async fn sandbox_overrides_take_effect_at_execute(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn update_replaces_sandbox_wholesale(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({
@@ -421,10 +425,296 @@ async fn update_replaces_sandbox_wholesale(pool: PgPool) {
assert_eq!(cleared["sandbox"], json!({}));
}
// ============================================================================
// Custom routing
// ============================================================================
async fn create_basic_script(s: &TestServer, name: &str, source: &str) -> String {
let v: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({ "name": name, "source": source }))
.await
.json();
v["id"].as_str().unwrap().to_string()
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_exact_dispatches_to_script(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(
&s,
"greet",
"#{ statusCode: 200, body: #{ msg: \"hi\", path: ctx.request.path } }",
)
.await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "exact",
"path": "/greet"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/greet").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body["msg"], "hi");
assert_eq!(body["path"], "/greet");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_param_captures_path_vars(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(
&s,
"greet-name",
"#{ statusCode: 200, body: #{ name: ctx.request.params.name } }",
)
.await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "param",
"path": "/greet/:name"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/greet/alice").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body["name"], "alice");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_prefix_captures_rest(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(
&s,
"echo-prefix",
"#{ statusCode: 200, body: #{ rest: ctx.request.rest } }",
)
.await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "prefix",
"path": "/echo/*"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/echo/foo/bar").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body["rest"], "foo/bar");
s.get("/echo").await.assert_status_not_found();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_query_string_exposed_to_script(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "qs", "#{ statusCode: 200, body: ctx.request.query }").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "exact",
"path": "/qs"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s.get("/qs?a=1&b=two").await;
r.assert_status_ok();
let body: Value = r.json();
assert_eq!(body, json!({ "a": "1", "b": "two" }));
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_invalid_pattern_returns_422(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "param",
"path": "/greet/my:name"
}))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_conflict_returns_409(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "param",
"path": "/users/:id"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "param",
"path": "/users/:userId"
}))
.await;
r.assert_status(axum::http::StatusCode::CONFLICT);
let body: Value = r.json();
assert!(body["conflicting_route"].is_object());
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_reserved_path_returns_422(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "x", "1").await;
let r = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "exact",
"path": "/admin/foo"
}))
.await;
r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_match_preview_endpoint(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "g", "1").await;
s.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "param",
"path": "/greet/:name"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
let r = s
.post("/api/v1/admin/routes:match")
.json(&json!({ "url": "http://localhost:8000/greet/alice", "method": "GET" }))
.await;
r.assert_status_ok();
let body: Value = r.json();
assert!(body["matched"].is_object());
assert_eq!(body["matched"]["params"]["name"], "alice");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_delete_removes_dispatch(pool: PgPool) {
let s = server(pool).await;
let id = create_basic_script(&s, "g", "#{ statusCode: 200, body: 1 }").await;
let created: Value = s
.post(&format!("/api/v1/admin/scripts/{id}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "exact",
"path": "/g"
}))
.await
.json();
let route_id = created["id"].as_str().unwrap();
s.get("/g").await.assert_status_ok();
s.delete(&format!("/api/v1/admin/routes/{route_id}"))
.await
.assert_status(axum::http::StatusCode::NO_CONTENT);
s.get("/g").await.assert_status_not_found();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn route_specificity_param_beats_prefix(pool: PgPool) {
let s = server(pool).await;
let id_p = create_basic_script(
&s,
"by-param",
"#{ statusCode: 200, body: #{ tag: \"param\" } }",
)
.await;
let id_pr = create_basic_script(
&s,
"by-prefix",
"#{ statusCode: 200, body: #{ tag: \"prefix\" } }",
)
.await;
s.post(&format!("/api/v1/admin/scripts/{id_p}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "param",
"path": "/foo/:bar"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
s.post(&format!("/api/v1/admin/scripts/{id_pr}/routes"))
.json(&json!({
"host_kind": "any",
"path_kind": "prefix",
"path": "/foo/*"
}))
.await
.assert_status(axum::http::StatusCode::CREATED);
// Single segment under /foo/ — both match; param wins by spec.
let r = s.get("/foo/x").await;
let body: Value = r.json();
assert_eq!(body["tag"], "param");
// Two segments — only prefix matches.
let r2 = s.get("/foo/x/y").await;
let body2: Value = r2.json();
assert_eq!(body2["tag"], "prefix");
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn root_returns_404_when_no_route(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/").await;
r.assert_status_not_found();
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn version_includes_public_base_url(pool: PgPool) {
let s = server(pool).await;
let r = s.get("/version").await;
r.assert_status_ok();
let v: Value = r.json();
assert!(v["public_base_url"].is_string());
assert_eq!(v["api"], 1);
assert_eq!(v["schema"], 3);
assert_eq!(v["sdk"], "1.1");
}
// ============================================================================
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn execution_errors_are_still_logged(pool: PgPool) {
let s = server(pool);
let s = server(pool).await;
let created: Value = s
.post("/api/v1/admin/scripts")
.json(&json!({

View File

@@ -8,6 +8,7 @@ pub mod error;
pub mod execution_log;
pub mod ids;
pub mod log_sink;
pub mod route;
pub mod sandbox;
pub mod script;
pub mod validator;
@@ -17,6 +18,7 @@ pub use error::Error;
pub use execution_log::{ExecutionLog, ExecutionStatus};
pub use ids::{ExecutionId, RequestId, ScriptId};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use route::{HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox;
pub use script::Script;
pub use validator::{ScriptValidator, ValidationError};

View File

@@ -0,0 +1,60 @@
//! Route binding: which `(host, method, path)` tuples invoke a given
//! script. The storage shape (this file) is intentionally flat — the
//! orchestrator parses these into typed `HostPattern` / `PathPattern`
//! values for matching, and reconstructs strings for display.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::ScriptId;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum HostKind {
/// Matches any host header.
Any,
/// Exact hostname match: `sub.example.com`.
Strict,
/// Wildcard suffix match: `*.example.com` matches any subdomain of
/// `example.com`. Capture name is reserved for the future
/// `{name}.example.com` syntax (currently always None on writes).
Wildcard,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PathKind {
/// Literal string equality: `/greet` matches only `/greet`.
Exact,
/// Strict-subtree match. Stored as the prefix including the trailing
/// slash; `/greet/*` is stored as path "/greet/" and matches
/// `/greet/x` but not `/greet` itself (which would need its own
/// exact route).
Prefix,
/// Named-parameter pattern: `/greet/:name` where each `:foo` consumes
/// exactly one segment and captures it into `ctx.request.params.foo`.
Param,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Route {
pub id: Uuid,
pub script_id: ScriptId,
pub host_kind: HostKind,
/// For `Any`: empty string. For `Strict`: full hostname. For
/// `Wildcard`: the suffix after the leading `*.` (e.g. `example.com`).
pub host: String,
pub host_param_name: Option<String>,
pub path_kind: PathKind,
/// Raw path as the user typed it. For `Prefix`, normalized to end
/// with `/` (the trailing `*` is dropped on write).
pub path: String,
/// `None` = any method.
pub method: Option<String>,
pub created_at: DateTime<Utc>,
}

View File

@@ -12,11 +12,14 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Rhai SDK version, in `"major.minor"` form. Scripts read this from
/// `ctx.sdk_version` for feature detection. Bump rules:
/// * patch (`1.0.x`): doc-only, no script-observable change
/// * patch (`1.x.0`): doc-only, no script-observable change
/// * minor (`1.0 → 1.1`): added functions / fields; existing
/// scripts must still run unchanged
/// * major (`1 → 2`): removed, renamed, retyped, restricted
pub const SDK_VERSION: &str = "1.0";
///
/// 1.1 additions: `ctx.request.params`, `ctx.request.query`,
/// `ctx.request.rest`.
pub const SDK_VERSION: &str = "1.1";
/// HTTP API major version. Appears in URL paths as `/api/v{N}/...`.
/// Bump (new integer + new URL prefix) when the request/response

View File

@@ -1,6 +1,6 @@
{
"name": "picloud-dashboard",
"version": "0.3.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1,12 +1,13 @@
<script lang="ts">
import { base } from '$app/paths';
let { children } = $props();
</script>
<div class="shell">
<header>
<a href="/" class="brand">PiCloud</a>
<a href={base + '/'} class="brand">PiCloud</a>
<nav>
<a href="/">Scripts</a>
<a href={base + '/'}>Scripts</a>
</nav>
</header>
<main>

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type Script } from '$lib/api';
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
@@ -106,7 +107,7 @@
<ul class="list">
{#each scripts as script (script.id)}
<li>
<a href="/scripts/{script.id}">
<a href="{base}/scripts/{script.id}">
<div class="primary">
<strong>{script.name}</strong>
<span class="muted">v{script.version}</span>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { page } from '$app/state';
import { api, ApiError, type ExecutionLog, type Script } from '$lib/api';
import { logLevelColor, statusColor } from '$lib/styles';
@@ -118,7 +119,7 @@
deleting = true;
try {
await api.scripts.remove(id);
await goto('/');
await goto(base + '/');
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
deleting = false;
@@ -132,7 +133,7 @@
</script>
<section>
<a class="back" href="/">Scripts</a>
<a class="back" href={base + '/'}> Scripts</a>
{#if scriptLoading}
<p class="muted">Loading…</p>

View File

@@ -6,8 +6,11 @@ const config = {
preprocess: vitePreprocess(),
kit: {
// SPA build: Caddy serves these files in prod, falls back to
// index.html for client-side routing. Matches our architecture
// — the dashboard is a pure SPA against /api/admin/*.
// index.html for client-side routing. Mounted at /admin so
// the rest of the URL space is free for user-defined routes.
paths: {
base: '/admin'
},
adapter: adapter({
fallback: 'index.html',
pages: 'build',

View File

@@ -39,6 +39,7 @@ services:
PICLOUD_BIND: 0.0.0.0:8080
DATABASE_URL: postgres://${POSTGRES_USER:-picloud}:${POSTGRES_PASSWORD:-picloud}@postgres:5432/${POSTGRES_DB:-picloud}
RUST_LOG: ${RUST_LOG:-info}
PICLOUD_PUBLIC_BASE_URL: ${PICLOUD_PUBLIC_BASE_URL:-http://localhost:8000}
depends_on:
postgres:
condition: service_healthy

View File

@@ -20,9 +20,14 @@ WORKDIR /srv
COPY --from=builder /app/build /srv
RUN printf ':80 {\n\troot * /srv\n\ttry_files {path} /index.html\n\tfile_server\n\tencode zstd gzip\n\tlog {\n\t\toutput stdout\n\t\tformat console\n\t}\n}\n' > /etc/caddy/Caddyfile
# SvelteKit was built with paths.base='/admin', so the bundle's URLs
# already include the /admin/ prefix. The outer Caddy forwards
# /admin/* here verbatim; we strip the prefix and serve from /srv
# with index.html as the SPA fallback. Bare / 404s (the outer Caddy
# already sends user routes to picloud, so we shouldn't see them here).
RUN printf '{\n\tauto_https off\n\tadmin off\n}\n:80 {\n\thandle_path /admin* {\n\t\troot * /srv\n\t\ttry_files {path} /index.html\n\t\tfile_server\n\t\tencode zstd gzip\n\t}\n\thandle {\n\t\trespond 404\n\t}\n\tlog {\n\t\toutput stdout\n\t\tformat console\n\t}\n}\n' > /etc/caddy/Caddyfile
EXPOSE 80
HEALTHCHECK --interval=10s --timeout=2s --retries=3 \
CMD wget -qO- http://127.0.0.1/ >/dev/null 2>&1 || exit 1
CMD wget -qO- http://127.0.0.1/admin/ >/dev/null 2>&1 || exit 1

View File

@@ -124,10 +124,10 @@ A surface can hit its own `1.0` independently of the product. The SDK in particu
| | Version |
|---|---|
| Product | `0.3.0` |
| SDK | `1.0` |
| Product | `0.4.0` |
| SDK | `1.1` (adds `ctx.request.params`, `ctx.request.query`, `ctx.request.rest`) |
| API | `1` |
| Schema | `2` (matches `migrations/0002_sandbox.sql`) |
| Schema | `3` (matches `migrations/0003_routes.sql`) |
| Wire | `1` (reserved; cluster mode not implemented) |
Read live from `GET /version` on any running instance.