feat: instance-level ruff config auto-pulled by LSP container (#8803)

* feat: add instance-level ruff config auto-pulled by LSP container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move ruff config to new LSP tab in instance settings

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-13 12:04:49 -04:00
committed by GitHub
parent 78a877eb96
commit 3f5841f84d
7 changed files with 149 additions and 3 deletions

View File

@@ -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<DB>) -> error::Result<Response> {
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()

View File

@@ -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

View File

@@ -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")]
{

View File

@@ -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";

View File

@@ -277,6 +277,8 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_extra_index_url: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ruff_config: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub npm_config_registry: Option<StringOrSecretRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bunfig_install_scopes: Option<StringOrSecretRef>,

View File

@@ -731,6 +731,18 @@ export const settings: Record<string, Setting[]> = {
!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 <a href="https://docs.astral.sh/ruff/configuration/">ruff docs</a>',
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<string, string> = {
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<string, 'sso' | 'oauth' | 'scim'> = {
@@ -950,7 +969,8 @@ export const categoryToTabMap: Record<string, string> = {
'Private Hub': 'private_hub',
'GitHub App': 'github_enterprise_app',
WebSocket: 'websocket',
'DB Health': 'db_health'
'DB Health': 'db_health',
LSP: 'lsp'
}
export interface SearchableSettingItem {

View File

@@ -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(
[