feat: declarative project-tool foundation (pull/plan/apply/prune)
Add a server-side, atomic, declarative reconcile loop for a single app —
the foundation of the project-tool design. Developers describe an app's
scripts, routes, triggers, and secret-names in `picloud.toml`, then
`pic pull / plan / apply [--prune]` to converge live state to the manifest.
Server (manager-core):
- apply_service: a pure diff engine (compute_diff) shared by plan and
apply, plus an ApplyService that composes the existing per-repo writes
into ONE Postgres transaction. Identity keys mirror the DB UNIQUE
constraints (script=lower(name); route=(method,host_kind,host,
path_kind,path); trigger=per-kind semantic tuple; secret=name).
Apply takes a per-app advisory lock, recomputes the diff in-tx, applies
scripts -> routes -> triggers, prunes dependents-first, commits, then
refreshes the route table once post-commit.
- apply_api: POST /apps/{id}/plan (AppRead) and /apps/{id}/apply.
Apply requires the per-kind write caps the bundle exercises (all three
when --prune), plus AppSecretsRead when it binds an email trigger.
- tx-accepting repo siblings (insert/update/delete *_tx) so the existing
create/update/delete delegate to one SQL definition each.
- email triggers reference an inbound secret by NAME; the value is
resolved, decrypted (AAD-bound), and re-sealed server-side at apply —
it never travels in the manifest.
CLI (picloud-cli):
- manifest.rs (picloud.toml model), client plan/apply, and the pull/plan/
apply commands. pull rejects filesystem-unsafe script names up front.
Safety properties enforced and tested:
- idempotent: a freshly-pulled manifest re-applies as all-NoOp.
- atomic: a mid-bundle failure rolls back with nothing written.
- routes delete-before-insert so a freed binding is reusable in one apply.
- queue one-consumer invariant held inside the shared tx.
- email triggers are never pruned, and a script that still owns an
email/dead-letter trigger can't be pruned (the FK cascade would destroy
the sealed secret) — refused with a pointer to `pic triggers rm`.
- plan and apply agree on unset email-secret references.
No migration: the existing schema's UNIQUE constraints serve as identity
keys. Groups, env-scoping, and the `enabled` toggle are later milestones.
Tested: manager-core lib (360) + CLI bins (27) + 8 project-tool journeys
(pull/plan/apply/prune/email+queue), all green; clippy -D warnings clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
160
crates/manager-core/src/apply_api.rs
Normal file
160
crates/manager-core/src/apply_api.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
//! Admin HTTP surface for the declarative reconcile engine.
|
||||
//!
|
||||
//! `POST /api/v1/admin/apps/{id}/plan` — diff a desired-state bundle
|
||||
//! against the app's live state and return the plan. Read-only; requires
|
||||
//! `AppRead`. The `apply` route (write path) lands in the next milestone.
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use picloud_shared::{AppId, Principal};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::apply_service::{ApplyError, ApplyReport, ApplyService, Bundle, BundleTrigger, Plan};
|
||||
use crate::authz::{require, AuthzDenied, Capability};
|
||||
|
||||
/// Build the apply/plan router. Mounted under `/api/v1/admin`.
|
||||
pub fn apply_router(service: ApplyService) -> Router {
|
||||
Router::new()
|
||||
.route("/apps/{id}/plan", post(plan_handler))
|
||||
.route("/apps/{id}/apply", post(apply_handler))
|
||||
.with_state(service)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ApplyRequest {
|
||||
pub bundle: Bundle,
|
||||
#[serde(default)]
|
||||
pub prune: bool,
|
||||
}
|
||||
|
||||
async fn 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 app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// Read is always needed; write caps are required for the resource kinds
|
||||
// the bundle touches — and for ALL kinds when `prune` is set, since
|
||||
// pruning deletes resources whose bundle section is empty (and a script
|
||||
// delete cascades its routes/triggers).
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
if req.prune || !req.bundle.scripts.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteScript(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.routes.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppWriteRoute(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
if req.prune || !req.bundle.triggers.is_empty() {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppManageTriggers(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
// Email triggers resolve and decrypt a stored secret by name server-side,
|
||||
// which the secrets API guards with `AppSecretsRead`. Require it here too
|
||||
// so apply can't bind a secret a principal couldn't otherwise read — the
|
||||
// caps aren't strictly nested on the API-key scope path.
|
||||
if req.bundle.triggers.iter().any(BundleTrigger::is_email) {
|
||||
require(
|
||||
svc.authz.as_ref(),
|
||||
&principal,
|
||||
Capability::AppSecretsRead(app_id),
|
||||
)
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
}
|
||||
let report = svc
|
||||
.apply(app_id, &req.bundle, req.prune, principal.user_id)
|
||||
.await?;
|
||||
Ok(Json(report))
|
||||
}
|
||||
|
||||
async fn plan_handler(
|
||||
State(svc): State<ApplyService>,
|
||||
Extension(principal): Extension<Principal>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(bundle): Json<Bundle>,
|
||||
) -> Result<Json<Plan>, ApplyError> {
|
||||
let app_id = resolve_app_id(svc.apps.as_ref(), &id_or_slug).await?;
|
||||
// NOTE: the returned `Plan` discloses live secret NAMES (not values). That
|
||||
// is safe today only because `AppRead` and `AppSecretsRead` are co-granted
|
||||
// at every tier (same `script:read` scope, both in the viewer role). If a
|
||||
// future authz split puts `AppSecretsRead` on its own tier, this handler
|
||||
// must additionally require it — otherwise it leaks names a principal
|
||||
// couldn't enumerate via the secrets API.
|
||||
require(svc.authz.as_ref(), &principal, Capability::AppRead(app_id))
|
||||
.await
|
||||
.map_err(map_authz)?;
|
||||
let plan = svc.plan(app_id, &bundle).await?;
|
||||
Ok(Json(plan))
|
||||
}
|
||||
|
||||
/// Resolve a slug-or-id path param to an `AppId`, mapping miss → 404.
|
||||
/// Mirrors the `triggers_api` helper of the same shape.
|
||||
async fn resolve_app_id(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApplyError> {
|
||||
crate::app_repo::resolve_app(apps, ident)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
.map(|l| l.app.id)
|
||||
.ok_or_else(|| ApplyError::AppNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
fn map_authz(denied: AuthzDenied) -> ApplyError {
|
||||
match denied {
|
||||
AuthzDenied::Denied => ApplyError::Forbidden,
|
||||
AuthzDenied::Repo(e) => ApplyError::AuthzRepo(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApplyError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_) => (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, "apply authz repo error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
Self::Backend(e) => {
|
||||
tracing::error!(error = %e, "apply backend error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user