feat(apply): owner-generic apply engine — group-node plan/apply (Phase 5 C1)
Phase 5 C1. Generalize the reconcile engine from app-only to an `ApplyOwner
{ App | Group }`, so a GROUP's own config + scripts can be declaratively
reconciled — the foundation for the tree-spanning project tool.
A group node is a subset of an app node (scripts + vars only; no routes /
triggers / secrets-values), so the diff, script/route/trigger/var CRUD, prune,
idempotency, and the Phase-4 inherited-target resolution are all reused
unchanged. Only the owner-varying bits switch on `ApplyOwner`:
* `load_current` — `list_for_group` + `VarOwner::Group` for a group (empty
routes/triggers/secrets); `list_for_app` for an app, exactly as before.
* script create owner (`NewScript{app_id|group_id}`), var writes (new
`set_group_var_tx`/`delete_group_var_tx` mirroring the app variants at
scope `*`), and the advisory lock (owner-tagged).
* `validate_bundle_for` rejects routes/triggers on a group bundle (422);
route-host validation, inherited-target resolution, the post-commit route
refresh, and the unreachable-endpoint warning are app-only.
`apply`/`plan` are unchanged thin wrappers over the new `apply_owner`/
`plan_owner`. New endpoints `POST /groups/{id}/{plan,apply}` gated by
`GroupScripts{Read,Write}` / `GroupVarsWrite`. `ApplyService` gains a `groups`
repo (the tree apply in C3 needs it too).
Live-validated: group plan → apply (group-owned script + var) → idempotent
re-plan noop → routes rejected 422 → DB confirms group ownership. App apply
unchanged: 391 manager-core unit tests + the apply/prune/staleness/overlay
journeys all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,21 +11,24 @@ use axum::{
|
|||||||
routing::post,
|
routing::post,
|
||||||
Extension, Json, Router,
|
Extension, Json, Router,
|
||||||
};
|
};
|
||||||
use picloud_shared::{AppId, Principal};
|
use picloud_shared::{AppId, GroupId, Principal};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::app_repo::AppRepository;
|
use crate::app_repo::AppRepository;
|
||||||
use crate::apply_service::{
|
use crate::apply_service::{
|
||||||
ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
|
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, PlanResult,
|
||||||
};
|
};
|
||||||
use crate::authz::{require, AuthzDenied, Capability};
|
use crate::authz::{require, AuthzDenied, Capability};
|
||||||
|
use crate::group_repo::GroupRepository;
|
||||||
|
|
||||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||||
pub fn apply_router(service: ApplyService) -> Router {
|
pub fn apply_router(service: ApplyService) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/apps/{id}/plan", post(plan_handler))
|
.route("/apps/{id}/plan", post(plan_handler))
|
||||||
.route("/apps/{id}/apply", post(apply_handler))
|
.route("/apps/{id}/apply", post(apply_handler))
|
||||||
|
.route("/groups/{id}/plan", post(group_plan_handler))
|
||||||
|
.route("/groups/{id}/apply", post(group_apply_handler))
|
||||||
.with_state(service)
|
.with_state(service)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +138,91 @@ async fn plan_handler(
|
|||||||
Ok(Json(plan))
|
Ok(Json(plan))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Group node apply (Phase 5): a group declares only scripts + vars (+ secret
|
||||||
|
// names). The bundle reuses the app `Bundle` shape with empty routes/triggers;
|
||||||
|
// the service rejects routes/triggers on a group node.
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async fn group_plan_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(bundle): Json<Bundle>,
|
||||||
|
) -> Result<Json<PlanResult>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
// Plan discloses the group's script + var + secret names; viewer-tier reads.
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsRead(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
let plan = svc.plan_owner(ApplyOwner::Group(group_id), &bundle).await?;
|
||||||
|
Ok(Json(plan))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn group_apply_handler(
|
||||||
|
State(svc): State<ApplyService>,
|
||||||
|
Extension(principal): Extension<Principal>,
|
||||||
|
Path(id_or_slug): Path<String>,
|
||||||
|
Json(req): Json<ApplyRequest>,
|
||||||
|
) -> Result<Json<ApplyReport>, ApplyError> {
|
||||||
|
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
|
||||||
|
// Write caps per touched kind (+ all kinds on prune). Group scripts are
|
||||||
|
// editor-tier (`GroupScriptsWrite`), group vars likewise (`GroupVarsWrite`).
|
||||||
|
if req.prune || !req.bundle.scripts.is_empty() {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupScriptsWrite(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
if req.prune || !req.bundle.vars.is_empty() {
|
||||||
|
require(
|
||||||
|
svc.authz.as_ref(),
|
||||||
|
&principal,
|
||||||
|
Capability::GroupVarsWrite(group_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(map_authz)?;
|
||||||
|
}
|
||||||
|
let report = svc
|
||||||
|
.apply_owner(
|
||||||
|
ApplyOwner::Group(group_id),
|
||||||
|
&req.bundle,
|
||||||
|
req.prune,
|
||||||
|
principal.user_id,
|
||||||
|
req.expected_token.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(Json(report))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
|
||||||
|
async fn resolve_group_id(
|
||||||
|
groups: &dyn GroupRepository,
|
||||||
|
ident: &str,
|
||||||
|
) -> Result<GroupId, ApplyError> {
|
||||||
|
let found = if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||||
|
groups
|
||||||
|
.get_by_id(uuid.into())
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
} else {
|
||||||
|
groups
|
||||||
|
.get_by_slug(ident)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
|
};
|
||||||
|
found
|
||||||
|
.map(|g| g.id)
|
||||||
|
.ok_or_else(|| ApplyError::AppNotFound(format!("group {ident}")))
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
||||||
/// Mirrors the `triggers_api` helper of the same shape.
|
/// Mirrors the `triggers_api` helper of the same shape.
|
||||||
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
use picloud_orchestrator_core::routing::{pattern, RouteTable};
|
||||||
use picloud_shared::{
|
use picloud_shared::{
|
||||||
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, HostKind, KvEventOp, MasterKey,
|
AdminUserId, AppId, DispatchMode, DocsEventOp, FilesEventOp, GroupId, HostKind, KvEventOp,
|
||||||
PathKind, Route, Script, ScriptId, ScriptKind, ScriptSandbox, ScriptValidator, TriggerId,
|
MasterKey, PathKind, Route, Script, ScriptId, ScriptKind, ScriptSandbox, ScriptValidator,
|
||||||
|
TriggerId,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -399,6 +400,8 @@ pub struct ApplyService {
|
|||||||
/// write goes through `set_app_var_tx` / `delete_app_var_tx`).
|
/// write goes through `set_app_var_tx` / `delete_app_var_tx`).
|
||||||
pub vars: Arc<dyn VarsRepo>,
|
pub vars: Arc<dyn VarsRepo>,
|
||||||
pub apps: Arc<dyn AppRepository>,
|
pub apps: Arc<dyn AppRepository>,
|
||||||
|
/// Group lookups (Phase 5): resolve a group slug→id for group/tree apply.
|
||||||
|
pub groups: Arc<dyn crate::group_repo::GroupRepository>,
|
||||||
/// App domain claims — validates a route's host belongs to the app.
|
/// App domain claims — validates a route's host belongs to the app.
|
||||||
pub domains: Arc<dyn AppDomainRepository>,
|
pub domains: Arc<dyn AppDomainRepository>,
|
||||||
pub authz: Arc<dyn AuthzRepo>,
|
pub authz: Arc<dyn AuthzRepo>,
|
||||||
@@ -419,27 +422,79 @@ impl ApplyService {
|
|||||||
/// # Errors
|
/// # Errors
|
||||||
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
||||||
pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result<PlanResult, ApplyError> {
|
pub async fn plan(&self, app_id: AppId, bundle: &Bundle) -> Result<PlanResult, ApplyError> {
|
||||||
let inherited = self.resolve_inherited_targets(app_id, bundle).await?;
|
self.plan_owner(ApplyOwner::App(app_id), bundle).await
|
||||||
self.validate_bundle(bundle, &keys_set(&inherited))?;
|
}
|
||||||
self.validate_route_hosts(app_id, bundle).await?;
|
|
||||||
let current = self.load_current(app_id).await?;
|
/// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
|
||||||
|
/// A group node has only scripts + vars — routes/triggers are rejected, and
|
||||||
|
/// the app-only inherited-target + route-host validation is skipped.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// `Invalid` for a malformed bundle; `Backend` for repo failures.
|
||||||
|
pub async fn plan_owner(
|
||||||
|
&self,
|
||||||
|
owner: ApplyOwner,
|
||||||
|
bundle: &Bundle,
|
||||||
|
) -> Result<PlanResult, ApplyError> {
|
||||||
|
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||||||
|
self.validate_bundle_for(owner, bundle, &keys_set(&inherited))?;
|
||||||
|
if let ApplyOwner::App(app_id) = owner {
|
||||||
|
self.validate_route_hosts(app_id, bundle).await?;
|
||||||
|
}
|
||||||
|
let current = self.load_current(owner).await?;
|
||||||
let current_names = self.resolve_current_target_names(¤t).await?;
|
let current_names = self.resolve_current_target_names(¤t).await?;
|
||||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||||
Ok(PlanResult {
|
Ok(PlanResult {
|
||||||
plan: compute_diff_with_names(¤t, bundle, ¤t_names),
|
plan: compute_diff_with_names(¤t, bundle, ¤t_names),
|
||||||
// Fingerprint of the live state this plan was computed against, so
|
// Fingerprint of the live state this plan was computed against, so
|
||||||
// `apply` can refuse if the app changed underneath it (§4.2).
|
// `apply` can refuse if the node changed underneath it (§4.2).
|
||||||
state_token: state_token_with_names(¤t, ¤t_names),
|
state_token: state_token_with_names(¤t, ¤t_names),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Inherited group-script targets for an app node; a group node has no
|
||||||
|
/// routes/triggers to bind, so there is nothing to inherit (empty map).
|
||||||
|
async fn resolve_inherited_targets_for(
|
||||||
|
&self,
|
||||||
|
owner: ApplyOwner,
|
||||||
|
bundle: &Bundle,
|
||||||
|
) -> Result<HashMap<String, ScriptId>, ApplyError> {
|
||||||
|
match owner {
|
||||||
|
ApplyOwner::App(app_id) => self.resolve_inherited_targets(app_id, bundle).await,
|
||||||
|
ApplyOwner::Group(_) => Ok(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `validate_bundle`, plus a group-node guard: a group owns only scripts +
|
||||||
|
/// vars (+ declared secret names), so a `[group]` manifest carrying routes
|
||||||
|
/// or triggers is a 422 — those are app concerns.
|
||||||
|
fn validate_bundle_for(
|
||||||
|
&self,
|
||||||
|
owner: ApplyOwner,
|
||||||
|
bundle: &Bundle,
|
||||||
|
inherited_endpoints: &HashSet<String>,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
if matches!(owner, ApplyOwner::Group(_)) {
|
||||||
|
if !bundle.routes.is_empty() {
|
||||||
|
return Err(ApplyError::Invalid(
|
||||||
|
"a group manifest cannot declare routes — routes bind to an app".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !bundle.triggers.is_empty() {
|
||||||
|
return Err(ApplyError::Invalid(
|
||||||
|
"a group manifest cannot declare triggers — triggers belong to an app".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.validate_bundle(bundle, inherited_endpoints)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reconcile app `app_id` to `bundle` in a single transaction. Creates
|
/// Reconcile app `app_id` to `bundle` in a single transaction. Creates
|
||||||
/// and updates are always applied; deletions of resources absent from the
|
/// and updates are always applied; deletions of resources absent from the
|
||||||
/// bundle are executed only when `prune` is set (secrets are never pruned).
|
/// bundle are executed only when `prune` is set (secrets are never pruned).
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
|
/// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
|
||||||
#[allow(clippy::too_many_lines)]
|
|
||||||
pub async fn apply(
|
pub async fn apply(
|
||||||
&self,
|
&self,
|
||||||
app_id: AppId,
|
app_id: AppId,
|
||||||
@@ -448,9 +503,35 @@ impl ApplyService {
|
|||||||
actor: AdminUserId,
|
actor: AdminUserId,
|
||||||
expected_token: Option<&str>,
|
expected_token: Option<&str>,
|
||||||
) -> Result<ApplyReport, ApplyError> {
|
) -> Result<ApplyReport, ApplyError> {
|
||||||
let inherited = self.resolve_inherited_targets(app_id, bundle).await?;
|
self.apply_owner(
|
||||||
self.validate_bundle(bundle, &keys_set(&inherited))?;
|
ApplyOwner::App(app_id),
|
||||||
self.validate_route_hosts(app_id, bundle).await?;
|
bundle,
|
||||||
|
prune,
|
||||||
|
actor,
|
||||||
|
expected_token,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner-generic apply (Phase 5): reconcile an app OR group node to `bundle`
|
||||||
|
/// in a single transaction. A group node reconciles only its scripts + vars.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// `Invalid` for a bad bundle; `Backend` for repo/transaction failures.
|
||||||
|
#[allow(clippy::too_many_lines)]
|
||||||
|
pub async fn apply_owner(
|
||||||
|
&self,
|
||||||
|
owner: ApplyOwner,
|
||||||
|
bundle: &Bundle,
|
||||||
|
prune: bool,
|
||||||
|
actor: AdminUserId,
|
||||||
|
expected_token: Option<&str>,
|
||||||
|
) -> Result<ApplyReport, ApplyError> {
|
||||||
|
let inherited = self.resolve_inherited_targets_for(owner, bundle).await?;
|
||||||
|
self.validate_bundle_for(owner, bundle, &keys_set(&inherited))?;
|
||||||
|
if let ApplyOwner::App(app_id) = owner {
|
||||||
|
self.validate_route_hosts(app_id, bundle).await?;
|
||||||
|
}
|
||||||
|
|
||||||
let mut tx = self
|
let mut tx = self
|
||||||
.pool
|
.pool
|
||||||
@@ -466,12 +547,12 @@ impl ApplyService {
|
|||||||
// proper fix and is left as a follow-up. (The queue one-consumer
|
// proper fix and is left as a follow-up. (The queue one-consumer
|
||||||
// invariant is closed independently in `insert_trigger_tx`.)
|
// invariant is closed independently in `insert_trigger_tx`.)
|
||||||
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
||||||
.bind(apply_lock_key(app_id))
|
.bind(apply_lock_key(owner))
|
||||||
.execute(&mut *tx)
|
.execute(&mut *tx)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
|
||||||
let current = self.load_current(app_id).await?;
|
let current = self.load_current(owner).await?;
|
||||||
// Phase 4: names of group scripts the current routes/triggers bind to,
|
// Phase 4: names of group scripts the current routes/triggers bind to,
|
||||||
// so the token / diff / prune resolve inherited bindings (see method).
|
// so the token / diff / prune resolve inherited bindings (see method).
|
||||||
let current_names = self.resolve_current_target_names(¤t).await?;
|
let current_names = self.resolve_current_target_names(¤t).await?;
|
||||||
@@ -497,9 +578,14 @@ impl ApplyService {
|
|||||||
// deployed-but-unreachable. Not an error (it's valid desired state),
|
// deployed-but-unreachable. Not an error (it's valid desired state),
|
||||||
// but surfaced so the operator isn't surprised by a silent 404.
|
// but surfaced so the operator isn't surprised by a silent 404.
|
||||||
report.warnings.extend(disabled_target_warnings(bundle));
|
report.warnings.extend(disabled_target_warnings(bundle));
|
||||||
report
|
// A group endpoint is reached by *inheritance* (a descendant app binds
|
||||||
.warnings
|
// it by name), never by its own route, so the "no route/trigger"
|
||||||
.extend(unreachable_endpoint_warnings(bundle));
|
// warning is noise for every group script — emit it for apps only.
|
||||||
|
if matches!(owner, ApplyOwner::App(_)) {
|
||||||
|
report
|
||||||
|
.warnings
|
||||||
|
.extend(unreachable_endpoint_warnings(bundle));
|
||||||
|
}
|
||||||
|
|
||||||
let bundle_scripts: HashMap<String, &BundleScript> = bundle
|
let bundle_scripts: HashMap<String, &BundleScript> = bundle
|
||||||
.scripts
|
.scripts
|
||||||
@@ -524,9 +610,10 @@ impl ApplyService {
|
|||||||
match ch.op {
|
match ch.op {
|
||||||
Op::Create => {
|
Op::Create => {
|
||||||
let bs = bundle_scripts[&key];
|
let bs = bundle_scripts[&key];
|
||||||
|
let (script_app_id, script_group_id) = owner.script_owner();
|
||||||
let new = NewScript {
|
let new = NewScript {
|
||||||
app_id: Some(app_id),
|
app_id: script_app_id,
|
||||||
group_id: None,
|
group_id: script_group_id,
|
||||||
name: bs.name.clone(),
|
name: bs.name.clone(),
|
||||||
description: bs.description.clone(),
|
description: bs.description.clone(),
|
||||||
source: bs.source.clone(),
|
source: bs.source.clone(),
|
||||||
@@ -638,6 +725,9 @@ impl ApplyService {
|
|||||||
};
|
};
|
||||||
let br = bundle_routes[&ch.key];
|
let br = bundle_routes[&ch.key];
|
||||||
let sid = resolve_script(&name_to_id, &br.script)?;
|
let sid = resolve_script(&name_to_id, &br.script)?;
|
||||||
|
// Routes only exist on app nodes (a group bundle has none, so this
|
||||||
|
// loop is empty for a group — validated above).
|
||||||
|
let app_id = owner.app_id().expect("routes only exist on app nodes");
|
||||||
insert_bundle_route(&mut tx, app_id, sid, br).await?;
|
insert_bundle_route(&mut tx, app_id, sid, br).await?;
|
||||||
if is_update {
|
if is_update {
|
||||||
report.routes_updated += 1;
|
report.routes_updated += 1;
|
||||||
@@ -655,6 +745,8 @@ impl ApplyService {
|
|||||||
if ch.op != Op::Create {
|
if ch.op != Op::Create {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// Triggers only exist on app nodes (a group bundle has none).
|
||||||
|
let app_id = owner.app_id().expect("triggers only exist on app nodes");
|
||||||
let bt = bundle_triggers[&ch.key];
|
let bt = bundle_triggers[&ch.key];
|
||||||
let sid = resolve_script(&name_to_id, bt.script())?;
|
let sid = resolve_script(&name_to_id, bt.script())?;
|
||||||
if let BundleTrigger::Email {
|
if let BundleTrigger::Email {
|
||||||
@@ -688,11 +780,15 @@ impl ApplyService {
|
|||||||
for ch in &plan.vars {
|
for ch in &plan.vars {
|
||||||
match ch.op {
|
match ch.op {
|
||||||
Op::Create => {
|
Op::Create => {
|
||||||
set_app_var_tx(&mut tx, app_id, &ch.key, &bundle.vars[&ch.key]).await?;
|
owner
|
||||||
|
.set_var_tx(&mut tx, &ch.key, &bundle.vars[&ch.key])
|
||||||
|
.await?;
|
||||||
report.vars_created += 1;
|
report.vars_created += 1;
|
||||||
}
|
}
|
||||||
Op::Update => {
|
Op::Update => {
|
||||||
set_app_var_tx(&mut tx, app_id, &ch.key, &bundle.vars[&ch.key]).await?;
|
owner
|
||||||
|
.set_var_tx(&mut tx, &ch.key, &bundle.vars[&ch.key])
|
||||||
|
.await?;
|
||||||
report.vars_updated += 1;
|
report.vars_updated += 1;
|
||||||
}
|
}
|
||||||
Op::NoOp | Op::Delete => {}
|
Op::NoOp | Op::Delete => {}
|
||||||
@@ -770,7 +866,7 @@ impl ApplyService {
|
|||||||
// pruned): a live app var the manifest dropped is deleted.
|
// pruned): a live app var the manifest dropped is deleted.
|
||||||
for ch in &plan.vars {
|
for ch in &plan.vars {
|
||||||
if ch.op == Op::Delete {
|
if ch.op == Op::Delete {
|
||||||
delete_app_var_tx(&mut tx, app_id, &ch.key).await?;
|
owner.delete_var_tx(&mut tx, &ch.key).await?;
|
||||||
report.vars_deleted += 1;
|
report.vars_deleted += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -782,12 +878,15 @@ impl ApplyService {
|
|||||||
|
|
||||||
// Post-commit: rebuild the orchestrator's route snapshot once. A
|
// Post-commit: rebuild the orchestrator's route snapshot once. A
|
||||||
// failure here doesn't undo the committed DB write — the table
|
// failure here doesn't undo the committed DB write — the table
|
||||||
// self-heals on the next route write or restart.
|
// self-heals on the next route write or restart. A group node touches
|
||||||
if let Err(e) = self.refresh_route_table().await {
|
// no routes, so there's nothing to refresh.
|
||||||
tracing::warn!(error = %e, "apply: route table refresh failed");
|
if matches!(owner, ApplyOwner::App(_)) {
|
||||||
report
|
if let Err(e) = self.refresh_route_table().await {
|
||||||
.warnings
|
tracing::warn!(error = %e, "apply: route table refresh failed");
|
||||||
.push("route table refresh failed; it will self-heal".into());
|
report
|
||||||
|
.warnings
|
||||||
|
.push("route table refresh failed; it will self-heal".into());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(report)
|
Ok(report)
|
||||||
}
|
}
|
||||||
@@ -1146,30 +1245,44 @@ impl ApplyService {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn load_current(&self, app_id: AppId) -> Result<CurrentState, ApplyError> {
|
async fn load_current(&self, owner: ApplyOwner) -> Result<CurrentState, ApplyError> {
|
||||||
let scripts = self
|
// A group node has only scripts + vars; routes / triggers / secrets are
|
||||||
.scripts
|
// app-only and stay empty (the bundle for a group declares none).
|
||||||
.list_for_app(app_id)
|
let (scripts, routes, triggers, secret_names, var_owner) = match owner {
|
||||||
.await
|
ApplyOwner::App(app_id) => (
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
self.scripts
|
||||||
let routes = self
|
.list_for_app(app_id)
|
||||||
.routes
|
.await
|
||||||
.list_for_app(app_id)
|
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||||
.await
|
self.routes
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.list_for_app(app_id)
|
||||||
let triggers = self
|
.await
|
||||||
.triggers
|
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||||
.list_for_app(app_id)
|
self.triggers
|
||||||
.await
|
.list_for_app(app_id)
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
.await
|
||||||
let secret_names = self.all_secret_names(app_id).await?;
|
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||||
// App's OWN vars, narrowed to scope `*` and real values: the manifest
|
self.all_secret_names(app_id).await?,
|
||||||
// reconciles app-own env-agnostic config, not inherited group vars
|
VarOwner::App(app_id),
|
||||||
// (those aren't in this owner's rows at all) nor tombstones (an
|
),
|
||||||
// imperative suppress the manifest can't express).
|
ApplyOwner::Group(group_id) => (
|
||||||
|
self.scripts
|
||||||
|
.list_for_group(group_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
Vec::new(),
|
||||||
|
VarOwner::Group(group_id),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
// The owner's OWN vars, narrowed to scope `*` and real values: the
|
||||||
|
// manifest reconciles env-agnostic config, not inherited vars (not in
|
||||||
|
// this owner's rows) nor tombstones (an imperative suppress the manifest
|
||||||
|
// can't express).
|
||||||
let vars = self
|
let vars = self
|
||||||
.vars
|
.vars
|
||||||
.list_for_owner(VarOwner::App(app_id))
|
.list_for_owner(var_owner)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1350,6 +1463,99 @@ async fn delete_app_var_tx(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Upsert one group-owned var at scope `*`, in the apply transaction (Phase 5).
|
||||||
|
/// Mirrors `set_app_var_tx` for `VarOwner::Group`. Env-scoped group vars
|
||||||
|
/// (`@staging`) stay imperative for now — a group manifest declares only
|
||||||
|
/// env-agnostic (`*`) group config.
|
||||||
|
async fn set_group_var_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
group_id: GroupId,
|
||||||
|
key: &str,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO vars (group_id, environment_scope, key, value, is_tombstone) \
|
||||||
|
VALUES ($1, '*', $2, $3, FALSE) \
|
||||||
|
ON CONFLICT (group_id, environment_scope, key) WHERE group_id IS NOT NULL DO UPDATE \
|
||||||
|
SET value = EXCLUDED.value, is_tombstone = FALSE, updated_at = NOW()",
|
||||||
|
)
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.bind(key)
|
||||||
|
.bind(value)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete one group-owned var at scope `*`, in the apply transaction (Phase 5).
|
||||||
|
async fn delete_group_var_tx(
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
group_id: GroupId,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
sqlx::query("DELETE FROM vars WHERE group_id = $1 AND environment_scope = '*' AND key = $2")
|
||||||
|
.bind(group_id.into_inner())
|
||||||
|
.bind(key)
|
||||||
|
.execute(&mut **tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner of an apply node (Phase 5): an app or a group. The apply engine is
|
||||||
|
/// owner-generic — only script-create ownership, var writes, the current-state
|
||||||
|
/// load, and the advisory lock differ; the diff and all other CRUD are shared.
|
||||||
|
/// A group node has only scripts + vars (no routes / triggers / secrets).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ApplyOwner {
|
||||||
|
App(AppId),
|
||||||
|
Group(GroupId),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplyOwner {
|
||||||
|
/// `(app_id, group_id)` for a `NewScript` — exactly one is `Some`.
|
||||||
|
fn script_owner(self) -> (Option<AppId>, Option<GroupId>) {
|
||||||
|
match self {
|
||||||
|
Self::App(a) => (Some(a), None),
|
||||||
|
Self::Group(g) => (None, Some(g)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The app id, when this is an app node. Routes/triggers/email-secrets only
|
||||||
|
/// exist on app nodes, so their reconcile paths call this with an
|
||||||
|
/// `expect` — guarded by validation that rejects them on a group bundle.
|
||||||
|
fn app_id(self) -> Option<AppId> {
|
||||||
|
match self {
|
||||||
|
Self::App(a) => Some(a),
|
||||||
|
Self::Group(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_var_tx(
|
||||||
|
self,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
key: &str,
|
||||||
|
value: &serde_json::Value,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
match self {
|
||||||
|
Self::App(a) => set_app_var_tx(tx, a, key, value).await,
|
||||||
|
Self::Group(g) => set_group_var_tx(tx, g, key, value).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_var_tx(
|
||||||
|
self,
|
||||||
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<(), ApplyError> {
|
||||||
|
match self {
|
||||||
|
Self::App(a) => delete_app_var_tx(tx, a, key).await,
|
||||||
|
Self::Group(g) => delete_group_var_tx(tx, g, key).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn diff_scripts(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
fn diff_scripts(current: &CurrentState, bundle: &Bundle) -> Vec<ResourceChange> {
|
||||||
let by_name: HashMap<String, &Script> = current
|
let by_name: HashMap<String, &Script> = current
|
||||||
.scripts
|
.scripts
|
||||||
@@ -2118,13 +2324,23 @@ fn state_token_with_names(
|
|||||||
format!("{h:016x}")
|
format!("{h:016x}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-app advisory lock key, namespaced so it can't collide with the
|
/// Per-node advisory lock key, namespaced so it can't collide with the
|
||||||
/// queue-trigger lock space.
|
/// queue-trigger lock space. App and group ids live in disjoint UUID spaces and
|
||||||
fn apply_lock_key(app_id: AppId) -> i64 {
|
/// are tagged by owner kind, so an app and a group never share a key.
|
||||||
|
fn apply_lock_key(owner: ApplyOwner) -> i64 {
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
"picloud-apply".hash(&mut h);
|
"picloud-apply".hash(&mut h);
|
||||||
app_id.into_inner().hash(&mut h);
|
match owner {
|
||||||
|
ApplyOwner::App(a) => {
|
||||||
|
"app".hash(&mut h);
|
||||||
|
a.into_inner().hash(&mut h);
|
||||||
|
}
|
||||||
|
ApplyOwner::Group(g) => {
|
||||||
|
"group".hash(&mut h);
|
||||||
|
g.into_inner().hash(&mut h);
|
||||||
|
}
|
||||||
|
}
|
||||||
i64::from_ne_bytes(h.finish().to_ne_bytes())
|
i64::from_ne_bytes(h.finish().to_ne_bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ pub async fn build_app(
|
|||||||
secrets: secrets_repo.clone(),
|
secrets: secrets_repo.clone(),
|
||||||
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
|
vars: Arc::new(PostgresVarsRepo::new(pool.clone())),
|
||||||
apps: apps_repo.clone(),
|
apps: apps_repo.clone(),
|
||||||
|
groups: groups_repo.clone(),
|
||||||
domains: domains_repo.clone(),
|
domains: domains_repo.clone(),
|
||||||
authz: authz.clone(),
|
authz: authz.clone(),
|
||||||
validator: engine.clone(),
|
validator: engine.clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user