feat(cli): pic config --effective (masked secret resolution)
The §11 Phase-1 `config --effective` surface. Single-app today, "config" = the app's secrets, so it cross-references the manifest's declared secret names against what's set on the server and renders each masked: `<set>` (managed / on-server-not-in-manifest) or `<unset>` (declared but not pushed). Values are never fetched or shown (§4.6). Shaped to grow an `--explain` mode and inherited `vars` when groups/vars arrive (Phase 3). Tested: a journey verifying declared-but-unset → `<unset>`, post-`secret set` → `<set>`/managed, and the value never appears in output. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
50
crates/picloud-cli/src/cmds/config.rs
Normal file
50
crates/picloud-cli/src/cmds/config.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//! `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).
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
use crate::client::Client;
|
||||||
|
use crate::config;
|
||||||
|
use crate::manifest::Manifest;
|
||||||
|
use crate::output::{OutputMode, Table};
|
||||||
|
|
||||||
|
pub async fn run(manifest_path: &Path, effective: bool, mode: OutputMode) -> Result<()> {
|
||||||
|
if !effective {
|
||||||
|
bail!("`pic config` currently supports only `--effective`");
|
||||||
|
}
|
||||||
|
let creds = config::resolve()?;
|
||||||
|
let client = Client::from_creds(&creds)?;
|
||||||
|
let manifest = Manifest::load(manifest_path)?;
|
||||||
|
|
||||||
|
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 mut table = Table::new(["secret", "value", "status"]);
|
||||||
|
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`"),
|
||||||
|
(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()]);
|
||||||
|
}
|
||||||
|
table.print(mode);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ pub mod api_keys;
|
|||||||
pub mod apply;
|
pub mod apply;
|
||||||
pub mod apps;
|
pub mod apps;
|
||||||
pub mod apps_domains;
|
pub mod apps_domains;
|
||||||
|
pub mod config;
|
||||||
pub mod dead_letters;
|
pub mod dead_letters;
|
||||||
pub mod files;
|
pub mod files;
|
||||||
pub mod init;
|
pub mod init;
|
||||||
|
|||||||
@@ -177,6 +177,10 @@ enum Cmd {
|
|||||||
/// app), `scripts/hello.rhai`, and a `.gitignore`. Offline; deploy with
|
/// app), `scripts/hello.rhai`, and a `.gitignore`. Offline; deploy with
|
||||||
/// `pic plan` then `pic apply`.
|
/// `pic plan` then `pic apply`.
|
||||||
Init(InitArgs),
|
Init(InitArgs),
|
||||||
|
|
||||||
|
/// Show an app's resolved configuration. `--effective` lists secrets with
|
||||||
|
/// values masked (`<set>`/`<unset>`), cross-referenced against the manifest.
|
||||||
|
Config(ConfigArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
#[derive(Args)]
|
||||||
@@ -230,6 +234,16 @@ struct InitArgs {
|
|||||||
force: bool,
|
force: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Args)]
|
||||||
|
struct ConfigArgs {
|
||||||
|
/// Path to the manifest.
|
||||||
|
#[arg(long, default_value = "picloud.toml")]
|
||||||
|
file: PathBuf,
|
||||||
|
/// Show the effective (resolved) config with secrets masked.
|
||||||
|
#[arg(long)]
|
||||||
|
effective: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum KvCmd {
|
enum KvCmd {
|
||||||
/// List keys in a collection.
|
/// List keys in a collection.
|
||||||
@@ -1122,6 +1136,7 @@ async fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
|
Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await,
|
||||||
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await,
|
||||||
|
Cmd::Config(args) => cmds::config::run(&args.file, args.effective, mode).await,
|
||||||
Cmd::Init(args) => cmds::init::run(
|
Cmd::Init(args) => cmds::init::run(
|
||||||
&args.dir,
|
&args.dir,
|
||||||
args.slug.as_deref(),
|
args.slug.as_deref(),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ mod api_keys;
|
|||||||
mod apply;
|
mod apply;
|
||||||
mod apps;
|
mod apps;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod config;
|
||||||
mod dead_letters;
|
mod dead_letters;
|
||||||
mod email_queue;
|
mod email_queue;
|
||||||
mod enabled;
|
mod enabled;
|
||||||
|
|||||||
57
crates/picloud-cli/tests/config.rs
Normal file
57
crates/picloud-cli/tests/config.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//! `pic config --effective` — masked secret resolution against the manifest.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
use crate::common;
|
||||||
|
use crate::common::cleanup::AppGuard;
|
||||||
|
|
||||||
|
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||||
|
#[test]
|
||||||
|
fn config_effective_masks_and_classifies_secrets() {
|
||||||
|
let Some(fx) = common::fixture_or_skip() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let env = common::admin_env(fx);
|
||||||
|
let slug = common::unique_slug("config");
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["apps", "create", &slug])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let manifest_path = dir.path().join("picloud.toml");
|
||||||
|
fs::write(
|
||||||
|
&manifest_path,
|
||||||
|
format!("[app]\nslug = \"{slug}\"\nname = \"Cfg\"\n\n[secrets]\nnames = [\"API_KEY\"]\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Declared but not pushed → masked + flagged unset; value never shown.
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["config", "--effective", "--file"])
|
||||||
|
.arg(&manifest_path)
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("API_KEY"))
|
||||||
|
.stdout(predicate::str::contains("<unset>"))
|
||||||
|
.stdout(predicate::str::contains("not pushed"));
|
||||||
|
|
||||||
|
// Push it → now masked as <set> / managed, still never the value.
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["secrets", "set", "--app", &slug, "API_KEY"])
|
||||||
|
.write_stdin("super-secret-value")
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
common::pic_as(&env)
|
||||||
|
.args(["config", "--effective", "--file"])
|
||||||
|
.arg(&manifest_path)
|
||||||
|
.assert()
|
||||||
|
.success()
|
||||||
|
.stdout(predicate::str::contains("<set>"))
|
||||||
|
.stdout(predicate::str::contains("managed"))
|
||||||
|
.stdout(predicate::str::contains("super-secret-value").not());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user