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:
@@ -13,39 +13,49 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::app_domain_repo::AppDomainRepository;
|
||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
pub struct RouteAdminState<RR> {
|
||||
pub struct RouteAdminState<RR, SR> {
|
||||
pub routes: Arc<RR>,
|
||||
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||
/// route inherits the script's app) and to scope conflict checks.
|
||||
pub scripts: Arc<SR>,
|
||||
/// Used to validate the route's host against the parent app's
|
||||
/// declared domain claims.
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub table: Arc<RouteTable>,
|
||||
}
|
||||
|
||||
impl<RR> Clone for RouteAdminState<RR> {
|
||||
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
routes: self.routes.clone(),
|
||||
scripts: self.scripts.clone(),
|
||||
domains: self.domains.clone(),
|
||||
table: self.table.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
||||
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
||||
where
|
||||
RR: RouteRepository + 'static,
|
||||
SR: ScriptRepository + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/scripts/{id}/routes",
|
||||
get(list_routes::<RR>).post(create_route::<RR>),
|
||||
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||
)
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
||||
.route("/routes:check", post(check_route::<RR>))
|
||||
.route("/routes:match", post(match_route::<RR>))
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||
.route("/routes:check", post(check_route::<RR, SR>))
|
||||
.route("/routes:match", post(match_route::<RR, SR>))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -67,6 +77,10 @@ pub struct CreateRouteRequest {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckRouteRequest {
|
||||
/// Required: which app's route table this hypothetical route would
|
||||
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||
/// errors would leak tenant info — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
@@ -84,6 +98,9 @@ pub struct CheckRouteResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchRouteRequest {
|
||||
/// Which app's route table to dispatch against. The dashboard's
|
||||
/// route-preview tester always knows the current app context.
|
||||
pub app_id: AppId,
|
||||
pub url: String,
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
@@ -111,15 +128,15 @@ pub struct MatchedRoute {
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_routes<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
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>>,
|
||||
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
Json(input): Json<CreateRouteRequest>,
|
||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||
@@ -130,8 +147,22 @@ async fn create_route<RR: RouteRepository>(
|
||||
input.host_param_name.as_deref(),
|
||||
)?;
|
||||
|
||||
// Within-kind conflict check against existing routes.
|
||||
let existing = state.routes.list_all().await?;
|
||||
// Look up the script's owning app — every route inherits it.
|
||||
let script = state
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
let app_id = script.app_id;
|
||||
|
||||
// Validate the route's host is consistent with one of the app's
|
||||
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||
// host the app already owns). Specific hosts must match a claim.
|
||||
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
|
||||
.await?;
|
||||
|
||||
// Within-app conflict check (cross-app is impossible by construction).
|
||||
let existing = state.routes.list_for_app(app_id).await?;
|
||||
if let Some((conflicting, reason)) = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -149,6 +180,7 @@ async fn create_route<RR: RouteRepository>(
|
||||
let created = state
|
||||
.routes
|
||||
.create(NewRoute {
|
||||
app_id,
|
||||
script_id,
|
||||
host_kind: input.host_kind,
|
||||
host: input.host,
|
||||
@@ -162,8 +194,8 @@ async fn create_route<RR: RouteRepository>(
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Path(route_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, RouteApiError> {
|
||||
state.routes.delete(route_id).await?;
|
||||
@@ -171,14 +203,14 @@ async fn delete_route<RR: RouteRepository>(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn check_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
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 existing = state.routes.list_for_app(input.app_id).await?;
|
||||
let conflict = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -201,8 +233,8 @@ async fn check_route<RR: RouteRepository>(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn match_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Json(input): Json<MatchRouteRequest>,
|
||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||
let parsed = url::Url::parse(&input.url)
|
||||
@@ -210,7 +242,9 @@ async fn match_route<RR: RouteRepository>(
|
||||
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);
|
||||
let result = state
|
||||
.table
|
||||
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||
Ok(Json(MatchRouteResponse {
|
||||
matched: result.map(|r| MatchedRoute {
|
||||
route_id: r.matched.route_id,
|
||||
@@ -263,12 +297,12 @@ fn first_conflict(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn refresh_table<RR: RouteRepository>(
|
||||
state: &RouteAdminState<RR>,
|
||||
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
state: &RouteAdminState<RR, SR>,
|
||||
) -> Result<(), RouteApiError> {
|
||||
let rows = state.routes.list_all().await?;
|
||||
let compiled = compile_routes(&rows)?;
|
||||
state.table.replace(compiled);
|
||||
state.table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -277,6 +311,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.map(|r| {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
app_id: r.app_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)?,
|
||||
@@ -286,6 +321,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||
/// always permitted — it catches every host the app already owns.
|
||||
async fn validate_route_host_against_app(
|
||||
domains: &dyn AppDomainRepository,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<(), RouteApiError> {
|
||||
if matches!(host_kind, HostKind::Any) {
|
||||
return Ok(());
|
||||
}
|
||||
let claims = domains.list_for_app(app_id).await?;
|
||||
if claims.is_empty() {
|
||||
return Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
for claim in &claims {
|
||||
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||
match (host_kind, claim.shape) {
|
||||
// Strict route under exact claim: must match exactly.
|
||||
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||
if host_lower == claim_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Strict route under wildcard/parameterized: must end with
|
||||
// ".<suffix>" where the claim's suffix is the part after
|
||||
// `*.` or `{...}.`.
|
||||
(
|
||||
HostKind::Strict,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let needle = format!(".{suffix}");
|
||||
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route: must match a wildcard or parameterized
|
||||
// claim with identical suffix.
|
||||
(
|
||||
HostKind::Wildcard,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let claim_suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
if claim_suffix == host_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route under exact claim: not allowed (would
|
||||
// shadow other apps' subdomains the operator didn't claim).
|
||||
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||
(HostKind::Any, _) => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -304,6 +412,15 @@ pub enum RouteApiError {
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("script not found: {0}")]
|
||||
ScriptNotFound(ScriptId),
|
||||
|
||||
#[error("host {host:?} is not claimed by this app")]
|
||||
HostNotClaimed {
|
||||
host: String,
|
||||
available_claims: Vec<String>,
|
||||
},
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
@@ -326,10 +443,21 @@ impl IntoResponse for RouteApiError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::HostNotClaimed {
|
||||
host,
|
||||
available_claims,
|
||||
} => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({
|
||||
"error": self.to_string(),
|
||||
"host": host,
|
||||
"available_claims": available_claims,
|
||||
}),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||
StatusCode::CONFLICT,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
|
||||
Reference in New Issue
Block a user