Lift "groups must pre-exist" for the tree apply: a `[group]` node now carries a
declared parent (inferred from directory nesting) and `pic apply --dir` creates
the ones the server lacks, in the same transaction.
Wire: `TreeNode` gains `parent`/`name`/`description` (`#[serde(default)]` — a
pre-§6 CLI still reconciles existing groups). `discover::build_tree` computes a
group's parent as the nearest ancestor directory holding a `[group]` (topmost →
the repo's `[project] parent_group` attach point, else instance root) and emits
nodes parents-first by directory depth.
Server: `apply_tree` runs a Phase 0 (`create_missing_groups_tx`) that
resolves-or-creates every group node IN the tx (a same-tree parent resolved
before its child via the new `group_repo::{create_group_tx,
read_group_id_by_slug_tx}`), under the coarse `GROUP_STRUCTURAL_LOCK_KEY` (taken
only when creating), enforcing the attach-point ceiling + RBAC
(`InstanceCreateGroup` / `GroupAdmin(parent)`) per create. A created group is
CLAIMED in Phase A alongside existing ones (`decide_group_claim` → `Claim`).
`prepare_tree` now takes a caller-supplied `resolved_ids` map and returns a
`to_create` list the plan path previews (empty current → full-create plan,
ownership `claim`); a to-create group's token part equals its post-create part
so plan/apply tokens match across a create. `validate_bundle_for` takes
`is_group: bool` (a to-create group has no id yet); `authz_tree` skips a
to-create group (its create authz is the service gate, not an existing-group
capability).
Reparent of a diverged existing group + the detect-and-refuse flags are M2.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
6.2 KiB
Rust
150 lines
6.2 KiB
Rust
//! Phase 5: discover a project *tree* — every `picloud.toml` under a root
|
|
//! directory — and assemble the wire tree bundle the server's `/tree/{plan,
|
|
//! apply}` endpoints consume. Each manifest becomes one node (app or group).
|
|
//!
|
|
//! §6: a GROUP node's PARENT is inferred from directory nesting — its parent is
|
|
//! the nearest ancestor directory that also holds a `[group]` manifest; the
|
|
//! topmost group binds to the repo's `[project] parent_group` (attach point) or
|
|
//! the instance root. The server uses this declared parent to create a missing
|
|
//! group and to detect structural divergence (M2). An app node declares no
|
|
//! parent (an app pre-exists with a fixed group).
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use serde_json::{json, Value};
|
|
|
|
use crate::cmds::plan::build_bundle;
|
|
use crate::manifest::{Manifest, ManifestProject, MANIFEST_FILE};
|
|
|
|
/// Find every `picloud.toml` under `root` (recursively), skipping the
|
|
/// `.picloud/` link-state dir, `.git`, and `scripts/` source dirs. Per-env
|
|
/// overlays (`picloud.<env>.toml`) are not matched — only the base filename.
|
|
pub fn find_manifests(root: &Path) -> Result<Vec<PathBuf>> {
|
|
let mut out = Vec::new();
|
|
walk(root, &mut out)?;
|
|
out.sort();
|
|
Ok(out)
|
|
}
|
|
|
|
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
|
let entries =
|
|
std::fs::read_dir(dir).with_context(|| format!("reading directory {}", dir.display()))?;
|
|
for entry in entries {
|
|
let entry = entry?;
|
|
let name = entry.file_name();
|
|
let ft = entry.file_type()?;
|
|
if ft.is_dir() {
|
|
if matches!(
|
|
name.to_str(),
|
|
Some(".picloud" | ".git" | "scripts" | "target")
|
|
) {
|
|
continue;
|
|
}
|
|
walk(&entry.path(), out)?;
|
|
} else if name == MANIFEST_FILE {
|
|
out.push(entry.path());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
|
|
/// manifest under `root`. Returns the JSON, the node count, and the owning
|
|
/// `[project]` (§7) declared in the tree ROOT's `picloud.toml` (if any) — one
|
|
/// project owns the whole tree; a `[project]` in any other node is ignored with
|
|
/// a note. Rejects a tree that names the same (kind, slug) twice.
|
|
pub fn build_tree(
|
|
root: &Path,
|
|
env: Option<&str>,
|
|
) -> Result<(Value, usize, Option<ManifestProject>)> {
|
|
let paths = find_manifests(root)?;
|
|
if paths.is_empty() {
|
|
bail!(
|
|
"no {MANIFEST_FILE} found under {} — nothing to apply",
|
|
root.display()
|
|
);
|
|
}
|
|
let root_manifest = root.join(MANIFEST_FILE);
|
|
// Load every manifest once, remembering its directory so we can infer group
|
|
// parentage from the nesting.
|
|
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
|
|
let mut project: Option<ManifestProject> = None;
|
|
for path in &paths {
|
|
let manifest = Manifest::load_with_env(path, env)
|
|
.with_context(|| format!("loading {}", path.display()))?;
|
|
if let Some(p) = &manifest.project {
|
|
if path.as_path() == root_manifest {
|
|
project = Some(p.clone());
|
|
} else {
|
|
eprintln!(
|
|
"note: [project] in {} is ignored — declare it in the tree root {}",
|
|
path.display(),
|
|
root_manifest.display()
|
|
);
|
|
}
|
|
}
|
|
loaded.push((path.clone(), manifest));
|
|
}
|
|
|
|
// Map each GROUP manifest's DIRECTORY → its slug, so a group node can find
|
|
// its nearest ancestor-directory group (its declared parent).
|
|
let group_dir_to_slug: HashMap<PathBuf, String> = loaded
|
|
.iter()
|
|
.filter(|(_, m)| m.is_group())
|
|
.map(|(path, m)| {
|
|
let dir = path
|
|
.parent()
|
|
.unwrap_or_else(|| Path::new("."))
|
|
.to_path_buf();
|
|
(dir, m.slug().to_string())
|
|
})
|
|
.collect();
|
|
let attach_point = project.as_ref().and_then(|p| p.parent_group.clone());
|
|
|
|
// Build (sort_key, node) pairs. Emit groups before apps, and shallower
|
|
// groups before deeper ones (a parent's directory has fewer components than
|
|
// its child's), so the server creates a parent group before the child that
|
|
// nests under it.
|
|
let mut keyed: Vec<((bool, usize), Value)> = Vec::with_capacity(loaded.len());
|
|
let mut seen: HashSet<(String, String)> = HashSet::new();
|
|
for (path, manifest) in &loaded {
|
|
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
|
let bundle = build_bundle(manifest, base_dir)?;
|
|
let is_group = manifest.is_group();
|
|
let kind = if is_group { "group" } else { "app" };
|
|
let slug = manifest.slug().to_string();
|
|
if !seen.insert((kind.to_string(), slug.clone())) {
|
|
bail!("project tree declares {kind} `{slug}` more than once");
|
|
}
|
|
let depth = base_dir.components().count();
|
|
let node = if is_group {
|
|
let parent = nearest_group_ancestor(base_dir, &group_dir_to_slug)
|
|
.or_else(|| attach_point.clone());
|
|
let g = manifest.group.as_ref();
|
|
let name = g.map(|g| g.name.clone());
|
|
let description = g.and_then(|g| g.description.clone());
|
|
json!({
|
|
"kind": kind, "slug": slug, "parent": parent,
|
|
"name": name, "description": description, "bundle": bundle,
|
|
})
|
|
} else {
|
|
json!({ "kind": kind, "slug": slug, "bundle": bundle })
|
|
};
|
|
keyed.push(((!is_group, depth), node));
|
|
}
|
|
keyed.sort_by(|a, b| a.0.cmp(&b.0));
|
|
let nodes: Vec<Value> = keyed.into_iter().map(|(_, n)| n).collect();
|
|
let count = nodes.len();
|
|
Ok((json!({ "nodes": nodes }), count, project))
|
|
}
|
|
|
|
/// The slug of the nearest STRICT ancestor directory of `dir` that holds a
|
|
/// `[group]` manifest, or `None` if there is no group above it in the tree.
|
|
fn nearest_group_ancestor(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
|
|
dir.ancestors()
|
|
.skip(1) // skip `dir` itself — we want a strict ancestor
|
|
.find_map(|anc| group_dirs.get(anc).cloned())
|
|
}
|