Files
PiCloud/crates/manager-core/src/api.rs
MechaCat02 84833d3e4e feat(v1.1.3-modules): shared types, migrations, engine + resolver scaffold
Lays down the v1.1.3 plumbing:

- `ScriptKind` enum in `picloud-shared` ('endpoint' | 'module').
- `ModuleSource` trait + `ModuleScript` DTO + `NoopModuleSource` in
  `picloud-shared`. Resolver lives in `executor-core`; Postgres impl
  in `manager-core` (`PostgresModuleSource`).
- `Services::new` grows a fifth `modules: Arc<dyn ModuleSource>` arg.
- `ScriptValidator` returns `ValidatedScript { imports }` so the
  manager can populate the dep-graph table on save. New
  `validate_module` method on the trait gates module-shape rules.
- `Engine::execute_ast(&Arc<rhai::AST>, req)` lets the orchestrator's
  script cache reuse compiled ASTs. `Engine::execute(&str, req)` is
  preserved as a convenience that compiles inline. `Engine::compile`
  exposes the AST for callers that want to cache.
- `PicloudModuleResolver` replaces `DummyModuleResolver` per-call.
  Bridges Rhai's sync `ModuleResolver::resolve` to async
  `ModuleSource::lookup` via `Handle::block_on`. Enforces:
  - cross-app isolation (resolver captures `Arc<SdkCallCx>`),
  - circular import detection (in-progress stack on the resolver),
  - import depth limit (default 8 via
    `Limits::module_import_depth_max`).
- Module-shape validation walks `ast.statements()` via `rhai/internals`
  and accepts only `Var { CONSTANT }`, `Import`, and `Noop`. The
  manager admin endpoint runs `validate_module` at save (primary
  gate); resolver re-runs it at load (defense in depth).
- LRU cache `(AppId, name) -> (updated_at, Arc<Module>)` owned by
  `Engine`. Size from `PICLOUD_MODULE_CACHE_SIZE` (default 512).
- Migration `0015_scripts_kind.sql` adds `scripts.kind` + composite
  index + module-name shape CHECK.
- Migration `0016_script_imports.sql` adds the dep-graph table with
  FK CASCADE on both columns.
- Repo: `kind` threaded through SELECT/INSERT/UPDATE. New
  `count_routes_for_script` / `count_triggers_for_script` /
  `list_imports` methods. `create`/`update` open a transaction and
  call `replace_imports_tx` to populate the dep-graph.
- Admin endpoint: accepts `kind`; rejects reserved module names;
  rejects `endpoint → module` transitions when routes / triggers
  exist.
- SDK_VERSION 1.3 → 1.4.

Workspace builds; full test suite (~440 tests) green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 22:04:21 +02:00

481 lines
17 KiB
Rust

//! Control-plane HTTP surface. Mounted by the `picloud` all-in-one
//! binary under `/api/admin` and by the future split `picloud-manager`
//! binary at its own root.
use std::sync::Arc;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
Extension, Json, Router,
};
use picloud_shared::{
AppId, ExecutionLog, InstanceRole, Principal, Script, ScriptId, ScriptKind, ScriptSandbox,
ScriptValidator, ValidatedScript, ValidationError,
};
use serde::Deserialize;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzRepo, Capability};
use crate::repo::{
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
};
use crate::sandbox::{CeilingError, SandboxCeiling};
/// State shared by control-plane handlers. Separates concerns so the
/// manager can validate at upload time without depending on the
/// concrete executor-core types.
pub struct AdminState<R, L> {
pub repo: Arc<R>,
pub logs: Arc<L>,
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
/// filter on list. Trait-object so apps_repo can stay separate.
pub apps: Arc<dyn AppRepository>,
/// Phase 3.5 capability checks — every script handler resolves
/// `AppRead/Write/LogRead(script.app_id)` against this repo after
/// loading the resource.
pub authz: Arc<dyn AuthzRepo>,
pub validator: Arc<dyn ScriptValidator>,
pub sandbox_ceiling: SandboxCeiling,
}
impl<R, L> Clone for AdminState<R, L> {
fn clone(&self) -> Self {
Self {
repo: self.repo.clone(),
logs: self.logs.clone(),
apps: self.apps.clone(),
authz: self.authz.clone(),
validator: self.validator.clone(),
sandbox_ceiling: self.sandbox_ceiling,
}
}
}
/// Build the admin router. The caller (binary) chooses where to mount
/// it (typically `Router::new().nest("/api/admin", admin_router(state))`).
pub fn admin_router<R, L>(state: AdminState<R, L>) -> Router
where
R: ScriptRepository + 'static,
L: ExecutionLogRepository + 'static,
{
Router::new()
.route(
"/scripts",
get(list_scripts::<R, L>).post(create_script::<R, L>),
)
.route(
"/scripts/{id}",
get(get_script::<R, L>)
.put(update_script::<R, L>)
.delete(delete_script::<R, L>),
)
.route("/scripts/{id}/logs", get(list_logs::<R, L>))
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct CreateScriptRequest {
/// Owning app. Required since Phase 3b — scripts cannot exist
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
pub app_id: AppId,
pub name: String,
pub description: Option<String>,
pub source: String,
/// v1.1.3: `endpoint` (default — handles HTTP routes / trigger
/// targets) or `module` (library of fn/const imported by other
/// scripts). Modules reject route binding and trigger creation.
#[serde(default)]
pub kind: ScriptKind,
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
/// Sandbox overrides; absent or empty `{}` means "use platform
/// defaults". Each non-null field is checked against the admin
/// ceiling at write time.
#[serde(default)]
pub sandbox: ScriptSandbox,
}
#[derive(Debug, Deserialize)]
pub struct ListScriptsQuery {
/// Optional filter: list scripts belonging to a single app, by id
/// or slug. Absent = all scripts across all apps (admin-global view).
#[serde(default)]
pub app: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct UpdateScriptRequest {
pub name: Option<String>,
// Double Option lets clients explicitly clear the description by
// sending `"description": null`; an absent field leaves it alone.
#[serde(default, deserialize_with = "deserialize_optional_optional")]
#[allow(clippy::option_option)]
pub description: Option<Option<String>>,
pub source: Option<String>,
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
/// `Some(sandbox)` replaces the stored overrides wholesale (use
/// `Some(ScriptSandbox::empty())` to clear them). Absent leaves
/// the stored value unchanged.
pub sandbox: Option<ScriptSandbox>,
/// v1.1.3: `Some(kind)` changes the script's role. Transitions to
/// `Module` are rejected if any routes or triggers still reference
/// the script. `module → endpoint` is always allowed.
pub kind: Option<ScriptKind>,
}
#[allow(clippy::option_option)]
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(d).map(Some)
}
// ----------------------------------------------------------------------------
// Handlers
// ----------------------------------------------------------------------------
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Query(q): Query<ListScriptsQuery>,
) -> Result<Json<Vec<Script>>, ApiError> {
// Membership filter: `member` users see only scripts in apps they
// belong to. `?app=` filters further by app and additionally
// requires the member to belong to that app (the read check uses
// the resource's app_id).
if let Some(ident) = q.app {
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
require(state.authz.as_ref(), &principal, Capability::AppRead(app)).await?;
return Ok(Json(state.repo.list_for_app(app).await?));
}
if principal.instance_role == InstanceRole::Member {
return Ok(Json(state.repo.list_for_user(principal.user_id).await?));
}
Ok(Json(state.repo.list().await?))
}
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
/// for redirects, but here we just need the live current id; if a
/// retired slug is given, we follow it to the current app silently.
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
let id = AppId::from(uuid);
apps.get_by_id(id)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
return Ok(id);
}
let lookup = apps
.get_by_slug_or_history(ident)
.await?
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
Ok(lookup.app.id)
}
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>,
) -> Result<Json<Script>, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppRead(script.app_id),
)
.await?;
Ok(Json(script))
}
async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Json(input): Json<CreateScriptRequest>,
) -> Result<(StatusCode, Json<Script>), ApiError> {
// Capability is bound to the *requested* app_id since there's no
// resource to load yet. If the app doesn't exist we 422 below;
// checking authz first means a Member trying to create against an
// unknown app gets 403 (no enumeration of app existence).
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(input.app_id),
)
.await?;
// v1.1.3: dispatch to the right validator based on declared kind.
// Module bodies have stricter rules (no top-level statements) so
// they need a separate gate; endpoints retain the parse-only path.
let validated: ValidatedScript = if input.kind == ScriptKind::Module {
if RESERVED_MODULE_NAMES.contains(&input.name.as_str()) {
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
input.name
))));
}
state.validator.validate_module(&input.source)?
} else {
state.validator.validate(&input.source)?
};
state.sandbox_ceiling.check(&input.sandbox)?;
// Refuse early if the app_id doesn't exist — a clean 422 beats a
// raw FK violation surfacing as 500.
if state.apps.get_by_id(input.app_id).await?.is_none() {
return Err(ApiError::AppNotFound(input.app_id.to_string()));
}
let created = state
.repo
.create(NewScript {
app_id: input.app_id,
name: input.name,
description: input.description,
source: input.source,
kind: input.kind,
timeout_seconds: input.timeout_seconds,
memory_limit_mb: input.memory_limit_mb,
sandbox: if input.sandbox.is_empty() {
None
} else {
Some(input.sandbox)
},
imports: validated.imports,
})
.await?;
Ok((StatusCode::CREATED, Json(created)))
}
/// Module names that would shadow a built-in stdlib / service namespace.
/// Rejected at create time so `import "kv" as foo` can never resolve to
/// a user-supplied module instead of (in a hypothetical future) the
/// real KV bridge — defense against author confusion, not a security
/// boundary (stdlib namespaces and module imports already live in
/// disjoint Rhai scopes).
const RESERVED_MODULE_NAMES: &[&str] = &[
"log",
"regex",
"random",
"time",
"json",
"base64",
"hex",
"url",
"kv",
"docs",
"dead_letters",
"http",
"files",
"pubsub",
"secrets",
"email",
"users",
"queue",
];
async fn update_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>,
Json(input): Json<UpdateScriptRequest>,
) -> Result<Json<Script>, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script.app_id),
)
.await?;
// Effective post-update kind: explicit override > existing kind.
let effective_kind = input.kind.unwrap_or(script.kind);
// v1.1.3: reject `endpoint → module` if the script still has
// routes or triggers bound to it. The reverse direction is always
// allowed (a module can't have routes/triggers anyway, so the
// transition can never strand users).
if effective_kind == ScriptKind::Module && script.kind != ScriptKind::Module {
let routes = state.repo.count_routes_for_script(id).await?;
let triggers = state.repo.count_triggers_for_script(id).await?;
if routes + triggers > 0 {
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
"cannot change kind to module: script is referenced by {routes} route(s) and {triggers} trigger(s); detach them first"
))));
}
if RESERVED_MODULE_NAMES.contains(&script.name.as_str()) {
return Err(ApiError::Invalid(ValidationError::ModuleShape(format!(
"{:?} is a reserved module name (shadows a built-in SDK namespace)",
script.name
))));
}
}
// v1.1.3: re-validate using the effective kind so endpoint → module
// transitions with a fresh source enforce the module shape rules.
// Source-less edits (name/description only) don't re-validate.
let imports_for_patch: Option<Vec<String>> = if let Some(src) = input.source.as_deref() {
let validated = if effective_kind == ScriptKind::Module {
state.validator.validate_module(src)?
} else {
state.validator.validate(src)?
};
Some(validated.imports)
} else {
None
};
if let Some(sb) = input.sandbox.as_ref() {
state.sandbox_ceiling.check(sb)?;
}
let updated = state
.repo
.update(
id,
ScriptPatch {
name: input.name,
description: input.description,
source: input.source,
timeout_seconds: input.timeout_seconds,
memory_limit_mb: input.memory_limit_mb,
sandbox: input.sandbox,
kind: input.kind,
imports: imports_for_patch,
},
)
.await?;
Ok(Json(updated))
}
async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
require(
state.authz.as_ref(),
&principal,
Capability::AppAdmin(script.app_id),
)
.await?;
state.repo.delete(id).await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
pub struct LogsQuery {
#[serde(default = "default_limit")]
pub limit: i64,
#[serde(default)]
pub offset: i64,
}
const fn default_limit() -> i64 {
50
}
async fn list_logs<R: ScriptRepository, L: ExecutionLogRepository>(
State(state): State<AdminState<R, L>>,
Extension(principal): Extension<Principal>,
Path(id): Path<ScriptId>,
axum::extract::Query(q): axum::extract::Query<LogsQuery>,
) -> Result<Json<Vec<ExecutionLog>>, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
require(
state.authz.as_ref(),
&principal,
Capability::AppLogRead(script.app_id),
)
.await?;
// Cap to keep the dashboard responsive; the data plane writes are
// unbounded over time so a paged read is the only sane default.
let limit = q.limit.clamp(1, 200);
let offset = q.offset.max(0);
let logs = state.logs.list_for_script(id, limit, offset).await?;
Ok(Json(logs))
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
#[error("script not found: {0}")]
NotFound(ScriptId),
#[error("app not found: {0}")]
AppNotFound(String),
#[error("conflict: {0}")]
Conflict(String),
#[error("invalid script: {0}")]
Invalid(#[from] ValidationError),
#[error("{0}")]
Ceiling(#[from] CeilingError),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("repository error: {0}")]
Repo(#[from] ScriptRepositoryError),
}
impl From<AuthzDenied> for ApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, message) = match &self {
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
Self::Invalid(_) | Self::Ceiling(_) => {
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
}
Self::Forbidden => (StatusCode::FORBIDDEN, self.to_string()),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
Self::Repo(ScriptRepositoryError::NotFound(_)) => {
(StatusCode::NOT_FOUND, self.to_string())
}
Self::Repo(ScriptRepositoryError::Conflict(_)) => {
(StatusCode::CONFLICT, self.to_string())
}
Self::Repo(ScriptRepositoryError::Db(e)) => {
tracing::error!(error = %e, "manager db error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal error".to_string(),
)
}
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}