feat: E2E #2 (Stash) gap remediation + S6 hardening
Some checks failed
CI / Rust — fmt, clippy, test (push) Failing after 6m19s
CI / Dashboard — check (push) Successful in 9m48s

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:
MechaCat02
2026-06-13 15:01:04 +02:00
parent a91b134285
commit 51f14fa2b1
33 changed files with 2223 additions and 195 deletions

View File

@@ -12,7 +12,7 @@ use chrono::{DateTime, Utc};
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use picloud_shared::{
AdminUserId, ApiKeyId, App, AppDomain, AppId, AppRole, AppUser, DispatchMode, ExecutionLog,
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId,
HostKind, InstanceRole, PathKind, Route, Scope, Script, ScriptId, ScriptKind, ScriptSandbox,
};
use reqwest::{header, Method, RequestBuilder, StatusCode};
use serde::{Deserialize, Serialize};
@@ -171,10 +171,23 @@ impl Client {
}
/// `PUT /api/v1/admin/scripts/{id}` — matches the dashboard, which
/// uses PUT despite the field-level update semantics.
pub async fn scripts_update_source(&self, id: &str, source: &str) -> Result<Script> {
/// uses PUT despite the field-level update semantics. `cfg` carries
/// optional per-script runtime overrides (G3); unset fields are
/// omitted so they keep their stored value.
pub async fn scripts_update_source(
&self,
id: &str,
source: &str,
cfg: &ScriptConfig,
) -> Result<Script> {
let id = seg(id);
let body = UpdateScriptBody { source };
let body = UpdateScriptBody {
source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
let resp = self
.request(Method::PUT, &format!("/api/v1/admin/scripts/{id}"))
.json(&body)
@@ -222,15 +235,19 @@ impl Client {
}
/// `GET /api/v1/admin/scripts/{id}/logs?limit=N`
pub async fn logs_list(&self, script_id: &str, limit: u32) -> Result<Vec<ExecutionLog>> {
pub async fn logs_list(
&self,
script_id: &str,
limit: u32,
source: Option<&str>,
) -> Result<Vec<ExecutionLog>> {
let script_id = seg(script_id);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}"),
)
.send()
.await?;
let mut path = format!("/api/v1/admin/scripts/{script_id}/logs?limit={limit}");
if let Some(src) = source {
path.push_str("&source=");
path.push_str(&seg(src));
}
let resp = self.request(Method::GET, &path).send().await?;
decode(resp).await
}
@@ -709,6 +726,180 @@ impl Client {
.await?;
decode(resp).await
}
// --- App membership (G2) ----------------------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/members`
pub async fn members_list(&self, app: &str) -> Result<Vec<AppMemberDto>> {
let app = seg(app);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/members"))
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/apps/{id_or_slug}/members`
pub async fn members_grant(
&self,
app: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let app = seg(app);
let body = serde_json::json!({ "user_id": user_id, "role": role });
let resp = self
.request(Method::POST, &format!("/api/v1/admin/apps/{app}/members"))
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `PATCH /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
pub async fn members_set_role(
&self,
app: &str,
user_id: &str,
role: AppRole,
) -> Result<AppMemberDto> {
let (app, user_id) = (seg(app), seg(user_id));
let body = serde_json::json!({ "role": role });
let resp = self
.request(
Method::PATCH,
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
)
.json(&body)
.send()
.await?;
decode(resp).await
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}/members/{user_id}`
pub async fn members_remove(&self, app: &str, user_id: &str) -> Result<()> {
let (app, user_id) = (seg(app), seg(user_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/members/{user_id}"),
)
.send()
.await?;
decode_status(resp).await
}
// --- Files (G2, read-only admin surface) ------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/files?collection=&limit=`
pub async fn files_list(
&self,
app: &str,
collection: &str,
limit: u32,
) -> Result<ListFilesResponse> {
let app = seg(app);
let collection = seg(collection);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/files?collection={collection}&limit={limit}"),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}` —
/// streams the raw bytes (download). Returns the body verbatim.
pub async fn files_get_bytes(
&self,
app: &str,
collection: &str,
file_id: &str,
) -> Result<Vec<u8>> {
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
)
.send()
.await?;
if resp.status().is_success() {
Ok(resp.bytes().await.context("reading file bytes")?.to_vec())
} else {
Err(server_error(resp).await)
}
}
/// `DELETE /api/v1/admin/apps/{id_or_slug}/files/{collection}/{file_id}`
pub async fn files_delete(&self, app: &str, collection: &str, file_id: &str) -> Result<()> {
let (app, collection, file_id) = (seg(app), seg(collection), seg(file_id));
let resp = self
.request(
Method::DELETE,
&format!("/api/v1/admin/apps/{app}/files/{collection}/{file_id}"),
)
.send()
.await?;
decode_status(resp).await
}
// --- KV (G2, read-only admin surface) ---------------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/kv?collection=&limit=`
pub async fn kv_list(&self, app: &str, collection: &str, limit: u32) -> Result<KvListPageDto> {
let app = seg(app);
let collection = seg(collection);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/kv?collection={collection}&limit={limit}"),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/kv/{collection}/{key}`
pub async fn kv_get(&self, app: &str, collection: &str, key: &str) -> Result<Value> {
let (app, collection, key) = (seg(app), seg(collection), seg(key));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/kv/{collection}/{key}"),
)
.send()
.await?;
let wrapped: KvGetResponse = decode(resp).await?;
Ok(wrapped.value)
}
// --- Queues (G2, read-only admin surface) -----------------------------
/// `GET /api/v1/admin/apps/{id_or_slug}/queues`
pub async fn queues_list(&self, app: &str) -> Result<Vec<QueueSummaryDto>> {
let app = seg(app);
let resp = self
.request(Method::GET, &format!("/api/v1/admin/apps/{app}/queues"))
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id_or_slug}/queues/{queue_name}`
pub async fn queue_get(&self, app: &str, queue_name: &str) -> Result<QueueDetailDto> {
let (app, queue_name) = (seg(app), seg(queue_name));
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{app}/queues/{queue_name}"),
)
.send()
.await?;
decode(resp).await
}
}
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
@@ -957,6 +1148,17 @@ pub struct SecretItemDto {
pub updated_at: DateTime<Utc>,
}
/// Per-script runtime config the CLI can now set (G3). All optional — an
/// unset field is omitted so the server applies its own default (and the
/// `PICLOUD_SANDBOX_MAX_*` admin ceilings still clamp overrides).
#[derive(Debug, Default, Clone)]
pub struct ScriptConfig {
pub timeout_seconds: Option<i32>,
pub memory_limit_mb: Option<i32>,
pub kind: Option<ScriptKind>,
pub sandbox: Option<ScriptSandbox>,
}
#[derive(Debug, Serialize)]
pub struct CreateScriptBody<'a> {
pub app_id: AppId,
@@ -964,11 +1166,104 @@ pub struct CreateScriptBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
pub source: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout_seconds: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub memory_limit_mb: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<ScriptKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sandbox: Option<ScriptSandbox>,
}
#[derive(Debug, Serialize)]
struct UpdateScriptBody<'a> {
source: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
timeout_seconds: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
memory_limit_mb: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
kind: Option<ScriptKind>,
#[serde(skip_serializing_if = "Option::is_none")]
sandbox: Option<ScriptSandbox>,
}
// --- G2 response DTOs (deserialize-only mirrors of the server shapes) ---
// `#[allow(dead_code)]` on a couple of fields below: these structs mirror
// the full server response shape (so the surface is documented and future
// columns are a one-line add), but the TSV/KvBlock renderers don't print
// every field today.
#[derive(Debug, Deserialize)]
pub struct AppMemberDto {
pub user_id: AdminUserId,
pub username: String,
#[allow(dead_code)]
pub email: Option<String>,
pub instance_role: InstanceRole,
pub is_active: bool,
pub role: AppRole,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Deserialize)]
pub struct FileMetaDto {
pub id: String,
#[allow(dead_code)]
pub collection: String,
pub name: String,
pub content_type: String,
pub size: u64,
#[allow(dead_code)]
pub checksum: String,
#[allow(dead_code)]
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Deserialize)]
pub struct ListFilesResponse {
pub files: Vec<FileMetaDto>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct KvListPageDto {
pub keys: Vec<String>,
pub next_cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct KvGetResponse {
value: Value,
}
#[derive(Debug, Deserialize)]
pub struct QueueSummaryDto {
pub queue_name: String,
pub total: u64,
pub pending: u64,
pub claimed: u64,
}
#[derive(Debug, Deserialize)]
pub struct QueueConsumerDto {
pub trigger_id: String,
pub script_id: ScriptId,
pub script_name: String,
pub visibility_timeout_secs: u32,
pub last_fired_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Deserialize)]
pub struct QueueDetailDto {
pub queue_name: String,
pub total: u64,
pub pending: u64,
pub claimed: u64,
pub consumer: Option<QueueConsumerDto>,
}
#[derive(Debug, Serialize)]

View File

@@ -0,0 +1,71 @@
//! `pic files ls | get | rm` — operator-facing files inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/files*` surface. There is
//! no `set`/`upload` — blob writes go through scripts (`files::create`);
//! the admin surface is inspect + delete only, matching the dashboard.
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = client.files_list(app, collection, limit).await?;
let mut table = Table::new(["id", "name", "content_type", "size", "updated_at"]);
for f in page.files {
table.row([
f.id,
f.name,
f.content_type,
f.size.to_string(),
f.updated_at,
]);
}
table.print(mode);
if page.next_cursor.is_some() {
let _ = writeln!(
std::io::stderr(),
"(more results available — raise --limit to see them)"
);
}
Ok(())
}
/// Download a file's bytes. With `--out <path>` writes to disk; otherwise
/// streams raw bytes to stdout (pipe to a file or `xxd`).
pub async fn get(app: &str, collection: &str, file_id: &str, out: Option<&Path>) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let bytes = client.files_get_bytes(app, collection, file_id).await?;
match out {
Some(path) => {
std::fs::write(path, &bytes).with_context(|| format!("writing {}", path.display()))?;
let _ = writeln!(
std::io::stderr(),
"Wrote {} bytes to {}",
bytes.len(),
path.display()
);
}
None => {
std::io::stdout()
.write_all(&bytes)
.context("writing bytes to stdout")?;
}
}
Ok(())
}
pub async fn rm(app: &str, collection: &str, file_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.files_delete(app, collection, file_id).await?;
println!("Deleted file {file_id} from {collection}");
Ok(())
}

View File

@@ -0,0 +1,42 @@
//! `pic kv ls | get` — read-only KV inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/kv*` surface. There is no
//! `set`/`rm` on purpose: KV writes go through `kv::set` in scripts (which
//! emit the change events triggers depend on); an admin write would bypass
//! that, so the CLI stays read-only (matching the queues precedent).
use std::io::Write;
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: &str, collection: &str, limit: u32, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let page = client.kv_list(app, collection, limit).await?;
let mut table = Table::new(["key"]);
for k in page.keys {
table.row([k]);
}
table.print(mode);
if page.next_cursor.is_some() {
let _ = writeln!(
std::io::stderr(),
"(more keys available — raise --limit to see them)"
);
}
Ok(())
}
pub async fn get(app: &str, collection: &str, key: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let value = client.kv_get(app, collection, key).await?;
// Always emit the JSON value (pretty) so it pipes cleanly into jq.
let pretty = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
println!("{pretty}");
Ok(())
}

View File

@@ -12,10 +12,15 @@ use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
pub async fn run(
script_id: &str,
limit: u32,
source: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let entries = client.logs_list(script_id, limit).await?;
let entries = client.logs_list(script_id, limit, source).await?;
match mode {
OutputMode::Tsv => render_tsv(&entries),
OutputMode::Json => render_json(&entries),
@@ -24,11 +29,14 @@ pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
}
fn render_tsv(entries: &[ExecutionLog]) {
let mut table = Table::new(["created_at", "status", "summary"]);
// `source` is shown so background runs (queue/cron/invoke/…) are
// distinguishable from HTTP at a glance — the whole point of G1.
let mut table = Table::new(["created_at", "source", "status", "summary"]);
for e in entries {
let summary = summarize(&e.response_body, &e.script_logs);
table.row([
e.created_at.to_rfc3339(),
e.source.as_str().to_string(),
status_label(&e.status).to_string(),
truncate(&summary, 120),
]);

View File

@@ -0,0 +1,86 @@
//! `pic members ls | add | set | rm` — manage app membership (G2).
//!
//! Wraps `/api/v1/admin/apps/{id}/members*`. All gated on
//! `AppAdmin(app)` server-side; Editors/Viewers get a 403.
use anyhow::Result;
use picloud_shared::AppRole;
use crate::client::Client;
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let members = client.members_list(app).await?;
let mut table = Table::new(["user_id", "username", "role", "instance_role", "active"]);
for m in members {
table.row([
m.user_id.to_string(),
m.username,
app_role_str(m.role).to_string(),
format!("{:?}", m.instance_role).to_lowercase(),
m.is_active.to_string(),
]);
}
table.print(mode);
Ok(())
}
pub async fn add(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.members_grant(app, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn set(app: &str, user_id: &str, role: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let m = client
.members_set_role(app, user_id, parse_role(role)?)
.await?;
print_member(&m, mode);
Ok(())
}
pub async fn rm(app: &str, user_id: &str) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
client.members_remove(app, user_id).await?;
println!("Removed {user_id} from {app}");
Ok(())
}
fn print_member(m: &crate::client::AppMemberDto, mode: OutputMode) {
let mut block = KvBlock::new();
block
.field("user_id", m.user_id.to_string())
.field("username", m.username.clone())
.field("role", app_role_str(m.role))
.field("created_at", m.created_at.to_rfc3339());
block.print(mode);
}
fn parse_role(s: &str) -> Result<AppRole> {
match s.to_ascii_lowercase().as_str() {
"app_admin" | "admin" => Ok(AppRole::AppAdmin),
"editor" => Ok(AppRole::Editor),
"viewer" => Ok(AppRole::Viewer),
other => Err(anyhow::anyhow!(
"unknown role {other:?} (want app_admin | editor | viewer)"
)),
}
}
fn app_role_str(r: AppRole) -> &'static str {
match r {
AppRole::AppAdmin => "app_admin",
AppRole::Editor => "editor",
AppRole::Viewer => "viewer",
}
}

View File

@@ -3,9 +3,13 @@ pub mod api_keys;
pub mod apps;
pub mod apps_domains;
pub mod dead_letters;
pub mod files;
pub mod kv;
pub mod login;
pub mod logout;
pub mod logs;
pub mod members;
pub mod queues;
pub mod routes;
pub mod scripts;
pub mod secrets;

View File

@@ -0,0 +1,62 @@
//! `pic queues ls | show` — read-only queue inspection (G2).
//!
//! Wraps the read-only `/api/v1/admin/apps/{id}/queues*` surface (no
//! purge/requeue — that is v1.2). Gated on `AppLogRead` server-side.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
pub async fn ls(app: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let queues = client.queues_list(app).await?;
let mut table = Table::new(["queue", "total", "pending", "claimed"]);
for q in queues {
table.row([
q.queue_name,
q.total.to_string(),
q.pending.to_string(),
q.claimed.to_string(),
]);
}
table.print(mode);
Ok(())
}
pub async fn show(app: &str, queue_name: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let q = client.queue_get(app, queue_name).await?;
let mut block = KvBlock::new();
block
.field("queue", q.queue_name)
.field("total", q.total.to_string())
.field("pending", q.pending.to_string())
.field("claimed", q.claimed.to_string());
match q.consumer {
Some(c) => {
block
.field("consumer_script", c.script_name)
.field("consumer_script_id", c.script_id.to_string())
.field("consumer_trigger_id", c.trigger_id)
.field(
"visibility_timeout_secs",
c.visibility_timeout_secs.to_string(),
)
.field(
"last_fired_at",
c.last_fired_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".to_string()),
);
}
None => {
block.field("consumer", "(none registered)");
}
}
block.print(mode);
Ok(())
}

View File

@@ -8,7 +8,7 @@ use anyhow::{anyhow, Context, Result};
use picloud_shared::AppId;
use serde_json::Value;
use crate::client::{Client, CreateScriptBody};
use crate::client::{Client, CreateScriptBody, ScriptConfig};
use crate::config;
use crate::output::{KvBlock, OutputMode, Table};
@@ -62,6 +62,7 @@ pub async fn deploy(
app_ident: &str,
name_override: Option<&str>,
description: Option<&str>,
cfg: &ScriptConfig,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
@@ -90,7 +91,7 @@ pub async fn deploy(
let existing = client.scripts_list_by_app(app_ident).await?;
let (script, action) = if let Some(s) = existing.into_iter().find(|s| s.name == name) {
let updated = client
.scripts_update_source(&s.id.to_string(), &source)
.scripts_update_source(&s.id.to_string(), &source, cfg)
.await?;
(updated, "updated")
} else {
@@ -99,6 +100,10 @@ pub async fn deploy(
name: &name,
description,
source: &source,
timeout_seconds: cfg.timeout_seconds,
memory_limit_mb: cfg.memory_limit_mb,
kind: cfg.kind,
sandbox: cfg.sandbox,
};
(client.scripts_create(&body).await?, "created")
};

View File

@@ -124,6 +124,114 @@ pub async fn create_dead_letter(
Ok(())
}
/// docs/files share KV's `{collection_glob, ops?}` shape.
async fn create_collection_trigger(
kind: &str,
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["collection_glob"] = json!(collection_glob);
if !ops.is_empty() {
body["ops"] = json!(ops);
}
let created = client.triggers_create(app, kind, &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_docs(
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
create_collection_trigger("docs", app, script_id, collection_glob, ops, dispatch, mode).await
}
pub async fn create_files(
app: &str,
script_id: &str,
collection_glob: &str,
ops: &[String],
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
create_collection_trigger(
"files",
app,
script_id,
collection_glob,
ops,
dispatch,
mode,
)
.await
}
pub async fn create_pubsub(
app: &str,
script_id: &str,
topic_pattern: &str,
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["topic_pattern"] = json!(topic_pattern);
let created = client.triggers_create(app, "pubsub", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_queue(
app: &str,
script_id: &str,
queue_name: &str,
visibility_timeout_secs: Option<u32>,
dispatch: &str,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let mut body = base_body(script_id, dispatch);
body["queue_name"] = json!(queue_name);
if let Some(v) = visibility_timeout_secs {
body["visibility_timeout_secs"] = json!(v);
}
let created = client.triggers_create(app, "queue", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_email(
app: &str,
script_id: &str,
inbound_secret: Option<&str>,
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
// Email triggers take no dispatch_mode (inbound webhook only) and an
// optional shared HMAC secret the provider signs POSTs with.
let mut body = json!({ "script_id": script_id });
if let Some(secret) = inbound_secret {
body["inbound_secret"] = json!(secret);
}
let created = client.triggers_create(app, "email", &body).await?;
print_created(&created, mode);
Ok(())
}
pub async fn create_from_json(app: &str, kind: &str, body: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;

View File

@@ -128,6 +128,138 @@ enum Cmd {
#[command(subcommand)]
cmd: SecretsCmd,
},
/// App membership — list members and grant / change / revoke their
/// per-app role (app_admin | editor | viewer).
Members {
#[command(subcommand)]
cmd: MembersCmd,
},
/// Files inspection — list a collection's blobs, download bytes, or
/// delete a file. Read + delete only; writes go through scripts.
Files {
#[command(subcommand)]
cmd: FilesCmd,
},
/// Queue inspection — list queues with depth counts, or drill into
/// one queue's stats + registered consumer. Read-only.
Queues {
#[command(subcommand)]
cmd: QueuesCmd,
},
/// KV inspection — list keys in a collection or fetch one value.
/// Read-only; writes go through `kv::set` in scripts.
Kv {
#[command(subcommand)]
cmd: KvCmd,
},
}
#[derive(Subcommand)]
enum KvCmd {
/// List keys in a collection.
Ls {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long, default_value_t = 100)]
limit: u32,
},
/// Fetch one key's value (printed as JSON).
Get {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
key: String,
},
}
#[derive(Subcommand)]
enum MembersCmd {
/// List app members.
Ls {
#[arg(long)]
app: String,
},
/// Grant a user a role on the app.
Add {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
/// `app_admin` | `editor` | `viewer`.
#[arg(long)]
role: String,
},
/// Change an existing member's role.
Set {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
#[arg(long)]
role: String,
},
/// Remove a member.
Rm {
#[arg(long)]
app: String,
#[arg(long = "user")]
user_id: String,
},
}
#[derive(Subcommand)]
enum FilesCmd {
/// List files in a collection.
Ls {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long, default_value_t = 100)]
limit: u32,
},
/// Download a file's bytes (to `--out <path>` or stdout).
Get {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long = "id")]
file_id: String,
#[arg(long)]
out: Option<PathBuf>,
},
/// Delete a file.
Rm {
#[arg(long)]
app: String,
#[arg(long)]
collection: String,
#[arg(long = "id")]
file_id: String,
},
}
#[derive(Subcommand)]
enum QueuesCmd {
/// List queues with depth counts.
Ls {
#[arg(long)]
app: String,
},
/// Show one queue's stats + consumer.
Show {
#[arg(long)]
app: String,
queue_name: String,
},
}
#[derive(Args)]
@@ -234,6 +366,82 @@ struct DeployArgs {
name: Option<String>,
#[arg(long)]
description: Option<String>,
/// Per-script wall-clock timeout in seconds (overrides the instance
/// default; still clamped by `PICLOUD_SANDBOX_MAX_*` ceilings).
#[arg(long)]
timeout: Option<i32>,
/// Per-script memory ceiling in MB.
#[arg(long)]
memory: Option<i32>,
/// Script kind: `endpoint` (default) or `module` (importable, no route).
#[arg(long, value_enum)]
kind: Option<ScriptKindArg>,
/// Sandbox override as `key=value`, repeatable. Keys: `max_operations`,
/// `max_string_size`, `max_array_size`, `max_map_size`,
/// `max_call_levels`, `max_expr_depth`. E.g. `--sandbox max_operations=500000`.
#[arg(long = "sandbox", value_name = "KEY=VALUE")]
sandbox: Vec<String>,
}
#[derive(Copy, Clone, clap::ValueEnum)]
enum ScriptKindArg {
Endpoint,
Module,
}
impl From<ScriptKindArg> for picloud_shared::ScriptKind {
fn from(v: ScriptKindArg) -> Self {
match v {
ScriptKindArg::Endpoint => Self::Endpoint,
ScriptKindArg::Module => Self::Module,
}
}
}
impl DeployArgs {
/// Fold the runtime-config flags into a `ScriptConfig`, parsing and
/// validating the `--sandbox key=value` pairs.
fn script_config(&self) -> anyhow::Result<client::ScriptConfig> {
let sandbox = parse_sandbox_overrides(&self.sandbox)?;
Ok(client::ScriptConfig {
timeout_seconds: self.timeout,
memory_limit_mb: self.memory,
kind: self.kind.map(Into::into),
sandbox,
})
}
}
/// Parse repeatable `--sandbox key=value` flags into a `ScriptSandbox`.
/// Unknown keys and non-integer values are hard errors so a typo doesn't
/// silently deploy an unrestricted script.
fn parse_sandbox_overrides(
pairs: &[String],
) -> anyhow::Result<Option<picloud_shared::ScriptSandbox>> {
use anyhow::{anyhow, Context};
if pairs.is_empty() {
return Ok(None);
}
let mut sb = picloud_shared::ScriptSandbox::default();
for raw in pairs {
let (key, val) = raw
.split_once('=')
.ok_or_else(|| anyhow!("--sandbox expects key=value, got {raw:?}"))?;
let n: u64 = val
.trim()
.parse()
.with_context(|| format!("--sandbox {key}: {val:?} is not a non-negative integer"))?;
match key.trim() {
"max_operations" => sb.max_operations = Some(n),
"max_string_size" => sb.max_string_size = Some(n),
"max_array_size" => sb.max_array_size = Some(n),
"max_map_size" => sb.max_map_size = Some(n),
"max_call_levels" => sb.max_call_levels = Some(n),
"max_expr_depth" => sb.max_expr_depth = Some(n),
other => return Err(anyhow!("unknown --sandbox key: {other:?}")),
}
}
Ok(Some(sb))
}
#[derive(Args)]
@@ -275,6 +483,11 @@ struct LogsArgs {
script_id: String,
#[arg(long, default_value_t = 50)]
limit: u32,
/// Filter by execution origin: `http`, `kv`, `cron`, `queue`,
/// `invoke`, `dead_letter`, `docs`, `files`, `pubsub`, `email`.
/// Omit (or `all`) to show every source.
#[arg(long)]
source: Option<String>,
}
#[derive(Clone, Copy, ValueEnum)]
@@ -498,10 +711,89 @@ enum TriggersCmd {
dispatch: DispatchModeArg,
},
/// Create a docs trigger — fires on document mutations in matching
/// collections.
#[command(name = "create-docs")]
CreateDocs {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Glob over collection names (`*`, `posts`, `events_*`, …).
#[arg(long)]
collection: String,
/// Repeat to filter ops: `--op insert --op delete`. Empty fires on any.
#[arg(long = "op")]
ops: Vec<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a files trigger — fires on blob create/update/delete in
/// matching collections.
#[command(name = "create-files")]
CreateFiles {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
#[arg(long)]
collection: String,
#[arg(long = "op")]
ops: Vec<String>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a pub/sub trigger — fires on messages published to topics
/// matching the pattern.
#[command(name = "create-pubsub")]
CreatePubsub {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Topic glob (`*`, `orders.*`, `user.signup`, …).
#[arg(long = "topic")]
topic_pattern: String,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create a queue consumer trigger — fires per message claimed off
/// the named queue.
#[command(name = "create-queue")]
CreateQueue {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
#[arg(long = "queue")]
queue_name: String,
/// Per-message visibility timeout in seconds (claim lease).
#[arg(long = "visibility-timeout")]
visibility_timeout_secs: Option<u32>,
#[arg(long, value_enum, default_value_t = DispatchModeArg::Async)]
dispatch: DispatchModeArg,
},
/// Create an email trigger — fires on inbound mail POSTed to the
/// webhook receiver. No dispatch mode (inbound webhook only).
#[command(name = "create-email")]
CreateEmail {
#[arg(long)]
app: String,
#[arg(long)]
script: String,
/// Shared HMAC secret the provider signs inbound POSTs with.
/// Omit to accept unsigned POSTs.
#[arg(long = "inbound-secret")]
inbound_secret: Option<String>,
},
/// Create a trigger of any kind from a raw JSON body — escape
/// hatch for kinds the CLI doesn't expose per-kind wrappers for
/// (docs/files/pubsub/email/queue) and for advanced retry/dispatch
/// settings beyond the per-kind defaults.
/// hatch for advanced retry/dispatch settings beyond the per-kind
/// wrappers' defaults.
#[command(name = "create-from-json")]
CreateFromJson {
#[arg(long)]
@@ -790,16 +1082,20 @@ async fn main() -> ExitCode {
} => cmds::scripts::ls(app.as_deref(), mode).await,
Cmd::Scripts {
cmd: ScriptsCmd::Deploy(args),
} => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
mode,
)
.await
}
} => match args.script_config() {
Ok(cfg) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
&cfg,
mode,
)
.await
}
Err(e) => Err(e),
},
Cmd::Scripts {
cmd: ScriptsCmd::Invoke(args),
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
@@ -821,20 +1117,28 @@ async fn main() -> ExitCode {
Cmd::ApiKeys {
cmd: ApiKeysCmd::Rm { id },
} => cmds::api_keys::rm(&id).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
Cmd::Logs(LogsArgs {
script_id,
limit,
source,
}) => cmds::logs::run(&script_id, limit, source.as_deref(), mode).await,
Cmd::Invoke(args) => {
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
}
Cmd::Deploy(args) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
mode,
)
.await
}
Cmd::Deploy(args) => match args.script_config() {
Ok(cfg) => {
cmds::scripts::deploy(
&args.file,
&args.app,
args.name.as_deref(),
args.description.as_deref(),
&cfg,
mode,
)
.await
}
Err(e) => Err(e),
},
Cmd::Routes {
cmd: RoutesCmd::Ls { script_id },
} => cmds::routes::ls(&script_id, mode).await,
@@ -1004,6 +1308,92 @@ async fn main() -> ExitCode {
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateDocs {
app,
script,
collection,
ops,
dispatch,
},
} => {
cmds::triggers::create_docs(
&app,
&script,
&collection,
&ops,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateFiles {
app,
script,
collection,
ops,
dispatch,
},
} => {
cmds::triggers::create_files(
&app,
&script,
&collection,
&ops,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreatePubsub {
app,
script,
topic_pattern,
dispatch,
},
} => {
cmds::triggers::create_pubsub(
&app,
&script,
&topic_pattern,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateQueue {
app,
script,
queue_name,
visibility_timeout_secs,
dispatch,
},
} => {
cmds::triggers::create_queue(
&app,
&script,
&queue_name,
visibility_timeout_secs,
dispatch_wire(dispatch),
mode,
)
.await
}
Cmd::Triggers {
cmd:
TriggersCmd::CreateEmail {
app,
script,
inbound_secret,
},
} => cmds::triggers::create_email(&app, &script, inbound_secret.as_deref(), mode).await,
Cmd::Triggers {
cmd: TriggersCmd::CreateFromJson { app, kind, body },
} => cmds::triggers::create_from_json(&app, &kind, &body, mode).await,
@@ -1074,6 +1464,65 @@ async fn main() -> ExitCode {
Cmd::Secrets {
cmd: SecretsCmd::Rm { app, name },
} => cmds::secrets::rm(&app, &name).await,
Cmd::Members {
cmd: MembersCmd::Ls { app },
} => cmds::members::ls(&app, mode).await,
Cmd::Members {
cmd: MembersCmd::Add { app, user_id, role },
} => cmds::members::add(&app, &user_id, &role, mode).await,
Cmd::Members {
cmd: MembersCmd::Set { app, user_id, role },
} => cmds::members::set(&app, &user_id, &role, mode).await,
Cmd::Members {
cmd: MembersCmd::Rm { app, user_id },
} => cmds::members::rm(&app, &user_id).await,
Cmd::Files {
cmd:
FilesCmd::Ls {
app,
collection,
limit,
},
} => cmds::files::ls(&app, &collection, limit, mode).await,
Cmd::Files {
cmd:
FilesCmd::Get {
app,
collection,
file_id,
out,
},
} => cmds::files::get(&app, &collection, &file_id, out.as_deref()).await,
Cmd::Files {
cmd:
FilesCmd::Rm {
app,
collection,
file_id,
},
} => cmds::files::rm(&app, &collection, &file_id).await,
Cmd::Queues {
cmd: QueuesCmd::Ls { app },
} => cmds::queues::ls(&app, mode).await,
Cmd::Queues {
cmd: QueuesCmd::Show { app, queue_name },
} => cmds::queues::show(&app, &queue_name, mode).await,
Cmd::Kv {
cmd:
KvCmd::Ls {
app,
collection,
limit,
},
} => cmds::kv::ls(&app, &collection, limit, mode).await,
Cmd::Kv {
cmd:
KvCmd::Get {
app,
collection,
key,
},
} => cmds::kv::get(&app, &collection, &key).await,
};
match result {