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:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal 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
100
CLAUDE.md
Normal 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
3283
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
81
Cargo.toml
Normal file
81
Cargo.toml
Normal 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
54
README.md
Normal 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.
|
||||||
20
crates/executor-core/Cargo.toml
Normal file
20
crates/executor-core/Cargo.toml
Normal 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
|
||||||
8
crates/executor-core/src/context.rs
Normal file
8
crates/executor-core/src/context.rs
Normal 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.
|
||||||
48
crates/executor-core/src/engine.rs
Normal file
48
crates/executor-core/src/engine.rs
Normal 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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
17
crates/executor-core/src/lib.rs
Normal file
17
crates/executor-core/src/lib.rs
Normal 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,
|
||||||
|
};
|
||||||
6
crates/executor-core/src/logging.rs
Normal file
6
crates/executor-core/src/logging.rs
Normal 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.
|
||||||
37
crates/executor-core/src/sandbox.rs
Normal file
37
crates/executor-core/src/sandbox.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
crates/executor-core/src/types.rs
Normal file
80
crates/executor-core/src/types.rs
Normal 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),
|
||||||
|
}
|
||||||
22
crates/manager-core/Cargo.toml
Normal file
22
crates/manager-core/Cargo.toml
Normal 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
|
||||||
10
crates/manager-core/src/lib.rs
Normal file
10
crates/manager-core/src/lib.rs
Normal 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};
|
||||||
83
crates/manager-core/src/repo.rs
Normal file
83
crates/manager-core/src/repo.rs
Normal 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()))
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/manager-core/src/scheduler.rs
Normal file
6
crates/manager-core/src/scheduler.rs
Normal 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.
|
||||||
22
crates/orchestrator-core/Cargo.toml
Normal file
22
crates/orchestrator-core/Cargo.toml
Normal 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
|
||||||
61
crates/orchestrator-core/src/client.rs
Normal file
61
crates/orchestrator-core/src/client.rs
Normal 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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
15
crates/orchestrator-core/src/lib.rs
Normal file
15
crates/orchestrator-core/src/lib.rs
Normal 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};
|
||||||
17
crates/orchestrator-core/src/resolver.rs
Normal file
17
crates/orchestrator-core/src/resolver.rs
Normal 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),
|
||||||
|
}
|
||||||
22
crates/picloud-executor/Cargo.toml
Normal file
22
crates/picloud-executor/Cargo.toml
Normal 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
|
||||||
10
crates/picloud-executor/src/main.rs
Normal file
10
crates/picloud-executor/src/main.rs
Normal 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);
|
||||||
|
}
|
||||||
22
crates/picloud-manager/Cargo.toml
Normal file
22
crates/picloud-manager/Cargo.toml
Normal 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
|
||||||
10
crates/picloud-manager/src/main.rs
Normal file
10
crates/picloud-manager/src/main.rs
Normal 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);
|
||||||
|
}
|
||||||
22
crates/picloud-orchestrator/Cargo.toml
Normal file
22
crates/picloud-orchestrator/Cargo.toml
Normal 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
|
||||||
10
crates/picloud-orchestrator/src/main.rs
Normal file
10
crates/picloud-orchestrator/src/main.rs
Normal 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
31
crates/picloud/Cargo.toml
Normal 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
|
||||||
39
crates/picloud/src/main.rs
Normal file
39
crates/picloud/src/main.rs
Normal 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
16
crates/shared/Cargo.toml
Normal 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
|
||||||
14
crates/shared/src/error.rs
Normal file
14
crates/shared/src/error.rs
Normal 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
52
crates/shared/src/ids.rs
Normal 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
13
crates/shared/src/lib.rs
Normal 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;
|
||||||
24
crates/shared/src/script.rs
Normal file
24
crates/shared/src/script.rs
Normal 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
202
docs/git-workflow.md
Normal 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
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.92.0"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
1345
serverless_cloud_blueprint.md
Normal file
1345
serverless_cloud_blueprint.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user