feat(vars): admin CRUD API + pic vars CLI
Completes the vars half of Phase 3 end-to-end:
- vars_repo: VarOwner{Group|App} upsert/delete/list (owner-kind-specific
SQL; ON CONFLICT restates the partial-index predicate).
- vars_api: GET/PUT/DELETE under /apps/{id}/vars and /groups/{id}/vars,
resolve-then-require gated on App/GroupVars{Read,Write}, secrets-style
error mapping + key/env-scope validation.
- pic vars ls/set/rm (--group|--app, --env, --json, --tombstone).
- journey test: a group var is inherited by a descendant app's
vars::get(), and an app-level value overrides it (proximity) — green.
386 manager-core lib tests + the vars journey pass; clippy clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,4 +22,5 @@ pub mod secrets;
|
||||
pub mod topics;
|
||||
pub mod triggers;
|
||||
pub mod users;
|
||||
pub mod vars;
|
||||
pub mod whoami;
|
||||
|
||||
85
crates/picloud-cli/src/cmds/vars.rs
Normal file
85
crates/picloud-cli/src/cmds/vars.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
//! `pic vars ls | set | rm` — manage Phase-3 group/app config vars.
|
||||
//!
|
||||
//! Wraps `/api/v1/admin/{apps,groups}/{id}/vars*`. Exactly one of
|
||||
//! `--group` / `--app` selects the owner. `ls` shows the owner's OWN rows
|
||||
//! (not the resolved/inherited view). Set values are JSON strings by
|
||||
//! default; `--json` parses the value as raw JSON.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
use crate::client::{Client, VarOwnerArg};
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
/// Resolve the `--group`/`--app` pair into exactly one owner.
|
||||
fn owner<'a>(group: Option<&'a str>, app: Option<&'a str>) -> Result<VarOwnerArg<'a>> {
|
||||
match (group, app) {
|
||||
(Some(g), None) => Ok(VarOwnerArg::Group(g)),
|
||||
(None, Some(a)) => Ok(VarOwnerArg::App(a)),
|
||||
(Some(_), Some(_)) => Err(anyhow!("pass exactly one of --group / --app, not both")),
|
||||
(None, None) => Err(anyhow!("pass one of --group / --app")),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ls(group: Option<&str>, app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let resp = client.vars_list(owner).await?;
|
||||
let mut table = Table::new(["key", "env", "value", "tombstone", "updated_at"]);
|
||||
for v in resp.vars {
|
||||
table.row([
|
||||
v.key,
|
||||
v.env,
|
||||
v.value.to_string(),
|
||||
v.is_tombstone.to_string(),
|
||||
v.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
key: &str,
|
||||
value: &str,
|
||||
env: Option<&str>,
|
||||
as_json: bool,
|
||||
tombstone: bool,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// A tombstone carries no value (the server stores JSON null + the
|
||||
// deletion marker); otherwise parse per `--json`.
|
||||
let parsed = if tombstone {
|
||||
serde_json::Value::Null
|
||||
} else if as_json {
|
||||
serde_json::from_str(value).map_err(|e| anyhow!("parse value as JSON: {e}"))?
|
||||
} else {
|
||||
serde_json::Value::String(value.to_string())
|
||||
};
|
||||
client.vars_set(owner, key, parsed, env, tombstone).await?;
|
||||
if tombstone {
|
||||
println!("Set tombstone for {key}");
|
||||
} else {
|
||||
println!("Set var {key}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(
|
||||
group: Option<&str>,
|
||||
app: Option<&str>,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let owner = owner(group, app)?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.vars_delete(owner, key, env).await?;
|
||||
println!("Deleted var {key}");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user