feat(cli): pic secrets --group, value read, and effective vars/config

Mirrors `pic vars` on the secrets surface: `pic secrets ls/set/rm` take an
`--app`/`--group` owner selector (exactly-one) with `--env` for group
secrets. Adds `pic secrets read --group <g> <name> [--env]` — the only
command that reveals a secret value, hitting the group-gated value
endpoint. `pic config --effective` now folds in the resolved vars section
(value + owner + scope) and an `--explain` provenance view, alongside the
existing masked-secrets cross-reference, via `GET /config/effective`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-24 22:00:32 +02:00
parent c914758c09
commit 30441549d5
5 changed files with 347 additions and 70 deletions

View File

@@ -1,26 +1,38 @@
//! `pic config --effective` — read-only view of an app's resolved
//! configuration, with secret values masked (§4.6).
//!
//! Single-app today: "config" means the app's secrets, cross-referenced
//! against the manifest so an operator can see at a glance which declared
//! secrets are still unset and which live secrets aren't declared. Values are
//! never fetched or shown — only `<set>` / `<unset>`. The command is shaped to
//! grow an `--explain` mode and inherited `vars` once groups/vars land (the
//! Phase-3 multi-level resolution).
//! 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;
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<()> {
@@ -34,24 +46,54 @@ pub async fn run(
// base slug — when an overlay re-points slug/secrets.
let manifest = Manifest::load_with_env(manifest_path, env)?;
let declared: BTreeSet<String> = manifest.secrets.names.iter().cloned().collect();
let on_server: BTreeSet<String> = client
.secrets_list(&manifest.app.slug)
.await?
.secrets
.into_iter()
.map(|s| s.name)
.collect();
let eff = client.config_effective(&manifest.app.slug).await?;
let mut table = Table::new(["secret", "value", "status"]);
// --- 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 secret set`"),
(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"),
};
table.row([name.clone(), value.to_string(), status.to_string()]);
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(())