feat: custom routing — bind scripts to your own URLs
Scripts can now answer at user-chosen paths (e.g. /greet, /greet/:name,
/webhooks/*), on user-chosen hosts (strict or *.example.com wildcards),
on user-chosen methods. The internal /api/v1/execute/{id} endpoint
stays as the always-available ID-based bypass.
Routing rules (decided in design with the user; see chat history):
Path kinds:
exact /greet literal
prefix /greet/* strict-subtree; stored as "/greet/";
does NOT match bare /greet (add an
exact route for that case)
param /users/:id :name captures one whole segment;
mid-segment colons are rejected;
{name} is reserved for a future SDK
Host kinds:
any no Host header constraint
strict sub.example.com literal match (case-insensitive)
wildcard *.example.com suffix match; multi-level subdomains OK
Within-kind uniqueness:
two routes of the same kind that could match the same request
conflict at config time. Algorithm (orchestrator_core::routing::
conflict):
exact: literal equality
prefix: literal equality (longer-prefix coexists; longer wins
at request time)
param: same segment count + same literals at every
literal-vs-literal position (the user's example:
:id vs :userId at same shape is a conflict)
Request-time precedence:
exact > param > prefix
among non-exact: more leading-literal segments wins
tie: param > prefix (more constrained)
within prefix: longest matching prefix wins
host bucket: strict > wildcard (longer suffix) > any; fall through
to less specific buckets when path doesn't match
Reserved path prefixes: /api/, /admin/, /healthz, /version
Routes that look invalid at config time return 422 with the precise
parse error; conflicting routes return 409 with the conflicting route
in the body (so the dashboard can render the conflict inline).
What landed:
* 0003_routes.sql — routes table (host_kind, host, host_param_name,
path_kind, path, method, script_id) with UNIQUE index on the
literal binding tuple. Schema 2 → 3.
* shared::Route / HostKind / PathKind — flat storage shape that
crosses wire boundaries cleanly.
* orchestrator_core::routing — four sub-modules, all unit-tested:
pattern.rs (16 tests) parse + validate + display
conflict.rs (12 tests) within-kind overlap predicate
matcher.rs (12 tests) runtime dispatch (specificity-aware)
table.rs Arc<RwLock<Vec<CompiledRoute>>>
shared by manager (writes) and
orchestrator (reads); atomic replace
after each admin write
* manager-core::route_admin — five new admin endpoints under
/api/v1/admin:
POST /scripts/{id}/routes create
GET /scripts/{id}/routes list per script
DELETE /routes/{route_id} delete (refreshes table)
POST /routes:check pre-flight conflict check
(powers the dashboard's
live conflict warning)
POST /routes:match synthetic URL → matched
route + extracted params
(powers the dashboard's
match-preview tool)
Stored path strings stay raw (user-typed); normalization
happens only in the in-memory CompiledRoute so re-parses are
idempotent.
* orchestrator_core::api::user_routes_router — fallback handler
mounted in picloud after the system routes. Reads Host /
method / path / query from the request, dispatches via the
table, builds an ExecRequest with params/query/rest filled,
calls the executor, writes to the log sink. 10 MiB body cap.
* executor-core::ctx (SDK 1.0 → 1.1) — adds
ctx.request.params (map of named-param captures)
ctx.request.query (parsed query string)
ctx.request.rest (suffix for prefix routes; "" otherwise)
All three are always present (empty when not applicable) so
scripts can read them unconditionally.
* picloud::build_app — now async; loads routes at startup,
populates the shared table, mounts route_admin_router under
/api/v1/admin alongside the script CRUD, and the user-routes
fallback at the app root.
* caddy/Caddyfile + Caddyfile.prod widened: anything not
/healthz, /version, /api/v1/admin/*, /api/v1/execute/*,
/api/* (404 sunset), or /admin/* (dashboard) → picloud.
* Dashboard moves to /admin/* via SvelteKit paths.base. Its
internal Caddy strips the prefix and serves with SPA fallback.
All in-app links use $app/paths. The dashboard URL is now
http://localhost:8000/admin/ — one-time break for the new
URL freedom users gained.
* PICLOUD_PUBLIC_BASE_URL env var, exposed via /version so the
dashboard renders full URLs for routes regardless of the
operator's external port / TLS setup.
* memory_limit_mb stays in the schema, still v1.3+ advisory.
Verified live through Caddy:
/version → schema 3, sdk 1.1, public_base_url
GET /admin/ → 200, dashboard HTML containing "PiCloud"
POST /api/v1/admin/scripts → 201
POST .../scripts/{id}/routes (path=/greet/:name) → 201
GET /greet/alice?lang=en → 200 {"name":"alice","q":"en"}
POST conflicting route → 409 with conflicting_route body
POST /admin/foo route → 422 "reserved"
POST /api/v1/admin/routes:match → matched + params extracted
GET /unbound-path → 404 JSON
Tests:
* 40 routing unit tests (pattern + conflict + matcher tables)
* 14 executor-core unit tests (one new for ctx.request.params/
query/rest exposure)
* 32 integration tests (10 new for routing CRUD + dispatch +
conflict + reserved + specificity tie-break + match preview +
delete invalidation + /version returns public_base_url)
* default cargo test --workspace stays green; opt-in via
DATABASE_URL + --include-ignored for the integration suite
Bumps: schema 2 → 3; SDK 1.0 → 1.1; product 0.3.0 → 0.4.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
12
CLAUDE.md
12
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.
|
||||
|
||||
|
||||
24
Cargo.lock
generated
24
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
# PiCloud dev Caddyfile.
|
||||
#
|
||||
# Same routing shape as prod, with two differences:
|
||||
# - bound to plain HTTP (no domain, no automatic TLS)
|
||||
# - upstreams are the docker-compose service names
|
||||
# Routing topology:
|
||||
# /healthz, /version → picloud (liveness + version, k8s-friendly)
|
||||
# /api/v1/admin/* → picloud (manager control plane)
|
||||
# /api/v1/execute/{id} → picloud (orchestrator ID-based bypass)
|
||||
# /api/* → 404 (unsupported / sunset API version)
|
||||
# /admin/* → dashboard SPA (mounted with paths.base=/admin)
|
||||
# everything else → picloud (orchestrator route table; user paths)
|
||||
#
|
||||
# Control plane (`/api/admin/*`) and data plane (`/api/execute/*`, `/exec/*`)
|
||||
# both terminate on the `picloud` all-in-one for now; in cluster mode the
|
||||
# data-plane handles will list multiple orchestrator upstreams here while
|
||||
# the admin handle still points at a single manager.
|
||||
# The "everything else → picloud" rule is what makes user-defined paths
|
||||
# like /greet possible. picloud's matcher answers them via the routes
|
||||
# table, or returns 404 with a precise JSON error.
|
||||
#
|
||||
# When v2 of the API ships, add `handle /api/v2/admin/* { ... }` etc.
|
||||
# alongside the v1 handles, before the catch-all `/api/*` 404.
|
||||
{
|
||||
auto_https off
|
||||
admin off
|
||||
@@ -19,7 +25,6 @@
|
||||
|
||||
:80 {
|
||||
handle /healthz {
|
||||
handle /healthz {
|
||||
reverse_proxy picloud:8080
|
||||
}
|
||||
handle /version {
|
||||
@@ -27,29 +32,13 @@
|
||||
}
|
||||
|
||||
handle /api/v1/admin/* {
|
||||
# second `handle /api/v2/...` block while keeping v1 live for at
|
||||
# least one product-minor deprecation window.
|
||||
|
||||
# Control plane → manager (single-process: picloud).
|
||||
handle /api/v1/admin/* {
|
||||
reverse_proxy picloud:8080
|
||||
}
|
||||
handle /api/v1/execute/* {
|
||||
# Data plane → orchestrator (single-process: picloud).
|
||||
handle /api/v1/execute/* {
|
||||
reverse_proxy picloud:8080
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
# "your script runs against this body", so no API versioning applies.
|
||||
handle /exec/* {
|
||||
reverse_proxy picloud:8080
|
||||
}
|
||||
|
||||
# Anything else under /api/* — old major versions that have been
|
||||
# fully sunset, or typos. Fail loudly rather than silently serving
|
||||
# the dashboard SPA.
|
||||
handle /api/* {
|
||||
respond 404 {
|
||||
body "{\"error\":\"no such API version — see /version for supported routes\"}"
|
||||
close
|
||||
@@ -57,12 +46,22 @@
|
||||
}
|
||||
|
||||
# Dashboard SPA at /admin. Its internal Caddy serves files from /srv
|
||||
# dashboard container that already runs file_server with index.html
|
||||
# fallback for client-side routing).
|
||||
handle {
|
||||
reverse_proxy dashboard:80
|
||||
# (root); we don't strip the prefix because SvelteKit was built with
|
||||
# paths.base = '/admin' so the bundle's URLs already include it.
|
||||
handle /admin/* {
|
||||
reverse_proxy dashboard:80
|
||||
}
|
||||
handle /admin {
|
||||
# Bare /admin (no trailing slash) — let SvelteKit's SPA handle it.
|
||||
reverse_proxy dashboard:80
|
||||
}
|
||||
|
||||
# Everything else → picloud's user-route fallback. If no route
|
||||
# matches, picloud responds 404 with a JSON body.
|
||||
handle {
|
||||
reverse_proxy picloud:8080
|
||||
}
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
|
||||
@@ -24,12 +24,7 @@
|
||||
reverse_proxy picloud:8080
|
||||
}
|
||||
|
||||
reverse_proxy picloud:8080
|
||||
}
|
||||
|
||||
handle /api/* {
|
||||
# the SPA — old clients should fail loudly.
|
||||
handle /api/* {
|
||||
respond 404 {
|
||||
body "{\"error\":\"no such API version — see /version for supported routes\"}"
|
||||
close
|
||||
@@ -37,9 +32,16 @@
|
||||
}
|
||||
|
||||
handle /admin/* {
|
||||
reverse_proxy dashboard:80
|
||||
reverse_proxy dashboard:80
|
||||
}
|
||||
handle /admin {
|
||||
reverse_proxy dashboard:80
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy picloud:8080
|
||||
}
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format json
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -28,6 +28,23 @@ pub struct ExecRequest {
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub body: serde_json::Value,
|
||||
|
||||
/// Path-parameter captures from named-param routes (`/users/:id`).
|
||||
/// Exposed to scripts as `ctx.request.params`. Empty for exact and
|
||||
/// prefix routes.
|
||||
#[serde(default)]
|
||||
pub params: BTreeMap<String, String>,
|
||||
|
||||
/// Query-string parameters, parsed once at request entry.
|
||||
/// Exposed as `ctx.request.query`.
|
||||
#[serde(default)]
|
||||
pub query: BTreeMap<String, String>,
|
||||
|
||||
/// Suffix captured by a `prefix` route: `/greet/*` matched against
|
||||
/// `/greet/alice/morning` yields `"alice/morning"`. Empty for
|
||||
/// exact and param matches.
|
||||
#[serde(default)]
|
||||
pub rest: String,
|
||||
|
||||
/// Per-script sandbox overrides resolved by the manager. The
|
||||
/// executor's default `Limits` get merged with these (per-field
|
||||
/// override) before the Rhai engine is built.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -21,3 +21,4 @@ tracing.workspace = true
|
||||
uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
sqlx.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
41
crates/manager-core/migrations/0003_routes.sql
Normal file
41
crates/manager-core/migrations/0003_routes.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Custom routes bind a script to (host, method, path) tuples. The
|
||||
-- orchestrator's in-memory matcher dispatches user-defined URLs to the
|
||||
-- right script; the internal /api/v1/execute/{id} endpoint stays as a
|
||||
-- bypass that always works.
|
||||
--
|
||||
-- host_kind / path_kind drive matching and conflict-detection semantics;
|
||||
-- the orchestrator parses the stored TEXT into its internal pattern
|
||||
-- representation on table load. See docs/versioning.md and the
|
||||
-- orchestrator_core::routing module for the matching rules.
|
||||
--
|
||||
-- host_param_name is reserved for the future `{subdomain}.example.com`
|
||||
-- syntax; the current parser only accepts `*.example.com` (wildcard,
|
||||
-- no capture).
|
||||
--
|
||||
-- method = NULL means "any HTTP method"; NULL handling in the unique
|
||||
-- index uses COALESCE so two "any" rows still conflict.
|
||||
|
||||
CREATE TABLE routes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
script_id UUID NOT NULL REFERENCES scripts(id) ON DELETE CASCADE,
|
||||
|
||||
host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')),
|
||||
host TEXT NOT NULL DEFAULT '',
|
||||
host_param_name TEXT,
|
||||
|
||||
path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')),
|
||||
path TEXT NOT NULL,
|
||||
|
||||
method TEXT,
|
||||
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Catch the easy case of two literally identical routes at the DB level.
|
||||
-- Cross-pattern overlap (param vs exact for the same request shape) is
|
||||
-- enforced in code by the conflict detector.
|
||||
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||
ON routes (host_kind, host, path_kind, path, COALESCE(method, ''));
|
||||
|
||||
CREATE INDEX routes_lookup_idx ON routes (host_kind, host);
|
||||
CREATE INDEX routes_script_id_idx ON routes (script_id);
|
||||
@@ -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};
|
||||
|
||||
347
crates/manager-core/src/route_admin.rs
Normal file
347
crates/manager-core/src/route_admin.rs
Normal file
@@ -0,0 +1,347 @@
|
||||
//! Admin endpoints for routes. Mounted under `/api/v1/admin` alongside
|
||||
//! the script CRUD endpoints; the picloud binary wires the
|
||||
//! `RouteTable` shared with the orchestrator dispatcher in here so
|
||||
//! writes invalidate the in-memory snapshot.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::{delete, get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
pub struct RouteAdminState<RR> {
|
||||
pub routes: Arc<RR>,
|
||||
pub table: Arc<RouteTable>,
|
||||
}
|
||||
|
||||
impl<RR> Clone for RouteAdminState<RR> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
routes: self.routes.clone(),
|
||||
table: self.table.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
||||
where
|
||||
RR: RouteRepository + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/scripts/{id}/routes",
|
||||
get(list_routes::<RR>).post(create_route::<RR>),
|
||||
)
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
||||
.route("/routes:check", post(check_route::<RR>))
|
||||
.route("/routes:match", post(match_route::<RR>))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateRouteRequest {
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
#[serde(default)]
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckRouteRequest {
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CheckRouteResponse {
|
||||
pub ok: bool,
|
||||
pub conflicting_route: Option<Route>,
|
||||
pub conflict_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchRouteRequest {
|
||||
pub url: String,
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
fn default_method() -> String {
|
||||
"GET".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchRouteResponse {
|
||||
pub matched: Option<MatchedRoute>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchedRoute {
|
||||
pub route_id: Uuid,
|
||||
pub script_id: ScriptId,
|
||||
pub params: std::collections::BTreeMap<String, String>,
|
||||
pub rest: Option<String>,
|
||||
pub host_param: Option<(String, String)>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_routes<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||
}
|
||||
|
||||
async fn create_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
Json(input): Json<CreateRouteRequest>,
|
||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||
pattern::parse_host(
|
||||
input.host_kind,
|
||||
&input.host,
|
||||
input.host_param_name.as_deref(),
|
||||
)?;
|
||||
|
||||
// Within-kind conflict check against existing routes.
|
||||
let existing = state.routes.list_all().await?;
|
||||
if let Some((conflicting, reason)) = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
&input.host,
|
||||
input.path_kind,
|
||||
&normalized_path,
|
||||
input.method.as_deref(),
|
||||
)? {
|
||||
return Err(RouteApiError::Conflict {
|
||||
conflicting_route: Box::new(conflicting),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
|
||||
let created = state
|
||||
.routes
|
||||
.create(NewRoute {
|
||||
script_id,
|
||||
host_kind: input.host_kind,
|
||||
host: input.host,
|
||||
host_param_name: input.host_param_name,
|
||||
path_kind: input.path_kind,
|
||||
path: normalized_path,
|
||||
method: input.method,
|
||||
})
|
||||
.await?;
|
||||
refresh_table(&state).await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
Path(route_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, RouteApiError> {
|
||||
state.routes.delete(route_id).await?;
|
||||
refresh_table(&state).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn check_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
Json(input): Json<CheckRouteRequest>,
|
||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||
|
||||
let existing = state.routes.list_all().await?;
|
||||
let conflict = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
&input.host,
|
||||
input.path_kind,
|
||||
&normalized_path,
|
||||
input.method.as_deref(),
|
||||
)?;
|
||||
Ok(Json(match conflict {
|
||||
None => CheckRouteResponse {
|
||||
ok: true,
|
||||
conflicting_route: None,
|
||||
conflict_reason: None,
|
||||
},
|
||||
Some((route, reason)) => CheckRouteResponse {
|
||||
ok: false,
|
||||
conflicting_route: Some(route),
|
||||
conflict_reason: Some(reason),
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn match_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
Json(input): Json<MatchRouteRequest>,
|
||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||
let parsed = url::Url::parse(&input.url)
|
||||
.map_err(|e| RouteApiError::BadRequest(format!("invalid url: {e}")))?;
|
||||
let host = parsed.host_str().unwrap_or("").to_string();
|
||||
let path = parsed.path().to_string();
|
||||
|
||||
let result = state.table.match_request(&host, &input.method, &path);
|
||||
Ok(Json(MatchRouteResponse {
|
||||
matched: result.map(|r| MatchedRoute {
|
||||
route_id: r.matched.route_id,
|
||||
script_id: r.matched.script_id,
|
||||
params: r.params,
|
||||
rest: r.rest,
|
||||
host_param: r.host_param,
|
||||
}),
|
||||
}))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Validate the raw user-typed path string and return it verbatim if
|
||||
/// it parses cleanly. Prefix normalization (`/echo/*` → `/echo/`)
|
||||
/// happens only in memory at compile time; persisted strings stay in
|
||||
/// the form the user submitted so re-parses are idempotent.
|
||||
fn parse_and_normalize_path(kind: PathKind, raw: &str) -> Result<String, pattern::ParseError> {
|
||||
pattern::parse_path(kind, raw)?;
|
||||
Ok(raw.to_string())
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn first_conflict(
|
||||
existing: &[Route],
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
path_kind: PathKind,
|
||||
path: &str,
|
||||
method: Option<&str>,
|
||||
) -> Result<Option<(Route, String)>, RouteApiError> {
|
||||
let new_host = pattern::parse_host(host_kind, host, None)?;
|
||||
let new_path = pattern::parse_path(path_kind, path)?;
|
||||
|
||||
for r in existing {
|
||||
let r_host = pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?;
|
||||
if !conflict::hosts_overlap(&new_host, &r_host) {
|
||||
continue;
|
||||
}
|
||||
if !conflict::methods_overlap(method, r.method.as_deref()) {
|
||||
continue;
|
||||
}
|
||||
let r_path = pattern::parse_path(r.path_kind, &r.path)?;
|
||||
if let Some(reason) = conflict::conflicts(&new_path, &r_path) {
|
||||
return Ok(Some((r.clone(), format!("{reason:?}"))));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn refresh_table<RR: RouteRepository>(
|
||||
state: &RouteAdminState<RR>,
|
||||
) -> Result<(), RouteApiError> {
|
||||
let rows = state.routes.list_all().await?;
|
||||
let compiled = compile_routes(&rows)?;
|
||||
state.table.replace(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::ParseError> {
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
script_id: r.script_id,
|
||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||
method: r.method.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RouteApiError {
|
||||
#[error("route conflicts with existing route ({reason})")]
|
||||
Conflict {
|
||||
conflicting_route: Box<Route>,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("invalid route: {0}")]
|
||||
Pattern(#[from] pattern::ParseError),
|
||||
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl IntoResponse for RouteApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::Conflict {
|
||||
conflicting_route,
|
||||
reason,
|
||||
} => (
|
||||
StatusCode::CONFLICT,
|
||||
serde_json::json!({
|
||||
"error": self.to_string(),
|
||||
"reason": reason,
|
||||
"conflicting_route": &**conflicting_route,
|
||||
}),
|
||||
),
|
||||
Self::Pattern(_) | Self::BadRequest(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||
StatusCode::CONFLICT,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "route admin db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
serde_json::json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
168
crates/manager-core/src/route_repo.rs
Normal file
168
crates/manager-core/src/route_repo.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
//! CRUD over the `routes` table.
|
||||
//!
|
||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
||||
//! every write — see the route_admin module for the binding.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewRoute {
|
||||
pub script_id: ScriptId,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RouteRepository: Send + Sync {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresRouteRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresRouteRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(script_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at",
|
||||
)
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
.bind(input.host_param_name.as_deref())
|
||||
.bind(path_kind_str(input.path_kind))
|
||||
.bind(&input.path)
|
||||
.bind(input.method.as_deref())
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict("a route with this binding already exists".into()),
|
||||
),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
Err(ScriptRepositoryError::NotFound(input.script_id))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM routes WHERE id = $1")
|
||||
.bind(route_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(ScriptRepositoryError::NotFound(ScriptId::from(route_id)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||
match k {
|
||||
HostKind::Any => "any",
|
||||
HostKind::Strict => "strict",
|
||||
HostKind::Wildcard => "wildcard",
|
||||
}
|
||||
}
|
||||
|
||||
const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
match k {
|
||||
PathKind::Exact => "exact",
|
||||
PathKind::Prefix => "prefix",
|
||||
PathKind::Param => "param",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
script_id: Uuid,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
host_param_name: Option<String>,
|
||||
path_kind: String,
|
||||
path: String,
|
||||
method: Option<String>,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<RouteRow> for Route {
|
||||
fn from(r: RouteRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
script_id: r.script_id.into(),
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
"wildcard" => HostKind::Wildcard,
|
||||
_ => HostKind::Any,
|
||||
},
|
||||
host: r.host,
|
||||
host_param_name: r.host_param_name,
|
||||
path_kind: match r.path_kind.as_str() {
|
||||
"prefix" => PathKind::Prefix,
|
||||
"param" => PathKind::Param,
|
||||
_ => PathKind::Exact,
|
||||
},
|
||||
path: r.path,
|
||||
method: r.method,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,3 +22,4 @@ uuid.workspace = true
|
||||
chrono.workspace = true
|
||||
reqwest.workspace = true
|
||||
tokio.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::time::Duration;
|
||||
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{Path, State},
|
||||
extract::{Path, Request, State},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
@@ -24,12 +24,16 @@ use uuid::Uuid;
|
||||
|
||||
use crate::client::ExecutorClient;
|
||||
use crate::resolver::{ResolverError, ScriptResolver};
|
||||
use crate::routing::RouteTable;
|
||||
|
||||
/// State shared by data-plane handlers.
|
||||
pub struct DataPlaneState<E, R> {
|
||||
pub executor: Arc<E>,
|
||||
pub resolver: Arc<R>,
|
||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||
/// Routing table for user-defined paths. Shared with the manager
|
||||
/// (admin router writes; this side reads).
|
||||
pub routes: Arc<RouteTable>,
|
||||
}
|
||||
|
||||
impl<E, R> Clone for DataPlaneState<E, R> {
|
||||
@@ -38,11 +42,13 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
||||
executor: self.executor.clone(),
|
||||
resolver: self.resolver.clone(),
|
||||
log_sink: self.log_sink.clone(),
|
||||
routes: self.routes.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the data-plane router. Handles `POST /execute/:id`.
|
||||
/// Build the data-plane router. Handles `POST /execute/:id` — the
|
||||
/// always-available ID-based bypass.
|
||||
pub fn data_plane_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||
where
|
||||
E: ExecutorClient + 'static,
|
||||
@@ -53,6 +59,19 @@ where
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Build a router that handles ALL paths via the user-defined routing
|
||||
/// table. Intended to be merged into the picloud app router as a
|
||||
/// fallback (after the system routes are mounted).
|
||||
pub fn user_routes_router<E, R>(state: DataPlaneState<E, R>) -> Router
|
||||
where
|
||||
E: ExecutorClient + 'static,
|
||||
R: ScriptResolver + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.fallback(user_route_handler::<E, R>)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -106,6 +125,113 @@ where
|
||||
Ok(exec_response_to_http(outcome?))
|
||||
}
|
||||
|
||||
async fn user_route_handler<E, R>(
|
||||
State(state): State<DataPlaneState<E, R>>,
|
||||
request: Request,
|
||||
) -> Result<Response, ApiError>
|
||||
where
|
||||
E: ExecutorClient + 'static,
|
||||
R: ScriptResolver + 'static,
|
||||
{
|
||||
let method = request.method().as_str().to_string();
|
||||
let uri = request.uri().clone();
|
||||
let path = uri.path().to_string();
|
||||
let query_str = uri.query().unwrap_or("").to_string();
|
||||
let host = request
|
||||
.headers()
|
||||
.get("host")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let headers = request.headers().clone();
|
||||
|
||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("no route matches {method} {path}")
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
let script = state
|
||||
.resolver
|
||||
.resolve(matched.matched.script_id)
|
||||
.await?
|
||||
.ok_or(ApiError::NotFound(matched.matched.script_id))?;
|
||||
|
||||
// Drain the body now that we know we'll execute. 10 MiB cap matches
|
||||
// the conservative default response/request size in the blueprint.
|
||||
let body_bytes = match axum::body::to_bytes(request.into_body(), 10 * 1024 * 1024).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Err(ApiError::BadRequest(format!("body read failed: {e}"))),
|
||||
};
|
||||
|
||||
let mut req = build_exec_request(
|
||||
matched.matched.script_id,
|
||||
&script.name,
|
||||
&headers,
|
||||
&body_bytes,
|
||||
)?;
|
||||
req.path = path;
|
||||
req.params = matched.params;
|
||||
req.query = parse_query_string(&query_str);
|
||||
req.rest = matched.rest.unwrap_or_default();
|
||||
req.sandbox_overrides = script.sandbox;
|
||||
|
||||
let request_id = req.request_id;
|
||||
let request_path = req.path.clone();
|
||||
let request_headers = req.headers.clone();
|
||||
let request_body = req.body.clone();
|
||||
|
||||
let timeout = Duration::from_secs(u64::from(script.timeout_seconds));
|
||||
let started = Utc::now();
|
||||
let outcome = state.executor.execute(&script.source, req, timeout).await;
|
||||
let finished = Utc::now();
|
||||
|
||||
let log = build_execution_log(
|
||||
matched.matched.script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
request_headers,
|
||||
request_body,
|
||||
&outcome,
|
||||
started,
|
||||
finished,
|
||||
);
|
||||
if let Err(e) = state.log_sink.record(log).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
script_id = %matched.matched.script_id,
|
||||
"failed to persist execution log"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(exec_response_to_http(outcome?))
|
||||
}
|
||||
|
||||
fn parse_query_string(s: &str) -> BTreeMap<String, String> {
|
||||
let mut out = BTreeMap::new();
|
||||
if s.is_empty() {
|
||||
return out;
|
||||
}
|
||||
for pair in s.split('&') {
|
||||
let (k, v) = match pair.split_once('=') {
|
||||
Some((k, v)) => (k, v),
|
||||
None => (pair, ""),
|
||||
};
|
||||
let key = urlencoding::decode(k)
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.unwrap_or_default();
|
||||
let val = urlencoding::decode(v)
|
||||
.map(std::borrow::Cow::into_owned)
|
||||
.unwrap_or_default();
|
||||
out.insert(key, val);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Marshalling
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -139,6 +265,9 @@ fn build_exec_request(
|
||||
path: format!("/api/execute/{id}"),
|
||||
headers: hmap,
|
||||
body: body_json,
|
||||
params: BTreeMap::new(),
|
||||
query: BTreeMap::new(),
|
||||
rest: String::new(),
|
||||
// Overwritten by the handler after the script is resolved.
|
||||
sandbox_overrides: picloud_shared::ScriptSandbox::default(),
|
||||
})
|
||||
|
||||
@@ -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};
|
||||
|
||||
238
crates/orchestrator-core/src/routing/conflict.rs
Normal file
238
crates/orchestrator-core/src/routing/conflict.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
//! Within-kind overlap detection.
|
||||
//!
|
||||
//! Two routes "conflict" if they're the same kind AND there exists at
|
||||
//! least one request that both would match. Cross-kind overlap is OK —
|
||||
//! the runtime matcher resolves it via the precedence rule
|
||||
//! (`exact > param > prefix`, with leading-literal specificity).
|
||||
//!
|
||||
//! The user's design intent (see chat history): "There should only be
|
||||
//! one eligible route in each domain for a path within the same kind."
|
||||
//! This file is the predicate that enforces it at write time.
|
||||
|
||||
use super::pattern::{HostPattern, PathPattern, PathSegment};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConflictReason {
|
||||
/// Two exact paths with identical literal value.
|
||||
IdenticalExact,
|
||||
/// Two prefix paths with identical literal value (different
|
||||
/// lengths coexist; the longer wins at runtime).
|
||||
IdenticalPrefix,
|
||||
/// Two param paths with the same shape (same segment count and
|
||||
/// matching literals at every literal-vs-literal position).
|
||||
OverlappingParam,
|
||||
}
|
||||
|
||||
/// True if these two patterns conflict (within-kind only). Callers
|
||||
/// should already have decided the host/method dimensions overlap.
|
||||
#[must_use]
|
||||
pub fn conflicts(a: &PathPattern, b: &PathPattern) -> Option<ConflictReason> {
|
||||
match (a, b) {
|
||||
(PathPattern::Exact(x), PathPattern::Exact(y)) if x == y => {
|
||||
Some(ConflictReason::IdenticalExact)
|
||||
}
|
||||
(PathPattern::Prefix(x), PathPattern::Prefix(y)) if x == y => {
|
||||
Some(ConflictReason::IdenticalPrefix)
|
||||
}
|
||||
(PathPattern::Param(xs), PathPattern::Param(ys)) if param_overlap(xs, ys) => {
|
||||
Some(ConflictReason::OverlappingParam)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Two host patterns "share a domain bucket" — i.e., a request to one
|
||||
/// host satisfies both — exactly when their parsed forms agree on kind
|
||||
/// and value. Conflict-checks across different domain buckets do not
|
||||
/// apply (the runtime host precedence picks one bucket per request).
|
||||
#[must_use]
|
||||
pub fn hosts_overlap(a: &HostPattern, b: &HostPattern) -> bool {
|
||||
match (a, b) {
|
||||
(HostPattern::Any, HostPattern::Any) => true,
|
||||
(HostPattern::Strict(x), HostPattern::Strict(y)) => x == y,
|
||||
(HostPattern::Wildcard { suffix: x, .. }, HostPattern::Wildcard { suffix: y, .. }) => {
|
||||
x == y
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the two HTTP methods would dispatch to the same route.
|
||||
/// `None` = "any method"; two Anys overlap, an Any overlaps any
|
||||
/// specific method, and two specifics overlap iff equal.
|
||||
#[must_use]
|
||||
pub fn methods_overlap(a: Option<&str>, b: Option<&str>) -> bool {
|
||||
match (a, b) {
|
||||
(None, _) | (_, None) => true,
|
||||
(Some(x), Some(y)) => x.eq_ignore_ascii_case(y),
|
||||
}
|
||||
}
|
||||
|
||||
fn param_overlap(a: &[PathSegment], b: &[PathSegment]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
a.iter().zip(b.iter()).all(|(x, y)| match (x, y) {
|
||||
(PathSegment::Literal(p), PathSegment::Literal(q)) => p == q,
|
||||
// Any pair involving at least one Param accepts overlap.
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::pattern::{parse_path, PathPattern};
|
||||
use super::*;
|
||||
use picloud_shared::PathKind;
|
||||
|
||||
fn p(kind: PathKind, raw: &str) -> PathPattern {
|
||||
parse_path(kind, raw).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_exacts_conflict() {
|
||||
assert_eq!(
|
||||
conflicts(&p(PathKind::Exact, "/x"), &p(PathKind::Exact, "/x")),
|
||||
Some(ConflictReason::IdenticalExact)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_exacts_dont_conflict() {
|
||||
assert_eq!(
|
||||
conflicts(&p(PathKind::Exact, "/x"), &p(PathKind::Exact, "/y")),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_prefixes_conflict() {
|
||||
assert_eq!(
|
||||
conflicts(
|
||||
&p(PathKind::Prefix, "/greet/*"),
|
||||
&p(PathKind::Prefix, "/greet/*")
|
||||
),
|
||||
Some(ConflictReason::IdenticalPrefix)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_length_prefixes_dont_conflict() {
|
||||
assert_eq!(
|
||||
conflicts(
|
||||
&p(PathKind::Prefix, "/greet/*"),
|
||||
&p(PathKind::Prefix, "/greet/sub/*")
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_kind_never_conflicts() {
|
||||
assert_eq!(
|
||||
conflicts(&p(PathKind::Exact, "/x"), &p(PathKind::Prefix, "/x/*")),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
conflicts(
|
||||
&p(PathKind::Exact, "/greet/alice"),
|
||||
&p(PathKind::Param, "/greet/:name")
|
||||
),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
conflicts(
|
||||
&p(PathKind::Param, "/greet/:name"),
|
||||
&p(PathKind::Prefix, "/greet/*")
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_shape_params_conflict_regardless_of_name() {
|
||||
assert_eq!(
|
||||
conflicts(
|
||||
&p(PathKind::Param, "/users/:id"),
|
||||
&p(PathKind::Param, "/users/:userId")
|
||||
),
|
||||
Some(ConflictReason::OverlappingParam)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_shapes_with_overlapping_literals_conflict() {
|
||||
// /users/admin/posts is reachable by both.
|
||||
assert_eq!(
|
||||
conflicts(
|
||||
&p(PathKind::Param, "/users/:id/posts"),
|
||||
&p(PathKind::Param, "/users/admin/:action")
|
||||
),
|
||||
Some(ConflictReason::OverlappingParam)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_with_distinct_trailing_literal_no_conflict() {
|
||||
assert_eq!(
|
||||
conflicts(
|
||||
&p(PathKind::Param, "/users/:id/posts"),
|
||||
&p(PathKind::Param, "/users/:id/comments")
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_of_different_lengths_no_conflict() {
|
||||
assert_eq!(
|
||||
conflicts(
|
||||
&p(PathKind::Param, "/users/:id"),
|
||||
&p(PathKind::Param, "/users/:id/posts")
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_any_overlaps_anything() {
|
||||
assert!(methods_overlap(None, None));
|
||||
assert!(methods_overlap(None, Some("GET")));
|
||||
assert!(methods_overlap(Some("POST"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_case_insensitive() {
|
||||
assert!(methods_overlap(Some("GET"), Some("get")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn methods_different_specifics_no_overlap() {
|
||||
assert!(!methods_overlap(Some("GET"), Some("POST")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_strict_strict() {
|
||||
let a = HostPattern::Strict("a.com".into());
|
||||
let b = HostPattern::Strict("a.com".into());
|
||||
let c = HostPattern::Strict("b.com".into());
|
||||
assert!(hosts_overlap(&a, &b));
|
||||
assert!(!hosts_overlap(&a, &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosts_strict_vs_wildcard_different_buckets() {
|
||||
let s = HostPattern::Strict("sub.example.com".into());
|
||||
let w = HostPattern::Wildcard {
|
||||
suffix: "example.com".into(),
|
||||
capture: None,
|
||||
};
|
||||
// They CAN match the same request at runtime; specificity
|
||||
// picks one. They're not in the same conflict bucket.
|
||||
assert!(!hosts_overlap(&s, &w));
|
||||
}
|
||||
}
|
||||
454
crates/orchestrator-core/src/routing/matcher.rs
Normal file
454
crates/orchestrator-core/src/routing/matcher.rs
Normal file
@@ -0,0 +1,454 @@
|
||||
//! Runtime route matching.
|
||||
//!
|
||||
//! Given a `(host, method, path)` triple and a `RouteTable`, returns
|
||||
//! the single best matching route plus extracted parameters, or `None`.
|
||||
//!
|
||||
//! Rules (decided in design discussion, see chat history):
|
||||
//!
|
||||
//! 1. Host dispatch: collect candidate routes whose host pattern
|
||||
//! matches the request Host. `strict > wildcard (longer suffix
|
||||
//! wins) > any`. Within the most-specific matching host bucket,
|
||||
//! try path matching; if nothing matches, fall through to less
|
||||
//! specific buckets.
|
||||
//! 2. Within a host bucket, method must overlap (request method
|
||||
//! equals route method, or route method is `any`).
|
||||
//! 3. Path dispatch by kind: exact wins absolute; among non-exact
|
||||
//! matches, more leading literal segments wins; tie → `param >
|
||||
//! prefix`. Within prefix, longest matching prefix wins.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::pattern::{HostPattern, PathPattern, PathSegment};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MatchResult {
|
||||
pub matched: Matched,
|
||||
pub params: BTreeMap<String, String>,
|
||||
/// `Some(rest)` when the matched route is a prefix; the remainder
|
||||
/// of the URL path after the matched prefix, with no leading slash.
|
||||
pub rest: Option<String>,
|
||||
/// Captured host parameter (future `{tenant}.example.com`); always
|
||||
/// `None` for the v1.1 SDK since the brace syntax is reserved.
|
||||
pub host_param: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// Reference back into the caller's route storage. Generic so callers
|
||||
/// can carry their own `Route` shape; only the patterns are required.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Matched {
|
||||
pub route_id: uuid::Uuid,
|
||||
pub script_id: picloud_shared::ScriptId,
|
||||
}
|
||||
|
||||
/// A single route ready for matching.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledRoute {
|
||||
pub route_id: uuid::Uuid,
|
||||
pub script_id: picloud_shared::ScriptId,
|
||||
pub host: HostPattern,
|
||||
pub path: PathPattern,
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
/// Find the best matching route for the request. Returns `None` if no
|
||||
/// route is eligible at any host-specificity level.
|
||||
#[must_use]
|
||||
pub fn r#match<'a, I>(
|
||||
routes: I,
|
||||
request_host: &str,
|
||||
request_method: &str,
|
||||
request_path: &str,
|
||||
) -> Option<MatchResult>
|
||||
where
|
||||
I: IntoIterator<Item = &'a CompiledRoute>,
|
||||
{
|
||||
let candidates: Vec<&CompiledRoute> = routes.into_iter().collect();
|
||||
|
||||
// Group by host-specificity, descending. We try each bucket in
|
||||
// turn — the moment we find a path match within a bucket, return.
|
||||
let buckets = bucket_by_host(&candidates, request_host);
|
||||
|
||||
for bucket in buckets {
|
||||
if let Some(result) = match_within_bucket(&bucket, request_method, request_path) {
|
||||
return Some(result);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Optional host capture (param-name + extracted label) when a wildcard
|
||||
/// match has the `{name}.example.com` capture syntax (deferred SDK
|
||||
/// feature; always `None` today but the type carries through so we
|
||||
/// don't have to thread it later).
|
||||
type HostCapture = Option<(String, String)>;
|
||||
|
||||
type RouteHit<'a> = (&'a CompiledRoute, HostCapture);
|
||||
|
||||
/// Returns the routes whose host pattern accepts `request_host`,
|
||||
/// grouped by host-specificity in descending order (most-specific
|
||||
/// first). Higher-priority buckets are tried first by the matcher.
|
||||
fn bucket_by_host<'a>(
|
||||
candidates: &[&'a CompiledRoute],
|
||||
request_host: &str,
|
||||
) -> Vec<Vec<RouteHit<'a>>> {
|
||||
let host_stripped = strip_port(request_host);
|
||||
|
||||
let mut hits: Vec<(HostBucket, &CompiledRoute, HostCapture)> = Vec::new();
|
||||
for r in candidates {
|
||||
if let Some(capture) = host_matches(&r.host, host_stripped) {
|
||||
hits.push((host_bucket(&r.host), r, capture));
|
||||
}
|
||||
}
|
||||
// Sort descending by bucket; group runs of equal buckets.
|
||||
hits.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
|
||||
let mut out: Vec<Vec<RouteHit<'a>>> = Vec::new();
|
||||
let mut current_bucket: Option<HostBucket> = None;
|
||||
for (bucket, route, capture) in hits {
|
||||
if Some(bucket) == current_bucket {
|
||||
out.last_mut().unwrap().push((route, capture));
|
||||
} else {
|
||||
current_bucket = Some(bucket);
|
||||
out.push(vec![(route, capture)]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum HostBucket {
|
||||
Any,
|
||||
Wildcard(usize), // suffix length
|
||||
Strict,
|
||||
}
|
||||
|
||||
fn host_bucket(p: &HostPattern) -> HostBucket {
|
||||
match p {
|
||||
HostPattern::Any => HostBucket::Any,
|
||||
HostPattern::Wildcard { suffix, .. } => HostBucket::Wildcard(suffix.len()),
|
||||
HostPattern::Strict(_) => HostBucket::Strict,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::option_option)]
|
||||
fn host_matches(pattern: &HostPattern, request_host: &str) -> Option<HostCapture> {
|
||||
match pattern {
|
||||
HostPattern::Any => Some(None),
|
||||
HostPattern::Strict(value) => {
|
||||
if value.eq_ignore_ascii_case(request_host) {
|
||||
Some(None)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
HostPattern::Wildcard { suffix, capture } => {
|
||||
let host = request_host.to_ascii_lowercase();
|
||||
let suffix = suffix.to_ascii_lowercase();
|
||||
// Require: host ends with ".suffix" AND has at least one
|
||||
// non-empty label before. Multi-level subdomains (a.b.suffix)
|
||||
// do match — common platform convention for wildcard certs
|
||||
// is one-level only, but for HTTP routing users almost
|
||||
// always want "anything under this domain".
|
||||
let dotted = format!(".{suffix}");
|
||||
host.strip_suffix(&dotted)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|label| capture.as_ref().map(|c| (c.clone(), label.to_string())))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_port(host: &str) -> &str {
|
||||
host.split(':').next().unwrap_or(host)
|
||||
}
|
||||
|
||||
fn match_within_bucket(
|
||||
bucket: &[RouteHit<'_>],
|
||||
request_method: &str,
|
||||
request_path: &str,
|
||||
) -> Option<MatchResult> {
|
||||
// 1. Exact wins absolute.
|
||||
for (route, host_param) in bucket {
|
||||
if !method_matches(route.method.as_deref(), request_method) {
|
||||
continue;
|
||||
}
|
||||
if let PathPattern::Exact(p) = &route.path {
|
||||
if p == request_path {
|
||||
return Some(MatchResult {
|
||||
matched: Matched {
|
||||
route_id: route.route_id,
|
||||
script_id: route.script_id,
|
||||
},
|
||||
params: BTreeMap::new(),
|
||||
rest: None,
|
||||
host_param: host_param.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Among non-exact matches, score by leading-literal count;
|
||||
// tie → param > prefix; tie among prefixes → longest wins.
|
||||
let mut best: Option<(MatchScore, MatchResult)> = None;
|
||||
for (route, host_param) in bucket {
|
||||
if !method_matches(route.method.as_deref(), request_method) {
|
||||
continue;
|
||||
}
|
||||
let (params, rest) = match &route.path {
|
||||
PathPattern::Exact(_) => continue, // handled above
|
||||
PathPattern::Prefix(prefix) => {
|
||||
if let Some(rest) = request_path.strip_prefix(prefix.as_str()) {
|
||||
// Successful prefix match. Empty rest is fine for
|
||||
// "/*" matching "/", but for non-root the rest is
|
||||
// typically a real path segment.
|
||||
(BTreeMap::new(), Some(rest.to_string()))
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
PathPattern::Param(segs) => {
|
||||
if let Some(captures) = match_param(segs, request_path) {
|
||||
(captures, None)
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let score = MatchScore {
|
||||
leading_literals: route.path.leading_literal_count(),
|
||||
kind_rank: kind_rank(&route.path),
|
||||
prefix_len: match &route.path {
|
||||
PathPattern::Prefix(p) => p.len(),
|
||||
_ => 0,
|
||||
},
|
||||
};
|
||||
|
||||
let result = MatchResult {
|
||||
matched: Matched {
|
||||
route_id: route.route_id,
|
||||
script_id: route.script_id,
|
||||
},
|
||||
params,
|
||||
rest,
|
||||
host_param: host_param.clone(),
|
||||
};
|
||||
|
||||
match &best {
|
||||
None => best = Some((score, result)),
|
||||
Some((bs, _)) if score > *bs => best = Some((score, result)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|(_, r)| r)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
struct MatchScore {
|
||||
leading_literals: usize,
|
||||
kind_rank: u8, // higher = preferred at equal leading_literals
|
||||
prefix_len: usize,
|
||||
}
|
||||
|
||||
fn kind_rank(p: &PathPattern) -> u8 {
|
||||
match p {
|
||||
PathPattern::Exact(_) => 3, // handled separately but kept for completeness
|
||||
PathPattern::Param(_) => 2,
|
||||
PathPattern::Prefix(_) => 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn method_matches(route_method: Option<&str>, request_method: &str) -> bool {
|
||||
match route_method {
|
||||
None => true,
|
||||
Some(m) => m.eq_ignore_ascii_case(request_method),
|
||||
}
|
||||
}
|
||||
|
||||
fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<String, String>> {
|
||||
let req_segs: Vec<&str> = request_path
|
||||
.trim_start_matches('/')
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if req_segs.len() != segs.len() {
|
||||
return None;
|
||||
}
|
||||
let mut captures = BTreeMap::new();
|
||||
for (pattern_seg, req_seg) in segs.iter().zip(req_segs.iter()) {
|
||||
match pattern_seg {
|
||||
PathSegment::Literal(lit) => {
|
||||
if lit != req_seg {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
PathSegment::Param(name) => {
|
||||
captures.insert(name.clone(), (*req_seg).to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(captures)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::pattern::parse_path;
|
||||
use super::*;
|
||||
use picloud_shared::{PathKind, ScriptId};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||
CompiledRoute {
|
||||
route_id: Uuid::new_v4(),
|
||||
script_id: ScriptId::new(),
|
||||
host,
|
||||
path: parse_path(path_kind, raw).unwrap(),
|
||||
method: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_wins_over_param_and_prefix() {
|
||||
let routes = vec![
|
||||
route(HostPattern::Any, PathKind::Exact, "/greet"),
|
||||
route(HostPattern::Any, PathKind::Param, "/:anything"),
|
||||
route(HostPattern::Any, PathKind::Prefix, "/*"),
|
||||
];
|
||||
let r = r#match(&routes, "host", "GET", "/greet").unwrap();
|
||||
assert_eq!(r.matched.route_id, routes[0].route_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_beats_prefix_at_equal_leading_literals() {
|
||||
let routes = vec![
|
||||
route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
|
||||
route(HostPattern::Any, PathKind::Param, "/foo/users/:id"),
|
||||
];
|
||||
let r = r#match(&routes, "host", "GET", "/foo/users/foo").unwrap();
|
||||
assert_eq!(r.matched.route_id, routes[1].route_id);
|
||||
assert_eq!(r.params.get("id").map(String::as_str), Some("foo"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_prefix_wins_over_shorter_prefix() {
|
||||
let routes = vec![
|
||||
route(HostPattern::Any, PathKind::Prefix, "/foo/*"),
|
||||
route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
|
||||
];
|
||||
let r = r#match(&routes, "host", "GET", "/foo/users/list").unwrap();
|
||||
assert_eq!(r.matched.route_id, routes[1].route_id);
|
||||
assert_eq!(r.rest.as_deref(), Some("list"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_leading_literals_wins_across_kinds() {
|
||||
// prefix has more leading literals than param → prefix wins.
|
||||
let routes = vec![
|
||||
route(HostPattern::Any, PathKind::Param, "/foo/:section"),
|
||||
route(HostPattern::Any, PathKind::Prefix, "/foo/users/*"),
|
||||
];
|
||||
let r = r#match(&routes, "host", "GET", "/foo/users/list").unwrap();
|
||||
assert_eq!(r.matched.route_id, routes[1].route_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_does_not_match_bare_root() {
|
||||
let routes = vec![route(HostPattern::Any, PathKind::Prefix, "/greet/*")];
|
||||
assert!(r#match(&routes, "host", "GET", "/greet").is_none());
|
||||
assert!(r#match(&routes, "host", "GET", "/greet/x").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_strict_beats_wildcard() {
|
||||
let routes = vec![
|
||||
route(
|
||||
HostPattern::Wildcard {
|
||||
suffix: "example.com".into(),
|
||||
capture: None,
|
||||
},
|
||||
PathKind::Exact,
|
||||
"/greet",
|
||||
),
|
||||
route(
|
||||
HostPattern::Strict("sub.example.com".into()),
|
||||
PathKind::Exact,
|
||||
"/greet",
|
||||
),
|
||||
];
|
||||
// sub.example.com matches both; strict wins.
|
||||
let r = r#match(&routes, "sub.example.com", "GET", "/greet").unwrap();
|
||||
assert_eq!(r.matched.route_id, routes[1].route_id);
|
||||
// other.example.com only matches wildcard.
|
||||
let r2 = r#match(&routes, "other.example.com", "GET", "/greet").unwrap();
|
||||
assert_eq!(r2.matched.route_id, routes[0].route_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wildcard_requires_subdomain_label() {
|
||||
let routes = vec![route(
|
||||
HostPattern::Wildcard {
|
||||
suffix: "example.com".into(),
|
||||
capture: None,
|
||||
},
|
||||
PathKind::Exact,
|
||||
"/x",
|
||||
)];
|
||||
// Bare apex shouldn't match a *.example.com wildcard.
|
||||
assert!(r#match(&routes, "example.com", "GET", "/x").is_none());
|
||||
// Two-level subdomains DO match (`.label.example.com` ends with `.example.com`).
|
||||
assert!(r#match(&routes, "a.b.example.com", "GET", "/x").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_to_less_specific_host_when_path_misses() {
|
||||
// Strict bucket has no path that matches; should fall through
|
||||
// to the wildcard bucket.
|
||||
let routes = vec![
|
||||
route(
|
||||
HostPattern::Strict("sub.example.com".into()),
|
||||
PathKind::Exact,
|
||||
"/admin-only",
|
||||
),
|
||||
route(
|
||||
HostPattern::Wildcard {
|
||||
suffix: "example.com".into(),
|
||||
capture: None,
|
||||
},
|
||||
PathKind::Exact,
|
||||
"/greet",
|
||||
),
|
||||
];
|
||||
let r = r#match(&routes, "sub.example.com", "GET", "/greet").unwrap();
|
||||
assert_eq!(r.matched.route_id, routes[1].route_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_filters_apply() {
|
||||
let mut get_route = route(HostPattern::Any, PathKind::Exact, "/echo");
|
||||
get_route.method = Some("GET".into());
|
||||
let mut post_route = route(HostPattern::Any, PathKind::Exact, "/echo");
|
||||
post_route.method = Some("POST".into());
|
||||
|
||||
let routes = vec![get_route.clone(), post_route.clone()];
|
||||
let r1 = r#match(&routes, "host", "GET", "/echo").unwrap();
|
||||
assert_eq!(r1.matched.route_id, get_route.route_id);
|
||||
let r2 = r#match(&routes, "host", "POST", "/echo").unwrap();
|
||||
assert_eq!(r2.matched.route_id, post_route.route_id);
|
||||
assert!(r#match(&routes, "host", "DELETE", "/echo").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_with_port_strips_port() {
|
||||
let routes = vec![route(
|
||||
HostPattern::Strict("sub.example.com".into()),
|
||||
PathKind::Exact,
|
||||
"/x",
|
||||
)];
|
||||
let r = r#match(&routes, "sub.example.com:8443", "GET", "/x");
|
||||
assert!(r.is_some());
|
||||
}
|
||||
}
|
||||
28
crates/orchestrator-core/src/routing/mod.rs
Normal file
28
crates/orchestrator-core/src/routing/mod.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! Custom routing: turning a `(host, method, path)` request into the
|
||||
//! script that should answer it.
|
||||
//!
|
||||
//! This module is the single source of truth for how PiCloud routes
|
||||
//! work. The user contract:
|
||||
//!
|
||||
//! * **Path kinds** — `exact`, `prefix`, `param`. Validation rules
|
||||
//! in `pattern`; the orchestrator parses the raw stored strings
|
||||
//! into typed `PathPattern` values for matching.
|
||||
//! * **Host kinds** — `any`, `strict`, `wildcard`. Same shape.
|
||||
//! * **Within-kind uniqueness** — two routes of the same kind that
|
||||
//! could match the same request are a configuration error.
|
||||
//! See `conflict`.
|
||||
//! * **Cross-kind precedence at request time** — `exact` wins
|
||||
//! absolute; among non-exact, more leading-literal segments
|
||||
//! wins; tie → `param > prefix`. See `matcher`.
|
||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||
//! wildcard suffix breaks ties between wildcards.
|
||||
|
||||
pub mod conflict;
|
||||
pub mod matcher;
|
||||
pub mod pattern;
|
||||
pub mod table;
|
||||
|
||||
pub use conflict::{conflicts, ConflictReason};
|
||||
pub use matcher::{MatchResult, Matched};
|
||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
||||
pub use table::RouteTable;
|
||||
417
crates/orchestrator-core/src/routing/pattern.rs
Normal file
417
crates/orchestrator-core/src/routing/pattern.rs
Normal file
@@ -0,0 +1,417 @@
|
||||
//! Parsing + validation of host and path patterns.
|
||||
//!
|
||||
//! Both kinds round-trip via the storage form (`(kind, raw_string)`)
|
||||
//! and the parsed form (`HostPattern` / `PathPattern`). The
|
||||
//! orchestrator parses once at table load; matching and conflict-checks
|
||||
//! work over the parsed form.
|
||||
|
||||
use picloud_shared::{HostKind, PathKind};
|
||||
use thiserror::Error;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
||||
pub enum ParseError {
|
||||
#[error("path must not be empty")]
|
||||
EmptyPath,
|
||||
#[error("path must start with '/'")]
|
||||
PathMissingLeadingSlash,
|
||||
#[error("prefix path must end with '/*'")]
|
||||
PrefixMissingTail,
|
||||
#[error(
|
||||
"param path must contain at least one ':name' segment (use kind=exact for literal paths)"
|
||||
)]
|
||||
ParamWithoutCaptures,
|
||||
#[error("':' must start a whole path segment; bad segment: {0:?}")]
|
||||
MidSegmentParam(String),
|
||||
#[error("empty param name in segment {0:?}")]
|
||||
EmptyParamName(String),
|
||||
#[error("invalid param name {0:?}: must match [a-zA-Z_][a-zA-Z0-9_]*")]
|
||||
InvalidParamName(String),
|
||||
#[error("duplicate param name {0:?} within a single route")]
|
||||
DuplicateParamName(String),
|
||||
#[error("path '{0}' is reserved for system use")]
|
||||
ReservedPath(String),
|
||||
#[error("'{{name}}' syntax is reserved for a future SDK release; use ':name' for whole-segment params")]
|
||||
ReservedBraceSyntax,
|
||||
|
||||
#[error("host must not be empty")]
|
||||
EmptyHost,
|
||||
#[error("wildcard host must start with '*.'")]
|
||||
WildcardMissingPrefix,
|
||||
#[error("wildcard suffix must not be empty (got just '*')")]
|
||||
EmptyWildcardSuffix,
|
||||
#[error("'{{name}}.example.com' subdomain capture is reserved for a future SDK release; use '*.example.com'")]
|
||||
ReservedHostBraceSyntax,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Path patterns
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Path-side prefixes that user routes are not allowed to bind to.
|
||||
/// Posting a route with a path beginning with any of these returns 422.
|
||||
pub const RESERVED_PATH_PREFIXES: &[&str] = &["/api/", "/admin/", "/healthz", "/version"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathPattern {
|
||||
Exact(String),
|
||||
/// Stored with the trailing slash (no `*`). Matches `path.starts_with(prefix)`.
|
||||
Prefix(String),
|
||||
Param(Vec<PathSegment>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathSegment {
|
||||
Literal(String),
|
||||
Param(String),
|
||||
}
|
||||
|
||||
impl PathPattern {
|
||||
/// Number of leading literal segments before the first variable segment.
|
||||
/// Used as the primary specificity score at match time.
|
||||
#[must_use]
|
||||
pub fn leading_literal_count(&self) -> usize {
|
||||
match self {
|
||||
Self::Exact(_) => usize::MAX, // exact wins absolute
|
||||
Self::Prefix(p) => p.split('/').filter(|s| !s.is_empty()).count(),
|
||||
Self::Param(segs) => segs
|
||||
.iter()
|
||||
.take_while(|s| matches!(s, PathSegment::Literal(_)))
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a stored path. The `kind` selects validation rules; the same
|
||||
/// raw string can be valid under multiple kinds (e.g., `/greet/:name`
|
||||
/// parses as `Exact` with a literal `:` or as `Param` with a capture).
|
||||
pub fn parse_path(kind: PathKind, raw: &str) -> Result<PathPattern, ParseError> {
|
||||
if raw.is_empty() {
|
||||
return Err(ParseError::EmptyPath);
|
||||
}
|
||||
if !raw.starts_with('/') {
|
||||
return Err(ParseError::PathMissingLeadingSlash);
|
||||
}
|
||||
if raw.contains('{') || raw.contains('}') {
|
||||
return Err(ParseError::ReservedBraceSyntax);
|
||||
}
|
||||
check_reserved(raw)?;
|
||||
|
||||
match kind {
|
||||
PathKind::Exact => Ok(PathPattern::Exact(raw.to_string())),
|
||||
PathKind::Prefix => parse_prefix(raw),
|
||||
PathKind::Param => parse_param(raw),
|
||||
}
|
||||
}
|
||||
|
||||
fn check_reserved(raw: &str) -> Result<(), ParseError> {
|
||||
for r in RESERVED_PATH_PREFIXES {
|
||||
if raw == r.trim_end_matches('/') || raw.starts_with(r) {
|
||||
return Err(ParseError::ReservedPath(raw.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_prefix(raw: &str) -> Result<PathPattern, ParseError> {
|
||||
let stripped = raw
|
||||
.strip_suffix("/*")
|
||||
.ok_or(ParseError::PrefixMissingTail)?;
|
||||
// Normalize: store with trailing `/` so starts_with matches exactly
|
||||
// the "more under here" semantic. `/greet/*` → "/greet/".
|
||||
let prefix = if stripped.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("{stripped}/")
|
||||
};
|
||||
Ok(PathPattern::Prefix(prefix))
|
||||
}
|
||||
|
||||
fn parse_param(raw: &str) -> Result<PathPattern, ParseError> {
|
||||
let mut segments = Vec::new();
|
||||
let mut seen_names = Vec::new();
|
||||
let mut any_param = false;
|
||||
|
||||
// Skip the leading '/'; trailing '/' (other than the root case)
|
||||
// would create an empty segment and is rejected.
|
||||
for seg in raw.trim_start_matches('/').split('/') {
|
||||
if seg.is_empty() {
|
||||
return Err(ParseError::MidSegmentParam(raw.to_string()));
|
||||
}
|
||||
if let Some(rest) = seg.strip_prefix(':') {
|
||||
// The chars after `:` must be the WHOLE name and nothing
|
||||
// else. Distinguish "name starts with digit" (an invalid
|
||||
// identifier as such) from "name has trailing non-ident
|
||||
// characters" (mixing :name with literal text — what we
|
||||
// call mid-segment).
|
||||
if rest.is_empty() {
|
||||
return Err(ParseError::EmptyParamName(seg.to_string()));
|
||||
}
|
||||
let bad_first =
|
||||
!(rest.chars().next().unwrap().is_ascii_alphabetic() || rest.starts_with('_'));
|
||||
let all_ident_chars = rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
|
||||
if !all_ident_chars {
|
||||
return Err(ParseError::MidSegmentParam(seg.to_string()));
|
||||
}
|
||||
if bad_first {
|
||||
return Err(ParseError::InvalidParamName(rest.to_string()));
|
||||
}
|
||||
if seen_names.iter().any(|n: &String| n == rest) {
|
||||
return Err(ParseError::DuplicateParamName(rest.to_string()));
|
||||
}
|
||||
seen_names.push(rest.to_string());
|
||||
segments.push(PathSegment::Param(rest.to_string()));
|
||||
any_param = true;
|
||||
} else if seg.contains(':') {
|
||||
return Err(ParseError::MidSegmentParam(seg.to_string()));
|
||||
} else {
|
||||
segments.push(PathSegment::Literal(seg.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if !any_param {
|
||||
return Err(ParseError::ParamWithoutCaptures);
|
||||
}
|
||||
Ok(PathPattern::Param(segments))
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Host patterns
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum HostPattern {
|
||||
Any,
|
||||
Strict(String),
|
||||
/// Suffix WITHOUT the leading `*.`; the matcher accepts any
|
||||
/// non-empty label followed by `.suffix`.
|
||||
Wildcard {
|
||||
suffix: String,
|
||||
capture: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl HostPattern {
|
||||
/// Specificity rank for host dispatch (higher wins).
|
||||
#[must_use]
|
||||
pub fn specificity(&self) -> HostSpecificity {
|
||||
match self {
|
||||
Self::Strict(_) => HostSpecificity::Strict,
|
||||
Self::Wildcard { suffix, .. } => HostSpecificity::Wildcard(suffix.len()),
|
||||
Self::Any => HostSpecificity::Any,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum HostSpecificity {
|
||||
Any,
|
||||
/// Longer wildcard suffix = more specific.
|
||||
Wildcard(usize),
|
||||
Strict,
|
||||
}
|
||||
|
||||
pub fn parse_host(
|
||||
kind: HostKind,
|
||||
raw: &str,
|
||||
capture: Option<&str>,
|
||||
) -> Result<HostPattern, ParseError> {
|
||||
match kind {
|
||||
HostKind::Any => Ok(HostPattern::Any),
|
||||
HostKind::Strict => {
|
||||
if raw.is_empty() {
|
||||
return Err(ParseError::EmptyHost);
|
||||
}
|
||||
if raw.contains('{') || raw.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
Ok(HostPattern::Strict(raw.to_string()))
|
||||
}
|
||||
HostKind::Wildcard => {
|
||||
if raw.is_empty() {
|
||||
return Err(ParseError::EmptyHost);
|
||||
}
|
||||
if raw.contains('{') || raw.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
let suffix = raw
|
||||
.strip_prefix("*.")
|
||||
.ok_or(ParseError::WildcardMissingPrefix)?;
|
||||
if suffix.is_empty() {
|
||||
return Err(ParseError::EmptyWildcardSuffix);
|
||||
}
|
||||
Ok(HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: capture.map(str::to_string),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exact_takes_any_string() {
|
||||
let p = parse_path(PathKind::Exact, "/greet").unwrap();
|
||||
assert_eq!(p, PathPattern::Exact("/greet".into()));
|
||||
// Even one with a colon — exact stores it literally.
|
||||
let p2 = parse_path(PathKind::Exact, "/greet/:name").unwrap();
|
||||
assert_eq!(p2, PathPattern::Exact("/greet/:name".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_normalizes_trailing_slash() {
|
||||
let p = parse_path(PathKind::Prefix, "/greet/*").unwrap();
|
||||
assert_eq!(p, PathPattern::Prefix("/greet/".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_rejects_no_trailing_star() {
|
||||
let e = parse_path(PathKind::Prefix, "/greet").unwrap_err();
|
||||
assert_eq!(e, ParseError::PrefixMissingTail);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_at_root() {
|
||||
let p = parse_path(PathKind::Prefix, "/*").unwrap();
|
||||
assert_eq!(p, PathPattern::Prefix("/".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_parses_segments_and_captures_names() {
|
||||
let p = parse_path(PathKind::Param, "/users/:id/posts/:post").unwrap();
|
||||
match p {
|
||||
PathPattern::Param(segs) => {
|
||||
assert_eq!(segs.len(), 4);
|
||||
assert_eq!(segs[0], PathSegment::Literal("users".into()));
|
||||
assert_eq!(segs[1], PathSegment::Param("id".into()));
|
||||
assert_eq!(segs[2], PathSegment::Literal("posts".into()));
|
||||
assert_eq!(segs[3], PathSegment::Param("post".into()));
|
||||
}
|
||||
_ => panic!("expected Param"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_rejects_mid_segment_colon() {
|
||||
let e = parse_path(PathKind::Param, "/greet/my:name").unwrap_err();
|
||||
assert!(matches!(e, ParseError::MidSegmentParam(_)));
|
||||
let e2 = parse_path(PathKind::Param, "/greet/:name.json").unwrap_err();
|
||||
assert!(matches!(e2, ParseError::MidSegmentParam(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_rejects_duplicate_names() {
|
||||
let e = parse_path(PathKind::Param, "/users/:id/posts/:id").unwrap_err();
|
||||
assert_eq!(e, ParseError::DuplicateParamName("id".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_rejects_empty_or_invalid_names() {
|
||||
assert!(matches!(
|
||||
parse_path(PathKind::Param, "/greet/:"),
|
||||
Err(ParseError::EmptyParamName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_path(PathKind::Param, "/greet/:9id"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn param_requires_at_least_one_capture() {
|
||||
let e = parse_path(PathKind::Param, "/just/literal").unwrap_err();
|
||||
assert_eq!(e, ParseError::ParamWithoutCaptures);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_brace_syntax_reserved() {
|
||||
let e = parse_path(PathKind::Exact, "/x/{name}").unwrap_err();
|
||||
assert_eq!(e, ParseError::ReservedBraceSyntax);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_reserved_paths() {
|
||||
for raw in [
|
||||
"/api/v1/admin/scripts",
|
||||
"/api/v2/foo",
|
||||
"/admin/dashboard",
|
||||
"/healthz",
|
||||
"/version",
|
||||
] {
|
||||
let e = parse_path(PathKind::Exact, raw).unwrap_err();
|
||||
assert!(
|
||||
matches!(e, ParseError::ReservedPath(_)),
|
||||
"expected reserved for {raw:?}, got {e:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_leading_slash() {
|
||||
let e = parse_path(PathKind::Exact, "greet").unwrap_err();
|
||||
assert_eq!(e, ParseError::PathMissingLeadingSlash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_strict_and_wildcard() {
|
||||
assert_eq!(
|
||||
parse_host(HostKind::Strict, "sub.example.com", None).unwrap(),
|
||||
HostPattern::Strict("sub.example.com".into())
|
||||
);
|
||||
assert_eq!(
|
||||
parse_host(HostKind::Wildcard, "*.example.com", None).unwrap(),
|
||||
HostPattern::Wildcard {
|
||||
suffix: "example.com".into(),
|
||||
capture: None
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_host(HostKind::Any, "", None).unwrap(),
|
||||
HostPattern::Any
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_rejects_wildcard_without_star_dot() {
|
||||
let e = parse_host(HostKind::Wildcard, "example.com", None).unwrap_err();
|
||||
assert_eq!(e, ParseError::WildcardMissingPrefix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_rejects_brace_syntax() {
|
||||
let e = parse_host(HostKind::Wildcard, "{tenant}.example.com", None).unwrap_err();
|
||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_literal_count_works() {
|
||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||
assert_eq!(exact.leading_literal_count(), usize::MAX);
|
||||
|
||||
let prefix2 = parse_path(PathKind::Prefix, "/foo/users/*").unwrap();
|
||||
assert_eq!(prefix2.leading_literal_count(), 2);
|
||||
|
||||
let prefix1 = parse_path(PathKind::Prefix, "/foo/*").unwrap();
|
||||
assert_eq!(prefix1.leading_literal_count(), 1);
|
||||
|
||||
let param2 = parse_path(PathKind::Param, "/foo/users/:id").unwrap();
|
||||
assert_eq!(param2.leading_literal_count(), 2);
|
||||
|
||||
let param1 = parse_path(PathKind::Param, "/foo/:section").unwrap();
|
||||
assert_eq!(param1.leading_literal_count(), 1);
|
||||
|
||||
let param_then_lit = parse_path(PathKind::Param, "/foo/:s/list").unwrap();
|
||||
// Literal AFTER a param doesn't count for "leading literal".
|
||||
assert_eq!(param_then_lit.leading_literal_count(), 1);
|
||||
}
|
||||
}
|
||||
43
crates/orchestrator-core/src/routing/table.rs
Normal file
43
crates/orchestrator-core/src/routing/table.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
||||
//! and orchestrator (reads).
|
||||
//!
|
||||
//! Holds an `arc-swap`-style lock-free hand-off so the dispatcher can
|
||||
//! read without contending against the writer; in MVP-single-process
|
||||
//! we just use `RwLock` and accept the cheap contention.
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct RouteTable {
|
||||
inner: RwLock<Vec<CompiledRoute>>,
|
||||
}
|
||||
|
||||
impl RouteTable {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Replace the whole table atomically. The manager calls this after
|
||||
/// each successful route CRUD operation (by re-reading from DB).
|
||||
pub fn replace(&self, routes: Vec<CompiledRoute>) {
|
||||
let mut guard = self.inner.write().expect("route table poisoned");
|
||||
*guard = routes;
|
||||
}
|
||||
|
||||
/// Dispatch a request to a matching route, or `None`.
|
||||
#[must_use]
|
||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
||||
let guard = self.inner.read().expect("route table poisoned");
|
||||
r#match(guard.iter(), host, method, path)
|
||||
}
|
||||
|
||||
/// Returns a clone of the currently compiled routes; intended for
|
||||
/// the dashboard's "list routes" admin endpoint.
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
||||
self.inner.read().expect("route table poisoned").clone()
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,14 @@ use std::time::Duration;
|
||||
use axum::{routing::get, Json, Router};
|
||||
use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, migrations, AdminState, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
||||
PostgresScriptRepository, RepoResolver, SandboxCeiling,
|
||||
admin_router, compile_routes, migrations, route_admin_router, AdminState,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::RouteTable;
|
||||
use picloud_orchestrator_core::{
|
||||
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
||||
};
|
||||
use picloud_orchestrator_core::{data_plane_router, DataPlaneState, LocalExecutorClient};
|
||||
use picloud_shared::{
|
||||
ExecutionLogSink, ScriptValidator, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
||||
};
|
||||
@@ -23,16 +27,24 @@ use tower_http::trace::TraceLayer;
|
||||
/// Compose the manager + orchestrator routes on top of a shared
|
||||
/// Postgres pool, returning an Axum router ready to be served.
|
||||
///
|
||||
/// All API routes live under `/api/v{API_VERSION}/...`. New major
|
||||
/// versions get a parallel nest under `/api/v{N+1}/...`; the old
|
||||
/// prefix is kept live for at least one product-minor deprecation
|
||||
/// window (see `docs/versioning.md`).
|
||||
pub fn build_app(pool: PgPool) -> Router {
|
||||
/// All API routes live under `/api/v{API_VERSION}/...`. The dashboard
|
||||
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
||||
/// falls through to the user-route table — user scripts can bind to
|
||||
/// arbitrary paths (subject to the reserved-prefix list).
|
||||
pub async fn build_app(pool: PgPool) -> anyhow::Result<Router> {
|
||||
let engine = Arc::new(Engine::new(Limits::default()));
|
||||
|
||||
let script_repo = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||
let log_repo = Arc::new(PostgresExecutionLogRepository::new(pool.clone()));
|
||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool));
|
||||
let log_sink: Arc<dyn ExecutionLogSink> = Arc::new(PostgresExecutionLogSink::new(pool.clone()));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
|
||||
|
||||
// Compile the routes table once at startup; admin writes refresh it.
|
||||
let route_table = Arc::new(RouteTable::new());
|
||||
let initial = route_repo.list_all().await?;
|
||||
let compiled = compile_routes(&initial)
|
||||
.map_err(|e| anyhow::anyhow!("failed to compile stored routes: {e}"))?;
|
||||
route_table.replace(compiled);
|
||||
|
||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||
script_repo.clone(),
|
||||
@@ -45,22 +57,28 @@ pub fn build_app(pool: PgPool) -> Router {
|
||||
validator: engine as Arc<dyn ScriptValidator>,
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let route_admin = RouteAdminState {
|
||||
routes: route_repo,
|
||||
table: route_table.clone(),
|
||||
};
|
||||
let data_plane = DataPlaneState {
|
||||
executor,
|
||||
resolver,
|
||||
log_sink,
|
||||
routes: route_table,
|
||||
};
|
||||
|
||||
let api_v1 = Router::new()
|
||||
.nest("/admin", admin_router(admin))
|
||||
.merge(data_plane_router(data_plane));
|
||||
.nest("/admin", route_admin_router(route_admin))
|
||||
.merge(data_plane_router(data_plane.clone()));
|
||||
|
||||
Router::new()
|
||||
Ok(Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/version", get(version))
|
||||
.route("/", get(root))
|
||||
.nest(&format!("/api/v{API_VERSION}"), api_v1)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.merge(user_routes_router(data_plane))
|
||||
.layer(TraceLayer::new_for_http()))
|
||||
}
|
||||
|
||||
/// Open a Postgres pool with the binary's standard timeout settings.
|
||||
@@ -78,20 +96,23 @@ async fn healthz() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
async fn root() -> &'static str {
|
||||
"picloud — see /api/v1/admin/* (manager), /api/v1/execute/* (orchestrator), /version"
|
||||
}
|
||||
|
||||
/// Snapshot of every compatibility-surface version this process speaks.
|
||||
/// Documented in `docs/versioning.md`; the source of truth is
|
||||
/// `shared::version` plus the embedded migrations.
|
||||
/// Snapshot of every compatibility-surface version this process speaks
|
||||
/// plus the operator-configured public base URL (so the dashboard can
|
||||
/// render full URLs for user routes).
|
||||
///
|
||||
/// Source of truth: `shared::version`, the embedded migrations, and
|
||||
/// the `PICLOUD_PUBLIC_BASE_URL` env var (default
|
||||
/// `http://localhost:8000`).
|
||||
async fn version() -> Json<serde_json::Value> {
|
||||
let public_base_url = std::env::var("PICLOUD_PUBLIC_BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||
Json(serde_json::json!({
|
||||
"product": PRODUCT_VERSION,
|
||||
"sdk": SDK_VERSION,
|
||||
"api": API_VERSION,
|
||||
"schema": migrations::latest_version(),
|
||||
"wire": WIRE_VERSION,
|
||||
"public_base_url": public_base_url,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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};
|
||||
|
||||
60
crates/shared/src/route.rs
Normal file
60
crates/shared/src/route.rs
Normal file
@@ -0,0 +1,60 @@
|
||||
//! Route binding: which `(host, method, path)` tuples invoke a given
|
||||
//! script. The storage shape (this file) is intentionally flat — the
|
||||
//! orchestrator parses these into typed `HostPattern` / `PathPattern`
|
||||
//! values for matching, and reconstructs strings for display.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::ScriptId;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum HostKind {
|
||||
/// Matches any host header.
|
||||
Any,
|
||||
/// Exact hostname match: `sub.example.com`.
|
||||
Strict,
|
||||
/// Wildcard suffix match: `*.example.com` matches any subdomain of
|
||||
/// `example.com`. Capture name is reserved for the future
|
||||
/// `{name}.example.com` syntax (currently always None on writes).
|
||||
Wildcard,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PathKind {
|
||||
/// Literal string equality: `/greet` matches only `/greet`.
|
||||
Exact,
|
||||
/// Strict-subtree match. Stored as the prefix including the trailing
|
||||
/// slash; `/greet/*` is stored as path "/greet/" and matches
|
||||
/// `/greet/x` but not `/greet` itself (which would need its own
|
||||
/// exact route).
|
||||
Prefix,
|
||||
/// Named-parameter pattern: `/greet/:name` where each `:foo` consumes
|
||||
/// exactly one segment and captures it into `ctx.request.params.foo`.
|
||||
Param,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Route {
|
||||
pub id: Uuid,
|
||||
pub script_id: ScriptId,
|
||||
|
||||
pub host_kind: HostKind,
|
||||
/// For `Any`: empty string. For `Strict`: full hostname. For
|
||||
/// `Wildcard`: the suffix after the leading `*.` (e.g. `example.com`).
|
||||
pub host: String,
|
||||
pub host_param_name: Option<String>,
|
||||
|
||||
pub path_kind: PathKind,
|
||||
/// Raw path as the user typed it. For `Prefix`, normalized to end
|
||||
/// with `/` (the trailing `*` is dropped on write).
|
||||
pub path: String,
|
||||
|
||||
/// `None` = any method.
|
||||
pub method: Option<String>,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picloud-dashboard",
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="shell">
|
||||
<header>
|
||||
<a href="/" class="brand">PiCloud</a>
|
||||
<a href={base + '/'} class="brand">PiCloud</a>
|
||||
<nav>
|
||||
<a href="/">Scripts</a>
|
||||
<a href={base + '/'}>Scripts</a>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { api, ApiError, type Script } from '$lib/api';
|
||||
|
||||
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
|
||||
@@ -106,7 +107,7 @@
|
||||
<ul class="list">
|
||||
{#each scripts as script (script.id)}
|
||||
<li>
|
||||
<a href="/scripts/{script.id}">
|
||||
<a href="{base}/scripts/{script.id}">
|
||||
<div class="primary">
|
||||
<strong>{script.name}</strong>
|
||||
<span class="muted">v{script.version}</span>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type ExecutionLog, type Script } from '$lib/api';
|
||||
import { logLevelColor, statusColor } from '$lib/styles';
|
||||
@@ -118,7 +119,7 @@
|
||||
deleting = true;
|
||||
try {
|
||||
await api.scripts.remove(id);
|
||||
await goto('/');
|
||||
await goto(base + '/');
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : String(e));
|
||||
deleting = false;
|
||||
@@ -132,7 +133,7 @@
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<a class="back" href="/">← Scripts</a>
|
||||
<a class="back" href={base + '/'}>← Scripts</a>
|
||||
|
||||
{#if scriptLoading}
|
||||
<p class="muted">Loading…</p>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user