diff --git a/.env.example b/.env.example index d849f6e..d458691 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 79bbcc1..9e773bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 4a47f11..b452523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 5e580a1..717c1fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/caddy/Caddyfile b/caddy/Caddyfile index e945f8d..9b06ee6 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -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 @@ -19,7 +25,6 @@ } :80 { - # Health + version are unversioned (k8s probes, monitoring). handle /healthz { reverse_proxy picloud:8080 } @@ -27,29 +32,13 @@ reverse_proxy picloud:8080 } - # Versioned API (see docs/versioning.md). When v2 ships, add a - # 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 } - - # Data plane → orchestrator (single-process: picloud). handle /api/v1/execute/* { reverse_proxy picloud:8080 } - # Unversioned: user-defined script paths (v1.1+). Contract is just - # "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\"}" @@ -57,12 +46,22 @@ } } - # Everything else → dashboard SPA (Caddy serves a self-contained - # dashboard container that already runs file_server with index.html - # fallback for client-side routing). - handle { + # Dashboard SPA at /admin. Its internal Caddy serves files from /srv + # (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 diff --git a/caddy/Caddyfile.prod b/caddy/Caddyfile.prod index f2e765d..8b037b4 100644 --- a/caddy/Caddyfile.prod +++ b/caddy/Caddyfile.prod @@ -24,12 +24,7 @@ handle /api/v1/execute/* { reverse_proxy picloud:8080 } - handle /exec/* { - reverse_proxy picloud:8080 - } - # Catch unsupported / sunset API majors before they fall through to - # the SPA — old clients should fail loudly. handle /api/* { respond 404 { body "{\"error\":\"no such API version — see /version for supported routes\"}" @@ -37,9 +32,16 @@ } } - handle { + handle /admin/* { reverse_proxy dashboard:80 } + handle /admin { + reverse_proxy dashboard:80 + } + + handle { + reverse_proxy picloud:8080 + } log { output stdout diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index 9907492..ee6b0ee 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -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 } diff --git a/crates/executor-core/src/types.rs b/crates/executor-core/src/types.rs index f09ff19..fd57dce 100644 --- a/crates/executor-core/src/types.rs +++ b/crates/executor-core/src/types.rs @@ -28,6 +28,23 @@ pub struct ExecRequest { pub headers: BTreeMap, 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, + + /// Query-string parameters, parsed once at request entry. + /// Exposed as `ctx.request.query`. + #[serde(default)] + pub query: BTreeMap, + + /// 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. diff --git a/crates/executor-core/tests/engine.rs b/crates/executor-core/tests/engine.rs index e9b5332..c888ae4 100644 --- a/crates/executor-core/tests/engine.rs +++ b/crates/executor-core/tests/engine.rs @@ -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() diff --git a/crates/manager-core/Cargo.toml b/crates/manager-core/Cargo.toml index 98688f5..0ff13fb 100644 --- a/crates/manager-core/Cargo.toml +++ b/crates/manager-core/Cargo.toml @@ -21,3 +21,4 @@ tracing.workspace = true uuid.workspace = true chrono.workspace = true sqlx.workspace = true +url.workspace = true diff --git a/crates/manager-core/migrations/0003_routes.sql b/crates/manager-core/migrations/0003_routes.sql new file mode 100644 index 0000000..9a999c5 --- /dev/null +++ b/crates/manager-core/migrations/0003_routes.sql @@ -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); diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 3740540..0fca7f6 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -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}; diff --git a/crates/manager-core/src/route_admin.rs b/crates/manager-core/src/route_admin.rs new file mode 100644 index 0000000..77d3c5b --- /dev/null +++ b/crates/manager-core/src/route_admin.rs @@ -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 { + pub routes: Arc, + pub table: Arc, +} + +impl Clone for RouteAdminState { + fn clone(&self) -> Self { + Self { + routes: self.routes.clone(), + table: self.table.clone(), + } + } +} + +pub fn route_admin_router(state: RouteAdminState) -> Router +where + RR: RouteRepository + 'static, +{ + Router::new() + .route( + "/scripts/{id}/routes", + get(list_routes::).post(create_route::), + ) + .route("/routes/{route_id}", delete(delete_route::)) + .route("/routes:check", post(check_route::)) + .route("/routes:match", post(match_route::)) + .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, + pub path_kind: PathKind, + pub path: String, + pub method: Option, +} + +#[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, +} + +#[derive(Debug, Serialize)] +pub struct CheckRouteResponse { + pub ok: bool, + pub conflicting_route: Option, + pub conflict_reason: Option, +} + +#[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, +} + +#[derive(Debug, Serialize)] +pub struct MatchedRoute { + pub route_id: Uuid, + pub script_id: ScriptId, + pub params: std::collections::BTreeMap, + pub rest: Option, + pub host_param: Option<(String, String)>, +} + +// ---------------------------------------------------------------------------- +// Handlers +// ---------------------------------------------------------------------------- + +async fn list_routes( + State(state): State>, + Path(script_id): Path, +) -> Result>, RouteApiError> { + Ok(Json(state.routes.list_for_script(script_id).await?)) +} + +async fn create_route( + State(state): State>, + Path(script_id): Path, + Json(input): Json, +) -> Result<(StatusCode, Json), 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( + State(state): State>, + Path(route_id): Path, +) -> Result { + state.routes.delete(route_id).await?; + refresh_table(&state).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn check_route( + State(state): State>, + Json(input): Json, +) -> Result, 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( + State(state): State>, + Json(input): Json, +) -> Result, 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 { + 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, 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( + state: &RouteAdminState, +) -> 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, 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, + 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() + } +} diff --git a/crates/manager-core/src/route_repo.rs b/crates/manager-core/src/route_repo.rs new file mode 100644 index 0000000..ae2944e --- /dev/null +++ b/crates/manager-core/src/route_repo.rs @@ -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, + pub path_kind: PathKind, + pub path: String, + pub method: Option, +} + +#[async_trait] +pub trait RouteRepository: Send + Sync { + async fn list_all(&self) -> Result, ScriptRepositoryError>; + async fn list_for_script( + &self, + script_id: ScriptId, + ) -> Result, ScriptRepositoryError>; + async fn create(&self, input: NewRoute) -> Result; + 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, 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, 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 { + 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, + path_kind: String, + path: String, + method: Option, + created_at: chrono::DateTime, +} + +impl From 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, + } + } +} diff --git a/crates/orchestrator-core/Cargo.toml b/crates/orchestrator-core/Cargo.toml index d39f66d..ce21dff 100644 --- a/crates/orchestrator-core/Cargo.toml +++ b/crates/orchestrator-core/Cargo.toml @@ -22,3 +22,4 @@ uuid.workspace = true chrono.workspace = true reqwest.workspace = true tokio.workspace = true +urlencoding.workspace = true diff --git a/crates/orchestrator-core/src/api.rs b/crates/orchestrator-core/src/api.rs index d91e3ff..dc3b642 100644 --- a/crates/orchestrator-core/src/api.rs +++ b/crates/orchestrator-core/src/api.rs @@ -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 { pub executor: Arc, pub resolver: Arc, pub log_sink: Arc, + /// Routing table for user-defined paths. Shared with the manager + /// (admin router writes; this side reads). + pub routes: Arc, } impl Clone for DataPlaneState { @@ -38,11 +42,13 @@ impl Clone for DataPlaneState { 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(state: DataPlaneState) -> 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(state: DataPlaneState) -> Router +where + E: ExecutorClient + 'static, + R: ScriptResolver + 'static, +{ + Router::new() + .fallback(user_route_handler::) + .with_state(state) +} + // ---------------------------------------------------------------------------- // Handlers // ---------------------------------------------------------------------------- @@ -106,6 +125,113 @@ where Ok(exec_response_to_http(outcome?)) } +async fn user_route_handler( + State(state): State>, + request: Request, +) -> Result +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 { + 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(), }) diff --git a/crates/orchestrator-core/src/lib.rs b/crates/orchestrator-core/src/lib.rs index da3b81c..3d07e4e 100644 --- a/crates/orchestrator-core/src/lib.rs +++ b/crates/orchestrator-core/src/lib.rs @@ -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}; diff --git a/crates/orchestrator-core/src/routing/conflict.rs b/crates/orchestrator-core/src/routing/conflict.rs new file mode 100644 index 0000000..e263b9e --- /dev/null +++ b/crates/orchestrator-core/src/routing/conflict.rs @@ -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 { + 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)); + } +} diff --git a/crates/orchestrator-core/src/routing/matcher.rs b/crates/orchestrator-core/src/routing/matcher.rs new file mode 100644 index 0000000..8c40d71 --- /dev/null +++ b/crates/orchestrator-core/src/routing/matcher.rs @@ -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, + /// `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, + /// 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, +} + +/// 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 +where + I: IntoIterator, +{ + 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>> { + 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::new(); + let mut current_bucket: Option = 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 { + 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 { + // 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> { + 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()); + } +} diff --git a/crates/orchestrator-core/src/routing/mod.rs b/crates/orchestrator-core/src/routing/mod.rs new file mode 100644 index 0000000..acb2b5f --- /dev/null +++ b/crates/orchestrator-core/src/routing/mod.rs @@ -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; diff --git a/crates/orchestrator-core/src/routing/pattern.rs b/crates/orchestrator-core/src/routing/pattern.rs new file mode 100644 index 0000000..d089d00 --- /dev/null +++ b/crates/orchestrator-core/src/routing/pattern.rs @@ -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), +} + +#[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 { + 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 { + 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 { + 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, + }, +} + +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 { + 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); + } +} diff --git a/crates/orchestrator-core/src/routing/table.rs b/crates/orchestrator-core/src/routing/table.rs new file mode 100644 index 0000000..d5a4ab1 --- /dev/null +++ b/crates/orchestrator-core/src/routing/table.rs @@ -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>, +} + +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) { + 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 { + 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 { + self.inner.read().expect("route table poisoned").clone() + } +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index c48eaca..c1bd908 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -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 { 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 = Arc::new(PostgresExecutionLogSink::new(pool)); + let log_sink: Arc = 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, 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 { + 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, + "product": PRODUCT_VERSION, + "sdk": SDK_VERSION, + "api": API_VERSION, + "schema": migrations::latest_version(), + "wire": WIRE_VERSION, + "public_base_url": public_base_url, })) } diff --git a/crates/picloud/src/main.rs b/crates/picloud/src/main.rs index 8b82681..a7296fb 100644 --- a/crates/picloud/src/main.rs +++ b/crates/picloud/src/main.rs @@ -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"); diff --git a/crates/picloud/tests/api.rs b/crates/picloud/tests/api.rs index 0597a71..74554a0 100644 --- a/crates/picloud/tests/api.rs +++ b/crates/picloud/tests/api.rs @@ -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!({ diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 940dbfa..5fbd0ce 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -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}; diff --git a/crates/shared/src/route.rs b/crates/shared/src/route.rs new file mode 100644 index 0000000..89013c9 --- /dev/null +++ b/crates/shared/src/route.rs @@ -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, + + 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, + + pub created_at: DateTime, +} diff --git a/crates/shared/src/version.rs b/crates/shared/src/version.rs index b6996a3..8691fed 100644 --- a/crates/shared/src/version.rs +++ b/crates/shared/src/version.rs @@ -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 diff --git a/dashboard/package.json b/dashboard/package.json index af5b804..24af623 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "picloud-dashboard", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/dashboard/src/routes/+layout.svelte b/dashboard/src/routes/+layout.svelte index 82027df..ee41f6a 100644 --- a/dashboard/src/routes/+layout.svelte +++ b/dashboard/src/routes/+layout.svelte @@ -1,12 +1,13 @@
- PiCloud + PiCloud
diff --git a/dashboard/src/routes/+page.svelte b/dashboard/src/routes/+page.svelte index be780ac..bf20a2c 100644 --- a/dashboard/src/routes/+page.svelte +++ b/dashboard/src/routes/+page.svelte @@ -1,4 +1,5 @@
- ← Scripts + ← Scripts {#if scriptLoading}

Loading…

diff --git a/dashboard/svelte.config.js b/dashboard/svelte.config.js index 13382c4..7e587f8 100644 --- a/dashboard/svelte.config.js +++ b/dashboard/svelte.config.js @@ -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', diff --git a/docker-compose.yml b/docker-compose.yml index c237957..bbd8403 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docker/dashboard.Dockerfile b/docker/dashboard.Dockerfile index 22161b4..0f380ed 100644 --- a/docker/dashboard.Dockerfile +++ b/docker/dashboard.Dockerfile @@ -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 diff --git a/docs/versioning.md b/docs/versioning.md index fe3e41d..d25ba7d 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -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.