feat: declarative project-tool foundation (pull/plan/apply/prune)
Add a server-side, atomic, declarative reconcile loop for a single app —
the foundation of the project-tool design. Developers describe an app's
scripts, routes, triggers, and secret-names in `picloud.toml`, then
`pic pull / plan / apply [--prune]` to converge live state to the manifest.
Server (manager-core):
- apply_service: a pure diff engine (compute_diff) shared by plan and
apply, plus an ApplyService that composes the existing per-repo writes
into ONE Postgres transaction. Identity keys mirror the DB UNIQUE
constraints (script=lower(name); route=(method,host_kind,host,
path_kind,path); trigger=per-kind semantic tuple; secret=name).
Apply takes a per-app advisory lock, recomputes the diff in-tx, applies
scripts -> routes -> triggers, prunes dependents-first, commits, then
refreshes the route table once post-commit.
- apply_api: POST /apps/{id}/plan (AppRead) and /apps/{id}/apply.
Apply requires the per-kind write caps the bundle exercises (all three
when --prune), plus AppSecretsRead when it binds an email trigger.
- tx-accepting repo siblings (insert/update/delete *_tx) so the existing
create/update/delete delegate to one SQL definition each.
- email triggers reference an inbound secret by NAME; the value is
resolved, decrypted (AAD-bound), and re-sealed server-side at apply —
it never travels in the manifest.
CLI (picloud-cli):
- manifest.rs (picloud.toml model), client plan/apply, and the pull/plan/
apply commands. pull rejects filesystem-unsafe script names up front.
Safety properties enforced and tested:
- idempotent: a freshly-pulled manifest re-applies as all-NoOp.
- atomic: a mid-bundle failure rolls back with nothing written.
- routes delete-before-insert so a freed binding is reusable in one apply.
- queue one-consumer invariant held inside the shared tx.
- email triggers are never pruned, and a script that still owns an
email/dead-letter trigger can't be pruned (the FK cascade would destroy
the sealed secret) — refused with a pointer to `pic triggers rm`.
- plan and apply agree on unset email-secret references.
No migration: the existing schema's UNIQUE constraints serve as identity
keys. Groups, env-scoping, and the `enabled` toggle are later milestones.
Tested: manager-core lib (360) + CLI bins (27) + 8 project-tool journeys
(pull/plan/apply/prune/email+queue), all green; clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
362
crates/picloud-cli/src/manifest.rs
Normal file
362
crates/picloud-cli/src/manifest.rs
Normal file
@@ -0,0 +1,362 @@
|
||||
//! Declarative project manifest (`picloud.toml`).
|
||||
//!
|
||||
//! One manifest describes the desired state of a **single app** — its
|
||||
//! scripts, routes, triggers, and the *names* of the secrets it expects
|
||||
//! (values are pushed out-of-band via `pic secret set`, never committed).
|
||||
//!
|
||||
//! This is the foundation of the declarative project tool (`pic pull` /
|
||||
//! `pic plan` / `pic apply`). The types deliberately reuse `picloud_shared`
|
||||
//! enums (`HostKind`, `PathKind`, `DispatchMode`, `ScriptKind`,
|
||||
//! `ScriptSandbox`, the event-op enums) so the manifest's wire shape stays
|
||||
//! identical to the admin API — the CLI never depends on `manager-core`.
|
||||
//!
|
||||
//! All eight trigger kinds are representable except `dead_letter` (not
|
||||
//! exposed declaratively). `email` triggers carry an `inbound_secret_ref`
|
||||
//! (a secret name) resolved server-side at apply.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::{
|
||||
DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, PathKind, ScriptKind,
|
||||
ScriptSandbox,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Conventional manifest filename at a project root.
|
||||
pub const MANIFEST_FILE: &str = "picloud.toml";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Manifest {
|
||||
pub app: ManifestApp,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub scripts: Vec<ManifestScript>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub routes: Vec<ManifestRoute>,
|
||||
#[serde(default, skip_serializing_if = "ManifestTriggers::is_empty")]
|
||||
pub triggers: ManifestTriggers,
|
||||
#[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")]
|
||||
pub secrets: ManifestSecrets,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Parse a manifest from TOML text.
|
||||
pub fn parse(text: &str) -> Result<Self> {
|
||||
toml::from_str(text).context("parsing manifest TOML")
|
||||
}
|
||||
|
||||
/// Load and parse the manifest at `path`.
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let body =
|
||||
fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
|
||||
Self::parse(&body)
|
||||
}
|
||||
|
||||
/// Render to TOML text. Tables are emitted after scalars (the struct
|
||||
/// field order already satisfies TOML's "values before tables" rule).
|
||||
pub fn to_toml(&self) -> Result<String> {
|
||||
toml::to_string_pretty(self).context("serializing manifest TOML")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestApp {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestScript {
|
||||
pub name: String,
|
||||
/// Path to the `.rhai` source, relative to the manifest's directory.
|
||||
pub file: String,
|
||||
#[serde(default, skip_serializing_if = "is_endpoint")]
|
||||
pub kind: ScriptKind,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub timeout_seconds: Option<i32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_limit_mb: Option<i32>,
|
||||
/// Per-script sandbox overrides; omitted entirely when no knob is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sandbox: Option<ScriptSandbox>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestRoute {
|
||||
/// Name of the script this route binds to.
|
||||
pub script: String,
|
||||
/// HTTP method; omit for ANY.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub host: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
#[serde(default, skip_serializing_if = "is_sync")]
|
||||
pub dispatch_mode: DispatchMode,
|
||||
}
|
||||
|
||||
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestTriggers {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub kv: Vec<KvTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub docs: Vec<DocsTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub files: Vec<FilesTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cron: Vec<CronTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pubsub: Vec<PubsubTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub email: Vec<EmailTriggerSpec>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub queue: Vec<QueueTriggerSpec>,
|
||||
}
|
||||
|
||||
impl ManifestTriggers {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.kv.is_empty()
|
||||
&& self.docs.is_empty()
|
||||
&& self.files.is_empty()
|
||||
&& self.cron.is_empty()
|
||||
&& self.pubsub.is_empty()
|
||||
&& self.email.is_empty()
|
||||
&& self.queue.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct KvTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<KvEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DocsTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<DocsEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FilesTriggerSpec {
|
||||
pub script: String,
|
||||
pub collection_glob: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ops: Vec<FilesEventOp>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CronTriggerSpec {
|
||||
pub script: String,
|
||||
/// 6-field cron expression (with seconds).
|
||||
pub schedule: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
pub timezone: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PubsubTriggerSpec {
|
||||
pub script: String,
|
||||
pub topic_pattern: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct EmailTriggerSpec {
|
||||
pub script: String,
|
||||
/// Name of the secret (set via `pic secret set`) holding the inbound
|
||||
/// HMAC value — resolved + sealed server-side at apply. Never the value.
|
||||
pub inbound_secret_ref: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct QueueTriggerSpec {
|
||||
pub script: String,
|
||||
pub queue_name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub visibility_timeout_secs: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub dispatch_mode: Option<DispatchMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub retry_max_attempts: Option<u32>,
|
||||
}
|
||||
|
||||
/// `[secrets] names = [...]` — declares which secrets the app expects.
|
||||
/// Values are never in the manifest; `pic secret set` pushes them.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ManifestSecrets {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
impl ManifestSecrets {
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.names.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serde skip/default helpers ----
|
||||
|
||||
fn is_endpoint(kind: &ScriptKind) -> bool {
|
||||
*kind == ScriptKind::Endpoint
|
||||
}
|
||||
|
||||
fn is_sync(mode: &DispatchMode) -> bool {
|
||||
*mode == DispatchMode::Sync
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Manifest {
|
||||
Manifest {
|
||||
app: ManifestApp {
|
||||
slug: "blog".into(),
|
||||
name: "My Blog".into(),
|
||||
description: Some("demo".into()),
|
||||
},
|
||||
scripts: vec![
|
||||
ManifestScript {
|
||||
name: "create-post".into(),
|
||||
file: "scripts/create-post.rhai".into(),
|
||||
kind: ScriptKind::Endpoint,
|
||||
description: None,
|
||||
timeout_seconds: Some(10),
|
||||
memory_limit_mb: Some(256),
|
||||
sandbox: None,
|
||||
},
|
||||
ManifestScript {
|
||||
name: "lib".into(),
|
||||
file: "scripts/lib.rhai".into(),
|
||||
kind: ScriptKind::Module,
|
||||
description: None,
|
||||
timeout_seconds: None,
|
||||
memory_limit_mb: None,
|
||||
sandbox: Some(ScriptSandbox {
|
||||
max_operations: Some(5_000_000),
|
||||
..ScriptSandbox::empty()
|
||||
}),
|
||||
},
|
||||
],
|
||||
routes: vec![ManifestRoute {
|
||||
script: "create-post".into(),
|
||||
method: Some("POST".into()),
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/posts".into(),
|
||||
dispatch_mode: DispatchMode::Sync,
|
||||
}],
|
||||
triggers: ManifestTriggers {
|
||||
cron: vec![CronTriggerSpec {
|
||||
script: "create-post".into(),
|
||||
schedule: "0 6 * * * *".into(),
|
||||
timezone: "UTC".into(),
|
||||
dispatch_mode: None,
|
||||
retry_max_attempts: None,
|
||||
}],
|
||||
kv: vec![KvTriggerSpec {
|
||||
script: "create-post".into(),
|
||||
collection_glob: "users".into(),
|
||||
ops: vec![KvEventOp::Insert, KvEventOp::Update],
|
||||
dispatch_mode: Some(DispatchMode::Async),
|
||||
retry_max_attempts: Some(5),
|
||||
}],
|
||||
..ManifestTriggers::default()
|
||||
},
|
||||
secrets: ManifestSecrets {
|
||||
names: vec!["STRIPE_KEY".into()],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_through_toml() {
|
||||
let m = sample();
|
||||
let text = m.to_toml().expect("serialize");
|
||||
let back = Manifest::parse(&text).expect("parse");
|
||||
assert_eq!(m, back, "manifest must survive a TOML round-trip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_defaulted_fields() {
|
||||
let text = sample().to_toml().unwrap();
|
||||
// Endpoint kind + sync dispatch are defaults → not emitted.
|
||||
assert!(
|
||||
!text.contains("kind = \"endpoint\""),
|
||||
"default kind should be omitted:\n{text}"
|
||||
);
|
||||
assert!(
|
||||
!text.contains("dispatch_mode = \"sync\""),
|
||||
"default route dispatch should be omitted:\n{text}"
|
||||
);
|
||||
// Module kind IS non-default → emitted.
|
||||
assert!(text.contains("kind = \"module\""), "got:\n{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_optional_sections_omitted() {
|
||||
let m = Manifest {
|
||||
app: ManifestApp {
|
||||
slug: "x".into(),
|
||||
name: "X".into(),
|
||||
description: None,
|
||||
},
|
||||
scripts: vec![],
|
||||
routes: vec![],
|
||||
triggers: ManifestTriggers::default(),
|
||||
secrets: ManifestSecrets::default(),
|
||||
};
|
||||
let text = m.to_toml().unwrap();
|
||||
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
|
||||
assert!(!text.contains("triggers"), "got:\n{text}");
|
||||
assert!(!text.contains("secrets"), "got:\n{text}");
|
||||
// Still round-trips.
|
||||
assert_eq!(m, Manifest::parse(&text).unwrap());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user