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:
510
crates/manager-core/src/apps_api.rs
Normal file
510
crates/manager-core/src/apps_api.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
|
||||
//!
|
||||
//! All endpoints are guarded by `require_admin`. Per-app permissions
|
||||
//! are deferred (every authenticated admin can act on every app); the
|
||||
//! middleware seam exists for when that lands.
|
||||
//!
|
||||
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||
//! list rejected. Slug renames record the old slug in
|
||||
//! `app_slug_history` for permanent 301 redirects; reclaiming a
|
||||
//! historical slug requires `"force_takeover": true` in the request.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::Router;
|
||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||
use picloud_shared::{App, AppDomain, AppId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
const SLUG_MIN: usize = 1;
|
||||
const SLUG_MAX: usize = 63;
|
||||
const RESERVED_SLUGS: &[&str] = &[
|
||||
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppsState {
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub routes: Arc<dyn RouteRepository>,
|
||||
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||
/// operation so the orchestrator sees changes immediately.
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
}
|
||||
|
||||
pub fn apps_router(state: AppsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps", get(list_apps).post(create_app))
|
||||
.route(
|
||||
"/apps/{id_or_slug}",
|
||||
get(get_app).patch(patch_app).delete(delete_app),
|
||||
)
|
||||
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
|
||||
.route(
|
||||
"/apps/{id_or_slug}/domains",
|
||||
get(list_domains).post(create_domain),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id_or_slug}/domains/{domain_id}",
|
||||
delete(delete_domain),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppDto {
|
||||
#[serde(flatten)]
|
||||
pub app: App,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAppRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||
/// the requested slug (breaking old redirects).
|
||||
#[serde(default)]
|
||||
pub force_takeover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchAppRequest {
|
||||
pub name: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
||||
#[allow(clippy::option_option)]
|
||||
pub description: Option<Option<String>>,
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub force_takeover: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::option_option)]
|
||||
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SlugCheckRequest {
|
||||
pub new_slug: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SlugCheckResponse {
|
||||
pub ok: bool,
|
||||
pub conflict_kind: Option<&'static str>,
|
||||
pub current_app: Option<App>,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDomainRequest {
|
||||
pub pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppLookupResponse {
|
||||
#[serde(flatten)]
|
||||
pub app: App,
|
||||
/// When the operator hits the API with a retired slug, this points
|
||||
/// at the live slug so dashboards can redirect.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redirect_to: Option<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||
Ok(Json(s.apps.list().await?))
|
||||
}
|
||||
|
||||
async fn create_app(
|
||||
State(s): State<AppsState>,
|
||||
Json(input): Json<CreateAppRequest>,
|
||||
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
// Historical-slug check before insert: if the slug is in history
|
||||
// and the caller hasn't asked to force takeover, surface a clean
|
||||
// 409 so the dashboard can present a "this will break old links"
|
||||
// confirmation.
|
||||
if !input.force_takeover {
|
||||
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
}
|
||||
}
|
||||
|
||||
let created = if input.force_takeover {
|
||||
s.apps
|
||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||
.await?
|
||||
} else {
|
||||
s.apps
|
||||
.create(&input.slug, &input.name, input.description.as_deref())
|
||||
.await?
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn get_app(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
let redirect_to = if lookup.redirected {
|
||||
Some(lookup.app.slug.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Json(AppLookupResponse {
|
||||
app: lookup.app,
|
||||
redirect_to,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn patch_app(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<PatchAppRequest>,
|
||||
) -> Result<Json<App>, AppsApiError> {
|
||||
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
|
||||
// Edits to name/description go first (separate from rename so we
|
||||
// don't conflate the two errors).
|
||||
let after_meta = if input.name.is_some() || input.description.is_some() {
|
||||
s.apps
|
||||
.update(
|
||||
current.id,
|
||||
input.name.as_deref(),
|
||||
input.description.as_ref().map(|d| d.as_deref()),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
current
|
||||
};
|
||||
|
||||
// Slug rename is a separate operation; the rename method does its
|
||||
// own history bookkeeping in a transaction.
|
||||
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
|
||||
validate_slug(new_slug)?;
|
||||
match s
|
||||
.apps
|
||||
.rename_slug(after_meta.id, new_slug, input.force_takeover)
|
||||
.await
|
||||
{
|
||||
Ok(app) => app,
|
||||
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
}
|
||||
return Err(AppsApiError::Conflict(msg));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
after_meta
|
||||
};
|
||||
|
||||
Ok(Json(after_rename))
|
||||
}
|
||||
|
||||
async fn delete_app(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<StatusCode, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
|
||||
// Soft pre-check for a clean error; the DB FK is the real guard
|
||||
// (ON DELETE RESTRICT on scripts.app_id).
|
||||
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
||||
if n_scripts > 0 {
|
||||
return Err(AppsApiError::HasScripts(n_scripts));
|
||||
}
|
||||
|
||||
s.apps.delete(app.id).await?;
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn slug_check(
|
||||
State(s): State<AppsState>,
|
||||
Path(_id_or_slug): Path<String>,
|
||||
Json(input): Json<SlugCheckRequest>,
|
||||
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||
match validate_slug(&input.new_slug) {
|
||||
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("invalid"),
|
||||
current_app: None,
|
||||
reason: Some(reason),
|
||||
}));
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
Ok(()) => {}
|
||||
}
|
||||
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("current"),
|
||||
current_app: Some(app),
|
||||
reason: Some("another app currently uses this slug".into()),
|
||||
}));
|
||||
}
|
||||
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("historical"),
|
||||
current_app: Some(app),
|
||||
reason: Some("slug is a retired redirect; using it will break old links".into()),
|
||||
}));
|
||||
}
|
||||
Ok(Json(SlugCheckResponse {
|
||||
ok: true,
|
||||
conflict_kind: None,
|
||||
current_app: None,
|
||||
reason: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_domains(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||
}
|
||||
|
||||
async fn create_domain(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<CreateDomainRequest>,
|
||||
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||
let created = s
|
||||
.domains
|
||||
.create(NewAppDomain {
|
||||
app_id: app.id,
|
||||
pattern: input.pattern,
|
||||
shape: parsed.shape,
|
||||
shape_key: parsed.shape_key,
|
||||
})
|
||||
.await?;
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_domain(
|
||||
State(s): State<AppsState>,
|
||||
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||
) -> Result<StatusCode, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||
};
|
||||
if domain.app_id != app.id {
|
||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||
}
|
||||
|
||||
// Guard: routes inside this app may reference this exact host
|
||||
// pattern. The host-kind on the route is `strict` or `wildcard`
|
||||
// (Any routes don't pin a specific host). We block deletion in
|
||||
// either case and let the operator clean up first.
|
||||
let strict = s
|
||||
.routes
|
||||
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
|
||||
.await?;
|
||||
let wild_suffix = domain
|
||||
.pattern
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let wild = if wild_suffix.is_empty() {
|
||||
0
|
||||
} else {
|
||||
s.routes
|
||||
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
|
||||
.await?
|
||||
};
|
||||
if strict + wild > 0 {
|
||||
return Err(AppsApiError::DomainHasRoutes(strict + wild));
|
||||
}
|
||||
|
||||
s.domains.delete(domain_id).await?;
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(
|
||||
apps: &dyn AppRepository,
|
||||
ident: &str,
|
||||
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
|
||||
return Ok(crate::app_repo::AppLookup {
|
||||
app,
|
||||
redirected: false,
|
||||
});
|
||||
}
|
||||
return Err(AppsApiError::AppNotFound(ident.to_string()));
|
||||
}
|
||||
apps.get_by_slug_or_history(ident)
|
||||
.await?
|
||||
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
||||
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
|
||||
return Err(AppsApiError::InvalidSlug(format!(
|
||||
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
|
||||
)));
|
||||
}
|
||||
if !slug
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphanumeric())
|
||||
{
|
||||
return Err(AppsApiError::InvalidSlug(
|
||||
"slug must start with [a-z0-9]".into(),
|
||||
));
|
||||
}
|
||||
for c in slug.chars() {
|
||||
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||
return Err(AppsApiError::InvalidSlug(
|
||||
"slug may only contain lowercase letters, digits, and '-'".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&slug) {
|
||||
return Err(AppsApiError::InvalidSlug(format!(
|
||||
"slug {slug:?} is reserved for system use"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||
/// Called after every domain CRUD operation.
|
||||
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||
let all = state.domains.list_all().await?;
|
||||
let compiled = all
|
||||
.into_iter()
|
||||
.filter_map(|d| {
|
||||
// Parse the stored pattern; skip on parse error rather than
|
||||
// poisoning the entire cache. The handlers reject bad input,
|
||||
// so this is purely defensive against a future migration
|
||||
// that loosens the constraints.
|
||||
pattern::parse_app_domain(&d.pattern)
|
||||
.ok()
|
||||
.map(|p| CompiledAppDomain {
|
||||
app_id: d.app_id,
|
||||
pattern: p.pattern,
|
||||
shape_key: p.shape_key,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
state.domain_table.replace(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppsApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("domain not found: {0}")]
|
||||
DomainNotFound(Uuid),
|
||||
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||
SlugInHistory(App),
|
||||
|
||||
#[error("app still contains {0} script(s); delete or move them first")]
|
||||
HasScripts(i64),
|
||||
|
||||
#[error("domain has {0} route(s) bound to it; delete the routes first")]
|
||||
DomainHasRoutes(i64),
|
||||
|
||||
#[error("invalid pattern: {0}")]
|
||||
Pattern(#[from] pattern::ParseError),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_)
|
||||
| Self::DomainNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::InvalidSlug(_) | Self::Pattern(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::SlugInHistory(current) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({
|
||||
"error": self.to_string(),
|
||||
"conflict_kind": "historical",
|
||||
"current_app": current,
|
||||
}),
|
||||
),
|
||||
Self::HasScripts(n) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({ "error": self.to_string(), "script_count": n }),
|
||||
),
|
||||
Self::DomainHasRoutes(n) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({ "error": self.to_string(), "route_count": n }),
|
||||
),
|
||||
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "apps api db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user