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:
MechaCat02
2026-06-24 21:11:46 +02:00
parent 343f6d3b4d
commit 9ee85993d8
11 changed files with 996 additions and 20 deletions

View File

@@ -85,6 +85,8 @@ pub mod trigger_repo;
pub mod triggers_api; pub mod triggers_api;
pub mod users_admin_api; pub mod users_admin_api;
pub mod users_service; pub mod users_service;
pub mod vars_api;
pub mod vars_repo;
pub mod vars_service; pub mod vars_service;
pub use abandoned_repo::{ pub use abandoned_repo::{
@@ -221,4 +223,6 @@ pub use trigger_repo::{
pub use triggers_api::{triggers_router, TriggersApiError, TriggersState}; pub use triggers_api::{triggers_router, TriggersApiError, TriggersState};
pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState}; pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState};
pub use users_service::{UsersServiceConfig, UsersServiceImpl}; pub use users_service::{UsersServiceConfig, UsersServiceImpl};
pub use vars_api::{vars_router, VarsApiError, VarsApiState};
pub use vars_repo::{PostgresVarsRepo, VarOwner, VarRow, VarsRepo, VarsRepoError};
pub use vars_service::VarsServiceImpl; pub use vars_service::VarsServiceImpl;

View File

@@ -0,0 +1,410 @@
//! `/api/v1/admin/{apps,groups}/{id_or_slug}/vars*` — the Phase-3 config
//! `vars` admin surface (write/list side; resolution lives in
//! `config_resolver` + the `vars::` SDK).
//!
//! * `GET /apps/{id}/vars` — list the app's OWN vars.
//! * `PUT /apps/{id}/vars` — set/overwrite one app var.
//! * `DELETE /apps/{id}/vars/{key}` — delete one app var.
//! * `GET/PUT/DELETE /groups/{id}/vars[...]` — same, group-owned.
//!
//! App routes gate on `App{Vars}Read/Write`; group routes on
//! `Group{Vars}Read/Write`. The owner is resolved FIRST (slug-or-uuid),
//! THEN `authz::require` binds the capability to the resolved owner id —
//! never to a caller-controlled path param. Listing returns the owner's
//! OWN rows only (not the resolved/inherited view).
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{get, put};
use axum::{Extension, Router};
use picloud_shared::{AppId, GroupId, Principal};
use serde::Deserialize;
use serde_json::json;
use crate::app_repo::AppRepository;
use crate::authz::{require, AuthzDenied, AuthzError, AuthzRepo, Capability};
use crate::group_repo::GroupRepository;
use crate::vars_repo::{VarOwner, VarsRepo, VarsRepoError};
#[derive(Clone)]
pub struct VarsApiState {
pub vars: Arc<dyn VarsRepo>,
pub apps: Arc<dyn AppRepository>,
pub groups: Arc<dyn GroupRepository>,
pub authz: Arc<dyn AuthzRepo>,
}
pub fn vars_router(state: VarsApiState) -> Router {
Router::new()
.route(
"/apps/{id_or_slug}/vars",
get(list_app_vars).put(set_app_var),
)
.route(
"/apps/{id_or_slug}/vars/{key}",
axum::routing::delete(delete_app_var),
)
.route(
"/groups/{id_or_slug}/vars",
put(set_group_var).get(list_group_vars),
)
.route(
"/groups/{id_or_slug}/vars/{key}",
axum::routing::delete(delete_group_var),
)
.with_state(state)
}
// ----------------------------------------------------------------------------
// DTOs
// ----------------------------------------------------------------------------
#[derive(Debug, Deserialize)]
pub struct SetVarRequest {
pub key: String,
pub value: serde_json::Value,
/// Environment scope — `*` (env-agnostic, default) or a concrete env
/// name matched against `apps.environment` at resolution time.
#[serde(default)]
pub env: Option<String>,
/// Write a tombstone (suppresses an inherited key) instead of a real
/// value. The body's `value` is ignored for a tombstone.
#[serde(default)]
pub tombstone: bool,
}
#[derive(Debug, Deserialize)]
pub struct EnvQuery {
#[serde(default)]
pub env: Option<String>,
}
#[derive(Debug, serde::Serialize)]
struct VarItem {
key: String,
env: String,
value: serde_json::Value,
is_tombstone: bool,
updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, serde::Serialize)]
struct ListVarsResponse {
vars: Vec<VarItem>,
}
// ----------------------------------------------------------------------------
// App handlers
// ----------------------------------------------------------------------------
async fn list_app_vars(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsRead(app_id),
)
.await?;
list(&*s.vars, VarOwner::App(app_id)).await
}
async fn set_app_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetVarRequest>,
) -> Result<StatusCode, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsWrite(app_id),
)
.await?;
set(&*s.vars, VarOwner::App(app_id), input).await
}
async fn delete_app_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, key)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, VarsApiError> {
let app_id = resolve_app(&*s.apps, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::AppVarsWrite(app_id),
)
.await?;
delete(&*s.vars, VarOwner::App(app_id), &key, q.env.as_deref()).await
}
// ----------------------------------------------------------------------------
// Group handlers
// ----------------------------------------------------------------------------
async fn list_group_vars(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsRead(group_id),
)
.await?;
list(&*s.vars, VarOwner::Group(group_id)).await
}
async fn set_group_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
Json(input): Json<SetVarRequest>,
) -> Result<StatusCode, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsWrite(group_id),
)
.await?;
set(&*s.vars, VarOwner::Group(group_id), input).await
}
async fn delete_group_var(
State(s): State<VarsApiState>,
Extension(principal): Extension<Principal>,
Path((id_or_slug, key)): Path<(String, String)>,
Query(q): Query<EnvQuery>,
) -> Result<StatusCode, VarsApiError> {
let group_id = resolve_group(&*s.groups, &id_or_slug).await?;
require(
s.authz.as_ref(),
&principal,
Capability::GroupVarsWrite(group_id),
)
.await?;
delete(&*s.vars, VarOwner::Group(group_id), &key, q.env.as_deref()).await
}
// ----------------------------------------------------------------------------
// Shared owner-generic bodies
// ----------------------------------------------------------------------------
async fn list(
vars: &dyn VarsRepo,
owner: VarOwner,
) -> Result<Json<ListVarsResponse>, VarsApiError> {
let rows = vars.list_for_owner(owner).await?;
Ok(Json(ListVarsResponse {
vars: rows
.into_iter()
.map(|r| VarItem {
key: r.key,
env: r.environment_scope,
value: r.value,
is_tombstone: r.is_tombstone,
updated_at: r.updated_at,
})
.collect(),
}))
}
async fn set(
vars: &dyn VarsRepo,
owner: VarOwner,
input: SetVarRequest,
) -> Result<StatusCode, VarsApiError> {
validate_key(&input.key)?;
let env = input.env.as_deref().unwrap_or("*");
validate_env_scope(env)?;
// A tombstone carries no meaningful value (the resolver suppresses the
// key regardless); store JSON null so the NOT NULL column is satisfied.
let value = if input.tombstone {
serde_json::Value::Null
} else {
input.value
};
vars.set(owner, env, &input.key, &value, input.tombstone)
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn delete(
vars: &dyn VarsRepo,
owner: VarOwner,
key: &str,
env: Option<&str>,
) -> Result<StatusCode, VarsApiError> {
let env = env.unwrap_or("*");
validate_env_scope(env)?;
if !vars.delete(owner, env, key).await? {
return Err(VarsApiError::NotFound);
}
Ok(StatusCode::NO_CONTENT)
}
// ----------------------------------------------------------------------------
// Resolution + validation
// ----------------------------------------------------------------------------
async fn resolve_app(apps: &dyn AppRepository, ident: &str) -> Result<AppId, VarsApiError> {
crate::app_repo::resolve_app(apps, ident)
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
.map(|l| l.app.id)
.ok_or(VarsApiError::AppNotFound)
}
async fn resolve_group(groups: &dyn GroupRepository, ident: &str) -> Result<GroupId, VarsApiError> {
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
groups
.get_by_id(uuid.into())
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
} else {
groups
.get_by_slug(ident)
.await
.map_err(|e| VarsApiError::Backend(e.to_string()))?
};
found.map(|g| g.id).ok_or(VarsApiError::GroupNotFound)
}
/// Keys are kebab identifiers (`^[a-z0-9][a-z0-9-]*$`) — same shape as the
/// manifest's var names (docs/design §4.3).
fn validate_key(key: &str) -> Result<(), VarsApiError> {
if key.is_empty() || key.len() > 128 {
return Err(VarsApiError::Invalid("key must be 1128 characters".into()));
}
let mut chars = key.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(VarsApiError::Invalid(
"key must start with a lowercase letter or digit".into(),
));
}
if !key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(VarsApiError::Invalid(
"key may contain only lowercase letters, digits, and hyphens".into(),
));
}
Ok(())
}
/// Env scope is `*` (env-agnostic) or a kebab env name.
fn validate_env_scope(env: &str) -> Result<(), VarsApiError> {
if env == "*" {
return Ok(());
}
if env.is_empty() || env.len() > 63 {
return Err(VarsApiError::Invalid(
"env must be '*' or 163 characters".into(),
));
}
let mut chars = env.chars();
let first = chars.next().unwrap();
if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
return Err(VarsApiError::Invalid(
"env must start with a lowercase letter or digit".into(),
));
}
if !env
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
{
return Err(VarsApiError::Invalid(
"env may contain only lowercase letters, digits, and hyphens".into(),
));
}
Ok(())
}
// ----------------------------------------------------------------------------
// Errors
// ----------------------------------------------------------------------------
#[derive(Debug, thiserror::Error)]
pub enum VarsApiError {
#[error("app not found")]
AppNotFound,
#[error("group not found")]
GroupNotFound,
#[error("var not found")]
NotFound,
#[error("invalid request: {0}")]
Invalid(String),
#[error("forbidden")]
Forbidden,
#[error("authorization repo error: {0}")]
AuthzRepo(String),
#[error("vars backend: {0}")]
Backend(String),
}
impl From<AuthzDenied> for VarsApiError {
fn from(d: AuthzDenied) -> Self {
match d {
AuthzDenied::Denied => Self::Forbidden,
AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()),
}
}
}
impl From<AuthzError> for VarsApiError {
fn from(e: AuthzError) -> Self {
Self::AuthzRepo(e.to_string())
}
}
impl From<VarsRepoError> for VarsApiError {
fn from(e: VarsRepoError) -> Self {
match e {
VarsRepoError::Db(e) => Self::Backend(e.to_string()),
}
}
}
impl IntoResponse for VarsApiError {
fn into_response(self) -> Response {
let (status, body) = match &self {
Self::AppNotFound | Self::GroupNotFound | Self::NotFound => {
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
}
Self::Invalid(_) => (
StatusCode::UNPROCESSABLE_ENTITY,
json!({ "error": self.to_string() }),
),
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "vars admin authz repo error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
Self::Backend(e) => {
tracing::error!(error = %e, "vars admin backend error");
(
StatusCode::INTERNAL_SERVER_ERROR,
json!({ "error": "internal error" }),
)
}
};
(status, Json(body)).into_response()
}
}

View File

@@ -0,0 +1,210 @@
//! Low-level Postgres CRUD over `vars` — the write/admin side of the
//! Phase-3 config layer (the read/resolution side lives in
//! `config_resolver`). Storage-only: it upserts and lists an owner's OWN
//! rows. Authorization, env-scope validation, and value encoding live one
//! layer up in `vars_api`.
//!
//! A var is owned by exactly one group OR one app (the migration's
//! `vars_owner_exactly_one` CHECK). Because the owner is split across two
//! nullable columns (`group_id`, `app_id`), the upsert writes
//! owner-kind-specific SQL with the matching partial-unique conflict
//! target.
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use picloud_shared::{AppId, GroupId};
use serde_json::Value as JsonValue;
use sqlx::PgPool;
#[derive(Debug, thiserror::Error)]
pub enum VarsRepoError {
#[error("database error: {0}")]
Db(#[from] sqlx::Error),
}
/// Which side of the polymorphic owner a var hangs off. The repo chooses
/// the conflict target (group vs app partial-unique index) from this.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VarOwner {
Group(GroupId),
App(AppId),
}
/// One of an owner's OWN var rows (NOT a resolved/inherited value). Backs
/// the admin list surface.
#[derive(Debug, Clone)]
pub struct VarRow {
pub environment_scope: String,
pub key: String,
pub value: JsonValue,
pub is_tombstone: bool,
pub updated_at: DateTime<Utc>,
}
/// Repo surface. A trait so service/handler tests can substitute an
/// in-memory backing without Postgres.
#[async_trait]
pub trait VarsRepo: Send + Sync {
/// Upsert one (owner, env_scope, key) row.
async fn set(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
value: &JsonValue,
is_tombstone: bool,
) -> Result<(), VarsRepoError>;
/// Delete one (owner, env_scope, key) row; returns whether a row was
/// present.
async fn delete(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
) -> Result<bool, VarsRepoError>;
/// The owner's OWN rows only (NOT resolved/inherited), ordered by
/// (key, environment_scope).
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError>;
}
pub struct PostgresVarsRepo {
pool: PgPool,
}
impl PostgresVarsRepo {
#[must_use]
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl VarsRepo for PostgresVarsRepo {
async fn set(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
value: &JsonValue,
is_tombstone: bool,
) -> Result<(), VarsRepoError> {
// Owner-kind-specific SQL: only one of the two nullable owner
// columns is written, and the conflict target is the matching
// partial-unique index.
match owner {
VarOwner::Group(g) => {
sqlx::query(
// The conflict target is a PARTIAL unique index, so the
// index predicate (`WHERE group_id IS NOT NULL`) must be
// restated for Postgres to infer the arbiter.
"INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (group_id, environment_scope, key) \
WHERE group_id IS NOT NULL DO UPDATE \
SET value = EXCLUDED.value, \
is_tombstone = EXCLUDED.is_tombstone, \
updated_at = NOW()",
)
.bind(g.into_inner())
.bind(env_scope)
.bind(key)
.bind(value)
.bind(is_tombstone)
.execute(&self.pool)
.await?;
}
VarOwner::App(a) => {
sqlx::query(
// Partial-index conflict target — restate the predicate.
"INSERT INTO vars (app_id, environment_scope, key, value, is_tombstone) \
VALUES ($1, $2, $3, $4, $5) \
ON CONFLICT (app_id, environment_scope, key) \
WHERE app_id IS NOT NULL DO UPDATE \
SET value = EXCLUDED.value, \
is_tombstone = EXCLUDED.is_tombstone, \
updated_at = NOW()",
)
.bind(a.into_inner())
.bind(env_scope)
.bind(key)
.bind(value)
.bind(is_tombstone)
.execute(&self.pool)
.await?;
}
}
Ok(())
}
async fn delete(
&self,
owner: VarOwner,
env_scope: &str,
key: &str,
) -> Result<bool, VarsRepoError> {
let res = match owner {
VarOwner::Group(g) => {
sqlx::query(
"DELETE FROM vars \
WHERE group_id = $1 AND environment_scope = $2 AND key = $3",
)
.bind(g.into_inner())
.bind(env_scope)
.bind(key)
.execute(&self.pool)
.await?
}
VarOwner::App(a) => {
sqlx::query(
"DELETE FROM vars \
WHERE app_id = $1 AND environment_scope = $2 AND key = $3",
)
.bind(a.into_inner())
.bind(env_scope)
.bind(key)
.execute(&self.pool)
.await?
}
};
Ok(res.rows_affected() > 0)
}
async fn list_for_owner(&self, owner: VarOwner) -> Result<Vec<VarRow>, VarsRepoError> {
let rows: Vec<(String, String, JsonValue, bool, DateTime<Utc>)> = match owner {
VarOwner::Group(g) => {
sqlx::query_as(
"SELECT environment_scope, key, value, is_tombstone, updated_at \
FROM vars WHERE group_id = $1 \
ORDER BY key ASC, environment_scope ASC",
)
.bind(g.into_inner())
.fetch_all(&self.pool)
.await?
}
VarOwner::App(a) => {
sqlx::query_as(
"SELECT environment_scope, key, value, is_tombstone, updated_at \
FROM vars WHERE app_id = $1 \
ORDER BY key ASC, environment_scope ASC",
)
.bind(a.into_inner())
.fetch_all(&self.pool)
.await?
}
};
Ok(rows
.into_iter()
.map(
|(environment_scope, key, value, is_tombstone, updated_at)| VarRow {
environment_scope,
key,
value,
is_tombstone,
updated_at,
},
)
.collect())
}
}

View File

@@ -692,6 +692,54 @@ impl Client {
decode_status(resp).await 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 ---------- // ---------- domains ----------
/// `GET /api/v1/admin/apps/{id_or_slug}/domains` /// `GET /api/v1/admin/apps/{id_or_slug}/domains`
@@ -1394,6 +1442,37 @@ pub struct DeadLetterDto {
pub resolution: Option<String>, 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)] #[derive(Debug, Deserialize)]
pub struct SecretListDto { pub struct SecretListDto {
pub secrets: Vec<SecretItemDto>, pub secrets: Vec<SecretItemDto>,

View File

@@ -22,4 +22,5 @@ pub mod secrets;
pub mod topics; pub mod topics;
pub mod triggers; pub mod triggers;
pub mod users; pub mod users;
pub mod vars;
pub mod whoami; pub mod whoami;

View 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(())
}

View File

@@ -144,6 +144,14 @@ enum Cmd {
cmd: MembersCmd, 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 /// Files inspection — list a collection's blobs, download bytes, or
/// delete a file. Read + delete only; writes go through scripts. /// delete a file. Read + delete only; writes go through scripts.
Files { 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)] #[derive(Subcommand)]
enum AdminsCmd { enum AdminsCmd {
/// List admin accounts. /// List admin accounts.
@@ -1761,6 +1816,41 @@ async fn main() -> ExitCode {
Cmd::Members { Cmd::Members {
cmd: MembersCmd::Rm { app, user_id }, cmd: MembersCmd::Rm { app, user_id },
} => cmds::members::rm(&app, &user_id).await, } => 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::Files {
cmd: cmd:
FilesCmd::Ls { FilesCmd::Ls {

View File

@@ -37,3 +37,4 @@ mod scripts;
mod secrets; mod secrets;
mod staleness; mod staleness;
mod triggers; mod triggers;
mod vars;

View File

@@ -0,0 +1,4 @@
// Phase-3 vars journey fixture: returns the resolved `region` config var
// verbatim so the test can assert on inheritance (group value) vs an app
// proximity override.
vars::get("region")

View File

@@ -0,0 +1,84 @@
//! Phase-3 config `vars`, end to end via `pic`: a group-owned var is
//! inherited by an app underneath it, and an app-owned var of the same key
//! overrides the inherited value (proximity wins, §3).
//!
//! Drives the real resolution path: a script does `vars::get("region")`
//! and returns it; we invoke it via `/api/v1/execute/{id}` and assert on
//! the body. First the group value flows down (inheritance), then an app
//! value shadows it (override).
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn group_var_is_inherited_then_app_value_overrides() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let acme = common::unique_slug("v-acme");
let app = common::unique_slug("v-app");
// Group `acme`, then a `region` var on it (a JSON string "eu").
let _g_acme = GroupGuard::new(&env.url, &env.token, &acme);
common::pic_as(&env)
.args(["groups", "create", &acme])
.assert()
.success();
common::pic_as(&env)
.args(["vars", "set", "region", "eu", "--group", &acme])
.assert()
.success();
// App under acme with a script that reads + returns the resolved var.
let _app = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &acme])
.assert()
.success();
let fixture = common::fixture_path("read-var.rhai");
common::pic_as(&env)
.args([
"scripts",
"deploy",
fixture.to_str().unwrap(),
"--app",
&app,
])
.assert()
.success();
// Resolve the deployed script's id.
let ls = common::pic_as(&env)
.args(["scripts", "ls", "--app", &app])
.output()
.expect("scripts ls");
let id = common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("scripts ls should produce one row");
// Inherited: the app has no own `region`, so the group's "eu" resolves.
assert_eq!(invoke_body(&env, &id), serde_json::json!("eu"), "inherited");
// Proximity override: an app-owned `region` shadows the group value.
common::pic_as(&env)
.args(["vars", "set", "region", "us", "--app", &app])
.assert()
.success();
assert_eq!(invoke_body(&env, &id), serde_json::json!("us"), "override");
}
/// Invoke a script via `pic scripts invoke <id>` (→ `/api/v1/execute/{id}`)
/// and parse its JSON body.
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

@@ -14,26 +14,27 @@ use picloud_manager_core::{
apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router, apps_router, attach_principal_if_present, auth_router, compile_routes, dead_letters_router,
dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router, dev_emails_router, email_inbound_router, files_admin_router, groups_router, kv_admin_router,
migrations, require_authenticated, route_admin_router, secrets_router, topics_router, migrations, require_authenticated, route_admin_router, secrets_router, topics_router,
triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository,
AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState,
AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState,
InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl,
PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository, PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository,
PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl,
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
TriggersState, UsersServiceConfig, UsersServiceImpl, VarsServiceImpl, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig,
UsersServiceImpl, VarsApiState, VarsServiceImpl,
}; };
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -569,6 +570,12 @@ pub async fn build_app(
users: auth.users.clone(), users: auth.users.clone(),
authz: authz.clone(), authz: authz.clone(),
}; };
let vars_state = VarsApiState {
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
apps: apps_repo.clone(),
groups: groups_repo.clone(),
authz: authz.clone(),
};
let app_users_admin_state = picloud_manager_core::AppUsersState { let app_users_admin_state = picloud_manager_core::AppUsersState {
apps: apps_state.apps.clone(), apps: apps_state.apps.clone(),
authz: authz.clone(), authz: authz.clone(),
@@ -592,6 +599,7 @@ pub async fn build_app(
.merge(apps_router(apps_state)) .merge(apps_router(apps_state))
.merge(app_members_router(app_members_state)) .merge(app_members_router(app_members_state))
.merge(groups_router(groups_state)) .merge(groups_router(groups_state))
.merge(vars_router(vars_state))
.merge(picloud_manager_core::app_users_router( .merge(picloud_manager_core::app_users_router(
app_users_admin_state, app_users_admin_state,
)) ))