From 9ee85993d85ad5d7eb00a3697723233a2eb21770 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 24 Jun 2026 21:11:46 +0200 Subject: [PATCH] feat(vars): admin CRUD API + pic vars CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/manager-core/src/lib.rs | 4 + crates/manager-core/src/vars_api.rs | 410 ++++++++++++++++++ crates/manager-core/src/vars_repo.rs | 210 +++++++++ crates/picloud-cli/src/client.rs | 79 ++++ crates/picloud-cli/src/cmds/mod.rs | 1 + crates/picloud-cli/src/cmds/vars.rs | 85 ++++ crates/picloud-cli/src/main.rs | 90 ++++ crates/picloud-cli/tests/cli.rs | 1 + .../picloud-cli/tests/fixtures/read-var.rhai | 4 + crates/picloud-cli/tests/vars.rs | 84 ++++ crates/picloud/src/lib.rs | 48 +- 11 files changed, 996 insertions(+), 20 deletions(-) create mode 100644 crates/manager-core/src/vars_api.rs create mode 100644 crates/manager-core/src/vars_repo.rs create mode 100644 crates/picloud-cli/src/cmds/vars.rs create mode 100644 crates/picloud-cli/tests/fixtures/read-var.rhai create mode 100644 crates/picloud-cli/tests/vars.rs diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 0bd3125..7fb9694 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -85,6 +85,8 @@ pub mod trigger_repo; pub mod triggers_api; pub mod users_admin_api; pub mod users_service; +pub mod vars_api; +pub mod vars_repo; pub mod vars_service; pub use abandoned_repo::{ @@ -221,4 +223,6 @@ pub use trigger_repo::{ pub use triggers_api::{triggers_router, TriggersApiError, TriggersState}; pub use users_admin_api::{app_users_router, AppUsersApiError, AppUsersState}; 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; diff --git a/crates/manager-core/src/vars_api.rs b/crates/manager-core/src/vars_api.rs new file mode 100644 index 0000000..0551dfa --- /dev/null +++ b/crates/manager-core/src/vars_api.rs @@ -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, + pub apps: Arc, + pub groups: Arc, + pub authz: Arc, +} + +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, + /// 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, +} + +#[derive(Debug, serde::Serialize)] +struct VarItem { + key: String, + env: String, + value: serde_json::Value, + is_tombstone: bool, + updated_at: chrono::DateTime, +} + +#[derive(Debug, serde::Serialize)] +struct ListVarsResponse { + vars: Vec, +} + +// ---------------------------------------------------------------------------- +// App handlers +// ---------------------------------------------------------------------------- + +async fn list_app_vars( + State(s): State, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result, 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, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result { + 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, + Extension(principal): Extension, + Path((id_or_slug, key)): Path<(String, String)>, + Query(q): Query, +) -> Result { + 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, + Extension(principal): Extension, + Path(id_or_slug): Path, +) -> Result, 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, + Extension(principal): Extension, + Path(id_or_slug): Path, + Json(input): Json, +) -> Result { + 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, + Extension(principal): Extension, + Path((id_or_slug, key)): Path<(String, String)>, + Query(q): Query, +) -> Result { + 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, 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 { + 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 { + 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 { + 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 { + let found = if let Ok(uuid) = ident.parse::() { + 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 1–128 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 1–63 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 for VarsApiError { + fn from(d: AuthzDenied) -> Self { + match d { + AuthzDenied::Denied => Self::Forbidden, + AuthzDenied::Repo(e) => Self::AuthzRepo(e.to_string()), + } + } +} + +impl From for VarsApiError { + fn from(e: AuthzError) -> Self { + Self::AuthzRepo(e.to_string()) + } +} + +impl From 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() + } +} diff --git a/crates/manager-core/src/vars_repo.rs b/crates/manager-core/src/vars_repo.rs new file mode 100644 index 0000000..db058e0 --- /dev/null +++ b/crates/manager-core/src/vars_repo.rs @@ -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, +} + +/// 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; + + /// The owner's OWN rows only (NOT resolved/inherited), ordered by + /// (key, environment_scope). + async fn list_for_owner(&self, owner: VarOwner) -> Result, 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 { + 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, VarsRepoError> { + let rows: Vec<(String, String, JsonValue, bool, DateTime)> = 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()) + } +} diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index e53f9a4..7a366de 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -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 { + 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, } +/// 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, +} + +#[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, +} + #[derive(Debug, Deserialize)] pub struct SecretListDto { pub secrets: Vec, diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index 8725727..b4d4629 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -22,4 +22,5 @@ pub mod secrets; pub mod topics; pub mod triggers; pub mod users; +pub mod vars; pub mod whoami; diff --git a/crates/picloud-cli/src/cmds/vars.rs b/crates/picloud-cli/src/cmds/vars.rs new file mode 100644 index 0000000..f6d42d2 --- /dev/null +++ b/crates/picloud-cli/src/cmds/vars.rs @@ -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> { + 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(()) +} diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 6ac8a90..5dc45b3 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -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, + /// Owning app (slug or id). Mutually exclusive with `--group`. + #[arg(long)] + app: Option, + }, + + /// 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, + #[arg(long)] + app: Option, + /// Environment scope (`*` = env-agnostic, the default). + #[arg(long)] + env: Option, + /// 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, + #[arg(long)] + app: Option, + #[arg(long)] + env: Option, + }, +} + #[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 { diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index 9ace257..612ad9c 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -37,3 +37,4 @@ mod scripts; mod secrets; mod staleness; mod triggers; +mod vars; diff --git a/crates/picloud-cli/tests/fixtures/read-var.rhai b/crates/picloud-cli/tests/fixtures/read-var.rhai new file mode 100644 index 0000000..8f9b1fe --- /dev/null +++ b/crates/picloud-cli/tests/fixtures/read-var.rhai @@ -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") diff --git a/crates/picloud-cli/tests/vars.rs b/crates/picloud-cli/tests/vars.rs new file mode 100644 index 0000000..49c9045 --- /dev/null +++ b/crates/picloud-cli/tests/vars.rs @@ -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 ` (→ `/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") +} diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 203179b..0baa70c 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -14,26 +14,27 @@ use picloud_manager_core::{ 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, migrations, require_authenticated, route_admin_router, secrets_router, topics_router, - triggers_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, AdminState, - AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, AppDomainRepository, - AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState, - AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, - EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, FilesServiceImpl, - FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, - InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo, - PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, - PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, - PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, - PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, - PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, - PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, - PostgresExecutionLogSink, PostgresGroupMembersRepository, PostgresGroupRepository, - PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, - PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, - PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState, - RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl, - SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo, - TriggersState, UsersServiceConfig, UsersServiceImpl, VarsServiceImpl, + triggers_router, vars_router, AbandonedRepo, AdminPrincipalResolver, AdminSessionRepository, + AdminState, AdminUserRepository, AdminsState, ApiKeyRepository, ApiKeysState, + AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, + AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, + DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, + FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState, + HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, + OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, + PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, + PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, + PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, + PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, + PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, + PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository, + PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, + PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, + PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl, + RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling, + ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig, + TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig, + UsersServiceImpl, VarsApiState, VarsServiceImpl, }; use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS; use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable}; @@ -569,6 +570,12 @@ pub async fn build_app( users: auth.users.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 { apps: apps_state.apps.clone(), authz: authz.clone(), @@ -592,6 +599,7 @@ pub async fn build_app( .merge(apps_router(apps_state)) .merge(app_members_router(app_members_state)) .merge(groups_router(groups_state)) + .merge(vars_router(vars_state)) .merge(picloud_manager_core::app_users_router( app_users_admin_state, ))