feat(cli): add pic routes + pic admins subcommands
Closes the audit's Critical "operator can't deploy a serverless endpoint
end-to-end from the CLI" finding. Two new subcommand families bring the
CLI coverage from 14 commands to ~25:
- pic routes {ls, create, rm, check, match}: full route CRUD plus the
dry-run conflict checker and the URL matcher already exposed by the
dashboard. The create form accepts --path-kind, --host-kind, and
--dispatch (sync|async) so async routes can finally be created
headlessly. The ls output adds a dispatch column.
- pic admins {ls, create, show, set, rm}: per-instance admin user
management. Create reads passwords from stdin via --password - so
shell history never sees the cleartext. Set is a JSON-Merge-Patch
shape that lets operators deactivate accounts or rotate roles
without touching the dashboard.
Six new ignored integration tests follow the established #[ignore]
pattern (DATABASE_URL gates them). They cover the happy-path round
trip, the async-dispatch persistence path, the password-required
error path, and the capability gate (a Member sees HTTP 403). All
pass against the local dev stack with PICLOUD_DEV_MODE=true +
PICLOUD_DEV_INSECURE_KEY=i-understand-this-is-insecure.
The `pic members` and `pic domains` subcommands the audit mentioned
are deferred — the apps_api shape may shift in v1.2 with per-app
roles and rebuilding the CLI surface twice would be churn.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,8 @@ use std::collections::BTreeMap;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::{
|
||||
AdminUserId, ApiKeyId, App, AppId, AppRole, ExecutionLog, InstanceRole, Scope, Script,
|
||||
AdminUserId, ApiKeyId, App, AppId, AppRole, DispatchMode, ExecutionLog, HostKind, InstanceRole,
|
||||
PathKind, Route, Scope, Script, ScriptId,
|
||||
};
|
||||
use reqwest::{header, Method, RequestBuilder, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -249,6 +250,113 @@ impl Client {
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/scripts/{id}/routes`
|
||||
pub async fn routes_list_for_script(&self, script_id: &str) -> Result<Vec<Route>> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/scripts/{script_id}/routes"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/scripts/{id}/routes`
|
||||
pub async fn routes_create(
|
||||
&self,
|
||||
script_id: &str,
|
||||
body: &CreateRouteBody<'_>,
|
||||
) -> Result<Route> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("/api/v1/admin/scripts/{script_id}/routes"),
|
||||
)
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/routes/{route_id}`
|
||||
pub async fn routes_delete(&self, route_id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/routes/{route_id}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/routes:check` — dry-run conflict check for a
|
||||
/// hypothetical route, scoped to one app.
|
||||
pub async fn routes_check(&self, body: &CheckRouteBody) -> Result<CheckRouteResponseDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/routes:check")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/routes:match` — what route would the given
|
||||
/// URL+method match, if any, for the given app.
|
||||
pub async fn routes_match(&self, body: &MatchRouteBody<'_>) -> Result<MatchRouteResponseDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/routes:match")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/admins`
|
||||
pub async fn admins_list(&self) -> Result<Vec<AdminDto>> {
|
||||
let resp = self
|
||||
.request(Method::GET, "/api/v1/admin/admins")
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_get(&self, id: &str) -> Result<AdminDto> {
|
||||
let resp = self
|
||||
.request(Method::GET, &format!("/api/v1/admin/admins/{id}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/admins`
|
||||
pub async fn admins_create(&self, body: &CreateAdminBody<'_>) -> Result<AdminDto> {
|
||||
let resp = self
|
||||
.request(Method::POST, "/api/v1/admin/admins")
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `PATCH /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_patch(&self, id: &str, body: &PatchAdminBody<'_>) -> Result<AdminDto> {
|
||||
let resp = self
|
||||
.request(Method::PATCH, &format!("/api/v1/admin/admins/{id}"))
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `DELETE /api/v1/admin/admins/{id}`
|
||||
pub async fn admins_delete(&self, id: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.request(Method::DELETE, &format!("/api/v1/admin/admins/{id}"))
|
||||
.send()
|
||||
.await?;
|
||||
decode_status(resp).await
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /api/v1/admin/auth/login` — sits outside the `Client` because
|
||||
@@ -304,6 +412,99 @@ pub struct CreateAppBody<'a> {
|
||||
pub description: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateRouteBody<'a> {
|
||||
pub host_kind: HostKind,
|
||||
#[serde(skip_serializing_if = "str::is_empty")]
|
||||
pub host: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub host_param_name: Option<&'a str>,
|
||||
pub path_kind: PathKind,
|
||||
pub path: &'a str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<&'a str>,
|
||||
pub dispatch_mode: DispatchMode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CheckRouteBody {
|
||||
pub app_id: AppId,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(skip_serializing_if = "String::is_empty")]
|
||||
pub host: String,
|
||||
pub path_kind: PathKind,
|
||||
pub path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckRouteResponseDto {
|
||||
pub ok: bool,
|
||||
#[serde(default)]
|
||||
pub conflicting_route: Option<Route>,
|
||||
#[serde(default)]
|
||||
pub conflict_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MatchRouteBody<'a> {
|
||||
pub app_id: AppId,
|
||||
pub url: &'a str,
|
||||
pub method: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchRouteResponseDto {
|
||||
#[serde(default)]
|
||||
pub matched: Option<MatchedRouteDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchedRouteDto {
|
||||
pub route_id: String,
|
||||
pub script_id: ScriptId,
|
||||
#[serde(default)]
|
||||
pub params: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub rest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateAdminBody<'a> {
|
||||
pub username: &'a str,
|
||||
pub password: &'a str,
|
||||
pub instance_role: InstanceRole,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub struct PatchAdminBody<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<&'a str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub is_active: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instance_role: Option<InstanceRole>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AdminDto {
|
||||
pub id: AdminUserId,
|
||||
pub username: String,
|
||||
pub is_active: bool,
|
||||
pub instance_role: InstanceRole,
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub last_login_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CreateScriptBody<'a> {
|
||||
pub app_id: AppId,
|
||||
|
||||
Reference in New Issue
Block a user