feat(manager-core,orchestrator-core): multi-app scoping (Phase 3b)
Apps become the isolation boundary for scripts, routes, domains, and
later data. Doing this now — while the surface is small — avoids
several migrations on populated tables once v1.1 data-plane services
ship.
Schema (migration 0005_apps.sql):
- New tables: apps, app_domains (with shape_key UNIQUE for collision
detection), app_slug_history (for permanent slug-rename redirects).
- app_id added to scripts, routes, execution_logs (non-null, cascading
rules per row).
- Script-name uniqueness becomes per-app; the route unique index is
swapped for an app-scoped version.
- The "default" app is seeded unconditionally with a localhost claim;
existing scripts/routes backfill into it. Fresh installs additionally
get the Hello World seed via seed_hello_world_if_fresh after
migrations run (idempotent — only fires when the default app has no
scripts).
Orchestrator dispatch is two-phase: AppDomainTable resolves Host →
app_id (most-specific match wins, exact beats wildcard), then the
existing route matcher runs against that app's partitioned slice via
RouteTable. Unknown hosts return 404 at the app layer with a clear
message; /api/v1/execute/{id} still works as the implicit
__internal__ claim, decoupled from any public domain.
Manager API: full CRUD for /api/v1/admin/apps/* and
/api/v1/admin/apps/{id_or_slug}/domains/*, with slug:check + force
takeover semantics implementing the rename-history flow (two-step
check → confirm, never a single endpoint). Script create requires
app_id; list accepts ?app= filter. Route create validates host
against the parent app's claims; conflict detection stays strictly
intra-app.
Dashboard: /admin/apps and /admin/apps/{slug} (overview + scripts +
domains + settings tabs, with slug-history-aware redirects). Root
path redirects to the apps list. Script detail page gains an app
breadcrumb and threads app_id into the route preview.
Deferred per design: per-app admin roles. The require_admin middleware
remains the seam where role checks will slot in later.
Blueprint §11.5 and roadmap updated to reflect what shipped; docs/
versioning.md notes the schema 3 → 5 bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,22 +17,26 @@ use axum::{
|
||||
use chrono::Utc;
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_shared::{
|
||||
ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||
AppId, ExecutionId, ExecutionLog, ExecutionLogSink, ExecutionStatus, RequestId, ScriptId,
|
||||
};
|
||||
use serde_json::Value as Json_;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::client::ExecutorClient;
|
||||
use crate::resolver::{ResolverError, ScriptResolver};
|
||||
use crate::routing::RouteTable;
|
||||
use crate::routing::{AppDomainTable, 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).
|
||||
/// Host → app_id resolver. Run before `routes` to filter to the
|
||||
/// owning app's slice. Shared with the manager (writes invalidate
|
||||
/// the cache by replacing the table).
|
||||
pub app_domains: Arc<AppDomainTable>,
|
||||
/// Routing table for user-defined paths, partitioned per app.
|
||||
/// Shared with the manager (admin router writes; this side reads).
|
||||
pub routes: Arc<RouteTable>,
|
||||
}
|
||||
|
||||
@@ -42,6 +46,7 @@ impl<E, R> Clone for DataPlaneState<E, R> {
|
||||
executor: self.executor.clone(),
|
||||
resolver: self.resolver.clone(),
|
||||
log_sink: self.log_sink.clone(),
|
||||
app_domains: self.app_domains.clone(),
|
||||
routes: self.routes.clone(),
|
||||
}
|
||||
}
|
||||
@@ -109,6 +114,7 @@ where
|
||||
// audit-visible platform — but a sink failure must not mask the
|
||||
// user-facing result, so we only log a warning if it fails.
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -145,7 +151,23 @@ where
|
||||
.to_string();
|
||||
let headers = request.headers().clone();
|
||||
|
||||
let Some(matched) = state.routes.match_request(&host, &method, &path) else {
|
||||
// Two-phase dispatch (blueprint §11.5): first resolve Host → app_id,
|
||||
// then run the existing matcher on that app's slice. No app claims
|
||||
// this host → flat 404; the path doesn't get the chance to fire.
|
||||
let Some(app_id) = state.app_domains.resolve_app(&host) else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
"error": format!("no app claims host {host:?}")
|
||||
})),
|
||||
)
|
||||
.into_response());
|
||||
};
|
||||
|
||||
let Some(matched) = state
|
||||
.routes
|
||||
.match_request_for_app(app_id, &host, &method, &path)
|
||||
else {
|
||||
return Ok((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(serde_json::json!({
|
||||
@@ -191,6 +213,7 @@ where
|
||||
let finished = Utc::now();
|
||||
|
||||
let log = build_execution_log(
|
||||
script.app_id,
|
||||
matched.matched.script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
@@ -292,6 +315,7 @@ fn exec_response_to_http(resp: ExecResponse) -> Response {
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_execution_log(
|
||||
app_id: AppId,
|
||||
script_id: ScriptId,
|
||||
request_id: RequestId,
|
||||
request_path: String,
|
||||
@@ -336,6 +360,7 @@ fn build_execution_log(
|
||||
|
||||
ExecutionLog {
|
||||
id: Uuid::new_v4(),
|
||||
app_id,
|
||||
script_id,
|
||||
request_id,
|
||||
request_path,
|
||||
|
||||
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
165
crates/orchestrator-core/src/routing/app_domains.rs
Normal file
@@ -0,0 +1,165 @@
|
||||
//! Host → app_id resolver. The first phase of the orchestrator's
|
||||
//! two-phase dispatch (the second phase is the per-app route matcher
|
||||
//! in `routing::table::RouteTable`).
|
||||
//!
|
||||
//! Cached in memory; the manager rebuilds the table after each
|
||||
//! domain-claim CRUD operation (same pattern as `RouteTable`).
|
||||
|
||||
use std::sync::RwLock;
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
use super::pattern::{HostPattern, HostSpecificity};
|
||||
|
||||
/// A parsed domain claim ready for runtime matching.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledAppDomain {
|
||||
pub app_id: AppId,
|
||||
pub pattern: HostPattern,
|
||||
pub shape_key: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct AppDomainTable {
|
||||
inner: RwLock<Vec<CompiledAppDomain>>,
|
||||
}
|
||||
|
||||
impl AppDomainTable {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Atomic full replacement; called at startup and after every
|
||||
/// domain CRUD operation.
|
||||
pub fn replace(&self, domains: Vec<CompiledAppDomain>) {
|
||||
let mut guard = self.inner.write().expect("app domain table poisoned");
|
||||
*guard = domains;
|
||||
}
|
||||
|
||||
/// Resolve a request's `Host` header to an `AppId`. Most-specific
|
||||
/// claim wins: exact > longest wildcard > shorter wildcard. Returns
|
||||
/// `None` when no claim covers `host` (orchestrator should 404).
|
||||
#[must_use]
|
||||
pub fn resolve_app(&self, host: &str) -> Option<AppId> {
|
||||
let host = strip_port(host).to_ascii_lowercase();
|
||||
let guard = self.inner.read().expect("app domain table poisoned");
|
||||
let mut best: Option<(HostSpecificity, AppId)> = None;
|
||||
for claim in guard.iter() {
|
||||
if let Some(()) = host_matches(&claim.pattern, &host) {
|
||||
let s = claim.pattern.specificity();
|
||||
if best.is_none_or(|(prev, _)| s > prev) {
|
||||
best = Some((s, claim.app_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, app_id)| app_id)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CompiledAppDomain> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("app domain table poisoned")
|
||||
.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_port(host: &str) -> &str {
|
||||
host.split(':').next().unwrap_or(host)
|
||||
}
|
||||
|
||||
fn host_matches(pattern: &HostPattern, host: &str) -> Option<()> {
|
||||
match pattern {
|
||||
HostPattern::Any => Some(()),
|
||||
HostPattern::Strict(s) => {
|
||||
if s.eq_ignore_ascii_case(host) {
|
||||
Some(())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
HostPattern::Wildcard { suffix, .. } => {
|
||||
let dotted = format!(".{}", suffix.to_ascii_lowercase());
|
||||
host.strip_suffix(&dotted)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|_| ())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::routing::pattern::parse_app_domain;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn id() -> AppId {
|
||||
AppId::from(Uuid::new_v4())
|
||||
}
|
||||
|
||||
fn compile(app_id: AppId, raw: &str) -> CompiledAppDomain {
|
||||
let d = parse_app_domain(raw).unwrap();
|
||||
CompiledAppDomain {
|
||||
app_id,
|
||||
pattern: d.pattern,
|
||||
shape_key: d.shape_key,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_exact_over_wildcard() {
|
||||
let app_a = id();
|
||||
let app_b = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![
|
||||
compile(app_a, "foo.example.com"),
|
||||
compile(app_b, "*.example.com"),
|
||||
]);
|
||||
assert_eq!(table.resolve_app("foo.example.com"), Some(app_a));
|
||||
assert_eq!(table.resolve_app("bar.example.com"), Some(app_b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longer_wildcard_beats_shorter() {
|
||||
let inner = id();
|
||||
let outer = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![
|
||||
compile(inner, "*.api.example.com"),
|
||||
compile(outer, "*.example.com"),
|
||||
]);
|
||||
assert_eq!(
|
||||
table.resolve_app("v1.api.example.com"),
|
||||
Some(inner),
|
||||
"more-specific wildcard should win"
|
||||
);
|
||||
assert_eq!(table.resolve_app("v1.example.com"), Some(outer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameterized_resolves_like_wildcard() {
|
||||
let app = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![compile(app, "{tenant}.example.com")]);
|
||||
assert_eq!(table.resolve_app("acme.example.com"), Some(app));
|
||||
assert!(table.resolve_app("example.com").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_when_no_claim() {
|
||||
let app = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![compile(app, "foo.example.com")]);
|
||||
assert!(table.resolve_app("nope.com").is_none());
|
||||
assert!(table.resolve_app("").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_port() {
|
||||
let app = id();
|
||||
let table = AppDomainTable::new();
|
||||
table.replace(vec![compile(app, "localhost")]);
|
||||
assert_eq!(table.resolve_app("localhost:18080"), Some(app));
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,13 @@ pub struct Matched {
|
||||
pub script_id: picloud_shared::ScriptId,
|
||||
}
|
||||
|
||||
/// A single route ready for matching.
|
||||
/// A single route ready for matching. `app_id` is carried so the
|
||||
/// caller (the orchestrator's `AppRouteTables`) can partition the
|
||||
/// table; the matcher itself doesn't read it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompiledRoute {
|
||||
pub route_id: uuid::Uuid,
|
||||
pub app_id: picloud_shared::AppId,
|
||||
pub script_id: picloud_shared::ScriptId,
|
||||
pub host: HostPattern,
|
||||
pub path: PathPattern,
|
||||
@@ -298,12 +301,13 @@ fn match_param(segs: &[PathSegment], request_path: &str) -> Option<BTreeMap<Stri
|
||||
mod tests {
|
||||
use super::super::pattern::parse_path;
|
||||
use super::*;
|
||||
use picloud_shared::{PathKind, ScriptId};
|
||||
use picloud_shared::{AppId, PathKind, ScriptId};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn route(host: HostPattern, path_kind: PathKind, raw: &str) -> CompiledRoute {
|
||||
CompiledRoute {
|
||||
route_id: Uuid::new_v4(),
|
||||
app_id: AppId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
host,
|
||||
path: parse_path(path_kind, raw).unwrap(),
|
||||
|
||||
@@ -17,12 +17,16 @@
|
||||
//! * **Host dispatch** — `strict > wildcard > any`; longest matching
|
||||
//! wildcard suffix breaks ties between wildcards.
|
||||
|
||||
pub mod app_domains;
|
||||
pub mod conflict;
|
||||
pub mod matcher;
|
||||
pub mod pattern;
|
||||
pub mod table;
|
||||
|
||||
pub use app_domains::{AppDomainTable, CompiledAppDomain};
|
||||
pub use conflict::{conflicts, ConflictReason};
|
||||
pub use matcher::{MatchResult, Matched};
|
||||
pub use pattern::{HostPattern, ParseError, PathPattern, PathSegment};
|
||||
pub use pattern::{
|
||||
parse_app_domain, HostPattern, ParseError, ParsedAppDomain, PathPattern, PathSegment,
|
||||
};
|
||||
pub use table::RouteTable;
|
||||
|
||||
@@ -251,6 +251,106 @@ pub fn parse_host(
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// App-domain patterns
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
use picloud_shared::DomainShape;
|
||||
|
||||
/// Result of parsing a user-supplied app domain claim. Carries the
|
||||
/// host pattern (used at request time), the shape (used at write time
|
||||
/// for collision checks), and the normalized shape_key.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ParsedAppDomain {
|
||||
pub pattern: HostPattern,
|
||||
pub shape: DomainShape,
|
||||
/// Collision key: `"exact:<host>"` for exact; `"wildcard:<suffix>"`
|
||||
/// for both wildcard AND parameterized — they share a shape per
|
||||
/// blueprint §11.5 ("`{tenant}` has the same shape as `*` for this
|
||||
/// check").
|
||||
pub shape_key: String,
|
||||
/// Captured binding name for parameterized claims, e.g., `Some("tenant")`
|
||||
/// for `{tenant}.example.com`. Currently informational; the binding
|
||||
/// is surfaced into request context in a future iteration.
|
||||
pub binding: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse a user-supplied app domain claim. Accepts:
|
||||
/// * `app.example.com` — exact host
|
||||
/// * `*.example.com` — wildcard suffix
|
||||
/// * `{tenant}.example.com` — parameterized; same shape as wildcard
|
||||
///
|
||||
/// Distinct from `parse_host` (which is for route host fields): the
|
||||
/// route parser still rejects `{...}` syntax — see
|
||||
/// `ParseError::ReservedHostBraceSyntax`.
|
||||
pub fn parse_app_domain(raw: &str) -> Result<ParsedAppDomain, ParseError> {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ParseError::EmptyHost);
|
||||
}
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
|
||||
// Wildcard: starts with "*."
|
||||
if let Some(suffix) = lowered.strip_prefix("*.") {
|
||||
if suffix.is_empty() {
|
||||
return Err(ParseError::EmptyWildcardSuffix);
|
||||
}
|
||||
return Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: None,
|
||||
},
|
||||
shape: DomainShape::Wildcard,
|
||||
shape_key: format!("wildcard:{suffix}"),
|
||||
binding: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Parameterized: starts with "{name}." where `name` is an ident.
|
||||
if let Some(stripped) = lowered.strip_prefix('{') {
|
||||
let (binding, rest) = stripped
|
||||
.split_once('}')
|
||||
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||
if binding.is_empty()
|
||||
|| !binding
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
|| !binding.chars().next().unwrap().is_ascii_alphabetic()
|
||||
{
|
||||
return Err(ParseError::InvalidParamName(binding.to_string()));
|
||||
}
|
||||
let suffix = rest
|
||||
.strip_prefix('.')
|
||||
.ok_or(ParseError::ReservedHostBraceSyntax)?;
|
||||
if suffix.is_empty() || suffix.contains('{') || suffix.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
return Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Wildcard {
|
||||
suffix: suffix.to_string(),
|
||||
capture: Some(binding.to_string()),
|
||||
},
|
||||
shape: DomainShape::Parameterized,
|
||||
// Same shape_key as the equivalent wildcard — parameter
|
||||
// name is a binding, not a discriminator.
|
||||
shape_key: format!("wildcard:{suffix}"),
|
||||
binding: Some(binding.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
// Anything else: exact host. Reject braces anywhere in the body
|
||||
// (they'd be a malformed parameterized form).
|
||||
if lowered.contains('{') || lowered.contains('}') {
|
||||
return Err(ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
Ok(ParsedAppDomain {
|
||||
pattern: HostPattern::Strict(lowered.clone()),
|
||||
shape: DomainShape::Exact,
|
||||
shape_key: format!("exact:{lowered}"),
|
||||
binding: None,
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -393,6 +493,49 @@ mod tests {
|
||||
assert_eq!(e, ParseError::ReservedHostBraceSyntax);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_exact() {
|
||||
let d = parse_app_domain("App.Example.COM").unwrap();
|
||||
assert_eq!(d.shape, DomainShape::Exact);
|
||||
assert_eq!(d.shape_key, "exact:app.example.com");
|
||||
assert_eq!(d.pattern, HostPattern::Strict("app.example.com".into()));
|
||||
assert!(d.binding.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_wildcard_and_parameterized_share_shape_key() {
|
||||
let w = parse_app_domain("*.example.com").unwrap();
|
||||
let p = parse_app_domain("{tenant}.example.com").unwrap();
|
||||
assert_eq!(w.shape, DomainShape::Wildcard);
|
||||
assert_eq!(p.shape, DomainShape::Parameterized);
|
||||
// Same shape_key — they collide at claim time (blueprint §11.5).
|
||||
assert_eq!(w.shape_key, "wildcard:example.com");
|
||||
assert_eq!(p.shape_key, "wildcard:example.com");
|
||||
assert_eq!(p.binding.as_deref(), Some("tenant"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_app_domain_rejects_garbage() {
|
||||
assert!(matches!(parse_app_domain(""), Err(ParseError::EmptyHost)));
|
||||
assert!(matches!(
|
||||
parse_app_domain("*."),
|
||||
Err(ParseError::EmptyWildcardSuffix)
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_app_domain("{}.example.com"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
parse_app_domain("{1tenant}.example.com"),
|
||||
Err(ParseError::InvalidParamName(_))
|
||||
));
|
||||
// Mid-host braces — disallowed.
|
||||
assert!(matches!(
|
||||
parse_app_domain("foo.{tenant}.example.com"),
|
||||
Err(ParseError::ReservedHostBraceSyntax)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_literal_count_works() {
|
||||
let exact = parse_path(PathKind::Exact, "/foo/users").unwrap();
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! In-memory snapshot of compiled routes, shared by manager (writes)
|
||||
//! and orchestrator (reads).
|
||||
//! In-memory snapshot of compiled routes, partitioned by `app_id`.
|
||||
//!
|
||||
//! 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.
|
||||
//! The orchestrator looks up the app's slice by id after `AppDomainTable`
|
||||
//! has resolved Host → app_id, then runs the existing matcher on that
|
||||
//! slice. The matcher is unchanged; this type is just a per-app bucket.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use picloud_shared::AppId;
|
||||
|
||||
use super::matcher::{r#match, CompiledRoute, MatchResult};
|
||||
|
||||
/// Per-app compiled-route tables. Single MVP-mode writer (the manager,
|
||||
/// via `replace_all`); contention against readers is minimal so a plain
|
||||
/// `RwLock` is fine.
|
||||
#[derive(Default)]
|
||||
pub struct RouteTable {
|
||||
inner: RwLock<Vec<CompiledRoute>>,
|
||||
inner: RwLock<HashMap<AppId, Vec<CompiledRoute>>>,
|
||||
}
|
||||
|
||||
impl RouteTable {
|
||||
@@ -20,24 +25,54 @@ impl RouteTable {
|
||||
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>) {
|
||||
/// Replace every per-app slice atomically. The manager calls this
|
||||
/// after each successful route CRUD operation; in cluster mode the
|
||||
/// orchestrator's HTTP-fed receiver will too.
|
||||
pub fn replace_all(&self, routes: Vec<CompiledRoute>) {
|
||||
let mut by_app: HashMap<AppId, Vec<CompiledRoute>> = HashMap::new();
|
||||
for r in routes {
|
||||
by_app.entry(r.app_id).or_default().push(r);
|
||||
}
|
||||
let mut guard = self.inner.write().expect("route table poisoned");
|
||||
*guard = routes;
|
||||
*guard = by_app;
|
||||
}
|
||||
|
||||
/// Dispatch a request to a matching route, or `None`.
|
||||
/// Dispatch a request to a matching route within `app_id`, or
|
||||
/// `None`. Returns `None` when the app has no routes at all.
|
||||
#[must_use]
|
||||
pub fn match_request(&self, host: &str, method: &str, path: &str) -> Option<MatchResult> {
|
||||
pub fn match_request_for_app(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host: &str,
|
||||
method: &str,
|
||||
path: &str,
|
||||
) -> Option<MatchResult> {
|
||||
let guard = self.inner.read().expect("route table poisoned");
|
||||
r#match(guard.iter(), host, method, path)
|
||||
let slice = guard.get(&app_id)?;
|
||||
r#match(slice.iter(), host, method, path)
|
||||
}
|
||||
|
||||
/// Returns a clone of the currently compiled routes; intended for
|
||||
/// the dashboard's "list routes" admin endpoint.
|
||||
/// Returns a clone of the currently compiled routes for `app_id`;
|
||||
/// intended for admin endpoints like "list this app's routes".
|
||||
#[must_use]
|
||||
pub fn snapshot(&self) -> Vec<CompiledRoute> {
|
||||
self.inner.read().expect("route table poisoned").clone()
|
||||
pub fn snapshot_for_app(&self, app_id: AppId) -> Vec<CompiledRoute> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("route table poisoned")
|
||||
.get(&app_id)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// All compiled routes across all apps. Used by tests and the
|
||||
/// global admin "every route on this install" view.
|
||||
#[must_use]
|
||||
pub fn snapshot_all(&self) -> Vec<CompiledRoute> {
|
||||
self.inner
|
||||
.read()
|
||||
.expect("route table poisoned")
|
||||
.values()
|
||||
.flat_map(|v| v.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user