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,
|
||||
/// §11.6: `true` for a shared-collection template.
|
||||
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
|
||||
@@ -838,12 +851,17 @@ pub struct TriggerTemplateInfo {
|
||||
/// rows, so the semantic bits are shown instead.
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
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 path_kind: String,
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
pub script: String,
|
||||
pub dispatch: String,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||
pub sealed: bool,
|
||||
@@ -3651,6 +3669,9 @@ impl ApplyService {
|
||||
enabled: t.enabled,
|
||||
sealed: t.sealed,
|
||||
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())
|
||||
@@ -3681,16 +3702,14 @@ impl ApplyService {
|
||||
Ok(routes
|
||||
.into_iter()
|
||||
.map(|r| RouteTemplateInfo {
|
||||
method: r.method.clone().unwrap_or_else(|| "ANY".into()),
|
||||
host: match r.host_kind {
|
||||
HostKind::Any => "any".to_string(),
|
||||
HostKind::Strict => format!("strict:{}", r.host),
|
||||
HostKind::Wildcard => format!("*.{}", r.host),
|
||||
},
|
||||
path_kind: path_kind_str(r.path_kind).to_string(),
|
||||
method: r.method.clone(),
|
||||
host_kind: r.host_kind,
|
||||
host: r.host.clone(),
|
||||
host_param_name: r.host_param_name.clone(),
|
||||
path_kind: r.path_kind,
|
||||
path: r.path.clone(),
|
||||
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,
|
||||
sealed: r.sealed,
|
||||
})
|
||||
|
||||
@@ -1702,7 +1702,9 @@ pub struct SuppressionDto {
|
||||
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)]
|
||||
pub struct TriggerTemplateDto {
|
||||
pub kind: String,
|
||||
@@ -1717,23 +1719,36 @@ pub struct TriggerTemplateDto {
|
||||
/// §11.6: `true` for a shared-collection template.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
pub struct RouteTemplateDto {
|
||||
#[serde(default)]
|
||||
pub method: String,
|
||||
pub method: Option<String>,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
#[serde(default)]
|
||||
pub path_kind: String,
|
||||
pub host_param_name: Option<String>,
|
||||
pub path_kind: PathKind,
|
||||
#[serde(default)]
|
||||
pub path: String,
|
||||
#[serde(default)]
|
||||
pub script: String,
|
||||
#[serde(default)]
|
||||
pub dispatch: String,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
pub enabled: bool,
|
||||
/// §11 tail: `true` for a sealed (non-suppressible) template.
|
||||
#[serde(default)]
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
//! `pic pull <app> [--dir .]` — export an app's current server state into
|
||||
//! a `picloud.toml` manifest plus `scripts/<name>.rhai` source files, for
|
||||
//! declarative management with `pic plan` / `pic apply`.
|
||||
//! `pic pull <app> [--dir .]` / `pic pull --group <slug>` — export a node's
|
||||
//! current server state into a `picloud.toml` manifest plus
|
||||
//! `scripts/<name>.rhai` source files, for declarative management with `pic
|
||||
//! plan` / `pic apply`.
|
||||
//!
|
||||
//! Read-only: issues `GET`s only and writes local files. Every trigger
|
||||
//! kind is exported except `email` — the server stores the *sealed secret
|
||||
//! value*, not the secret name, so the manifest's `inbound_secret_ref`
|
||||
//! can't be reconstructed (email triggers must be set up by hand).
|
||||
//! kind is exported except `email` (the server stores the *sealed secret
|
||||
//! value*, not the secret name, so `inbound_secret_ref` can't be
|
||||
//! reconstructed) and `dead_letter` (not manifest-representable) — both are
|
||||
//! skipped with a warning.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -14,25 +16,40 @@ use anyhow::{Context, Result};
|
||||
use picloud_shared::{DispatchMode, DocsEventOp, FilesEventOp, KvEventOp, ScriptId};
|
||||
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::manifest::{
|
||||
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
|
||||
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, ManifestWorkflow,
|
||||
ManifestWorkflowRetry, ManifestWorkflowStep, PubsubTriggerSpec, QueueTriggerSpec,
|
||||
MANIFEST_FILE,
|
||||
CollectionDecl, CollectionKind, CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec,
|
||||
KvTriggerSpec, Manifest, ManifestApp, ManifestGroup, ManifestRoute, ManifestScript,
|
||||
ManifestSecrets, ManifestSuppress, ManifestTriggers, ManifestWorkflow, ManifestWorkflowRetry,
|
||||
ManifestWorkflowStep, PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
|
||||
};
|
||||
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 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`
|
||||
// overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a
|
||||
// stray `pic pull <wrong-app>` in a populated dir would destroy local
|
||||
// edits. Fail fast — before any network call or file write — unless the
|
||||
// operator opted in with `--force`.
|
||||
/// Refuse to clobber an existing project (mirrors `pic init`). `pull` overwrites
|
||||
/// `picloud.toml` and every colliding `scripts/*.rhai`, so a stray `pic pull
|
||||
/// <wrong-node>` in a populated dir would destroy local edits. Fail fast —
|
||||
/// before any network call or file write — unless the operator opted in with
|
||||
/// `--force`.
|
||||
fn check_clobber(dir: &Path, force: bool) -> Result<std::path::PathBuf> {
|
||||
let manifest_path = dir.join(MANIFEST_FILE);
|
||||
if !force && manifest_path.exists() {
|
||||
anyhow::bail!(
|
||||
@@ -41,6 +58,17 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
|
||||
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).
|
||||
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.
|
||||
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,
|
||||
});
|
||||
}
|
||||
let manifest_scripts = write_scripts(dir, &scripts)?;
|
||||
|
||||
// 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 skipped: Vec<String> = Vec::new();
|
||||
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)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| t.script_id.to_string());
|
||||
let dispatch_mode = DispatchMode::from_wire(&t.dispatch_mode);
|
||||
let retry_max_attempts = Some(t.retry_max_attempts);
|
||||
match t.kind.as_str() {
|
||||
"kv" => {
|
||||
let d: CollectionDetails<KvEventOp> = decode_details(&t.details, &t.kind)?;
|
||||
manifest_triggers.kv.push(KvTriggerSpec {
|
||||
script,
|
||||
collection_glob: d.collection_glob,
|
||||
ops: d.ops,
|
||||
dispatch_mode,
|
||||
retry_max_attempts,
|
||||
// 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");
|
||||
push_trigger(
|
||||
&mut manifest_triggers,
|
||||
&mut skipped,
|
||||
&t.kind,
|
||||
&t.details,
|
||||
script,
|
||||
DispatchMode::from_wire(&t.dispatch_mode),
|
||||
Some(t.retry_max_attempts),
|
||||
false,
|
||||
false,
|
||||
&t.id.to_string(),
|
||||
)?;
|
||||
}
|
||||
warn_skipped(&skipped);
|
||||
|
||||
let manifest = Manifest {
|
||||
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(),
|
||||
},
|
||||
vars: manifest_vars,
|
||||
suppress: crate::manifest::ManifestSuppress::default(),
|
||||
suppress: ManifestSuppress::default(),
|
||||
workflows: workflows.iter().map(wire_workflow_to_manifest).collect(),
|
||||
// `pull` does not round-trip interceptor markers yet (read surface TBD);
|
||||
// an interceptor authored in the manifest survives re-apply regardless.
|
||||
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()))?;
|
||||
|
||||
let owner_label = if manifest.is_group() { "group" } else { "app" };
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.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("routes", manifest.routes.len().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 {
|
||||
table.row([
|
||||
r.method,
|
||||
r.host,
|
||||
r.path_kind,
|
||||
r.method.clone().unwrap_or_else(|| "ANY".into()),
|
||||
host_label(r.host_kind, &r.host),
|
||||
path_kind_label(r.path_kind).to_string(),
|
||||
r.path,
|
||||
r.script,
|
||||
r.dispatch,
|
||||
dispatch_label(r.dispatch_mode).to_string(),
|
||||
r.enabled.to_string(),
|
||||
r.sealed.to_string(),
|
||||
]);
|
||||
|
||||
@@ -309,8 +309,11 @@ struct PlanArgs {
|
||||
|
||||
#[derive(Args)]
|
||||
struct PullArgs {
|
||||
/// App slug or id to export.
|
||||
app: String,
|
||||
/// App slug or id to export (positional). Omit when using `--group`.
|
||||
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.
|
||||
#[arg(long, default_value = ".")]
|
||||
dir: PathBuf,
|
||||
@@ -1569,7 +1572,16 @@ async fn main() -> ExitCode {
|
||||
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,
|
||||
},
|
||||
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) => {
|
||||
cmds::config::run(
|
||||
&args.file,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
//! `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.
|
||||
//!
|
||||
//! 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 crate::common;
|
||||
use crate::common::cleanup::{GroupGuard, ScriptGuard};
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
@@ -89,3 +97,123 @@ fn pull_exports_manifest_and_sources() {
|
||||
|
||||
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