//! 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, pub sessions: Arc, 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::().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 { 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 = Arc::new(PostgresExecutionLogSink::new(pool.clone())); let route_repo = Arc::new(PostgresRouteRepository::new(pool.clone())); let apps_repo: Arc = Arc::new(PostgresAppRepository::new(pool.clone())); let domains_repo: Arc = 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, 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 { 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 { 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); #[async_trait::async_trait] impl picloud_manager_core::ScriptRepository for PostgresScriptRepoHandle { async fn get( &self, id: picloud_shared::ScriptId, ) -> Result, picloud_manager_core::ScriptRepositoryError> { self.0.get(id).await } async fn list( &self, ) -> Result, picloud_manager_core::ScriptRepositoryError> { self.0.list().await } async fn list_for_app( &self, app_id: picloud_shared::AppId, ) -> Result, picloud_manager_core::ScriptRepositoryError> { self.0.list_for_app(app_id).await } async fn create( &self, input: picloud_manager_core::NewScript, ) -> Result { self.0.create(input).await } async fn update( &self, id: picloud_shared::ScriptId, patch: picloud_manager_core::ScriptPatch, ) -> Result { self.0.update(id, patch).await } async fn delete( &self, id: picloud_shared::ScriptId, ) -> Result<(), picloud_manager_core::ScriptRepositoryError> { self.0.delete(id).await } }