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:
MechaCat02
2026-05-25 21:03:05 +02:00
parent 6891496589
commit 4c41374db4
38 changed files with 3848 additions and 441 deletions

View File

@@ -5,17 +5,18 @@
use std::sync::Arc;
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Json, Router,
};
use picloud_shared::{
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
};
use serde::Deserialize;
use crate::app_repo::AppRepository;
use crate::repo::{
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
};
@@ -27,6 +28,9 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
pub struct AdminState<R, L> {
pub repo: Arc<R>,
pub logs: Arc<L>,
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
/// filter on list. Trait-object so apps_repo can stay separate.
pub apps: Arc<dyn AppRepository>,
pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling,
}
@@ -36,6 +40,7 @@ impl<R, L> Clone for AdminState<R, L> {
Self {
repo: self.repo.clone(),
logs: self.logs.clone(),
apps: self.apps.clone(),
validator: self.validator.clone(),
sandbox_ceiling: self.sandbox_ceiling,
}
@@ -70,6 +75,9 @@ where
#[derive(Debug, Deserialize)]
pub struct CreateScriptRequest {
/// Owning app. Required since Phase 3b — scripts cannot exist
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
pub app_id: AppId,
pub name: String,
pub description: Option<String>,
pub source: String,
@@ -82,6 +90,14 @@ pub struct CreateScriptRequest {
pub sandbox: ScriptSandbox,
}
#[derive(Debug, Deserialize)]
pub struct ListScriptsQuery {
/// Optional filter: list scripts belonging to a single app, by id
/// or slug. Absent = all scripts across all apps (admin-global view).
#[serde(default)]
pub app: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateScriptRequest {
pub name: Option<String>,
@@ -113,8 +129,32 @@ where
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Query(q): Query<ListScriptsQuery>,
) -> Result<Json<Vec<Script>>, ApiError> {
Ok(Json(state.repo.list().await?))
if let Some(ident) = q.app {
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
Ok(Json(state.repo.list_for_app(app).await?))
} else {
Ok(Json(state.repo.list().await?))
}
}
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
/// for redirects, but here we just need the live current id; if a
/// retired slug is given, we follow it to the current app silently.
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
let id = AppId::from(uuid);
apps.get_by_id(id)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
return Ok(id);
}
let lookup = apps
.get_by_slug_or_history(ident)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
Ok(lookup.app.id)
}
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
@@ -135,9 +175,15 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
) -> Result<(StatusCode, Json<Script>), ApiError> {
state.validator.validate(&input.source)?;
state.sandbox_ceiling.check(&input.sandbox)?;
// Refuse early if the app_id doesn't exist — a clean 422 beats a
// raw FK violation surfacing as 500.
if state.apps.get_by_id(input.app_id).await?.is_none() {
return Err(ApiError::AppNotFound(input.app_id.to_string()));
}
let created = state
.repo
.create(NewScript {
app_id: input.app_id,
name: input.name,
description: input.description,
source: input.source,
@@ -223,6 +269,9 @@ pub enum ApiError {
#[error("script not found: {0}")]
NotFound(ScriptId),
#[error("app not found: {0}")]
AppNotFound(String),
#[error("conflict: {0}")]
Conflict(String),
@@ -240,6 +289,7 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())