Scaling Phase 1: multi-tenant onboarding + replica-safe coordination
Decouples "many users" + "many server replicas" from "many machines" so the platform is tenant-isolated and horizontally safe on the current single node. - Per-signup workspaces (cm-auth): a new hosted-identity sign-in provisions and owns its own workspace instead of joining the first. Config-gated by auth.per_signup_workspace (default off); concurrent first-logins serialized by a per-subject advisory lock so no duplicate workspaces. - Terminal tickets in Postgres (migration 0016, hashed, single-use): any replica can redeem a ticket minted by another. Drops the in-process ticket map. - Container registry in Postgres (migration 0017, agent_containers): Terminal and Sandbox managers resolve an agent's container through a shared registry, so a 2nd replica reuses it instead of spawning a duplicate. node_id recorded as 'local' (Phase 2 hook). Boot reconcile removes only true orphans, so terminals now survive a redeploy (tmux sessions resume). - Per-workspace quotas (cm-api/quota.rs): plan-tier caps on agents + live containers, enforced at agent create + terminal spin-up (reconnects allowed), returned as HTTP 402. New GET /api/quota surfaces usage vs limits. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
f8f2b65e1f
commit
e9ce368ec1
@@ -0,0 +1,104 @@
|
||||
//! Per-workspace resource quotas keyed off the workspace plan. Phase 1 of the
|
||||
//! multi-tenant scaling work: keep one tenant from exhausting the shared pool.
|
||||
|
||||
use axum::extract::State;
|
||||
use axum::Json;
|
||||
use cm_domain::WorkspaceId;
|
||||
use serde::Serialize;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::{ApiError, AppState, Authed};
|
||||
|
||||
/// Caps for a plan tier.
|
||||
pub struct Quota {
|
||||
pub max_agents: i64,
|
||||
pub max_live_containers: i64,
|
||||
}
|
||||
|
||||
/// Per-plan limits. Unknown plans fall back to the free tier.
|
||||
pub fn plan_quota(plan: &str) -> Quota {
|
||||
match plan {
|
||||
"team" => Quota {
|
||||
max_agents: 50,
|
||||
max_live_containers: 50,
|
||||
},
|
||||
"pro" => Quota {
|
||||
max_agents: 20,
|
||||
max_live_containers: 20,
|
||||
},
|
||||
_ => Quota {
|
||||
max_agents: 3,
|
||||
max_live_containers: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspace's plan name (defaults to "free").
|
||||
async fn plan_of(state: &AppState, workspace_id: WorkspaceId) -> Result<String, ApiError> {
|
||||
let row = sqlx::query("SELECT plan FROM workspaces WHERE id = $1")
|
||||
.bind(workspace_id.as_uuid())
|
||||
.fetch_optional(&state.pool)
|
||||
.await?;
|
||||
Ok(row
|
||||
.map(|r| r.get::<String, _>("plan"))
|
||||
.unwrap_or_else(|| "free".to_string()))
|
||||
}
|
||||
|
||||
/// Reject creating another agent if the workspace is at its plan cap.
|
||||
pub async fn enforce_new_agent(state: &AppState, workspace_id: WorkspaceId) -> Result<(), ApiError> {
|
||||
let plan = plan_of(state, workspace_id).await?;
|
||||
let quota = plan_quota(&plan);
|
||||
let used = cm_db::repo::agents::count_active(&state.pool, workspace_id).await?;
|
||||
if used >= quota.max_agents {
|
||||
return Err(ApiError::Quota(format!(
|
||||
"agent limit reached ({} on the {plan} plan) — upgrade your plan or remove an agent",
|
||||
quota.max_agents
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject spinning up another container if the workspace is at its plan cap.
|
||||
pub async fn enforce_new_container(
|
||||
state: &AppState,
|
||||
workspace_id: WorkspaceId,
|
||||
) -> Result<(), ApiError> {
|
||||
let plan = plan_of(state, workspace_id).await?;
|
||||
let quota = plan_quota(&plan);
|
||||
let used = cm_db::repo::agent_containers::count_for_workspace(&state.pool, workspace_id).await?;
|
||||
if used >= quota.max_live_containers {
|
||||
return Err(ApiError::Quota(format!(
|
||||
"live container limit reached ({} on the {plan} plan) — close a terminal/agent or upgrade",
|
||||
quota.max_live_containers
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct QuotaUsage {
|
||||
plan: String,
|
||||
agents_used: i64,
|
||||
max_agents: i64,
|
||||
containers_used: i64,
|
||||
max_live_containers: i64,
|
||||
}
|
||||
|
||||
/// `GET /api/quota` — the caller's workspace usage + limits (for the UI).
|
||||
pub async fn get_quota(
|
||||
State(state): State<AppState>,
|
||||
Authed(user): Authed,
|
||||
) -> Result<Json<QuotaUsage>, ApiError> {
|
||||
let plan = plan_of(&state, user.workspace_id).await?;
|
||||
let quota = plan_quota(&plan);
|
||||
let agents_used = cm_db::repo::agents::count_active(&state.pool, user.workspace_id).await?;
|
||||
let containers_used =
|
||||
cm_db::repo::agent_containers::count_for_workspace(&state.pool, user.workspace_id).await?;
|
||||
Ok(Json(QuotaUsage {
|
||||
plan,
|
||||
agents_used,
|
||||
max_agents: quota.max_agents,
|
||||
containers_used,
|
||||
max_live_containers: quota.max_live_containers,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user