mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 00:03:07 +00:00
feat: support workspace forks on cloud using parent workspace limits (#9864)
* feat: support workspace forks on cloud using parent workspace limits Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: clarify count_paid_seats approximates rather than mirrors billing seats Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: non-admin fork UI, attach cap, and fork-count for cloud forks Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: cloud fork billing cache on rename, usage display, attach cap edge Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: fork count in cloud quotas + fork billing points to parent Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: invalidate billing/fork caches on fork deletion for id reuse Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: gate fork usage remap on CLOUD_HOSTED, not just the cloud feature Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: note cloud feature vs CLOUD_HOSTED gating in backend guide Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: reserve fork-cap slots for an attach candidate's whole subtree Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: invalidate team-plan cache on delete, raise fork depth cap Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: cap fork nesting depth (MAX_FORK_DEPTH, default 5) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: fork count/height robust to cycles and deleted intermediates Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): reset fork button loading state on creation error Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: invalidate billing cache for attached fork subtree; helper auth docs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d15033cde6
commit
7c7d7474cc
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, chain.depth + 1\n FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT COALESCE(MAX(depth), 0)::bigint AS \"depth!\" FROM chain\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "depth!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "27a131537cee699ae53088cfd710b70eb81b4e6b8f79d57db9d1d12d7f3ffde2"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH RECURSIVE tree AS (\n SELECT id, deleted, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.deleted, tree.depth + 1 FROM workspace w\n JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT COALESCE(MAX(depth) FILTER (WHERE NOT deleted), 0)::bigint AS \"height!\" FROM tree\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "height!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "2eb746c4cc5c65e277d4c3a12e0bc9b6a776a4941e68d8e2b31904c677493e2d"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH RECURSIVE tree AS (\n SELECT id, deleted, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.deleted, tree.depth + 1 FROM workspace w\n JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT COUNT(DISTINCT id) AS \"count!\" FROM tree WHERE id != $1 AND NOT deleted\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "453c8dc05f4946a30fbbc517b5beaaee42e954e0b4ed0bd5ba6a5b308894d6ba"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH RECURSIVE tree AS (\n SELECT id, 0 AS depth FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, tree.depth + 1 FROM workspace w\n JOIN tree ON w.parent_workspace_id = tree.id\n WHERE tree.depth < 20\n )\n SELECT id AS \"id!\" FROM tree WHERE id != $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "71ee328f016bac75a9050dc3c13dc898c0b40fe6f1c4e5c631b6f784f7e73a3d"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH RECURSIVE chain AS (\n SELECT id, parent_workspace_id, 0 AS depth\n FROM workspace WHERE id = $1\n UNION ALL\n SELECT w.id, w.parent_workspace_id, chain.depth + 1\n FROM workspace w\n JOIN chain ON w.id = chain.parent_workspace_id\n WHERE chain.depth < 20\n )\n SELECT id AS \"id!\" FROM chain WHERE parent_workspace_id IS NULL LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "bcb36b5d38a07dbdfed71983aec1af05c5a6882f988ba5e0ec1f63828353987b"
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS \"developers!\",\n COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS \"operators!\"\n FROM usr WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "developers!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operators!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ebce1633afe3d3f8a1a68bc8d26adcdd88b137bb892437a3ca67fd716772512b"
|
||||
}
|
||||
@@ -11,3 +11,13 @@
|
||||
cd backend/windmill-duckdb-ffi-internal && ./build_dev.sh
|
||||
```
|
||||
Re-run after clean builds or when `target/debug/libwindmill_duckdb_ffi_internal.*` is missing.
|
||||
|
||||
## Cloud vs self-hosted gating
|
||||
|
||||
The `cloud` cargo feature is compiled into **all** EE builds, so `#[cfg(feature = "cloud")]` is **not** a "cloud-only" runtime gate — it only means the code is present. The real gate for behavior specific to the managed cloud (app.windmill.dev) is the runtime flag `*CLOUD_HOSTED` (`windmill_common::worker::CLOUD_HOSTED`, from the `CLOUD_HOSTED` env var; note it's loaded from `.env` via `dotenv`, so it won't show in `/proc/<pid>/environ` — check the running behavior, not the exec env).
|
||||
|
||||
Cloud-only logic must be behind `if *CLOUD_HOSTED { ... }`: feature-gate the helper so it compiles, then **runtime-gate the call**. `#[cfg(feature = "cloud")]` on its own is only sufficient for:
|
||||
- pure helper/struct definitions (they only run when a gated caller invokes them),
|
||||
- code already inside an `if *CLOUD_HOSTED { ... }` block,
|
||||
- handlers that early-return on `!*CLOUD_HOSTED`,
|
||||
- idempotent no-ops that are harmless off-cloud (e.g. cache invalidation).
|
||||
|
||||
@@ -629,11 +629,13 @@ async fn list_pending_invites(
|
||||
}
|
||||
|
||||
async fn is_premium(
|
||||
authed: ApiAuthed,
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
) -> JsonResult<bool> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
// Any workspace member (not just admins) may read whether the workspace is on a paid plan: it's a
|
||||
// single boolean, and the frontend needs it to decide whether to surface premium-gated affordances
|
||||
// (e.g. forking) to non-admin developers too. The `_authed` extractor still enforces membership.
|
||||
#[cfg(feature = "cloud")]
|
||||
let premium = windmill_common::workspaces::get_team_plan_status(&_db, &_w_id)
|
||||
.await?
|
||||
@@ -3797,6 +3799,24 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
};
|
||||
|
||||
// Cloud only: how many forks a premium workspace may have per paid (developer) seat.
|
||||
pub static ref MAX_FORKS_PER_SEAT: i64 = std::env::var("MAX_FORKS_PER_SEAT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.filter(|v| *v >= 0)
|
||||
.unwrap_or(5);
|
||||
|
||||
// How deep a fork chain may nest (root = depth 0, a direct fork = depth 1). A general guardrail
|
||||
// for all builds, independent of the cloud per-seat cap: deep fork chains are a footgun and no
|
||||
// real use case needs them. Clamped to [1, 20] so it's always a real limit and can never exceed
|
||||
// the fork-walk recursion backstop (20) that billing/count resolution uses (a chain deeper than
|
||||
// the backstop would truncate and mis-resolve its root).
|
||||
pub static ref MAX_FORK_DEPTH: i64 = std::env::var("MAX_FORK_DEPTH")
|
||||
.ok()
|
||||
.and_then(|v| v.parse::<i64>().ok())
|
||||
.map(|v| v.clamp(1, 20))
|
||||
.unwrap_or(5);
|
||||
|
||||
}
|
||||
|
||||
async fn create_workspace_require_superadmin() -> String {
|
||||
@@ -4996,10 +5016,12 @@ async fn create_workspace_fork_branch(
|
||||
Path(w_id): Path<String>,
|
||||
Json(nw): Json<CreateWorkspaceFork>,
|
||||
) -> JsonResult<Vec<Uuid>> {
|
||||
// Pre-check the fork guards before creating any git branch, so we don't leave orphaned branches
|
||||
// behind when the follow-up create_workspace_fork would be rejected anyway.
|
||||
enforce_fork_depth(&db, &w_id, 0).await?;
|
||||
#[cfg(feature = "cloud")]
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Forking workspaces is not available on app.windmill.dev"
|
||||
)));
|
||||
enforce_cloud_fork_cap(&db, &w_id).await?;
|
||||
}
|
||||
|
||||
if *DISABLE_WORKSPACE_FORK {
|
||||
@@ -5171,16 +5193,83 @@ async fn apply_forked_datatable(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cloud: require the fork/dev's root (billing) workspace be premium; returns the resolved root id.
|
||||
#[cfg(feature = "cloud")]
|
||||
async fn require_cloud_fork_premium(db: &DB, parent_workspace_id: &str) -> Result<String> {
|
||||
let root =
|
||||
windmill_common::workspaces::get_billing_workspace_id(db, parent_workspace_id).await?;
|
||||
if !windmill_common::workspaces::get_team_plan_status(db, &root)
|
||||
.await?
|
||||
.premium
|
||||
{
|
||||
return Err(Error::BadRequest(
|
||||
"Creating a fork or dev workspace on the cloud requires a paid team plan. Upgrade the workspace first.".to_string(),
|
||||
));
|
||||
}
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Cloud: reject if adding `incoming` fork/dev workspaces would push `root`'s family over its per-seat
|
||||
/// allotment. `incoming` is the number of workspaces the operation adds to the family — 1 for a plain
|
||||
/// create, but `1 + candidate_subtree` for an attach whose candidate already has child forks.
|
||||
#[cfg(feature = "cloud")]
|
||||
async fn enforce_cloud_fork_count(db: &DB, root: &str, incoming: i64) -> Result<()> {
|
||||
let seats = windmill_common::workspaces::count_paid_seats(db, root).await?;
|
||||
let per_seat = *MAX_FORKS_PER_SEAT;
|
||||
// Any premium workspace has at least one paid seat, so floor the seat count at 1.
|
||||
let allowed = seats.max(1) * per_seat;
|
||||
|
||||
let existing = windmill_common::workspaces::count_workspace_forks(db, root).await?;
|
||||
let projected = existing + incoming;
|
||||
if projected > allowed {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Fork limit reached: this would bring the workspace family to {projected} fork(s), over the cap of {allowed} ({seats} paid seat(s) × {per_seat} per seat). Delete a fork or add seats."
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Cloud-only guard for creating a fork/dev workspace. Forks piggyback on the parent's plan (a fork
|
||||
/// inherits the root's premium and meters its usage into the root's bill), so forking is limited to
|
||||
/// premium workspaces and capped at `MAX_FORKS_PER_SEAT` per paid (developer) seat of the root.
|
||||
#[cfg(feature = "cloud")]
|
||||
async fn enforce_cloud_fork_cap(db: &DB, parent_workspace_id: &str) -> Result<()> {
|
||||
let root = require_cloud_fork_premium(db, parent_workspace_id).await?;
|
||||
enforce_cloud_fork_count(db, &root, 1).await
|
||||
}
|
||||
|
||||
/// General guardrail (all builds): reject creating a fork/dev under `parent` when it would nest deeper
|
||||
/// than `MAX_FORK_DEPTH`. `added_subtree_height` is the height of the subtree grafted below the new
|
||||
/// node — 0 for a plain fork, or the candidate's own subtree height for an attach.
|
||||
async fn enforce_fork_depth(
|
||||
db: &DB,
|
||||
parent_workspace_id: &str,
|
||||
added_subtree_height: i64,
|
||||
) -> Result<()> {
|
||||
let parent_depth =
|
||||
windmill_common::workspaces::fork_chain_depth(db, parent_workspace_id).await?;
|
||||
// The new node sits one level below the parent; its deepest descendant adds the grafted height.
|
||||
let resulting_depth = parent_depth + 1 + added_subtree_height;
|
||||
if resulting_depth > *MAX_FORK_DEPTH {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Fork depth limit reached: forks can be nested at most {} level(s) deep, but this would create a fork at depth {}. Fork from a workspace closer to the root instead.",
|
||||
*MAX_FORK_DEPTH, resulting_depth
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_workspace_fork(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(parent_workspace_id): Path<String>,
|
||||
Json(nw): Json<CreateWorkspaceFork>,
|
||||
) -> Result<String> {
|
||||
enforce_fork_depth(&db, &parent_workspace_id, 0).await?;
|
||||
#[cfg(feature = "cloud")]
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Forking workspaces is not available on app.windmill.dev"
|
||||
)));
|
||||
enforce_cloud_fork_cap(&db, &parent_workspace_id).await?;
|
||||
}
|
||||
|
||||
if nw.is_dev_workspace {
|
||||
@@ -5347,10 +5436,30 @@ async fn attach_dev_workspace(
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
|
||||
// Attaching grafts the candidate (and its own fork subtree) under prod, so enforce the general
|
||||
// depth limit on the deepest resulting node.
|
||||
let candidate_height =
|
||||
windmill_common::workspaces::fork_subtree_height(&db, &req.dev_workspace_id).await?;
|
||||
enforce_fork_depth(&db, &prod_w_id, candidate_height).await?;
|
||||
|
||||
// Attaching reparents a workspace under prod (one dev per prod, admin-gated) and it then draws
|
||||
// prod's plan, so hold it to the same premium requirement as creating a fork. Only enforce the
|
||||
// per-seat count when the attach actually adds a new workspace to the family: re-designating a
|
||||
// workspace already under this root as its dev doesn't increase the descendant count.
|
||||
#[cfg(feature = "cloud")]
|
||||
if *CLOUD_HOSTED {
|
||||
return Err(Error::BadRequest(
|
||||
"Dev workspaces are not available on app.windmill.dev".to_string(),
|
||||
));
|
||||
let root = require_cloud_fork_premium(&db, &prod_w_id).await?;
|
||||
// Only count against the cap when this attach adds workspaces to the family (candidate not
|
||||
// already under this root). The candidate may itself have child forks, so reserve slots for its
|
||||
// whole incoming subtree (the candidate + its descendants), not just one.
|
||||
if windmill_common::workspaces::get_billing_workspace_id(&db, &req.dev_workspace_id).await?
|
||||
!= root
|
||||
{
|
||||
let incoming =
|
||||
1 + windmill_common::workspaces::count_workspace_forks(&db, &req.dev_workspace_id)
|
||||
.await?;
|
||||
enforce_cloud_fork_count(&db, &root, incoming).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let dev_w_id = req.dev_workspace_id;
|
||||
@@ -5468,6 +5577,17 @@ async fn attach_dev_workspace(
|
||||
// The dev workspace's parent just changed (none -> prod); drop its cached fork->parent mapping
|
||||
// so per-workspace job tags route to the prod family immediately rather than after the TTL.
|
||||
windmill_queue::tags::invalidate_fork_parent_cache(&dev_w_id);
|
||||
// Same reparent invalidates the billing-workspace mapping so its usage meters to prod at once. The
|
||||
// candidate can bring its own fork subtree, whose descendants had resolved their (now-stale) root
|
||||
// to the candidate's old family; invalidate them too so they meter to prod without waiting out the
|
||||
// 60s TTL. Their immediate fork->parent links don't move, so the tag-routing cache needs no change.
|
||||
#[cfg(feature = "cloud")]
|
||||
{
|
||||
windmill_common::workspaces::invalidate_billing_workspace_cache(&dev_w_id);
|
||||
for id in windmill_common::workspaces::list_fork_descendants(&db, &dev_w_id).await? {
|
||||
windmill_common::workspaces::invalidate_billing_workspace_cache(&id);
|
||||
}
|
||||
}
|
||||
|
||||
if req.lock_prod_deploy || req.lock_prod_forking {
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&prod_w_id);
|
||||
@@ -6415,6 +6535,17 @@ async fn change_workspace_color(
|
||||
}
|
||||
|
||||
async fn get_usage(Extension(db): Extension<DB>, Path(w_id): Path<String>) -> Result<String> {
|
||||
// On cloud, a fork's executions meter against its billing root, so report the root's usage here too;
|
||||
// otherwise the free-execs indicator would show the fork's own (often 0) count while enforcement
|
||||
// applies the root's shared quota. Gated on `*CLOUD_HOSTED` (not just the `cloud` feature, which is
|
||||
// compiled into all EE builds): self-hosted doesn't meter usage this way. Off-fork it resolves to
|
||||
// `w_id` itself anyway.
|
||||
#[cfg(feature = "cloud")]
|
||||
let w_id = if *CLOUD_HOSTED {
|
||||
windmill_common::workspaces::get_billing_workspace_id(&db, &w_id).await?
|
||||
} else {
|
||||
w_id
|
||||
};
|
||||
let usage = sqlx::query_scalar!(
|
||||
"
|
||||
SELECT usage.usage FROM usage
|
||||
@@ -8230,6 +8361,10 @@ struct CloudQuotas {
|
||||
apps: QuotaInfo,
|
||||
variables: QuotaInfo,
|
||||
resources: QuotaInfo,
|
||||
/// Fork/dev workspaces under this workspace's billing root vs the per-seat cap. `limit` is 0 for a
|
||||
/// non-premium root (forking is premium-only). Family-wide: resolves to the billing root, so it
|
||||
/// reads the same whether viewed from the root or one of its forks.
|
||||
forks: QuotaInfo,
|
||||
}
|
||||
|
||||
async fn get_cloud_quotas(
|
||||
@@ -8310,12 +8445,32 @@ async fn get_cloud_quotas(
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
// Fork/dev workspaces vs the per-seat cap, resolved to the billing root. Non-premium roots can't
|
||||
// fork, so their allowance is 0.
|
||||
#[cfg(feature = "cloud")]
|
||||
let forks = {
|
||||
use windmill_common::workspaces::{
|
||||
count_paid_seats, count_workspace_forks, get_billing_workspace_id, get_team_plan_status,
|
||||
};
|
||||
let root = get_billing_workspace_id(&db, &w_id).await?;
|
||||
let used = count_workspace_forks(&db, &root).await?;
|
||||
let limit = if get_team_plan_status(&db, &root).await?.premium {
|
||||
count_paid_seats(&db, &root).await?.max(1) * *MAX_FORKS_PER_SEAT
|
||||
} else {
|
||||
0
|
||||
};
|
||||
QuotaInfo { used, limit, prunable: 0 }
|
||||
};
|
||||
#[cfg(not(feature = "cloud"))]
|
||||
let forks = QuotaInfo { used: 0, limit: 0, prunable: 0 };
|
||||
|
||||
Ok(Json(CloudQuotas {
|
||||
scripts: QuotaInfo { used: scripts_used, limit: 5000, prunable: scripts_prunable },
|
||||
flows: QuotaInfo { used: flows_used, limit: 1000, prunable: flows_prunable },
|
||||
apps: QuotaInfo { used: apps_used, limit: 1000, prunable: apps_prunable },
|
||||
variables: QuotaInfo { used: variables_used, limit: 10000, prunable: 0 },
|
||||
resources: QuotaInfo { used: resources_used, limit: 10000, prunable: 0 },
|
||||
forks,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -726,10 +726,13 @@ pub(crate) async fn change_workspace_id(
|
||||
tx.commit().await?;
|
||||
|
||||
// The children's parent_workspace_id changed (old root -> new root); invalidate their fork-parent
|
||||
// routing cache so jobs route under the renamed root rather than the old (archived) one until the
|
||||
// 300s TTL would otherwise expire.
|
||||
// routing cache and their billing-workspace mapping so jobs route + meter under the renamed root
|
||||
// rather than the old (archived) one, instead of waiting for the caches' TTLs. Deeper descendants
|
||||
// (fork-of-fork) self-heal via the 60s billing-cache TTL.
|
||||
for child in &reparented_children {
|
||||
windmill_queue::tags::invalidate_fork_parent_cache(child);
|
||||
#[cfg(feature = "cloud")]
|
||||
windmill_common::workspaces::invalidate_billing_workspace_cache(child);
|
||||
}
|
||||
|
||||
// Archive old workspace: disable schedules, cancel remaining jobs, set deleted=true
|
||||
@@ -1017,6 +1020,16 @@ pub(crate) async fn delete_workspace(
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
// Capture direct child forks before the delete: the FK is ON DELETE SET NULL, so they're about to
|
||||
// be orphaned (their billing root changes from this workspace's root to themselves). We drop their
|
||||
// cached mappings after commit alongside the deleted id itself.
|
||||
let orphaned_children: Vec<String> = sqlx::query_scalar!(
|
||||
"SELECT id FROM workspace WHERE parent_workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!("DELETE FROM workspace WHERE id = $1", &w_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1050,6 +1063,22 @@ pub(crate) async fn delete_workspace(
|
||||
windmill_common::workspaces::invalidate_protection_rules_cache(&parent);
|
||||
}
|
||||
|
||||
// Workspace ids are reusable after permanent deletion, so drop every cached mapping keyed by the
|
||||
// deleted id (and any just-orphaned children) — otherwise a recreated id could inherit the gone
|
||||
// workspace's state within the caches' lifetimes. This covers fork->parent (tag routing) and
|
||||
// fork->root (billing), plus the premium/team-plan status: TEAM_PLAN_CACHE has no TTL and is only
|
||||
// evicted by the premium-change NOTIFY, so without this a reused id would keep the old workspace's
|
||||
// premium indefinitely (free forks/usage). Deeper (grandchild) descendants self-heal via the 60s
|
||||
// billing TTL.
|
||||
for id in std::iter::once(&w_id).chain(orphaned_children.iter()) {
|
||||
windmill_queue::tags::invalidate_fork_parent_cache(id);
|
||||
#[cfg(feature = "cloud")]
|
||||
{
|
||||
windmill_common::workspaces::invalidate_billing_workspace_cache(id);
|
||||
windmill_common::workspaces::invalidate_team_plan_cache(id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(format!("Deleted workspace {}", &w_id))
|
||||
}
|
||||
|
||||
|
||||
@@ -6221,12 +6221,15 @@ paths:
|
||||
$ref: "#/components/schemas/QuotaInfo"
|
||||
resources:
|
||||
$ref: "#/components/schemas/QuotaInfo"
|
||||
forks:
|
||||
$ref: "#/components/schemas/QuotaInfo"
|
||||
required:
|
||||
- scripts
|
||||
- flows
|
||||
- apps
|
||||
- variables
|
||||
- resources
|
||||
- forks
|
||||
|
||||
/w/{workspace}/workspaces/prune_versions:
|
||||
post:
|
||||
|
||||
@@ -347,9 +347,215 @@ lazy_static::lazy_static! {
|
||||
pub static ref PUBLIC_APP_RATE_LIMIT_CACHE: Cache<String, (Option<i32>, i64)> = Cache::new(1000);
|
||||
}
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
lazy_static::lazy_static! {
|
||||
// Maps a workspace id to its root (billing) ancestor. Value: (root_id, expiry_timestamp).
|
||||
// Reparenting (attach/detach dev) is rare and self-heals via the 60s TTL, so a brief stale
|
||||
// mapping only mis-attributes usage for <60s across other instances.
|
||||
pub static ref BILLING_WORKSPACE_CACHE: Cache<String, (String, i64)> = Cache::new(5000);
|
||||
}
|
||||
|
||||
/// Resolve the "billing" workspace for `w_id`: the root ancestor of the fork/dev chain (the
|
||||
/// workspace whose plan and usage a fork draws from). Returns `w_id` unchanged for a standalone
|
||||
/// workspace, an unknown id, or a (malformed) cyclic chain.
|
||||
///
|
||||
/// Unauthenticated metering helper: it only reads the parent chain and returns another workspace id,
|
||||
/// so callers must already be authorized for `w_id` (or run in trusted server-side code); `w_id` is
|
||||
/// expected to be a server-side id, not raw user input.
|
||||
#[cfg(feature = "cloud")]
|
||||
pub async fn get_billing_workspace_id(db: &crate::DB, w_id: &str) -> Result<String> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
if let Some((root, expiry)) = BILLING_WORKSPACE_CACHE.get(w_id) {
|
||||
if expiry > now {
|
||||
return Ok(root);
|
||||
}
|
||||
}
|
||||
|
||||
// The depth bound is a cycle-safety backstop kept well above the enforced `MAX_FORK_DEPTH`, so a
|
||||
// truncated (root-not-found) result — which would fall back to `w_id` and mis-attribute billing —
|
||||
// is unreachable for any real hierarchy; only a malformed cycle could hit it.
|
||||
let root = sqlx::query_scalar!(
|
||||
r#"
|
||||
WITH RECURSIVE chain AS (
|
||||
SELECT id, parent_workspace_id, 0 AS depth
|
||||
FROM workspace WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_workspace_id, chain.depth + 1
|
||||
FROM workspace w
|
||||
JOIN chain ON w.id = chain.parent_workspace_id
|
||||
WHERE chain.depth < 20
|
||||
)
|
||||
SELECT id AS "id!" FROM chain WHERE parent_workspace_id IS NULL LIMIT 1
|
||||
"#,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("resolving billing workspace for {w_id}: {e:#}")))?
|
||||
.unwrap_or_else(|| w_id.to_string());
|
||||
|
||||
BILLING_WORKSPACE_CACHE.insert(w_id.to_string(), (root.clone(), now + 60));
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Invalidate the billing-workspace mapping for a workspace (call after reparenting it).
|
||||
#[cfg(feature = "cloud")]
|
||||
pub fn invalidate_billing_workspace_cache(w_id: &str) {
|
||||
BILLING_WORKSPACE_CACHE.remove(w_id);
|
||||
}
|
||||
|
||||
/// Invalidate the cached team-plan (premium/past-due) status for a workspace. `TEAM_PLAN_CACHE` has
|
||||
/// no TTL — it's only evicted by the premium-change NOTIFY — so call this when a workspace id is
|
||||
/// permanently deleted, otherwise a reused id could inherit the old workspace's premium status.
|
||||
#[cfg(feature = "cloud")]
|
||||
pub fn invalidate_team_plan_cache(w_id: &str) {
|
||||
TEAM_PLAN_CACHE.remove(w_id);
|
||||
}
|
||||
|
||||
/// Depth of `w_id` in its fork chain: 0 for a root (no parent), 1 for a direct fork, and so on. Walks
|
||||
/// the parent chain up to the root. The recursion bound is a cycle-safety backstop set well above the
|
||||
/// enforced `MAX_FORK_DEPTH`; a (malformed) cyclic chain saturates it and so reads as "too deep",
|
||||
/// which safely rejects rather than allows.
|
||||
///
|
||||
/// Unauthenticated helper: reads workspace hierarchy for any `w_id`, so callers must already be
|
||||
/// authorized for that workspace (or run in trusted server-side code).
|
||||
pub async fn fork_chain_depth(db: &crate::DB, w_id: &str) -> Result<i64> {
|
||||
let depth = sqlx::query_scalar!(
|
||||
r#"
|
||||
WITH RECURSIVE chain AS (
|
||||
SELECT id, parent_workspace_id, 0 AS depth
|
||||
FROM workspace WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.parent_workspace_id, chain.depth + 1
|
||||
FROM workspace w
|
||||
JOIN chain ON w.id = chain.parent_workspace_id
|
||||
WHERE chain.depth < 20
|
||||
)
|
||||
SELECT COALESCE(MAX(depth), 0)::bigint AS "depth!" FROM chain
|
||||
"#,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("computing fork depth for {w_id}: {e:#}")))?;
|
||||
Ok(depth)
|
||||
}
|
||||
|
||||
/// Height of the fork subtree rooted at `w_id`: 0 when it has no live child forks, 1 with direct
|
||||
/// children, and so on. Used so that attaching a candidate which already has its own child forks can't
|
||||
/// push the family past the depth limit.
|
||||
///
|
||||
/// Unauthenticated helper: reads workspace hierarchy for any `w_id`, so callers must already be
|
||||
/// authorized for that workspace (or run in trusted server-side code).
|
||||
pub async fn fork_subtree_height(db: &crate::DB, w_id: &str) -> Result<i64> {
|
||||
// The `deleted` filter is applied in the outer aggregation (not the recursive step, matching
|
||||
// count_workspace_forks) so a live descendant under a soft-deleted intermediate is still measured
|
||||
// at its true depth rather than pruned — otherwise the height could be underestimated and let the
|
||||
// resulting chain exceed the depth limit.
|
||||
let height = sqlx::query_scalar!(
|
||||
r#"
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, deleted, 0 AS depth FROM workspace WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.deleted, tree.depth + 1 FROM workspace w
|
||||
JOIN tree ON w.parent_workspace_id = tree.id
|
||||
WHERE tree.depth < 20
|
||||
)
|
||||
SELECT COALESCE(MAX(depth) FILTER (WHERE NOT deleted), 0)::bigint AS "height!" FROM tree
|
||||
"#,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("computing fork subtree height for {w_id}: {e:#}")))?;
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
/// Ids of every fork/dev workspace anywhere under `w_id` (excludes `w_id` itself), including live
|
||||
/// descendants beneath a soft-deleted intermediate. Used to invalidate per-workspace caches for a
|
||||
/// whole subtree after its ancestor is reparented.
|
||||
///
|
||||
/// Unauthenticated helper: reads workspace hierarchy for any `w_id`, so callers must already be
|
||||
/// authorized for that workspace (or run in trusted server-side code).
|
||||
pub async fn list_fork_descendants(db: &crate::DB, w_id: &str) -> Result<Vec<String>> {
|
||||
let ids = sqlx::query_scalar!(
|
||||
r#"
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, 0 AS depth FROM workspace WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, tree.depth + 1 FROM workspace w
|
||||
JOIN tree ON w.parent_workspace_id = tree.id
|
||||
WHERE tree.depth < 20
|
||||
)
|
||||
SELECT id AS "id!" FROM tree WHERE id != $1
|
||||
"#,
|
||||
w_id
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("listing fork descendants of {w_id}: {e:#}")))?;
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Count non-deleted fork/dev workspaces anywhere under `root` (excludes `root` itself).
|
||||
///
|
||||
/// Unauthenticated metering helper: it reads workspace hierarchy for any `root` id, so callers must
|
||||
/// already be authorized for that workspace (or run in trusted server-side code). `root` is expected
|
||||
/// to be a server-resolved id, never raw user input.
|
||||
#[cfg(feature = "cloud")]
|
||||
pub async fn count_workspace_forks(db: &crate::DB, root: &str) -> Result<i64> {
|
||||
// The `deleted` filter is on the outer SELECT (not the recursive step) so that a live sub-fork
|
||||
// whose intermediate parent was soft-deleted is still counted rather than pruned with it. The
|
||||
// depth bound is a cycle-safety backstop kept well above the enforced `MAX_FORK_DEPTH`, so
|
||||
// descendants are never silently dropped from the cap count.
|
||||
let count = sqlx::query_scalar!(
|
||||
r#"
|
||||
WITH RECURSIVE tree AS (
|
||||
SELECT id, deleted, 0 AS depth FROM workspace WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT w.id, w.deleted, tree.depth + 1 FROM workspace w
|
||||
JOIN tree ON w.parent_workspace_id = tree.id
|
||||
WHERE tree.depth < 20
|
||||
)
|
||||
SELECT COUNT(DISTINCT id) AS "count!" FROM tree WHERE id != $1 AND NOT deleted
|
||||
"#,
|
||||
root
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("counting forks of {root}: {e:#}")))?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Approximate paid seats of a workspace as `ceil(developers + operators/2)`, excluding disabled and
|
||||
/// service-account members. Reuses billing's author/operator weighting, but counts provisioned
|
||||
/// members rather than the active-user population billing meters, so it only ever loosens the fork
|
||||
/// cap (never blocks a paid seat) — good enough for a soft guardrail.
|
||||
///
|
||||
/// Unauthenticated metering helper: reads member counts for any `w_id`, so callers must already be
|
||||
/// authorized for that workspace (or run in trusted server-side code).
|
||||
#[cfg(feature = "cloud")]
|
||||
pub async fn count_paid_seats(db: &crate::DB, w_id: &str) -> Result<i64> {
|
||||
let row = sqlx::query!(
|
||||
r#"SELECT
|
||||
COUNT(*) FILTER (WHERE NOT operator AND NOT disabled AND NOT is_service_account) AS "developers!",
|
||||
COUNT(*) FILTER (WHERE operator AND NOT disabled AND NOT is_service_account) AS "operators!"
|
||||
FROM usr WHERE workspace_id = $1"#,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("counting paid seats of {w_id}: {e:#}")))?;
|
||||
Ok(((row.developers as f64) + 0.5 * (row.operators as f64)).ceil() as i64)
|
||||
}
|
||||
|
||||
#[cfg(feature = "cloud")]
|
||||
pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result<TeamPlanStatus> {
|
||||
let cached = TEAM_PLAN_CACHE.get(_w_id);
|
||||
// A fork/dev workspace draws its plan from the root (billing) workspace. Resolve to the root and
|
||||
// key the cache by it: the premium-change NOTIFY is keyed by the workspace whose premium row
|
||||
// changed (the root), so keying by root keeps invalidation correct and lets forks share it.
|
||||
let billing_w_id = get_billing_workspace_id(_db, _w_id).await?;
|
||||
let cached = TEAM_PLAN_CACHE.get(&billing_w_id);
|
||||
if let Some(cached) = cached {
|
||||
return Ok(cached);
|
||||
}
|
||||
@@ -368,7 +574,7 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result<TeamPl
|
||||
WHERE
|
||||
w.id = $1
|
||||
"#,
|
||||
_w_id
|
||||
billing_w_id
|
||||
)
|
||||
.fetch_optional(_db)
|
||||
.await
|
||||
@@ -380,13 +586,13 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result<TeamPl
|
||||
)
|
||||
.notify(|err, dur| {
|
||||
tracing::error!(
|
||||
"Failed to get team plan status for workspace {_w_id} (will retry in {dur:?}): {err:#}"
|
||||
"Failed to get team plan status for workspace {billing_w_id} (will retry in {dur:?}): {err:#}"
|
||||
);
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
Error::internal_err(format!(
|
||||
"Failed to get team plan status for workspace {_w_id} after 10 retries: {err:#}"
|
||||
"Failed to get team plan status for workspace {billing_w_id} after 10 retries: {err:#}"
|
||||
))
|
||||
})?
|
||||
.unwrap_or_else(|| TeamPlanStatus {
|
||||
@@ -395,7 +601,7 @@ pub async fn get_team_plan_status(_db: &crate::DB, _w_id: &str) -> Result<TeamPl
|
||||
max_tolerated_executions: None,
|
||||
});
|
||||
|
||||
TEAM_PLAN_CACHE.insert(_w_id.to_string(), team_plan_info.clone());
|
||||
TEAM_PLAN_CACHE.insert(billing_w_id, team_plan_info.clone());
|
||||
|
||||
Ok(team_plan_info)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
#![cfg(feature = "cloud")]
|
||||
//! Tests for the fork/dev "billing workspace" resolution and the fork-cap seat/count helpers.
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::workspaces::{
|
||||
count_paid_seats, count_workspace_forks, fork_chain_depth, fork_subtree_height,
|
||||
get_billing_workspace_id, invalidate_billing_workspace_cache, list_fork_descendants,
|
||||
};
|
||||
|
||||
async fn insert_ws(db: &Pool<Postgres>, id: &str, parent: Option<&str>, deleted: bool) {
|
||||
sqlx::query(
|
||||
"INSERT INTO workspace (id, name, owner, parent_workspace_id, deleted)
|
||||
VALUES ($1, $1, 'test-user', $2, $3)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(parent)
|
||||
.bind(deleted)
|
||||
.execute(db)
|
||||
.await
|
||||
.expect("insert workspace");
|
||||
// The resolver caches per id (60s TTL) in a process-global cache shared across tests, so drop
|
||||
// any stale mapping for this id before the test reads it.
|
||||
invalidate_billing_workspace_cache(id);
|
||||
}
|
||||
|
||||
async fn insert_member(
|
||||
db: &Pool<Postgres>,
|
||||
w_id: &str,
|
||||
email: &str,
|
||||
operator: bool,
|
||||
disabled: bool,
|
||||
is_service_account: bool,
|
||||
) {
|
||||
// `usr.username` has a `proper_username` check (no `@`), so derive one from the email prefix.
|
||||
let username = email.split('@').next().unwrap();
|
||||
sqlx::query(
|
||||
"INSERT INTO usr (workspace_id, email, username, is_admin, operator, disabled, is_service_account, role)
|
||||
VALUES ($1, $2, $3, false, $4, $5, $6, 'Developer')",
|
||||
)
|
||||
.bind(w_id)
|
||||
.bind(email)
|
||||
.bind(username)
|
||||
.bind(operator)
|
||||
.bind(disabled)
|
||||
.bind(is_service_account)
|
||||
.execute(db)
|
||||
.await
|
||||
.expect("insert usr");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn billing_workspace_resolves_to_root(db: Pool<Postgres>) {
|
||||
insert_ws(&db, "bwt-root", None, false).await;
|
||||
insert_ws(&db, "bwt-fork", Some("bwt-root"), false).await;
|
||||
insert_ws(&db, "bwt-grandchild", Some("bwt-fork"), false).await;
|
||||
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "bwt-root").await.unwrap(),
|
||||
"bwt-root"
|
||||
);
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "bwt-fork").await.unwrap(),
|
||||
"bwt-root"
|
||||
);
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "bwt-grandchild")
|
||||
.await
|
||||
.unwrap(),
|
||||
"bwt-root"
|
||||
);
|
||||
// Unknown / orphaned ids resolve to themselves.
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "bwt-missing").await.unwrap(),
|
||||
"bwt-missing"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn billing_workspace_survives_cycles(db: Pool<Postgres>) {
|
||||
insert_ws(&db, "bwc-a", None, false).await;
|
||||
insert_ws(&db, "bwc-b", Some("bwc-a"), false).await;
|
||||
// Introduce a cycle a -> b -> a; no row has a NULL parent, so resolution falls back to the input
|
||||
// and the depth guard keeps it from looping forever.
|
||||
sqlx::query("UPDATE workspace SET parent_workspace_id = 'bwc-b' WHERE id = 'bwc-a'")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
invalidate_billing_workspace_cache("bwc-a");
|
||||
invalidate_billing_workspace_cache("bwc-b");
|
||||
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "bwc-a").await.unwrap(),
|
||||
"bwc-a"
|
||||
);
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "bwc-b").await.unwrap(),
|
||||
"bwc-b"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn paid_seats_and_fork_count(db: Pool<Postgres>) {
|
||||
insert_ws(&db, "seat-root", None, false).await;
|
||||
// 2 developers + 2 operators counted -> ceil(2 + 0.5*2) = 3.
|
||||
insert_member(&db, "seat-root", "dev1@w.dev", false, false, false).await;
|
||||
insert_member(&db, "seat-root", "dev2@w.dev", false, false, false).await;
|
||||
insert_member(&db, "seat-root", "op1@w.dev", true, false, false).await;
|
||||
insert_member(&db, "seat-root", "op2@w.dev", true, false, false).await;
|
||||
// These must NOT count towards seats.
|
||||
insert_member(&db, "seat-root", "disabled@w.dev", false, true, false).await;
|
||||
insert_member(&db, "seat-root", "svc@w.dev", false, false, true).await;
|
||||
|
||||
assert_eq!(count_paid_seats(&db, "seat-root").await.unwrap(), 3);
|
||||
|
||||
insert_ws(&db, "seat-fork1", Some("seat-root"), false).await;
|
||||
insert_ws(&db, "seat-fork2", Some("seat-root"), false).await;
|
||||
// A deleted fork itself is not counted...
|
||||
insert_ws(&db, "seat-fork-deleted", Some("seat-root"), true).await;
|
||||
// ...but a live sub-fork under it still is (deleted filter is on the outer SELECT, not the walk).
|
||||
insert_ws(&db, "seat-deleted-child", Some("seat-fork-deleted"), false).await;
|
||||
// A grandchild fork still counts.
|
||||
insert_ws(&db, "seat-fork1-child", Some("seat-fork1"), false).await;
|
||||
|
||||
// Live: fork1, fork2, fork1-child, deleted-child -> 4 (seat-fork-deleted excluded).
|
||||
assert_eq!(count_workspace_forks(&db, "seat-root").await.unwrap(), 4);
|
||||
// A standalone workspace has no forks.
|
||||
assert_eq!(count_workspace_forks(&db, "seat-fork2").await.unwrap(), 0);
|
||||
|
||||
// list_fork_descendants returns every descendant id (deleted included, for cache invalidation):
|
||||
// fork1, fork2, fork-deleted, deleted-child, fork1-child -> 5.
|
||||
let mut descendants = list_fork_descendants(&db, "seat-root").await.unwrap();
|
||||
descendants.sort();
|
||||
assert_eq!(
|
||||
descendants,
|
||||
vec![
|
||||
"seat-deleted-child",
|
||||
"seat-fork-deleted",
|
||||
"seat-fork1",
|
||||
"seat-fork1-child",
|
||||
"seat-fork2",
|
||||
]
|
||||
);
|
||||
assert!(list_fork_descendants(&db, "seat-fork2")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn billing_cache_invalidation_reflects_reparent(db: Pool<Postgres>) {
|
||||
insert_ws(&db, "inv-root-a", None, false).await;
|
||||
insert_ws(&db, "inv-root-b", None, false).await;
|
||||
insert_ws(&db, "inv-fork", Some("inv-root-a"), false).await;
|
||||
|
||||
// Resolve + cache: fork -> root-a.
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "inv-fork").await.unwrap(),
|
||||
"inv-root-a"
|
||||
);
|
||||
|
||||
// Reparent in the DB, as delete+recreate-under-another-root (or attach) would.
|
||||
sqlx::query("UPDATE workspace SET parent_workspace_id = 'inv-root-b' WHERE id = 'inv-fork'")
|
||||
.execute(&db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The cached mapping survives until invalidated (this is the staleness the delete/attach/rename
|
||||
// paths must clear).
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "inv-fork").await.unwrap(),
|
||||
"inv-root-a"
|
||||
);
|
||||
|
||||
// After invalidation (what delete_workspace / attach_dev_workspace / change_workspace_id now call),
|
||||
// it re-resolves to the new root.
|
||||
invalidate_billing_workspace_cache("inv-fork");
|
||||
assert_eq!(
|
||||
get_billing_workspace_id(&db, "inv-fork").await.unwrap(),
|
||||
"inv-root-b"
|
||||
);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn fork_depth_and_subtree_height(db: Pool<Postgres>) {
|
||||
// Chain: root -> f1 -> f2 -> f3
|
||||
insert_ws(&db, "fd-root", None, false).await;
|
||||
insert_ws(&db, "fd-f1", Some("fd-root"), false).await;
|
||||
insert_ws(&db, "fd-f2", Some("fd-f1"), false).await;
|
||||
insert_ws(&db, "fd-f3", Some("fd-f2"), false).await;
|
||||
|
||||
// Depth walks up to the root: root = 0, each fork adds one.
|
||||
assert_eq!(fork_chain_depth(&db, "fd-root").await.unwrap(), 0);
|
||||
assert_eq!(fork_chain_depth(&db, "fd-f1").await.unwrap(), 1);
|
||||
assert_eq!(fork_chain_depth(&db, "fd-f3").await.unwrap(), 3);
|
||||
// An unknown id has no chain, so depth 0 (treated as a root by the guard).
|
||||
assert_eq!(fork_chain_depth(&db, "fd-missing").await.unwrap(), 0);
|
||||
|
||||
// Height walks down: the deepest live descendant below the node.
|
||||
assert_eq!(fork_subtree_height(&db, "fd-root").await.unwrap(), 3);
|
||||
assert_eq!(fork_subtree_height(&db, "fd-f2").await.unwrap(), 1);
|
||||
assert_eq!(fork_subtree_height(&db, "fd-f3").await.unwrap(), 0);
|
||||
|
||||
// A deleted leaf doesn't add to the height.
|
||||
insert_ws(&db, "fd-f3-del", Some("fd-f3"), true).await;
|
||||
assert_eq!(fork_subtree_height(&db, "fd-f3").await.unwrap(), 0);
|
||||
|
||||
// ...but a LIVE descendant below a soft-deleted intermediate still counts at its true depth
|
||||
// (the walk traverses through the deleted node; only the aggregation filters deleted).
|
||||
insert_ws(&db, "fd-f3-live-gc", Some("fd-f3-del"), false).await;
|
||||
assert_eq!(fork_subtree_height(&db, "fd-f3").await.unwrap(), 2);
|
||||
}
|
||||
@@ -1914,22 +1914,31 @@ fn apply_completed_job_cloud_usage(
|
||||
tokio::task::spawn(async move {
|
||||
let additional_usage = _duration / 1000;
|
||||
let result = tokio::time::timeout(std::time::Duration::from_secs(10), async move {
|
||||
// Fork/dev execution-seconds meter against the root (billing) workspace; resolves to
|
||||
// `w_id` itself off-fork.
|
||||
let billing_w_id =
|
||||
windmill_common::workspaces::get_billing_workspace_id(&db, &w_id)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("Failed to resolve billing workspace for {w_id}: {e:#}");
|
||||
w_id.clone()
|
||||
});
|
||||
// Update workspace usage
|
||||
let workspace_result = sqlx::query!(
|
||||
"INSERT INTO usage (id, is_workspace, month_, usage)
|
||||
VALUES ($1, TRUE, EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date), $2)
|
||||
ON CONFLICT (id, is_workspace, month_) DO UPDATE SET usage = usage.usage + EXCLUDED.usage",
|
||||
&w_id,
|
||||
&billing_w_id,
|
||||
additional_usage as i32
|
||||
)
|
||||
.execute(&db)
|
||||
.await;
|
||||
|
||||
if let Err(e) = workspace_result {
|
||||
tracing::error!("Failed to update workspace usage for {}: {:#}", w_id, e);
|
||||
tracing::error!("Failed to update workspace usage for {}: {:#}", billing_w_id, e);
|
||||
}
|
||||
|
||||
match windmill_common::workspaces::get_team_plan_status(&db, &w_id).await {
|
||||
match windmill_common::workspaces::get_team_plan_status(&db, &billing_w_id).await {
|
||||
Ok(team_plan_status) => {
|
||||
// Update user usage for non-premium workspaces
|
||||
if !team_plan_status.premium {
|
||||
@@ -5124,8 +5133,13 @@ async fn push_inner<'c, 'd>(
|
||||
) -> Result<(Uuid, Transaction<'c, Postgres>), Error> {
|
||||
#[cfg(feature = "cloud")]
|
||||
if *CLOUD_HOSTED {
|
||||
// A fork/dev workspace draws its plan and usage from the root (billing) workspace, so its
|
||||
// executions are metered against the parent's quota/bill. Resolves to `workspace_id` itself
|
||||
// for a standalone workspace (no behavior change off-fork).
|
||||
let billing_w_id =
|
||||
windmill_common::workspaces::get_billing_workspace_id(db, workspace_id).await?;
|
||||
let team_plan_status =
|
||||
windmill_common::workspaces::get_team_plan_status(db, workspace_id).await?;
|
||||
windmill_common::workspaces::get_team_plan_status(db, &billing_w_id).await?;
|
||||
// we track only non flow steps
|
||||
let (workspace_usage, user_usage) = if !matches!(
|
||||
job_payload,
|
||||
@@ -5134,12 +5148,12 @@ async fn push_inner<'c, 'd>(
|
||||
// Check current usage with SELECT (fast, no row locks)
|
||||
// Only check user usage for non-premium workspaces
|
||||
let (current_workspace_usage, current_user_usage) =
|
||||
check_usage_limits(db, workspace_id, email, !team_plan_status.premium).await?;
|
||||
check_usage_limits(db, &billing_w_id, email, !team_plan_status.premium).await?;
|
||||
|
||||
// Spawn async task to update usage counters in the background
|
||||
increment_usage_async(
|
||||
db.clone(),
|
||||
workspace_id.to_string(),
|
||||
billing_w_id.clone(),
|
||||
if !team_plan_status.premium {
|
||||
Some(email.to_string())
|
||||
} else {
|
||||
@@ -5242,7 +5256,7 @@ async fn push_inner<'c, 'd>(
|
||||
WHERE is_workspace IS TRUE AND
|
||||
month_ = EXTRACT(YEAR FROM current_date) * 12 + EXTRACT(MONTH FROM current_date)
|
||||
AND id = $1",
|
||||
workspace_id
|
||||
billing_w_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
@@ -5269,6 +5283,13 @@ async fn push_inner<'c, 'd>(
|
||||
)));
|
||||
}
|
||||
|
||||
// These two burst guards intentionally stay keyed to `workspace_id`, not the
|
||||
// billing root: the shared caps are the monthly usage above (metered to the
|
||||
// root) and the per-user in-queue/concurrent guards further up (keyed by email,
|
||||
// global across the whole family). Keying these to the root would count the
|
||||
// root's own queue rather than this workspace's load or the family's; a true
|
||||
// family-shared burst cap would need a family-wide subquery, not worth the
|
||||
// hot-path cost for this downgrade-only soft guard.
|
||||
let in_queue_workspace = sqlx::query_scalar!(
|
||||
"SELECT COUNT(id) FROM v2_job_queue WHERE workspace_id = $1",
|
||||
workspace_id
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
} from 'lucide-svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte'
|
||||
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { isPremiumStore, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { canCreateFork } from '$lib/utils/editInFork'
|
||||
import { isCloudHosted } from '$lib/cloud'
|
||||
@@ -52,9 +52,12 @@
|
||||
const isFork = $derived(!!parentWorkspaceId)
|
||||
|
||||
// Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar.
|
||||
// When forking isn't available the diff/review surface is moot.
|
||||
// When forking isn't available the diff/review surface is moot. On cloud, forking is a
|
||||
// premium-only feature (backend caps it per paid seat).
|
||||
const forksAllowed = $derived(
|
||||
!isCloudHosted() && canCreateFork($userStore) && $workspaceStore !== 'admins'
|
||||
(!isCloudHosted() || $isPremiumStore) &&
|
||||
canCreateFork($userStore) &&
|
||||
$workspaceStore !== 'admins'
|
||||
)
|
||||
|
||||
let diffDrawer: ForkDiffDrawer | undefined = $state(undefined)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { tick, type Snippet } from 'svelte'
|
||||
import {
|
||||
enterpriseLicense,
|
||||
isPremiumStore,
|
||||
userStore,
|
||||
userWorkspaces,
|
||||
workspaceStore,
|
||||
@@ -114,12 +115,14 @@
|
||||
rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot
|
||||
)
|
||||
|
||||
// Structural gate: hidden on cloud, in the admins workspace, or when the user
|
||||
// can't fork. DisableWorkspaceForking on the active workspace (a locked prod)
|
||||
// doesn't apply when there's a dev to fork from instead — the dev isn't
|
||||
// locked, and devOfRoot only resolves when the user is a member of it.
|
||||
// Structural gate: hidden in the admins workspace, or when the user can't fork; on cloud, forking
|
||||
// is premium-only (backend caps it per paid seat). DisableWorkspaceForking on the active workspace
|
||||
// (a locked prod) doesn't apply when there's a dev to fork from instead — the dev isn't locked, and
|
||||
// devOfRoot only resolves when the user is a member of it.
|
||||
const forksGateOpen = $derived(
|
||||
!isCloudHosted() && $workspaceStore !== 'admins' && (canCreateFork($userStore) || !!devOfRoot)
|
||||
(!isCloudHosted() || $isPremiumStore) &&
|
||||
$workspaceStore !== 'admins' &&
|
||||
(canCreateFork($userStore) || !!devOfRoot)
|
||||
)
|
||||
// A fork is a new workspace, so it's subject to the community-edition cap on
|
||||
// the number of non-'admins' workspaces (backend _check_nb_of_workspaces,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
apps: QuotaInfo
|
||||
variables: QuotaInfo
|
||||
resources: QuotaInfo
|
||||
forks: QuotaInfo
|
||||
}
|
||||
| undefined = $state(undefined)
|
||||
|
||||
@@ -87,7 +88,8 @@
|
||||
{ label: 'Flows', key: 'flows', prunable: true },
|
||||
{ label: 'Apps', key: 'apps', prunable: true },
|
||||
{ label: 'Variables', key: 'variables', prunable: false },
|
||||
{ label: 'Resources', key: 'resources', prunable: false }
|
||||
{ label: 'Resources', key: 'resources', prunable: false },
|
||||
{ label: 'Forks', key: 'forks', prunable: false }
|
||||
]
|
||||
</script>
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@
|
||||
</MenuItem>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !strictWorkspaceSelect && !isCloudHosted() && canCreateFork($userStore) && $workspaceStore !== 'admins'}
|
||||
{#if !strictWorkspaceSelect && (!isCloudHosted() || $isPremiumStore) && canCreateFork($userStore) && $workspaceStore !== 'admins'}
|
||||
<div class="py-1" role="none">
|
||||
<MenuItem
|
||||
class={itemClass}
|
||||
|
||||
@@ -228,20 +228,31 @@
|
||||
}
|
||||
|
||||
async function completeFork(prefixed_id: string): Promise<void> {
|
||||
let gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
is_dev_workspace: createAsDevWorkspace,
|
||||
// Send the lock intent in this first phase too so the backend can reject a non-admin's
|
||||
// locked-dev request before any branch is created (avoids dangling branches).
|
||||
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
|
||||
lock_prod_forking: createAsDevWorkspace && lockProdForking,
|
||||
copy_members: copyMembers
|
||||
}
|
||||
})
|
||||
let gitSyncJobIds: string[]
|
||||
try {
|
||||
gitSyncJobIds = await WorkspaceService.createWorkspaceForkGitBranch({
|
||||
workspace: $workspaceStore!,
|
||||
requestBody: {
|
||||
id: prefixed_id,
|
||||
name,
|
||||
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
|
||||
is_dev_workspace: createAsDevWorkspace,
|
||||
// Send the lock intent in this first phase too so the backend can reject a non-admin's
|
||||
// locked-dev request before any branch is created (avoids dangling branches).
|
||||
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
|
||||
lock_prod_forking: createAsDevWorkspace && lockProdForking,
|
||||
copy_members: copyMembers
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
// The backend can reject here (fork cap, depth limit, premium, non-admin lock). Reset the
|
||||
// loading state and surface the error rather than leaving the button spinning.
|
||||
forkCreationError = `Failed to create fork '${prefixed_id}'`
|
||||
errorMsgs.push(e?.body ?? e ?? 'Unknown error')
|
||||
forkCreationLoading = false
|
||||
sendUserToast(`Could not create fork '${prefixed_id}' ${e?.body ?? e}`, true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
|
||||
@@ -147,8 +147,15 @@
|
||||
} catch (e) {
|
||||
console.error('Could not persist username to local storage', e)
|
||||
}
|
||||
if (isCloudHosted() && user?.is_admin) {
|
||||
isPremiumStore.set(await WorkspaceService.getIsPremium({ workspace }))
|
||||
// Populate for all members (not just admins) so non-admin developers also get premium-gated
|
||||
// affordances like the fork entry points on cloud. The `is_premium` endpoint is a boolean
|
||||
// and no longer admin-gated. Best-effort: a failure here must not block user-store init.
|
||||
if (isCloudHosted()) {
|
||||
try {
|
||||
isPremiumStore.set(await WorkspaceService.getIsPremium({ workspace }))
|
||||
} catch (e) {
|
||||
console.error('Could not fetch premium status', e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userStore.set(undefined)
|
||||
|
||||
@@ -1377,7 +1377,16 @@
|
||||
/>
|
||||
<WorkspaceRulesets />
|
||||
{:else if tab == 'premium'}
|
||||
<PremiumInfo {customer_id} {plan} />
|
||||
{#if currentWsForDevTab?.parent_workspace_id}
|
||||
<Alert type="info" title="Billing is managed on the parent workspace">
|
||||
This workspace is a fork of <b>{currentWsForDevTab.parent_workspace_id}</b>. It
|
||||
runs on the parent's plan and its executions count toward the parent's usage and
|
||||
bill, so there is no separate subscription here. Manage billing, seats, and quotas
|
||||
from the parent workspace's settings.
|
||||
</Alert>
|
||||
{:else}
|
||||
<PremiumInfo {customer_id} {plan} />
|
||||
{/if}
|
||||
{:else if tab == 'slack'}
|
||||
<SettingsPageHeader
|
||||
title="Workspace connections to Slack and Teams"
|
||||
|
||||
Reference in New Issue
Block a user