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:
hugocasa
2026-05-12 00:09:21 +02:00
committed by GitHub
parent 1abfe9de39
commit 9c6cd8c852
24 changed files with 396 additions and 196 deletions
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
@@ -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"
}
@@ -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"
}
@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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
View File
@@ -1 +1 @@
a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2
c6cd1afe2d9e04809b30751cd1687b28a65e62b1
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+5 -1
View File
@@ -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"))
}
+51 -2
View File
@@ -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!(
+57
View File
@@ -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
+5 -1
View File
@@ -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"))
}
+54
View File
@@ -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"))]
+30
View File
@@ -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)
+3 -1
View File
@@ -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()