feat(v1.1.4): outbound HTTP SDK + cron triggers

HTTP (`http::*`):
- `HttpService` trait (picloud-shared) + reqwest-backed `HttpServiceImpl`
  (manager-core), wired into the `Services` bundle.
- SSRF deny-list applied to the resolved IP via a custom reqwest
  `dns_resolver` (covers every redirect hop + defeats DNS rebinding) plus
  a literal-IP check at URL-parse time. Scheme/port restrictions, request
  + response body caps (stream-with-cap), layered timeout. Error reason is
  a CIDR category, never the IP. `PICLOUD_HTTP_ALLOW_PRIVATE` dev override
  (logs a startup warning).
- Rhai bridge with three-arg split `verb(url, body, opts)` (resolves the
  brief's body-vs-opts contradiction; unknown opt keys throw). Body
  dispatch by type; response `#{status,headers,body,body_raw}` with JSON
  auto-parse; non-2xx does not throw.
- `Capability::AppHttpRequest` → existing `script:write` scope (no new
  Scope variant). `SdkCallCx` gains `script_id` (attribution + User-Agent).

Cron triggers (4th trigger kind):
- Migration 0017 widens the kind/source_kind CHECKs and adds
  `cron_trigger_details`. `cron`/`chrono-tz` parse + validate 6-field
  schedules and IANA timezones.
- `spawn_cron_scheduler` polls due triggers and enqueues to the universal
  outbox; the dispatcher delivers them (one-line match-arm extension).
  Catch-up fires exactly once per trigger per tick, not once per missed
  window. `ctx.event.cron` for handlers.
- `POST /api/v1/admin/apps/{id}/triggers/cron` reuses the v1.1.3
  cross-app + kind!=module target check.
- Dashboard: admin-gated Triggers tab (cron create form + list).

Follow-ups: redact module backend errors at the resolver boundary (log
original at error level); pin `rhai = "=1.24"`; CHANGELOG incl. retroactive
v1.1.3 cross-app-trigger security note. Version bumps: workspace 1.1.4,
SDK 1.5, dashboard 0.10.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-03 20:23:18 +02:00
parent 6f17259e06
commit 10b5f655d5
39 changed files with 3828 additions and 53 deletions

View File

@@ -0,0 +1,297 @@
//! Cron scheduler — the v1.1.4 time-based trigger source.
//!
//! A single tokio task polls `cron_trigger_details` on a tick (default
//! 30s; `PICLOUD_CRON_TICK_INTERVAL_MS`). For each enabled cron trigger
//! whose next scheduled fire is due, it enqueues ONE outbox row
//! (`source_kind = 'cron'`) and updates `last_fired_at` — both in the
//! same transaction, claimed via `FOR UPDATE SKIP LOCKED` so a future
//! multi-node deploy can't double-fire.
//!
//! The scheduler does NOT dispatch or touch the `ExecutionGate`: it only
//! enqueues. The existing dispatcher picks the row up and acquires the
//! gate exactly as it does for kv/docs/dead_letter rows.
//!
//! **Catch-up policy (matches the brief):** a trigger that missed N fire
//! windows since `last_fired_at` fires exactly ONCE on the next tick,
//! not N times. This falls out of the design: [`next_due`] returns a
//! single canonical scheduled time (the first slot after the reference
//! point), and after firing we set `last_fired_at = now`, so the next
//! tick computes from `now` and sees only future slots. Backfilling
//! missed windows is intentionally out of scope (an explicit replay
//! action is the v1.2+ escape hatch).
use std::str::FromStr;
use std::time::Duration;
use chrono::{DateTime, Utc};
use chrono_tz::Tz;
use cron::Schedule;
use picloud_shared::TriggerEvent;
use sqlx::PgPool;
use uuid::Uuid;
/// Validate a 6-field cron expression. Returns the parse error message
/// on failure.
///
/// # Errors
///
/// Returns the underlying parse error string when `schedule` is not a
/// valid cron expression.
pub fn validate_schedule(schedule: &str) -> Result<(), String> {
Schedule::from_str(schedule)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Validate an IANA timezone name (e.g. `America/Los_Angeles`).
///
/// # Errors
///
/// Returns an error string when `timezone` is not a known IANA name.
pub fn validate_timezone(timezone: &str) -> Result<(), String> {
Tz::from_str(timezone)
.map(|_| ())
.map_err(|_| format!("unknown IANA timezone: {timezone}"))
}
/// Compute whether a cron trigger is due, and if so its canonical
/// scheduled-at moment (UTC).
///
/// Returns `Some(scheduled_at)` when the first scheduled slot after the
/// reference point (`last_fired_at`, or `created_at` if never fired) is
/// at/before `now`; `None` otherwise. Returns `None` if the schedule or
/// timezone fails to parse (the row is skipped — it should never have
/// been inserted, since the admin endpoint validates).
#[must_use]
pub fn next_due(
schedule: &str,
timezone: &str,
last_fired_at: Option<DateTime<Utc>>,
created_at: DateTime<Utc>,
now: DateTime<Utc>,
) -> Option<DateTime<Utc>> {
let sched = Schedule::from_str(schedule).ok()?;
let tz = Tz::from_str(timezone).ok()?;
// Reference: the last actual fire, or creation if never fired. A
// never-fired trigger fires at its first slot at/after creation.
let base = last_fired_at.unwrap_or(created_at);
let base_tz = base.with_timezone(&tz);
let next = sched.after(&base_tz).next()?;
let next_utc = next.with_timezone(&Utc);
(next_utc <= now).then_some(next_utc)
}
/// Spawn the scheduler loop. Runs for the process lifetime.
pub fn spawn_cron_scheduler(pool: PgPool, tick_interval_ms: u32) {
// Floor the tick at 1s so a misconfigured 0 can't spin.
let interval = Duration::from_millis(u64::from(tick_interval_ms).max(1_000));
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
// Skip the immediate first fire so we don't race startup.
ticker.tick().await;
loop {
ticker.tick().await;
if let Err(e) = tick(&pool, Utc::now()).await {
tracing::warn!(?e, "cron scheduler tick errored");
}
}
});
}
#[derive(sqlx::FromRow)]
struct DueRow {
id: Uuid,
app_id: Uuid,
script_id: Uuid,
registered_by_principal: Uuid,
created_at: DateTime<Utc>,
schedule: String,
timezone: String,
last_fired_at: Option<DateTime<Utc>>,
}
/// One scheduler tick: claim enabled cron rows, enqueue the due ones,
/// bump `last_fired_at`. Returns the number of triggers fired.
async fn tick(pool: &PgPool, now: DateTime<Utc>) -> Result<usize, sqlx::Error> {
let mut tx = pool.begin().await?;
let rows: Vec<DueRow> = sqlx::query_as(
"SELECT t.id, t.app_id, t.script_id, t.registered_by_principal, t.created_at, \
d.schedule, d.timezone, d.last_fired_at \
FROM cron_trigger_details d \
JOIN triggers t ON t.id = d.trigger_id \
WHERE t.enabled = TRUE \
FOR UPDATE OF d SKIP LOCKED",
)
.fetch_all(&mut *tx)
.await?;
let mut fired = 0usize;
for r in rows {
let Some(scheduled_at) =
next_due(&r.schedule, &r.timezone, r.last_fired_at, r.created_at, now)
else {
continue;
};
let event = TriggerEvent::Cron {
schedule: r.schedule.clone(),
timezone: r.timezone.clone(),
scheduled_at,
fired_at: now,
};
let payload = serde_json::to_value(&event)
.map_err(|e| sqlx::Error::Decode(Box::new(std::io::Error::other(e))))?;
// Enqueue exactly one outbox row. Relies on the same column
// defaults the OutboxEventEmitter uses (next_attempt_at = NOW(),
// attempt_count = 0, claimed_at NULL → immediately due).
sqlx::query(
"INSERT INTO outbox \
(app_id, source_kind, trigger_id, script_id, payload, \
origin_principal, trigger_depth) \
VALUES ($1, 'cron', $2, $3, $4, $5, 0)",
)
.bind(r.app_id)
.bind(r.id)
.bind(r.script_id)
.bind(payload)
.bind(r.registered_by_principal)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE cron_trigger_details SET last_fired_at = $2 WHERE trigger_id = $1")
.bind(r.id)
.bind(now)
.execute(&mut *tx)
.await?;
fired += 1;
}
tx.commit().await?;
Ok(fired)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn valid_six_field_schedule_accepted() {
// sec min hour dom mon dow — "every weekday at 9am".
validate_schedule("0 0 9 * * MON-FRI").unwrap();
validate_schedule("*/5 * * * * *").unwrap();
validate_schedule("0 0 0 1 1 *").unwrap();
}
#[test]
fn invalid_schedules_rejected() {
// 5-field (no seconds) is not the format we accept.
assert!(validate_schedule("* * * * *").is_err());
// Gibberish.
assert!(validate_schedule("not a cron").is_err());
assert!(validate_schedule("").is_err());
// Out-of-range hour.
assert!(validate_schedule("0 0 99 * * *").is_err());
}
#[test]
fn known_timezones_accepted() {
validate_timezone("UTC").unwrap();
validate_timezone("America/Los_Angeles").unwrap();
validate_timezone("Europe/Berlin").unwrap();
}
#[test]
fn unknown_timezones_rejected() {
assert!(validate_timezone("Mars/Phobos").is_err());
assert!(validate_timezone("PST").is_err()); // abbreviations aren't IANA names
assert!(validate_timezone("").is_err());
}
fn ts(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc)
}
#[test]
fn due_when_next_slot_is_at_or_before_now() {
// Every minute at second 0. Last fired 90s ago → the next slot
// after that is due now.
let created = ts("2026-06-01T00:00:00Z");
let last = Some(ts("2026-06-15T11:58:10Z"));
let now = ts("2026-06-15T12:00:05Z");
let due = next_due("0 * * * * *", "UTC", last, created, now);
assert_eq!(due, Some(ts("2026-06-15T11:59:00Z")));
}
#[test]
fn not_due_when_next_slot_is_in_the_future() {
let created = ts("2026-06-01T00:00:00Z");
let last = Some(ts("2026-06-15T12:00:00Z"));
let now = ts("2026-06-15T12:00:30Z");
// Next minute slot is 12:01:00 — still in the future.
assert_eq!(next_due("0 * * * * *", "UTC", last, created, now), None);
}
#[test]
fn never_fired_uses_created_at_as_reference() {
let created = ts("2026-06-15T12:00:10Z");
let now = ts("2026-06-15T12:01:30Z");
// First slot after creation is 12:01:00, which is <= now → due.
let due = next_due("0 * * * * *", "UTC", None, created, now);
assert_eq!(due, Some(ts("2026-06-15T12:01:00Z")));
}
/// Catch-up policy: a trigger that missed many windows fires exactly
/// ONCE. We simulate two consecutive scheduler ticks the way the DB
/// loop does — fire once, set last_fired = now, then re-evaluate.
#[test]
fn catch_up_fires_exactly_once_after_missed_windows() {
let created = ts("2026-06-15T09:00:00Z");
// Last fired over 5 minutes (5 windows) ago.
let mut last_fired = Some(ts("2026-06-15T11:54:30Z"));
let now = ts("2026-06-15T12:00:05Z");
// Tick 1: due → fire once, advance last_fired to `now`.
let first = next_due("0 * * * * *", "UTC", last_fired, created, now);
assert!(first.is_some(), "should be due after missing windows");
last_fired = Some(now);
// Tick 2 (same wall-clock): NOT due again — only one fire total,
// not one-per-missed-window.
let second = next_due("0 * * * * *", "UTC", last_fired, created, now);
assert_eq!(second, None, "catch-up must fire exactly once");
}
#[test]
fn timezone_affects_fire_time() {
// "9am every day" in Los Angeles. On 2026-06-15, PDT = UTC-7, so
// 09:00 local = 16:00 UTC.
let created = ts("2026-06-15T00:00:00Z");
let last = Some(ts("2026-06-15T15:59:00Z"));
let now = ts("2026-06-15T16:00:30Z");
let due = next_due("0 0 9 * * *", "America/Los_Angeles", last, created, now);
assert_eq!(due, Some(ts("2026-06-15T16:00:00Z")));
// Sanity: the same expression in UTC would NOT be due at 16:00.
assert_eq!(next_due("0 0 9 * * *", "UTC", last, created, now), None);
}
#[test]
fn bad_schedule_or_tz_yields_none() {
let created = ts("2026-06-15T00:00:00Z");
let now = ts("2026-06-15T12:00:00Z");
assert_eq!(next_due("garbage", "UTC", None, created, now), None);
assert_eq!(
next_due("0 * * * * *", "Mars/Phobos", None, created, now),
None
);
}
#[test]
fn utc_offset_constructor_smoke() {
// Guard the chrono TimeZone import is actually exercised.
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 12, 0, 0).unwrap();
assert_eq!(dt, ts("2026-06-15T12:00:00Z"));
}
}