//! 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, api_keys_router, app_members_router, apps_api, apps_router, attach_principal_if_present, auth_router, compile_routes, migrations, require_authenticated, route_admin_router, AdminSessionRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, AppsState, AuthState, AuthzRepo, KvServiceImpl, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresKvRepo, PostgresRouteRepository, PostgresScriptRepository, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, }; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::{ data_plane_router, user_routes_router, DataPlaneState, ExecutionGate, LocalExecutorClient, }; use picloud_shared::{ ExecutionLogSink, KvService, NoopDeadLetterService, NoopEventEmitter, ScriptValidator, ServiceEventEmitter, Services, 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 keys: 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.clone())), keys: Arc::new(PostgresApiKeyRepository::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. #[allow(clippy::too_many_lines)] pub async fn build_app(pool: PgPool, auth: AuthDeps) -> anyhow::Result { 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.clone())); // The Postgres app_members repo implements both `AppMembersRepository` // (CRUD over the table) and `AuthzRepo` (single-row membership lookup // for capability checks). Construct it once and clone the Arc into // both trait views — same allocation, two vtables. let members_concrete = Arc::new(PostgresAppMembersRepository::new(pool.clone())); let members: Arc = members_concrete.clone(); let authz: Arc = members_concrete; // SDK services bundle. v1.1.1 ships the KV store; the outbox-backed // event emitter replaces `NoopEventEmitter` once the triggers // dispatcher lands. `NoopDeadLetterService` is a v1.1.1 stub that // errors loudly until the real `PostgresDeadLetterService` ships. let kv_repo = Arc::new(PostgresKvRepo::new(pool.clone())); let events: Arc = Arc::new(NoopEventEmitter); let kv: Arc = Arc::new(KvServiceImpl::new(kv_repo, authz.clone(), events.clone())); let services = Services::new(kv, Arc::new(NoopDeadLetterService), events); let engine = Arc::new(Engine::new(Limits::default(), services)); // 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(), ))); // Single global gate — overflow is rejected with 503 + Retry-After. // See `ExecutionGate` docs and `PICLOUD_MAX_CONCURRENT_EXECUTIONS`. let gate = Arc::new(ExecutionGate::from_env()); let executor = Arc::new(LocalExecutorClient::new(engine.clone(), gate)); let admin = AdminState { repo: Arc::new(PostgresScriptRepoHandle(script_repo.clone())), logs: log_repo, apps: apps_repo.clone(), authz: authz.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(), authz: authz.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, authz: authz.clone(), }; let auth_state = AuthState { users: auth.users.clone(), sessions: auth.sessions.clone(), keys: auth.keys.clone(), ttl: auth.ttl, }; let admins_state = AdminsState { users: auth.users.clone(), sessions: auth.sessions, keys: auth.keys.clone(), authz: authz.clone(), }; let app_members_state = AppMembersState { apps: apps_state.apps.clone(), users: auth.users, members, authz, }; let api_keys_state = ApiKeysState { keys: auth.keys }; // /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_authenticated layer; capability // checks live in each handler (after the resource is loaded so the // capability binds to the resource's actual app_id). 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)) .merge(app_members_router(app_members_state)) .merge(api_keys_router(api_keys_state)) .layer(from_fn_with_state( auth_state.clone(), require_authenticated, )); // 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; // Opportunistic principal extraction on every data-plane request. // Always inserts `Extension>`: Some for authed // ingress (bearer / cookie), None otherwise. Handlers depend on // this layer being applied — scoped to the data-plane routers so // the admin path (which uses `require_authenticated`) doesn't // double-resolve the same token. let data_plane_routed = data_plane_router(data_plane.clone()).layer(from_fn_with_state( auth_state.clone(), attach_principal_if_present, )); let user_routes = user_routes_router(data_plane).layer(from_fn_with_state( auth_state.clone(), attach_principal_if_present, )); let api_v1 = Router::new() .nest("/admin", auth_router(auth_state)) .nest("/admin", guarded_admin) .merge(data_plane_routed); Ok(Router::new() .route("/healthz", get(healthz)) .route("/version", get(version)) .nest(&format!("/api/v{API_VERSION}"), api_v1) .merge(user_routes) .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 list_for_user( &self, user_id: picloud_shared::AdminUserId, ) -> Result, picloud_manager_core::ScriptRepositoryError> { self.0.list_for_user(user_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 } }