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:
@@ -692,6 +692,54 @@ impl Client {
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// ---------- vars (Phase 3 config) ----------
|
||||
|
||||
/// `GET /api/v1/admin/{apps,groups}/{id}/vars` — the owner's OWN vars
|
||||
/// (not the resolved/inherited view).
|
||||
pub async fn vars_list(&self, owner: VarOwnerArg<'_>) -> Result<VarListDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("{}/vars", owner.base_path()))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PUT /api/v1/admin/{apps,groups}/{id}/vars`
|
||||
pub async fn vars_set(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
key: &str,
|
||||
value: serde_json::Value,
|
||||
env: Option<&str>,
|
||||
tombstone: bool,
|
||||
) -> Result<()> {
|
||||
let mut body = serde_json::json!({ "key": key, "value": value, "tombstone": tombstone });
|
||||
if let Some(env) = env {
|
||||
body["env"] = serde_json::Value::String(env.to_string());
|
||||
}
|
||||
let resp = self
|
||||
.request(Method::PUT, &format!("{}/vars", owner.base_path()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/{apps,groups}/{id}/vars/{key}`
|
||||
pub async fn vars_delete(
|
||||
&self,
|
||||
owner: VarOwnerArg<'_>,
|
||||
key: &str,
|
||||
env: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut path = format!("{}/vars/{}", owner.base_path(), seg(key));
|
||||
if let Some(env) = env {
|
||||
path.push_str(&format!("?env={}", seg(env)));
|
||||
}
|
||||
let resp = self.request(Method::DELETE, &path).send().await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
// ---------- domains ----------
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id_or_slug}/domains`
|
||||
@@ -1394,6 +1442,37 @@ pub struct DeadLetterDto {
|
||||
pub resolution: Option<String>,
|
||||
}
|
||||
|
||||
/// Which owner a `pic vars` call targets. Selects the `apps` vs `groups`
|
||||
/// admin path prefix; the identifier travels in the path (encoded).
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VarOwnerArg<'a> {
|
||||
App(&'a str),
|
||||
Group(&'a str),
|
||||
}
|
||||
|
||||
impl VarOwnerArg<'_> {
|
||||
fn base_path(&self) -> String {
|
||||
match self {
|
||||
Self::App(ident) => format!("/api/v1/admin/apps/{}", seg(ident)),
|
||||
Self::Group(ident) => format!("/api/v1/admin/groups/{}", seg(ident)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VarListDto {
|
||||
pub vars: Vec<VarItemDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct VarItemDto {
|
||||
pub key: String,
|
||||
pub env: String,
|
||||
pub value: serde_json::Value,
|
||||
pub is_tombstone: bool,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SecretListDto {
|
||||
pub secrets: Vec<SecretItemDto>,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -144,6 +144,14 @@ enum Cmd {
|
||||
cmd: MembersCmd,
|
||||
},
|
||||
|
||||
/// Config vars (Phase 3) — set / list / delete group- or app-owned
|
||||
/// env-scoped vars. Values inherit down the group tree; an app value
|
||||
/// overrides an inherited one (proximity wins).
|
||||
Vars {
|
||||
#[command(subcommand)]
|
||||
cmd: VarsCmd,
|
||||
},
|
||||
|
||||
/// Files inspection — list a collection's blobs, download bytes, or
|
||||
/// delete a file. Read + delete only; writes go through scripts.
|
||||
Files {
|
||||
@@ -1162,6 +1170,53 @@ enum SecretsCmd {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum VarsCmd {
|
||||
/// List the owner's OWN vars (not the resolved/inherited view).
|
||||
Ls {
|
||||
/// Owning group (slug or id). Mutually exclusive with `--app`.
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
/// Owning app (slug or id). Mutually exclusive with `--group`.
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
},
|
||||
|
||||
/// Set a var. The value is stored as a JSON string by default; pass
|
||||
/// `--json` to parse it as raw JSON. `--tombstone` writes a deletion
|
||||
/// marker that suppresses an inherited key.
|
||||
Set {
|
||||
key: String,
|
||||
/// Ignored (but accepted) when `--tombstone` is set.
|
||||
#[arg(default_value = "")]
|
||||
value: String,
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
/// Environment scope (`*` = env-agnostic, the default).
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
/// Parse `value` as raw JSON instead of a string literal.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
/// Write a tombstone (suppress an inherited key) instead of a value.
|
||||
#[arg(long)]
|
||||
tombstone: bool,
|
||||
},
|
||||
|
||||
/// Delete a var by key (optionally scoped to one environment).
|
||||
Rm {
|
||||
key: String,
|
||||
#[arg(long)]
|
||||
group: Option<String>,
|
||||
#[arg(long)]
|
||||
app: Option<String>,
|
||||
#[arg(long)]
|
||||
env: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum AdminsCmd {
|
||||
/// List admin accounts.
|
||||
@@ -1761,6 +1816,41 @@ async fn main() -> ExitCode {
|
||||
Cmd::Members {
|
||||
cmd: MembersCmd::Rm { app, user_id },
|
||||
} => cmds::members::rm(&app, &user_id).await,
|
||||
Cmd::Vars {
|
||||
cmd: VarsCmd::Ls { group, app },
|
||||
} => cmds::vars::ls(group.as_deref(), app.as_deref(), mode).await,
|
||||
Cmd::Vars {
|
||||
cmd:
|
||||
VarsCmd::Set {
|
||||
key,
|
||||
value,
|
||||
group,
|
||||
app,
|
||||
env,
|
||||
json,
|
||||
tombstone,
|
||||
},
|
||||
} => {
|
||||
cmds::vars::set(
|
||||
group.as_deref(),
|
||||
app.as_deref(),
|
||||
&key,
|
||||
&value,
|
||||
env.as_deref(),
|
||||
json,
|
||||
tombstone,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Cmd::Vars {
|
||||
cmd:
|
||||
VarsCmd::Rm {
|
||||
key,
|
||||
group,
|
||||
app,
|
||||
env,
|
||||
},
|
||||
} => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await,
|
||||
Cmd::Files {
|
||||
cmd:
|
||||
FilesCmd::Ls {
|
||||
|
||||
Reference in New Issue
Block a user