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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user