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:
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>,
|
||||
}
|
||||
Reference in New Issue
Block a user