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>
220 lines
7.5 KiB
Rust
220 lines
7.5 KiB
Rust
//! `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]
|
|
fn pull_exports_manifest_and_sources() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
|
|
// App + script "hello" (deploy derives the name from the file stem).
|
|
let (script_id, guard) = common::deploy_fixture(&env, "pull", "hello.rhai");
|
|
let app = guard.slug().to_string();
|
|
|
|
// Route → script.
|
|
common::pic_as(&env)
|
|
.args([
|
|
"routes", "create", "--script", &script_id, "--path", "/hook", "--method", "POST",
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
// Cron trigger → script.
|
|
common::pic_as(&env)
|
|
.args([
|
|
"triggers",
|
|
"create-cron",
|
|
"--app",
|
|
&app,
|
|
"--script",
|
|
&script_id,
|
|
"--schedule",
|
|
"0 0 * * * *",
|
|
])
|
|
.assert()
|
|
.success();
|
|
|
|
// Secret (name only ends up in the manifest; value stays server-side).
|
|
common::pic_as(&env)
|
|
.args(["secrets", "set", "--app", &app, "api_key"])
|
|
.write_stdin("xyzzy")
|
|
.assert()
|
|
.success();
|
|
|
|
// Pull into a scratch dir.
|
|
let out_dir = TempDir::new().expect("pull tempdir");
|
|
common::pic_as(&env)
|
|
.args(["pull", &app, "--dir"])
|
|
.arg(out_dir.path())
|
|
.assert()
|
|
.success();
|
|
|
|
// Manifest exists and captures every resource.
|
|
let manifest = std::fs::read_to_string(out_dir.path().join("picloud.toml"))
|
|
.expect("picloud.toml should be written");
|
|
assert!(
|
|
manifest.contains(&format!("slug = \"{app}\"")),
|
|
"manifest missing app slug:\n{manifest}"
|
|
);
|
|
assert!(
|
|
manifest.contains("name = \"hello\"") && manifest.contains("scripts/hello.rhai"),
|
|
"manifest missing script entry:\n{manifest}"
|
|
);
|
|
assert!(
|
|
manifest.contains("[[routes]]") && manifest.contains("path = \"/hook\""),
|
|
"manifest missing route:\n{manifest}"
|
|
);
|
|
assert!(
|
|
manifest.contains("[[triggers.cron]]") && manifest.contains("schedule = \"0 0 * * * *\""),
|
|
"manifest missing cron trigger:\n{manifest}"
|
|
);
|
|
assert!(
|
|
manifest.contains("api_key"),
|
|
"manifest missing secret name:\n{manifest}"
|
|
);
|
|
|
|
// Script source was written out faithfully.
|
|
let src = std::fs::read_to_string(out_dir.path().join("scripts/hello.rhai"))
|
|
.expect("scripts/hello.rhai should be written");
|
|
assert!(
|
|
src.contains("hello from pic"),
|
|
"exported source mismatch:\n{src}"
|
|
);
|
|
|
|
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();
|
|
}
|