Files
PiCloud/crates/picloud-cli/src/discover.rs
MechaCat02 654e38752d feat(project-tool): per-env approval-policy gating (§4.2/§6)
Salvaged from the superseded feat/hierarchies-extension-points branch (M5),
re-ported onto main's evolved tree-apply engine. Fills the §7/§11.1 "per-env
approval-policy gating" that main's design doc still lists as intended but
unbuilt.

A project's root manifest declares which environments require explicit
approval; `pic apply --dir --env <e>` to a confirm-required env needs an
explicit `--approve <e>` (a blanket `--yes` does NOT cover it). The approval
is admin-gated (admin on every declared node) and audited, and the policy
folds into the bound-plan token.

- manifest: `[project]` block → ManifestProject { environments[{name,confirm}] },
  root-manifest-only (rejected elsewhere by build_tree).
- discover: build_tree emits `project` into the bundle from the root manifest.
- apply_service: TreeBundle.project + ProjectPolicy; policy folds into the
  state_token; plan surfaces approvals_required; ApplyError::ApprovalRequired
  → 409.
- apply_api: enforce_env_approval (after authz_tree) — refuses a gated env not
  in approved_envs, requires AppAdmin/GroupAdmin on every declared node on
  approval, audits. Server re-derives policy from the bundle (authoritative).
- CLI: --approve (repeatable); resolve_approvals refuses a gated env
  non-interactively, prompts on a TTY; plan renders gated envs. Single-node
  apply --file refuses a confirm-required env and directs to --dir.
- approval journey + manifest/ProjectPolicy unit tests. No migration (policy
  lives in the manifest, like takeover/blast-radius).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 19:51:11 +02:00

110 lines
4.1 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);
//! the server resolves ancestry from its own group tree, so the on-disk
//! nesting is organizational, not authoritative here.
use std::collections::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, 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}], project }`)
/// from every manifest under `root`. Returns the JSON, the node count, and the
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
/// tree that names the same (kind, slug) twice, or declares `[project]` anywhere
/// but the root manifest.
pub fn build_tree(
root: &Path,
env: Option<&str>,
) -> Result<(Value, usize, Vec<crate::manifest::ManifestEnvironment>)> {
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);
let mut nodes = Vec::with_capacity(paths.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut project: Option<crate::manifest::ManifestProject> = None;
for path in &paths {
let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?;
// `[project]` is project-level — valid only on the tree's root manifest.
if let Some(p) = &manifest.project {
if path == &root_manifest {
project = Some(p.clone());
} else {
bail!(
"{} declares [project], which is only valid on the root manifest ({})",
path.display(),
root_manifest.display()
);
}
}
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.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");
}
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
}
let count = nodes.len();
let envs = project
.as_ref()
.map(|p| p.environments.clone())
.unwrap_or_default();
let mut bundle = json!({ "nodes": nodes });
if let Some(p) = &project {
bundle.as_object_mut().expect("json object").insert(
"project".into(),
json!({
"environments": p.environments.iter()
.map(|e| json!({ "name": e.name, "confirm": e.confirm }))
.collect::<Vec<_>>(),
}),
);
}
Ok((bundle, count, envs))
}