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>
267 lines
10 KiB
Rust
267 lines
10 KiB
Rust
//! Library half of the picloud all-in-one. `main.rs` is a thin wrapper
|
|
//! that opens the pool, runs migrations, calls `build_app`, and binds
|
|
//! the listener. Tests use the same `build_app` against an
|
|
//! ephemeral test database.
|
|
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
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, 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::{AppDomainTable, RouteTable};
|
|
use picloud_orchestrator_core::{
|
|
data_plane_router, user_routes_router, DataPlaneState, LocalExecutorClient,
|
|
};
|
|
use picloud_shared::{
|
|
ExecutionLogSink, ScriptValidator, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION,
|
|
};
|
|
use sqlx::postgres::PgPoolOptions;
|
|
use sqlx::PgPool;
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
/// Default session TTL when `PICLOUD_SESSION_TTL_HOURS` isn't set.
|
|
const DEFAULT_SESSION_TTL_HOURS: u64 = 24;
|
|
|
|
/// Bundles the auth-related dependencies that both `build_app` and the
|
|
/// startup bootstrap need. Built once in `main.rs` from the shared pool.
|
|
pub struct AuthDeps {
|
|
pub users: Arc<dyn AdminUserRepository>,
|
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
|
pub ttl: Duration,
|
|
}
|
|
|
|
impl AuthDeps {
|
|
/// Construct from a pool with the binary's standard defaults.
|
|
#[must_use]
|
|
pub fn from_pool(pool: PgPool) -> Self {
|
|
Self {
|
|
users: Arc::new(PostgresAdminUserRepository::new(pool.clone())),
|
|
sessions: Arc::new(PostgresAdminSessionRepository::new(pool)),
|
|
ttl: read_session_ttl(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_session_ttl() -> Duration {
|
|
let hours = std::env::var("PICLOUD_SESSION_TTL_HOURS")
|
|
.ok()
|
|
.and_then(|s| s.parse::<u64>().ok())
|
|
.filter(|h| *h > 0)
|
|
.unwrap_or(DEFAULT_SESSION_TTL_HOURS);
|
|
Duration::from_secs(hours * 3600)
|
|
}
|
|
|
|
/// Compose the manager + orchestrator routes on top of a shared
|
|
/// Postgres pool, returning an Axum router ready to be served.
|
|
///
|
|
/// All API routes live under `/api/v{API_VERSION}/...`. The dashboard
|
|
/// is mounted by Caddy at `/admin/*` (its base path). Anything else
|
|
/// falls through to the user-route table — user scripts can bind to
|
|
/// arbitrary paths (subject to the reserved-prefix list).
|
|
///
|
|
/// `auth` carries the admin user/session repositories and the
|
|
/// configured session TTL. The manager-side admin endpoints
|
|
/// (`/api/v1/admin/scripts/*`, `/api/v1/admin/routes/*`,
|
|
/// `/api/v1/admin/admins/*`, `/api/v1/admin/auth/me`) are guarded by
|
|
/// the `require_admin` middleware. The data plane
|
|
/// (`/api/v1/execute/{id}`, the user-route fallthrough, `/healthz`,
|
|
/// `/version`) stays open — it's the public ingress for user scripts.
|
|
pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result<Router> {
|
|
let engine = Arc::new(Engine::new(Limits::default()));
|
|
|
|
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.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_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(),
|
|
)));
|
|
let executor = Arc::new(LocalExecutorClient::new(engine.clone()));
|
|
|
|
let admin = AdminState {
|
|
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.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(),
|
|
sessions: auth.sessions.clone(),
|
|
ttl: auth.ttl,
|
|
};
|
|
let admins_state = AdminsState {
|
|
users: auth.users,
|
|
sessions: auth.sessions,
|
|
};
|
|
|
|
// /admin/auth/login + /logout are unguarded by design (login is how
|
|
// you get in). /admin/auth/me applies the middleware internally so
|
|
// the same Router::with_state machinery composes cleanly. Everything
|
|
// else under /admin gets the require_admin layer.
|
|
let guarded_admin = Router::new()
|
|
.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)
|
|
.merge(data_plane_router(data_plane.clone()));
|
|
|
|
Ok(Router::new()
|
|
.route("/healthz", get(healthz))
|
|
.route("/version", get(version))
|
|
.nest(&format!("/api/v{API_VERSION}"), api_v1)
|
|
.merge(user_routes_router(data_plane))
|
|
.layer(TraceLayer::new_for_http()))
|
|
}
|
|
|
|
/// Open a Postgres pool with the binary's standard timeout settings.
|
|
/// Exposed so tests reach for the same configuration when needed.
|
|
pub async fn init_db(url: &str) -> anyhow::Result<PgPool> {
|
|
let pool = PgPoolOptions::new()
|
|
.max_connections(10)
|
|
.acquire_timeout(Duration::from_secs(5))
|
|
.connect(url)
|
|
.await?;
|
|
Ok(pool)
|
|
}
|
|
|
|
async fn healthz() -> &'static str {
|
|
"ok"
|
|
}
|
|
|
|
/// Snapshot of every compatibility-surface version this process speaks
|
|
/// plus the operator-configured public base URL (so the dashboard can
|
|
/// render full URLs for user routes).
|
|
///
|
|
/// Source of truth: `shared::version`, the embedded migrations, and
|
|
/// the `PICLOUD_PUBLIC_BASE_URL` env var (default
|
|
/// `http://localhost:8000`).
|
|
async fn version() -> Json<serde_json::Value> {
|
|
let public_base_url = std::env::var("PICLOUD_PUBLIC_BASE_URL")
|
|
.unwrap_or_else(|_| "http://localhost:8000".to_string());
|
|
Json(serde_json::json!({
|
|
"product": PRODUCT_VERSION,
|
|
"sdk": SDK_VERSION,
|
|
"api": API_VERSION,
|
|
"schema": migrations::latest_version(),
|
|
"wire": WIRE_VERSION,
|
|
"public_base_url": public_base_url,
|
|
}))
|
|
}
|
|
|
|
// ----------------------------------------------------------------------------
|
|
// Bridge: a single `PostgresScriptRepository` Arc is shared between the
|
|
// admin router (writes) and the resolver (reads). The resolver wants
|
|
// owned `impl ScriptRepository`, so we wrap the Arc in a delegating
|
|
// handle here rather than instantiating two repos against the same pool.
|
|
// ----------------------------------------------------------------------------
|
|
|
|
struct PostgresScriptRepoHandle(Arc<PostgresScriptRepository>);
|
|
|
|
#[async_trait::async_trait]
|
|
impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle {
|
|
async fn get(
|
|
&self,
|
|
id: picloud_shared::ScriptId,
|
|
) -> Result<Option<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
|
self.0.get(id).await
|
|
}
|
|
async fn list(
|
|
&self,
|
|
) -> 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,
|
|
) -> Result<picloud_shared::Script, picloud_manager_core::ScriptRepositoryError> {
|
|
self.0.create(input).await
|
|
}
|
|
async fn update(
|
|
&self,
|
|
id: picloud_shared::ScriptId,
|
|
patch: picloud_manager_core::ScriptPatch,
|
|
) -> Result<picloud_shared::Script, picloud_manager_core::ScriptRepositoryError> {
|
|
self.0.update(id, patch).await
|
|
}
|
|
async fn delete(
|
|
&self,
|
|
id: picloud_shared::ScriptId,
|
|
) -> Result<(), picloud_manager_core::ScriptRepositoryError> {
|
|
self.0.delete(id).await
|
|
}
|
|
}
|