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:
@@ -900,6 +900,36 @@ impl Client {
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/plan` — diff a desired-state
|
||||
/// bundle against the app's live state. Read-only.
|
||||
pub async fn plan(&self, app: &str, bundle: &serde_json::Value) -> Result<PlanDto> {
|
||||
let app = seg(app);
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/plan"))
|
||||
.json(bundle)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/apps/{id_or_slug}/apply` — reconcile the live
|
||||
/// app to the bundle in one transaction.
|
||||
pub async fn apply(
|
||||
&self,
|
||||
app: &str,
|
||||
bundle: &serde_json::Value,
|
||||
prune: bool,
|
||||
) -> Result<ApplyReportDto> {
|
||||
let app = seg(app);
|
||||
let body = serde_json::json!({ "bundle": bundle, "prune": prune });
|
||||
let resp = self
|
||||
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/apply"))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -924,6 +954,50 @@ pub async fn auth_login(url: &str, username: &str, password: &str) -> Result<Log
|
||||
|
||||
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
|
||||
|
||||
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PlanDto {
|
||||
#[serde(default)]
|
||||
pub scripts: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub triggers: Vec<ChangeDto>,
|
||||
#[serde(default)]
|
||||
pub secrets: Vec<ChangeDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChangeDto {
|
||||
pub op: String,
|
||||
pub key: String,
|
||||
#[serde(default)]
|
||||
pub detail: Option<String>,
|
||||
}
|
||||
|
||||
/// Response of `POST .../apply`: counts of what changed.
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct ApplyReportDto {
|
||||
#[serde(default)]
|
||||
pub scripts_created: u32,
|
||||
#[serde(default)]
|
||||
pub scripts_updated: u32,
|
||||
#[serde(default)]
|
||||
pub scripts_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub routes_created: u32,
|
||||
#[serde(default)]
|
||||
pub routes_updated: u32,
|
||||
#[serde(default)]
|
||||
pub routes_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub triggers_created: u32,
|
||||
#[serde(default)]
|
||||
pub triggers_deleted: u32,
|
||||
#[serde(default)]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AuthMeDto {
|
||||
|
||||
52
crates/picloud-cli/src/cmds/apply.rs
Normal file
52
crates/picloud-cli/src/cmds/apply.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
//! `pic apply [--file picloud.toml]` — reconcile the live app to the
|
||||
//! manifest's desired state in one server-side transaction. Additive in
|
||||
//! this milestone (creates + updates); pruning of stale resources lands
|
||||
//! next.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::cmds::plan::build_bundle;
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(manifest_path: &Path, prune: bool, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load(manifest_path)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let report = client.apply(&manifest.app.slug, &bundle, prune).await?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field(
|
||||
"scripts",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.scripts_created, report.scripts_updated, report.scripts_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"routes",
|
||||
format!(
|
||||
"+{} ~{} -{}",
|
||||
report.routes_created, report.routes_updated, report.routes_deleted
|
||||
),
|
||||
)
|
||||
.field(
|
||||
"triggers",
|
||||
format!("+{} -{}", report.triggers_created, report.triggers_deleted),
|
||||
);
|
||||
for w in &report.warnings {
|
||||
block.field("warning", w.clone());
|
||||
}
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod admins;
|
||||
pub mod api_keys;
|
||||
pub mod apply;
|
||||
pub mod apps;
|
||||
pub mod apps_domains;
|
||||
pub mod dead_letters;
|
||||
@@ -9,6 +10,8 @@ pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod members;
|
||||
pub mod plan;
|
||||
pub mod pull;
|
||||
pub mod queues;
|
||||
pub mod routes;
|
||||
pub mod scripts;
|
||||
|
||||
123
crates/picloud-cli/src/cmds/plan.rs
Normal file
123
crates/picloud-cli/src/cmds/plan.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
//! `pic plan [--file picloud.toml]` — diff the manifest's desired state
|
||||
//! against the live app and print the per-resource changes. Read-only:
|
||||
//! builds a bundle (manifest + script sources) and POSTs it to the
|
||||
//! server's plan endpoint, which computes the diff.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::client::{ChangeDto, Client, PlanDto};
|
||||
use crate::config;
|
||||
use crate::manifest::Manifest;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(manifest_path: &Path, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let manifest = Manifest::load(manifest_path)?;
|
||||
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let bundle = build_bundle(&manifest, base_dir)?;
|
||||
|
||||
let plan = client.plan(&manifest.app.slug, &bundle).await?;
|
||||
render(&plan, mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Assemble the wire bundle: scripts carry inlined source (read from
|
||||
/// their `file`), routes pass through, triggers flatten into a tagged
|
||||
/// array, secrets are names only.
|
||||
pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
|
||||
let mut scripts = Vec::with_capacity(manifest.scripts.len());
|
||||
for s in &manifest.scripts {
|
||||
let source = std::fs::read_to_string(base_dir.join(&s.file))
|
||||
.with_context(|| format!("reading script source {}", s.file))?;
|
||||
let mut obj = Map::new();
|
||||
obj.insert("name".into(), json!(s.name));
|
||||
obj.insert("source".into(), json!(source));
|
||||
obj.insert("kind".into(), serde_json::to_value(s.kind)?);
|
||||
if let Some(d) = &s.description {
|
||||
obj.insert("description".into(), json!(d));
|
||||
}
|
||||
if let Some(t) = s.timeout_seconds {
|
||||
obj.insert("timeout_seconds".into(), json!(t));
|
||||
}
|
||||
if let Some(m) = s.memory_limit_mb {
|
||||
obj.insert("memory_limit_mb".into(), json!(m));
|
||||
}
|
||||
if let Some(sb) = &s.sandbox {
|
||||
obj.insert("sandbox".into(), serde_json::to_value(sb)?);
|
||||
}
|
||||
scripts.push(Value::Object(obj));
|
||||
}
|
||||
|
||||
let routes = manifest
|
||||
.routes
|
||||
.iter()
|
||||
.map(serde_json::to_value)
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
let t = &manifest.triggers;
|
||||
let mut triggers = Vec::new();
|
||||
for s in &t.kv {
|
||||
triggers.push(tagged("kv", s)?);
|
||||
}
|
||||
for s in &t.docs {
|
||||
triggers.push(tagged("docs", s)?);
|
||||
}
|
||||
for s in &t.files {
|
||||
triggers.push(tagged("files", s)?);
|
||||
}
|
||||
for s in &t.cron {
|
||||
triggers.push(tagged("cron", s)?);
|
||||
}
|
||||
for s in &t.pubsub {
|
||||
triggers.push(tagged("pubsub", s)?);
|
||||
}
|
||||
for s in &t.email {
|
||||
triggers.push(tagged("email", s)?);
|
||||
}
|
||||
for s in &t.queue {
|
||||
triggers.push(tagged("queue", s)?);
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"scripts": scripts,
|
||||
"routes": routes,
|
||||
"triggers": triggers,
|
||||
"secrets": manifest.secrets.names,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Serialize a trigger spec and stamp its `kind` discriminator.
|
||||
fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
|
||||
let mut v = serde_json::to_value(spec)?;
|
||||
if let Value::Object(map) = &mut v {
|
||||
map.insert("kind".into(), Value::String(kind.to_string()));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn render(plan: &PlanDto, mode: OutputMode) {
|
||||
let mut table = Table::new(["kind", "op", "resource", "detail"]);
|
||||
let groups: [(&str, &Vec<ChangeDto>); 4] = [
|
||||
("script", &plan.scripts),
|
||||
("route", &plan.routes),
|
||||
("trigger", &plan.triggers),
|
||||
("secret", &plan.secrets),
|
||||
];
|
||||
for (kind, changes) in groups {
|
||||
for c in changes {
|
||||
table.row([
|
||||
kind.to_string(),
|
||||
c.op.clone(),
|
||||
c.key.clone(),
|
||||
c.detail.clone().unwrap_or_default(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
table.print(mode);
|
||||
}
|
||||
285
crates/picloud-cli/src/cmds/pull.rs
Normal file
285
crates/picloud-cli/src/cmds/pull.rs
Normal file
@@ -0,0 +1,285 @@
|
||||
//! `pic pull <app> [--dir .]` — export an app's current server state into
|
||||
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
|
||||
//! declarative management with `pic plan` / `pic apply`.
|
||||
//!
|
||||
//! Read-only: issues `GET`s only and writes local files. Every trigger
|
||||
//! kind is exported except `email` — the server stores the *sealed secret
|
||||
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
|
||||
//! can't be reconstructed (email triggers must be set up by hand).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
|
||||
QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
// One GET per resource kind (routes are per-script, below).
|
||||
let app = client.apps_get(app_ident).await?;
|
||||
let scripts = client.scripts_list_by_app(app_ident).await?;
|
||||
let triggers = client.triggers_list(app_ident).await?.triggers;
|
||||
let secrets = client.secrets_list(app_ident).await?.secrets;
|
||||
|
||||
let name_by_id: HashMap<ScriptId, String> =
|
||||
scripts.iter().map(|s| (s.id, s.name.clone())).collect();
|
||||
|
||||
// Routes: the admin surface lists them per script.
|
||||
let mut routes = Vec::new();
|
||||
for s in &scripts {
|
||||
for r in client.routes_list_for_script(&s.id.to_string()).await? {
|
||||
routes.push(ManifestRoute {
|
||||
script: s.name.clone(),
|
||||
method: r.method,
|
||||
host_kind: r.host_kind,
|
||||
host: r.host,
|
||||
host_param_name: r.host_param_name,
|
||||
path_kind: r.path_kind,
|
||||
path: r.path,
|
||||
dispatch_mode: r.dispatch_mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The server does not constrain script names to a filesystem-safe
|
||||
// charset, so a name containing a path separator or `..` would let `pull`
|
||||
// write outside the project dir. Validate ALL names up front, before any
|
||||
// file is written, so a single bad name can't leave a half-written dir.
|
||||
// Reject rather than sanitize: a silent rename would desync the manifest
|
||||
// `name` from its `file`.
|
||||
for s in &scripts {
|
||||
if !is_safe_filename(&s.name) {
|
||||
anyhow::bail!(
|
||||
"script name {:?} is not filesystem-safe (contains a path \
|
||||
separator, `..`, or a leading dot); cannot pull",
|
||||
s.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scripts: write each source next to the manifest and record a path ref.
|
||||
let scripts_dir = dir.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir)
|
||||
.with_context(|| format!("creating {}", scripts_dir.display()))?;
|
||||
let mut manifest_scripts = Vec::with_capacity(scripts.len());
|
||||
for s in &scripts {
|
||||
let rel = format!("scripts/{}.rhai", s.name);
|
||||
std::fs::write(dir.join(&rel), &s.source).with_context(|| format!("writing {rel}"))?;
|
||||
manifest_scripts.push(ManifestScript {
|
||||
name: s.name.clone(),
|
||||
file: rel,
|
||||
kind: s.kind,
|
||||
description: s.description.clone(),
|
||||
timeout_seconds: i32::try_from(s.timeout_seconds).ok(),
|
||||
memory_limit_mb: i32::try_from(s.memory_limit_mb).ok(),
|
||||
sandbox: if s.sandbox.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.sandbox)
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Triggers: map the five settled kinds; warn + skip the rest.
|
||||
let mut manifest_triggers = ManifestTriggers::default();
|
||||
let mut skipped: Vec<String> = Vec::new();
|
||||
for t in &triggers {
|
||||
let script = name_by_id
|
||||
.get(&t.script_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| t.script_id.to_string());
|
||||
let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
|
||||
let retry_max_attempts = Some(t.retry_max_attempts);
|
||||
match t.kind.as_str() {
|
||||
"kv" => {
|
||||
let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.kv.push(KvTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"docs" => {
|
||||
let d: CollectionDetails<DocsEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.docs.push(DocsTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"files" => {
|
||||
let d: CollectionDetails<FilesEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.files.push(FilesTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"cron" => {
|
||||
let d: CronDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.cron.push(CronTriggerSpec {
|
||||
script,
|
||||
schedule: d.schedule,
|
||||
timezone: d.timezone,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"pubsub" => {
|
||||
let d: PubsubDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.pubsub.push(PubsubTriggerSpec {
|
||||
script,
|
||||
topic_pattern: d.topic_pattern,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
"queue" => {
|
||||
let d: QueueDetails = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.queue.push(QueueTriggerSpec {
|
||||
script,
|
||||
queue_name: d.queue_name,
|
||||
visibility_timeout_secs: Some(d.visibility_timeout_secs),
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
});
|
||||
}
|
||||
// `email` is skipped: the server stores the sealed secret value,
|
||||
// not the secret *name*, so the manifest's `inbound_secret_ref`
|
||||
// can't be reconstructed — set it up by hand.
|
||||
other => skipped.push(format!("{other} ({})", t.id)),
|
||||
}
|
||||
}
|
||||
for s in &skipped {
|
||||
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
app: ManifestApp {
|
||||
slug: app.app.slug.clone(),
|
||||
name: app.app.name.clone(),
|
||||
description: app.app.description.clone(),
|
||||
},
|
||||
scripts: manifest_scripts,
|
||||
routes,
|
||||
triggers: manifest_triggers,
|
||||
secrets: ManifestSecrets {
|
||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||
},
|
||||
};
|
||||
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
||||
.with_context(|| format!("writing {}", manifest_path.display()))?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("manifest", manifest_path.display().to_string())
|
||||
.field("app", manifest.app.slug.clone())
|
||||
.field("scripts", manifest.scripts.len().to_string())
|
||||
.field("routes", manifest.routes.len().to_string())
|
||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
||||
.field("secrets", manifest.secrets.names.len().to_string());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trigger_count(t: &ManifestTriggers) -> usize {
|
||||
t.kv.len() + t.docs.len() + t.files.len() + t.cron.len() + t.pubsub.len() + t.queue.len()
|
||||
}
|
||||
|
||||
/// True if `name` is safe to use as a single path component in `scripts/`.
|
||||
/// Rejects empty names, path separators, `.`/`..`, and leading dots.
|
||||
fn is_safe_filename(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& !name.starts_with('.')
|
||||
&& !name.contains('/')
|
||||
&& !name.contains('\\')
|
||||
&& name != ".."
|
||||
&& !name.contains('\0')
|
||||
}
|
||||
|
||||
/// Deserialize a trigger's `details` JSON, attributing failures to the kind.
|
||||
/// The server tags details with a `kind` field which these structs ignore.
|
||||
fn decode_details<T: for<'de> Deserialize<'de>>(
|
||||
details: &serde_json::Value,
|
||||
kind: &str,
|
||||
) -> Result<T> {
|
||||
serde_json::from_value(details.clone())
|
||||
.with_context(|| format!("decoding {kind} trigger details"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CollectionDetails<Op> {
|
||||
collection_glob: String,
|
||||
#[serde(default = "Vec::new")]
|
||||
ops: Vec<Op>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CronDetails {
|
||||
schedule: String,
|
||||
#[serde(default = "default_timezone")]
|
||||
timezone: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PubsubDetails {
|
||||
topic_pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct QueueDetails {
|
||||
queue_name: String,
|
||||
visibility_timeout_secs: u32,
|
||||
}
|
||||
|
||||
fn default_timezone() -> String {
|
||||
"UTC".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_safe_filename;
|
||||
|
||||
#[test]
|
||||
fn rejects_traversal_and_separators() {
|
||||
for bad in [
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../etc/passwd",
|
||||
"a/b",
|
||||
"a\\b",
|
||||
".hidden",
|
||||
"with\0nul",
|
||||
] {
|
||||
assert!(!is_safe_filename(bad), "expected {bad:?} to be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_names() {
|
||||
for ok in ["create-post", "nightly_digest", "Greet", "x", "a.b"] {
|
||||
assert!(is_safe_filename(ok), "expected {ok:?} to be accepted");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||
mod client;
|
||||
mod cmds;
|
||||
mod config;
|
||||
mod manifest;
|
||||
mod output;
|
||||
|
||||
use crate::output::OutputMode;
|
||||
@@ -156,6 +157,46 @@ enum Cmd {
|
||||
#[command(subcommand)]
|
||||
cmd: KvCmd,
|
||||
},
|
||||
|
||||
/// Reconcile the live app to a `picloud.toml` manifest in one
|
||||
/// transaction (additive: creates + updates).
|
||||
Apply(ApplyArgs),
|
||||
|
||||
/// Diff a `picloud.toml` manifest against the live app and print the
|
||||
/// changes (create / update / no-op / delete). Read-only.
|
||||
Plan(PlanArgs),
|
||||
|
||||
/// Export an app's current server state into a `picloud.toml` manifest
|
||||
/// (+ `scripts/<name>.rhai` sources) for declarative management with
|
||||
/// `pic plan` / `pic apply`.
|
||||
Pull(PullArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct ApplyArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
/// Delete live scripts/routes/triggers absent from the manifest.
|
||||
/// Secrets are never pruned.
|
||||
#[arg(long)]
|
||||
prune: bool,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct PlanArgs {
|
||||
/// Path to the manifest.
|
||||
#[arg(long, default_value = "picloud.toml")]
|
||||
file: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct PullArgs {
|
||||
/// App slug or id to export.
|
||||
app: String,
|
||||
/// Directory to write `picloud.toml` + `scripts/` into.
|
||||
#[arg(long, default_value = ".")]
|
||||
dir: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -1045,6 +1086,9 @@ async fn main() -> ExitCode {
|
||||
}
|
||||
Cmd::Logout => cmds::logout::run().await,
|
||||
Cmd::Whoami => cmds::whoami::run(mode).await,
|
||||
Cmd::Apply(args) => cmds::apply::run(&args.file, args.prune, mode).await,
|
||||
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
|
||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
||||
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
||||
Cmd::Apps {
|
||||
cmd:
|
||||
|
||||
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