Phase 5 C2. A `picloud.toml` can now declare a `[group]` node (instead of
`[app]`): the group's own scripts + `[vars]` (+ secret names). `pic plan` /
`pic apply` reconcile it via the C1 group endpoints — the same plan/apply/
bound-token flow as an app, routed by node kind.
* `Manifest` is now app-XOR-group: `app`/`group` are both optional with an
exactly-one check in `parse`, plus a group-node guard that rejects
`[[routes]]`/`[[triggers]]` (those bind to an app). Accessors `slug()` /
`is_group()` replace the hardcoded `manifest.app.slug`.
* `client`: `plan_node`/`apply_node` take a `NodeKind { App | Group }` that
selects the `apps` vs `groups` API path; the old `plan`/`apply` wrappers
are gone (callers pass the kind).
* `pic plan`/`apply` detect the node kind from the manifest and label output
`group`/`app`; `pic config --effective` cleanly rejects a group manifest
(effective config is an app's inherited view).
Live-validated: a `[group]` manifest with a script + var → `pic plan` (script
+ var creates) → `pic apply` (group-owned) → idempotent re-plan noop →
`pic scripts ls --group` shows it. Manifest unit test for group parse +
app-only-block rejection; init/pull/plan/apply/overlay journeys all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
4.0 KiB
Rust
107 lines
4.0 KiB
Rust
//! `pic config --effective` — read-only view of an app's resolved
|
|
//! configuration, with secret values masked (§4.6).
|
|
//!
|
|
//! Two sections:
|
|
//! * `vars` — the resolved (group-inherited) config vars, each `key = value`
|
|
//! annotated with the owner that won (kind + depth) and its scope. Pass
|
|
//! `--explain` to also dump each var's `merged_from` provenance — the
|
|
//! ordered (depth, scope) layers that fed the resolution.
|
|
//! * `secrets` — masked statuses, cross-referenced against the manifest so an
|
|
//! operator can see which declared secrets are still unset and which live
|
|
//! secrets aren't declared. Values are never fetched or shown here; the
|
|
//! server reports only `<set>` / `<unset>` plus the owning layer.
|
|
//!
|
|
//! The vars + masked-secret owner info comes from the `/config/effective`
|
|
//! endpoint; the manifest is only used to flag `declared`/`unset` drift.
|
|
|
|
use std::collections::BTreeSet;
|
|
use std::path::Path;
|
|
|
|
use anyhow::{bail, Result};
|
|
|
|
use crate::client::{Client, EffectiveOwnerDto};
|
|
use crate::config;
|
|
use crate::manifest::Manifest;
|
|
use crate::output::{OutputMode, Table};
|
|
|
|
/// Render an owner as `kind@depth` (e.g. `group@1`, `app@0`).
|
|
fn owner_label(owner: &EffectiveOwnerDto) -> String {
|
|
format!("{}@{}", owner.kind, owner.depth)
|
|
}
|
|
|
|
pub async fn run(
|
|
manifest_path: &Path,
|
|
effective: bool,
|
|
explain: bool,
|
|
env: Option<&str>,
|
|
mode: OutputMode,
|
|
) -> Result<()> {
|
|
if !effective {
|
|
bail!("`pic config` currently supports only `--effective`");
|
|
}
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
// Resolve against the same env overlay as `pic plan`/`apply` so the
|
|
// masked report targets the app those commands would act on — not the
|
|
// base slug — when an overlay re-points slug/secrets.
|
|
let manifest = Manifest::load_with_env(manifest_path, env)?;
|
|
if manifest.is_group() {
|
|
bail!(
|
|
"`pic config --effective` resolves an APP's inherited config; \
|
|
a [group] manifest has no single effective view"
|
|
);
|
|
}
|
|
|
|
let eff = client.config_effective(manifest.slug()).await?;
|
|
|
|
// --- vars: the resolved view, with winning owner + provenance. ---
|
|
let mut vars_table = Table::new(["key", "value", "owner", "scope"]);
|
|
for (key, var) in &eff.vars {
|
|
vars_table.row([
|
|
key.clone(),
|
|
var.value.to_string(),
|
|
owner_label(&var.owner),
|
|
var.scope.clone(),
|
|
]);
|
|
}
|
|
vars_table.print(mode);
|
|
|
|
if explain {
|
|
// Provenance: the (depth, scope) layers each resolved key merged from.
|
|
let mut prov = Table::new(["key", "depth", "scope"]);
|
|
for (key, var) in &eff.vars {
|
|
for layer in &var.merged_from {
|
|
prov.row([key.clone(), layer.depth.to_string(), layer.scope.clone()]);
|
|
}
|
|
}
|
|
prov.print(mode);
|
|
}
|
|
|
|
// --- secrets: masked status, folding manifest drift + owning layer. ---
|
|
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
|
|
let on_server: BTreeSet<String> = eff.secrets.keys().cloned().collect();
|
|
|
|
let mut table = Table::new(["secret", "value", "status", "owner", "scope"]);
|
|
for name in declared.union(&on_server) {
|
|
let (value, status) = match (declared.contains(name), on_server.contains(name)) {
|
|
(true, true) => ("<set>", "managed"),
|
|
(true, false) => ("<unset>", "declared, not pushed — `pic secrets set`"),
|
|
(false, true) => ("<set>", "on server, not in manifest"),
|
|
(false, false) => unreachable!("name came from one of the two sets"),
|
|
};
|
|
let (owner, scope) = match eff.secrets.get(name) {
|
|
Some(s) => (owner_label(&s.owner), s.scope.clone()),
|
|
None => ("-".to_string(), "-".to_string()),
|
|
};
|
|
table.row([
|
|
name.clone(),
|
|
value.to_string(),
|
|
status.to_string(),
|
|
owner,
|
|
scope,
|
|
]);
|
|
}
|
|
table.print(mode);
|
|
Ok(())
|
|
}
|