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:
MechaCat02
2026-07-15 21:19:59 +02:00
parent e4a58dc140
commit 35345e62de
6 changed files with 562 additions and 165 deletions

View File

@@ -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,
}) })

View File

@@ -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)]

View File

@@ -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())

View File

@@ -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(),
]); ]);

View File

@@ -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,

View 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();
}