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:
@@ -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;
|
||||||
|
|||||||
410
crates/manager-core/src/vars_api.rs
Normal file
410
crates/manager-core/src/vars_api.rs
Normal 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 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<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()
|
||||||
|
}
|
||||||
|
}
|
||||||
210
crates/manager-core/src/vars_repo.rs
Normal file
210
crates/manager-core/src/vars_repo.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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,
|
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 {
|
||||||
|
|||||||
@@ -37,3 +37,4 @@ mod scripts;
|
|||||||
mod secrets;
|
mod secrets;
|
||||||
mod staleness;
|
mod staleness;
|
||||||
mod triggers;
|
mod triggers;
|
||||||
|
mod vars;
|
||||||
|
|||||||
4
crates/picloud-cli/tests/fixtures/read-var.rhai
vendored
Normal file
4
crates/picloud-cli/tests/fixtures/read-var.rhai
vendored
Normal 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")
|
||||||
84
crates/picloud-cli/tests/vars.rs
Normal file
84
crates/picloud-cli/tests/vars.rs
Normal 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")
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
))
|
))
|
||||||
|
|||||||
Reference in New Issue
Block a user