First v1.1.1 commit. Adds the KV store the design notes commit to: `(app_id, collection, key)` identity with JSONB value and a per-app index. Trait lives in `picloud-shared` so the executor-core Rhai bridge (next commit), the Postgres impl, and tests all depend on the same surface without coupling crates. The `Services` bundle grows from empty to three fields: `kv`, `dead_letters` (NoopDeadLetterService stub — replaced by the Postgres impl in commit 8), and `events` (NoopEventEmitter until the outbox emitter lands with the dispatcher). Tests use `Services::default()` for an all-noop bundle. New capabilities `AppKvRead` / `AppKvWrite` join the Capability enum. They map onto the existing seven-value `Scope` (script:read / script:write) — the scope vocabulary stays locked per the `docs/versioning.md` commitment. Script-as-gate semantics in `KvServiceImpl`: capability check runs when `cx.principal.is_some()`, skipped when None (public HTTP). Cross-app isolation is enforced independently by deriving every row's `app_id` from `cx.app_id` rather than a script-passed argument. In-memory `KvRepo` impl + unit tests cover the round-trips, the cross-app isolation property, empty-collection rejection, script-as-gate behaviour for both anonymous and authed contexts, and cursor-style pagination. Postgres impl exists; integration testing waits for a real DB harness (see HANDBACK). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
333 lines
14 KiB
Rust
333 lines
14 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, 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<dyn AdminUserRepository>,
|
|
pub sessions: Arc<dyn AdminSessionRepository>,
|
|
pub keys: Arc<dyn ApiKeyRepository>,
|
|
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::<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.
|
|
#[allow(clippy::too_many_lines)]
|
|
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.clone()));
|
|
let apps_repo: Arc<dyn AppRepository> = Arc::new(PostgresAppRepository::new(pool.clone()));
|
|
let domains_repo: Arc<dyn AppDomainRepository> =
|
|
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<dyn AppMembersRepository> = members_concrete.clone();
|
|
let authz: Arc<dyn AuthzRepo> = 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<dyn ServiceEventEmitter> = Arc::new(NoopEventEmitter);
|
|
let kv: Arc<dyn KvService> =
|
|
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<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(),
|
|
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<Option<Principal>>`: 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<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 list_for_user(
|
|
&self,
|
|
user_id: picloud_shared::AdminUserId,
|
|
) -> Result<Vec<picloud_shared::Script>, picloud_manager_core::ScriptRepositoryError> {
|
|
self.0.list_for_user(user_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
|
|
}
|
|
}
|