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:
@@ -10,13 +10,15 @@ use axum::middleware::from_fn_with_state;
|
||||
use axum::{routing::get, Json, Router};
|
||||
use picloud_executor_core::{Engine, Limits};
|
||||
use picloud_manager_core::{
|
||||
admin_router, admins_router, auth_router, compile_routes, migrations, require_admin,
|
||||
route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState,
|
||||
AuthState, PostgresAdminSessionRepository, PostgresAdminUserRepository,
|
||||
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresRouteRepository,
|
||||
PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
|
||||
admin_router, admins_router, apps_api, apps_router, auth_router, compile_routes, migrations,
|
||||
require_admin, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository,
|
||||
AdminsState, AppDomainRepository, AppRepository, AppsState, AuthState,
|
||||
PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresAppDomainRepository,
|
||||
PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink,
|
||||
PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState,
|
||||
RouteRepository, SandboxCeiling,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::RouteTable;
|
||||
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
|
||||
use picloud_orchestrator_core::{
|
||||
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
||||
};
|
||||
@@ -80,14 +82,34 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
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.clone()));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool));
|
||||
let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||
let domains_repo: Arc<dyn AppDomainRepository> =
|
||||
Arc::new(PostgresAppDomainRepository::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);
|
||||
route_table.replace_all(compiled);
|
||||
|
||||
// Same shape for app domains (Host → app_id cache).
|
||||
let app_domain_table = Arc::new(AppDomainTable::new());
|
||||
let initial_domains = domains_repo.list_all().await?;
|
||||
let compiled_domains: Vec<_> = initial_domains
|
||||
.iter()
|
||||
.filter_map(|d| {
|
||||
picloud_orchestrator_core::routing::parse_app_domain(&d.pattern)
|
||||
.ok()
|
||||
.map(|p| picloud_orchestrator_core::routing::CompiledAppDomain {
|
||||
app_id: d.app_id,
|
||||
pattern: p.pattern,
|
||||
shape_key: p.shape_key,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
app_domain_table.replace(compiled_domains);
|
||||
|
||||
let resolver = Arc::new(RepoResolver::new(PostgresScriptRepoHandle(
|
||||
script_repo.clone(),
|
||||
@@ -95,21 +117,31 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
||||
|
||||
let admin = AdminState {
|
||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||
repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())),
|
||||
logs: log_repo,
|
||||
apps: apps_repo.clone(),
|
||||
validator: engine as Arc<dyn ScriptValidator>,
|
||||
sandbox_ceiling: SandboxCeiling::from_env(),
|
||||
};
|
||||
let route_admin = RouteAdminState {
|
||||
routes: route_repo,
|
||||
routes: route_repo.clone(),
|
||||
scripts: Arc::new(PostgresScriptRepoHandle(script_repo)),
|
||||
domains: domains_repo.clone(),
|
||||
table: route_table.clone(),
|
||||
};
|
||||
let data_plane = DataPlaneState {
|
||||
executor,
|
||||
resolver,
|
||||
log_sink,
|
||||
app_domains: app_domain_table.clone(),
|
||||
routes: route_table,
|
||||
};
|
||||
let apps_state = AppsState {
|
||||
apps: apps_repo,
|
||||
domains: domains_repo,
|
||||
routes: route_repo,
|
||||
domain_table: app_domain_table,
|
||||
};
|
||||
|
||||
let auth_state = AuthState {
|
||||
users: auth.users.clone(),
|
||||
@@ -129,8 +161,13 @@ pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
||||
.merge(admin_router(admin))
|
||||
.merge(route_admin_router(route_admin))
|
||||
.merge(admins_router(admins_state))
|
||||
.merge(apps_router(apps_state))
|
||||
.layer(from_fn_with_state(auth_state.clone(), require_admin));
|
||||
|
||||
// Silence "unused import" lint on `apps_api` — we re-export via the
|
||||
// facade above; the bare module path is retained so it's discoverable.
|
||||
let _ = apps_api::AppsState::clone;
|
||||
|
||||
let api_v1 = Router::new()
|
||||
.nest("/admin", auth_router(auth_state))
|
||||
.nest("/admin", guarded_admin)
|
||||
@@ -201,6 +238,12 @@ impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||
self.0.list().await
|
||||
}
|
||||
async fn list_for_app(
|
||||
&self,
|
||||
app_id: picloud_shared::AppId,
|
||||
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
||||
self.0.list_for_app(app_id).await
|
||||
}
|
||||
async fn create(
|
||||
&self,
|
||||
input: picloud_manager_core::NewScript,
|
||||
|
||||
@@ -11,7 +11,9 @@ use std::time::Duration;
|
||||
use picloud::{build_app, init_db, AuthDeps};
|
||||
use picloud_manager_core::{
|
||||
auth::{hash_password, validate_password_hash},
|
||||
bootstrap_first_admin, migrations, AdminSessionRepository, AdminUserRepository,
|
||||
bootstrap_first_admin, migrations, seed_hello_world_if_fresh, AdminSessionRepository,
|
||||
AdminUserRepository, HelloWorldOutcome, PostgresAppRepository, PostgresRouteRepository,
|
||||
PostgresScriptRepository,
|
||||
};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
@@ -44,6 +46,23 @@ async fn run_server() -> anyhow::Result<()> {
|
||||
let auth = AuthDeps::from_pool(pool.clone());
|
||||
bootstrap_first_admin(&*auth.users).await?;
|
||||
|
||||
// Seed Hello World into the default app when this is a fresh
|
||||
// install (no scripts and no routes). Idempotent on upgrades.
|
||||
let apps = Arc::new(PostgresAppRepository::new(pool.clone()));
|
||||
let scripts = Arc::new(PostgresScriptRepository::new(pool.clone()));
|
||||
let routes = Arc::new(PostgresRouteRepository::new(pool.clone()));
|
||||
match seed_hello_world_if_fresh(apps, scripts, routes).await {
|
||||
Ok(HelloWorldOutcome::Seeded) => {
|
||||
tracing::info!("hello-world seed inserted into the default app");
|
||||
}
|
||||
Ok(HelloWorldOutcome::SkippedExisting) => {
|
||||
tracing::debug!("hello-world seed skipped (default app already populated)");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(?err, "hello-world seed failed (continuing startup)");
|
||||
}
|
||||
}
|
||||
|
||||
// Background session-prune sweep. Cheap; keeps the table from
|
||||
// growing unbounded. Expired rows are also rejected at lookup time,
|
||||
// so a delayed sweep can't extend session lifetimes.
|
||||
|
||||
Reference in New Issue
Block a user