From 0473d295af59a1b2c491af411ba13a9983d85295 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 23 May 2026 00:31:08 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20versioning=20scheme=20=E2=80=94=20locks?= =?UTF-8?q?tep=20crates=20+=20four=20independent=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish how versions are assigned, bumped, and checked across the five things that actually change for users: the product itself, the Rhai SDK, the HTTP API, the database schema, and the inter-service wire (reserved for cluster mode). Crates ship in lockstep — drift between picloud-shared and picloud-manager-core is fiction since they always release together — but surfaces are versioned and checked at their natural boundaries. * docs/versioning.md is the authoritative reference: what gets a version, the per-surface compatibility rules, how each surface bump cascades to the product version (loose pre-1.0, strict post-1.0), and the five enforcement mechanisms (lockstep at compile time, /version at runtime, golden SDK contract tests, migration replay, CI guardrail). * shared::version exposes four constants — PRODUCT_VERSION (from CARGO_PKG_VERSION), SDK_VERSION ("1.0"), API_VERSION (1), WIRE_VERSION (1). Scripts read SDK_VERSION as ctx.sdk_version and can feature-detect against it. * Workspace inheritance: `[workspace.package] version = "0.2.0"` is the single point of truth; every crate uses `version.workspace = true`. dashboard/package.json mirrors. * Routes move to /api/v1/* — both control plane (/api/v1/admin/*) and data plane (/api/v1/execute/{id}). Picloud composes them via a single `/api/v{API_VERSION}` nest, so the next major is a copy-paste-and-bump. Caddyfile (dev and prod) routes /api/v1/* to picloud and 404s any other /api/* so old clients fail loudly instead of getting the SPA shell. Dashboard client + integration tests updated. * /healthz remains a plain "ok" string (k8s probes); /version is the new JSON endpoint returning every surface version in one place — product, sdk, api, schema (from manager-core::migrations::latest_version), wire. * Reasonable bump rationale: API path changes are breaking by definition, so 0.1.0 → 0.2.0 (pre-1.0 license to bump minor on any breaking change). SDK starts at 1.0 because scripts depend on it more strictly than the product depends on its internals; we'd rather promise SDK stability early than pull the rug. Verified live: * /healthz → "ok" (plain text) * /version → {product:"0.2.0",sdk:"1.0",api:1,schema:1,wire:1} * /api/v1/admin/scripts → 200 * /api/admin/scripts → 404 with error JSON (sunset major) * Script can read ctx.sdk_version → "1.0" * All 14 integration tests pass against new paths * 11 executor-core unit tests pass (added one for sdk_version exposure with the major.minor format invariant) Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 11 +- Cargo.lock | 16 +-- Cargo.toml | 1 + caddy/Caddyfile | 26 ++++- caddy/Caddyfile.prod | 17 ++- crates/executor-core/Cargo.toml | 2 +- crates/executor-core/src/engine.rs | 3 +- crates/executor-core/tests/engine.rs | 14 +++ crates/manager-core/Cargo.toml | 2 +- crates/manager-core/src/migrations.rs | 12 ++ crates/orchestrator-core/Cargo.toml | 2 +- crates/picloud-executor/Cargo.toml | 2 +- crates/picloud-manager/Cargo.toml | 2 +- crates/picloud-orchestrator/Cargo.toml | 2 +- crates/picloud/Cargo.toml | 2 +- crates/picloud/src/lib.rs | 36 +++++- crates/picloud/tests/api.rs | 66 ++++++----- crates/shared/Cargo.toml | 2 +- crates/shared/src/lib.rs | 2 + crates/shared/src/version.rs | 31 +++++ dashboard/package.json | 2 +- dashboard/src/lib/api.ts | 18 +-- docs/versioning.md | 155 +++++++++++++++++++++++++ 23 files changed, 356 insertions(+), 70 deletions(-) create mode 100644 crates/shared/src/version.rs create mode 100644 docs/versioning.md diff --git a/CLAUDE.md b/CLAUDE.md index 94b09b9..79bbcc1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,10 +24,13 @@ In MVP, all three run in one process (`picloud` binary). In cluster mode, each r ## 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) +Versioned API surfaces live under `/api/v{N}/...`. See [docs/versioning.md](docs/versioning.md) for the full scheme. + +- `/api/v1/admin/*` — manager (control plane: script CRUD, logs, config) +- `/api/v1/execute/{id}` — orchestrator (data plane: invoke a script by ID) +- `/exec/*` — orchestrator (data plane: invoke scripts at custom paths, v1.1+). Unversioned — the contract is user-defined per script. +- `/healthz` — liveness (string `"ok"`) +- `/version` — versions of every compatibility surface (JSON) - `/` 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. diff --git a/Cargo.lock b/Cargo.lock index c26daab..4a47f11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1273,7 +1273,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "picloud" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "async-trait", @@ -1297,7 +1297,7 @@ dependencies = [ [[package]] name = "picloud-executor" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "picloud-executor-core", @@ -1309,7 +1309,7 @@ dependencies = [ [[package]] name = "picloud-executor-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "picloud-shared", @@ -1323,7 +1323,7 @@ dependencies = [ [[package]] name = "picloud-manager" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "picloud-manager-core", @@ -1335,7 +1335,7 @@ dependencies = [ [[package]] name = "picloud-manager-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "axum", @@ -1352,7 +1352,7 @@ dependencies = [ [[package]] name = "picloud-orchestrator" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "picloud-orchestrator-core", @@ -1364,7 +1364,7 @@ dependencies = [ [[package]] name = "picloud-orchestrator-core" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "axum", @@ -1382,7 +1382,7 @@ dependencies = [ [[package]] name = "picloud-shared" -version = "0.1.0" +version = "0.2.0" dependencies = [ "async-trait", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 2719b5a..a89e788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ ] [workspace.package] +version = "0.2.0" edition = "2021" rust-version = "1.92" license = "MIT OR Apache-2.0" diff --git a/caddy/Caddyfile b/caddy/Caddyfile index b7e421d..e945f8d 100644 --- a/caddy/Caddyfile +++ b/caddy/Caddyfile @@ -19,24 +19,44 @@ } :80 { - # Health probes go straight to the orchestrator. + # Health + version are unversioned (k8s probes, monitoring). handle /healthz { reverse_proxy picloud:8080 } + handle /version { + reverse_proxy picloud:8080 + } + + # Versioned API (see docs/versioning.md). When v2 ships, add a + # second `handle /api/v2/...` block while keeping v1 live for at + # least one product-minor deprecation window. # Control plane → manager (single-process: picloud). - handle /api/admin/* { + handle /api/v1/admin/* { reverse_proxy picloud:8080 } # Data plane → orchestrator (single-process: picloud). - handle /api/execute/* { + handle /api/v1/execute/* { reverse_proxy picloud:8080 } + + # Unversioned: user-defined script paths (v1.1+). Contract is just + # "your script runs against this body", so no API versioning applies. handle /exec/* { reverse_proxy picloud:8080 } + # Anything else under /api/* — old major versions that have been + # fully sunset, or typos. Fail loudly rather than silently serving + # the dashboard SPA. + handle /api/* { + respond 404 { + body "{\"error\":\"no such API version — see /version for supported routes\"}" + close + } + } + # Everything else → dashboard SPA (Caddy serves a self-contained # dashboard container that already runs file_server with index.html # fallback for client-side routing). diff --git a/caddy/Caddyfile.prod b/caddy/Caddyfile.prod index a5c4820..f2e765d 100644 --- a/caddy/Caddyfile.prod +++ b/caddy/Caddyfile.prod @@ -14,18 +14,29 @@ handle /healthz { reverse_proxy picloud:8080 } - - handle /api/admin/* { + handle /version { reverse_proxy picloud:8080 } - handle /api/execute/* { + handle /api/v1/admin/* { + reverse_proxy picloud:8080 + } + handle /api/v1/execute/* { reverse_proxy picloud:8080 } handle /exec/* { reverse_proxy picloud:8080 } + # Catch unsupported / sunset API majors before they fall through to + # the SPA — old clients should fail loudly. + handle /api/* { + respond 404 { + body "{\"error\":\"no such API version — see /version for supported routes\"}" + close + } + } + handle { reverse_proxy dashboard:80 } diff --git a/crates/executor-core/Cargo.toml b/crates/executor-core/Cargo.toml index d321bc1..1ab142b 100644 --- a/crates/executor-core/Cargo.toml +++ b/crates/executor-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picloud-executor-core" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/executor-core/src/engine.rs b/crates/executor-core/src/engine.rs index 2193f82..cdf4b10 100644 --- a/crates/executor-core/src/engine.rs +++ b/crates/executor-core/src/engine.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex}; use std::time::Instant; use chrono::Utc; -use picloud_shared::{ScriptValidator, ValidationError}; +use picloud_shared::{ScriptValidator, ValidationError, SDK_VERSION}; use rhai::{Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module, Scope}; use serde_json::Value as Json; @@ -172,6 +172,7 @@ fn push_log(logs: &Arc>>, level: LogLevel, message: &str, da fn build_ctx_map(req: &ExecRequest) -> Map { let mut ctx = Map::new(); + ctx.insert("sdk_version".into(), SDK_VERSION.into()); ctx.insert("execution_id".into(), req.execution_id.to_string().into()); ctx.insert("script_id".into(), req.script_id.to_string().into()); ctx.insert("script_name".into(), req.script_name.clone().into()); diff --git a/crates/executor-core/tests/engine.rs b/crates/executor-core/tests/engine.rs index 1ee37de..892300b 100644 --- a/crates/executor-core/tests/engine.rs +++ b/crates/executor-core/tests/engine.rs @@ -143,6 +143,20 @@ fn module_import_is_blocked() { assert!(matches!(err, ExecError::Runtime(_) | ExecError::Parse(_))); } +#[test] +fn ctx_exposes_sdk_version() { + let resp = engine() + .execute("ctx.sdk_version", req(json!(null))) + .unwrap(); + // Whatever it is, it must look like "MAJOR.MINOR" — that's the + // contract scripts feature-detect against. + let v = resp.body.as_str().expect("sdk_version is a string"); + let parts: Vec<&str> = v.split('.').collect(); + assert_eq!(parts.len(), 2, "expected major.minor, got {v:?}"); + assert!(parts[0].parse::().is_ok(), "major not numeric: {v:?}"); + assert!(parts[1].parse::().is_ok(), "minor not numeric: {v:?}"); +} + #[test] fn body_passes_through_nested_json_round_trip() { let src = "#{ statusCode: 200, body: ctx.request.body }"; diff --git a/crates/manager-core/Cargo.toml b/crates/manager-core/Cargo.toml index bc8d725..98688f5 100644 --- a/crates/manager-core/Cargo.toml +++ b/crates/manager-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picloud-manager-core" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/manager-core/src/migrations.rs b/crates/manager-core/src/migrations.rs index aae00d5..2588647 100644 --- a/crates/manager-core/src/migrations.rs +++ b/crates/manager-core/src/migrations.rs @@ -7,3 +7,15 @@ use sqlx::PgPool; pub async fn run(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> { sqlx::migrate!("./migrations").run(pool).await } + +/// Highest embedded migration version. This is the schema version the +/// binary expects to find applied — surfaced from `/version` so peers +/// and operators can verify schema compatibility at a glance. +#[must_use] +pub fn latest_version() -> i64 { + sqlx::migrate!("./migrations") + .iter() + .map(|m| m.version) + .max() + .unwrap_or(0) +} diff --git a/crates/orchestrator-core/Cargo.toml b/crates/orchestrator-core/Cargo.toml index b87d392..d39f66d 100644 --- a/crates/orchestrator-core/Cargo.toml +++ b/crates/orchestrator-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picloud-orchestrator-core" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/picloud-executor/Cargo.toml b/crates/picloud-executor/Cargo.toml index 14b3f4d..3e4f3a0 100644 --- a/crates/picloud-executor/Cargo.toml +++ b/crates/picloud-executor/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picloud-executor" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/picloud-manager/Cargo.toml b/crates/picloud-manager/Cargo.toml index ddc6bce..6bc7186 100644 --- a/crates/picloud-manager/Cargo.toml +++ b/crates/picloud-manager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picloud-manager" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/picloud-orchestrator/Cargo.toml b/crates/picloud-orchestrator/Cargo.toml index 361c207..8baed55 100644 --- a/crates/picloud-orchestrator/Cargo.toml +++ b/crates/picloud-orchestrator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picloud-orchestrator" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/picloud/Cargo.toml b/crates/picloud/Cargo.toml index f7b5f47..3d351f2 100644 --- a/crates/picloud/Cargo.toml +++ b/crates/picloud/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picloud" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index ac6ce84..273399b 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -6,20 +6,27 @@ use std::sync::Arc; use std::time::Duration; -use axum::{routing::get, Router}; +use axum::{routing::get, Json, Router}; use picloud_executor_core::{Engine, Limits}; use picloud_manager_core::{ - admin_router, AdminState, PostgresExecutionLogRepository, PostgresExecutionLogSink, + admin_router, migrations, AdminState, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresScriptRepository, RepoResolver, }; use picloud_orchestrator_core::{data_plane_router, DataPlaneState, LocalExecutorClient}; -use picloud_shared::{ExecutionLogSink, ScriptValidator}; +use picloud_shared::{ + ExecutionLogSink, ScriptValidator, API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION, +}; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; use tower_http::trace::TraceLayer; /// Compose the manager + orchestrator routes on top of a shared /// Postgres pool, returning an Axum router ready to be served. +/// +/// All API routes live under `/api/v{API_VERSION}/...`. New major +/// versions get a parallel nest under `/api/v{N+1}/...`; the old +/// prefix is kept live for at least one product-minor deprecation +/// window (see `docs/versioning.md`). pub fn build_app(pool: PgPool) -> Router { let engine = Arc::new(Engine::new(Limits::default())); @@ -43,11 +50,15 @@ pub fn build_app(pool: PgPool) -> Router { log_sink, }; + let api_v1 = Router::new() + .nest("/admin", admin_router(admin)) + .merge(data_plane_router(data_plane)); + Router::new() .route("/healthz", get(healthz)) + .route("/version", get(version)) .route("/", get(root)) - .nest("/api/admin", admin_router(admin)) - .nest("/api", data_plane_router(data_plane)) + .nest(&format!("/api/v{API_VERSION}"), api_v1) .layer(TraceLayer::new_for_http()) } @@ -67,7 +78,20 @@ async fn healthz() -> &'static str { } async fn root() -> &'static str { - "picloud — see /api/admin/* (manager) and /api/execute/* (orchestrator)" + "picloud — see /api/v1/admin/* (manager), /api/v1/execute/* (orchestrator), /version" +} + +/// Snapshot of every compatibility-surface version this process speaks. +/// Documented in `docs/versioning.md`; the source of truth is +/// `shared::version` plus the embedded migrations. +async fn version() -> Json { + Json(serde_json::json!({ + "product": PRODUCT_VERSION, + "sdk": SDK_VERSION, + "api": API_VERSION, + "schema": migrations::latest_version(), + "wire": WIRE_VERSION, + })) } // ---------------------------------------------------------------------------- diff --git a/crates/picloud/tests/api.rs b/crates/picloud/tests/api.rs index c611bcb..695426f 100644 --- a/crates/picloud/tests/api.rs +++ b/crates/picloud/tests/api.rs @@ -42,7 +42,7 @@ async fn healthz_responds_ok(pool: PgPool) { async fn create_script_returns_201_with_full_record(pool: PgPool) { let s = server(pool); let r = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "echo", "description": "test", @@ -61,7 +61,7 @@ async fn create_script_returns_201_with_full_record(pool: PgPool) { #[sqlx::test(migrations = "../manager-core/migrations")] async fn create_with_invalid_syntax_returns_422(pool: PgPool) { let r = server(pool) - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "broken", "source": "@@@ not rhai @@@" })) .await; r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); @@ -73,12 +73,12 @@ async fn create_with_invalid_syntax_returns_422(pool: PgPool) { #[sqlx::test(migrations = "../manager-core/migrations")] async fn duplicate_name_returns_409(pool: PgPool) { let s = server(pool); - s.post("/api/admin/scripts") + s.post("/api/v1/admin/scripts") .json(&json!({ "name": "dup", "source": "42" })) .await .assert_status(axum::http::StatusCode::CREATED); let r = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "dup", "source": "43" })) .await; r.assert_status(axum::http::StatusCode::CONFLICT); @@ -89,12 +89,12 @@ async fn duplicate_name_returns_409(pool: PgPool) { async fn list_returns_all_scripts(pool: PgPool) { let s = server(pool); for name in ["alpha", "bravo", "charlie"] { - s.post("/api/admin/scripts") + s.post("/api/v1/admin/scripts") .json(&json!({ "name": name, "source": "1" })) .await .assert_status(axum::http::StatusCode::CREATED); } - let r = s.get("/api/admin/scripts").await; + let r = s.get("/api/v1/admin/scripts").await; r.assert_status_ok(); let body: Vec = r.json(); assert_eq!(body.len(), 3); @@ -107,14 +107,14 @@ async fn list_returns_all_scripts(pool: PgPool) { async fn update_bumps_version_and_persists_changes(pool: PgPool) { let s = server(pool); let created: Value = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "u", "source": "1" })) .await .json(); let id = created["id"].as_str().unwrap(); let r = s - .put(&format!("/api/admin/scripts/{id}")) + .put(&format!("/api/v1/admin/scripts/{id}")) .json(&json!({ "source": "#{ statusCode: 200, body: \"v2\" }", "timeout_seconds": 60 })) .await; r.assert_status_ok(); @@ -129,14 +129,14 @@ async fn update_bumps_version_and_persists_changes(pool: PgPool) { async fn update_with_invalid_source_returns_422(pool: PgPool) { let s = server(pool); let created: Value = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "u", "source": "1" })) .await .json(); let id = created["id"].as_str().unwrap(); let r = s - .put(&format!("/api/admin/scripts/{id}")) + .put(&format!("/api/v1/admin/scripts/{id}")) .json(&json!({ "source": "@@@ broken @@@" })) .await; r.assert_status(axum::http::StatusCode::UNPROCESSABLE_ENTITY); @@ -147,17 +147,17 @@ async fn update_with_invalid_source_returns_422(pool: PgPool) { async fn delete_then_get_returns_404(pool: PgPool) { let s = server(pool); let created: Value = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "d", "source": "1" })) .await .json(); let id = created["id"].as_str().unwrap(); - s.delete(&format!("/api/admin/scripts/{id}")) + s.delete(&format!("/api/v1/admin/scripts/{id}")) .await .assert_status(axum::http::StatusCode::NO_CONTENT); - s.get(&format!("/api/admin/scripts/{id}")) + s.get(&format!("/api/v1/admin/scripts/{id}")) .await .assert_status_not_found(); } @@ -166,7 +166,7 @@ async fn delete_then_get_returns_404(pool: PgPool) { #[sqlx::test(migrations = "../manager-core/migrations")] async fn get_nonexistent_returns_404(pool: PgPool) { let r = server(pool) - .get("/api/admin/scripts/00000000-0000-0000-0000-000000000000") + .get("/api/v1/admin/scripts/00000000-0000-0000-0000-000000000000") .await; r.assert_status_not_found(); } @@ -180,7 +180,7 @@ async fn get_nonexistent_returns_404(pool: PgPool) { async fn execute_echoes_body_back(pool: PgPool) { let s = server(pool); let created: Value = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "echo", "source": "#{ statusCode: 200, body: ctx.request.body }", @@ -190,7 +190,7 @@ async fn execute_echoes_body_back(pool: PgPool) { let id = created["id"].as_str().unwrap(); let r = s - .post(&format!("/api/execute/{id}")) + .post(&format!("/api/v1/execute/{id}")) .json(&json!({ "n": 42 })) .await; r.assert_status_ok(); @@ -203,7 +203,7 @@ async fn execute_echoes_body_back(pool: PgPool) { async fn execute_passes_through_status_and_headers(pool: PgPool) { let s = server(pool); let created: Value = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "header-test", "source": "#{ statusCode: 201, headers: #{ \"x-tag\": \"on\" }, body: 1 }", @@ -212,7 +212,10 @@ async fn execute_passes_through_status_and_headers(pool: PgPool) { .json(); let id = created["id"].as_str().unwrap(); - let r = s.post(&format!("/api/execute/{id}")).json(&json!({})).await; + let r = s + .post(&format!("/api/v1/execute/{id}")) + .json(&json!({})) + .await; r.assert_status(axum::http::StatusCode::CREATED); assert_eq!(r.header("x-tag"), "on"); } @@ -221,7 +224,7 @@ async fn execute_passes_through_status_and_headers(pool: PgPool) { #[sqlx::test(migrations = "../manager-core/migrations")] async fn execute_nonexistent_returns_404(pool: PgPool) { let r = server(pool) - .post("/api/execute/00000000-0000-0000-0000-000000000000") + .post("/api/v1/execute/00000000-0000-0000-0000-000000000000") .json(&json!({})) .await; r.assert_status_not_found(); @@ -232,7 +235,7 @@ async fn execute_nonexistent_returns_404(pool: PgPool) { async fn execution_logs_capture_invocations(pool: PgPool) { let s = server(pool); let created: Value = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "logger", "source": "log::info(\"called\", #{ marker: 7 }); #{ statusCode: 200, body: \"done\" }", @@ -242,22 +245,25 @@ async fn execution_logs_capture_invocations(pool: PgPool) { let id = created["id"].as_str().unwrap(); // No logs yet. - let r = s.get(&format!("/api/admin/scripts/{id}/logs")).await; + let r = s.get(&format!("/api/v1/admin/scripts/{id}/logs")).await; r.assert_status_ok(); let logs: Vec = r.json(); assert!(logs.is_empty()); // Two invocations. - s.post(&format!("/api/execute/{id}")) + s.post(&format!("/api/v1/execute/{id}")) .json(&json!({ "first": true })) .await .assert_status_ok(); - s.post(&format!("/api/execute/{id}")) + s.post(&format!("/api/v1/execute/{id}")) .json(&json!({ "second": true })) .await .assert_status_ok(); - let logs: Vec = s.get(&format!("/api/admin/scripts/{id}/logs")).await.json(); + let logs: Vec = s + .get(&format!("/api/v1/admin/scripts/{id}/logs")) + .await + .json(); assert_eq!(logs.len(), 2); // Most-recent-first ordering. @@ -282,7 +288,7 @@ async fn execution_logs_capture_invocations(pool: PgPool) { async fn execution_errors_are_still_logged(pool: PgPool) { let s = server(pool); let created: Value = s - .post("/api/admin/scripts") + .post("/api/v1/admin/scripts") .json(&json!({ "name": "boom", "source": "1 / 0", @@ -291,10 +297,16 @@ async fn execution_errors_are_still_logged(pool: PgPool) { .json(); let id = created["id"].as_str().unwrap(); - let r = s.post(&format!("/api/execute/{id}")).json(&json!({})).await; + let r = s + .post(&format!("/api/v1/execute/{id}")) + .json(&json!({})) + .await; r.assert_status(axum::http::StatusCode::BAD_GATEWAY); - let logs: Vec = s.get(&format!("/api/admin/scripts/{id}/logs")).await.json(); + let logs: Vec = s + .get(&format!("/api/v1/admin/scripts/{id}/logs")) + .await + .json(); assert_eq!(logs.len(), 1); assert_eq!(logs[0]["status"], "error"); assert!(logs[0]["response_body"]["error"].is_string()); diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index c45e783..25e83a8 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "picloud-shared" -version = "0.1.0" +version.workspace = true edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 64a7f41..8524217 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -10,6 +10,7 @@ pub mod ids; pub mod log_sink; pub mod script; pub mod validator; +pub mod version; pub use error::Error; pub use execution_log::{ExecutionLog, ExecutionStatus}; @@ -17,3 +18,4 @@ pub use ids::{ExecutionId, RequestId, ScriptId}; pub use log_sink::{ExecutionLogSink, LogSinkError}; pub use script::Script; pub use validator::{ScriptValidator, ValidationError}; +pub use version::{API_VERSION, PRODUCT_VERSION, SDK_VERSION, WIRE_VERSION}; diff --git a/crates/shared/src/version.rs b/crates/shared/src/version.rs new file mode 100644 index 0000000..b6996a3 --- /dev/null +++ b/crates/shared/src/version.rs @@ -0,0 +1,31 @@ +//! Version constants for PiCloud's compatibility surfaces. +//! +//! See [`docs/versioning.md`](../../../../docs/versioning.md) for the +//! full scheme. The product version is sourced from the workspace +//! package version; the four surface versions live in this module +//! and are bumped under the rules in that doc. + +/// Product version (e.g. `"0.2.0"`). Sourced from this crate's +/// `Cargo.toml` so the workspace-inherited package version is the +/// single point of update. +pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Rhai SDK version, in `"major.minor"` form. Scripts read this from +/// `ctx.sdk_version` for feature detection. Bump rules: +/// * patch (`1.0.x`): doc-only, no script-observable change +/// * minor (`1.0 → 1.1`): added functions / fields; existing +/// scripts must still run unchanged +/// * major (`1 → 2`): removed, renamed, retyped, restricted +pub const SDK_VERSION: &str = "1.0"; + +/// HTTP API major version. Appears in URL paths as `/api/v{N}/...`. +/// Bump (new integer + new URL prefix) when the request/response +/// shape, status-code semantics, or auth model changes. The previous +/// major is kept live for at least one product-minor deprecation +/// window. +pub const API_VERSION: u32 = 1; + +/// Wire-protocol version between manager / orchestrator / executor +/// nodes in cluster mode. Negotiated via the `X-PiCloud-Wire` header +/// on inter-service requests. Reserved at `1`; cluster mode is v1.3+. +pub const WIRE_VERSION: u32 = 1; diff --git a/dashboard/package.json b/dashboard/package.json index 82f111d..c074713 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -1,6 +1,6 @@ { "name": "picloud-dashboard", - "version": "0.1.0", + "version": "0.2.0", "private": true, "type": "module", "scripts": { diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts index d20c0ce..fcb7f0c 100644 --- a/dashboard/src/lib/api.ts +++ b/dashboard/src/lib/api.ts @@ -1,7 +1,7 @@ // Thin client for the PiCloud control-plane and data-plane APIs. // -// The dashboard primarily targets `/api/admin/*` (manager). The -// data-plane (`/api/execute/*`, orchestrator) is reachable through +// The dashboard primarily targets `/api/v1/admin/*` (manager). The +// data-plane (`/api/v1/execute/*`, orchestrator) is reachable through // the same Caddy upstream so the "Test invoke" panel can hit it // without any cross-origin gymnastics. @@ -102,27 +102,27 @@ export const api = { health: () => fetch('/healthz').then((r) => r.text()), scripts: { - list: () => adminRequest('/api/admin/scripts'), - get: (id: string) => adminRequest