mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-18 16:02:10 +00:00
offline (URL-bound) license keys (#9089)
* [ee] feat(license): offline (URL-bound) license keys Offline keys are a 4-segment variant for air-gapped customers — no phone-home, embedded seat/CU caps, locked to the instance's base_url. Existing 3-segment online keys are unchanged. Companion PRs: - windmill-labs/windmill-ee-private (full design + EE impl) - windmill-labs/windmill-customer-service (issuance + portal) - windmill-labs/windmill-cf-worker-keygen (signing) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] refactor(license): bind offline keys via instance hash; simpler CU enforcement - /settings/license_status now surfaces an `instance_hash` superadmins share with support when requesting an offline key - OfflineMetadata: `hash` replaces `base_url`; OfflineCapStatus reports `current_cu` (last 2min) and drops the grace-period fields - verify_license_key now takes a db so EE can recheck the hash - InstanceSetting.svelte: hash copy-block + simpler status panel - Bump ee-repo-ref Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Pulls in the current_cu clamp + prod public key restoration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] refactor(license): split instance_hash endpoint; minimal cap UI; restore workers expiry toast - `instance_hash` is no longer part of /settings/license_status responses; it lives at GET /settings/instance_hash (super-admin only) so it isn't re-emitted on every status poll. The UI doesn't show it — admins fetch it explicitly when requesting a key from support. - InstanceSetting offline cap UI is now two compact green/red status lines (Seats X.X/Y and CUs X.X/Y) placed above the action buttons, matching the existing "Latest key renewal" badge style. The block-panel is gone. - "Latest key renewal" line and the "Renew key" button are now hidden when an offline key is loaded (renewal is server-disabled for offline keys). - Restore parseLicenseKey + checkLicenseExpiration toast on /workers (works for both 3- and 4-segment keys). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Pulls in the plain-SHA256 instance hash + stats_ee revert. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Picks up the alert wording change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Picks up the instance_uid cache so the periodic verify_license_key cycle no longer hits global_settings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] refactor(license): rename /settings/license_status → /offline_license_status The endpoint was only used by the offline-license UI; the other fields it returned (license_key_id, license_key_valid, kind, offline metadata) were unused. Rename to clarify scope and flatten the response — it now returns just the OfflineCapStatus (or null when no offline license is loaded). Frontend uses `offlineCapStatus != null` as the "is offline" check. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] fix(ci): regenerate sqlx cache for the inline worker_ping query After reverting unused stats_ee helpers (fetch_worker_pings*), the inline `sqlx::query_as!(WorkerPingRecord, ...)` in get_stats_payload lost its cache entry — CI's check_ee_full + cargo_test were failing under SQLX_OFFLINE=true with E0282 type-inference errors. Re-running update_sqlx.sh regenerates the cache file under its current hash and prunes a couple of stale entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] fix(license): address cubic-bot review - get_offline_license_status: propagate enforce_offline_caps errors as 500 instead of swallowing into a "no offline license" (Option::None) response - canonical_base_url: rewrite the doc to match the actual fallback behavior (lowercase + trailing-slash strip on URL parse failure); the original cross-service contract is gone since the customer-service no longer canonicalizes (treats the instance hash as opaque) - check_seat_cap_for_new_user: take an email and short-circuit when the email is already in `usr ∪ workspace_invite` so net-zero invite upserts and invite→user transitions aren't spuriously blocked at cap. Mirrors the dedup rule the count itself uses. - Bump ee-repo-ref to pull in the EE-side change Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] chore(license): bump ee-repo-ref Picks up the exact-delta seat-cap check (replaces the simple existence short-circuit). Regenerates the new sqlx cache for the bool_and query. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [ee] fix(license): propagate get_instance_hash errors; bump ee-repo-ref - get_instance_hash: replace `.ok().flatten()` with map_err+? so DB errors during instance_uid lookup surface as 500 instead of silently returning `{"instance_hash": null}` (same pattern get_offline_license_status already uses) - Bump ee-repo-ref to pull in the enforce_offline_caps cached-state preservation Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to c6cd1afe2d9e04809b30751cd1687b28a65e62b1 This commit updates the EE repository reference after PR #566 was merged in windmill-ee-private. Previous ee-repo-ref: a6d91016ae0d43c46604313aecae3aa9c778c8e0 New ee-repo-ref: c6cd1afe2d9e04809b30751cd1687b28a65e62b1 Automated by sync-ee-ref workflow. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
+5
-5
@@ -46,11 +46,11 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT item_kind, path FROM ws_specific WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87"
|
||||
}
|
||||
-58
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "label",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "super_admin",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "owner",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expiration",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026"
|
||||
}
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4"
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT s.item_kind, s.path\n FROM ws_specific s\n WHERE s.workspace_id = $1\n AND (\n (s.item_kind = 'resource' AND EXISTS (\n SELECT 1 FROM resource r\n WHERE r.workspace_id = s.workspace_id AND r.path = s.path\n ))\n OR (s.item_kind = 'variable' AND EXISTS (\n SELECT 1 FROM variable v\n WHERE v.workspace_id = s.workspace_id AND v.path = s.path\n ))\n )\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac"
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "bool_and",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9"
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb"
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH potential AS (\n SELECT email, operator FROM usr\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "authors!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "operators!",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455"
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT memory, worker, native_mode FROM worker_ping WHERE ping_at > now() - interval '2 minutes'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "memory",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "worker",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "native_mode",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd"
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2
|
||||
c6cd1afe2d9e04809b30751cd1687b28a65e62b1
|
||||
|
||||
@@ -8,6 +8,6 @@ pub async fn set_license_key(_license_key: String, _db: Option<&windmill_common:
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", not(feature = "private")))]
|
||||
pub async fn verify_license_key() -> () {
|
||||
pub async fn verify_license_key(_db: Option<&windmill_common::db::DB>) -> () {
|
||||
// Implementation is not open source
|
||||
}
|
||||
|
||||
+1
-1
@@ -1441,7 +1441,7 @@ Windmill Community Edition {GIT_VERSION}
|
||||
tracing::error!("Failed to reload license key on agent: {e:#}");
|
||||
}
|
||||
#[cfg(feature = "enterprise")]
|
||||
ee_oss::verify_license_key().await;
|
||||
ee_oss::verify_license_key(conn.as_sql()).await;
|
||||
}
|
||||
|
||||
// update min version explicitly.
|
||||
|
||||
+19
-1
@@ -2373,7 +2373,19 @@ pub async fn monitor_db(
|
||||
let verify_license_key_f = async {
|
||||
#[cfg(feature = "enterprise")]
|
||||
if !initial_load {
|
||||
verify_license_key().await;
|
||||
verify_license_key(conn.as_sql()).await;
|
||||
}
|
||||
};
|
||||
|
||||
let enforce_offline_caps_f = async {
|
||||
#[cfg(feature = "enterprise")]
|
||||
if server_mode && !initial_load {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
// Cheap: one query for workers active in the last 2 minutes.
|
||||
if let Err(e) = windmill_common::ee_oss::enforce_offline_caps(db).await {
|
||||
tracing::error!("Failed to enforce offline license caps: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2522,6 +2534,7 @@ pub async fn monitor_db(
|
||||
vacuum_queue_f,
|
||||
expose_queue_metrics_f,
|
||||
verify_license_key_f,
|
||||
enforce_offline_caps_f,
|
||||
worker_groups_alerts_f,
|
||||
jobs_waiting_alerts_f,
|
||||
low_disk_alerts_f,
|
||||
@@ -2853,6 +2866,11 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
|
||||
|
||||
IS_SECURE.store(is_secure, Ordering::Relaxed);
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
crate::ee_oss::verify_license_key(conn.as_sql()).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ use anyhow::anyhow;
|
||||
pub async fn validate_license_key(
|
||||
_license_key: String,
|
||||
_db: Option<&windmill_common::DB>,
|
||||
) -> anyhow::Result<(String, bool)> {
|
||||
) -> anyhow::Result<(
|
||||
String,
|
||||
bool,
|
||||
Option<windmill_common::ee_oss::OfflineMetadata>,
|
||||
)> {
|
||||
// Implementation is not open source
|
||||
Err(anyhow!("License can't be validated in Windmill CE"))
|
||||
}
|
||||
|
||||
@@ -37,13 +37,13 @@ use axum::{
|
||||
use serde_json::json;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel};
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use windmill_common::secret_backend::{
|
||||
AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings,
|
||||
};
|
||||
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
|
||||
use windmill_common::{
|
||||
email_oss::send_email_plain_text,
|
||||
error::{self, JsonResult, Result},
|
||||
@@ -118,6 +118,8 @@ pub fn global_service() -> Router {
|
||||
get(get_latest_key_renewal_attempt),
|
||||
)
|
||||
.route("/renew_license_key", post(renew_license_key))
|
||||
.route("/offline_license_status", get(get_offline_license_status))
|
||||
.route("/instance_hash", get(get_instance_hash))
|
||||
.route("/customer_portal", post(create_customer_portal_session))
|
||||
.route("/test_critical_channels", post(test_critical_channels))
|
||||
.route("/critical_alerts", get(get_critical_alerts))
|
||||
@@ -340,7 +342,7 @@ pub async fn test_license_key(
|
||||
Json(TestKey { license_key }): Json<TestKey>,
|
||||
) -> error::Result<String> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
let (_, expired) = validate_license_key(license_key, Some(&db)).await?;
|
||||
let (_, expired, _offline_meta) = validate_license_key(license_key, Some(&db)).await?;
|
||||
|
||||
if expired {
|
||||
Err(error::Error::BadRequest("Expired license key".to_string()))
|
||||
@@ -349,6 +351,53 @@ pub async fn test_license_key(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct InstanceHash {
|
||||
pub instance_hash: Option<String>,
|
||||
}
|
||||
|
||||
/// Returns the live cap status for an offline license, or `null` when no
|
||||
/// offline license is loaded. Used by the superadmin settings panel.
|
||||
pub async fn get_offline_license_status(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
) -> error::JsonResult<Option<windmill_common::ee_oss::OfflineCapStatus>> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
|
||||
let offline = (**windmill_common::ee_oss::LICENSE_OFFLINE_METADATA.load()).clone();
|
||||
let is_offline = matches!(&offline, Some(m) if m.is_offline());
|
||||
|
||||
if !is_offline {
|
||||
return Ok(Json(None));
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
let cap = windmill_common::ee_oss::enforce_offline_caps(&db)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(format!("enforce_offline_caps: {e:#}")))?;
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let cap: Option<windmill_common::ee_oss::OfflineCapStatus> = None;
|
||||
|
||||
Ok(Json(cap))
|
||||
}
|
||||
|
||||
/// Returns the per-instance binding hash that goes into offline license keys.
|
||||
/// Admin invokes via `curl` with their personal token when requesting a key
|
||||
/// from support.
|
||||
pub async fn get_instance_hash(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
) -> error::JsonResult<InstanceHash> {
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
#[cfg(feature = "enterprise")]
|
||||
let hash = windmill_common::ee_oss::compute_instance_hash(&db)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(format!("compute_instance_hash: {e:#}")))?;
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
let hash: Option<String> = None;
|
||||
Ok(Json(InstanceHash { instance_hash: hash }))
|
||||
}
|
||||
|
||||
pub async fn get_local_settings(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
|
||||
@@ -5233,6 +5233,13 @@ async fn invite_user(
|
||||
|
||||
nu.email = nu.email.to_lowercase();
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
if let Some(msg) =
|
||||
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
|
||||
{
|
||||
return Err(Error::BadRequest(msg));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let already_in_workspace = sqlx::query_scalar!(
|
||||
@@ -5306,6 +5313,13 @@ async fn add_user(
|
||||
|
||||
nu.email = nu.email.to_lowercase();
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
if let Some(msg) =
|
||||
windmill_common::ee_oss::check_seat_cap_for_new_user(&db, &nu.email, nu.operator).await?
|
||||
{
|
||||
return Err(Error::BadRequest(msg));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let already_exists_email = sqlx::query_scalar!(
|
||||
|
||||
@@ -1779,6 +1779,63 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/offline_license_status:
|
||||
get:
|
||||
summary: get cap-usage status for the currently-loaded offline license
|
||||
description: |
|
||||
Returns the live cap status (seats used vs cap, current CU vs cap) for
|
||||
the offline license key currently in use. Returns `null` if no offline
|
||||
license is loaded. Super-admin only.
|
||||
operationId: getOfflineLicenseStatus
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: cap status (or null when no offline license)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
nullable: true
|
||||
properties:
|
||||
seats_used:
|
||||
type: number
|
||||
description: Author-equivalent seats consumed (authors + 0.5 × operators)
|
||||
seats_cap:
|
||||
type: integer
|
||||
author_count:
|
||||
type: integer
|
||||
operator_count:
|
||||
type: integer
|
||||
current_cu:
|
||||
type: number
|
||||
description: Sum of CU rate across workers that pinged in the last 2 minutes.
|
||||
cu_cap:
|
||||
type: number
|
||||
cu_over_cap:
|
||||
type: boolean
|
||||
|
||||
/settings/instance_hash:
|
||||
get:
|
||||
summary: per-instance binding hash for offline license issuance
|
||||
description: |
|
||||
Returns the hash a superadmin shares with Windmill support when
|
||||
requesting an offline license. Super-admin only.
|
||||
operationId: getInstanceHash
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
'200':
|
||||
description: instance hash
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
instance_hash:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
/settings/customer_portal:
|
||||
post:
|
||||
summary: create customer portal session
|
||||
|
||||
@@ -10,7 +10,11 @@ use anyhow::anyhow;
|
||||
pub async fn validate_license_key(
|
||||
_license_key: String,
|
||||
_db: Option<&crate::db::DB>,
|
||||
) -> anyhow::Result<(String, bool)> {
|
||||
) -> anyhow::Result<(
|
||||
String,
|
||||
bool,
|
||||
Option<windmill_common::ee_oss::OfflineMetadata>,
|
||||
)> {
|
||||
// Implementation is not open source
|
||||
Err(anyhow!("License can't be validated in Windmill CE"))
|
||||
}
|
||||
|
||||
@@ -18,6 +18,60 @@ lazy_static::lazy_static! {
|
||||
pub static ref LICENSE_KEY_VALID: AtomicBool = AtomicBool::new(true);
|
||||
pub static ref LICENSE_KEY_ID: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
|
||||
pub static ref LICENSE_KEY: arc_swap::ArcSwap<String> = arc_swap::ArcSwap::from_pointee("".to_string());
|
||||
pub static ref LICENSE_OFFLINE_METADATA: arc_swap::ArcSwap<Option<OfflineMetadata>> = arc_swap::ArcSwap::from_pointee(None);
|
||||
pub static ref LICENSE_OFFLINE_OVER_CU_CAP: AtomicBool = AtomicBool::new(false);
|
||||
pub static ref LICENSE_OFFLINE_LAST_STATUS: arc_swap::ArcSwap<Option<OfflineCapStatus>> = arc_swap::ArcSwap::from_pointee(None);
|
||||
pub static ref LICENSE_OFFLINE_LAST_CHECKED_AT: arc_swap::ArcSwap<Option<chrono::DateTime<chrono::Utc>>> = arc_swap::ArcSwap::from_pointee(None);
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[derive(Clone, Debug, Deserialize, serde::Serialize)]
|
||||
pub struct OfflineMetadata {
|
||||
pub v: u32,
|
||||
pub kind: String,
|
||||
pub hash: String,
|
||||
pub seats: i64,
|
||||
pub cu_limit: f64,
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
impl OfflineMetadata {
|
||||
pub fn is_offline(&self) -> bool {
|
||||
self.kind == "offline"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
#[derive(Clone, Debug, serde::Serialize)]
|
||||
pub struct OfflineCapStatus {
|
||||
pub seats_used: f64,
|
||||
pub seats_cap: i64,
|
||||
pub author_count: i64,
|
||||
pub operator_count: i64,
|
||||
pub current_cu: f64,
|
||||
pub cu_cap: f64,
|
||||
pub cu_over_cap: bool,
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", not(feature = "private")))]
|
||||
pub async fn check_seat_cap_for_new_user(
|
||||
_db: &DB,
|
||||
_email: &str,
|
||||
_new_user_is_operator: bool,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", not(feature = "private")))]
|
||||
pub async fn compute_instance_hash(_db: &DB) -> anyhow::Result<Option<String>> {
|
||||
// Implementation is not open source
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "enterprise", not(feature = "private")))]
|
||||
pub async fn enforce_offline_caps(_db: &DB) -> anyhow::Result<Option<OfflineCapStatus>> {
|
||||
// Implementation is not open source
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "private"))]
|
||||
|
||||
@@ -121,6 +121,36 @@ pub const PRIVATE_HUB_MIN_VERSION: i32 = 10_000_000;
|
||||
pub const SERVICE_LOG_RETENTION_SECS: i64 = 60 * 60 * 24 * 14; // 2 weeks retention period for logs
|
||||
pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers";
|
||||
|
||||
/// Canonical form of a base URL, used as one of the inputs to the offline-license
|
||||
/// instance hash (`compute_instance_hash`).
|
||||
///
|
||||
/// Rules: lowercase scheme and host, drop default ports (80/443), strip path/query/fragment,
|
||||
/// strip trailing slash. If URL parsing fails, falls back to a best-effort lowercase +
|
||||
/// trailing-slash strip so two semantically-equivalent inputs still produce the same
|
||||
/// canonical form.
|
||||
pub fn canonical_base_url(input: &str) -> String {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
match url::Url::parse(trimmed) {
|
||||
Ok(u) => {
|
||||
let scheme = u.scheme().to_ascii_lowercase();
|
||||
let host = u
|
||||
.host_str()
|
||||
.map(|h| h.to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
let port = match (u.port(), scheme.as_str()) {
|
||||
(Some(80), "http") | (Some(443), "https") => String::new(),
|
||||
(Some(p), _) => format!(":{p}"),
|
||||
(None, _) => String::new(),
|
||||
};
|
||||
format!("{scheme}://{host}{port}")
|
||||
}
|
||||
Err(_) => trimmed.trim_end_matches('/').to_ascii_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if the user is allowed to preserve on_behalf_of values (admin or deployer).
|
||||
pub fn can_preserve_on_behalf_of(authed: &impl db::Authable) -> bool {
|
||||
authed.is_admin() || authed.groups().iter().any(|g| g == &WM_DEPLOYERS_GROUP)
|
||||
|
||||
@@ -447,7 +447,9 @@ pub async fn get_license_id_or_uid<'c, E: sqlx::Executor<'c, Database = Postgres
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(db: E) -> Result<String> {
|
||||
pub async fn get_instance_uid<'c, E: sqlx::Executor<'c, Database = Postgres>>(
|
||||
db: E,
|
||||
) -> Result<String> {
|
||||
let uid_value = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = $1",
|
||||
UNIQUE_ID_SETTING
|
||||
|
||||
@@ -56,6 +56,16 @@
|
||||
attempted_at: string
|
||||
} | null = $state(null)
|
||||
|
||||
let offlineCapStatus: {
|
||||
seats_used: number
|
||||
seats_cap: number
|
||||
author_count: number
|
||||
operator_count: number
|
||||
current_cu: number
|
||||
cu_cap: number
|
||||
cu_over_cap: boolean
|
||||
} | null = $state(null)
|
||||
|
||||
function showSetting(setting: string, values: Record<string, any>) {
|
||||
if (setting == 'dev_instance') {
|
||||
if (values['license_key'] == undefined) {
|
||||
@@ -72,6 +82,14 @@
|
||||
latestKeyRenewalAttempt = await SettingService.getLatestKeyRenewalAttempt()
|
||||
}
|
||||
|
||||
async function reloadLicenseStatus() {
|
||||
try {
|
||||
offlineCapStatus = (await SettingService.getOfflineLicenseStatus()) as any
|
||||
} catch {
|
||||
offlineCapStatus = null
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadLicenseKey() {
|
||||
$values['license_key'] = await SettingService.getGlobal({
|
||||
key: 'license_key'
|
||||
@@ -80,7 +98,10 @@
|
||||
|
||||
$effect(() => {
|
||||
if (setting.key == 'license_key') {
|
||||
untrack(() => reloadKeyrenewalAttemptInfo())
|
||||
untrack(() => {
|
||||
reloadKeyrenewalAttemptInfo()
|
||||
reloadLicenseStatus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -430,7 +451,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if latestKeyRenewalAttempt}
|
||||
{#if latestKeyRenewalAttempt && !offlineCapStatus}
|
||||
{@const attemptedAt = new Date(latestKeyRenewalAttempt.attempted_at).toLocaleString()}
|
||||
{@const isTrial = latestKeyRenewalAttempt.result.startsWith('error: trial:')}
|
||||
<div class="relative">
|
||||
@@ -500,11 +521,41 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if offlineCapStatus}
|
||||
{@const cap = offlineCapStatus}
|
||||
{@const seatsOver = cap.seats_used > cap.seats_cap}
|
||||
{@const cuOver = cap.cu_over_cap}
|
||||
<div class="mt-1 flex flex-row items-center gap-2 text-xs">
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
{#if seatsOver}
|
||||
<BadgeX class="text-red-600" size={12} />
|
||||
{:else}
|
||||
<BadgeCheck class="text-green-600" size={12} />
|
||||
{/if}
|
||||
<span class={seatsOver ? 'text-red-600' : 'text-green-600'}>
|
||||
Seats: {cap.seats_used.toFixed(1)} / {cap.seats_cap}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
{#if cuOver}
|
||||
<BadgeX class="text-red-600" size={12} />
|
||||
{:else}
|
||||
<BadgeCheck class="text-green-600" size={12} />
|
||||
{/if}
|
||||
<span class={cuOver ? 'text-red-600' : 'text-green-600'}>
|
||||
CUs: {cap.current_cu.toFixed(2)} / {cap.cu_cap.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if valid || expiration}
|
||||
<div class="flex flex-row gap-2 mt-1">
|
||||
<Button on:click={renewLicenseKey} loading={renewing} size="xs" variant="accent"
|
||||
>Renew key
|
||||
</Button>
|
||||
{#if !offlineCapStatus}
|
||||
<Button on:click={renewLicenseKey} loading={renewing} size="xs" variant="accent"
|
||||
>Renew key
|
||||
</Button>
|
||||
{/if}
|
||||
<Button variant="accent" size="xs" loading={opening} on:click={openCustomerPortal}>
|
||||
Open customer portal
|
||||
</Button>
|
||||
|
||||
@@ -194,19 +194,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
let defaultTagPerWorkspace: boolean | undefined = $state(undefined)
|
||||
let defaultTagWorkspaces: string[] = $state([])
|
||||
async function loadDefaultTagsPerWorkspace() {
|
||||
try {
|
||||
defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace()
|
||||
defaultTagWorkspaces = (await SettingService.getGlobal({
|
||||
key: DEFAULT_TAGS_WORKSPACES_SETTING
|
||||
})) as any
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load default tag per workspace setting: ${err}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
function parseLicenseKey(key: string): {
|
||||
valid: boolean
|
||||
expiration?: Date
|
||||
@@ -247,13 +234,11 @@
|
||||
const { valid, expiration } = parseLicenseKey(licenseKey)
|
||||
|
||||
if (!valid && expiration) {
|
||||
// License is expired
|
||||
sendUserToast(
|
||||
`Enterprise license key expired on ${expiration.toLocaleDateString()}. Please renew your license key to continue using Windmill.`,
|
||||
true
|
||||
)
|
||||
} else if (expiration) {
|
||||
// Check if expires within 7 days
|
||||
const daysUntilExpiration = Math.floor(
|
||||
(expiration.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
)
|
||||
@@ -266,11 +251,23 @@
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently fail - don't show errors for license check
|
||||
console.error('Failed to check license expiration:', err)
|
||||
}
|
||||
}
|
||||
|
||||
let defaultTagPerWorkspace: boolean | undefined = $state(undefined)
|
||||
let defaultTagWorkspaces: string[] = $state([])
|
||||
async function loadDefaultTagsPerWorkspace() {
|
||||
try {
|
||||
defaultTagPerWorkspace = await WorkerService.isDefaultTagsPerWorkspace()
|
||||
defaultTagWorkspaces = (await SettingService.getGlobal({
|
||||
key: DEFAULT_TAGS_WORKSPACES_SETTING
|
||||
})) as any
|
||||
} catch (err) {
|
||||
sendUserToast(`Could not load default tag per workspace setting: ${err}`, true)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
intervalId = setInterval(() => {
|
||||
loadWorkers()
|
||||
|
||||
Reference in New Issue
Block a user