feat(manager-core,orchestrator-core): multi-app scoping (Phase 3b)
Apps become the isolation boundary for scripts, routes, domains, and
later data. Doing this now — while the surface is small — avoids
several migrations on populated tables once v1.1 data-plane services
ship.
Schema (migration 0005_apps.sql):
- New tables: apps, app_domains (with shape_key UNIQUE for collision
detection), app_slug_history (for permanent slug-rename redirects).
- app_id added to scripts, routes, execution_logs (non-null, cascading
rules per row).
- Script-name uniqueness becomes per-app; the route unique index is
swapped for an app-scoped version.
- The "default" app is seeded unconditionally with a localhost claim;
existing scripts/routes backfill into it. Fresh installs additionally
get the Hello World seed via seed_hello_world_if_fresh after
migrations run (idempotent — only fires when the default app has no
scripts).
Orchestrator dispatch is two-phase: AppDomainTable resolves Host →
app_id (most-specific match wins, exact beats wildcard), then the
existing route matcher runs against that app's partitioned slice via
RouteTable. Unknown hosts return 404 at the app layer with a clear
message; /api/v1/execute/{id} still works as the implicit
__internal__ claim, decoupled from any public domain.
Manager API: full CRUD for /api/v1/admin/apps/* and
/api/v1/admin/apps/{id_or_slug}/domains/*, with slug:check + force
takeover semantics implementing the rename-history flow (two-step
check → confirm, never a single endpoint). Script create requires
app_id; list accepts ?app= filter. Route create validates host
against the parent app's claims; conflict detection stays strictly
intra-app.
Dashboard: /admin/apps and /admin/apps/{slug} (overview + scripts +
domains + settings tabs, with slug-history-aware redirects). Root
path redirects to the apps list. Script detail page gains an app
breadcrumb and threads app_id into the route preview.
Deferred per design: per-app admin roles. The require_admin middleware
remains the seam where role checks will slot in later.
Blueprint §11.5 and roadmap updated to reflect what shipped; docs/
versioning.md notes the schema 3 → 5 bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
117
crates/manager-core/migrations/0005_apps.sql
Normal file
117
crates/manager-core/migrations/0005_apps.sql
Normal file
@@ -0,0 +1,117 @@
|
||||
-- Phase 3b multi-app scoping — see blueprint §11.5.
|
||||
--
|
||||
-- Apps are the top-level isolation boundary for scripts, routes, domain
|
||||
-- claims and (forward) data. The orchestrator dispatches Host → app_id →
|
||||
-- route trie; cross-app resource access is not possible.
|
||||
--
|
||||
-- This migration is unconditional:
|
||||
-- 1. Creates the three new tables (apps, app_domains, app_slug_history).
|
||||
-- 2. Always inserts a "default" app claiming `localhost` so existing
|
||||
-- installs get a usable home for their pre-existing scripts/routes.
|
||||
-- 3. Backfills app_id on scripts, routes, execution_logs from the
|
||||
-- default app row, then promotes the columns to NOT NULL + FK.
|
||||
--
|
||||
-- Fresh installs get the same "default" app row; an in-Rust bootstrap
|
||||
-- step (manager-core::app_bootstrap) decides whether to seed a Hello
|
||||
-- World script into it. Doing the seed in Rust keeps it testable and
|
||||
-- lets the script source live in a real .rhai file.
|
||||
|
||||
CREATE TABLE apps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
-- URL-safe identifier; mutable via the rename flow which records
|
||||
-- the prior slug in app_slug_history for permanent 301 redirects.
|
||||
-- Format validation (`^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||
-- check) lives in Rust handlers, not SQL.
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Domain claims. Most-specific wins at request time; same-shape
|
||||
-- collisions are rejected at claim time via the UNIQUE(shape_key).
|
||||
-- shape_key encoding:
|
||||
-- exact:<lowercased-host> for shape='exact'
|
||||
-- wildcard:<lowercased-suffix> for shape='wildcard' AND 'parameterized'
|
||||
-- (parameterized is the same shape as wildcard for collision — the
|
||||
-- parameter name is a binding, not a discriminator. See blueprint §11.5.)
|
||||
CREATE TABLE app_domains (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
pattern TEXT NOT NULL,
|
||||
shape TEXT NOT NULL CHECK (shape IN ('exact', 'wildcard', 'parameterized')),
|
||||
shape_key TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX app_domains_app_id_idx ON app_domains (app_id);
|
||||
|
||||
-- Permanent 301 redirects after a slug rename. A row dies only when
|
||||
-- another app explicitly claims the retired slug (with confirmation in
|
||||
-- the UI). On_delete cascade: if the owning app is deleted, its history
|
||||
-- row goes too (otherwise the redirect would point at a dead app).
|
||||
CREATE TABLE app_slug_history (
|
||||
slug TEXT PRIMARY KEY,
|
||||
current_app_id UUID NOT NULL REFERENCES apps(id) ON DELETE CASCADE,
|
||||
retired_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Seed the default app + a localhost claim. Used by both upgrade and
|
||||
-- fresh-install paths; the Rust bootstrap layers Hello World on top
|
||||
-- only when the install was fresh.
|
||||
WITH default_app AS (
|
||||
INSERT INTO apps (slug, name, description)
|
||||
VALUES ('default', 'Default', 'The default application — assigned to all pre-existing scripts and routes during the multi-app migration.')
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO app_domains (app_id, pattern, shape, shape_key)
|
||||
SELECT id, 'localhost', 'exact', 'exact:localhost' FROM default_app;
|
||||
|
||||
-- Add app_id to scripts. The default app already exists (above), so
|
||||
-- there is exactly one row to look up.
|
||||
ALTER TABLE scripts ADD COLUMN app_id UUID;
|
||||
UPDATE scripts SET app_id = (SELECT id FROM apps WHERE slug = 'default');
|
||||
ALTER TABLE scripts ALTER COLUMN app_id SET NOT NULL;
|
||||
ALTER TABLE scripts
|
||||
ADD CONSTRAINT scripts_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT;
|
||||
|
||||
-- Per-app name uniqueness. Two apps can each have a script called
|
||||
-- "echo"; previously they could not.
|
||||
DROP INDEX scripts_name_uidx;
|
||||
CREATE UNIQUE INDEX scripts_name_uidx ON scripts (app_id, LOWER(name));
|
||||
|
||||
CREATE INDEX scripts_app_id_idx ON scripts (app_id);
|
||||
|
||||
-- Add app_id to routes, mirroring the script's app.
|
||||
ALTER TABLE routes ADD COLUMN app_id UUID;
|
||||
UPDATE routes
|
||||
SET app_id = scripts.app_id
|
||||
FROM scripts
|
||||
WHERE routes.script_id = scripts.id;
|
||||
ALTER TABLE routes ALTER COLUMN app_id SET NOT NULL;
|
||||
ALTER TABLE routes
|
||||
ADD CONSTRAINT routes_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||
|
||||
-- Replace the route uniqueness index so two apps can claim identical
|
||||
-- (host_kind, host, path_kind, path, method) tuples — they live in
|
||||
-- separate route trees and never see each other.
|
||||
DROP INDEX routes_unique_binding_idx;
|
||||
CREATE UNIQUE INDEX routes_unique_binding_idx
|
||||
ON routes (app_id, host_kind, host, path_kind, path, COALESCE(method, ''));
|
||||
|
||||
CREATE INDEX routes_app_id_idx ON routes (app_id);
|
||||
|
||||
-- Add app_id to execution_logs. Materialized at write time so future
|
||||
-- script-moves (or eventual export/import) don't silently retag history.
|
||||
ALTER TABLE execution_logs ADD COLUMN app_id UUID;
|
||||
UPDATE execution_logs
|
||||
SET app_id = scripts.app_id
|
||||
FROM scripts
|
||||
WHERE execution_logs.script_id = scripts.id;
|
||||
ALTER TABLE execution_logs ALTER COLUMN app_id SET NOT NULL;
|
||||
ALTER TABLE execution_logs
|
||||
ADD CONSTRAINT execution_logs_app_id_fk FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX execution_logs_app_id_created_at_idx
|
||||
ON execution_logs (app_id, created_at DESC);
|
||||
15
crates/manager-core/seeds/hello.rhai
Normal file
15
crates/manager-core/seeds/hello.rhai
Normal file
@@ -0,0 +1,15 @@
|
||||
// Hello World — the reference example seeded into the default app on
|
||||
// fresh installs. Bound to GET /hello.
|
||||
|
||||
let who = ctx.request.body;
|
||||
let name = if who != () && type_of(who) == "map" && who.contains("name") {
|
||||
who.name
|
||||
} else {
|
||||
"world"
|
||||
};
|
||||
|
||||
return #{
|
||||
statusCode: 200,
|
||||
headers: #{ "Content-Type": "application/json" },
|
||||
body: #{ message: `Hello, ${name}!` }
|
||||
};
|
||||
@@ -5,17 +5,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
routing::get,
|
||||
Json, Router,
|
||||
};
|
||||
use picloud_shared::{
|
||||
ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
||||
AppId, ExecutionLog, Script, ScriptId, ScriptSandbox, ScriptValidator, ValidationError,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::repo::{
|
||||
ExecutionLogRepository, NewScript, ScriptPatch, ScriptRepository, ScriptRepositoryError,
|
||||
};
|
||||
@@ -27,6 +28,9 @@ use crate::sandbox::{CeilingError, SandboxCeiling};
|
||||
pub struct AdminState<R, L> {
|
||||
pub repo: Arc<R>,
|
||||
pub logs: Arc<L>,
|
||||
/// App lookups: validates `app_id` on create, resolves `?app=<slug>`
|
||||
/// filter on list. Trait-object so apps_repo can stay separate.
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub validator: Arc<dyn ScriptValidator>,
|
||||
pub sandbox_ceiling: SandboxCeiling,
|
||||
}
|
||||
@@ -36,6 +40,7 @@ impl<R, L> Clone for AdminState<R, L> {
|
||||
Self {
|
||||
repo: self.repo.clone(),
|
||||
logs: self.logs.clone(),
|
||||
apps: self.apps.clone(),
|
||||
validator: self.validator.clone(),
|
||||
sandbox_ceiling: self.sandbox_ceiling,
|
||||
}
|
||||
@@ -70,6 +75,9 @@ where
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateScriptRequest {
|
||||
/// Owning app. Required since Phase 3b — scripts cannot exist
|
||||
/// outside an app. Use `/api/v1/admin/apps` to list known ids.
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -82,6 +90,14 @@ pub struct CreateScriptRequest {
|
||||
pub sandbox: ScriptSandbox,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ListScriptsQuery {
|
||||
/// Optional filter: list scripts belonging to a single app, by id
|
||||
/// or slug. Absent = all scripts across all apps (admin-global view).
|
||||
#[serde(default)]
|
||||
pub app: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateScriptRequest {
|
||||
pub name: Option<String>,
|
||||
@@ -113,8 +129,32 @@ where
|
||||
|
||||
async fn list_scripts<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
State(state): State<AdminState<R, L>>,
|
||||
Query(q): Query<ListScriptsQuery>,
|
||||
) -> Result<Json<Vec<Script>>, ApiError> {
|
||||
Ok(Json(state.repo.list().await?))
|
||||
if let Some(ident) = q.app {
|
||||
let app = resolve_app_ident(state.apps.as_ref(), &ident).await?;
|
||||
Ok(Json(state.repo.list_for_app(app).await?))
|
||||
} else {
|
||||
Ok(Json(state.repo.list().await?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept `?app=<uuid>` OR `?app=<slug>`. Slugs route through history
|
||||
/// for redirects, but here we just need the live current id; if a
|
||||
/// retired slug is given, we follow it to the current app silently.
|
||||
async fn resolve_app_ident(apps: &dyn AppRepository, ident: &str) -> Result<AppId, ApiError> {
|
||||
if let Ok(uuid) = ident.parse::<uuid::Uuid>() {
|
||||
let id = AppId::from(uuid);
|
||||
apps.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||
return Ok(id);
|
||||
}
|
||||
let lookup = apps
|
||||
.get_by_slug_or_history(ident)
|
||||
.await?
|
||||
.ok_or(ApiError::AppNotFound(ident.to_string()))?;
|
||||
Ok(lookup.app.id)
|
||||
}
|
||||
|
||||
async fn get_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
@@ -135,9 +175,15 @@ async fn create_script<R: ScriptRepository, L: ExecutionLogRepository>(
|
||||
) -> Result<(StatusCode, Json<Script>), ApiError> {
|
||||
state.validator.validate(&input.source)?;
|
||||
state.sandbox_ceiling.check(&input.sandbox)?;
|
||||
// Refuse early if the app_id doesn't exist — a clean 422 beats a
|
||||
// raw FK violation surfacing as 500.
|
||||
if state.apps.get_by_id(input.app_id).await?.is_none() {
|
||||
return Err(ApiError::AppNotFound(input.app_id.to_string()));
|
||||
}
|
||||
let created = state
|
||||
.repo
|
||||
.create(NewScript {
|
||||
app_id: input.app_id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
@@ -223,6 +269,9 @@ pub enum ApiError {
|
||||
#[error("script not found: {0}")]
|
||||
NotFound(ScriptId),
|
||||
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
@@ -240,6 +289,7 @@ impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, message) = match &self {
|
||||
Self::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
|
||||
Self::AppNotFound(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
|
||||
Self::Conflict(_) => (StatusCode::CONFLICT, self.to_string()),
|
||||
Self::Invalid(_) | Self::Ceiling(_) => {
|
||||
(StatusCode::UNPROCESSABLE_ENTITY, self.to_string())
|
||||
|
||||
92
crates/manager-core/src/app_bootstrap.rs
Normal file
92
crates/manager-core/src/app_bootstrap.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Hello-World seed for fresh installs.
|
||||
//!
|
||||
//! Idempotent. Runs after migrations and after admin bootstrap. Only
|
||||
//! seeds when the default app is empty (no scripts, no routes); on
|
||||
//! upgrades it does nothing so existing content isn't polluted.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use picloud_shared::{App, AppId, HostKind, PathKind};
|
||||
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::repo::{NewScript, ScriptRepository, ScriptRepositoryError};
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
const HELLO_RHAI_SOURCE: &str = include_str!("../seeds/hello.rhai");
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HelloWorldOutcome {
|
||||
/// Default app already has scripts (or doesn't exist) — left alone.
|
||||
SkippedExisting,
|
||||
/// Inserted the hello.rhai script and the `/hello` route.
|
||||
Seeded,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SeedError {
|
||||
#[error("default app not found — did the migration run?")]
|
||||
MissingDefaultApp,
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
pub async fn seed_hello_world_if_fresh(
|
||||
apps: Arc<dyn AppRepository>,
|
||||
scripts: Arc<dyn ScriptRepository>,
|
||||
routes: Arc<dyn RouteRepository>,
|
||||
) -> Result<HelloWorldOutcome, SeedError> {
|
||||
let default = apps
|
||||
.get_by_slug("default")
|
||||
.await?
|
||||
.ok_or(SeedError::MissingDefaultApp)?;
|
||||
|
||||
// Idempotence: only seed when both scripts AND routes are empty.
|
||||
// (Either alone is suspicious enough to skip — the operator may have
|
||||
// already started shaping the default app.)
|
||||
let existing_scripts = scripts.list_for_app(default.id).await?;
|
||||
let existing_routes = routes.list_for_app(default.id).await?;
|
||||
if !existing_scripts.is_empty() || !existing_routes.is_empty() {
|
||||
return Ok(HelloWorldOutcome::SkippedExisting);
|
||||
}
|
||||
|
||||
seed_into(&*scripts, &*routes, &default).await?;
|
||||
Ok(HelloWorldOutcome::Seeded)
|
||||
}
|
||||
|
||||
async fn seed_into(
|
||||
scripts: &dyn ScriptRepository,
|
||||
routes: &dyn RouteRepository,
|
||||
default: &App,
|
||||
) -> Result<(), ScriptRepositoryError> {
|
||||
let script = scripts
|
||||
.create(NewScript {
|
||||
app_id: default.id,
|
||||
name: "hello".to_string(),
|
||||
description: Some("Reference example: returns a greeting at GET /hello.".to_string()),
|
||||
source: HELLO_RHAI_SOURCE.to_string(),
|
||||
timeout_seconds: Some(5),
|
||||
memory_limit_mb: None,
|
||||
sandbox: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
routes
|
||||
.create(NewRoute {
|
||||
app_id: default.id,
|
||||
script_id: script.id,
|
||||
host_kind: HostKind::Any,
|
||||
host: String::new(),
|
||||
host_param_name: None,
|
||||
path_kind: PathKind::Exact,
|
||||
path: "/hello".to_string(),
|
||||
// Accept any method so both `curl /hello` and
|
||||
// `curl -d '{"name":"X"}' /hello` work out of the box.
|
||||
method: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _typecheck(_id: AppId) {} // suppress unused-import warnings if reshuffled
|
||||
152
crates/manager-core/src/app_domain_repo.rs
Normal file
152
crates/manager-core/src/app_domain_repo.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
//! CRUD over the `app_domains` table.
|
||||
//!
|
||||
//! Parsing + shape_key derivation live in `orchestrator-core`'s
|
||||
//! `routing::pattern::parse_app_domain` — this repo just stores what
|
||||
//! the API handler hands it. Same-shape collisions surface as a unique
|
||||
//! constraint violation on `shape_key`, mapped here to a clean error.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{AppDomain, AppId, DomainShape};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAppDomain {
|
||||
pub app_id: AppId,
|
||||
pub pattern: String,
|
||||
pub shape: DomainShape,
|
||||
pub shape_key: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AppDomainRepository: Send + Sync {
|
||||
/// All domain claims across all apps — used by the orchestrator's
|
||||
/// `AppDomainTable` to build its lookup cache at startup and after
|
||||
/// every write.
|
||||
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError>;
|
||||
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError>;
|
||||
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAppDomainRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresAppDomainRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppDomainRepository for PostgresAppDomainRepository {
|
||||
async fn list_all(&self) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, DomainRow>(
|
||||
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||
FROM app_domains ORDER BY pattern",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<AppDomain>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, DomainRow>(
|
||||
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||
FROM app_domains WHERE app_id = $1 ORDER BY pattern",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get(&self, domain_id: Uuid) -> Result<Option<AppDomain>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, DomainRow>(
|
||||
"SELECT id, app_id, pattern, shape, shape_key, created_at \
|
||||
FROM app_domains WHERE id = $1",
|
||||
)
|
||||
.bind(domain_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewAppDomain) -> Result<AppDomain, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, DomainRow>(
|
||||
"INSERT INTO app_domains (app_id, pattern, shape, shape_key) \
|
||||
VALUES ($1, $2, $3, $4) \
|
||||
RETURNING id, app_id, pattern, shape, shape_key, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.pattern)
|
||||
.bind(shape_str(input.shape))
|
||||
.bind(&input.shape_key)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(format!(
|
||||
"domain {:?} (or another claim of the same shape) is already claimed",
|
||||
input.pattern
|
||||
)))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, domain_id: Uuid) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM app_domains WHERE id = $1")
|
||||
.bind(domain_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if res.rows_affected() == 0 {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"domain {domain_id} not found"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const fn shape_str(s: DomainShape) -> &'static str {
|
||||
match s {
|
||||
DomainShape::Exact => "exact",
|
||||
DomainShape::Wildcard => "wildcard",
|
||||
DomainShape::Parameterized => "parameterized",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct DomainRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
pattern: String,
|
||||
shape: String,
|
||||
shape_key: String,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<DomainRow> for AppDomain {
|
||||
fn from(r: DomainRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
pattern: r.pattern,
|
||||
shape: match r.shape.as_str() {
|
||||
"wildcard" => DomainShape::Wildcard,
|
||||
"parameterized" => DomainShape::Parameterized,
|
||||
_ => DomainShape::Exact,
|
||||
},
|
||||
shape_key: r.shape_key,
|
||||
created_at: r.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
380
crates/manager-core/src/app_repo.rs
Normal file
380
crates/manager-core/src/app_repo.rs
Normal file
@@ -0,0 +1,380 @@
|
||||
//! CRUD over the `apps` and `app_slug_history` tables.
|
||||
//!
|
||||
//! Slug validation (regex, reserved-word check) lives in the API
|
||||
//! handler; this repo enforces only what Postgres enforces (uniqueness,
|
||||
//! FK). The slug-rename flow is exposed as a single `rename_slug` call
|
||||
//! that writes the history row in the same transaction.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{App, AppId};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
|
||||
/// Result of looking up an app by slug or via the redirect history.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AppLookup {
|
||||
pub app: App,
|
||||
/// `true` when the slug was found in `app_slug_history` rather than
|
||||
/// directly on `apps`. Dashboards should issue a redirect.
|
||||
pub redirected: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AppRepository: Send + Sync {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError>;
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<AppLookup>, ScriptRepositoryError>;
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError>;
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Create that also consumes a matching `app_slug_history` row, if
|
||||
/// any. Used after the operator has confirmed they want to break old
|
||||
/// redirects.
|
||||
async fn create_with_takeover(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
id: AppId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
/// Rename and record the old slug in `app_slug_history` (so
|
||||
/// retired URLs keep redirecting). If `take_over_history` is true,
|
||||
/// any existing history row for `new_slug` is consumed.
|
||||
async fn rename_slug(
|
||||
&self,
|
||||
id: AppId,
|
||||
new_slug: &str,
|
||||
take_over_history: bool,
|
||||
) -> Result<App, ScriptRepositoryError>;
|
||||
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError>;
|
||||
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresAppRepository {
|
||||
pool: PgPool,
|
||||
}
|
||||
|
||||
impl PostgresAppRepository {
|
||||
#[must_use]
|
||||
pub fn new(pool: PgPool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AppRepository for PostgresAppRepository {
|
||||
async fn list(&self) -> Result<Vec<App>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps ORDER BY name",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn get_by_id(&self, id: AppId) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_slug(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps WHERE slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn get_by_slug_or_history(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<AppLookup>, ScriptRepositoryError> {
|
||||
if let Some(app) = self.get_by_slug(slug).await? {
|
||||
return Ok(Some(AppLookup {
|
||||
app,
|
||||
redirected: false,
|
||||
}));
|
||||
}
|
||||
if let Some(app) = self.slug_in_history(slug).await? {
|
||||
return Ok(Some(AppLookup {
|
||||
app,
|
||||
redirected: true,
|
||||
}));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn slug_in_history(&self, slug: &str) -> Result<Option<App>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT a.id, a.slug, a.name, a.description, a.created_at, a.updated_at \
|
||||
FROM app_slug_history h \
|
||||
JOIN apps a ON a.id = h.current_app_id \
|
||||
WHERE h.slug = $1",
|
||||
)
|
||||
.bind(slug)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(Into::into))
|
||||
}
|
||||
|
||||
async fn create(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.fetch_one(&self.pool)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
|
||||
ScriptRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
|
||||
),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_with_takeover(
|
||||
&self,
|
||||
slug: &str,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"INSERT INTO apps (slug, name, description) \
|
||||
VALUES ($1, $2, $3) \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(slug)
|
||||
.bind(name)
|
||||
.bind(description)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
Ok(r) => r,
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {slug:?} is already in use"
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn update(
|
||||
&self,
|
||||
id: AppId,
|
||||
name: Option<&str>,
|
||||
description: Option<Option<&str>>,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(name)
|
||||
.bind(description.is_some())
|
||||
.bind(description.and_then(|d| d))
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
row.map(Into::into)
|
||||
.ok_or_else(|| ScriptRepositoryError::Conflict(format!("app {id} not found")))
|
||||
}
|
||||
|
||||
async fn rename_slug(
|
||||
&self,
|
||||
id: AppId,
|
||||
new_slug: &str,
|
||||
take_over_history: bool,
|
||||
) -> Result<App, ScriptRepositoryError> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// 1. Read the current slug (so we can record it in history).
|
||||
let current: Option<(String,)> = sqlx::query_as("SELECT slug FROM apps WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
let Some((current_slug,)) = current else {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"app {id} not found"
|
||||
)));
|
||||
};
|
||||
|
||||
if current_slug == new_slug {
|
||||
// No-op rename; just return the row.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"SELECT id, slug, name, description, created_at, updated_at \
|
||||
FROM apps WHERE id = $1",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
return Ok(row.into());
|
||||
}
|
||||
|
||||
// 2. If renaming back to this app's own retired slug, just
|
||||
// consume the history row silently (no warning, no takeover
|
||||
// flag required).
|
||||
let owns_history: Option<(uuid::Uuid,)> =
|
||||
sqlx::query_as("SELECT current_app_id FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
match owns_history {
|
||||
Some((owner,)) if owner == id.into_inner() => {
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Some(_) if take_over_history => {
|
||||
sqlx::query("DELETE FROM app_slug_history WHERE slug = $1")
|
||||
.bind(new_slug)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
Some(_) => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {new_slug:?} is in history; rename with takeover to claim it"
|
||||
)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
// 3. Record the current slug in history (replacing any older
|
||||
// entry — the same slug can pass through history multiple
|
||||
// times across many renames).
|
||||
sqlx::query(
|
||||
"INSERT INTO app_slug_history (slug, current_app_id) \
|
||||
VALUES ($1, $2) \
|
||||
ON CONFLICT (slug) DO UPDATE SET current_app_id = EXCLUDED.current_app_id, \
|
||||
retired_at = NOW()",
|
||||
)
|
||||
.bind(¤t_slug)
|
||||
.bind(id.into_inner())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 4. Apply the rename. Unique violation = another live app
|
||||
// already holds this slug.
|
||||
let row = sqlx::query_as::<_, AppRow>(
|
||||
"UPDATE apps SET slug = $2, updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, slug, name, description, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
.bind(new_slug)
|
||||
.fetch_one(&mut *tx)
|
||||
.await;
|
||||
let row = match row {
|
||||
Ok(r) => r,
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
return Err(ScriptRepositoryError::Conflict(format!(
|
||||
"slug {new_slug:?} is already in use by another app"
|
||||
)));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(row.into())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: AppId) -> Result<(), ScriptRepositoryError> {
|
||||
let res = sqlx::query("DELETE FROM apps WHERE id = $1")
|
||||
.bind(id.into_inner())
|
||||
.execute(&self.pool)
|
||||
.await;
|
||||
match res {
|
||||
Ok(r) if r.rows_affected() == 0 => Err(ScriptRepositoryError::Conflict(format!(
|
||||
"app {id} not found"
|
||||
))),
|
||||
Ok(_) => Ok(()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
|
||||
// ON DELETE RESTRICT on scripts.app_id — surface a clean
|
||||
// "has dependents" error rather than a raw SQL message.
|
||||
Err(ScriptRepositoryError::Conflict(
|
||||
"app still contains scripts; delete or move them first".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn count_scripts_in_app(&self, id: AppId) -> Result<i64, ScriptRepositoryError> {
|
||||
let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM scripts WHERE app_id = $1")
|
||||
.bind(id.into_inner())
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct AppRow {
|
||||
id: uuid::Uuid,
|
||||
slug: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
created_at: chrono::DateTime<chrono::Utc>,
|
||||
updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
impl From<AppRow> for App {
|
||||
fn from(r: AppRow) -> Self {
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
510
crates/manager-core/src/apps_api.rs
Normal file
510
crates/manager-core/src/apps_api.rs
Normal file
@@ -0,0 +1,510 @@
|
||||
//! `/api/v1/admin/apps/*` — app + domain claim CRUD.
|
||||
//!
|
||||
//! All endpoints are guarded by `require_admin`. Per-app permissions
|
||||
//! are deferred (every authenticated admin can act on every app); the
|
||||
//! middleware seam exists for when that lands.
|
||||
//!
|
||||
//! Slug validation: regex `^[a-z0-9][a-z0-9-]{0,62}$`, reserved-word
|
||||
//! list rejected. Slug renames record the old slug in
|
||||
//! `app_slug_history` for permanent 301 redirects; reclaiming a
|
||||
//! historical slug requires `"force_takeover": true` in the request.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{delete, get, post};
|
||||
use axum::Router;
|
||||
use picloud_orchestrator_core::routing::{pattern, AppDomainTable, CompiledAppDomain};
|
||||
use picloud_shared::{App, AppDomain, AppId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_domain_repo::{AppDomainRepository, NewAppDomain};
|
||||
use crate::app_repo::AppRepository;
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::route_repo::RouteRepository;
|
||||
|
||||
const SLUG_MIN: usize = 1;
|
||||
const SLUG_MAX: usize = 63;
|
||||
const RESERVED_SLUGS: &[&str] = &[
|
||||
"new", "api", "admin", "admins", "healthz", "version", "login", "logout", "apps",
|
||||
];
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppsState {
|
||||
pub apps: Arc<dyn AppRepository>,
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub routes: Arc<dyn RouteRepository>,
|
||||
/// Cached host → app_id lookup; replaced after every domain CRUD
|
||||
/// operation so the orchestrator sees changes immediately.
|
||||
pub domain_table: Arc<AppDomainTable>,
|
||||
}
|
||||
|
||||
pub fn apps_router(state: AppsState) -> Router {
|
||||
Router::new()
|
||||
.route("/apps", get(list_apps).post(create_app))
|
||||
.route(
|
||||
"/apps/{id_or_slug}",
|
||||
get(get_app).patch(patch_app).delete(delete_app),
|
||||
)
|
||||
.route("/apps/{id_or_slug}/slug:check", post(slug_check))
|
||||
.route(
|
||||
"/apps/{id_or_slug}/domains",
|
||||
get(list_domains).post(create_domain),
|
||||
)
|
||||
.route(
|
||||
"/apps/{id_or_slug}/domains/{domain_id}",
|
||||
delete(delete_domain),
|
||||
)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// DTOs
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppDto {
|
||||
#[serde(flatten)]
|
||||
pub app: App,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateAppRequest {
|
||||
pub slug: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
/// Set to `true` to consume an existing `app_slug_history` row for
|
||||
/// the requested slug (breaking old redirects).
|
||||
#[serde(default)]
|
||||
pub force_takeover: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PatchAppRequest {
|
||||
pub name: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_optional")]
|
||||
#[allow(clippy::option_option)]
|
||||
pub description: Option<Option<String>>,
|
||||
pub slug: Option<String>,
|
||||
#[serde(default)]
|
||||
pub force_takeover: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::option_option)]
|
||||
fn deserialize_optional_optional<'de, D>(d: D) -> Result<Option<Option<String>>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(d).map(Some)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct SlugCheckRequest {
|
||||
pub new_slug: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SlugCheckResponse {
|
||||
pub ok: bool,
|
||||
pub conflict_kind: Option<&'static str>,
|
||||
pub current_app: Option<App>,
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateDomainRequest {
|
||||
pub pattern: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AppLookupResponse {
|
||||
#[serde(flatten)]
|
||||
pub app: App,
|
||||
/// When the operator hits the API with a retired slug, this points
|
||||
/// at the live slug so dashboards can redirect.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub redirect_to: Option<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_apps(State(s): State<AppsState>) -> Result<Json<Vec<App>>, AppsApiError> {
|
||||
Ok(Json(s.apps.list().await?))
|
||||
}
|
||||
|
||||
async fn create_app(
|
||||
State(s): State<AppsState>,
|
||||
Json(input): Json<CreateAppRequest>,
|
||||
) -> Result<(StatusCode, Json<App>), AppsApiError> {
|
||||
validate_slug(&input.slug)?;
|
||||
|
||||
// Historical-slug check before insert: if the slug is in history
|
||||
// and the caller hasn't asked to force takeover, surface a clean
|
||||
// 409 so the dashboard can present a "this will break old links"
|
||||
// confirmation.
|
||||
if !input.force_takeover {
|
||||
if let Some(current) = s.apps.slug_in_history(&input.slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
}
|
||||
}
|
||||
|
||||
let created = if input.force_takeover {
|
||||
s.apps
|
||||
.create_with_takeover(&input.slug, &input.name, input.description.as_deref())
|
||||
.await?
|
||||
} else {
|
||||
s.apps
|
||||
.create(&input.slug, &input.name, input.description.as_deref())
|
||||
.await?
|
||||
};
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn get_app(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<AppLookupResponse>, AppsApiError> {
|
||||
let lookup = resolve_app(&*s.apps, &id_or_slug).await?;
|
||||
let redirect_to = if lookup.redirected {
|
||||
Some(lookup.app.slug.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Json(AppLookupResponse {
|
||||
app: lookup.app,
|
||||
redirect_to,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn patch_app(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<PatchAppRequest>,
|
||||
) -> Result<Json<App>, AppsApiError> {
|
||||
let current = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
|
||||
// Edits to name/description go first (separate from rename so we
|
||||
// don't conflate the two errors).
|
||||
let after_meta = if input.name.is_some() || input.description.is_some() {
|
||||
s.apps
|
||||
.update(
|
||||
current.id,
|
||||
input.name.as_deref(),
|
||||
input.description.as_ref().map(|d| d.as_deref()),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
current
|
||||
};
|
||||
|
||||
// Slug rename is a separate operation; the rename method does its
|
||||
// own history bookkeeping in a transaction.
|
||||
let after_rename = if let Some(new_slug) = input.slug.as_deref() {
|
||||
validate_slug(new_slug)?;
|
||||
match s
|
||||
.apps
|
||||
.rename_slug(after_meta.id, new_slug, input.force_takeover)
|
||||
.await
|
||||
{
|
||||
Ok(app) => app,
|
||||
Err(ScriptRepositoryError::Conflict(msg)) if msg.contains("history") => {
|
||||
if let Some(current) = s.apps.slug_in_history(new_slug).await? {
|
||||
return Err(AppsApiError::SlugInHistory(current));
|
||||
}
|
||||
return Err(AppsApiError::Conflict(msg));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
} else {
|
||||
after_meta
|
||||
};
|
||||
|
||||
Ok(Json(after_rename))
|
||||
}
|
||||
|
||||
async fn delete_app(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<StatusCode, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
|
||||
// Soft pre-check for a clean error; the DB FK is the real guard
|
||||
// (ON DELETE RESTRICT on scripts.app_id).
|
||||
let n_scripts = s.apps.count_scripts_in_app(app.id).await?;
|
||||
if n_scripts > 0 {
|
||||
return Err(AppsApiError::HasScripts(n_scripts));
|
||||
}
|
||||
|
||||
s.apps.delete(app.id).await?;
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn slug_check(
|
||||
State(s): State<AppsState>,
|
||||
Path(_id_or_slug): Path<String>,
|
||||
Json(input): Json<SlugCheckRequest>,
|
||||
) -> Result<Json<SlugCheckResponse>, AppsApiError> {
|
||||
match validate_slug(&input.new_slug) {
|
||||
Err(AppsApiError::InvalidSlug(reason)) => {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("invalid"),
|
||||
current_app: None,
|
||||
reason: Some(reason),
|
||||
}));
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
Ok(()) => {}
|
||||
}
|
||||
if let Some(app) = s.apps.get_by_slug(&input.new_slug).await? {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("current"),
|
||||
current_app: Some(app),
|
||||
reason: Some("another app currently uses this slug".into()),
|
||||
}));
|
||||
}
|
||||
if let Some(app) = s.apps.slug_in_history(&input.new_slug).await? {
|
||||
return Ok(Json(SlugCheckResponse {
|
||||
ok: false,
|
||||
conflict_kind: Some("historical"),
|
||||
current_app: Some(app),
|
||||
reason: Some("slug is a retired redirect; using it will break old links".into()),
|
||||
}));
|
||||
}
|
||||
Ok(Json(SlugCheckResponse {
|
||||
ok: true,
|
||||
conflict_kind: None,
|
||||
current_app: None,
|
||||
reason: None,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn list_domains(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
) -> Result<Json<Vec<AppDomain>>, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
Ok(Json(s.domains.list_for_app(app.id).await?))
|
||||
}
|
||||
|
||||
async fn create_domain(
|
||||
State(s): State<AppsState>,
|
||||
Path(id_or_slug): Path<String>,
|
||||
Json(input): Json<CreateDomainRequest>,
|
||||
) -> Result<(StatusCode, Json<AppDomain>), AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
let parsed = pattern::parse_app_domain(&input.pattern)?;
|
||||
let created = s
|
||||
.domains
|
||||
.create(NewAppDomain {
|
||||
app_id: app.id,
|
||||
pattern: input.pattern,
|
||||
shape: parsed.shape,
|
||||
shape_key: parsed.shape_key,
|
||||
})
|
||||
.await?;
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_domain(
|
||||
State(s): State<AppsState>,
|
||||
Path((id_or_slug, domain_id)): Path<(String, Uuid)>,
|
||||
) -> Result<StatusCode, AppsApiError> {
|
||||
let app = resolve_app(&*s.apps, &id_or_slug).await?.app;
|
||||
let Some(domain) = s.domains.get(domain_id).await? else {
|
||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||
};
|
||||
if domain.app_id != app.id {
|
||||
return Err(AppsApiError::DomainNotFound(domain_id));
|
||||
}
|
||||
|
||||
// Guard: routes inside this app may reference this exact host
|
||||
// pattern. The host-kind on the route is `strict` or `wildcard`
|
||||
// (Any routes don't pin a specific host). We block deletion in
|
||||
// either case and let the operator clean up first.
|
||||
let strict = s
|
||||
.routes
|
||||
.count_for_app_host(app.id, picloud_shared::HostKind::Strict, &domain.pattern)
|
||||
.await?;
|
||||
let wild_suffix = domain
|
||||
.pattern
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let wild = if wild_suffix.is_empty() {
|
||||
0
|
||||
} else {
|
||||
s.routes
|
||||
.count_for_app_host(app.id, picloud_shared::HostKind::Wildcard, &wild_suffix)
|
||||
.await?
|
||||
};
|
||||
if strict + wild > 0 {
|
||||
return Err(AppsApiError::DomainHasRoutes(strict + wild));
|
||||
}
|
||||
|
||||
s.domains.delete(domain_id).await?;
|
||||
refresh_domain_cache(&s).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn resolve_app(
|
||||
apps: &dyn AppRepository,
|
||||
ident: &str,
|
||||
) -> Result<crate::app_repo::AppLookup, AppsApiError> {
|
||||
if let Ok(uuid) = ident.parse::<Uuid>() {
|
||||
if let Some(app) = apps.get_by_id(AppId::from(uuid)).await? {
|
||||
return Ok(crate::app_repo::AppLookup {
|
||||
app,
|
||||
redirected: false,
|
||||
});
|
||||
}
|
||||
return Err(AppsApiError::AppNotFound(ident.to_string()));
|
||||
}
|
||||
apps.get_by_slug_or_history(ident)
|
||||
.await?
|
||||
.ok_or_else(|| AppsApiError::AppNotFound(ident.to_string()))
|
||||
}
|
||||
|
||||
fn validate_slug(slug: &str) -> Result<(), AppsApiError> {
|
||||
if slug.len() < SLUG_MIN || slug.len() > SLUG_MAX {
|
||||
return Err(AppsApiError::InvalidSlug(format!(
|
||||
"slug length must be between {SLUG_MIN} and {SLUG_MAX}"
|
||||
)));
|
||||
}
|
||||
if !slug
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_ascii_alphanumeric())
|
||||
{
|
||||
return Err(AppsApiError::InvalidSlug(
|
||||
"slug must start with [a-z0-9]".into(),
|
||||
));
|
||||
}
|
||||
for c in slug.chars() {
|
||||
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') {
|
||||
return Err(AppsApiError::InvalidSlug(
|
||||
"slug may only contain lowercase letters, digits, and '-'".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if RESERVED_SLUGS.contains(&slug) {
|
||||
return Err(AppsApiError::InvalidSlug(format!(
|
||||
"slug {slug:?} is reserved for system use"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rebuild the in-memory host → app_id cache used by the orchestrator.
|
||||
/// Called after every domain CRUD operation.
|
||||
pub async fn refresh_domain_cache(state: &AppsState) -> Result<(), AppsApiError> {
|
||||
let all = state.domains.list_all().await?;
|
||||
let compiled = all
|
||||
.into_iter()
|
||||
.filter_map(|d| {
|
||||
// Parse the stored pattern; skip on parse error rather than
|
||||
// poisoning the entire cache. The handlers reject bad input,
|
||||
// so this is purely defensive against a future migration
|
||||
// that loosens the constraints.
|
||||
pattern::parse_app_domain(&d.pattern)
|
||||
.ok()
|
||||
.map(|p| CompiledAppDomain {
|
||||
app_id: d.app_id,
|
||||
pattern: p.pattern,
|
||||
shape_key: p.shape_key,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
state.domain_table.replace(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppsApiError {
|
||||
#[error("app not found: {0}")]
|
||||
AppNotFound(String),
|
||||
|
||||
#[error("domain not found: {0}")]
|
||||
DomainNotFound(Uuid),
|
||||
|
||||
#[error("invalid slug: {0}")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("slug {0:?} is in history; will break old redirects — pass force_takeover")]
|
||||
SlugInHistory(App),
|
||||
|
||||
#[error("app still contains {0} script(s); delete or move them first")]
|
||||
HasScripts(i64),
|
||||
|
||||
#[error("domain has {0} route(s) bound to it; delete the routes first")]
|
||||
DomainHasRoutes(i64),
|
||||
|
||||
#[error("invalid pattern: {0}")]
|
||||
Pattern(#[from] pattern::ParseError),
|
||||
|
||||
#[error("conflict: {0}")]
|
||||
Conflict(String),
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
|
||||
impl IntoResponse for AppsApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let (status, body) = match &self {
|
||||
Self::AppNotFound(_)
|
||||
| Self::DomainNotFound(_)
|
||||
| Self::Repo(ScriptRepositoryError::NotFound(_)) => {
|
||||
(StatusCode::NOT_FOUND, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::InvalidSlug(_) | Self::Pattern(_) => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::SlugInHistory(current) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({
|
||||
"error": self.to_string(),
|
||||
"conflict_kind": "historical",
|
||||
"current_app": current,
|
||||
}),
|
||||
),
|
||||
Self::HasScripts(n) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({ "error": self.to_string(), "script_count": n }),
|
||||
),
|
||||
Self::DomainHasRoutes(n) => (
|
||||
StatusCode::CONFLICT,
|
||||
json!({ "error": self.to_string(), "route_count": n }),
|
||||
),
|
||||
Self::Conflict(_) | Self::Repo(ScriptRepositoryError::Conflict(_)) => {
|
||||
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
|
||||
}
|
||||
Self::Repo(ScriptRepositoryError::Db(e)) => {
|
||||
tracing::error!(error = %e, "apps api db error");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
json!({ "error": "internal error" }),
|
||||
)
|
||||
}
|
||||
};
|
||||
(status, Json(body)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,10 @@ pub mod admin_session_repo;
|
||||
pub mod admin_user_repo;
|
||||
pub mod admin_users_api;
|
||||
pub mod api;
|
||||
pub mod app_bootstrap;
|
||||
pub mod app_domain_repo;
|
||||
pub mod app_repo;
|
||||
pub mod apps_api;
|
||||
pub mod auth;
|
||||
pub mod auth_api;
|
||||
pub mod auth_bootstrap;
|
||||
@@ -30,6 +34,10 @@ pub use admin_user_repo::{
|
||||
};
|
||||
pub use admin_users_api::{admins_router, AdminsState};
|
||||
pub use api::{admin_router, AdminState};
|
||||
pub use app_bootstrap::{seed_hello_world_if_fresh, HelloWorldOutcome};
|
||||
pub use app_domain_repo::{AppDomainRepository, NewAppDomain, PostgresAppDomainRepository};
|
||||
pub use app_repo::{AppLookup, AppRepository, PostgresAppRepository};
|
||||
pub use apps_api::{apps_router, AppsState};
|
||||
pub use auth_api::auth_router;
|
||||
pub use auth_bootstrap::{
|
||||
bootstrap_first_admin, bootstrap_first_admin_with, BootstrapEnv, BootstrapError,
|
||||
|
||||
@@ -28,15 +28,16 @@ impl ExecutionLogSink for PostgresExecutionLogSink {
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO execution_logs ( \
|
||||
id, script_id, request_id, \
|
||||
id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
) VALUES ( \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 \
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13 \
|
||||
)",
|
||||
)
|
||||
.bind(log.id)
|
||||
.bind(log.app_id.into_inner())
|
||||
.bind(log.script_id.into_inner())
|
||||
.bind(log.request_id.into_inner())
|
||||
.bind(&log.request_path)
|
||||
|
||||
@@ -2,7 +2,9 @@ use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_orchestrator_core::{ResolverError, ScriptResolver};
|
||||
use picloud_shared::{ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox};
|
||||
use picloud_shared::{
|
||||
AppId, ExecutionLog, ExecutionStatus, RequestId, Script, ScriptId, ScriptSandbox,
|
||||
};
|
||||
use sqlx::PgPool;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -21,7 +23,10 @@ pub enum ScriptRepositoryError {
|
||||
#[async_trait]
|
||||
pub trait ScriptRepository: Send + Sync {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError>;
|
||||
/// Every script across all apps. Mostly for tests and admin
|
||||
/// "global" views; the dashboard reaches scripts via `list_for_app`.
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError>;
|
||||
async fn update(
|
||||
&self,
|
||||
@@ -35,6 +40,7 @@ pub trait ScriptRepository: Send + Sync {
|
||||
/// constraints; the repo enforces them in the DB regardless.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewScript {
|
||||
pub app_id: AppId,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub source: String,
|
||||
@@ -78,7 +84,7 @@ impl PostgresScriptRepository {
|
||||
impl ScriptRepository for PostgresScriptRepository {
|
||||
async fn get(&self, id: ScriptId) -> Result<Option<Script>, ScriptRepositoryError> {
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, name, description, version, source, \
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE id = $1",
|
||||
)
|
||||
@@ -90,7 +96,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
|
||||
async fn list(&self) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, name, description, version, source, \
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts ORDER BY name",
|
||||
)
|
||||
@@ -99,17 +105,30 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Script>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ScriptRow>(
|
||||
"SELECT id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at \
|
||||
FROM scripts WHERE app_id = $1 ORDER BY name",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn create(&self, input: NewScript) -> Result<Script, ScriptRepositoryError> {
|
||||
let sandbox_json = serde_json::to_value(input.sandbox.unwrap_or_default())
|
||||
.unwrap_or_else(|_| serde_json::json!({}));
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
"INSERT INTO scripts ( \
|
||||
name, description, source, \
|
||||
app_id, name, description, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox \
|
||||
) VALUES ($1, $2, $3, COALESCE($4, 30), COALESCE($5, 256), $6) \
|
||||
RETURNING id, name, description, version, source, \
|
||||
) VALUES ($1, $2, $3, $4, COALESCE($5, 30), COALESCE($6, 256), $7) \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(&input.name)
|
||||
.bind(input.description.as_deref())
|
||||
.bind(&input.source)
|
||||
@@ -123,7 +142,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
Ok(row) => Ok(row.into()),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(format!(
|
||||
"a script named {:?} already exists",
|
||||
"a script named {:?} already exists in this app",
|
||||
input.name
|
||||
)))
|
||||
}
|
||||
@@ -141,12 +160,13 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
// explicitly set it to NULL (Some(None)) vs leave it alone (None).
|
||||
// Sandbox is replaced wholesale when present; per-field merging
|
||||
// happens in the API layer (clearer semantics for a "PUT a new
|
||||
// sandbox config" call).
|
||||
// sandbox config" call). app_id is immutable — moving a script
|
||||
// to another app is a copy-and-delete, not an in-place edit.
|
||||
let sandbox_json = patch
|
||||
.sandbox
|
||||
.as_ref()
|
||||
.map(|s| serde_json::to_value(s).unwrap_or_else(|_| serde_json::json!({})));
|
||||
let row = sqlx::query_as::<_, ScriptRow>(
|
||||
let res = sqlx::query_as::<_, ScriptRow>(
|
||||
"UPDATE scripts SET \
|
||||
name = COALESCE($2, name), \
|
||||
description = CASE WHEN $3::bool THEN $4 ELSE description END, \
|
||||
@@ -157,7 +177,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
version = version + 1, \
|
||||
updated_at = NOW() \
|
||||
WHERE id = $1 \
|
||||
RETURNING id, name, description, version, source, \
|
||||
RETURNING id, app_id, name, description, version, source, \
|
||||
timeout_seconds, memory_limit_mb, sandbox, created_at, updated_at",
|
||||
)
|
||||
.bind(id.into_inner())
|
||||
@@ -169,10 +189,18 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
.bind(patch.memory_limit_mb)
|
||||
.bind(sandbox_json)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
.await;
|
||||
|
||||
row.map(Into::into)
|
||||
.ok_or(ScriptRepositoryError::NotFound(id))
|
||||
match res {
|
||||
Ok(Some(row)) => Ok(row.into()),
|
||||
Ok(None) => Err(ScriptRepositoryError::NotFound(id)),
|
||||
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => {
|
||||
Err(ScriptRepositoryError::Conflict(
|
||||
"a script with that name already exists in this app".into(),
|
||||
))
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete(&self, id: ScriptId) -> Result<(), ScriptRepositoryError> {
|
||||
@@ -191,6 +219,7 @@ impl ScriptRepository for PostgresScriptRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ScriptRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
version: i32,
|
||||
@@ -211,6 +240,7 @@ impl From<ScriptRow> for Script {
|
||||
let sandbox = serde_json::from_value(r.sandbox).unwrap_or_default();
|
||||
Self {
|
||||
id: r.id.into(),
|
||||
app_id: r.app_id.into(),
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
version: r.version,
|
||||
@@ -284,7 +314,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
offset: i64,
|
||||
) -> Result<Vec<ExecutionLog>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, ExecutionLogRow>(
|
||||
"SELECT id, script_id, request_id, \
|
||||
"SELECT id, app_id, script_id, request_id, \
|
||||
request_path, request_headers, request_body, \
|
||||
response_code, response_body, \
|
||||
logs, duration_ms, status, created_at \
|
||||
@@ -306,6 +336,7 @@ impl ExecutionLogRepository for PostgresExecutionLogRepository {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ExecutionLogRow {
|
||||
id: uuid::Uuid,
|
||||
app_id: uuid::Uuid,
|
||||
script_id: uuid::Uuid,
|
||||
request_id: uuid::Uuid,
|
||||
request_path: Option<String>,
|
||||
@@ -331,6 +362,7 @@ impl From<ExecutionLogRow> for ExecutionLog {
|
||||
};
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
request_id: RequestId::from(r.request_id),
|
||||
request_path: r.request_path.unwrap_or_default(),
|
||||
|
||||
@@ -13,39 +13,49 @@ use axum::{
|
||||
Json, Router,
|
||||
};
|
||||
use picloud_orchestrator_core::routing::{conflict, matcher::CompiledRoute, pattern, RouteTable};
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::repo::ScriptRepositoryError;
|
||||
use crate::app_domain_repo::AppDomainRepository;
|
||||
use crate::repo::{ScriptRepository, ScriptRepositoryError};
|
||||
use crate::route_repo::{NewRoute, RouteRepository};
|
||||
|
||||
pub struct RouteAdminState<RR> {
|
||||
pub struct RouteAdminState<RR, SR> {
|
||||
pub routes: Arc<RR>,
|
||||
/// Used to resolve `script_id → app_id` when creating routes (the
|
||||
/// route inherits the script's app) and to scope conflict checks.
|
||||
pub scripts: Arc<SR>,
|
||||
/// Used to validate the route's host against the parent app's
|
||||
/// declared domain claims.
|
||||
pub domains: Arc<dyn AppDomainRepository>,
|
||||
pub table: Arc<RouteTable>,
|
||||
}
|
||||
|
||||
impl<RR> Clone for RouteAdminState<RR> {
|
||||
impl<RR, SR> Clone for RouteAdminState<RR, SR> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
routes: self.routes.clone(),
|
||||
scripts: self.scripts.clone(),
|
||||
domains: self.domains.clone(),
|
||||
table: self.table.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn route_admin_router<RR>(state: RouteAdminState<RR>) -> Router
|
||||
pub fn route_admin_router<RR, SR>(state: RouteAdminState<RR, SR>) -> Router
|
||||
where
|
||||
RR: RouteRepository + 'static,
|
||||
SR: ScriptRepository + 'static,
|
||||
{
|
||||
Router::new()
|
||||
.route(
|
||||
"/scripts/{id}/routes",
|
||||
get(list_routes::<RR>).post(create_route::<RR>),
|
||||
get(list_routes::<RR, SR>).post(create_route::<RR, SR>),
|
||||
)
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR>))
|
||||
.route("/routes:check", post(check_route::<RR>))
|
||||
.route("/routes:match", post(match_route::<RR>))
|
||||
.route("/routes/{route_id}", delete(delete_route::<RR, SR>))
|
||||
.route("/routes:check", post(check_route::<RR, SR>))
|
||||
.route("/routes:match", post(match_route::<RR, SR>))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -67,6 +77,10 @@ pub struct CreateRouteRequest {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CheckRouteRequest {
|
||||
/// Required: which app's route table this hypothetical route would
|
||||
/// join. Conflict checks are strictly intra-app (cross-app route
|
||||
/// errors would leak tenant info — see blueprint §11.5).
|
||||
pub app_id: AppId,
|
||||
pub host_kind: HostKind,
|
||||
#[serde(default)]
|
||||
pub host: String,
|
||||
@@ -84,6 +98,9 @@ pub struct CheckRouteResponse {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct MatchRouteRequest {
|
||||
/// Which app's route table to dispatch against. The dashboard's
|
||||
/// route-preview tester always knows the current app context.
|
||||
pub app_id: AppId,
|
||||
pub url: String,
|
||||
#[serde(default = "default_method")]
|
||||
pub method: String,
|
||||
@@ -111,15 +128,15 @@ pub struct MatchedRoute {
|
||||
// Handlers
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
async fn list_routes<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn list_routes<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
) -> Result<Json<Vec<Route>>, RouteApiError> {
|
||||
Ok(Json(state.routes.list_for_script(script_id).await?))
|
||||
}
|
||||
|
||||
async fn create_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Path(script_id): Path<ScriptId>,
|
||||
Json(input): Json<CreateRouteRequest>,
|
||||
) -> Result<(StatusCode, Json<Route>), RouteApiError> {
|
||||
@@ -130,8 +147,22 @@ async fn create_route<RR: RouteRepository>(
|
||||
input.host_param_name.as_deref(),
|
||||
)?;
|
||||
|
||||
// Within-kind conflict check against existing routes.
|
||||
let existing = state.routes.list_all().await?;
|
||||
// Look up the script's owning app — every route inherits it.
|
||||
let script = state
|
||||
.scripts
|
||||
.get(script_id)
|
||||
.await?
|
||||
.ok_or(RouteApiError::ScriptNotFound(script_id))?;
|
||||
let app_id = script.app_id;
|
||||
|
||||
// Validate the route's host is consistent with one of the app's
|
||||
// domain claims. `HostKind::Any` is always permitted (catches every
|
||||
// host the app already owns). Specific hosts must match a claim.
|
||||
validate_route_host_against_app(state.domains.as_ref(), app_id, input.host_kind, &input.host)
|
||||
.await?;
|
||||
|
||||
// Within-app conflict check (cross-app is impossible by construction).
|
||||
let existing = state.routes.list_for_app(app_id).await?;
|
||||
if let Some((conflicting, reason)) = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -149,6 +180,7 @@ async fn create_route<RR: RouteRepository>(
|
||||
let created = state
|
||||
.routes
|
||||
.create(NewRoute {
|
||||
app_id,
|
||||
script_id,
|
||||
host_kind: input.host_kind,
|
||||
host: input.host,
|
||||
@@ -162,8 +194,8 @@ async fn create_route<RR: RouteRepository>(
|
||||
Ok((StatusCode::CREATED, Json(created)))
|
||||
}
|
||||
|
||||
async fn delete_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn delete_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Path(route_id): Path<Uuid>,
|
||||
) -> Result<StatusCode, RouteApiError> {
|
||||
state.routes.delete(route_id).await?;
|
||||
@@ -171,14 +203,14 @@ async fn delete_route<RR: RouteRepository>(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
async fn check_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn check_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Json(input): Json<CheckRouteRequest>,
|
||||
) -> Result<Json<CheckRouteResponse>, RouteApiError> {
|
||||
let normalized_path = parse_and_normalize_path(input.path_kind, &input.path)?;
|
||||
pattern::parse_host(input.host_kind, &input.host, None)?;
|
||||
|
||||
let existing = state.routes.list_all().await?;
|
||||
let existing = state.routes.list_for_app(input.app_id).await?;
|
||||
let conflict = first_conflict(
|
||||
&existing,
|
||||
input.host_kind,
|
||||
@@ -201,8 +233,8 @@ async fn check_route<RR: RouteRepository>(
|
||||
}))
|
||||
}
|
||||
|
||||
async fn match_route<RR: RouteRepository>(
|
||||
State(state): State<RouteAdminState<RR>>,
|
||||
async fn match_route<RR: RouteRepository, SR: ScriptRepository>(
|
||||
State(state): State<RouteAdminState<RR, SR>>,
|
||||
Json(input): Json<MatchRouteRequest>,
|
||||
) -> Result<Json<MatchRouteResponse>, RouteApiError> {
|
||||
let parsed = url::Url::parse(&input.url)
|
||||
@@ -210,7 +242,9 @@ async fn match_route<RR: RouteRepository>(
|
||||
let host = parsed.host_str().unwrap_or("").to_string();
|
||||
let path = parsed.path().to_string();
|
||||
|
||||
let result = state.table.match_request(&host, &input.method, &path);
|
||||
let result = state
|
||||
.table
|
||||
.match_request_for_app(input.app_id, &host, &input.method, &path);
|
||||
Ok(Json(MatchRouteResponse {
|
||||
matched: result.map(|r| MatchedRoute {
|
||||
route_id: r.matched.route_id,
|
||||
@@ -263,12 +297,12 @@ fn first_conflict(
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn refresh_table<RR: RouteRepository>(
|
||||
state: &RouteAdminState<RR>,
|
||||
async fn refresh_table<RR: RouteRepository, SR: ScriptRepository>(
|
||||
state: &RouteAdminState<RR, SR>,
|
||||
) -> Result<(), RouteApiError> {
|
||||
let rows = state.routes.list_all().await?;
|
||||
let compiled = compile_routes(&rows)?;
|
||||
state.table.replace(compiled);
|
||||
state.table.replace_all(compiled);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -277,6 +311,7 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.map(|r| {
|
||||
Ok(CompiledRoute {
|
||||
route_id: r.id,
|
||||
app_id: r.app_id,
|
||||
script_id: r.script_id,
|
||||
host: pattern::parse_host(r.host_kind, &r.host, r.host_param_name.as_deref())?,
|
||||
path: pattern::parse_path(r.path_kind, &r.path)?,
|
||||
@@ -286,6 +321,79 @@ pub fn compile_routes(rows: &[Route]) -> Result<Vec<CompiledRoute>, pattern::Par
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate that a new route's (host_kind, host) is consistent with at
|
||||
/// least one of the parent app's domain claims. `HostKind::Any` is
|
||||
/// always permitted — it catches every host the app already owns.
|
||||
async fn validate_route_host_against_app(
|
||||
domains: &dyn AppDomainRepository,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<(), RouteApiError> {
|
||||
if matches!(host_kind, HostKind::Any) {
|
||||
return Ok(());
|
||||
}
|
||||
let claims = domains.list_for_app(app_id).await?;
|
||||
if claims.is_empty() {
|
||||
return Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
let host_lower = host.to_ascii_lowercase();
|
||||
for claim in &claims {
|
||||
let claim_lower = claim.pattern.to_ascii_lowercase();
|
||||
match (host_kind, claim.shape) {
|
||||
// Strict route under exact claim: must match exactly.
|
||||
(HostKind::Strict, picloud_shared::DomainShape::Exact) => {
|
||||
if host_lower == claim_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Strict route under wildcard/parameterized: must end with
|
||||
// ".<suffix>" where the claim's suffix is the part after
|
||||
// `*.` or `{...}.`.
|
||||
(
|
||||
HostKind::Strict,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
let needle = format!(".{suffix}");
|
||||
if !suffix.is_empty() && host_lower.ends_with(&needle) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route: must match a wildcard or parameterized
|
||||
// claim with identical suffix.
|
||||
(
|
||||
HostKind::Wildcard,
|
||||
picloud_shared::DomainShape::Wildcard | picloud_shared::DomainShape::Parameterized,
|
||||
) => {
|
||||
let claim_suffix = claim_lower
|
||||
.split_once('.')
|
||||
.map(|(_, s)| s.to_string())
|
||||
.unwrap_or_default();
|
||||
if claim_suffix == host_lower {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Wildcard route under exact claim: not allowed (would
|
||||
// shadow other apps' subdomains the operator didn't claim).
|
||||
(HostKind::Wildcard, picloud_shared::DomainShape::Exact) => {}
|
||||
(HostKind::Any, _) => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
Err(RouteApiError::HostNotClaimed {
|
||||
host: host.to_string(),
|
||||
available_claims: claims.into_iter().map(|c| c.pattern).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Errors
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -304,6 +412,15 @@ pub enum RouteApiError {
|
||||
#[error("bad request: {0}")]
|
||||
BadRequest(String),
|
||||
|
||||
#[error("script not found: {0}")]
|
||||
ScriptNotFound(ScriptId),
|
||||
|
||||
#[error("host {host:?} is not claimed by this app")]
|
||||
HostNotClaimed {
|
||||
host: String,
|
||||
available_claims: Vec<String>,
|
||||
},
|
||||
|
||||
#[error("repository error: {0}")]
|
||||
Repo(#[from] ScriptRepositoryError),
|
||||
}
|
||||
@@ -326,10 +443,21 @@ impl IntoResponse for RouteApiError {
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
Self::ScriptNotFound(_) | Self::Repo(ScriptRepositoryError::NotFound(_)) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
),
|
||||
Self::HostNotClaimed {
|
||||
host,
|
||||
available_claims,
|
||||
} => (
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
serde_json::json!({
|
||||
"error": self.to_string(),
|
||||
"host": host,
|
||||
"available_claims": available_claims,
|
||||
}),
|
||||
),
|
||||
Self::Repo(ScriptRepositoryError::Conflict(_)) => (
|
||||
StatusCode::CONFLICT,
|
||||
serde_json::json!({ "error": self.to_string() }),
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! CRUD over the `routes` table.
|
||||
//!
|
||||
//! The orchestrator's `RouteTable` is repopulated from this repo after
|
||||
//! every write — see the route_admin module for the binding.
|
||||
//! The orchestrator's `AppRouteTables` is repopulated from this repo
|
||||
//! after every write — see the route_admin module for the binding.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use picloud_shared::{HostKind, PathKind, Route, ScriptId};
|
||||
use picloud_shared::{AppId, HostKind, PathKind, Route, ScriptId};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::repo::ScriptRepositoryError;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewRoute {
|
||||
pub app_id: AppId,
|
||||
pub script_id: ScriptId,
|
||||
pub host_kind: HostKind,
|
||||
pub host: String,
|
||||
@@ -24,12 +25,21 @@ pub struct NewRoute {
|
||||
#[async_trait]
|
||||
pub trait RouteRepository: Send + Sync {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError>;
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError>;
|
||||
async fn delete(&self, route_id: Uuid) -> Result<(), ScriptRepositoryError>;
|
||||
/// Count routes whose host_kind/host pair matches a pattern in
|
||||
/// `app_id`. Used by the domain-claim delete guard.
|
||||
async fn count_for_app_host(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<i64, ScriptRepositoryError>;
|
||||
}
|
||||
|
||||
pub struct PostgresRouteRepository {
|
||||
@@ -47,7 +57,7 @@ impl PostgresRouteRepository {
|
||||
impl RouteRepository for PostgresRouteRepository {
|
||||
async fn list_all(&self) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes ORDER BY created_at",
|
||||
)
|
||||
@@ -56,12 +66,24 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_app(&self, app_id: AppId) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes WHERE app_id = $1 ORDER BY created_at",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
async fn list_for_script(
|
||||
&self,
|
||||
script_id: ScriptId,
|
||||
) -> Result<Vec<Route>, ScriptRepositoryError> {
|
||||
let rows = sqlx::query_as::<_, RouteRow>(
|
||||
"SELECT id, script_id, host_kind, host, host_param_name, \
|
||||
"SELECT id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at \
|
||||
FROM routes WHERE script_id = $1 ORDER BY created_at",
|
||||
)
|
||||
@@ -74,12 +96,13 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
async fn create(&self, input: NewRoute) -> Result<Route, ScriptRepositoryError> {
|
||||
let res = sqlx::query_as::<_, RouteRow>(
|
||||
"INSERT INTO routes ( \
|
||||
script_id, host_kind, host, host_param_name, \
|
||||
app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7) \
|
||||
RETURNING id, script_id, host_kind, host, host_param_name, \
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) \
|
||||
RETURNING id, app_id, script_id, host_kind, host, host_param_name, \
|
||||
path_kind, path, method, created_at",
|
||||
)
|
||||
.bind(input.app_id.into_inner())
|
||||
.bind(input.script_id.into_inner())
|
||||
.bind(host_kind_str(input.host_kind))
|
||||
.bind(&input.host)
|
||||
@@ -112,6 +135,24 @@ impl RouteRepository for PostgresRouteRepository {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn count_for_app_host(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
host_kind: HostKind,
|
||||
host: &str,
|
||||
) -> Result<i64, ScriptRepositoryError> {
|
||||
let count: (i64,) = sqlx::query_as(
|
||||
"SELECT COUNT(*) FROM routes \
|
||||
WHERE app_id = $1 AND host_kind = $2 AND host = $3",
|
||||
)
|
||||
.bind(app_id.into_inner())
|
||||
.bind(host_kind_str(host_kind))
|
||||
.bind(host)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(count.0)
|
||||
}
|
||||
}
|
||||
|
||||
const fn host_kind_str(k: HostKind) -> &'static str {
|
||||
@@ -133,6 +174,7 @@ const fn path_kind_str(k: PathKind) -> &'static str {
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct RouteRow {
|
||||
id: Uuid,
|
||||
app_id: Uuid,
|
||||
script_id: Uuid,
|
||||
host_kind: String,
|
||||
host: String,
|
||||
@@ -147,6 +189,7 @@ impl From<RouteRow> for Route {
|
||||
fn from(r: RouteRow) -> Self {
|
||||
Self {
|
||||
id: r.id,
|
||||
app_id: r.app_id.into(),
|
||||
script_id: r.script_id.into(),
|
||||
host_kind: match r.host_kind.as_str() {
|
||||
"strict" => HostKind::Strict,
|
||||
|
||||
@@ -3,6 +3,43 @@
|
||||
|
||||
## tables
|
||||
|
||||
table: admin_sessions
|
||||
token_hash: text NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
expires_at: timestamp with time zone NOT NULL
|
||||
last_used_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: admin_users
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
username: text NOT NULL
|
||||
password_hash: text NOT NULL
|
||||
is_active: boolean NOT NULL default=true
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
last_login_at: timestamp with time zone NULL
|
||||
|
||||
table: app_domains
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
app_id: uuid NOT NULL
|
||||
pattern: text NOT NULL
|
||||
shape: text NOT NULL
|
||||
shape_key: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: app_slug_history
|
||||
slug: text NOT NULL
|
||||
current_app_id: uuid NOT NULL
|
||||
retired_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: apps
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
slug: text NOT NULL
|
||||
name: text NOT NULL
|
||||
description: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: execution_logs
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
script_id: uuid NOT NULL
|
||||
@@ -16,6 +53,7 @@ table: execution_logs
|
||||
duration_ms: integer NOT NULL default=0
|
||||
status: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: routes
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -27,6 +65,7 @@ table: routes
|
||||
path: text NOT NULL
|
||||
method: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
table: scripts
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
@@ -39,42 +78,94 @@ table: scripts
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||
app_id: uuid NOT NULL
|
||||
|
||||
## indexes
|
||||
|
||||
indexes on admin_sessions:
|
||||
admin_sessions_expiry_idx: public.admin_sessions USING btree (expires_at)
|
||||
admin_sessions_pkey: public.admin_sessions USING btree (token_hash)
|
||||
admin_sessions_user_idx: public.admin_sessions USING btree (user_id)
|
||||
|
||||
indexes on admin_users:
|
||||
admin_users_pkey: public.admin_users USING btree (id)
|
||||
admin_users_username_key: public.admin_users USING btree (username)
|
||||
|
||||
indexes on app_domains:
|
||||
app_domains_app_id_idx: public.app_domains USING btree (app_id)
|
||||
app_domains_pkey: public.app_domains USING btree (id)
|
||||
app_domains_shape_key_key: public.app_domains USING btree (shape_key)
|
||||
|
||||
indexes on app_slug_history:
|
||||
app_slug_history_pkey: public.app_slug_history USING btree (slug)
|
||||
|
||||
indexes on apps:
|
||||
apps_pkey: public.apps USING btree (id)
|
||||
apps_slug_key: public.apps USING btree (slug)
|
||||
|
||||
indexes on execution_logs:
|
||||
execution_logs_app_id_created_at_idx: public.execution_logs USING btree (app_id, created_at DESC)
|
||||
execution_logs_pkey: public.execution_logs USING btree (id)
|
||||
execution_logs_script_id_created_at_idx: public.execution_logs USING btree (script_id, created_at DESC)
|
||||
|
||||
indexes on routes:
|
||||
routes_app_id_idx: public.routes USING btree (app_id)
|
||||
routes_lookup_idx: public.routes USING btree (host_kind, host)
|
||||
routes_pkey: public.routes USING btree (id)
|
||||
routes_script_id_idx: public.routes USING btree (script_id)
|
||||
routes_unique_binding_idx: public.routes USING btree (host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
routes_unique_binding_idx: public.routes USING btree (app_id, host_kind, host, path_kind, path, COALESCE(method, ''::text))
|
||||
|
||||
indexes on scripts:
|
||||
scripts_name_uidx: public.scripts USING btree (lower(name))
|
||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||
scripts_pkey: public.scripts USING btree (id)
|
||||
|
||||
## constraints
|
||||
|
||||
constraints on admin_sessions:
|
||||
[FOREIGN KEY] admin_sessions_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] admin_sessions_pkey: PRIMARY KEY (token_hash)
|
||||
|
||||
constraints on admin_users:
|
||||
[PRIMARY KEY] admin_users_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] admin_users_username_key: UNIQUE (username)
|
||||
|
||||
constraints on app_domains:
|
||||
[CHECK] app_domains_shape_check: CHECK ((shape = ANY (ARRAY['exact'::text, 'wildcard'::text, 'parameterized'::text])))
|
||||
[FOREIGN KEY] app_domains_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_domains_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] app_domains_shape_key_key: UNIQUE (shape_key)
|
||||
|
||||
constraints on app_slug_history:
|
||||
[FOREIGN KEY] app_slug_history_current_app_id_fkey: FOREIGN KEY (current_app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] app_slug_history_pkey: PRIMARY KEY (slug)
|
||||
|
||||
constraints on apps:
|
||||
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||
|
||||
constraints on execution_logs:
|
||||
[CHECK] execution_logs_status_check: CHECK ((status = ANY (ARRAY['success'::text, 'error'::text, 'timeout'::text, 'budget_exceeded'::text])))
|
||||
[FOREIGN KEY] execution_logs_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] execution_logs_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] execution_logs_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on routes:
|
||||
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
|
||||
[CHECK] routes_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
|
||||
[FOREIGN KEY] routes_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] routes_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] routes_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on scripts:
|
||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||
|
||||
## applied migrations
|
||||
0001: init
|
||||
0002: sandbox
|
||||
0003: routes
|
||||
0004: admin auth
|
||||
0005: apps
|
||||
|
||||
Reference in New Issue
Block a user