`shared = true` on a group [[triggers.kv|docs|files]] flows manifest → Bundle → insert_trigger_tx (new shared column), mirroring sealed: shared lives on the Trigger DTO + is part of the apply diff identity (bundle + current sides) so a toggle re-applies. validate_bundle_for rejects shared on an app owner, on a non-collection kind (pubsub/cron/etc.), and on a concrete collection the group does not declare as a shared collection of that kind. Emission (M2.3) now has authored triggers to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
918 lines
35 KiB
Rust
918 lines
35 KiB
Rust
//! 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::collections::BTreeMap;
|
|
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)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct Manifest {
|
|
/// An app node declares `[app]`; a group node declares `[group]` (Phase 5).
|
|
/// Exactly one is present (enforced by [`Manifest::parse`]).
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub app: Option<ManifestApp>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub group: Option<ManifestGroup>,
|
|
#[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,
|
|
/// `[vars]` — app config key → value, reconciled at app scope `*`. Values
|
|
/// are non-secret and live inline (unlike `[secrets]`, which is name-only).
|
|
/// The overlay merges per-env, last-write-wins by key.
|
|
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
|
pub vars: BTreeMap<String, toml::Value>,
|
|
/// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates.
|
|
/// App-only; a `[group]` carrying it is rejected in [`Manifest::parse`].
|
|
#[serde(default, skip_serializing_if = "ManifestSuppress::is_empty")]
|
|
pub suppress: ManifestSuppress,
|
|
}
|
|
|
|
impl Manifest {
|
|
/// Parse a manifest from TOML text. Enforces the app-XOR-group invariant.
|
|
pub fn parse(text: &str) -> Result<Self> {
|
|
let m: Self = toml::from_str(text).context("parsing manifest TOML")?;
|
|
match (&m.app, &m.group) {
|
|
(Some(_), None) | (None, Some(_)) => {}
|
|
(Some(_), Some(_)) => {
|
|
anyhow::bail!(
|
|
"manifest declares both [app] and [group]; a node is one or the other"
|
|
)
|
|
}
|
|
(None, None) => {
|
|
anyhow::bail!("manifest declares neither [app] nor [group]")
|
|
}
|
|
}
|
|
// A group node owns scripts + vars (+ secret names) and, as of §11 tail,
|
|
// ROUTE TEMPLATES + EVENT trigger TEMPLATES (kv/docs/files/pubsub) that
|
|
// fan out live to descendant apps. As of §11 tail M1 a group may also
|
|
// declare `[suppress]` to decline a template it inherits from a higher
|
|
// ancestor, for its whole subtree. The stateful trigger kinds
|
|
// (cron/queue/email) stay app-only — they need per-app state/secrets →
|
|
// materialization.
|
|
if m.group.is_some() {
|
|
let t = &m.triggers;
|
|
if !t.cron.is_empty() || !t.queue.is_empty() || !t.email.is_empty() {
|
|
anyhow::bail!(
|
|
"a [group] trigger template must be an event kind \
|
|
(kv, docs, files, pubsub); cron/queue/email are app-only"
|
|
);
|
|
}
|
|
}
|
|
Ok(m)
|
|
}
|
|
|
|
/// This node's slug (app or group).
|
|
#[must_use]
|
|
pub fn slug(&self) -> &str {
|
|
match (&self.app, &self.group) {
|
|
(Some(a), _) => &a.slug,
|
|
(_, Some(g)) => &g.slug,
|
|
_ => "",
|
|
}
|
|
}
|
|
|
|
/// True iff this manifest declares a `[group]` node (Phase 5).
|
|
#[must_use]
|
|
pub fn is_group(&self) -> bool {
|
|
self.group.is_some()
|
|
}
|
|
|
|
/// This node's declared extension-point names (§5.5), from whichever of
|
|
/// `[app]` / `[group]` is present.
|
|
#[must_use]
|
|
pub fn extension_points(&self) -> &[String] {
|
|
match (&self.app, &self.group) {
|
|
(Some(a), _) => &a.extension_points,
|
|
(_, Some(g)) => &g.extension_points,
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
/// This node's declared shared group collections (§11.6), normalized to
|
|
/// `(name, kind)` pairs. Authored on `[group]` nodes only — an `[app]`
|
|
/// manifest carrying `collections` is a hard parse error (`ManifestApp` has
|
|
/// no such field + `deny_unknown_fields`).
|
|
#[must_use]
|
|
pub fn collections(&self) -> Vec<(String, String)> {
|
|
match &self.group {
|
|
Some(g) => g
|
|
.collections
|
|
.iter()
|
|
.map(CollectionDecl::normalized)
|
|
.collect(),
|
|
None => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// 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")
|
|
}
|
|
|
|
/// Load the base manifest, then (if `env` is set) merge the sparse
|
|
/// `picloud.<env>.toml` overlay on top — the §4.1 base+overlay model
|
|
/// where "an environment is an app". The overlay carries per-env slug,
|
|
/// secrets, and vars (overlay vars win per key); scripts/routes/triggers
|
|
/// stay in the shared base.
|
|
pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result<Self> {
|
|
let mut base = Self::load(base_path)?;
|
|
if let Some(env) = env {
|
|
let path = overlay_path(base_path, env);
|
|
let body = fs::read_to_string(&path).with_context(|| {
|
|
format!(
|
|
"reading overlay {} for env `{env}` (expected next to the base manifest)",
|
|
path.display()
|
|
)
|
|
})?;
|
|
let overlay: ManifestOverlay = toml::from_str(&body)
|
|
.with_context(|| format!("parsing overlay {}", path.display()))?;
|
|
base.apply_overlay(overlay);
|
|
}
|
|
Ok(base)
|
|
}
|
|
|
|
/// Merge a sparse overlay onto this manifest: overlay `app.slug`/`name`
|
|
/// replace the base's; overlay secret names union into the base set.
|
|
fn apply_overlay(&mut self, overlay: ManifestOverlay) {
|
|
if let Some(app) = &mut self.app {
|
|
if let Some(slug) = overlay.app.slug {
|
|
app.slug = slug;
|
|
}
|
|
if let Some(name) = overlay.app.name {
|
|
app.name = name;
|
|
}
|
|
}
|
|
for n in overlay.secrets.names {
|
|
if !self.secrets.names.contains(&n) {
|
|
self.secrets.names.push(n);
|
|
}
|
|
}
|
|
// Overlay vars override base vars per key (the env-specific value of
|
|
// an env-agnostic default); keys only in the base are kept.
|
|
for (k, v) in overlay.vars {
|
|
self.vars.insert(k, v);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// `picloud.toml` → `picloud.<env>.toml`, alongside the base (works for a
|
|
/// custom `--file` too: `custom.toml` → `custom.<env>.toml`).
|
|
fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf {
|
|
let parent = base_path.parent().unwrap_or_else(|| Path::new("."));
|
|
let name = base_path
|
|
.file_name()
|
|
.map(|n| n.to_string_lossy().into_owned())
|
|
.unwrap_or_else(|| MANIFEST_FILE.to_string());
|
|
let stem = name.strip_suffix(".toml").unwrap_or(&name);
|
|
parent.join(format!("{stem}.{env}.toml"))
|
|
}
|
|
|
|
/// A sparse per-environment overlay (`picloud.<env>.toml`). Only the fields
|
|
/// that vary per environment today — slug/name and secret names.
|
|
///
|
|
/// `deny_unknown_fields`: an overlay can carry *only* `[app]`, `[secrets]`,
|
|
/// and `[vars]`. Scripts/routes/triggers belong in the shared base manifest,
|
|
/// so a `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in
|
|
/// an overlay is a mistake — error loudly rather than silently dropping it.
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ManifestOverlay {
|
|
#[serde(default)]
|
|
pub app: OverlayApp,
|
|
#[serde(default)]
|
|
pub secrets: ManifestSecrets,
|
|
#[serde(default)]
|
|
pub vars: BTreeMap<String, toml::Value>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct OverlayApp {
|
|
#[serde(default)]
|
|
pub slug: Option<String>,
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ManifestApp {
|
|
pub slug: String,
|
|
pub name: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
/// `extension_points = ["theme", …]` (§5.5) — module names this node marks
|
|
/// as provided/overridable by descendants. Name-only, like `[secrets]`; the
|
|
/// optional default body is a co-located `[[scripts]]` module of the same
|
|
/// name. A key of the `[app]` table so TOML can't mis-nest it.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub extension_points: Vec<String>,
|
|
}
|
|
|
|
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
|
|
/// and `[vars]`. The group must already exist on the server (created with
|
|
/// `pic groups create`); the manifest reconciles its content, not the tree
|
|
/// shape. In a nested project the parent is inferred from the directory tree.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ManifestGroup {
|
|
pub slug: String,
|
|
pub name: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub description: Option<String>,
|
|
/// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub extension_points: Vec<String>,
|
|
/// `collections = [...]` (§11.6) — shared collections this group offers as
|
|
/// cross-app-shared (read by any app in the subtree, written by an
|
|
/// authenticated editor+). Each entry is either a bare string (a `kv`
|
|
/// collection) or a `{ name, kind }` table (`kind` ∈ `kv`/`docs`).
|
|
/// Group-only: there is no `collections` key on `[app]`.
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub collections: Vec<CollectionDecl>,
|
|
}
|
|
|
|
/// The store kind of a shared collection (§11.6).
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum CollectionKind {
|
|
Kv,
|
|
Docs,
|
|
Files,
|
|
}
|
|
|
|
impl CollectionKind {
|
|
#[must_use]
|
|
pub fn as_str(self) -> &'static str {
|
|
match self {
|
|
Self::Kv => "kv",
|
|
Self::Docs => "docs",
|
|
Self::Files => "files",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One `collections` entry: a bare string (`kv` shorthand) or a `{ name, kind }`
|
|
/// table. Untagged so the shipped `collections = ["catalog"]` form keeps working
|
|
/// alongside `{ name = "articles", kind = "docs" }`.
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(untagged)]
|
|
pub enum CollectionDecl {
|
|
Name(String),
|
|
Full {
|
|
name: String,
|
|
#[serde(default = "kv_kind")]
|
|
kind: CollectionKind,
|
|
},
|
|
}
|
|
|
|
fn kv_kind() -> CollectionKind {
|
|
CollectionKind::Kv
|
|
}
|
|
|
|
impl CollectionDecl {
|
|
/// `(name, kind-as-string)` — a bare string normalizes to `kind = "kv"`.
|
|
#[must_use]
|
|
pub fn normalized(&self) -> (String, String) {
|
|
match self {
|
|
Self::Name(n) => (n.clone(), "kv".to_string()),
|
|
Self::Full { name, kind } => (name.clone(), kind.as_str().to_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>,
|
|
/// Three-state lifecycle (§4.3): `false` deploys the script inert (not
|
|
/// invocable). Omitted ⇒ active; only serialized when disabled.
|
|
#[serde(
|
|
default = "picloud_shared::default_true",
|
|
skip_serializing_if = "is_true"
|
|
)]
|
|
pub enabled: bool,
|
|
}
|
|
|
|
#[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,
|
|
/// Three-state lifecycle (§4.3): `false` deploys the route inert (404).
|
|
/// Omitted ⇒ active; only serialized when disabled.
|
|
#[serde(
|
|
default = "picloud_shared::default_true",
|
|
skip_serializing_if = "is_true"
|
|
)]
|
|
pub enabled: bool,
|
|
/// §11 tail: `sealed = true` on a `[group]` route template makes it
|
|
/// non-suppressible — a descendant's `[suppress]` cannot decline it.
|
|
/// Meaningless (rejected at apply) on an `[app]` route. Omitted ⇒ unsealed.
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub sealed: bool,
|
|
}
|
|
|
|
/// 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>,
|
|
/// §11 tail: `sealed = true` on a `[group]` template makes it
|
|
/// non-suppressible (event kinds only; group-only). Omitted ⇒ unsealed.
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub sealed: bool,
|
|
/// §11.6: `shared = true` on a `[group]` template makes it watch the group's
|
|
/// SHARED collection (not per-app ones); group-only. Omitted ⇒ per-app.
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub shared: bool,
|
|
}
|
|
|
|
#[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>,
|
|
/// See [`KvTriggerSpec::sealed`].
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub sealed: bool,
|
|
/// See [`KvTriggerSpec::shared`].
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub shared: bool,
|
|
}
|
|
|
|
#[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>,
|
|
/// See [`KvTriggerSpec::sealed`].
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub sealed: bool,
|
|
/// See [`KvTriggerSpec::shared`].
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub shared: bool,
|
|
}
|
|
|
|
#[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>,
|
|
/// See [`KvTriggerSpec::sealed`].
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub sealed: bool,
|
|
}
|
|
|
|
#[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()
|
|
}
|
|
}
|
|
|
|
/// `[suppress]` (§11 tail) — per-app opt-out of inherited group templates.
|
|
/// `triggers = [...]` names handler SCRIPTS whose inherited triggers this app
|
|
/// declines; `routes = [...]` names PATHS whose inherited route this app
|
|
/// declines (404s instead of serving). App-only — a `[group]` carrying
|
|
/// `[suppress]` is a hard parse error.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ManifestSuppress {
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub triggers: Vec<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub routes: Vec<String>,
|
|
}
|
|
|
|
impl ManifestSuppress {
|
|
#[must_use]
|
|
pub fn is_empty(&self) -> bool {
|
|
self.triggers.is_empty() && self.routes.is_empty()
|
|
}
|
|
}
|
|
|
|
// ---- serde skip/default helpers ----
|
|
|
|
fn is_endpoint(kind: &ScriptKind) -> bool {
|
|
*kind == ScriptKind::Endpoint
|
|
}
|
|
|
|
fn is_sync(mode: &DispatchMode) -> bool {
|
|
*mode == DispatchMode::Sync
|
|
}
|
|
|
|
/// Skip-serialize helper: `enabled` defaults true, so only emit it when false.
|
|
fn is_true(b: &bool) -> bool {
|
|
*b
|
|
}
|
|
|
|
/// Skip-serialize helper: `sealed` defaults false, so only emit it when true.
|
|
fn is_false(b: &bool) -> bool {
|
|
!*b
|
|
}
|
|
|
|
fn default_timezone() -> String {
|
|
"UTC".to_string()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn sample() -> Manifest {
|
|
Manifest {
|
|
app: Some(ManifestApp {
|
|
slug: "blog".into(),
|
|
name: "My Blog".into(),
|
|
description: Some("demo".into()),
|
|
extension_points: vec!["theme".into()],
|
|
}),
|
|
group: None,
|
|
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,
|
|
enabled: true,
|
|
},
|
|
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()
|
|
}),
|
|
enabled: false,
|
|
},
|
|
],
|
|
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,
|
|
enabled: true,
|
|
sealed: false,
|
|
}],
|
|
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),
|
|
sealed: false,
|
|
shared: false,
|
|
}],
|
|
..ManifestTriggers::default()
|
|
},
|
|
secrets: ManifestSecrets {
|
|
names: vec!["STRIPE_KEY".into()],
|
|
},
|
|
vars: BTreeMap::from([
|
|
("region".to_string(), toml::Value::String("eu".into())),
|
|
("max-retries".to_string(), toml::Value::Integer(3)),
|
|
]),
|
|
suppress: ManifestSuppress::default(),
|
|
}
|
|
}
|
|
|
|
#[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}");
|
|
// `enabled` defaults true → omitted for the active script/route, but
|
|
// the disabled `lib` script emits `enabled = false`.
|
|
assert!(
|
|
text.contains("enabled = false"),
|
|
"a disabled entity must emit enabled:\n{text}"
|
|
);
|
|
assert!(
|
|
!text.contains("enabled = true"),
|
|
"active entities must omit the default:\n{text}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_merges_slug_and_unions_secrets() {
|
|
let mut m = sample(); // slug "blog", secrets ["STRIPE_KEY"]
|
|
let overlay: ManifestOverlay = toml::from_str(
|
|
"[app]\nslug = \"blog-staging\"\n\n[secrets]\nnames = [\"STRIPE_KEY\", \"STAGING_ONLY\"]\n",
|
|
)
|
|
.unwrap();
|
|
m.apply_overlay(overlay);
|
|
let app = m.app.as_ref().unwrap();
|
|
assert_eq!(app.slug, "blog-staging", "overlay slug wins");
|
|
assert_eq!(app.name, "My Blog", "base name kept when overlay omits it");
|
|
assert_eq!(
|
|
m.secrets.names,
|
|
vec!["STRIPE_KEY".to_string(), "STAGING_ONLY".to_string()],
|
|
"secrets union, no dupes"
|
|
);
|
|
// Scripts/routes come from the base, untouched by the overlay.
|
|
assert_eq!(m.scripts.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_rejects_non_overlay_tables() {
|
|
// An overlay carries only [app]/[secrets]. Scripts/routes/triggers
|
|
// belong in the shared base, so a `[[scripts]]` table (or a typo'd
|
|
// key) in an overlay must error loudly, not be silently dropped.
|
|
let err = toml::from_str::<ManifestOverlay>(
|
|
"[app]\nslug = \"blog-staging\"\n\n\
|
|
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n",
|
|
)
|
|
.expect_err("overlay with [[scripts]] must be rejected");
|
|
assert!(
|
|
err.to_string().contains("scripts") || err.to_string().contains("unknown"),
|
|
"error should point at the offending table: {err}"
|
|
);
|
|
|
|
// Typo'd key inside [app] is likewise rejected.
|
|
toml::from_str::<ManifestOverlay>("[app]\nslag = \"oops\"\n")
|
|
.expect_err("overlay with a typo'd [app] key must be rejected");
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_path_derivation() {
|
|
assert_eq!(
|
|
overlay_path(Path::new("proj/picloud.toml"), "staging"),
|
|
Path::new("proj/picloud.staging.toml")
|
|
);
|
|
assert_eq!(
|
|
overlay_path(Path::new("custom.toml"), "prod"),
|
|
Path::new("custom.prod.toml")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_optional_sections_omitted() {
|
|
let m = Manifest {
|
|
app: Some(ManifestApp {
|
|
slug: "x".into(),
|
|
name: "X".into(),
|
|
description: None,
|
|
extension_points: vec![],
|
|
}),
|
|
group: None,
|
|
scripts: vec![],
|
|
routes: vec![],
|
|
triggers: ManifestTriggers::default(),
|
|
secrets: ManifestSecrets::default(),
|
|
vars: BTreeMap::new(),
|
|
suppress: ManifestSuppress::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}");
|
|
assert!(!text.contains("vars"), "got:\n{text}");
|
|
assert!(!text.contains("extension_points"), "got:\n{text}");
|
|
// Still round-trips.
|
|
assert_eq!(m, Manifest::parse(&text).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_misplaced_or_unknown_keys() {
|
|
// §5.5 footgun guard: `extension_points` is an `[app]`/`[group]` node
|
|
// key. Placed at top level (before any table header) TOML reads it as a
|
|
// root key — `deny_unknown_fields` must reject it loudly rather than
|
|
// silently drop it (the silent-drop bug that bit in C5).
|
|
let misplaced = "extension_points = [\"theme\"]\n[app]\nslug = \"x\"\nname = \"X\"\n";
|
|
assert!(
|
|
Manifest::parse(misplaced).is_err(),
|
|
"a top-level extension_points must be rejected, not silently ignored"
|
|
);
|
|
// A typo'd key inside [app] is likewise a hard error, not a drop.
|
|
let typo = "[app]\nslug = \"x\"\nname = \"X\"\nextention_points = [\"theme\"]\n";
|
|
assert!(
|
|
Manifest::parse(typo).is_err(),
|
|
"an unknown key in [app] must be rejected"
|
|
);
|
|
// The correct node-key placement still parses.
|
|
let ok = "[app]\nslug = \"x\"\nname = \"X\"\nextension_points = [\"theme\"]\n";
|
|
let m = Manifest::parse(ok).expect("node-key extension_points must parse");
|
|
assert_eq!(m.extension_points(), ["theme"]);
|
|
}
|
|
|
|
#[test]
|
|
fn collections_string_or_table_normalizes_kind() {
|
|
// §11.6: a bare string is a `kv` collection (the shipped form); a
|
|
// `{ name, kind }` table sets an explicit kind. Both coexist.
|
|
let m = Manifest::parse(
|
|
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
|
collections = [\"catalog\", { name = \"articles\", kind = \"docs\" }, \
|
|
{ name = \"assets\", kind = \"files\" }]\n",
|
|
)
|
|
.expect("string-or-table collections must parse");
|
|
assert_eq!(
|
|
m.collections(),
|
|
vec![
|
|
("catalog".to_string(), "kv".to_string()),
|
|
("articles".to_string(), "docs".to_string()),
|
|
("assets".to_string(), "files".to_string()),
|
|
]
|
|
);
|
|
// An app manifest carrying `collections` is rejected (no such field +
|
|
// deny_unknown_fields).
|
|
let app = "[app]\nslug = \"x\"\nname = \"X\"\ncollections = [\"catalog\"]\n";
|
|
assert!(
|
|
Manifest::parse(app).is_err(),
|
|
"collections on [app] must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn app_and_group_suppress_parse() {
|
|
// §11 tail: an [app] declares [suppress] to opt out of inherited
|
|
// templates — script names (triggers) + paths (routes).
|
|
let m = Manifest::parse(
|
|
"[app]\nslug = \"blog\"\nname = \"Blog\"\n\n\
|
|
[suppress]\ntriggers = [\"audit\"]\nroutes = [\"/hello\"]\n",
|
|
)
|
|
.expect("app [suppress] must parse");
|
|
assert_eq!(m.suppress.triggers, ["audit"]);
|
|
assert_eq!(m.suppress.routes, ["/hello"]);
|
|
|
|
// §11 tail M1: a [group] MAY suppress — it declines a template it
|
|
// inherits from a higher ancestor, for its whole subtree.
|
|
let g = Manifest::parse(
|
|
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
|
[suppress]\ntriggers = [\"audit\"]\n",
|
|
)
|
|
.expect("group [suppress] must parse");
|
|
assert_eq!(g.suppress.triggers, ["audit"]);
|
|
|
|
// An unknown key inside [suppress] is a hard error (deny_unknown_fields).
|
|
let typo = "[app]\nslug = \"x\"\nname = \"X\"\n\n[suppress]\ntrigger = [\"a\"]\n";
|
|
assert!(
|
|
Manifest::parse(typo).is_err(),
|
|
"an unknown key in [suppress] must be rejected"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sealed_parses_on_group_route_and_trigger_templates() {
|
|
// §11 tail: a [group] can mark a route/trigger template `sealed = true`
|
|
// (non-suppressible). The default is unsealed.
|
|
let m = Manifest::parse(
|
|
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
|
[[routes]]\nscript = \"gate\"\npath = \"/health\"\npath_kind = \"exact\"\n\
|
|
host_kind = \"any\"\nsealed = true\n\n\
|
|
[[triggers.kv]]\nscript = \"audit\"\ncollection_glob = \"*\"\nsealed = true\n\n\
|
|
[[triggers.docs]]\nscript = \"log\"\ncollection_glob = \"*\"\n",
|
|
)
|
|
.expect("sealed templates parse");
|
|
assert!(m.routes[0].sealed, "route sealed must parse");
|
|
assert!(m.triggers.kv[0].sealed, "kv trigger sealed must parse");
|
|
assert!(
|
|
!m.triggers.docs[0].sealed,
|
|
"an omitted sealed defaults to false"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn group_manifest_parses_and_rejects_app_only_blocks() {
|
|
// A [group] node: scripts + vars, no [app].
|
|
let m = Manifest::parse(
|
|
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
|
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
|
|
[vars]\nregion = \"eu\"\n",
|
|
)
|
|
.expect("group manifest parses");
|
|
assert!(m.is_group());
|
|
assert_eq!(m.slug(), "acme");
|
|
assert_eq!(m.scripts.len(), 1);
|
|
|
|
// §11 tail: a group MAY carry ROUTE templates (inherited by descendants).
|
|
let routed = Manifest::parse(
|
|
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
|
[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x\"\n",
|
|
)
|
|
.expect("group with a route template parses");
|
|
assert!(routed.is_group());
|
|
assert_eq!(routed.routes.len(), 1);
|
|
|
|
// ...but a STATEFUL trigger kind (cron/queue/email) on a group is rejected.
|
|
let err = Manifest::parse(
|
|
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
|
|
[[triggers.cron]]\nscript = \"shared\"\nschedule = \"0 0 * * * *\"\n",
|
|
)
|
|
.expect_err("group with a cron trigger is rejected");
|
|
assert!(err.to_string().contains("event kind"), "got: {err}");
|
|
|
|
// Neither / both is rejected.
|
|
Manifest::parse("[vars]\nx = 1\n").expect_err("no [app] or [group]");
|
|
Manifest::parse("[app]\nslug=\"a\"\nname=\"A\"\n[group]\nslug=\"g\"\nname=\"G\"\n")
|
|
.expect_err("both [app] and [group]");
|
|
}
|
|
|
|
#[test]
|
|
fn overlay_vars_override_base_per_key() {
|
|
let mut base = sample();
|
|
base.vars
|
|
.insert("region".into(), toml::Value::String("eu".into()));
|
|
base.vars
|
|
.insert("tier".into(), toml::Value::String("base".into()));
|
|
let overlay: ManifestOverlay =
|
|
toml::from_str("[vars]\nregion = \"us\"\nextra = true\n").unwrap();
|
|
base.apply_overlay(overlay);
|
|
// overlay wins for `region`, base-only `tier` survives, overlay adds `extra`.
|
|
assert_eq!(base.vars["region"], toml::Value::String("us".into()));
|
|
assert_eq!(base.vars["tier"], toml::Value::String("base".into()));
|
|
assert_eq!(base.vars["extra"], toml::Value::Boolean(true));
|
|
}
|
|
}
|