diff --git a/crates/manager-core/migrations/0048_vars.sql b/crates/manager-core/migrations/0048_vars.sql new file mode 100644 index 0000000..3cd8cc0 --- /dev/null +++ b/crates/manager-core/migrations/0048_vars.sql @@ -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; diff --git a/crates/manager-core/src/config_resolver.rs b/crates/manager-core/src/config_resolver.rs new file mode 100644 index 0000000..e595005 --- /dev/null +++ b/crates/manager-core/src/config_resolver.rs @@ -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, src: &Map) { + 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) -> 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, +) -> (BTreeMap, BTreeMap) { + let mut by_key: BTreeMap> = 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, 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 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})); + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index ea7f5bc..176870d 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -31,6 +31,7 @@ pub mod auth_api; pub mod auth_bootstrap; pub mod auth_middleware; pub mod authz; +pub mod config_resolver; pub mod cron_scheduler; pub mod dead_letter_repo; pub mod dead_letter_service;