Remediation of the single-app reconcile foundation after two independent review passes. No new feature surface — closes correctness, parity, and safety gaps in pull/plan/apply/prune. apply engine (manager-core): - validate_bundle reached parity with the interactive trigger API: reject an empty kv/docs/files collection_glob and a malformed pubsub topic_pattern (previously written and silently never matched), and lift the queue visibility floor to MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30) — apply had accepted a [5,29] value the dashboard refuses. Per-kind checks extracted into a pure, unit-tested validate_trigger_shape. - An omitted script `description` now means "leave as-is" (matching the other optional fields and the function's own documented contract) instead of clearing the stored value. - Doc fixes: drop stale "next milestone" notes; record the one deliberate plan/apply divergence (set-but-empty secret); document delete_route_tx as intentionally idempotent for reconcile. CLI: - Gate destructive `apply --prune` behind confirmation: interactive y/N, or `--yes` for CI; a non-interactive prune without `--yes` refuses rather than silently deleting. Journey tests pass `--yes`; a new test proves the gate refuses and deletes nothing. - Harden pull's filename safety: reject all control characters and the Unicode bidi-override / zero-width chars used for terminal/filename spoofing, and cap length at 200 bytes (NAME_MAX headroom). Tested: manager-core lib (incl. queue-floor + sparse-description parity) + CLI bins + 9 project-tool journeys green; clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
5.7 KiB
Rust
178 lines
5.7 KiB
Rust
//! `pic apply --prune` journey: a resource dropped from the manifest
|
|
//! survives a plain (additive) apply but is deleted with `--prune`.
|
|
|
|
use std::fs;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use crate::common;
|
|
use crate::common::cleanup::AppGuard;
|
|
|
|
fn scripts_ls(env: &common::TestEnv, slug: &str) -> String {
|
|
String::from_utf8(
|
|
common::pic_as(env)
|
|
.args(["scripts", "ls", "--app", slug])
|
|
.output()
|
|
.unwrap()
|
|
.stdout,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn prune_deletes_stale_resources() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let slug = common::unique_slug("prune");
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &slug])
|
|
.assert()
|
|
.success();
|
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
|
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
|
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
|
let manifest_path = dir.path().join("picloud.toml");
|
|
|
|
// v1: two scripts + a route on `drop`.
|
|
let v1 = format!(
|
|
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
|
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
|
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n\n\
|
|
[[routes]]\nscript = \"drop\"\nmethod = \"GET\"\n\
|
|
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/drop\"\n"
|
|
);
|
|
fs::write(&manifest_path, &v1).unwrap();
|
|
common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&manifest_path)
|
|
.assert()
|
|
.success();
|
|
|
|
// v2: drop `drop` and its route.
|
|
let v2 = format!(
|
|
"[app]\nslug = \"{slug}\"\nname = \"Prune Test\"\n\n\
|
|
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
|
);
|
|
fs::write(&manifest_path, &v2).unwrap();
|
|
|
|
// Plain apply is additive — `drop` survives.
|
|
common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&manifest_path)
|
|
.assert()
|
|
.success();
|
|
assert!(
|
|
scripts_ls(&env, &slug).contains("drop"),
|
|
"additive apply must not delete"
|
|
);
|
|
|
|
// Prune apply removes `drop` and its route. `--yes` skips the
|
|
// confirmation prompt (this test runs non-interactively).
|
|
let out = common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&manifest_path)
|
|
.args(["--prune", "--yes"])
|
|
.output()
|
|
.expect("apply --prune");
|
|
assert!(
|
|
out.status.success(),
|
|
"prune failed: {}",
|
|
String::from_utf8_lossy(&out.stderr)
|
|
);
|
|
let report = String::from_utf8(out.stdout).unwrap();
|
|
assert!(
|
|
report.contains("-1"),
|
|
"expected deletions in report:\n{report}"
|
|
);
|
|
|
|
let s = scripts_ls(&env, &slug);
|
|
assert!(!s.contains("drop"), "prune should delete `drop`:\n{s}");
|
|
assert!(s.contains("keep"), "prune must keep `keep`:\n{s}");
|
|
|
|
// Plan is clean after prune.
|
|
let p = String::from_utf8(
|
|
common::pic_as(&env)
|
|
.args(["plan", "--file"])
|
|
.arg(&manifest_path)
|
|
.output()
|
|
.unwrap()
|
|
.stdout,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
!p.contains("delete"),
|
|
"plan should be clean after prune:\n{p}"
|
|
);
|
|
}
|
|
|
|
/// The prune confirmation gate: `--prune` without `--yes`, run
|
|
/// non-interactively (no TTY, as in CI), must refuse and delete nothing.
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn prune_without_yes_refuses_noninteractively() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let slug = common::unique_slug("prune-gate");
|
|
common::pic_as(&env)
|
|
.args(["apps", "create", &slug])
|
|
.assert()
|
|
.success();
|
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
fs::create_dir_all(dir.path().join("scripts")).unwrap();
|
|
fs::write(dir.path().join("scripts/keep.rhai"), "let x = 1; x").unwrap();
|
|
fs::write(dir.path().join("scripts/drop.rhai"), "let y = 2; y").unwrap();
|
|
let manifest_path = dir.path().join("picloud.toml");
|
|
|
|
// Deploy two scripts, then drop one from the manifest.
|
|
let v1 = format!(
|
|
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
|
|
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n\n\
|
|
[[scripts]]\nname = \"drop\"\nfile = \"scripts/drop.rhai\"\n"
|
|
);
|
|
fs::write(&manifest_path, &v1).unwrap();
|
|
common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&manifest_path)
|
|
.assert()
|
|
.success();
|
|
let v2 = format!(
|
|
"[app]\nslug = \"{slug}\"\nname = \"Gate Test\"\n\n\
|
|
[[scripts]]\nname = \"keep\"\nfile = \"scripts/keep.rhai\"\n"
|
|
);
|
|
fs::write(&manifest_path, &v2).unwrap();
|
|
|
|
// `--prune` with no `--yes` and no TTY → refuse, non-zero exit.
|
|
let out = common::pic_as(&env)
|
|
.args(["apply", "--file"])
|
|
.arg(&manifest_path)
|
|
.arg("--prune")
|
|
.output()
|
|
.expect("apply --prune");
|
|
assert!(
|
|
!out.status.success(),
|
|
"prune without --yes must refuse non-interactively"
|
|
);
|
|
let err = String::from_utf8_lossy(&out.stderr);
|
|
assert!(
|
|
err.contains("--yes"),
|
|
"refusal should mention --yes:\n{err}"
|
|
);
|
|
|
|
// The dropped script must still be there — the gate blocked the delete.
|
|
let s = scripts_ls(&env, &slug);
|
|
assert!(
|
|
s.contains("drop"),
|
|
"refused prune must not delete anything:\n{s}"
|
|
);
|
|
}
|