feat: E2E #2 (Stash) gap remediation + S6 hardening
Closes the gaps and the one security finding from the second end-to-end
CLI test (E2E_STASH_REPORT.md), plus the H1 boot-regression found while
re-reviewing those fixes.
Security
- S6: reserved-path validation (`check_reserved`) now case-folds before
comparing, so `/API/v2/x`, `/HEALTHZ`, `/Admin/x` are rejected like
their lowercase forms. Request-time matching stays case-sensitive.
- S10: "public route != public data" callout in sdk-shape.md (script_gate
skips authz when the principal is anonymous).
Observability / features
- G1: trigger executions now write `execution_logs`. Migration 0043 adds
a `source` column (CHECK mirrors ExecutionSource/OutboxSourceKind,
DEFAULT 'http' backfills history); a shared `build_execution_log` helper
in executor-core; dispatcher logging for outbox triggers + queue
consumers (skips sync-HTTP rows the orchestrator already logs). `pic
logs` gains a source column + `--source` filter.
- G5: dev-only in-memory email capture under PICLOUD_DEV_MODE with no SMTP
(email::send succeeds locally), readable at GET /api/v1/admin/dev/emails
(Owner/Admin only; route mounted only in capture mode).
- G6: generalized the Rhai in-place-mutation footgun note (trim/replace/
make_upper/make_lower/crop/truncate/pad return ()).
- G2/G3/G4 (CLI): `pic members`, `pic files`, `pic queues`, read-only
`pic kv` (+ new kv_api.rs); `pic deploy --timeout/--memory/--kind/
--sandbox`; first-class `pic triggers create-{docs,files,pubsub,queue,
email}` wrappers. All new client path segments percent-encoded via seg().
H1 regression fix (found in re-review)
- The S6 change also runs in `compile_routes`, which compiles every stored
route at boot and on each route CRUD. A single stored route the new
validation rejects (creatable while the S6 gap existed) made the whole
compile Err and aborted startup. `compile_routes` is now lenient: it
skips an un-compilable row with a warning instead of bricking boot
(route creation still validates separately). Migration 0044 sweeps
pre-existing reserved-path routes on upgrade (WHERE mirrors
check_reserved exactly). Added regression tests for both.
Verified: cargo fmt, clippy --all-targets --all-features -D warnings, the
schema_snapshot test, and the new S6/lenient-compile unit tests all pass;
boot-resilience and G1/G5 confirmed live.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,11 +24,14 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_executor_core::{ExecError, ExecRequest, ExecResponse, InvocationType};
|
||||
use picloud_executor_core::{
|
||||
build_execution_log, ExecError, ExecRequest, ExecResponse, InvocationType,
|
||||
};
|
||||
use picloud_orchestrator_core::{ExecutionGate, ExecutorClient};
|
||||
use picloud_shared::{
|
||||
DeadLetterId, ExecResponseSummary, ExecutionId, HttpDispatchPayload, InboxDeliveryOutcome,
|
||||
InboxFailureKind, InboxResolver, InboxResult, RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
DeadLetterId, ExecResponseSummary, ExecutionId, ExecutionLogSink, ExecutionSource,
|
||||
HttpDispatchPayload, InboxDeliveryOutcome, InboxFailureKind, InboxResolver, InboxResult,
|
||||
RequestId, ScriptId, ScriptSandbox, TriggerEvent,
|
||||
};
|
||||
use rand::Rng;
|
||||
use uuid::Uuid;
|
||||
@@ -53,6 +56,11 @@ pub struct Dispatcher {
|
||||
pub principals: Arc<dyn PrincipalResolver>,
|
||||
pub executor: Arc<dyn ExecutorClient>,
|
||||
pub gate: Arc<ExecutionGate>,
|
||||
/// G1: records an `execution_logs` row for every trigger run so
|
||||
/// background workers (queue / cron / dead-letter / invoke) show up
|
||||
/// in `pic logs`, not just synchronous HTTP. Same sink the
|
||||
/// orchestrator's data plane writes through.
|
||||
pub log_sink: Arc<dyn ExecutionLogSink>,
|
||||
pub inbox: Arc<dyn InboxResolver>,
|
||||
/// v1.1.9. Reads `queue_messages` for the queue arm + the reclaim
|
||||
/// task. None in tests / harnesses that don't exercise queues.
|
||||
@@ -149,6 +157,39 @@ fn async_exec_timeout_from_env() -> Duration {
|
||||
/// parallelism, not the dispatcher's serial-await.
|
||||
const QUEUE_DISPATCH_PARALLELISM: usize = 32;
|
||||
|
||||
/// Map an outbox row's source kind to the execution-log `source`. The
|
||||
/// wire strings are identical, so this is total in practice; an unknown
|
||||
/// kind would default to `Http`.
|
||||
fn exec_source(row: &OutboxRow) -> ExecutionSource {
|
||||
ExecutionSource::from_wire(row.source_kind.as_str()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// G1: the slice of an `ExecRequest` needed to write an execution-log
|
||||
/// row, captured before the request is moved into the executor.
|
||||
struct ExecLogContext {
|
||||
app_id: picloud_shared::AppId,
|
||||
script_id: ScriptId,
|
||||
request_id: RequestId,
|
||||
path: String,
|
||||
headers: std::collections::BTreeMap<String, String>,
|
||||
body: serde_json::Value,
|
||||
source: ExecutionSource,
|
||||
}
|
||||
|
||||
impl ExecLogContext {
|
||||
fn from_request(req: &ExecRequest, source: ExecutionSource) -> Self {
|
||||
Self {
|
||||
app_id: req.app_id,
|
||||
script_id: req.script_id,
|
||||
request_id: req.request_id,
|
||||
path: req.path.clone(),
|
||||
headers: req.headers.clone(),
|
||||
body: req.body.clone(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatcher {
|
||||
/// Spawn the dispatcher loop as a detached `tokio::task`. Also
|
||||
/// spawns the v1.1.9 queue visibility-timeout reclaim task. Both
|
||||
@@ -376,6 +417,11 @@ impl Dispatcher {
|
||||
script_id: consumer.script_id,
|
||||
updated_at: script.updated_at,
|
||||
};
|
||||
// G1: queue consumers dispatch outside the outbox, so they need
|
||||
// their own log write. Source is always `queue`; no `reply_to`
|
||||
// here, so there's no double-logging concern.
|
||||
let log_cx = ExecLogContext::from_request(&exec_req, ExecutionSource::Queue);
|
||||
let started = Utc::now();
|
||||
let outcome = self
|
||||
.executor
|
||||
.execute_with_identity(
|
||||
@@ -385,8 +431,12 @@ impl Dispatcher {
|
||||
async_exec_timeout_from_env(),
|
||||
)
|
||||
.await;
|
||||
let finished = Utc::now();
|
||||
drop(permit);
|
||||
|
||||
self.record_execution_log(&log_cx, &outcome, started, finished)
|
||||
.await;
|
||||
|
||||
// Best-effort touch on last_fired_at (a failure here doesn't
|
||||
// change ack/nack behavior).
|
||||
if let Err(e) = self
|
||||
@@ -585,18 +635,67 @@ impl Dispatcher {
|
||||
script_id: resolved.script_id,
|
||||
updated_at: resolved.script_updated_at,
|
||||
};
|
||||
// G1: capture the request context before `exec_req` is consumed so
|
||||
// we can write an execution-log row for this run (see below).
|
||||
let log_cx = ExecLogContext::from_request(&exec_req, exec_source(&row));
|
||||
let started = Utc::now();
|
||||
let outcome = self
|
||||
.executor
|
||||
.execute_with_identity(identity, &source, exec_req, async_exec_timeout_from_env())
|
||||
.await;
|
||||
let finished = Utc::now();
|
||||
drop(permit);
|
||||
|
||||
// G1: persist an execution-log row so this run shows up in
|
||||
// `pic logs`. Skip rows with a synchronous receiver (`reply_to`
|
||||
// is set) — those are sync HTTP routes that the orchestrator's
|
||||
// inbox path already logs, and logging here too would duplicate
|
||||
// them. Trigger rows and fire-and-forget async HTTP (202) have no
|
||||
// receiver, so the dispatcher is the only place that can log them.
|
||||
if row.reply_to.is_none() {
|
||||
self.record_execution_log(&log_cx, &outcome, started, finished)
|
||||
.await;
|
||||
}
|
||||
|
||||
match outcome {
|
||||
Ok(resp) => self.handle_success(&row, &resolved, resp).await,
|
||||
Err(err) => self.handle_failure(&row, &resolved, err).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// G1: best-effort persistence of an execution-log row for a
|
||||
/// dispatcher-run script. A sink failure is logged but never changes
|
||||
/// ack/nack/retry behavior — the audit trail is not on the hot path
|
||||
/// of message-delivery correctness.
|
||||
async fn record_execution_log(
|
||||
&self,
|
||||
cx: &ExecLogContext,
|
||||
outcome: &Result<ExecResponse, ExecError>,
|
||||
started: DateTime<Utc>,
|
||||
finished: DateTime<Utc>,
|
||||
) {
|
||||
let log = build_execution_log(
|
||||
cx.app_id,
|
||||
cx.script_id,
|
||||
cx.request_id,
|
||||
cx.path.clone(),
|
||||
cx.headers.clone(),
|
||||
cx.body.clone(),
|
||||
cx.source,
|
||||
outcome,
|
||||
started,
|
||||
finished,
|
||||
);
|
||||
if let Err(e) = self.log_sink.record(log).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
script_id = %cx.script_id,
|
||||
source = cx.source.as_str(),
|
||||
"failed to persist trigger execution log"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_trigger(&self, row: &OutboxRow) -> Result<ResolvedTrigger, DispatcherError> {
|
||||
// For KV and DL kinds, the outbox carries `trigger_id`. Use it
|
||||
// to look up the trigger row, then resolve the script.
|
||||
|
||||
Reference in New Issue
Block a user