feat(config): vars schema + the §3 resolution engine
Phase 3 foundation (docs §3): env-filtered, proximity-first config inheritance down the group tree. - Migration 0048: `vars` (polymorphic group|app owner via two nullable FKs + CHECK exactly-one, env scope, JSONB value, explicit tombstone) + `apps.environment` (the env marker the resolver filters on — 'an environment is an app'). - config_resolver: a shared chain-walk CTE (mirrors effective_app_role) that walks app → ancestor groups, env-filters in SQL, plus a pure Rust resolution pass implementing the §3 semantics SQL can't — per-key proximity-first, @E-over-* within a level, map deep-merge, tombstone suppression, and provenance for --explain. Verified: 8 unit tests (incl. the §3.2 proximity-beats-env-specificity call, deep-merge, tombstone, boundary) + the candidate-fetch CTE against live Postgres (env-filter drops a non-matching @production value; nearer level shadows farther). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
47
crates/manager-core/migrations/0048_vars.sql
Normal file
47
crates/manager-core/migrations/0048_vars.sql
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
-- Phase 3: group-inherited config — the `vars` table + an app environment
|
||||||
|
-- marker. See docs/design/groups-and-project-tool.md §3, §5.1.
|
||||||
|
--
|
||||||
|
-- `vars` is the net-new, env-scoped configuration layer (greenfield — there
|
||||||
|
-- is no env-agnostic config today, only `secrets`). A var is owned by
|
||||||
|
-- exactly one group OR one app, optionally scoped to an environment, and
|
||||||
|
-- resolved down the tree (§3): env-filter first, then nearest level wins.
|
||||||
|
--
|
||||||
|
-- "An environment is an app": each env is already a distinct app row. To
|
||||||
|
-- env-filter group-level `@E` values, the resolver must know which env an
|
||||||
|
-- app represents — recorded here on `apps.environment` (NULL = env-agnostic,
|
||||||
|
-- set at app-create from the CLI's known env).
|
||||||
|
|
||||||
|
ALTER TABLE apps ADD COLUMN environment TEXT;
|
||||||
|
|
||||||
|
CREATE TABLE vars (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- Polymorphic owner: exactly one of the two FKs is set. Real FKs (not a
|
||||||
|
-- bare owner_id) so ON DELETE CASCADE works per owner kind and a dangling
|
||||||
|
-- owner is impossible.
|
||||||
|
group_id UUID REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
app_id UUID REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT vars_owner_exactly_one
|
||||||
|
CHECK ((group_id IS NULL) <> (app_id IS NULL)),
|
||||||
|
-- '*' = env-agnostic; otherwise an env name matched against
|
||||||
|
-- apps.environment. NOT NULL with a '*' sentinel so the uniqueness
|
||||||
|
-- indexes are total (a NULL scope would break UNIQUE).
|
||||||
|
environment_scope TEXT NOT NULL DEFAULT '*',
|
||||||
|
key TEXT NOT NULL,
|
||||||
|
-- JSONB per the v1.1 data-plane convention. A tombstone (deletion of an
|
||||||
|
-- inherited key, §3) is the explicit boolean below, NOT value = 'null'
|
||||||
|
-- — JSON null is a legitimate value and must stay distinguishable.
|
||||||
|
value JSONB NOT NULL,
|
||||||
|
is_tombstone BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- One row per (owner, scope, key). Partial unique indexes because the owner
|
||||||
|
-- is split across two nullable columns.
|
||||||
|
CREATE UNIQUE INDEX vars_group_uidx
|
||||||
|
ON vars (group_id, environment_scope, key) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE UNIQUE INDEX vars_app_uidx
|
||||||
|
ON vars (app_id, environment_scope, key) WHERE app_id IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX vars_group_id_idx ON vars (group_id) WHERE group_id IS NOT NULL;
|
||||||
|
CREATE INDEX vars_app_id_idx ON vars (app_id) WHERE app_id IS NOT NULL;
|
||||||
363
crates/manager-core/src/config_resolver.rs
Normal file
363
crates/manager-core/src/config_resolver.rs
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
//! The §3 configuration-resolution engine: env-filtered, proximity-first
|
||||||
|
//! inheritance down the group tree.
|
||||||
|
//!
|
||||||
|
//! To resolve a key for app A in environment E (docs/design §3):
|
||||||
|
//! 1. **Env-filter first, per level** — a value scoped `@E` is eligible;
|
||||||
|
//! `*` (env-agnostic) is the fallback. Within one level `@E` beats `*`.
|
||||||
|
//! Env is *eligibility*, not a precedence tier.
|
||||||
|
//! 2. **Nearest level wins** — walk A → parent group → … → root; the
|
||||||
|
//! closest level that defines the (filtered) key wins. Proximity beats
|
||||||
|
//! farther-level env-specificity (a leaf's `*` beats an ancestor's `@E`).
|
||||||
|
//! 3. **Maps deep-merge per key; scalars/arrays replace; deletion is an
|
||||||
|
//! explicit tombstone** that suppresses the inherited key.
|
||||||
|
//!
|
||||||
|
//! The recursive CTE that walks `apps.group_id → groups.parent_id → root`
|
||||||
|
//! mirrors `app_members_repo::effective_app_role`. The env-eligibility
|
||||||
|
//! filter happens in SQL; the §3 merge/replace/tombstone semantics — which
|
||||||
|
//! a window-function pick can't express — happen in the pure `resolve`
|
||||||
|
//! function below, so they're unit-tested without Postgres.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use picloud_shared::AppId;
|
||||||
|
|
||||||
|
/// Shared chain-walk CTE: emits one row per owner-level for an app —
|
||||||
|
/// depth 0 = the app itself (`app_owner` set), then ancestor groups
|
||||||
|
/// nearest-first (`group_owner` set), each carrying the app's environment.
|
||||||
|
/// Depth-bounded `< 64` (the group cycle guard already forbids cycles; this
|
||||||
|
/// is a runaway guard). Bind `$1 = app_id`. Reused by the vars and secret
|
||||||
|
/// resolvers, which each append their own owner-keyed JOIN.
|
||||||
|
pub(crate) const CHAIN_LEVELS_CTE: &str = "\
|
||||||
|
WITH RECURSIVE chain AS ( \
|
||||||
|
SELECT a.id AS app_owner, NULL::uuid AS group_owner, \
|
||||||
|
a.group_id AS next_group, 0 AS depth, a.environment AS app_env \
|
||||||
|
FROM apps a WHERE a.id = $1 \
|
||||||
|
UNION ALL \
|
||||||
|
SELECT NULL::uuid, g.id, g.parent_id, c.depth + 1, c.app_env \
|
||||||
|
FROM groups g JOIN chain c ON g.id = c.next_group \
|
||||||
|
WHERE c.depth < 64 \
|
||||||
|
)";
|
||||||
|
|
||||||
|
/// Owner kind of a resolved value, for `--explain` provenance.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum OwnerKind {
|
||||||
|
App,
|
||||||
|
Group,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OwnerKind {
|
||||||
|
#[must_use]
|
||||||
|
pub const fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::App => "app",
|
||||||
|
Self::Group => "group",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One eligible (env-filtered) candidate row pulled by the resolver, before
|
||||||
|
/// §3 proximity/merge resolution.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Candidate {
|
||||||
|
/// 0 = the app itself; 1 = its parent group; … (nearest-first).
|
||||||
|
pub depth: i32,
|
||||||
|
pub owner_kind: OwnerKind,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
/// `*` (env-agnostic) or a concrete environment name.
|
||||||
|
pub scope: String,
|
||||||
|
pub key: String,
|
||||||
|
pub value: Value,
|
||||||
|
pub is_tombstone: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a resolved key came from (for `config --effective --explain`).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Provenance {
|
||||||
|
pub owner_kind: OwnerKind,
|
||||||
|
pub owner_id: Uuid,
|
||||||
|
pub depth: i32,
|
||||||
|
pub scope: String,
|
||||||
|
/// For a deep-merged map, the `(depth, scope)` of every layer that
|
||||||
|
/// contributed, nearest-first. Empty for a plain scalar/array winner.
|
||||||
|
pub merged_from: Vec<(i32, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `@E`-scoped values outrank `*` *within the same level* (§3 step 1).
|
||||||
|
fn env_priority(scope: &str) -> u8 {
|
||||||
|
u8::from(scope != "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deep-merge `src` into `dst` per key: nested objects merge recursively,
|
||||||
|
/// everything else is replaced by `src`. Caller merges farthest→nearest so
|
||||||
|
/// nearer layers overwrite.
|
||||||
|
fn deep_merge(dst: &mut Map<String, Value>, src: &Map<String, Value>) {
|
||||||
|
for (k, sv) in src {
|
||||||
|
match (dst.get_mut(k), sv) {
|
||||||
|
(Some(Value::Object(dm)), Value::Object(sm)) => deep_merge(dm, sm),
|
||||||
|
_ => {
|
||||||
|
dst.insert(k.clone(), sv.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve one key's candidate rows (any order in) to its effective value,
|
||||||
|
/// plus the provenance. Returns `None` when a tombstone suppresses the key.
|
||||||
|
fn resolve_one(mut rows: Vec<Candidate>) -> Option<(Value, Provenance)> {
|
||||||
|
// Nearest-first; within a level, `@E` before `*` (§3 steps 1-2).
|
||||||
|
rows.sort_by(|a, b| {
|
||||||
|
a.depth
|
||||||
|
.cmp(&b.depth)
|
||||||
|
.then(env_priority(&b.scope).cmp(&env_priority(&a.scope)))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect the contiguous run of map-valued rows from the nearest. A
|
||||||
|
// tombstone or scalar/array boundary stops the run (and, if it's the
|
||||||
|
// nearest row, decides the result outright).
|
||||||
|
let mut maps: Vec<&Candidate> = Vec::new();
|
||||||
|
let mut boundary: Option<&Candidate> = None;
|
||||||
|
for r in &rows {
|
||||||
|
if r.is_tombstone {
|
||||||
|
boundary = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if r.value.is_object() {
|
||||||
|
maps.push(r);
|
||||||
|
} else {
|
||||||
|
boundary = Some(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(first) = maps.first() {
|
||||||
|
// Map run wins; deep-merge farthest→nearest so nearest keys win.
|
||||||
|
let mut acc = Map::new();
|
||||||
|
for m in maps.iter().rev() {
|
||||||
|
if let Value::Object(obj) = &m.value {
|
||||||
|
deep_merge(&mut acc, obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prov = Provenance {
|
||||||
|
owner_kind: first.owner_kind,
|
||||||
|
owner_id: first.owner_id,
|
||||||
|
depth: first.depth,
|
||||||
|
scope: first.scope.clone(),
|
||||||
|
merged_from: maps.iter().map(|m| (m.depth, m.scope.clone())).collect(),
|
||||||
|
};
|
||||||
|
return Some((Value::Object(acc), prov));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No leading map run: the nearest row is the boundary.
|
||||||
|
match boundary {
|
||||||
|
// Nearest is a tombstone → key deleted.
|
||||||
|
Some(b) if b.is_tombstone => None,
|
||||||
|
// Nearest is a scalar/array → take it verbatim.
|
||||||
|
Some(b) => Some((
|
||||||
|
b.value.clone(),
|
||||||
|
Provenance {
|
||||||
|
owner_kind: b.owner_kind,
|
||||||
|
owner_id: b.owner_id,
|
||||||
|
depth: b.depth,
|
||||||
|
scope: b.scope.clone(),
|
||||||
|
merged_from: Vec::new(),
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
// No rows at all (caller only passes non-empty groups).
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a full candidate set (mixed keys, any order) into the effective
|
||||||
|
/// config map + per-key provenance. Pure — unit-tested without Postgres.
|
||||||
|
#[must_use]
|
||||||
|
pub fn resolve(
|
||||||
|
candidates: Vec<Candidate>,
|
||||||
|
) -> (BTreeMap<String, Value>, BTreeMap<String, Provenance>) {
|
||||||
|
let mut by_key: BTreeMap<String, Vec<Candidate>> = BTreeMap::new();
|
||||||
|
for c in candidates {
|
||||||
|
by_key.entry(c.key.clone()).or_default().push(c);
|
||||||
|
}
|
||||||
|
let mut values = BTreeMap::new();
|
||||||
|
let mut provenance = BTreeMap::new();
|
||||||
|
for (key, rows) in by_key {
|
||||||
|
if let Some((v, p)) = resolve_one(rows) {
|
||||||
|
values.insert(key.clone(), v);
|
||||||
|
provenance.insert(key, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(values, provenance)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull every env-eligible `vars` candidate for `app_id`: the app's own
|
||||||
|
/// rows + every ancestor group's rows, filtered to `*` or the app's
|
||||||
|
/// environment, ordered nearest-first.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Propagates sqlx errors.
|
||||||
|
pub async fn fetch_var_candidates(
|
||||||
|
pool: &PgPool,
|
||||||
|
app_id: AppId,
|
||||||
|
) -> Result<Vec<Candidate>, sqlx::Error> {
|
||||||
|
let sql = format!(
|
||||||
|
"{CHAIN_LEVELS_CTE} \
|
||||||
|
SELECT c.depth, \
|
||||||
|
CASE WHEN v.app_id IS NOT NULL THEN 'app' ELSE 'group' END AS owner_kind, \
|
||||||
|
COALESCE(v.app_id, v.group_id) AS owner_id, \
|
||||||
|
v.environment_scope, v.key, v.value, v.is_tombstone \
|
||||||
|
FROM chain c \
|
||||||
|
JOIN vars v ON (v.app_id = c.app_owner OR v.group_id = c.group_owner) \
|
||||||
|
WHERE v.environment_scope = '*' OR v.environment_scope = c.app_env \
|
||||||
|
ORDER BY c.depth ASC"
|
||||||
|
);
|
||||||
|
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
|
||||||
|
.bind(app_id.into_inner())
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(Into::into).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct VarCandidateRow {
|
||||||
|
depth: i32,
|
||||||
|
owner_kind: String,
|
||||||
|
owner_id: Uuid,
|
||||||
|
environment_scope: String,
|
||||||
|
key: String,
|
||||||
|
value: Value,
|
||||||
|
is_tombstone: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<VarCandidateRow> for Candidate {
|
||||||
|
fn from(r: VarCandidateRow) -> Self {
|
||||||
|
Self {
|
||||||
|
depth: r.depth,
|
||||||
|
owner_kind: if r.owner_kind == "app" {
|
||||||
|
OwnerKind::App
|
||||||
|
} else {
|
||||||
|
OwnerKind::Group
|
||||||
|
},
|
||||||
|
owner_id: r.owner_id,
|
||||||
|
scope: r.environment_scope,
|
||||||
|
key: r.key,
|
||||||
|
value: r.value,
|
||||||
|
is_tombstone: r.is_tombstone,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn cand(depth: i32, scope: &str, key: &str, value: Value) -> Candidate {
|
||||||
|
Candidate {
|
||||||
|
depth,
|
||||||
|
owner_kind: if depth == 0 {
|
||||||
|
OwnerKind::App
|
||||||
|
} else {
|
||||||
|
OwnerKind::Group
|
||||||
|
},
|
||||||
|
owner_id: Uuid::nil(),
|
||||||
|
scope: scope.into(),
|
||||||
|
key: key.into(),
|
||||||
|
value,
|
||||||
|
is_tombstone: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tomb(depth: i32, scope: &str, key: &str) -> Candidate {
|
||||||
|
Candidate {
|
||||||
|
is_tombstone: true,
|
||||||
|
value: Value::Null,
|
||||||
|
..cand(depth, scope, key, Value::Null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearest_level_wins() {
|
||||||
|
// app (depth 0) overrides group (depth 2).
|
||||||
|
let (v, p) = resolve(vec![
|
||||||
|
cand(2, "*", "region", json!("eu")),
|
||||||
|
cand(0, "*", "region", json!("us")),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["region"], json!("us"));
|
||||||
|
assert_eq!(p["region"].depth, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn env_scoped_beats_agnostic_within_a_level() {
|
||||||
|
let (v, p) = resolve(vec![
|
||||||
|
cand(1, "*", "db_url", json!("default")),
|
||||||
|
cand(1, "staging", "db_url", json!("staging-db")),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["db_url"], json!("staging-db"));
|
||||||
|
assert_eq!(p["db_url"].scope, "staging");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proximity_beats_env_specificity_across_levels() {
|
||||||
|
// §3.2 deliberately-novel call: a leaf's `*` beats an ancestor's `@E`.
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(2, "production", "db_url", json!("anc-prod")),
|
||||||
|
cand(0, "*", "db_url", json!("leaf-default")),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["db_url"], json!("leaf-default"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn maps_deep_merge_nearest_wins() {
|
||||||
|
// ancestor sets {title, region}; leaf overrides title, adds locale.
|
||||||
|
let (v, p) = resolve(vec![
|
||||||
|
cand(2, "*", "ui", json!({"title": "Base", "region": "eu"})),
|
||||||
|
cand(0, "*", "ui", json!({"title": "Leaf", "locale": "en"})),
|
||||||
|
]);
|
||||||
|
assert_eq!(
|
||||||
|
v["ui"],
|
||||||
|
json!({"title": "Leaf", "region": "eu", "locale": "en"})
|
||||||
|
);
|
||||||
|
assert_eq!(p["ui"].merged_from.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nearer_scalar_replaces_whole_inherited_map() {
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(2, "*", "x", json!({"a": 1})),
|
||||||
|
cand(0, "*", "x", json!("scalar")),
|
||||||
|
]);
|
||||||
|
assert_eq!(v["x"], json!("scalar"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tombstone_suppresses_inherited_key() {
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(2, "*", "secret_flag", json!(true)),
|
||||||
|
tomb(0, "*", "secret_flag"),
|
||||||
|
]);
|
||||||
|
assert!(!v.contains_key("secret_flag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_null_is_a_value_not_a_deletion() {
|
||||||
|
let (v, _) = resolve(vec![cand(0, "*", "k", Value::Null)]);
|
||||||
|
assert!(v.contains_key("k"));
|
||||||
|
assert_eq!(v["k"], Value::Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn map_merge_stops_at_a_nearer_scalar_boundary() {
|
||||||
|
// nearest map, then a scalar, then a farther map: only the
|
||||||
|
// contiguous top map run merges; the scalar bounds it.
|
||||||
|
let (v, _) = resolve(vec![
|
||||||
|
cand(3, "*", "m", json!({"deep": 1})),
|
||||||
|
cand(2, "*", "m", json!("scalar-boundary")),
|
||||||
|
cand(0, "*", "m", json!({"near": 2})),
|
||||||
|
]);
|
||||||
|
// depth 0 map is the only one above the depth-2 scalar boundary.
|
||||||
|
assert_eq!(v["m"], json!({"near": 2}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ pub mod auth_api;
|
|||||||
pub mod auth_bootstrap;
|
pub mod auth_bootstrap;
|
||||||
pub mod auth_middleware;
|
pub mod auth_middleware;
|
||||||
pub mod authz;
|
pub mod authz;
|
||||||
|
pub mod config_resolver;
|
||||||
pub mod cron_scheduler;
|
pub mod cron_scheduler;
|
||||||
pub mod dead_letter_repo;
|
pub mod dead_letter_repo;
|
||||||
pub mod dead_letter_service;
|
pub mod dead_letter_service;
|
||||||
|
|||||||
Reference in New Issue
Block a user