`tests/structural_divergence.rs`: a repo that nests a group under a different parent than the server → `pic plan` shows it `diverged`; a bare apply is refused (422); `--force-local-structure` reparents to the manifest shape; `--adopt-server-structure` keeps the server placement. Design doc §6 records M2 shipped, leaving only per-env approval gating deferred. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 lines
5.7 KiB
Rust
168 lines
5.7 KiB
Rust
//! §6 M2 — structural-divergence detection + declarative reparent.
|
|
//!
|
|
//! Once the manifest declares a group's parent (by directory nesting), a
|
|
//! `pic apply --dir` compares it to the server's actual parent. A divergence is
|
|
//! REFUSED by default (Terraform detect-and-refuse); `--force-local-structure`
|
|
//! reparents the group to match the manifest, `--adopt-server-structure` keeps
|
|
//! the server's placement and reconciles content in place. `pic plan` previews
|
|
//! the divergence first.
|
|
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use crate::common;
|
|
use crate::common::cleanup::GroupGuard;
|
|
|
|
/// A repo that declares `mover` NESTED under `alt` (so its manifest parent is
|
|
/// `alt`), with `alt` at the attach point `base`.
|
|
fn diverging_repo(dir: &Path, project: &str, base: &str, alt: &str, mover: &str) {
|
|
fs::write(
|
|
dir.join("picloud.toml"),
|
|
format!(
|
|
"[project]\nslug = \"{project}\"\nparent_group = \"{base}\"\n\n\
|
|
[group]\nslug = \"{alt}\"\nname = \"Alt\"\n"
|
|
),
|
|
)
|
|
.unwrap();
|
|
let sub = dir.join("mover");
|
|
fs::create_dir_all(&sub).unwrap();
|
|
fs::write(
|
|
sub.join("picloud.toml"),
|
|
format!("[group]\nslug = \"{mover}\"\nname = \"Mover\"\n"),
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
fn server_parent(env: &common::TestEnv, group: &str) -> String {
|
|
let ls = common::pic_as(env)
|
|
.args(["groups", "ls"])
|
|
.output()
|
|
.expect("groups ls");
|
|
let table = String::from_utf8(ls.stdout).unwrap();
|
|
table
|
|
.lines()
|
|
.map(common::cells)
|
|
.find(|c| c.first() == Some(&group))
|
|
.and_then(|c| c.get(2).map(|s| (*s).to_string()))
|
|
.unwrap_or_else(|| panic!("group `{group}` not in groups ls:\n{table}"))
|
|
}
|
|
|
|
/// Pre-create base → {alt, mover} (both directly under base), so a repo that
|
|
/// nests `mover` under `alt` diverges.
|
|
fn seed(env: &common::TestEnv, base: &str, alt: &str, mover: &str) {
|
|
common::pic_as(env)
|
|
.args(["groups", "create", base])
|
|
.assert()
|
|
.success();
|
|
common::pic_as(env)
|
|
.args(["groups", "create", alt, "--parent", base])
|
|
.assert()
|
|
.success();
|
|
common::pic_as(env)
|
|
.args(["groups", "create", mover, "--parent", base])
|
|
.assert()
|
|
.success();
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn divergence_refused_by_default_then_force_local_reparents() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let base = common::unique_slug("sd-base");
|
|
let alt = common::unique_slug("sd-alt");
|
|
let mover = common::unique_slug("sd-mover");
|
|
let project = common::unique_slug("sd-proj");
|
|
let _b = GroupGuard::new(&env.url, &env.token, &base);
|
|
let _a = GroupGuard::new(&env.url, &env.token, &alt);
|
|
let _m = GroupGuard::new(&env.url, &env.token, &mover);
|
|
seed(&env, &base, &alt, &mover);
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
diverging_repo(dir.path(), &project, &base, &alt, &mover);
|
|
|
|
// Plan previews the divergence (server parent `base`, manifest parent `alt`).
|
|
let plan = String::from_utf8(
|
|
common::pic_as(&env)
|
|
.args(["plan", "--dir"])
|
|
.arg(dir.path())
|
|
.output()
|
|
.unwrap()
|
|
.stdout,
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
plan.contains("diverged") && plan.contains(&base) && plan.contains(&alt),
|
|
"plan should show `mover` diverged (server={base}, manifest={alt}):\n{plan}"
|
|
);
|
|
|
|
// A bare apply is REFUSED on the divergence (422).
|
|
let out = common::pic_as(&env)
|
|
.args(["apply", "--dir"])
|
|
.arg(dir.path())
|
|
.output()
|
|
.expect("apply");
|
|
assert!(!out.status.success(), "a divergence must refuse by default");
|
|
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
|
|
assert!(
|
|
err.contains("structural") || err.contains("force-local") || err.contains("different parent"),
|
|
"the refusal must name the resolution flags:\n{err}"
|
|
);
|
|
assert_eq!(
|
|
server_parent(&env, &mover),
|
|
base,
|
|
"a refused apply must not move the group"
|
|
);
|
|
|
|
// `--force-local-structure` reparents `mover` to `alt` (the manifest shape).
|
|
common::pic_as(&env)
|
|
.args(["apply", "--dir"])
|
|
.arg(dir.path())
|
|
.arg("--force-local-structure")
|
|
.assert()
|
|
.success();
|
|
assert_eq!(
|
|
server_parent(&env, &mover),
|
|
alt,
|
|
"--force-local-structure must reparent to the manifest's parent"
|
|
);
|
|
}
|
|
|
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
|
#[test]
|
|
fn adopt_server_structure_keeps_the_server_placement() {
|
|
let Some(fx) = common::fixture_or_skip() else {
|
|
return;
|
|
};
|
|
let env = common::admin_env(fx);
|
|
let base = common::unique_slug("sda-base");
|
|
let alt = common::unique_slug("sda-alt");
|
|
let mover = common::unique_slug("sda-mover");
|
|
let project = common::unique_slug("sda-proj");
|
|
let _b = GroupGuard::new(&env.url, &env.token, &base);
|
|
let _a = GroupGuard::new(&env.url, &env.token, &alt);
|
|
let _m = GroupGuard::new(&env.url, &env.token, &mover);
|
|
seed(&env, &base, &alt, &mover);
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
diverging_repo(dir.path(), &project, &base, &alt, &mover);
|
|
|
|
// `--adopt-server-structure` proceeds without reparenting — `mover` stays
|
|
// under `base` (its server placement), and content reconciles in place.
|
|
common::pic_as(&env)
|
|
.args(["apply", "--dir"])
|
|
.arg(dir.path())
|
|
.arg("--adopt-server-structure")
|
|
.assert()
|
|
.success();
|
|
assert_eq!(
|
|
server_parent(&env, &mover),
|
|
base,
|
|
"--adopt-server-structure must keep the server's parent"
|
|
);
|
|
}
|