chore: initial scaffold — workspace, docs, blueprint

Sets up the PiCloud monorepo as a Cargo workspace organised around the
three-service architecture (manager / orchestrator / executor), each
backed by a *-core library crate so the same logic powers both the MVP
all-in-one `picloud` binary and the future split-process cluster mode.

  * crates/shared, executor-core, orchestrator-core, manager-core
    define the library surface and trait seams between the three
    services (`ExecutorClient`, `ScriptResolver`, `ScriptRepository`).
  * crates/picloud is the MVP entrypoint; serves /healthz on 8080
    (override via PICLOUD_BIND).
  * crates/picloud-{manager,orchestrator,executor} are skeleton
    binaries that keep the crate boundaries honest until cluster
    mode is built out in v1.3+.
  * docs/git-workflow.md defines the trunk-based workflow:
    short-lived branches, Conventional Commits, separate hotfix
    flow with mandatory reproduction tests.
  * CLAUDE.md captures the working rules for future Claude sessions.

Workspace passes `cargo fmt`, `cargo clippy -D warnings` (with
pedantic enabled), and `cargo test --workspace`. The all-in-one
binary responds on `/healthz` and `/`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-22 23:16:32 +02:00
commit b8b544816d
36 changed files with 5843 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Rust
/target
**/*.rs.bk
Cargo.lock.bak
# IDE
/.idea
/.vscode
*.swp
*.swo
.DS_Store
# Env / secrets
.env
.env.*
!.env.example
# Local config overrides
config.local.toml
/data
/postgres-data
# Dashboard
/dashboard/node_modules
/dashboard/.svelte-kit
/dashboard/build
/dashboard/.env
# Caddy
/caddy/data
/caddy/config
# Logs
*.log
/logs
# OS
Thumbs.db

100
CLAUDE.md Normal file
View File

@@ -0,0 +1,100 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
**PiCloud** is a self-hosted, event-driven serverless compute platform. Users upload Rhai scripts, get HTTP endpoints. Optimized for solo-dev / consumer hardware (single node MVP, multi-node cluster in v1.3+).
Authoritative design: [serverless_cloud_blueprint.md](serverless_cloud_blueprint.md). The blueprint is a living document — when architecture decisions are made in conversation that contradict it, treat the latest decision as truth and update the blueprint.
## Three-Service Architecture
The platform splits into three logical services, each backed by a `*-core` library crate so the same logic runs in single-process MVP mode and split-process cluster mode:
| Service | Role | Library crate |
|---------|------|---------------|
| **Manager** | Control plane: script CRUD, scheduling/cron, dashboard backend, config. Single-writer to Postgres. | `manager-core` |
| **Orchestrator** | Per-node ingress: receives HTTP (later SMTP, queue) events, resolves script, dispatches to local executor. Stateless. | `orchestrator-core` |
| **Executor** | Per-node compute: runs Rhai scripts in a sandboxed engine. Stateless. | `executor-core` |
In MVP, all three run in one process (`picloud` binary). In cluster mode, each runs as its own binary on each node, with one manager total and one orchestrator + executor per node.
**Key boundary:** the orchestrator never imports `executor-core` directly — it depends on an `ExecutorClient` trait. The local impl calls `executor-core` in-process; the remote impl is an HTTP client. Same pattern keeps cluster mode a swap, not a rewrite.
## Path Scheme
- `/api/admin/*` — manager (control plane: script CRUD, config, dashboard API)
- `/api/execute/{id}` — orchestrator (data plane: invoke a script by ID)
- `/exec/*` — orchestrator (data plane: invoke scripts at custom paths, v1.1+)
- `/healthz` — liveness (orchestrator)
- `/` and static assets — dashboard (SvelteKit static build, served by Caddy)
Caddy fronts everything. Same Caddyfile shape works for single-node and cluster — only upstream targets change.
## Tech Stack
- **Rust 1.92+** workspace, pinned via `rust-toolchain.toml`
- **Axum** for HTTP, **Tokio** async, **sqlx** for Postgres
- **Rhai** embedded scripting (in `executor-core`)
- **PostgreSQL 15+** with `pgcrypto` and (v1.1+) `hstore`
- **SvelteKit** dashboard, static adapter, CodeMirror 6 for the script editor
- **Caddy 2** reverse proxy (auto-HTTPS in prod)
- **Docker Compose** for dev and single-node prod
## Common Commands
```sh
# Rust workspace
cargo check --workspace
cargo test --workspace
cargo fmt --all
cargo clippy --all-targets --all-features -- -D warnings
# Run the all-in-one binary (MVP entry point)
cargo run -p picloud
# Run a single test
cargo test -p executor-core sandbox::tests::respects_operation_budget
# Dashboard (from dashboard/)
npm run dev
npm run build
npm run check
# Full stack (once docker-compose.yml exists)
docker compose up
docker compose down -v # reset Postgres data
```
## Workspace Layout
```
crates/
shared/ # cross-cutting types (Script, IDs, error enum, db pool)
executor-core/ # Rhai engine, sandbox, ctx, log, SDK
orchestrator-core/ # event ingress + ExecutorClient trait + dispatch
manager-core/ # control plane: repos, scheduler, config
picloud/ # ★ MVP all-in-one binary
picloud-manager/ # cluster mode binary (skeleton)
picloud-orchestrator/ # cluster mode binary (skeleton)
picloud-executor/ # cluster mode binary (skeleton)
dashboard/ # SvelteKit
caddy/ # Caddyfile, Caddyfile.prod
docker/ # Dockerfiles
docs/
git-workflow.md # trunk-based workflow
architecture.md # (TBD)
```
## Working Rules
- **Honor the three-service boundary.** Don't reach across `*-core` crates. If `orchestrator-core` needs something from `manager-core`, define a trait in `shared` and inject the impl.
- **`executor-core` has no Postgres dependency.** Data-plane services (kv, docs, users — v1.1+) come in via injected `ServiceProvider` traits.
- **Database writes only from `manager-core`.** `orchestrator-core` reads scripts (cached); `executor-core` doesn't touch the DB.
- **MVP builds only the `picloud` all-in-one binary.** The three split binaries exist as skeletons so the crate boundaries stay honest; flesh them out only when cluster mode is being implemented.
- **Trunk-based dev.** See [docs/git-workflow.md](docs/git-workflow.md). No long-lived branches. Feature flags for incomplete work.
## Out of MVP
Queue triggers, cron triggers, SMTP ingress, KV / docs / email / users / HTTP SDKs in scripts, interceptors, workflows, function-to-function `invoke()`, auth, multi-tenancy, secrets, metrics dashboard. All deferred to v1.1+ per the blueprint. Don't pre-build for them — but don't make decisions that close the door on them either.

3283
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

81
Cargo.toml Normal file
View File

@@ -0,0 +1,81 @@
[workspace]
resolver = "2"
members = [
"crates/shared",
"crates/executor-core",
"crates/orchestrator-core",
"crates/manager-core",
"crates/picloud",
"crates/picloud-manager",
"crates/picloud-orchestrator",
"crates/picloud-executor",
]
[workspace.package]
edition = "2021"
rust-version = "1.92"
license = "MIT OR Apache-2.0"
authors = ["PiCloud contributors"]
repository = "https://github.com/fhamm/picloud"
[workspace.dependencies]
# Internal crates
picloud-shared = { path = "crates/shared" }
picloud-executor-core = { path = "crates/executor-core" }
picloud-orchestrator-core = { path = "crates/orchestrator-core" }
picloud-manager-core = { path = "crates/manager-core" }
# Async + HTTP
tokio = { version = "1.40", features = ["full"] }
axum = "0.7"
tower = "0.5"
tower-http = { version = "0.6", features = ["trace", "cors"] }
hyper = "1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Errors + logging
thiserror = "1"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
# IDs + time
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
# Async traits
async-trait = "0.1"
# Rhai scripting
rhai = { version = "1.19", features = ["sync", "serde"] }
# Postgres (manager-core only — others stay DB-free)
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "json", "macros", "migrate"] }
# Config
figment = { version = "0.10", features = ["toml", "env"] }
# HTTP client (for RemoteExecutorClient later)
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
[workspace.lints.rust]
unsafe_code = "forbid"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module_name_repetitions = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
doc_markdown = "allow"
[profile.release]
lto = "thin"
codegen-units = 1
strip = "symbols"
[profile.dev]
debug = 1

54
README.md Normal file
View File

@@ -0,0 +1,54 @@
# PiCloud
A lightweight, self-hosted, event-driven serverless compute platform. Upload a [Rhai](https://rhai.rs/) script, get an HTTP endpoint. Designed to run on a single modest server with no idle CPU cost, and to scale out to a small cluster when you need it.
> **Status:** Phase 1 — MVP scaffolding in progress.
>
> The authoritative design lives in [`serverless_cloud_blueprint.md`](serverless_cloud_blueprint.md).
## Why
Existing serverless platforms are either cloud-locked, heavyweight, or both. PiCloud aims for the opposite end of the spectrum: one binary, one database, one reverse proxy — running on hardware you already own.
## Architecture (one paragraph)
PiCloud splits into three logical services — **manager** (control plane: scripts, schedules, dashboard), **orchestrator** (per-node event ingress and dispatch), and **executor** (per-node Rhai sandbox) — each backed by a `*-core` Rust library. In MVP they run in a single process; in cluster mode they run as three binaries with one manager and one orchestrator + executor per node. [Caddy](https://caddyserver.com/) fronts everything; [PostgreSQL](https://www.postgresql.org/) is the single source of truth.
See [`CLAUDE.md`](CLAUDE.md) for working notes and [`serverless_cloud_blueprint.md`](serverless_cloud_blueprint.md) for the full design.
## Quick Start
> _Coming as scaffolding lands. For now:_
```sh
# Rust toolchain (pinned via rust-toolchain.toml)
cargo check --workspace
# Run the all-in-one MVP binary (once main.rs is wired up)
cargo run -p picloud
```
## Repository Layout
```
crates/
shared/ cross-cutting types
executor-core/ Rhai engine + sandbox
orchestrator-core/ event ingress, dispatch
manager-core/ control plane
picloud/ MVP all-in-one binary
picloud-{manager,orchestrator,executor}/ cluster-mode binaries (skeleton)
dashboard/ SvelteKit
caddy/ Caddyfile
docker/ Dockerfiles
docs/
git-workflow.md Trunk-based workflow
```
## Contributing
See [`docs/git-workflow.md`](docs/git-workflow.md) for the branching and commit conventions. TL;DR: trunk-based, short-lived branches, Conventional Commits, no force-pushing `main`.
## License
TBD.

View File

@@ -0,0 +1,20 @@
[package]
name = "picloud-executor-core"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
picloud-shared.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
rhai.workspace = true

View File

@@ -0,0 +1,8 @@
//! The `ctx` object exposed to running Rhai scripts.
//!
//! MVP shape only: execution metadata + request data. The full SDK
//! (kv, docs, users, http, retry, invoke) lands in v1.1+ and will be
//! injected via a `ServiceProvider` trait rather than hardcoded here.
// Implementation lands together with the Rhai engine wiring; the file
// exists now so the module boundary is fixed in the public API.

View File

@@ -0,0 +1,48 @@
use crate::sandbox::Limits;
use crate::types::{ExecError, ExecRequest, ExecResponse};
/// Preconfigured Rhai engine with sandbox limits applied.
///
/// One `Engine` is constructed at process startup and reused across
/// invocations. `execute` is the only entry point — it owns the per-call
/// scope and log buffer, then returns a complete `ExecResponse`.
pub struct Engine {
limits: Limits,
// The actual `rhai::Engine` lands with the first execution implementation.
// Keep this opaque for now so callers don't bind to it.
}
impl Engine {
#[must_use]
pub fn new(limits: Limits) -> Self {
Self { limits }
}
#[must_use]
pub fn limits(&self) -> &Limits {
&self.limits
}
/// Parse-only validation, used by the manager at script-upload time
/// so syntax errors are surfaced before the first invocation.
pub fn validate(&self, _source: &str) -> Result<(), ExecError> {
// TODO(executor-core): wire `rhai::Engine::compile`
Ok(())
}
/// Execute `source` against `req` under the configured sandbox.
///
/// `async` is part of the contract: v1.1+ SDK calls (kv, docs, http)
/// will await injected service providers from inside this method.
#[allow(clippy::unused_async)]
pub async fn execute(
&self,
_source: &str,
_req: ExecRequest,
) -> Result<ExecResponse, ExecError> {
// TODO(executor-core): wire `rhai::Engine::eval_with_scope`
Err(ExecError::Runtime(
"executor-core::Engine::execute not yet implemented".into(),
))
}
}

View File

@@ -0,0 +1,17 @@
//! Rhai script execution: engine, sandbox, SDK bindings.
//!
//! This crate has no Postgres dependency and no awareness of HTTP. It is
//! used both by the orchestrator (in-process, via `LocalExecutorClient`) and
//! by the standalone executor binary (in cluster mode). Keep it that way.
pub mod context;
pub mod engine;
pub mod logging;
pub mod sandbox;
pub mod types;
pub use engine::Engine;
pub use sandbox::Limits;
pub use types::{
ExecError, ExecRequest, ExecResponse, ExecStats, InvocationType, LogEntry, LogLevel,
};

View File

@@ -0,0 +1,6 @@
//! `log.info / warn / error / debug` bindings exposed to Rhai scripts.
//!
//! Captures structured log entries into a per-execution buffer that is
//! attached to the `ExecResponse` and persisted by the manager.
// Implementation lands with the engine wiring.

View File

@@ -0,0 +1,37 @@
/// Resource and capability limits applied to every script execution.
///
/// Defaults are conservative and safe to expose to untrusted Rhai sources.
/// Per-script overrides (e.g. higher operation budgets) come from the
/// `Script` config and are clamped against these as upper bounds.
#[derive(Debug, Clone, Copy)]
pub struct Limits {
/// Hard cap on Rhai operations executed per invocation.
/// Doubles as a CPU-time proxy without needing real timers.
pub max_operations: u64,
/// Max length of any single string the script constructs.
pub max_string_size: usize,
/// Max number of elements in any array.
pub max_array_size: usize,
/// Max number of properties in any object/map.
pub max_map_size: usize,
/// Max call/expression nesting depth.
pub max_call_levels: usize,
pub max_expr_depth: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_operations: 1_000_000,
max_string_size: 64 * 1024,
max_array_size: 10_000,
max_map_size: 10_000,
max_call_levels: 64,
max_expr_depth: 64,
}
}
}

View File

@@ -0,0 +1,80 @@
use std::collections::BTreeMap;
use chrono::{DateTime, Utc};
use picloud_shared::{ExecutionId, RequestId, ScriptId};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum InvocationType {
Http,
Function,
Scheduled,
}
/// Everything the executor needs to run one invocation.
///
/// This type crosses process boundaries in cluster mode — keep it fully
/// serializable and don't add transient handles (DB pools, etc.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecRequest {
pub execution_id: ExecutionId,
pub request_id: RequestId,
pub script_id: ScriptId,
pub script_name: String,
pub invocation_type: InvocationType,
pub path: String,
pub headers: BTreeMap<String, String>,
pub body: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecResponse {
pub status_code: u16,
pub headers: BTreeMap<String, String>,
pub body: serde_json::Value,
pub logs: Vec<LogEntry>,
pub stats: ExecStats,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub timestamp: DateTime<Utc>,
pub level: LogLevel,
pub message: String,
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct ExecStats {
pub duration_ms: u64,
pub operations: u64,
}
#[derive(Debug, Error)]
pub enum ExecError {
#[error("script failed to parse: {0}")]
Parse(String),
#[error("script returned an invalid response shape: {0}")]
InvalidResponse(String),
#[error("execution timed out after {0}s")]
Timeout(u32),
#[error("execution exceeded operation budget")]
OperationBudgetExceeded,
#[error("script runtime error: {0}")]
Runtime(String),
}

View File

@@ -0,0 +1,22 @@
[package]
name = "picloud-manager-core"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
picloud-shared.workspace = true
picloud-orchestrator-core.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
sqlx.workspace = true

View File

@@ -0,0 +1,10 @@
//! Control plane: script storage, scheduling, configuration.
//!
//! Single-writer to Postgres. The orchestrator may *read* scripts from
//! the same DB for now; once we add caching and per-node ingress, the
//! manager will publish change events.
pub mod repo;
pub mod scheduler;
pub use repo::{PostgresScriptRepository, ScriptRepository, ScriptRepositoryError};

View File

@@ -0,0 +1,83 @@
use async_trait::async_trait;
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
use picloud_shared::{Script, ScriptId};
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum ScriptRepositoryError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
#[error("not found: {0}")]
NotFound(ScriptId),
}
/// CRUD over the `scripts` table.
#[async_trait]
pub trait ScriptRepository: Send + Sync {
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
async fn create(&self, script: &Script) -> Result<(), ScriptRepositoryError>;
async fn update(&self, script: &Script) -> Result<(), ScriptRepositoryError>;
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError>;
}
pub struct PostgresScriptRepository {
pool: PgPool,
}
impl PostgresScriptRepository {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
// Real query bodies land alongside the first migration. Stubbing the trait
// impl so the workspace compiles and the seam is visible.
#[async_trait]
impl ScriptRepository for PostgresScriptRepository {
async fn get(&self, _id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
let _ = &self.pool;
Ok(None)
}
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
Ok(Vec::new())
}
async fn create(&self, _script: &Script) -> Result<(), ScriptRepositoryError> {
Ok(())
}
async fn update(&self, _script: &Script) -> Result<(), ScriptRepositoryError> {
Ok(())
}
async fn delete(&self, _id: ScriptId) -> Result<(), ScriptRepositoryError> {
Ok(())
}
}
/// Adapts a `ScriptRepository` into the `ScriptResolver` trait the
/// orchestrator depends on, so we don't pull the manager into the
/// orchestrator's dependency graph.
pub struct RepoResolver<R: ScriptRepository> {
repo: R,
}
impl<R: ScriptRepository> RepoResolver<R> {
pub fn new(repo: R) -> Self {
Self { repo }
}
}
#[async_trait]
impl<R: ScriptRepository> ScriptResolver for RepoResolver<R> {
async fn resolve(&self, id: ScriptId) -> Result<Option<Script>, ResolverError> {
self.repo
.get(id)
.await
.map_err(|e| ResolverError::Backend(e.to_string()))
}
}

View File

@@ -0,0 +1,6 @@
//! Cron / scheduled task dispatcher.
//!
//! Postgres `SELECT ... FOR UPDATE SKIP LOCKED` over a `schedules` table
//! gives us strict singleton execution without external coordination.
//!
//! Skeleton only — wired up in v1.1+ when cron triggers ship.

View File

@@ -0,0 +1,22 @@
[package]
name = "picloud-orchestrator-core"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
picloud-shared.workspace = true
picloud-executor-core.workspace = true
async-trait.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tracing.workspace = true
uuid.workspace = true
chrono.workspace = true
reqwest.workspace = true

View File

@@ -0,0 +1,61 @@
use std::sync::Arc;
use async_trait::async_trait;
use picloud_executor_core::{Engine, ExecError, ExecRequest, ExecResponse};
/// The seam between the orchestrator and the executor.
///
/// Single-node mode plugs in `LocalExecutorClient`, which calls
/// `executor-core` in-process. Cluster mode plugs in `RemoteExecutorClient`,
/// which forwards over HTTP to an executor node. Everything else in
/// orchestrator-core depends only on this trait.
#[async_trait]
pub trait ExecutorClient: Send + Sync {
async fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError>;
}
/// In-process executor — wraps `executor-core::Engine` directly.
pub struct LocalExecutorClient {
engine: Arc<Engine>,
}
impl LocalExecutorClient {
#[must_use]
pub fn new(engine: Arc<Engine>) -> Self {
Self { engine }
}
}
#[async_trait]
impl ExecutorClient for LocalExecutorClient {
async fn execute(&self, source: &str, req: ExecRequest) -> Result<ExecResponse, ExecError> {
self.engine.execute(source, req).await
}
}
/// Remote executor — forwards to a peer executor node over HTTP.
///
/// Skeleton only; fleshed out when cluster mode lands.
pub struct RemoteExecutorClient {
_client: reqwest::Client,
_base_url: String,
}
impl RemoteExecutorClient {
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
Self {
_client: reqwest::Client::new(),
_base_url: base_url.into(),
}
}
}
#[async_trait]
impl ExecutorClient for RemoteExecutorClient {
async fn execute(&self, _source: &str, _req: ExecRequest) -> Result<ExecResponse, ExecError> {
Err(ExecError::Runtime(
"RemoteExecutorClient not implemented (cluster mode is v1.3+)".into(),
))
}
}

View File

@@ -0,0 +1,15 @@
//! Per-node event ingress and dispatch.
//!
//! Owns the data-plane request path:
//! inbound event → resolve script → call `ExecutorClient::execute`
//!
//! Does not import `executor-core` types in its public surface beyond the
//! transport DTOs (`ExecRequest`/`ExecResponse`). The `ExecutorClient`
//! trait is the seam that lets the orchestrator call executor logic
//! in-process (single-node) or over HTTP (cluster).
pub mod client;
pub mod resolver;
pub use client::{ExecutorClient, LocalExecutorClient, RemoteExecutorClient};
pub use resolver::{ResolverError, ScriptResolver};

View File

@@ -0,0 +1,17 @@
use async_trait::async_trait;
use picloud_shared::{Script, ScriptId};
/// How the orchestrator looks up a script before dispatching to the
/// executor. In MVP this is backed by a direct Postgres read inside the
/// manager-core repository, exposed through this trait so orchestrator-core
/// stays DB-agnostic.
#[async_trait]
pub trait ScriptResolver: Send + Sync {
async fn resolve(&self, id: ScriptId) -> Result<Option<Script>, ResolverError>;
}
#[derive(Debug, thiserror::Error)]
pub enum ResolverError {
#[error("backend error: {0}")]
Backend(String),
}

View File

@@ -0,0 +1,22 @@
[package]
name = "picloud-executor"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[[bin]]
name = "picloud-executor"
path = "src/main.rs"
[dependencies]
picloud-shared.workspace = true
picloud-executor-core.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

View File

@@ -0,0 +1,10 @@
//! Standalone executor binary for cluster mode. Skeleton — fleshed out
//! when cluster mode lands (v1.3+). For MVP use the `picloud` binary.
fn main() {
eprintln!(
"picloud-executor: cluster mode is not implemented yet (v1.3+).\n\
Use the `picloud` all-in-one binary for MVP."
);
std::process::exit(2);
}

View File

@@ -0,0 +1,22 @@
[package]
name = "picloud-manager"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[[bin]]
name = "picloud-manager"
path = "src/main.rs"
[dependencies]
picloud-shared.workspace = true
picloud-manager-core.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

View File

@@ -0,0 +1,10 @@
//! Standalone manager binary for cluster mode. Skeleton — fleshed out
//! when cluster mode lands (v1.3+). For MVP use the `picloud` binary.
fn main() {
eprintln!(
"picloud-manager: cluster mode is not implemented yet (v1.3+).\n\
Use the `picloud` all-in-one binary for MVP."
);
std::process::exit(2);
}

View File

@@ -0,0 +1,22 @@
[package]
name = "picloud-orchestrator"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[[bin]]
name = "picloud-orchestrator"
path = "src/main.rs"
[dependencies]
picloud-shared.workspace = true
picloud-orchestrator-core.workspace = true
tokio.workspace = true
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true

View File

@@ -0,0 +1,10 @@
//! Standalone orchestrator binary for cluster mode. Skeleton — fleshed
//! out when cluster mode lands (v1.3+). For MVP use the `picloud` binary.
fn main() {
eprintln!(
"picloud-orchestrator: cluster mode is not implemented yet (v1.3+).\n\
Use the `picloud` all-in-one binary for MVP."
);
std::process::exit(2);
}

31
crates/picloud/Cargo.toml Normal file
View File

@@ -0,0 +1,31 @@
[package]
name = "picloud"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[[bin]]
name = "picloud"
path = "src/main.rs"
[dependencies]
picloud-shared.workspace = true
picloud-executor-core.workspace = true
picloud-orchestrator-core.workspace = true
picloud-manager-core.workspace = true
tokio.workspace = true
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
figment.workspace = true

View File

@@ -0,0 +1,39 @@
//! PiCloud all-in-one binary — runs manager + orchestrator + executor in
//! one process. This is the only binary built for MVP. The split binaries
//! (`picloud-manager`, `picloud-orchestrator`, `picloud-executor`) exist
//! to enforce the crate boundaries and will be fleshed out in v1.3+
//! when cluster mode is built.
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.json()
.init();
let app = Router::new()
.route("/healthz", get(healthz))
.route("/", get(root));
let addr: SocketAddr = std::env::var("PICLOUD_BIND")
.unwrap_or_else(|_| "0.0.0.0:8080".into())
.parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(%addr, "picloud all-in-one listening");
axum::serve(listener, app).await?;
Ok(())
}
async fn healthz() -> &'static str {
"ok"
}
async fn root() -> &'static str {
"picloud — see /api/admin/* (manager) and /api/execute/* (orchestrator)"
}

16
crates/shared/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "picloud-shared"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
uuid.workspace = true
chrono.workspace = true

View File

@@ -0,0 +1,14 @@
use thiserror::Error;
/// Root error type for things that genuinely cross crate boundaries.
///
/// Crate-specific errors (e.g. `executor_core::ExecError`) stay local. Only
/// promote a variant to `Error` when more than one crate needs to match on it.
#[derive(Debug, Error)]
pub enum Error {
#[error("script not found: {0}")]
ScriptNotFound(crate::ScriptId),
#[error("invalid script source: {0}")]
InvalidScript(String),
}

52
crates/shared/src/ids.rs Normal file
View File

@@ -0,0 +1,52 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
macro_rules! id_type {
($name:ident) => {
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
#[serde(transparent)]
pub struct $name(pub Uuid);
impl $name {
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
#[must_use]
pub fn into_inner(self) -> Uuid {
self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl From<Uuid> for $name {
fn from(u: Uuid) -> Self {
Self(u)
}
}
impl From<$name> for Uuid {
fn from(id: $name) -> Self {
id.0
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
};
}
id_type!(ScriptId);
id_type!(ExecutionId);
id_type!(RequestId);

13
crates/shared/src/lib.rs Normal file
View File

@@ -0,0 +1,13 @@
//! Cross-cutting types used by every PiCloud crate.
//!
//! Keep this crate small. If something only one core needs, it belongs in
//! that core's crate. Things here must be genuinely shared (IDs, the Script
//! entity, error roots, transport DTOs).
pub mod error;
pub mod ids;
pub mod script;
pub use error::Error;
pub use ids::{ExecutionId, RequestId, ScriptId};
pub use script::Script;

View File

@@ -0,0 +1,24 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::ScriptId;
/// A user-uploaded Rhai script and its execution configuration.
///
/// This is the canonical representation that flows between manager (storage),
/// orchestrator (dispatch), and executor (run). It must stay serializable
/// because in cluster mode it crosses process boundaries on every invocation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Script {
pub id: ScriptId,
pub name: String,
pub description: Option<String>,
pub version: i32,
pub source: String,
pub timeout_seconds: u32,
pub memory_limit_mb: u32,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

202
docs/git-workflow.md Normal file
View File

@@ -0,0 +1,202 @@
# Git Workflow
PiCloud uses **trunk-based development**: a single long-lived branch (`main`) that is always deployable, with short-lived branches for changes that need review.
This document defines how branches, commits, reviews, and releases work — and how bug fixes flow through the system.
---
## 1. Branches
| Branch | Purpose | Lifetime |
|--------|---------|----------|
| `main` | The trunk. Always green, always deployable. | Permanent |
| `feat/<topic>` | New feature or non-trivial change. | ≤ 2 days |
| `fix/<topic>` | Bug fix (non-critical). | ≤ 1 day |
| `hotfix/<topic>` | Critical bug fix that needs to ship now. | Hours |
| `chore/<topic>` | Refactor, deps update, CI tweak, docs. | ≤ 1 day |
| `release/vX.Y` | Long-lived release branch for back-porting hotfixes to a shipped version. | Per minor version, once we tag releases |
**Rules:**
- Branch directly off `main`. Never branch off another feature branch.
- Keep branches short. If a branch lives longer than 2 days, you're doing too much in one go — split it.
- Branch names use `kebab-case` after the prefix: `feat/script-crud-endpoints`, `fix/executor-timeout-leak`.
- Delete the branch after merge.
---
## 2. Commits
**Format: Conventional Commits.**
```
<type>(<scope>): <subject>
<body — optional, wrapped at 72 chars>
<footer — optional: BREAKING CHANGE, issue refs>
```
**Types:** `feat`, `fix`, `chore`, `refactor`, `docs`, `test`, `perf`, `build`, `ci`.
**Scopes (current crates / areas):** `executor-core`, `orchestrator-core`, `manager-core`, `shared`, `picloud`, `dashboard`, `caddy`, `compose`, `ci`, `docs`.
**Examples:**
```
feat(executor-core): add operation budget enforcement
fix(orchestrator-core): release Postgres pool guard before dispatch
chore(ci): pin Rust toolchain to 1.92.0
```
**Rules:**
- One logical change per commit. If you can't describe it in one line, split it.
- Subject line: imperative, ≤ 72 chars, no trailing period.
- Body explains *why*, not *what* — the diff already shows what.
- No `WIP`, `fixup`, or `oops` commits on `main`. Squash or rewrite before merging.
---
## 3. The Feature Loop
```
┌─────────────────────┐
│ Pull latest main │
└──────────┬──────────┘
┌──────────▼──────────┐
│ Branch: feat/<name> │
└──────────┬──────────┘
┌──────────▼──────────┐
│ Commit small steps │◄────┐
│ Run tests locally │ │
└──────────┬──────────┘ │
│ │
┌──────────▼──────────┐ │
│ Open PR to main │─────┘ (iterate on feedback)
│ CI must be green │
└──────────┬──────────┘
┌──────────▼──────────┐
│ Squash-merge to main│
│ Delete branch │
└─────────────────────┘
```
**Before opening a PR:**
```sh
git fetch origin
git rebase origin/main # keep history linear
cargo fmt --all -- --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --workspace
```
**PR requirements:**
- CI green (fmt, clippy, tests, build).
- At least one approving review for non-trivial changes. Solo dev exception: self-review the diff explicitly before merging.
- PR description names *why* and links to any issue.
- Squash-merge by default → one commit per PR on `main`.
---
## 4. Bug-fix Workflow
Bugs are classified by severity:
### Non-critical bug — normal flow
1. Branch `fix/<topic>` from `main`.
2. Write a failing test that reproduces the bug.
3. Fix it. The test now passes.
4. PR → review → squash-merge.
### Critical / production hotfix
```
┌──────────────────┐
│ Bug reported │
└────────┬─────────┘
┌───────────▼───────────┐
│ Reproduce locally │ ← required before any fix
│ Write failing test │
└───────────┬───────────┘
┌───────────▼───────────┐
│ Branch hotfix/<name> │
│ from main │
└───────────┬───────────┘
┌───────────▼───────────┐
│ Minimal fix only │ ← no refactors, no unrelated changes
│ Test passes │
└───────────┬───────────┘
┌───────────▼───────────┐
│ Fast-track review │
│ Merge to main │
└───────────┬───────────┘
┌───────────▼───────────┐
│ Tag patch release │ (vX.Y.Z+1)
│ Deploy │
└───────────┬───────────┘
┌───────────▼───────────┐
│ Cherry-pick to │ ← only if old releases are
│ release/vX.Y if active│ still in use
└───────────────────────┘
```
**Critical hotfix rules:**
- A hotfix fixes one bug. Nothing else. No "while I'm here" cleanups.
- The reproduction test stays in the suite forever — it's our guarantee the bug doesn't return.
- If the fix can't be minimal (e.g. requires an architectural change), ship a *mitigation* as the hotfix (feature flag off, rate limit, revert), then schedule the real fix as a normal feature.
---
## 5. Keeping `main` Green
Trunk-based only works if `main` is always green. Mechanisms:
- **CI gate:** PRs cannot merge unless CI passes (`fmt`, `clippy -D warnings`, `cargo test --workspace`, dashboard build).
- **Feature flags:** Code for incomplete features lives on `main` behind a flag, off by default. We finish features in small mergeable slices, not in long-lived branches.
- **Reverts are cheap:** If something slips through and breaks `main`, revert first, debug after. `git revert <merge-sha>` and ship.
- **No force-push to `main`.** Ever.
---
## 6. Releases
Until we ship publicly we deploy off `main` continuously. Once we have external users:
- Tag releases on `main`: `v0.1.0`, `v0.1.1`, `v0.2.0` (SemVer).
- Patch releases (`v0.1.1`) come from hotfixes; bump the patch number on each fix.
- Minor releases (`v0.2.0`) come from accumulated features on `main`.
- For each minor version supported in the wild, create `release/v0.1` and cherry-pick patches there. Otherwise don't bother — single trunk is simpler.
**Tagging:**
```sh
git tag -a v0.1.0 -m "v0.1.0 — MVP"
git push origin v0.1.0
```
---
## 7. What We Do Not Do
- **No GitFlow.** No `develop`, no `release` branches by default, no `next`. They add merge overhead without buying anything at our scale.
- **No long-lived feature branches.** If a feature is too big to land in 2 days, split it. Use a feature flag.
- **No merge commits on `main` from feature branches.** Squash-merge keeps history linear and bisectable.
- **No commits straight to `main`** — even for typo fixes. Go through a PR (it can take 30 seconds to review and merge).
- **No `--no-verify`** to bypass hooks. If a hook fails, fix the underlying issue.

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "1.92.0"
components = ["rustfmt", "clippy"]

File diff suppressed because it is too large Load Diff