mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-25 16:02:11 +00:00
d666e8431c
* feat: read-only flag on API tokens, orthogonal to scopes Add a per-token `read_only` boolean set at creation time. When true, the token can only call HTTP methods classified as Read (GET/HEAD/OPTIONS). Mutating methods and job-run actions are rejected with 403, regardless of which scopes are attached. Surfaced as a prominent toggle in the standard token-creation flow and a discreet `2xs` toggle in MCP mode (where users often want write access, so we don't bias them toward enabling it). MCP enforcement: read-only tokens hide all script/flow/hub tools from `list_tools` and only see endpoint tools whose method is GET, and the runner rejects `call_tool` on anything mutating. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: review fixes for read-only token flag - Exempt /api/mcp/* and /mcp/* paths from the read-only middleware check. MCP transport runs over POST (streamable HTTP / SSE), so otherwise the middleware would 403 every MCP request before the runner could enforce read-only at the tool-call level. - Tighten is_endpoint_read_only to GET only, matching the read_only_hint that create_endpoint_annotations actually emits. - Add unit test for check_read_only_for_route covering GET/HEAD/OPTIONS, mutating methods, and run paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump ee-repo-ref to read-only-trigger-toggle Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): make read-only toggle discreet in both modes Match the MCP-mode treatment in standard mode: text-tertiary, 2xs, shared "Read-only" label. The tooltip switches per mode so the explanation still fits the context. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): gate read-only toggle behind Limit token permissions The read-only toggle now only shows when the user has limited the token's scopes (standard mode) or in MCP mode (which always picks an MCP scope). Turning the limit off also resets read-only so it doesn't silently stick. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(frontend): hide incompatible MCP tools when read-only is on When the read-only toggle is on in MCP mode: - Endpoint badges and the custom-mode endpoint MultiSelect filter to GET. - Already-selected non-GET endpoints are pruned from the scope. - The scripts/flows preview is replaced with a note explaining they're hidden (the runner already rejects script/flow runs for read-only). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): place read-only toggle at top of limited scope area The previous gate required at least one scope to be picked before the read-only toggle appeared, which made it look missing while the user was still building their scope list. Move the toggle inside ScopesPicker: - Standard mode: sits directly under the "Limit token permissions" toggle whenever Limit is on, before the scope selector. - MCP mode: sits at the top of the MCP scope block. readOnly is now $bindable on ScopesPicker so CreateToken still owns the value. The auto-reset on un-limit moves into ScopesPicker too. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(frontend): nest read-only toggle inside the scope list card Place the read-only toggle at the top of the scope list (between the Selected Scopes summary and the bordered domain list) via a new optional topSlot snippet on ScopeSelector. Keeps ScopeSelector decoupled from read-only specifics; ScopesPicker fills the slot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update ee-repo-ref to 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a This commit updates the EE repository reference after PR #571 was merged in windmill-ee-private. Previous ee-repo-ref: f53d26e6685dfd60bfa67686fbd7358169cfd130 New ee-repo-ref: 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a Automated by sync-ee-ref workflow. * fix: address CI review for read-only token flag - P1 (Codex): narrow the MCP middleware exemption from "any /api/mcp/*" to just the streamable HTTP transport endpoints (/api/mcp/gateway, /api/mcp/w/{ws}/{mcp,sse,list_tools}). Without this, a read-only token could POST /api/mcp/gateway/oauth/server/approve and mint a follow-on non-read-only MCP token via the OAuth code/token exchange. - P2 (Claude/cubic): fix test comment/assertion mismatch — the run-path assertion now exercises GET (which is what the RUN_PATH_ACTIONS elevation comment describes) in addition to POST. Add a regression assertion for /api/mcp/gateway/oauth/server/approve. - P2 (cubic): short-circuit script/flow/hub-script/resource fetches in MCP list_tools when read_only is on — they would only be discarded below, so skipping the DB and resource fan-out is pure win. - P2 (cubic): when scopes are pre-supplied via the CreateToken prop, the ScopesPicker isn't rendered, which previously hid the read-only toggle entirely. Render it next to the pre-supplied scopes display. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
1267 lines
45 KiB
Rust
1267 lines
45 KiB
Rust
/*
|
|
* Author: Ruben Fiszel
|
|
* Copyright: Windmill Labs, Inc 2022
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
use crate::db::ApiAuthed;
|
|
#[cfg(feature = "embedding")]
|
|
use crate::embeddings::load_embeddings_db;
|
|
#[cfg(feature = "oauth2")]
|
|
use crate::oauth2_oss::SlackVerifier;
|
|
#[cfg(feature = "smtp")]
|
|
use crate::smtp_server_oss::SmtpServer;
|
|
#[cfg(feature = "enterprise")]
|
|
use windmill_api_auth::ee_oss::ExternalJwks;
|
|
use windmill_store::resources::public_service;
|
|
|
|
#[cfg(feature = "mcp")]
|
|
use crate::mcp::{extract_and_store_workspace_id, setup_mcp_server};
|
|
use crate::triggers::start_all_listeners;
|
|
use tower_http::catch_panic::CatchPanicLayer;
|
|
|
|
use crate::tracing_init::MyOnFailure;
|
|
use crate::{
|
|
s3_log_batching::{s3_proxy_log_middleware, FLUSH_INTERVAL_MS},
|
|
tracing_init::{MyMakeSpan, MyOnResponse},
|
|
users::OptAuthed,
|
|
webhook_util::WebhookShared,
|
|
};
|
|
#[cfg(feature = "agent_worker_server")]
|
|
use windmill_api_agent_workers::AgentCache;
|
|
|
|
use anyhow::Context;
|
|
use argon2::Argon2;
|
|
use axum::body::Body;
|
|
use axum::extract::DefaultBodyLimit;
|
|
use axum::http::HeaderValue;
|
|
use axum::response::Response;
|
|
use axum::serve::ListenerExt;
|
|
use axum::{middleware::from_extractor, routing::get, routing::post, Extension, Json, Router};
|
|
use db::DB;
|
|
use tokio::task::JoinHandle;
|
|
use windmill_common::global_settings::load_value_from_global_settings;
|
|
use windmill_common::global_settings::EMAIL_DOMAIN_SETTING;
|
|
use windmill_common::worker::HUB_CACHE_DIR;
|
|
|
|
use std::fs::DirBuilder;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::sync::RwLock;
|
|
use tower::ServiceBuilder;
|
|
use tower_cookies::CookieManagerLayer;
|
|
use tower_http::{
|
|
cors::{Any, CorsLayer},
|
|
trace::TraceLayer,
|
|
};
|
|
use windmill_common::db::UserDB;
|
|
use windmill_common::worker::CLOUD_HOSTED;
|
|
#[allow(unused_imports)]
|
|
pub(crate) use windmill_common::BASE_URL;
|
|
use windmill_common::{utils::GIT_VERSION, INSTANCE_NAME};
|
|
|
|
use crate::scim_oss::has_scim_token;
|
|
use windmill_common::error::AppError;
|
|
|
|
mod ai;
|
|
mod apps;
|
|
pub mod args;
|
|
mod audit;
|
|
pub mod auth;
|
|
#[cfg(all(feature = "private", feature = "parquet"))]
|
|
pub mod azure_proxy_ee;
|
|
mod azure_proxy_oss;
|
|
#[cfg(feature = "bedrock")]
|
|
mod bedrock;
|
|
mod capture;
|
|
mod concurrency_groups;
|
|
mod db;
|
|
mod db_health;
|
|
mod google;
|
|
|
|
mod drafts;
|
|
#[cfg(feature = "private")]
|
|
pub mod ee;
|
|
pub mod ee_oss;
|
|
pub mod embeddings;
|
|
mod favorite;
|
|
pub mod flows;
|
|
mod folder_history;
|
|
mod folders;
|
|
mod granular_acls;
|
|
mod group_history;
|
|
mod groups;
|
|
mod health;
|
|
#[cfg(feature = "private")]
|
|
pub mod indexer_ee;
|
|
mod indexer_oss;
|
|
#[cfg(feature = "private")]
|
|
mod inkeep_ee;
|
|
mod inkeep_oss;
|
|
mod integration;
|
|
mod internal_db;
|
|
mod live_migrations;
|
|
#[cfg(all(feature = "private", feature = "parquet"))]
|
|
pub mod s3_proxy_ee;
|
|
mod s3_proxy_oss;
|
|
mod workspace_dependencies;
|
|
|
|
mod approvals;
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
pub mod apps_ee;
|
|
#[cfg(feature = "enterprise")]
|
|
mod apps_oss;
|
|
#[cfg(all(feature = "enterprise", feature = "private"))]
|
|
pub mod git_sync_ee;
|
|
#[cfg(feature = "enterprise")]
|
|
mod git_sync_oss;
|
|
#[cfg(all(feature = "parquet", feature = "private"))]
|
|
pub mod job_helpers_ee;
|
|
#[cfg(feature = "parquet")]
|
|
mod job_helpers_oss;
|
|
pub mod job_metrics;
|
|
pub mod jobs;
|
|
pub mod jobs_export;
|
|
#[cfg(all(feature = "oauth2", feature = "private"))]
|
|
pub mod oauth2_ee;
|
|
#[cfg(feature = "oauth2")]
|
|
pub mod oauth2_oss;
|
|
#[cfg(feature = "private")]
|
|
pub mod oidc_ee;
|
|
mod oidc_oss;
|
|
mod path_autocomplete;
|
|
mod raw_apps;
|
|
mod resources;
|
|
#[cfg(feature = "private")]
|
|
pub mod saml_ee;
|
|
mod saml_oss;
|
|
#[cfg(feature = "private")]
|
|
pub mod scim_ee;
|
|
mod scim_oss;
|
|
mod scripts;
|
|
mod secret_backend_ext;
|
|
mod service_logs;
|
|
mod slack_approvals;
|
|
#[cfg(all(feature = "smtp", feature = "private"))]
|
|
pub mod smtp_server_ee;
|
|
#[cfg(feature = "smtp")]
|
|
mod smtp_server_oss;
|
|
#[cfg(feature = "private")]
|
|
pub mod teams_approvals_ee;
|
|
mod teams_approvals_oss;
|
|
mod workspace_shared_ui;
|
|
|
|
#[cfg(feature = "native_trigger")]
|
|
pub mod native_triggers;
|
|
mod offboarding;
|
|
mod public_app_layer;
|
|
mod public_app_rate_limit;
|
|
mod s3_log_batching;
|
|
mod static_assets;
|
|
#[cfg(all(feature = "stripe", feature = "enterprise", feature = "private"))]
|
|
pub mod stripe_ee;
|
|
#[cfg(all(feature = "stripe", feature = "enterprise"))]
|
|
mod stripe_oss;
|
|
#[cfg(feature = "private")]
|
|
pub mod teams_cache_ee;
|
|
mod teams_cache_oss;
|
|
#[cfg(feature = "private")]
|
|
pub mod teams_ee;
|
|
mod teams_oss;
|
|
mod token;
|
|
mod tracing_init;
|
|
mod trash;
|
|
pub mod triggers;
|
|
mod users;
|
|
#[cfg(feature = "private")]
|
|
pub mod users_ee;
|
|
mod users_oss;
|
|
mod utils;
|
|
mod variables;
|
|
#[cfg(feature = "private")]
|
|
pub mod volumes_ee;
|
|
mod volumes_oss;
|
|
pub mod webhook_util;
|
|
mod workspaces;
|
|
#[cfg(feature = "private")]
|
|
pub mod workspaces_ee;
|
|
mod workspaces_export;
|
|
|
|
#[cfg(feature = "mcp")]
|
|
mod mcp_tools;
|
|
|
|
#[cfg(feature = "mcp")]
|
|
mod mcp;
|
|
#[cfg(all(feature = "mcp", feature = "private"))]
|
|
mod mcp_oauth_ee;
|
|
#[cfg(feature = "mcp")]
|
|
mod mcp_oauth_oss;
|
|
|
|
pub use apps::EditApp;
|
|
pub const DEFAULT_BODY_LIMIT: usize = 2097152 * 100; // 200MB
|
|
|
|
lazy_static::lazy_static! {
|
|
|
|
pub static ref REQUEST_SIZE_LIMIT: Arc<RwLock<usize>> = Arc::new(RwLock::new(DEFAULT_BODY_LIMIT));
|
|
|
|
pub static ref SCIM_TOKEN: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
|
pub static ref SAML_METADATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
|
|
|
|
|
// COOKIE_DOMAIN and IS_SECURE are now in windmill_common::utils
|
|
}
|
|
|
|
pub use windmill_common::utils::HTTP_CLIENT_PERMISSIVE as HTTP_CLIENT;
|
|
|
|
pub use windmill_common::utils::{COOKIE_DOMAIN, IS_SECURE};
|
|
|
|
pub use windmill_api_debug::reload_debug_signing_key;
|
|
|
|
#[cfg(feature = "oauth2")]
|
|
pub use windmill_oauth::OAUTH_CLIENTS;
|
|
|
|
#[cfg(feature = "oauth2")]
|
|
lazy_static::lazy_static! {
|
|
pub static ref SLACK_SIGNING_SECRET: Option<SlackVerifier> = std::env::var("SLACK_SIGNING_SECRET")
|
|
.ok()
|
|
.map(|x| SlackVerifier::new(x).unwrap());
|
|
|
|
// Comma-separated Slack v2 OAuth bot scopes requested when connecting a workspace.
|
|
// Must be a subset of the bot scopes declared in the Slack app manifest. Default matches
|
|
// Windmill's recommended manifest at docs.windmill.dev/docs/misc/setup_oauth.
|
|
pub static ref SLACK_OAUTH_SCOPES: String = std::env::var("SLACK_OAUTH_SCOPES")
|
|
.unwrap_or_else(|_| "commands,chat:write,chat:write.public,channels:join,files:write,app_mentions:read,im:history,im:read".to_string());
|
|
}
|
|
|
|
// Compliance with cloud events spec.
|
|
pub async fn add_webhook_allowed_origin(
|
|
req: axum::extract::Request,
|
|
next: axum::middleware::Next,
|
|
) -> axum::response::Response {
|
|
if req.method() == http::Method::OPTIONS {
|
|
if let Some(webhook_request_origin) = req.headers().get("Webhook-Request-Origin") {
|
|
let webhook_request_origin = webhook_request_origin.clone();
|
|
let mut response = next.run(req).await;
|
|
|
|
response
|
|
.headers_mut()
|
|
.insert("Webhook-Allowed-Origin", webhook_request_origin);
|
|
response
|
|
.headers_mut()
|
|
.insert("Webhook-Allowed-Rate", HeaderValue::from_static("*"));
|
|
return response;
|
|
}
|
|
}
|
|
next.run(req).await
|
|
}
|
|
|
|
#[cfg(not(feature = "tantivy"))]
|
|
type IndexReader = ();
|
|
|
|
#[cfg(not(feature = "tantivy"))]
|
|
type ServiceLogIndexReader = ();
|
|
|
|
#[cfg(feature = "tantivy")]
|
|
type IndexReader = windmill_indexer::completed_runs_oss::IndexReader;
|
|
#[cfg(feature = "tantivy")]
|
|
type ServiceLogIndexReader = windmill_indexer::service_logs_oss::ServiceLogIndexReader;
|
|
|
|
/// Worker name derived from the agent JWT token, used to authenticate volume operations.
|
|
/// Defined unconditionally so volume endpoint handlers can reference it regardless of
|
|
/// whether agent_worker_server is enabled (the extension is only populated on the agent path).
|
|
#[derive(Clone)]
|
|
pub struct AgentWorkerName(pub String);
|
|
|
|
/// Middleware that injects a synthetic `ApiAuthed` and JWT-derived worker name
|
|
/// into request extensions.
|
|
///
|
|
/// Used for volume proxy endpoints under the agent_workers path, where the
|
|
/// agent JWT auth layer has already validated the request. The volume handlers
|
|
/// need `ApiAuthed` to resolve the workspace S3 client, but the agent JWT
|
|
/// format is incompatible with the standard auth extractor.
|
|
///
|
|
/// The worker name is extracted from the JWT claims rather than trusting
|
|
/// self-reported values in request bodies/query params.
|
|
#[cfg(feature = "agent_worker_server")]
|
|
async fn inject_agent_authed(
|
|
request: axum::extract::Request,
|
|
next: axum::middleware::Next,
|
|
) -> Response {
|
|
let mut request = request;
|
|
|
|
// Extract worker name from agent JWT via AgentCache
|
|
// (OSS returns None; EE decodes the JWT and returns the worker name)
|
|
{
|
|
let extracted = {
|
|
let token = request
|
|
.headers()
|
|
.get(axum::http::header::AUTHORIZATION)
|
|
.and_then(|v| v.to_str().ok())
|
|
.and_then(|s| s.strip_prefix("Bearer ").map(|t| t.to_string()));
|
|
let cache = request.extensions().get::<Arc<AgentCache>>().cloned();
|
|
let db = request.extensions().get::<DB>().cloned();
|
|
match (token, cache, db) {
|
|
(Some(token), Some(cache), Some(db)) => Some((token, cache, db)),
|
|
_ => None,
|
|
}
|
|
};
|
|
|
|
if let Some((token, cache, db)) = extracted {
|
|
if let Some(worker_name) = cache.extract_worker_name(&token, &db).await {
|
|
request
|
|
.extensions_mut()
|
|
.insert(AgentWorkerName(worker_name));
|
|
}
|
|
}
|
|
}
|
|
|
|
request
|
|
.extensions_mut()
|
|
.insert(windmill_api_auth::OptJobAuthed {
|
|
authed: ApiAuthed {
|
|
email: "agent-worker@windmill.dev".to_string(),
|
|
username: "agent-worker".to_string(),
|
|
is_admin: true,
|
|
is_operator: false,
|
|
groups: Vec::new(),
|
|
folders: Vec::new(),
|
|
scopes: None,
|
|
username_override: None,
|
|
token_prefix: None,
|
|
read_only: false,
|
|
},
|
|
job_id: None,
|
|
});
|
|
next.run(request).await
|
|
}
|
|
|
|
pub async fn run_server(
|
|
db: DB,
|
|
job_index_reader: Option<IndexReader>,
|
|
log_index_reader: Option<ServiceLogIndexReader>,
|
|
listener: tokio::net::TcpListener,
|
|
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
|
port_tx: tokio::sync::oneshot::Sender<String>,
|
|
server_mode: bool,
|
|
mcp_mode: bool,
|
|
_base_internal_url: String,
|
|
name: Option<String>,
|
|
) -> anyhow::Result<()> {
|
|
let user_db = UserDB::new(db.clone());
|
|
|
|
for x in [&*HUB_CACHE_DIR] {
|
|
DirBuilder::new()
|
|
.recursive(true)
|
|
.create(x)
|
|
.expect("could not create initial server dir");
|
|
}
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
let ext_jwks = ExternalJwks::load().await;
|
|
let auth_cache = Arc::new(crate::auth::AuthCache::new(
|
|
db.clone(),
|
|
std::env::var("SUPERADMIN_SECRET").ok(),
|
|
#[cfg(feature = "enterprise")]
|
|
ext_jwks,
|
|
));
|
|
let argon2 = Arc::new(Argon2::default());
|
|
|
|
// Initialize debug signing key for debugger authentication
|
|
windmill_api_debug::init_debug_signing_key().await;
|
|
|
|
let disable_response_logs = std::env::var("DISABLE_RESPONSE_LOGS")
|
|
.ok()
|
|
.map(|x| x == "true")
|
|
.unwrap_or(false);
|
|
|
|
let middleware_stack = ServiceBuilder::new()
|
|
.layer(Extension(db.clone()))
|
|
.layer(Extension(user_db.clone()))
|
|
.layer(Extension(auth_cache.clone()))
|
|
.layer(Extension(job_index_reader))
|
|
.layer(Extension(log_index_reader))
|
|
// .layer(Extension(index_writer))
|
|
.layer(CookieManagerLayer::new())
|
|
.layer(Extension(WebhookShared::new(
|
|
killpill_rx.resubscribe(),
|
|
db.clone(),
|
|
)))
|
|
.layer(DefaultBodyLimit::max(
|
|
REQUEST_SIZE_LIMIT.read().await.clone(),
|
|
));
|
|
|
|
let request_size_limit = REQUEST_SIZE_LIMIT.read().await.clone();
|
|
|
|
let cors = CorsLayer::new()
|
|
.allow_methods([http::Method::GET, http::Method::POST, http::Method::DELETE])
|
|
.allow_headers([http::header::CONTENT_TYPE, http::header::AUTHORIZATION])
|
|
.allow_origin(Any);
|
|
|
|
let sp_extension = Arc::new(saml_oss::build_sp_extension().await?);
|
|
|
|
if server_mode {
|
|
#[cfg(feature = "embedding")]
|
|
load_embeddings_db(&db);
|
|
|
|
#[cfg(feature = "cloud")]
|
|
if *CLOUD_HOSTED {
|
|
windmill_queue::init_usage_buffer(db.clone());
|
|
}
|
|
|
|
let mut start_smtp_server = false;
|
|
if let Some(smtp_settings) =
|
|
load_value_from_global_settings(&db, EMAIL_DOMAIN_SETTING).await?
|
|
{
|
|
if smtp_settings.as_str().unwrap_or("") != "" {
|
|
start_smtp_server = true;
|
|
}
|
|
}
|
|
if !start_smtp_server {
|
|
tracing::info!("SMTP server not started because email domain is not set");
|
|
} else {
|
|
#[cfg(feature = "smtp")]
|
|
{
|
|
let smtp_server = Arc::new(SmtpServer {
|
|
db: db.clone(),
|
|
user_db: user_db.clone(),
|
|
auth_cache: auth_cache.clone(),
|
|
base_internal_url: _base_internal_url.clone(),
|
|
});
|
|
let addr = listener
|
|
.local_addr()
|
|
.unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
|
|
if let Err(err) = smtp_server.start_listener_thread(addr).await {
|
|
tracing::error!("Error starting SMTP server: {err:#}");
|
|
}
|
|
}
|
|
#[cfg(not(feature = "smtp"))]
|
|
{
|
|
tracing::info!("SMTP server not started because SMTP feature is not enabled");
|
|
}
|
|
}
|
|
}
|
|
|
|
let job_helpers_service = {
|
|
#[cfg(feature = "parquet")]
|
|
{
|
|
job_helpers_oss::workspaced_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "parquet"))]
|
|
{
|
|
Router::new()
|
|
}
|
|
};
|
|
|
|
// Initialize HTTP trigger refresh loop
|
|
#[cfg(feature = "http_trigger")]
|
|
{
|
|
let http_killpill_rx = killpill_rx.resubscribe();
|
|
triggers::http::refresh_routers_loop(&db, http_killpill_rx).await;
|
|
}
|
|
|
|
let triggers_service = triggers::generate_trigger_routers();
|
|
|
|
if !*CLOUD_HOSTED && server_mode && !mcp_mode {
|
|
start_all_listeners(db.clone(), &killpill_rx);
|
|
}
|
|
|
|
if server_mode {
|
|
health::start_health_check_loop(db.clone(), killpill_rx.resubscribe());
|
|
}
|
|
|
|
let port = listener.local_addr().map(|x| x.port()).unwrap_or(8000);
|
|
let ip = listener
|
|
.local_addr()
|
|
.map(|x| x.ip().to_string())
|
|
.unwrap_or("localhost".to_string());
|
|
|
|
// Setup MCP server
|
|
#[allow(unused_variables)]
|
|
let (mcp_router, gateway_mcp_router, mcp_cancellation_token) = {
|
|
#[cfg(feature = "mcp")]
|
|
if server_mode || mcp_mode {
|
|
use mcp::{
|
|
add_www_authenticate_header, add_www_authenticate_header_gateway,
|
|
extract_workspace_from_token,
|
|
};
|
|
let (mcp_router, mcp_cancellation_token) =
|
|
setup_mcp_server(db.clone(), user_db, _base_internal_url.clone()).await?;
|
|
// Workspace-scoped MCP router
|
|
let workspaced_mcp_router = mcp_router
|
|
.clone()
|
|
.route_layer(from_extractor::<ApiAuthed>())
|
|
.layer(axum::middleware::from_fn(add_www_authenticate_header))
|
|
.layer(axum::middleware::from_fn(extract_and_store_workspace_id));
|
|
// Gateway MCP router — resolves workspace from token
|
|
let gateway_mcp_router = mcp_router
|
|
.route_layer(from_extractor::<ApiAuthed>())
|
|
.layer(axum::middleware::from_fn(
|
|
add_www_authenticate_header_gateway,
|
|
))
|
|
.layer(axum::middleware::from_fn(extract_workspace_from_token));
|
|
(
|
|
workspaced_mcp_router,
|
|
gateway_mcp_router,
|
|
Some(mcp_cancellation_token),
|
|
)
|
|
} else {
|
|
(Router::new(), Router::new(), None)
|
|
}
|
|
|
|
#[cfg(not(feature = "mcp"))]
|
|
(Router::new(), Router::new(), Option::<()>::None)
|
|
};
|
|
|
|
let mcp_list_tools_service = {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
mcp::list_tools_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "mcp"))]
|
|
{
|
|
Router::new()
|
|
}
|
|
};
|
|
|
|
#[cfg(feature = "agent_worker_server")]
|
|
let (agent_workers_router, agent_workers_bg_processor, agent_workers_job_completed_tx) =
|
|
if server_mode {
|
|
windmill_api_agent_workers::workspaced_service(db.clone(), _base_internal_url.clone())
|
|
} else {
|
|
(Router::new(), vec![], None)
|
|
};
|
|
|
|
#[cfg(feature = "agent_worker_server")]
|
|
let agent_cache = Arc::new(AgentCache::new());
|
|
|
|
// build our application with a route
|
|
let app = Router::new()
|
|
.nest(
|
|
"/api",
|
|
Router::new()
|
|
.nest(
|
|
"/w/{workspace_id}",
|
|
Router::new()
|
|
// Reordered alphabetically
|
|
.nest("/acls", granular_acls::workspaced_service())
|
|
.nest("/apps", apps::workspaced_service(request_size_limit * 5))
|
|
.nest("/assets", windmill_api_assets::workspaced_service())
|
|
.nest("/audit", audit::workspaced_service())
|
|
.nest("/capture", capture::workspaced_service())
|
|
.nest(
|
|
"/concurrency_groups",
|
|
concurrency_groups::workspaced_service(),
|
|
)
|
|
.nest("/embeddings", embeddings::workspaced_service())
|
|
.nest("/drafts", drafts::workspaced_service())
|
|
.nest("/favorites", favorite::workspaced_service())
|
|
.nest("/flows", flows::workspaced_service())
|
|
.nest(
|
|
"/workspace_dependencies",
|
|
workspace_dependencies::workspaced_service(),
|
|
)
|
|
.nest(
|
|
"/flow_conversations",
|
|
windmill_api_flow_conversations::workspaced_service(),
|
|
)
|
|
.nest("/folders", folders::workspaced_service())
|
|
.nest("/folders_history", folder_history::workspaced_service())
|
|
.nest("/groups", groups::workspaced_service())
|
|
.nest("/groups_history", group_history::workspaced_service())
|
|
.nest("/inputs", windmill_api_inputs::workspaced_service())
|
|
.nest("/internal_db", internal_db::workspaced_service())
|
|
.route("/labels/list", get(list_workspace_labels))
|
|
.nest("/job_metrics", job_metrics::workspaced_service())
|
|
.nest("/job_helpers", job_helpers_service)
|
|
.nest("/jobs", jobs::workspaced_service())
|
|
.nest("/debug", windmill_api_debug::workspaced_service())
|
|
.nest("/native_triggers", {
|
|
#[cfg(feature = "native_trigger")]
|
|
{
|
|
native_triggers::handler::generate_native_trigger_routers().merge(
|
|
native_triggers::workspace_integrations::workspaced_service(),
|
|
)
|
|
}
|
|
#[cfg(not(feature = "native_trigger"))]
|
|
{
|
|
axum::Router::new()
|
|
}
|
|
})
|
|
.nest("/oauth", {
|
|
#[cfg(feature = "oauth2")]
|
|
{
|
|
oauth2_oss::workspaced_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "oauth2"))]
|
|
Router::new()
|
|
})
|
|
.nest("/mcp/oauth/server", {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
// Only /approve requires authentication (called by frontend)
|
|
mcp::oauth_server::workspaced_authed_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "mcp"))]
|
|
Router::new()
|
|
})
|
|
.nest("/ai", ai::workspaced_service())
|
|
.nest("/npm_proxy", windmill_api_npm_proxy::workspaced_service())
|
|
.nest(
|
|
"/path_autocomplete",
|
|
path_autocomplete::workspaced_service(),
|
|
)
|
|
.nest("/raw_apps", raw_apps::workspaced_service())
|
|
.nest("/resources", resources::workspaced_service())
|
|
.nest("/shared_ui", workspace_shared_ui::workspaced_service())
|
|
.nest("/schedules", windmill_api_schedule::workspaced_service())
|
|
.nest("/scripts", scripts::workspaced_service())
|
|
.nest("/trash", trash::workspaced_service())
|
|
.nest(
|
|
"/users",
|
|
users::workspaced_service().layer(Extension(argon2.clone())),
|
|
)
|
|
.nest("/variables", variables::workspaced_service())
|
|
.nest("/volumes", volumes_oss::workspaced_service())
|
|
.nest("/workers", windmill_api_workers::workspaced_service())
|
|
.nest("/workspaces", workspaces::workspaced_service())
|
|
.nest(
|
|
"/deployment_request",
|
|
windmill_api_workspaces::deployment_requests::workspaced_service(),
|
|
)
|
|
.nest("/oidc", oidc_oss::workspaced_service())
|
|
.nest("/openapi", {
|
|
#[cfg(feature = "http_trigger")]
|
|
{
|
|
windmill_api_openapi::openapi_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "http_trigger"))]
|
|
{
|
|
Router::new()
|
|
}
|
|
})
|
|
.merge(triggers_service),
|
|
)
|
|
.nest("/workspaces", workspaces::global_service())
|
|
.nest(
|
|
"/users",
|
|
users::global_service().layer(Extension(argon2.clone())),
|
|
)
|
|
.nest("/settings", windmill_api_settings::global_service())
|
|
.nest("/workers", windmill_api_workers::global_service())
|
|
.nest("/service_logs", service_logs::global_service())
|
|
.nest("/configs", windmill_api_configs::global_service())
|
|
.nest("/scripts", scripts::global_service())
|
|
.nest("/integrations", integration::global_service())
|
|
.nest("/groups", groups::global_service())
|
|
.nest("/flows", flows::global_service())
|
|
.nest("/apps", apps::global_service().layer(cors.clone()))
|
|
.nest("/schedules", windmill_api_schedule::global_service())
|
|
.nest("/embeddings", embeddings::global_service())
|
|
.nest("/ai", ai::global_service())
|
|
.nest("/inkeep", inkeep_oss::global_service())
|
|
.nest("/indexer", indexer_oss::management_service())
|
|
.nest("/mcp/w/{workspace_id}/list_tools", mcp_list_tools_service)
|
|
.nest("/db_health", db_health::global_service())
|
|
.nest("/health/detailed", health::detailed_service())
|
|
.nest(
|
|
"/saml",
|
|
saml_oss::authed_service().layer(Extension(Arc::clone(&sp_extension))),
|
|
)
|
|
.nest("/mcp/gateway/oauth/server", {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
mcp::oauth_server::gateway_authed_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "mcp"))]
|
|
Router::new()
|
|
})
|
|
.route_layer(from_extractor::<ApiAuthed>())
|
|
.route_layer(from_extractor::<users::Tokened>())
|
|
// Workspace-scoped OAuth endpoints that don't require authentication
|
|
// (authorize and token are called by MCP client before user is authenticated)
|
|
.nest("/w/{workspace_id}/mcp/oauth/server", {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
mcp::oauth_server::workspaced_unauthed_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "mcp"))]
|
|
Router::new()
|
|
})
|
|
// Gateway OAuth endpoints (authorize + token) — no auth required
|
|
.nest("/mcp/gateway/oauth/server", {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
mcp::oauth_server::gateway_unauthed_service().layer(cors.clone())
|
|
}
|
|
|
|
#[cfg(not(feature = "mcp"))]
|
|
Router::new()
|
|
})
|
|
.nest("/jobs", jobs::global_root_service())
|
|
.nest(
|
|
"/srch/w/{workspace_id}/index",
|
|
indexer_oss::workspaced_service(),
|
|
)
|
|
.nest("/srch/index", indexer_oss::global_service())
|
|
.nest("/oidc", oidc_oss::global_service())
|
|
.nest("/debug", windmill_api_debug::global_service())
|
|
.nest(
|
|
"/saml",
|
|
saml_oss::global_service().layer(Extension(Arc::clone(&sp_extension))),
|
|
)
|
|
.nest(
|
|
"/scim",
|
|
scim_oss::global_service()
|
|
.route_layer(axum::middleware::from_fn(has_scim_token)),
|
|
)
|
|
.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")]
|
|
{
|
|
apps_oss::global_unauthed_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "enterprise"))]
|
|
{
|
|
Router::new()
|
|
}
|
|
})
|
|
.nest(
|
|
"/w/{workspace_id}/apps_u",
|
|
apps::unauthed_service()
|
|
.layer(from_extractor::<OptAuthed>())
|
|
.layer(cors.clone()),
|
|
)
|
|
.layer(from_extractor::<OptAuthed>())
|
|
// Deprecated, here for backwards compatibility: user should use /mcp/w/{workspace_id}/mcp instead
|
|
.nest(
|
|
"/mcp/w/{workspace_id}/sse",
|
|
mcp_router.clone().layer(cors.clone()),
|
|
)
|
|
.nest(
|
|
"/mcp/w/{workspace_id}/mcp",
|
|
mcp_router.clone().layer(cors.clone()),
|
|
)
|
|
.nest("/mcp/gateway", gateway_mcp_router.layer(cors.clone()))
|
|
.nest("/agent_workers", {
|
|
#[cfg(feature = "agent_worker_server")]
|
|
{
|
|
if let Some(agent_workers_job_completed_tx) =
|
|
agent_workers_job_completed_tx.clone()
|
|
{
|
|
windmill_api_agent_workers::global_service(
|
|
agent_workers_job_completed_tx,
|
|
)
|
|
.layer(Extension(agent_cache.clone()))
|
|
} else {
|
|
Router::new()
|
|
}
|
|
}
|
|
#[cfg(not(feature = "agent_worker_server"))]
|
|
{
|
|
Router::new()
|
|
}
|
|
})
|
|
.nest("/w/{workspace_id}/agent_workers", {
|
|
#[cfg(feature = "agent_worker_server")]
|
|
{
|
|
agent_workers_router
|
|
.nest(
|
|
"/volumes",
|
|
volumes_oss::agent_workspaced_service()
|
|
.layer(axum::middleware::from_fn(inject_agent_authed)),
|
|
)
|
|
.layer(Extension(agent_cache.clone()))
|
|
}
|
|
#[cfg(not(feature = "agent_worker_server"))]
|
|
{
|
|
Router::new()
|
|
}
|
|
})
|
|
.nest(
|
|
"/w/{workspace_id}/jobs_u",
|
|
jobs::workspace_unauthed_service().layer(cors.clone()),
|
|
)
|
|
.route("/slack", post(slack_approvals::slack_app_callback_handler))
|
|
.nest("/teams", {
|
|
#[cfg(feature = "enterprise")]
|
|
{
|
|
teams_oss::teams_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "enterprise"))]
|
|
{
|
|
Router::new()
|
|
}
|
|
})
|
|
.route(
|
|
"/w/{workspace_id}/jobs/slack_approval/{job_id}",
|
|
get(slack_approvals::request_slack_approval),
|
|
)
|
|
.route(
|
|
"/w/{workspace_id}/jobs/teams_approval/{job_id}",
|
|
get(teams_approvals_oss::request_teams_approval),
|
|
)
|
|
.nest("/w/{workspace_id}/github_app", {
|
|
#[cfg(feature = "enterprise")]
|
|
{
|
|
git_sync_oss::workspaced_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "enterprise"))]
|
|
Router::new()
|
|
})
|
|
.nest("/github_app", {
|
|
#[cfg(feature = "enterprise")]
|
|
{
|
|
git_sync_oss::global_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "enterprise"))]
|
|
Router::new()
|
|
})
|
|
.nest(
|
|
"/w/{workspace_id}/resources_u",
|
|
public_service().layer(cors.clone()),
|
|
)
|
|
.nest(
|
|
"/w/{workspace_id}/capture_u",
|
|
capture::workspaced_unauthed_service().layer(cors.clone()),
|
|
)
|
|
.nest("/w/{workspace_id}/s3_proxy", {
|
|
s3_proxy_oss::workspaced_unauthed_service()
|
|
})
|
|
.nest(
|
|
"/auth",
|
|
users::make_unauthed_service().layer(Extension(argon2)),
|
|
)
|
|
.nest("/oauth", {
|
|
#[cfg(feature = "oauth2")]
|
|
{
|
|
oauth2_oss::global_service().layer(Extension(Arc::clone(&sp_extension)))
|
|
}
|
|
|
|
#[cfg(not(feature = "oauth2"))]
|
|
Router::new()
|
|
})
|
|
.nest("/mcp/oauth", {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
mcp_oauth_oss::global_service()
|
|
}
|
|
|
|
#[cfg(not(feature = "mcp"))]
|
|
Router::new()
|
|
})
|
|
.nest("/mcp/oauth/server", {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
mcp::oauth_server::global_service().layer(cors.clone())
|
|
}
|
|
|
|
#[cfg(not(feature = "mcp"))]
|
|
Router::new()
|
|
})
|
|
.nest("/r", {
|
|
#[cfg(feature = "http_trigger")]
|
|
{
|
|
triggers::http::handler::http_route_trigger_handler()
|
|
}
|
|
|
|
#[cfg(not(feature = "http_trigger"))]
|
|
{
|
|
Router::new()
|
|
}
|
|
})
|
|
.nest("/gcp/w/{workspace_id}", {
|
|
#[cfg(all(
|
|
feature = "enterprise",
|
|
feature = "gcp_trigger",
|
|
feature = "private"
|
|
))]
|
|
{
|
|
triggers::gcp::handler_oss::gcp_push_route_handler()
|
|
}
|
|
#[cfg(not(all(
|
|
feature = "enterprise",
|
|
feature = "gcp_trigger",
|
|
feature = "private"
|
|
)))]
|
|
{
|
|
Router::new()
|
|
}
|
|
})
|
|
.nest("/azure/w/{workspace_id}", {
|
|
#[cfg(all(
|
|
feature = "enterprise",
|
|
feature = "azure_trigger",
|
|
feature = "private"
|
|
))]
|
|
{
|
|
triggers::azure::handler_oss::azure_push_route_handler()
|
|
}
|
|
#[cfg(not(all(
|
|
feature = "enterprise",
|
|
feature = "azure_trigger",
|
|
feature = "private"
|
|
)))]
|
|
{
|
|
Router::new()
|
|
}
|
|
})
|
|
.route("/version", get(git_v))
|
|
.nest("/health/status", health::status_service())
|
|
.route("/min_keep_alive_version", get(min_keep_alive_version))
|
|
.route("/uptodate", get(is_up_to_date))
|
|
.route("/ee_license", get(ee_license))
|
|
.route("/openapi.yaml", get(openapi))
|
|
.route("/openapi.json", get(openapi_json)),
|
|
)
|
|
// Clients must use workspace-scoped OAuth metadata at:
|
|
// /.well-known/oauth-authorization-server/api/w/{workspace_id}/mcp/oauth/server
|
|
// This is discovered via /.well-known/oauth-protected-resource?workspace_id=...
|
|
.route(
|
|
"/.well-known/oauth-authorization-server/api/w/{workspace_id}/mcp/oauth/server",
|
|
{
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
get(mcp::oauth_server::workspaced_oauth_metadata)
|
|
}
|
|
#[cfg(not(feature = "mcp"))]
|
|
{
|
|
get(|| async { axum::http::StatusCode::NOT_FOUND })
|
|
}
|
|
},
|
|
)
|
|
// RFC 9728 path-based discovery: /.well-known/oauth-protected-resource/api/mcp/w/{workspace_id}/mcp
|
|
.route(
|
|
"/.well-known/oauth-protected-resource/api/mcp/w/{workspace_id}/mcp",
|
|
{
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
get(mcp::oauth_server::protected_resource_metadata_by_path)
|
|
}
|
|
#[cfg(not(feature = "mcp"))]
|
|
{
|
|
get(|| async { axum::http::StatusCode::NOT_FOUND })
|
|
}
|
|
},
|
|
)
|
|
// Gateway OAuth well-known endpoints
|
|
.route(
|
|
"/.well-known/oauth-authorization-server/api/mcp/gateway/oauth/server",
|
|
{
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
get(mcp::oauth_server::gateway_oauth_metadata)
|
|
}
|
|
#[cfg(not(feature = "mcp"))]
|
|
{
|
|
get(|| async { axum::http::StatusCode::NOT_FOUND })
|
|
}
|
|
},
|
|
)
|
|
.route("/.well-known/oauth-protected-resource/api/mcp/gateway", {
|
|
#[cfg(feature = "mcp")]
|
|
{
|
|
get(mcp::oauth_server::gateway_protected_resource_metadata)
|
|
}
|
|
#[cfg(not(feature = "mcp"))]
|
|
{
|
|
get(|| async { axum::http::StatusCode::NOT_FOUND })
|
|
}
|
|
})
|
|
// JWKS endpoint for HashiCorp Vault JWT authentication (must be outside /api prefix)
|
|
.route("/.well-known/jwks.json", {
|
|
#[cfg(all(feature = "private", feature = "enterprise", feature = "openidconnect"))]
|
|
{
|
|
get(crate::oidc_oss::jwks)
|
|
}
|
|
#[cfg(not(all(
|
|
feature = "private",
|
|
feature = "enterprise",
|
|
feature = "openidconnect"
|
|
)))]
|
|
{
|
|
get(windmill_api_settings::get_jwks)
|
|
}
|
|
})
|
|
.fallback(static_assets::static_handler)
|
|
.layer(middleware_stack);
|
|
|
|
let app = if disable_response_logs {
|
|
app
|
|
} else {
|
|
tokio::spawn(async {
|
|
let mut interval =
|
|
tokio::time::interval(std::time::Duration::from_millis(FLUSH_INTERVAL_MS));
|
|
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
|
loop {
|
|
interval.tick().await;
|
|
crate::s3_log_batching::flush_s3_batches();
|
|
}
|
|
});
|
|
|
|
app.layer(axum::middleware::from_fn(s3_proxy_log_middleware))
|
|
.layer(
|
|
TraceLayer::new_for_http()
|
|
.on_response(MyOnResponse {})
|
|
.make_span_with(MyMakeSpan {})
|
|
.on_request(())
|
|
.on_failure(MyOnFailure {}),
|
|
)
|
|
};
|
|
|
|
let app = if let Some(domain) = public_app_layer::PUBLIC_APP_DOMAIN.as_ref() {
|
|
tracing::info!("Public app domain filter enabled for domain: {}", domain);
|
|
app.layer(axum::middleware::from_fn(
|
|
public_app_layer::public_app_domain_filter,
|
|
))
|
|
} else {
|
|
app
|
|
};
|
|
|
|
// Seed the per-request LogContext task-local. Registered outside
|
|
// TraceLayer so MyOnResponse::on_response's `"response"` log fires inside
|
|
// the scope and gets method/uri/workspace_id/email attached by the EE
|
|
// LogContextBridge.
|
|
let app = app.layer(axum::middleware::from_fn(
|
|
tracing_init::log_context_middleware,
|
|
));
|
|
|
|
let app = app.layer(CatchPanicLayer::custom(|err| {
|
|
tracing::error!("panic in handler, returning 500: {:?}", err);
|
|
Response::builder()
|
|
.status(http::StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(Body::from("Internal Server Error"))
|
|
.unwrap()
|
|
}));
|
|
|
|
if let Some(name) = name.as_ref() {
|
|
tracing::info!("server starting for name={name}");
|
|
}
|
|
let listener = listener.tap_io(move |tcp_stream| {
|
|
let _ = tcp_stream.set_nodelay(!server_mode);
|
|
});
|
|
let server = axum::serve(listener, app.into_make_service());
|
|
|
|
tracing::info!(
|
|
instance = %*INSTANCE_NAME,
|
|
"server started on port={} and addr={} {}",
|
|
port,
|
|
ip,
|
|
name.map(|x| format!("name={x}")).unwrap_or_default()
|
|
);
|
|
|
|
if let Err(e) = port_tx.send(format!("http://localhost:{}", port)) {
|
|
tracing::error!("Failed to send port: {e:#}");
|
|
return Err(anyhow::anyhow!("Failed to send port, exiting early: {e:#}"));
|
|
}
|
|
|
|
// Announce this server is ready so coordinated restarts can detect a healthy peer.
|
|
if let Err(e) = announce_server_started(&db).await {
|
|
tracing::warn!("Failed to announce server started: {e:#}");
|
|
}
|
|
|
|
let server = server.with_graceful_shutdown(async move {
|
|
killpill_rx.recv().await.ok();
|
|
#[cfg(feature = "agent_worker_server")]
|
|
if let Some(agent_workers_job_completed_tx) = agent_workers_job_completed_tx {
|
|
if let Err(e) = agent_workers_job_completed_tx.kill().await {
|
|
tracing::error!("Error killing agent workers: {e:#}");
|
|
}
|
|
}
|
|
tracing::info!("Graceful shutdown of server");
|
|
|
|
#[cfg(feature = "mcp")]
|
|
if let Some(mcp_cancellation_token) = mcp_cancellation_token {
|
|
mcp_cancellation_token.cancel();
|
|
tracing::info!("MCP server shutdown");
|
|
}
|
|
});
|
|
|
|
server.await?;
|
|
#[cfg(feature = "agent_worker_server")]
|
|
for (i, bg_processor) in agent_workers_bg_processor.into_iter().enumerate() {
|
|
tracing::info!("server off. shutting down agent worker bg processor {i}");
|
|
bg_processor.await?;
|
|
tracing::info!("agent worker bg processor {i} shut down");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn is_up_to_date() -> Result<String, AppError> {
|
|
let error_reading_version = || anyhow::anyhow!("Error reading latest released version");
|
|
let version = HTTP_CLIENT
|
|
.get("https://api.github.com/repos/windmill-labs/windmill/releases/latest")
|
|
.timeout(Duration::from_secs(10))
|
|
.send()
|
|
.await
|
|
.context("Impossible to reach api.github")?
|
|
.json::<serde_json::Value>()
|
|
.await?
|
|
.get("tag_name")
|
|
.ok_or_else(error_reading_version)?
|
|
.as_str()
|
|
.ok_or_else(error_reading_version)?
|
|
.to_string();
|
|
let release = GIT_VERSION
|
|
.split('-')
|
|
.next()
|
|
.ok_or_else(error_reading_version)?
|
|
.to_string();
|
|
if version == release {
|
|
Ok("yes".to_string())
|
|
} else {
|
|
Ok(format!("Update: {GIT_VERSION} -> {version}"))
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
async fn git_v() -> String {
|
|
format!("EE {GIT_VERSION}")
|
|
}
|
|
|
|
#[cfg(not(feature = "enterprise"))]
|
|
async fn git_v() -> String {
|
|
format!("CE {GIT_VERSION}")
|
|
}
|
|
|
|
async fn min_keep_alive_version() -> Json<serde_json::Value> {
|
|
let worker = windmill_common::min_version::MIN_KEEP_ALIVE_VERSION;
|
|
let agent = windmill_common::min_version::AGENT_MIN_KEEP_ALIVE_VERSION;
|
|
Json(serde_json::json!({
|
|
"worker": format!("{}.{}.{}", worker.0, worker.1, worker.2),
|
|
"agent": format!("{}.{}.{}", agent.0, agent.1, agent.2)
|
|
}))
|
|
}
|
|
|
|
#[cfg(not(feature = "enterprise"))]
|
|
async fn ee_license() -> &'static str {
|
|
""
|
|
}
|
|
|
|
async fn list_workspace_labels(
|
|
Extension(db): Extension<DB>,
|
|
axum::extract::Path(w_id): axum::extract::Path<String>,
|
|
) -> windmill_common::error::JsonResult<Vec<String>> {
|
|
let labels = sqlx::query_scalar!(
|
|
"SELECT DISTINCT unnest(labels) as \"label!\" FROM (
|
|
SELECT labels FROM script WHERE workspace_id = $1 AND labels IS NOT NULL
|
|
UNION ALL SELECT labels FROM flow WHERE workspace_id = $1 AND labels IS NOT NULL
|
|
UNION ALL SELECT labels FROM resource WHERE workspace_id = $1 AND labels IS NOT NULL
|
|
UNION ALL SELECT labels FROM variable WHERE workspace_id = $1 AND labels IS NOT NULL
|
|
UNION ALL SELECT labels FROM schedule WHERE workspace_id = $1 AND labels IS NOT NULL
|
|
UNION ALL SELECT labels FROM app WHERE workspace_id = $1 AND labels IS NOT NULL
|
|
) t ORDER BY 1",
|
|
&w_id
|
|
)
|
|
.fetch_all(&db)
|
|
.await?;
|
|
Ok(axum::Json(labels))
|
|
}
|
|
|
|
#[cfg(feature = "enterprise")]
|
|
async fn ee_license() -> String {
|
|
use windmill_common::ee_oss::{LICENSE_KEY_ID, LICENSE_KEY_VALID};
|
|
|
|
if LICENSE_KEY_VALID.load(std::sync::atomic::Ordering::Relaxed) {
|
|
(**LICENSE_KEY_ID.load()).clone()
|
|
} else {
|
|
"".to_string()
|
|
}
|
|
}
|
|
|
|
async fn openapi() -> Response {
|
|
Response::builder()
|
|
.header("content-type", "application/yaml")
|
|
.body(Body::from(include_str!("../openapi-deref.yaml")))
|
|
.unwrap()
|
|
}
|
|
|
|
async fn openapi_json() -> Response {
|
|
Response::builder()
|
|
.header("content-type", "application/json")
|
|
.body(Body::from(include_str!("../openapi-deref.json")))
|
|
.unwrap()
|
|
}
|
|
|
|
pub async fn migrate_db(
|
|
db: &DB,
|
|
killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
|
) -> anyhow::Result<Option<JoinHandle<()>>> {
|
|
db::migrate(db, killpill_rx)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Error migrating db: {e:#}"))
|
|
}
|
|
|
|
pub async fn wait_for_db_migrations(
|
|
db: &DB,
|
|
killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
|
) -> anyhow::Result<()> {
|
|
db::wait_for_migrations(db, killpill_rx)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("Error waiting for db migrations: {e:#}"))
|
|
}
|
|
|
|
const SERVER_HEARTBEAT_TASK: &str = "server_heartbeat";
|
|
|
|
/// Write a server-started heartbeat to `background_task_state` so that
|
|
/// other instances waiting to restart can detect this server is healthy.
|
|
async fn announce_server_started(db: &DB) -> anyhow::Result<()> {
|
|
use windmill_common::INSTANCE_NAME;
|
|
|
|
let instance = INSTANCE_NAME.as_str();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO background_task_state (name, value, running, owner, started_at, updated_at)
|
|
VALUES ($1, '\"started\"'::jsonb, true, $2, NOW(), NOW())
|
|
ON CONFLICT (name)
|
|
DO UPDATE SET updated_at = NOW(), running = true, owner = $2",
|
|
)
|
|
.bind(format!("{SERVER_HEARTBEAT_TASK}:{instance}"))
|
|
.bind(instance)
|
|
.execute(db)
|
|
.await?;
|
|
|
|
tracing::info!("Announced server started for instance {instance}");
|
|
Ok(())
|
|
}
|
|
|
|
/// Check whether any server instance (other than ourselves) has announced
|
|
/// itself as started after `not_before` (i.e. after the restart was initiated).
|
|
pub async fn check_any_server_started(db: &DB, not_before: chrono::DateTime<chrono::Utc>) -> bool {
|
|
use windmill_common::INSTANCE_NAME;
|
|
|
|
let my_instance = INSTANCE_NAME.as_str();
|
|
let prefix = format!("{SERVER_HEARTBEAT_TASK}:");
|
|
|
|
sqlx::query_scalar!(
|
|
"SELECT EXISTS(
|
|
SELECT 1 FROM background_task_state
|
|
WHERE name LIKE $1
|
|
AND owner != $2
|
|
AND running = true
|
|
AND updated_at > $3
|
|
) AS \"exists!\"",
|
|
format!("{prefix}%"),
|
|
my_instance,
|
|
not_before
|
|
)
|
|
.fetch_one(db)
|
|
.await
|
|
.unwrap_or(false)
|
|
}
|