diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index dbd61531bf..a4f46c17f3 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -52,13 +52,49 @@ use windmill_common::{ AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, - HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING, + HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, RUFF_CONFIG_SETTING, WS_BASE_URL_SETTING, }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, }; use windmill_common::{error::to_anyhow, PgDatabase}; +/// Unauthenticated settings routes. +/// +/// Used by the extra container (LSP service) to fetch non-sensitive instance +/// configuration like the shared ruff.toml content without needing to carry +/// a credential. +pub fn unauthed_service() -> Router { + Router::new().route("/ruff_config", get(get_ruff_config_unauthed)) +} + +/// Public endpoint that returns the instance-level ruff config as plain text +/// TOML. Returns an empty body when unset. +/// +/// This is intentionally unauthenticated: ruff config is lint/format policy, +/// not a credential, and the extra container needs to pull it from any +/// deployment topology (docker-compose, k8s, local dev) without the extra +/// burden of shared secrets. +async fn get_ruff_config_unauthed(Extension(db): Extension) -> error::Result { + let value = sqlx::query_scalar!( + "SELECT value FROM global_settings WHERE name = $1", + RUFF_CONFIG_SETTING + ) + .fetch_optional(&db) + .await?; + + let body = value + .and_then(|v| v.as_str().map(|s| s.to_string())) + .unwrap_or_default(); + + Ok(Response::builder() + .status(200) + .header("content-type", "text/plain; charset=utf-8") + .header("cache-control", "no-store") + .body(Body::from(body)) + .unwrap()) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index fc5033d2b0..5936fb290b 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1262,6 +1262,26 @@ paths: schema: type: string + /settings_u/ruff_config: + get: + summary: get instance ruff config (unauthenticated) + description: | + Returns the instance-level ruff.toml content as plain text. + Intentionally unauthenticated so the LSP extra container can poll it + across any deployment topology. Responds with an empty body when the + instance config is unset. Ruff configuration is lint/format policy, + not a credential. + operationId: getRuffConfig + tags: + - setting + responses: + "200": + description: ruff.toml content (may be empty) + content: + text/plain: + schema: + type: string + /settings/local: get: summary: get local settings diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 50ed10578f..b61cc526d7 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -712,6 +712,7 @@ pub async fn run_server( .nest("/tokens", token::global_service()) .nest("/concurrency_groups", concurrency_groups::global_service()) .nest("/scripts_u", scripts::global_unauthed_service()) + .nest("/settings_u", windmill_api_settings::unauthed_service()) .nest("/apps_u", { #[cfg(feature = "enterprise")] { diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 1abdd2ed7f..b0e99c4279 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -28,6 +28,7 @@ pub const EXTRA_PIP_INDEX_URL_SETTING: &str = "pip_extra_index_url"; pub const PIP_INDEX_URL_SETTING: &str = "pip_index_url"; pub const UV_INDEX_STRATEGY_SETTING: &str = "uv_index_strategy"; pub const INSTANCE_PYTHON_VERSION_SETTING: &str = "instance_python_version"; +pub const RUFF_CONFIG_SETTING: &str = "ruff_config"; pub const SCIM_TOKEN_SETTING: &str = "scim_token"; pub const SAML_METADATA_SETTING: &str = "saml_metadata"; pub const SMTP_SETTING: &str = "smtp_settings"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index aa413649cf..ebcbeae690 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -277,6 +277,8 @@ pub struct GlobalSettings { #[serde(skip_serializing_if = "Option::is_none")] pub pip_extra_index_url: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub ruff_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub npm_config_registry: Option, #[serde(skip_serializing_if = "Option::is_none")] pub bunfig_install_scopes: Option, diff --git a/frontend/src/lib/components/instanceSettings.ts b/frontend/src/lib/components/instanceSettings.ts index ad50b79ce3..b11c5c4770 100644 --- a/frontend/src/lib/components/instanceSettings.ts +++ b/frontend/src/lib/components/instanceSettings.ts @@ -731,6 +731,18 @@ export const settings: Record = { !value.endsWith('/') && !value.endsWith(' ')) } + ], + LSP: [ + { + label: 'Ruff config (ruff.toml)', + description: + 'Shared ruff.toml applied to the Python editor linter across the whole instance. The LSP container fetches this every minute and writes it next to edited files. See ruff docs', + key: 'ruff_config', + fieldType: 'codearea', + codeAreaLang: 'toml', + placeholder: 'line-length = 100\n\n[lint]\nselect = ["E", "F", "I"]\nignore = ["E501"]', + storage: 'setting' + } ] } @@ -892,6 +904,12 @@ export const instanceSettingsNavigationGroups = [ label: 'WebSocket', aiId: 'instance-settings-websocket', aiDescription: 'WebSocket connectivity test and URL override' + }, + { + id: 'lsp', + label: 'LSP', + aiId: 'instance-settings-lsp', + aiDescription: 'Language server protocol settings (ruff config, editor linting)' } ] } @@ -916,7 +934,8 @@ export const tabToCategoryMap: Record = { private_hub: 'Private Hub', github_enterprise_app: 'GitHub App', websocket: 'WebSocket', - db_health: 'DB Health' + db_health: 'DB Health', + lsp: 'LSP' } export const tabToAuthSubTab: Record = { @@ -950,7 +969,8 @@ export const categoryToTabMap: Record = { 'Private Hub': 'private_hub', 'GitHub App': 'github_enterprise_app', WebSocket: 'websocket', - 'DB Health': 'db_health' + 'DB Health': 'db_health', + LSP: 'lsp' } export interface SearchableSettingItem { diff --git a/lsp/pyls_launcher.py b/lsp/pyls_launcher.py index 7763cbaa3b..aef2dee076 100644 --- a/lsp/pyls_launcher.py +++ b/lsp/pyls_launcher.py @@ -2,6 +2,8 @@ import logging import subprocess import threading import os +import urllib.request +import urllib.error from tornado import ioloop, process, web, websocket @@ -16,6 +18,65 @@ log = logging.getLogger(__name__) logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) +# Path where ruff (spawned with workspace rooted at /tmp/monaco) will discover +# a ruff.toml. Ruff walks up from the file being linted looking for a +# ruff.toml / .ruff.toml / pyproject.toml, so dropping it here covers every +# python editor session. +RUFF_CONFIG_PATH = "/tmp/monaco/ruff.toml" +# How often to re-fetch the instance ruff config from the backend. Existing +# ruff server processes won't pick up the change mid-session, but the next +# editor reload / WebSocket reconnect will. +RUFF_CONFIG_POLL_INTERVAL_SECS = int(os.environ.get("RUFF_CONFIG_POLL_INTERVAL_SECS", "60")) + + +def _sync_ruff_config_once(): + """Fetch the instance ruff config from the windmill backend and write it + to disk. No-op when WINDMILL_BASE_URL is unset (e.g., running the LSP + standalone for local development).""" + base_url = os.environ.get("WINDMILL_BASE_URL") or os.environ.get("BASE_INTERNAL_URL") + if not base_url: + return + url = base_url.rstrip("/") + "/api/settings_u/ruff_config" + try: + with urllib.request.urlopen(url, timeout=5) as resp: + body = resp.read().decode("utf-8") + except (urllib.error.URLError, TimeoutError, OSError) as e: + log.warning("Could not fetch instance ruff config from %s: %s", url, e) + return + + try: + existing = "" + if os.path.exists(RUFF_CONFIG_PATH): + with open(RUFF_CONFIG_PATH, "r") as f: + existing = f.read() + if existing == body: + return + if body: + os.makedirs(os.path.dirname(RUFF_CONFIG_PATH), exist_ok=True) + with open(RUFF_CONFIG_PATH, "w") as f: + f.write(body) + log.info("Wrote instance ruff config to %s (%d bytes)", RUFF_CONFIG_PATH, len(body)) + elif os.path.exists(RUFF_CONFIG_PATH): + os.remove(RUFF_CONFIG_PATH) + log.info("Removed %s (instance ruff config is empty)", RUFF_CONFIG_PATH) + except OSError as e: + log.warning("Could not write ruff config to %s: %s", RUFF_CONFIG_PATH, e) + + +def start_ruff_config_poller(): + """Fetch the ruff config once synchronously, then poll in the background.""" + _sync_ruff_config_once() + + def loop(): + import time + while True: + time.sleep(RUFF_CONFIG_POLL_INTERVAL_SECS) + _sync_ruff_config_once() + + t = threading.Thread(target=loop, name="ruff-config-poller", daemon=True) + t.start() + + class LanguageServerWebSocketHandler(websocket.WebSocketHandler): """Setup tornado websocket handler to host an external language server.""" @@ -121,6 +182,11 @@ if __name__ == "__main__": f = open(go_mod_path, "w") f.write("module mymod\ngo 1.26") f.close() + + # Sync instance-level ruff config into /tmp/monaco/ruff.toml so every + # spawned `ruff server` picks it up. + start_ruff_config_poller() + port = int(os.environ.get("PORT", "3001")) app = web.Application( [