feat(cli): pic pull --group round-trips a group node
Exports a group as a [group] manifest: its scripts, [[routes]]/[[triggers.*]] TEMPLATES (with the group-only sealed/shared flags), a shared collections entry, [vars]/[secrets], extension_points, and [suppress]. Groups own no workflows. The group trigger/route reports were display-only (no ops, dispatch, retry, host_kind, cron tz, queue timeout) so a pulled manifest could not re-plan clean. Enriched TriggerTemplateInfo with the full internally-tagged TriggerDetails JSON + dispatch_mode + retry_max_attempts, and RouteTemplateInfo with raw manifest-shaped fields (method/host_kind/host/host_param_name/ path_kind/dispatch_mode); pic routes ls --group now applies the ANY/host munge client-side, consistent with the app pic routes ls. pull.rs factors the script-writing + trigger-decoding it shares with the app path. Pinned by a pull journey: a group with a sealed+shared kv trigger, a sealed route, a shared collection, and a var round-trips and the exported manifest re-applies clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -830,6 +830,19 @@ pub struct TriggerTemplateInfo {
|
|||||||
pub sealed: bool,
|
pub sealed: bool,
|
||||||
/// §11.6: `true` for a shared-collection template.
|
/// §11.6: `true` for a shared-collection template.
|
||||||
pub shared: bool,
|
pub shared: bool,
|
||||||
|
/// Full `TriggerDetails` as internally-tagged JSON (`{ "kind": …, … }`),
|
||||||
|
/// so `pic pull --group` can round-trip a template's ops / schedule /
|
||||||
|
/// timezone / queue name / visibility timeout, not just the display target.
|
||||||
|
/// `#[serde(default)]` on the read side so an older server (which omits it)
|
||||||
|
/// still deserializes; the display command reads `target`, not this.
|
||||||
|
#[serde(default)]
|
||||||
|
pub details: serde_json::Value,
|
||||||
|
/// Wire dispatch mode (`sync`/`async`) — round-trip only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dispatch_mode: String,
|
||||||
|
/// Per-trigger retry ceiling — round-trip only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_max_attempts: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the read-only §11 tail route-template report (`pic routes ls
|
/// One row of the read-only §11 tail route-template report (`pic routes ls
|
||||||
@@ -838,12 +851,17 @@ pub struct TriggerTemplateInfo {
|
|||||||
/// rows, so the semantic bits are shown instead.
|
/// rows, so the semantic bits are shown instead.
|
||||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct RouteTemplateInfo {
|
pub struct RouteTemplateInfo {
|
||||||
pub method: String,
|
/// `None` = any method. Carried raw (not the "ANY" display munge) so
|
||||||
|
/// `pic pull --group` round-trips it; the display command applies the munge.
|
||||||
|
pub method: Option<String>,
|
||||||
|
pub host_kind: HostKind,
|
||||||
|
/// Raw host (empty for `Any`, suffix for `Wildcard`), not the display munge.
|
||||||
pub host: String,
|
pub host: String,
|
||||||
pub path_kind: String,
|
pub host_param_name: Option<String>,
|
||||||
|
pub path_kind: PathKind,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub script: String,
|
pub script: String,
|
||||||
pub dispatch: String,
|
pub dispatch_mode: DispatchMode,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||||
pub sealed: bool,
|
pub sealed: bool,
|
||||||
@@ -3651,6 +3669,9 @@ impl ApplyService {
|
|||||||
enabled: t.enabled,
|
enabled: t.enabled,
|
||||||
sealed: t.sealed,
|
sealed: t.sealed,
|
||||||
shared: t.shared,
|
shared: t.shared,
|
||||||
|
details: serde_json::to_value(&t.details).unwrap_or(serde_json::Value::Null),
|
||||||
|
dispatch_mode: t.dispatch_mode.as_str().to_string(),
|
||||||
|
retry_max_attempts: t.retry_max_attempts,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
@@ -3681,16 +3702,14 @@ impl ApplyService {
|
|||||||
Ok(routes
|
Ok(routes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| RouteTemplateInfo {
|
.map(|r| RouteTemplateInfo {
|
||||||
method: r.method.clone().unwrap_or_else(|| "ANY".into()),
|
method: r.method.clone(),
|
||||||
host: match r.host_kind {
|
host_kind: r.host_kind,
|
||||||
HostKind::Any => "any".to_string(),
|
host: r.host.clone(),
|
||||||
HostKind::Strict => format!("strict:{}", r.host),
|
host_param_name: r.host_param_name.clone(),
|
||||||
HostKind::Wildcard => format!("*.{}", r.host),
|
path_kind: r.path_kind,
|
||||||
},
|
|
||||||
path_kind: path_kind_str(r.path_kind).to_string(),
|
|
||||||
path: r.path.clone(),
|
path: r.path.clone(),
|
||||||
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
|
script: name_by_id.get(&r.script_id).cloned().unwrap_or_default(),
|
||||||
dispatch: r.dispatch_mode.as_str().to_string(),
|
dispatch_mode: r.dispatch_mode,
|
||||||
enabled: r.enabled,
|
enabled: r.enabled,
|
||||||
sealed: r.sealed,
|
sealed: r.sealed,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1702,7 +1702,9 @@ pub struct SuppressionDto {
|
|||||||
pub reference: String,
|
pub reference: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the §11 tail trigger-template report.
|
/// One row of the §11 tail trigger-template report. Carries both the display
|
||||||
|
/// `target` and the full round-trip `details`/`dispatch_mode`/`retry_max_attempts`
|
||||||
|
/// (`pic triggers ls --group` reads the former; `pic pull --group` the latter).
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct TriggerTemplateDto {
|
pub struct TriggerTemplateDto {
|
||||||
pub kind: String,
|
pub kind: String,
|
||||||
@@ -1717,23 +1719,36 @@ pub struct TriggerTemplateDto {
|
|||||||
/// §11.6: `true` for a shared-collection template.
|
/// §11.6: `true` for a shared-collection template.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub shared: bool,
|
pub shared: bool,
|
||||||
|
/// Full `TriggerDetails` JSON (internally tagged) — round-trip only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub details: serde_json::Value,
|
||||||
|
/// Wire dispatch mode (`sync`/`async`) — round-trip only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dispatch_mode: String,
|
||||||
|
/// Per-trigger retry ceiling — round-trip only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub retry_max_attempts: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row of the §11 tail route-template report.
|
/// One row of the §11 tail route-template report. Raw manifest-shaped fields so
|
||||||
|
/// `pic pull --group` round-trips; the display command applies the "ANY"/host
|
||||||
|
/// munge client-side (consistent with the app `pic routes ls`).
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RouteTemplateDto {
|
pub struct RouteTemplateDto {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub method: String,
|
pub method: Option<String>,
|
||||||
|
pub host_kind: HostKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub host: String,
|
pub host: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path_kind: String,
|
pub host_param_name: Option<String>,
|
||||||
|
pub path_kind: PathKind,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub path: String,
|
pub path: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub script: String,
|
pub script: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub dispatch: String,
|
pub dispatch_mode: DispatchMode,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
//! `pic pull <app> [--dir .]` — export an app's current server state into
|
//! `pic pull <app> [--dir .]` / `pic pull --group <slug>` — export a node's
|
||||||
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
|
//! current server state into a `picloud.toml` manifest plus
|
||||||
//! declarative management with `pic plan` / `pic apply`.
|
//! `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
|
//! Read-only: issues `GET`s only and writes local files. Every trigger
|
||||||
//! kind is exported except `email` — the server stores the *sealed secret
|
//! kind is exported except `email` (the server stores the *sealed secret
|
||||||
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
|
//! value*, not the secret name, so `inbound_secret_ref` can't be
|
||||||
//! can't be reconstructed (email triggers must be set up by hand).
|
//! reconstructed) and `dead_letter` (not manifest-representable) — both are
|
||||||
|
//! skipped with a warning.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
@@ -14,25 +16,40 @@ use anyhow::{Context, Result};
|
|||||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::client::{Client, VarOwnerArg};
|
use crate::client::{Client, NodeKind, VarOwnerArg};
|
||||||
|
use crate::cmds::{require_one_owner, OwnerRef};
|
||||||
use crate::config;
|
use crate::config;
|
||||||
use crate::manifest::{
|
use crate::manifest::{
|
||||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
CollectionDecl, CollectionKind, CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec,
|
||||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, ManifestWorkflow,
|
KvTriggerSpec, Manifest, ManifestApp, ManifestGroup, ManifestRoute, ManifestScript,
|
||||||
ManifestWorkflowRetry, ManifestWorkflowStep, PubsubTriggerSpec, QueueTriggerSpec,
|
ManifestSecrets, ManifestSuppress, ManifestTriggers, ManifestWorkflow, ManifestWorkflowRetry,
|
||||||
MANIFEST_FILE,
|
ManifestWorkflowStep, PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
|
||||||
};
|
};
|
||||||
use crate::output::{KvBlock, OutputMode};
|
use crate::output::{KvBlock, OutputMode};
|
||||||
|
|
||||||
pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> Result<()> {
|
/// Dispatch on the owner: an app (positional, the original form) or a group
|
||||||
|
/// (`--group`). Exactly one is required.
|
||||||
|
pub async fn run(
|
||||||
|
app: Option<&str>,
|
||||||
|
group: Option<&str>,
|
||||||
|
dir: &Path,
|
||||||
|
force: bool,
|
||||||
|
mode: OutputMode,
|
||||||
|
) -> Result<()> {
|
||||||
let creds = config::resolve()?;
|
let creds = config::resolve()?;
|
||||||
let client = Client::from_creds(&creds)?;
|
let client = Client::from_creds(&creds)?;
|
||||||
|
match require_one_owner(app, group)? {
|
||||||
|
OwnerRef::App(a) => pull_app(&client, a, dir, force, mode).await,
|
||||||
|
OwnerRef::Group(g) => pull_group(&client, g, dir, force, mode).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refuse to clobber an existing project (mirrors `pic init`). `pull`
|
/// Refuse to clobber an existing project (mirrors `pic init`). `pull` overwrites
|
||||||
// overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a
|
/// `picloud.toml` and every colliding `scripts/*.rhai`, so a stray `pic pull
|
||||||
// stray `pic pull <wrong-app>` in a populated dir would destroy local
|
/// <wrong-node>` in a populated dir would destroy local edits. Fail fast —
|
||||||
// edits. Fail fast — before any network call or file write — unless the
|
/// before any network call or file write — unless the operator opted in with
|
||||||
// operator opted in with `--force`.
|
/// `--force`.
|
||||||
|
fn check_clobber(dir: &Path, force: bool) -> Result<std::path::PathBuf> {
|
||||||
let manifest_path = dir.join(MANIFEST_FILE);
|
let manifest_path = dir.join(MANIFEST_FILE);
|
||||||
if !force && manifest_path.exists() {
|
if !force && manifest_path.exists() {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
@@ -41,6 +58,17 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
manifest_path.display()
|
manifest_path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Ok(manifest_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pull_app(
|
||||||
|
client: &Client,
|
||||||
|
app_ident: &str,
|
||||||
|
dir: &Path,
|
||||||
|
force: bool,
|
||||||
|
mode: OutputMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
let manifest_path = check_clobber(dir, force)?;
|
||||||
|
|
||||||
// One GET per resource kind (routes are per-script, below).
|
// One GET per resource kind (routes are per-script, below).
|
||||||
let app = client.apps_get(app_ident).await?;
|
let app = client.apps_get(app_ident).await?;
|
||||||
@@ -117,48 +145,11 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 (a path separator, \
|
|
||||||
`..`, a leading dot, a control character, or longer than 200 \
|
|
||||||
bytes); cannot pull",
|
|
||||||
s.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scripts: write each source next to the manifest and record a path ref.
|
// Scripts: write each source next to the manifest and record a path ref.
|
||||||
let scripts_dir = dir.join("scripts");
|
let manifest_scripts = write_scripts(dir, &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)
|
|
||||||
},
|
|
||||||
enabled: s.enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Triggers: map the five settled kinds; warn + skip the rest.
|
// Triggers: map the settled kinds; warn + skip the rest. App-owned triggers
|
||||||
|
// are never sealed/shared (both group-only), so pass `false`.
|
||||||
let mut manifest_triggers = ManifestTriggers::default();
|
let mut manifest_triggers = ManifestTriggers::default();
|
||||||
let mut skipped: Vec<String> = Vec::new();
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
for t in &triggers {
|
for t in &triggers {
|
||||||
@@ -166,89 +157,20 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
.get(&t.script_id)
|
.get(&t.script_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| t.script_id.to_string());
|
.unwrap_or_else(|| t.script_id.to_string());
|
||||||
let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
|
push_trigger(
|
||||||
let retry_max_attempts = Some(t.retry_max_attempts);
|
&mut manifest_triggers,
|
||||||
match t.kind.as_str() {
|
&mut skipped,
|
||||||
"kv" => {
|
&t.kind,
|
||||||
let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
|
&t.details,
|
||||||
manifest_triggers.kv.push(KvTriggerSpec {
|
script,
|
||||||
script,
|
DispatchMode::from_wire(&t.dispatch_mode),
|
||||||
collection_glob: d.collection_glob,
|
Some(t.retry_max_attempts),
|
||||||
ops: d.ops,
|
false,
|
||||||
dispatch_mode,
|
false,
|
||||||
retry_max_attempts,
|
&t.id.to_string(),
|
||||||
// app-owned triggers are never sealed/shared (group-only).
|
)?;
|
||||||
sealed: false,
|
|
||||||
shared: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"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,
|
|
||||||
sealed: false,
|
|
||||||
shared: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"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,
|
|
||||||
sealed: false,
|
|
||||||
shared: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"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,
|
|
||||||
// app-owned triggers are never sealed/shared (group-only).
|
|
||||||
sealed: false,
|
|
||||||
shared: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
"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,
|
|
||||||
// app-owned triggers are never shared (group-only).
|
|
||||||
shared: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// `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");
|
|
||||||
}
|
}
|
||||||
|
warn_skipped(&skipped);
|
||||||
|
|
||||||
let manifest = Manifest {
|
let manifest = Manifest {
|
||||||
app: Some(ManifestApp {
|
app: Some(ManifestApp {
|
||||||
@@ -266,20 +188,321 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
|||||||
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||||
},
|
},
|
||||||
vars: manifest_vars,
|
vars: manifest_vars,
|
||||||
suppress: crate::manifest::ManifestSuppress::default(),
|
suppress: ManifestSuppress::default(),
|
||||||
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
|
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
|
||||||
// `pull` does not round-trip interceptor markers yet (read surface TBD);
|
// `pull` does not round-trip interceptor markers yet (read surface TBD);
|
||||||
// an interceptor authored in the manifest survives re-apply regardless.
|
// an interceptor authored in the manifest survives re-apply regardless.
|
||||||
interceptors: Vec::new(),
|
interceptors: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
std::fs::write(&manifest_path, manifest.to_toml()?)
|
write_manifest_and_report(&manifest_path, &manifest, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `pic pull --group <slug>` — export a group node (a `[group]` manifest): its
|
||||||
|
/// own scripts, `[[routes]]`/`[[triggers.*]]` TEMPLATES (which can be
|
||||||
|
/// `sealed`/`shared`, unlike app resources), `[vars]`/`[secrets]`,
|
||||||
|
/// `extension_points`, `collections`, and `[suppress]`. Group nodes own no
|
||||||
|
/// workflows.
|
||||||
|
async fn pull_group(
|
||||||
|
client: &Client,
|
||||||
|
group_ident: &str,
|
||||||
|
dir: &Path,
|
||||||
|
force: bool,
|
||||||
|
mode: OutputMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
let manifest_path = check_clobber(dir, force)?;
|
||||||
|
|
||||||
|
let group = client.groups_get(group_ident).await?;
|
||||||
|
let scripts = client.group_scripts_list(group_ident).await?;
|
||||||
|
let routes_wire = client.group_routes_list(group_ident).await?;
|
||||||
|
let triggers_wire = client.group_triggers_list(group_ident).await?;
|
||||||
|
let secrets = client
|
||||||
|
.secrets_list(VarOwnerArg::Group(group_ident), None)
|
||||||
|
.await?
|
||||||
|
.secrets;
|
||||||
|
let extension_points: Vec<String> = client
|
||||||
|
.extension_points_list(NodeKind::Group, group_ident)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|ep| ep.declared_here)
|
||||||
|
.map(|ep| ep.name)
|
||||||
|
.collect();
|
||||||
|
// The group's OWN env-agnostic vars (inherited/tombstone excluded, as the
|
||||||
|
// app path). A value TOML can't represent is skipped with a warning.
|
||||||
|
let mut manifest_vars = std::collections::BTreeMap::new();
|
||||||
|
for v in client
|
||||||
|
.vars_list(VarOwnerArg::Group(group_ident))
|
||||||
|
.await?
|
||||||
|
.vars
|
||||||
|
{
|
||||||
|
if v.env != "*" || v.is_tombstone {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match toml::Value::try_from(&v.value) {
|
||||||
|
Ok(tv) => {
|
||||||
|
manifest_vars.insert(v.key, tv);
|
||||||
|
}
|
||||||
|
Err(_) => eprintln!(
|
||||||
|
"warning: skipping var `{}` — its value can't be represented in TOML",
|
||||||
|
v.key
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared collections this group offers (§11.6): a bare `kv` normalizes to a
|
||||||
|
// string, everything else to a `{ name, kind }` table.
|
||||||
|
let mut collections = Vec::new();
|
||||||
|
for c in client.collections_list(group_ident).await? {
|
||||||
|
let kind = match c.kind.as_str() {
|
||||||
|
"kv" => {
|
||||||
|
collections.push(CollectionDecl::Name(c.name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
"docs" => CollectionKind::Docs,
|
||||||
|
"files" => CollectionKind::Files,
|
||||||
|
"topic" => CollectionKind::Topic,
|
||||||
|
"queue" => CollectionKind::Queue,
|
||||||
|
other => {
|
||||||
|
eprintln!(
|
||||||
|
"warning: skipping collection `{}` — unknown kind `{other}`",
|
||||||
|
c.name
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collections.push(CollectionDecl::Full { name: c.name, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
// `[suppress]` this group declares for its whole subtree (§11 tail M1).
|
||||||
|
let mut suppress = ManifestSuppress::default();
|
||||||
|
for s in client.group_suppressions_list(group_ident).await? {
|
||||||
|
match s.target_kind.as_str() {
|
||||||
|
"trigger" => suppress.triggers.push(s.reference),
|
||||||
|
"route" => suppress.routes.push(s.reference),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scripts.
|
||||||
|
let manifest_scripts = write_scripts(dir, &scripts)?;
|
||||||
|
|
||||||
|
// Route TEMPLATES → `[[routes]]` (host/method un-munged; `sealed` carried).
|
||||||
|
let routes = routes_wire
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ManifestRoute {
|
||||||
|
script: r.script,
|
||||||
|
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,
|
||||||
|
enabled: r.enabled,
|
||||||
|
sealed: r.sealed,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Trigger TEMPLATES → `[[triggers.*]]`. Group templates carry `sealed`/
|
||||||
|
// `shared`; `email`/`dead_letter` are skipped (see the module header).
|
||||||
|
let mut manifest_triggers = ManifestTriggers::default();
|
||||||
|
let mut skipped: Vec<String> = Vec::new();
|
||||||
|
for t in &triggers_wire {
|
||||||
|
push_trigger(
|
||||||
|
&mut manifest_triggers,
|
||||||
|
&mut skipped,
|
||||||
|
&t.kind,
|
||||||
|
&t.details,
|
||||||
|
t.script.clone(),
|
||||||
|
DispatchMode::from_wire(&t.dispatch_mode),
|
||||||
|
Some(t.retry_max_attempts),
|
||||||
|
t.sealed,
|
||||||
|
t.shared,
|
||||||
|
&t.kind,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
warn_skipped(&skipped);
|
||||||
|
|
||||||
|
let manifest = Manifest {
|
||||||
|
app: None,
|
||||||
|
group: Some(ManifestGroup {
|
||||||
|
slug: group.group.slug.clone(),
|
||||||
|
name: group.group.name.clone(),
|
||||||
|
description: group.group.description.clone(),
|
||||||
|
extension_points,
|
||||||
|
collections,
|
||||||
|
}),
|
||||||
|
project: None,
|
||||||
|
scripts: manifest_scripts,
|
||||||
|
routes,
|
||||||
|
triggers: manifest_triggers,
|
||||||
|
secrets: ManifestSecrets {
|
||||||
|
names: secrets.iter().map(|s| s.name.clone()).collect(),
|
||||||
|
},
|
||||||
|
vars: manifest_vars,
|
||||||
|
suppress,
|
||||||
|
// Groups own no workflows.
|
||||||
|
workflows: Vec::new(),
|
||||||
|
interceptors: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
write_manifest_and_report(&manifest_path, &manifest, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate every script name is filesystem-safe (up front, so a single bad
|
||||||
|
/// name can't leave a half-written dir), then write each source under
|
||||||
|
/// `scripts/` and return the manifest specs. Shared by the app + group paths.
|
||||||
|
fn write_scripts(dir: &Path, scripts: &[picloud_shared::Script]) -> Result<Vec<ManifestScript>> {
|
||||||
|
// 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 (a path separator, \
|
||||||
|
`..`, a leading dot, a control character, or longer than 200 \
|
||||||
|
bytes); cannot pull",
|
||||||
|
s.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
enabled: s.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(manifest_scripts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode one trigger's `details` JSON and push a manifest spec of the right
|
||||||
|
/// kind. `email`/`dead_letter`/unknown are recorded in `skipped` (keyed by
|
||||||
|
/// `id_label` — a trigger id for apps, the kind for group templates). `sealed`/
|
||||||
|
/// `shared` are group-only (pass `false` for an app).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn push_trigger(
|
||||||
|
triggers: &mut ManifestTriggers,
|
||||||
|
skipped: &mut Vec<String>,
|
||||||
|
kind: &str,
|
||||||
|
details: &serde_json::Value,
|
||||||
|
script: String,
|
||||||
|
dispatch_mode: Option<DispatchMode>,
|
||||||
|
retry_max_attempts: Option<u32>,
|
||||||
|
sealed: bool,
|
||||||
|
shared: bool,
|
||||||
|
id_label: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
match kind {
|
||||||
|
"kv" => {
|
||||||
|
let d: CollectionDetails<KvEventOp> = decode_details(details, kind)?;
|
||||||
|
triggers.kv.push(KvTriggerSpec {
|
||||||
|
script,
|
||||||
|
collection_glob: d.collection_glob,
|
||||||
|
ops: d.ops,
|
||||||
|
dispatch_mode,
|
||||||
|
retry_max_attempts,
|
||||||
|
sealed,
|
||||||
|
shared,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"docs" => {
|
||||||
|
let d: CollectionDetails<DocsEventOp> = decode_details(details, kind)?;
|
||||||
|
triggers.docs.push(DocsTriggerSpec {
|
||||||
|
script,
|
||||||
|
collection_glob: d.collection_glob,
|
||||||
|
ops: d.ops,
|
||||||
|
dispatch_mode,
|
||||||
|
retry_max_attempts,
|
||||||
|
sealed,
|
||||||
|
shared,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"files" => {
|
||||||
|
let d: CollectionDetails<FilesEventOp> = decode_details(details, kind)?;
|
||||||
|
triggers.files.push(FilesTriggerSpec {
|
||||||
|
script,
|
||||||
|
collection_glob: d.collection_glob,
|
||||||
|
ops: d.ops,
|
||||||
|
dispatch_mode,
|
||||||
|
retry_max_attempts,
|
||||||
|
sealed,
|
||||||
|
shared,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"cron" => {
|
||||||
|
let d: CronDetails = decode_details(details, kind)?;
|
||||||
|
triggers.cron.push(CronTriggerSpec {
|
||||||
|
script,
|
||||||
|
schedule: d.schedule,
|
||||||
|
timezone: d.timezone,
|
||||||
|
dispatch_mode,
|
||||||
|
retry_max_attempts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"pubsub" => {
|
||||||
|
let d: PubsubDetails = decode_details(details, kind)?;
|
||||||
|
triggers.pubsub.push(PubsubTriggerSpec {
|
||||||
|
script,
|
||||||
|
topic_pattern: d.topic_pattern,
|
||||||
|
dispatch_mode,
|
||||||
|
retry_max_attempts,
|
||||||
|
sealed,
|
||||||
|
shared,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
"queue" => {
|
||||||
|
let d: QueueDetails = decode_details(details, kind)?;
|
||||||
|
triggers.queue.push(QueueTriggerSpec {
|
||||||
|
script,
|
||||||
|
queue_name: d.queue_name,
|
||||||
|
visibility_timeout_secs: Some(d.visibility_timeout_secs),
|
||||||
|
dispatch_mode,
|
||||||
|
retry_max_attempts,
|
||||||
|
shared,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// `email` (sealed secret, no recoverable ref) + `dead_letter` (no
|
||||||
|
// manifest spec) are not representable.
|
||||||
|
other => skipped.push(format!("{other} ({id_label})")),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn warn_skipped(skipped: &[String]) {
|
||||||
|
for s in skipped {
|
||||||
|
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the manifest to `picloud.toml` and print the summary block.
|
||||||
|
fn write_manifest_and_report(
|
||||||
|
manifest_path: &Path,
|
||||||
|
manifest: &Manifest,
|
||||||
|
mode: OutputMode,
|
||||||
|
) -> Result<()> {
|
||||||
|
std::fs::write(manifest_path, manifest.to_toml()?)
|
||||||
.with_context(|| format!("writing {}", manifest_path.display()))?;
|
.with_context(|| format!("writing {}", manifest_path.display()))?;
|
||||||
|
|
||||||
|
let owner_label = if manifest.is_group() { "group" } else { "app" };
|
||||||
let mut block = KvBlock::new();
|
let mut block = KvBlock::new();
|
||||||
block
|
block
|
||||||
.field("manifest", manifest_path.display().to_string())
|
.field("manifest", manifest_path.display().to_string())
|
||||||
.field("app", manifest.slug().to_string())
|
.field(owner_label, manifest.slug().to_string())
|
||||||
.field("scripts", manifest.scripts.len().to_string())
|
.field("scripts", manifest.scripts.len().to_string())
|
||||||
.field("routes", manifest.routes.len().to_string())
|
.field("routes", manifest.routes.len().to_string())
|
||||||
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
.field("triggers", trigger_count(&manifest.triggers).to_string())
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ pub async fn ls_group(group: &str, mode: OutputMode) -> Result<()> {
|
|||||||
]);
|
]);
|
||||||
for r in rows {
|
for r in rows {
|
||||||
table.row([
|
table.row([
|
||||||
r.method,
|
r.method.clone().unwrap_or_else(|| "ANY".into()),
|
||||||
r.host,
|
host_label(r.host_kind, &r.host),
|
||||||
r.path_kind,
|
path_kind_label(r.path_kind).to_string(),
|
||||||
r.path,
|
r.path,
|
||||||
r.script,
|
r.script,
|
||||||
r.dispatch,
|
dispatch_label(r.dispatch_mode).to_string(),
|
||||||
r.enabled.to_string(),
|
r.enabled.to_string(),
|
||||||
r.sealed.to_string(),
|
r.sealed.to_string(),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -309,8 +309,11 @@ struct PlanArgs {
|
|||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
struct PullArgs {
|
struct PullArgs {
|
||||||
/// App slug or id to export.
|
/// App slug or id to export (positional). Omit when using `--group`.
|
||||||
app: String,
|
app: Option<String>,
|
||||||
|
/// Group slug or id to export as a `[group]` node instead of an app.
|
||||||
|
#[arg(long, conflicts_with = "app")]
|
||||||
|
group: Option<String>,
|
||||||
/// Directory to write `picloud.toml` + `scripts/` into.
|
/// Directory to write `picloud.toml` + `scripts/` into.
|
||||||
#[arg(long, default_value = ".")]
|
#[arg(long, default_value = ".")]
|
||||||
dir: PathBuf,
|
dir: PathBuf,
|
||||||
@@ -1569,7 +1572,16 @@ async fn main() -> ExitCode {
|
|||||||
Some(dir) => cmds::plan::run_tree(dir, args.env.as_deref(), mode).await,
|
Some(dir) => cmds::plan::run_tree(dir, args.env.as_deref(), mode).await,
|
||||||
None => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
|
None => cmds::plan::run(&args.file, args.env.as_deref(), mode).await,
|
||||||
},
|
},
|
||||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await,
|
Cmd::Pull(args) => {
|
||||||
|
cmds::pull::run(
|
||||||
|
args.app.as_deref(),
|
||||||
|
args.group.as_deref(),
|
||||||
|
&args.dir,
|
||||||
|
args.force,
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
Cmd::Config(args) => {
|
Cmd::Config(args) => {
|
||||||
cmds::config::run(
|
cmds::config::run(
|
||||||
&args.file,
|
&args.file,
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
//! `pic pull` journey: stand up an app with a script, route, cron trigger,
|
//! `pic pull` journey: stand up an app with a script, route, cron trigger,
|
||||||
//! and a secret, then export it and assert the manifest + script file.
|
//! and a secret, then export it and assert the manifest + script file.
|
||||||
|
//!
|
||||||
|
//! B3: a second case exports a GROUP node (`pic pull --group`) — its scripts,
|
||||||
|
//! a `sealed`+`shared` `[[triggers.kv]]` template, a `[[routes]]` template, a
|
||||||
|
//! shared `collections` entry, and a `[vars]` value — and asserts the exported
|
||||||
|
//! manifest re-applies clean (the round-trip carries the group-only flags).
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
use crate::common;
|
use crate::common;
|
||||||
|
use crate::common::cleanup::{GroupGuard, ScriptGuard};
|
||||||
|
|
||||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
#[test]
|
#[test]
|
||||||
@@ -89,3 +97,123 @@ fn pull_exports_manifest_and_sources() {
|
|||||||
|
|
||||||
drop(guard);
|
drop(guard);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn group_script_id(env: &common::TestEnv, group: &str, name: &str) -> String {
|
||||||
|
let ls = common::pic_as(env)
|
||||||
|
.args(["scripts", "ls", "--group", group])
|
||||||
|
.output()
|
||||||
|
.expect("scripts ls");
|
||||||
|
let table = String::from_utf8(ls.stdout).unwrap();
|
||||||
|
table
|
||||||
|
.lines()
|
||||||
|
.map(common::cells)
|
||||||
|
.find(|c| c.get(2) == Some(&name))
|
||||||
|
.and_then(|c| c.first().map(|s| (*s).to_string()))
|
||||||
|
.unwrap_or_else(|| panic!("group script `{name}` not found:\n{table}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// B3: `pic pull --group` round-trips a group node, including the group-only
|
||||||
|
/// `sealed`/`shared` template flags and a shared `collections` entry.
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn pull_group_round_trips_templates_and_collections() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let group = common::unique_slug("pullg");
|
||||||
|
|
||||||
|
let _g = GroupGuard::new(&env.url, &env.token, &group);
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["groups", "create", &group])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// A group-owned handler the templates bind.
|
||||||
|
let dir = TempDir::new().expect("group pull tempdir");
|
||||||
|
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
dir.path().join("scripts/gh.rhai"),
|
||||||
|
r#"log::info("group handler"); "ok""#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["scripts", "deploy"])
|
||||||
|
.arg(dir.path().join("scripts/gh.rhai"))
|
||||||
|
.args(["--group", &group, "--name", "gh"])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group, "gh"));
|
||||||
|
|
||||||
|
// The group declares a shared collection, a sealed+shared kv trigger
|
||||||
|
// template, a sealed route template, and a var.
|
||||||
|
let gmanifest = format!(
|
||||||
|
"[group]\nslug = \"{group}\"\nname = \"PullG\"\n\n\
|
||||||
|
collections = [\"catalog\"]\n\n\
|
||||||
|
[vars]\ntier = \"gold\"\n\n\
|
||||||
|
[[routes]]\nscript = \"gh\"\npath = \"/gpull\"\npath_kind = \"exact\"\n\
|
||||||
|
host_kind = \"any\"\nsealed = true\n\n\
|
||||||
|
[[triggers.kv]]\nscript = \"gh\"\ncollection_glob = \"catalog\"\nops = [\"insert\"]\n\
|
||||||
|
sealed = true\nshared = true\n"
|
||||||
|
);
|
||||||
|
let gpath = dir.path().join("picloud.toml");
|
||||||
|
fs::write(&gpath, &gmanifest).unwrap();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(&gpath)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
// Pull the group into a FRESH dir.
|
||||||
|
let out_dir = TempDir::new().expect("pull out dir");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["pull", "--group", &group, "--dir"])
|
||||||
|
.arg(out_dir.path())
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let manifest =
|
||||||
|
std::fs::read_to_string(out_dir.path().join("picloud.toml")).expect("picloud.toml written");
|
||||||
|
assert!(
|
||||||
|
manifest.contains("[group]") && manifest.contains(&format!("slug = \"{group}\"")),
|
||||||
|
"manifest must be a [group] node:\n{manifest}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
manifest.contains("catalog"),
|
||||||
|
"shared collection must round-trip:\n{manifest}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
manifest.contains("tier = \"gold\"") || manifest.contains("tier = 'gold'"),
|
||||||
|
"group var must round-trip:\n{manifest}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
manifest.contains("[[routes]]") && manifest.contains("/gpull"),
|
||||||
|
"route template must round-trip:\n{manifest}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
manifest.contains("[[triggers.kv]]") && manifest.contains("collection_glob = \"catalog\""),
|
||||||
|
"kv trigger template must round-trip:\n{manifest}"
|
||||||
|
);
|
||||||
|
// The group-only flags survive the round-trip (the whole point of B3).
|
||||||
|
assert!(
|
||||||
|
manifest.matches("sealed = true").count() >= 2,
|
||||||
|
"both the route and trigger `sealed = true` must round-trip:\n{manifest}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
manifest.contains("shared = true"),
|
||||||
|
"the trigger `shared = true` must round-trip:\n{manifest}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The exported script source landed too.
|
||||||
|
let src = std::fs::read_to_string(out_dir.path().join("scripts/gh.rhai"))
|
||||||
|
.expect("scripts/gh.rhai written");
|
||||||
|
assert!(src.contains("group handler"), "source mismatch:\n{src}");
|
||||||
|
|
||||||
|
// The strong round-trip proof: re-applying the PULLED manifest is accepted
|
||||||
|
// (a faithful export re-plans clean; the server would reject a malformed one).
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apply", "--file"])
|
||||||
|
.arg(out_dir.path().join("picloud.toml"))
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user