refactor: switch operator from CRD to ConfigMap (#7972)

* refactor: switch operator from CRD to ConfigMap

Replace the WindmillInstance CRD with a plain ConfigMap for the K8s
operator. This simplifies deployment (no CRD to install/manage, no
ClusterRole for custom API groups) while keeping the same config schema.

- Replace crd_ee.rs with configmap_ee.rs (parses data.spec YAML key)
- Rewrite reconciler_ee.rs: ConfigMap watcher + Event recorder instead
  of CRD Controller + status subresource
- Add license_key preservation: if absent/empty in ConfigMap but present
  in DB, the DB value is kept
- Remove print_crd_yaml() and "operator crd" subcommand
- Drop schemars, chrono, instance_config_schema dependencies
- Delete manifests/crd.yaml
- Update K8s example and README for ConfigMap approach
- RBAC now only needs a namespace-scoped Role (not ClusterRole)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add superadmin YAML export endpoint and remove cache_clear from operator config

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-02-17 01:06:56 +01:00
committed by GitHub
parent 535e108cbf
commit f02ef6d03c
13 changed files with 206 additions and 863 deletions
+1 -2
View File
@@ -16294,6 +16294,7 @@ dependencies = [
"rsa",
"serde",
"serde_json",
"serde_yml",
"sha2 0.10.9",
"sqlx",
"tokio",
@@ -16687,11 +16688,9 @@ name = "windmill-operator"
version = "1.636.0"
dependencies = [
"anyhow",
"chrono",
"futures",
"k8s-openapi",
"kube",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_yml",
+1 -1
View File
@@ -1 +1 @@
06207a2083383e0320da287bdf0268454db515de
82633d56b2db33ce8fd9ecfda31867fefb4f4823
+2 -9
View File
@@ -485,8 +485,7 @@ fn print_help() {
println!(" cache [hubPaths.json] Pre-cache hub scripts (default: ./hubPaths.json)");
println!(" cache-rt Pre-cache hub resource types");
println!(" sync-config <file> Sync instance config from a YAML file to the database");
println!(" operator Run the Kubernetes operator (watches WindmillInstance CRDs)");
println!(" operator crd Print the WindmillInstance CRD YAML to stdout");
println!(" operator Run the Kubernetes operator (watches a ConfigMap)");
println!();
println!("Environment variables (name = default):");
println!(" DATABASE_URL = <required> The Postgres database url.");
@@ -633,17 +632,11 @@ async fn windmill_main() -> anyhow::Result<()> {
}
#[cfg(feature = "operator")]
"operator" => {
let sub_arg = std::env::args().nth(2).unwrap_or_default();
if sub_arg == "crd" {
windmill_operator::print_crd_yaml();
return Ok(());
}
tracing_subscriber::fmt::init();
tracing::info!("Starting Windmill Kubernetes operator...");
tracing::info!("Connecting to database...");
let db = crate::db_connect::initial_connection().await?;
tracing::info!("Database connected. Starting controller...");
tracing::info!("Database connected. Starting ConfigMap watcher...");
windmill_operator::run(db).await?;
return Ok(());
}
+2 -2
View File
@@ -240,7 +240,7 @@ async fn test_from_db_worker_config_prefix_stripping(db: Pool<Postgres>) {
config.worker_configs.contains_key("my_group_name"),
"worker__ prefix should be stripped"
);
assert_eq!(config.worker_configs["my_group_name"].cache_clear, Some(5));
assert_eq!(config.worker_configs["my_group_name"].extra["cache_clear"], serde_json::json!(5));
}
#[sqlx::test(fixtures("base"))]
@@ -852,7 +852,7 @@ async fn test_full_config_roundtrip(db: Pool<Postgres>) {
assert_eq!(otel.tracing_enabled, Some(true));
assert_eq!(config.worker_configs.len(), 2);
assert_eq!(config.worker_configs["default"].cache_clear, Some(7));
assert_eq!(config.worker_configs["default"].extra["cache_clear"], serde_json::json!(7));
let gpu_auto = config.worker_configs["gpu"].autoscaling.as_ref().unwrap();
assert!(gpu_auto.enabled);
assert_eq!(gpu_auto.min_workers, Some(0));
+1
View File
@@ -28,6 +28,7 @@ lazy_static.workspace = true
regex.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yml.workspace = true
sqlx.workspace = true
tokio.workspace = true
tracing.workspace = true
+71
View File
@@ -24,6 +24,8 @@ use windmill_common::usernames::generate_instance_username_for_all_users;
use axum::{
extract::{Extension, Path},
routing::{get, post},
body::Body,
response::Response,
Json, Router,
};
#[cfg(feature = "enterprise")]
@@ -62,6 +64,7 @@ pub fn global_service() -> Router {
"/instance_config",
get(get_instance_config).put(set_instance_config),
)
.route("/instance_config/yaml", get(get_instance_config_yaml))
.route("/test_smtp", post(test_email))
.route("/test_license_key", post(test_license_key))
.route("/send_stats", post(send_stats))
@@ -409,6 +412,22 @@ async fn get_instance_config(
Ok(Json(config))
}
async fn get_instance_config_yaml(
Extension(db): Extension<DB>,
authed: ApiAuthed,
) -> error::Result<Response> {
require_super_admin(&db, &authed.email).await?;
let config = InstanceConfig::from_db(&db)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
let yaml = serde_yml::to_string(&config)
.map_err(|e| error::Error::internal_err(format!("YAML serialization failed: {e}")))?;
Response::builder()
.header("content-type", "application/yaml")
.body(Body::from(yaml))
.map_err(|e| error::Error::internal_err(e.to_string()))
}
async fn set_instance_config(
Extension(db): Extension<DB>,
authed: ApiAuthed,
@@ -1097,3 +1116,55 @@ async fn sync_cached_resource_types(
cached_types.len() - synced_count
))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use windmill_common::instance_config::{GlobalSettings, InstanceConfig, WorkerGroupConfig};
#[test]
fn instance_config_yaml_round_trip() {
let config = InstanceConfig {
global_settings: GlobalSettings {
base_url: Some("https://windmill.example.com".to_string()),
retention_period_secs: Some(86400),
expose_metrics: Some(true),
..Default::default()
},
worker_configs: BTreeMap::from([(
"default".to_string(),
WorkerGroupConfig {
worker_tags: Some(vec!["deno".to_string(), "python3".to_string()]),
init_bash: Some("apt-get update".to_string()),
..Default::default()
},
)]),
};
let yaml = serde_yml::to_string(&config).unwrap();
// Verify key fields appear in the YAML output
assert!(yaml.contains("base_url: https://windmill.example.com"));
assert!(yaml.contains("retention_period_secs: 86400"));
assert!(yaml.contains("expose_metrics: true"));
assert!(yaml.contains("default:"));
assert!(yaml.contains("- deno"));
assert!(yaml.contains("- python3"));
assert!(yaml.contains("init_bash: apt-get update"));
// Round-trip back to struct
let deserialized: InstanceConfig = serde_yml::from_str(&yaml).unwrap();
assert_eq!(
deserialized.global_settings.base_url.as_deref(),
Some("https://windmill.example.com")
);
assert_eq!(deserialized.global_settings.retention_period_secs, Some(86400));
assert_eq!(deserialized.global_settings.expose_metrics, Some(true));
let wc = &deserialized.worker_configs["default"];
assert_eq!(
wc.worker_tags.as_deref(),
Some(["deno".to_string(), "python3".to_string()].as_slice())
);
assert_eq!(wc.init_bash.as_deref(), Some("apt-get update"));
}
}
@@ -701,8 +701,6 @@ pub struct WorkerGroupConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub periodic_script_interval_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_clear: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub additional_python_paths: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pip_local_dependencies: Option<Vec<String>>,
@@ -1576,7 +1574,7 @@ mod tests {
assert_eq!(config.init_bash.as_deref(), Some("apt-get install -y curl"));
assert_eq!(config.periodic_script_bash.as_deref(), Some("echo ping"));
assert_eq!(config.periodic_script_interval_seconds, Some(300));
assert_eq!(config.cache_clear, Some(7));
assert_eq!(config.extra["cache_clear"], serde_json::json!(7));
assert_eq!(config.additional_python_paths.as_ref().unwrap().len(), 1);
assert_eq!(config.pip_local_dependencies.as_ref().unwrap().len(), 1);
assert_eq!(config.env_vars_static.as_ref().unwrap()["FOO"], "bar");
+1 -3
View File
@@ -18,13 +18,11 @@ serde.workspace = true
serde_json.workspace = true
sqlx.workspace = true
tracing.workspace = true
windmill-common = { workspace = true, default-features = false, features = ["instance_config_schema"] }
windmill-common = { workspace = true, default-features = false }
anyhow.workspace = true
thiserror.workspace = true
kube.workspace = true
k8s-openapi.workspace = true
tokio.workspace = true
futures.workspace = true
chrono.workspace = true
schemars = "0.8"
serde_yml.workspace = true
@@ -1,703 +0,0 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: windmillinstances.windmill.dev
spec:
group: windmill.dev
names:
categories: []
kind: WindmillInstance
plural: windmillinstances
shortNames:
- wmi
singular: windmillinstance
scope: Namespaced
versions:
- additionalPrinterColumns:
- jsonPath: '.status.synced'
name: Synced
type: string
- jsonPath: '.status.lastSyncedAt'
name: Last Synced
type: date
- jsonPath: '.metadata.creationTimestamp'
name: Age
type: date
name: v1alpha1
schema:
openAPIV3Schema:
description: Auto-generated derived type for WindmillInstanceSpec via `CustomResource`
properties:
spec:
description: |-
WindmillInstance CRD spec.
Declares the desired state for instance-level configuration: - `global_settings` maps directly to the `global_settings` table - `worker_configs` maps to the `config` table with a `worker__` prefix
properties:
global_settings:
default: {}
description: Global settings to sync to the `global_settings` table.
properties:
app_workspaced_route:
nullable: true
type: boolean
base_url:
nullable: true
type: string
bunfig_install_scopes:
nullable: true
type: string
critical_alert_mute_ui:
nullable: true
type: boolean
critical_alerts_on_db_oversize:
description: Configuration for critical alerts when the database exceeds a size threshold.
nullable: true
properties:
enabled:
default: false
type: boolean
value:
default: 0.0
format: float
type: number
type: object
critical_error_channels:
items:
anyOf:
- required:
- email
- required:
- slack_channel
- required:
- teams_channel
description: A channel for delivering critical error alerts.
properties:
email:
type: string
slack_channel:
type: string
teams_channel:
description: Microsoft Teams channel reference.
properties:
channel_id:
type: string
channel_name:
type: string
team_id:
type: string
team_name:
type: string
required:
- channel_id
- channel_name
- team_id
- team_name
type: object
type: object
nullable: true
type: array
custom_instance_pg_databases:
description: Custom PostgreSQL databases managed by the instance.
nullable: true
properties:
databases:
additionalProperties:
description: Status of a single custom instance database.
properties:
error:
nullable: true
type: string
logs:
default:
super_admin: ''
description: Setup log entries for a custom instance database.
properties:
created_database:
type: string
database_credentials:
type: string
db_connect:
type: string
grant_permissions:
type: string
super_admin:
default: ''
type: string
valid_dbname:
type: string
type: object
success:
default: false
type: boolean
tag:
nullable: true
type: string
type: object
type: object
user_pwd:
nullable: true
type: string
type: object
custom_tags:
items:
type: string
nullable: true
type: array
default_tags_per_workspace:
nullable: true
type: boolean
default_tags_workspaces:
items:
type: string
nullable: true
type: array
dev_instance:
nullable: true
type: boolean
disable_stats:
nullable: true
type: boolean
ducklake_settings:
description: DuckLake catalog database settings.
nullable: true
properties:
ducklakes:
additionalProperties:
description: A single DuckLake instance configuration.
properties:
catalog:
description: DuckLake catalog backend reference.
properties:
resource_path:
type: string
resource_type:
description: The type of database backing a DuckLake catalog.
enum:
- postgresql
- mysql
- instance
type: string
required:
- resource_path
- resource_type
type: object
extra_args:
nullable: true
type: string
storage:
description: DuckLake storage location.
properties:
path:
type: string
storage:
nullable: true
type: string
required:
- path
type: object
required:
- catalog
- storage
type: object
type: object
required:
- ducklakes
type: object
email_domain:
nullable: true
type: string
expose_debug_metrics:
nullable: true
type: boolean
expose_metrics:
nullable: true
type: boolean
hub_accessible_url:
nullable: true
type: string
hub_api_secret:
nullable: true
type: string
hub_base_url:
nullable: true
type: string
indexer_settings:
description: Full-text search indexer configuration.
nullable: true
properties:
commit_job_max_batch_size:
format: uint64
minimum: 0.0
nullable: true
type: integer
commit_log_max_batch_size:
format: uint64
minimum: 0.0
nullable: true
type: integer
max_indexed_job_log_size:
format: uint64
minimum: 0.0
nullable: true
type: integer
refresh_index_period:
format: uint64
minimum: 0.0
nullable: true
type: integer
refresh_log_index_period:
format: uint64
minimum: 0.0
nullable: true
type: integer
should_clear_job_index:
nullable: true
type: boolean
should_clear_log_index:
nullable: true
type: boolean
writer_memory_budget:
format: uint64
minimum: 0.0
nullable: true
type: integer
type: object
instance_python_version:
nullable: true
type: string
job_default_timeout:
format: int64
nullable: true
type: integer
jwt_secret:
nullable: true
type: string
keep_job_dir:
nullable: true
type: boolean
license_key:
nullable: true
type: string
maven_repos:
nullable: true
type: string
min_keep_alive_version:
nullable: true
type: string
monitor_logs_on_s3:
nullable: true
type: boolean
no_default_maven:
nullable: true
type: boolean
npm_config_registry:
nullable: true
type: string
nuget_config:
nullable: true
type: string
oauths:
additionalProperties:
description: OAuth client configuration for a single provider.
properties:
allowed_domains:
items:
type: string
nullable: true
type: array
connect_config:
description: OAuth provider endpoint configuration.
nullable: true
properties:
auth_url:
type: string
extra_params:
additionalProperties:
type: string
nullable: true
type: object
extra_params_callback:
additionalProperties:
type: string
nullable: true
type: object
req_body_auth:
nullable: true
type: boolean
scopes:
items:
type: string
nullable: true
type: array
token_url:
type: string
userinfo_url:
nullable: true
type: string
required:
- auth_url
- token_url
type: object
id:
type: string
login_config:
description: OAuth provider endpoint configuration.
nullable: true
properties:
auth_url:
type: string
extra_params:
additionalProperties:
type: string
nullable: true
type: object
extra_params_callback:
additionalProperties:
type: string
nullable: true
type: object
req_body_auth:
nullable: true
type: boolean
scopes:
items:
type: string
nullable: true
type: array
token_url:
type: string
userinfo_url:
nullable: true
type: string
required:
- auth_url
- token_url
type: object
secret:
type: string
required:
- id
- secret
type: object
nullable: true
type: object
object_store_cache_config:
nullable: true
openai_azure_base_path:
nullable: true
type: string
otel:
description: OpenTelemetry exporter configuration.
nullable: true
properties:
logs_enabled:
nullable: true
type: boolean
metrics_enabled:
nullable: true
type: boolean
otel_exporter_otlp_compression:
nullable: true
type: string
otel_exporter_otlp_endpoint:
nullable: true
type: string
otel_exporter_otlp_headers:
nullable: true
type: string
otel_exporter_otlp_protocol:
nullable: true
type: string
tracing_enabled:
nullable: true
type: boolean
type: object
otel_tracing_proxy:
description: Per-language HTTP request tracing proxy configuration.
nullable: true
properties:
enabled:
default: false
type: boolean
enabled_languages:
items:
description: Script language identifier.
enum:
- python3
- deno
- go
- bash
- powershell
- postgresql
- bun
- bunnative
- mysql
- bigquery
- snowflake
- graphql
- nativets
- mssql
- oracledb
- duckdb
- php
- rust
- ansible
- csharp
- nu
- java
- ruby
type: string
type: array
type: object
pip_extra_index_url:
nullable: true
type: string
pip_index_url:
nullable: true
type: string
powershell_repo_pat:
nullable: true
type: string
powershell_repo_url:
nullable: true
type: string
request_size_limit_mb:
format: int64
nullable: true
type: integer
require_preexisting_user_for_oauth:
nullable: true
type: boolean
retention_period_secs:
format: int64
nullable: true
type: integer
ruby_repos:
nullable: true
type: string
saml_metadata:
nullable: true
type: string
scim_token:
nullable: true
type: string
secret_backend:
nullable: true
slack:
nullable: true
smtp_settings:
description: SMTP server configuration.
nullable: true
properties:
smtp_disable_tls:
nullable: true
type: boolean
smtp_from:
nullable: true
type: string
smtp_host:
nullable: true
type: string
smtp_password:
nullable: true
type: string
smtp_port:
format: uint16
minimum: 0.0
nullable: true
type: integer
smtp_tls_implicit:
nullable: true
type: boolean
smtp_username:
nullable: true
type: string
type: object
teams:
nullable: true
timeout_wait_result:
format: int64
nullable: true
type: integer
type: object
x-kubernetes-preserve-unknown-fields: true
worker_configs:
additionalProperties:
description: Worker group configuration.
properties:
additional_python_paths:
items:
type: string
nullable: true
type: array
autoscaling:
description: Worker group autoscaling configuration.
nullable: true
properties:
cooldown_seconds:
format: uint64
minimum: 0.0
nullable: true
type: integer
custom_tags:
items:
type: string
nullable: true
type: array
dec_scale_occupancy_rate:
format: uint8
minimum: 0.0
nullable: true
type: integer
enabled:
default: false
type: boolean
full_scale_cooldown_seconds:
format: uint64
minimum: 0.0
nullable: true
type: integer
full_scale_jobs_waiting:
format: uint64
minimum: 0.0
nullable: true
type: integer
inc_num_workers:
format: uint32
minimum: 0.0
nullable: true
type: integer
inc_scale_num_jobs_waiting:
format: uint64
minimum: 0.0
nullable: true
type: integer
inc_scale_occupancy_rate:
format: uint8
minimum: 0.0
nullable: true
type: integer
integration:
description: |-
Autoscaling integration backend.
The `type` field selects the backend: `"script"`, `"dryrun"`, or `"kubernetes"`. For `"script"`, `path` is required and `tag` is optional.
nullable: true
properties:
path:
nullable: true
type: string
tag:
nullable: true
type: string
type:
type: string
required:
- type
type: object
max_workers:
format: uint32
minimum: 0.0
nullable: true
type: integer
min_workers:
format: uint32
minimum: 0.0
nullable: true
type: integer
type: object
cache_clear:
format: uint32
minimum: 0.0
nullable: true
type: integer
dedicated_worker:
nullable: true
type: string
dedicated_workers:
items:
type: string
nullable: true
type: array
env_vars_allowlist:
items:
type: string
nullable: true
type: array
env_vars_static:
additionalProperties:
type: string
nullable: true
type: object
init_bash:
nullable: true
type: string
min_alive_workers_alert_threshold:
format: uint32
minimum: 0.0
nullable: true
type: integer
periodic_script_bash:
nullable: true
type: string
periodic_script_interval_seconds:
format: uint64
minimum: 0.0
nullable: true
type: integer
pip_local_dependencies:
items:
type: string
nullable: true
type: array
priority_tags:
additionalProperties:
format: uint8
minimum: 0.0
type: integer
nullable: true
type: object
worker_tags:
items:
type: string
nullable: true
type: array
type: object
x-kubernetes-preserve-unknown-fields: true
default: {}
description: Worker group configs to sync to the `config` table. Keys are worker group names (e.g. "default", "gpu"). Each key is stored in the DB as `worker__<key>`.
type: object
type: object
status:
description: Status subresource for WindmillInstance.
nullable: true
properties:
lastSyncedAt:
description: Timestamp of the last successful sync.
nullable: true
type: string
message:
default: ''
description: Human-readable status message.
type: string
observedGeneration:
default: 0
description: The `.metadata.generation` that was last observed.
format: int64
type: integer
synced:
description: Whether the last reconciliation was successful.
type: boolean
required:
- synced
type: object
required:
- spec
title: WindmillInstance
type: object
served: true
storage: true
subresources:
status: {}
+1 -1
View File
@@ -1,5 +1,5 @@
#[cfg(feature = "private")]
pub mod crd_ee;
pub mod configmap_ee;
#[cfg(feature = "private")]
pub mod db_sync_ee;
#[cfg(feature = "private")]
@@ -1,19 +1,7 @@
#[cfg(feature = "private")]
pub use crate::reconciler_ee::run;
#[cfg(feature = "private")]
pub fn print_crd_yaml() {
use kube::CustomResourceExt;
let crd = crate::crd_ee::WindmillInstance::crd();
println!("{}", serde_yml::to_string(&crd).unwrap());
}
#[cfg(not(feature = "private"))]
pub async fn run(_db: sqlx::Pool<sqlx::Postgres>) -> anyhow::Result<()> {
anyhow::bail!("K8s operator is not available in this build")
}
#[cfg(not(feature = "private"))]
pub fn print_crd_yaml() {
eprintln!("K8s operator CRD generation is not available in this build");
}
+72 -74
View File
@@ -173,23 +173,17 @@ If you only want to manage a subset of settings, include all settings you want t
## Kubernetes (Operator)
The Windmill Kubernetes operator watches `WindmillInstance` Custom Resources and continuously reconciles the database to match the declared state. It also supports `secretKeyRef` to pull values from Kubernetes Secrets natively.
The Windmill Kubernetes operator watches a ConfigMap and continuously reconciles the database to match the declared state. It also supports `secretKeyRef` to pull values from Kubernetes Secrets natively.
### Prerequisites
- Windmill built with the `operator` feature flag
- RBAC permissions for the operator pod (see below)
- The CRD installed in the cluster
- A ConfigMap named `windmill-instance` (or a custom name via the `OPERATOR_CONFIGMAP` env var)
### Setup
**1. Install the CRD**:
```bash
windmill operator crd | kubectl apply -f -
```
**2. Create a Kubernetes Secret for sensitive values**:
**1. Create a Kubernetes Secret for sensitive values**:
```yaml
apiVersion: v1
@@ -204,78 +198,91 @@ stringData:
google-oauth-secret: "your-google-oauth-secret"
```
**3. Create the WindmillInstance resource** (`windmill-instance.yaml`):
**2. Create the ConfigMap** (`windmill-instance.yaml`):
```yaml
apiVersion: windmill.dev/v1alpha1
kind: WindmillInstance
apiVersion: v1
kind: ConfigMap
metadata:
name: production
name: windmill-instance
namespace: windmill
spec:
global_settings:
base_url: "https://windmill.example.com"
license_key:
secretKeyRef:
name: windmill-secrets
key: license-key
retention_period_secs: 2592000
expose_metrics: true
smtp_settings:
smtp_host: "smtp.example.com"
smtp_port: 587
smtp_from: "windmill@example.com"
smtp_password:
data:
spec: |
global_settings:
base_url: "https://windmill.example.com"
license_key:
secretKeyRef:
name: windmill-secrets
key: smtp-password
oauths:
google:
id: "google-client-id"
secret:
key: license-key
retention_period_secs: 2592000
expose_metrics: true
smtp_settings:
smtp_host: "smtp.example.com"
smtp_port: 587
smtp_from: "windmill@example.com"
smtp_password:
secretKeyRef:
name: windmill-secrets
key: google-oauth-secret
login_config:
auth_url: "https://accounts.google.com/o/oauth2/v2/auth"
token_url: "https://oauth2.googleapis.com/token"
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo"
scopes: ["openid", "profile", "email"]
custom_tags:
- gpu
- high-mem
key: smtp-password
oauths:
google:
id: "google-client-id"
secret:
secretKeyRef:
name: windmill-secrets
key: google-oauth-secret
login_config:
auth_url: "https://accounts.google.com/o/oauth2/v2/auth"
token_url: "https://oauth2.googleapis.com/token"
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo"
scopes: ["openid", "profile", "email"]
custom_tags:
- gpu
- high-mem
worker_configs:
default:
worker_tags: ["deno", "python3", "bun", "go", "bash", "powershell"]
init_bash: "echo 'Worker starting'"
native:
worker_tags: ["nativets"]
worker_configs:
default:
worker_tags: ["deno", "python3", "bun", "go", "bash", "powershell"]
init_bash: "echo 'Worker starting'"
native:
worker_tags: ["nativets"]
```
**4. Apply**:
The config lives under `data.spec` as a YAML string. This is the same schema used by `sync-config`.
**3. Apply**:
```bash
kubectl apply -f windmill-instance.yaml
```
**5. Check status**:
**4. Check sync status** via events:
```bash
kubectl get wmi
# NAME SYNCED LAST SYNCED AGE
# production true 2025-01-15T10:30:00Z 2d
kubectl get events --field-selector involvedObject.name=windmill-instance
```
### License key handling
If `license_key` is absent or empty in the ConfigMap but already exists in the database, the operator preserves the database value. This lets you manage the license key separately (e.g., via the UI) without the operator overwriting it.
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `OPERATOR_NAMESPACE` | Pod's own namespace | Namespace of the ConfigMap |
| `OPERATOR_CONFIGMAP` | `windmill-instance` | Name of the ConfigMap to watch |
### Using `envRef` in Kubernetes
`envRef` also works in the operator context. Values are resolved from the operator pod's environment. This is useful when secrets are injected via pod env vars (e.g., from a vault sidecar):
```yaml
spec:
global_settings:
license_key:
envRef: "WM_LICENSE_KEY" # Read from operator pod env
data:
spec: |
global_settings:
license_key:
envRef: "WM_LICENSE_KEY" # Read from operator pod env
```
The operator pod's Deployment would include:
@@ -289,21 +296,22 @@ env:
key: license-key
```
This is functionally equivalent to using `secretKeyRef` directly in the CRD, but lets you use any secret injection mechanism your cluster supports (external-secrets, vault-agent, etc.).
This is functionally equivalent to using `secretKeyRef` directly in the ConfigMap, but lets you use any secret injection mechanism your cluster supports (external-secrets, vault-agent, etc.).
### RBAC
The operator pod needs permissions to read Secrets and manage the CRD. Minimal ClusterRole:
The operator pod needs permissions to read ConfigMaps, Secrets, and create Events. Minimal Role:
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
kind: Role
metadata:
name: windmill-operator
namespace: windmill
rules:
- apiGroups: ["windmill.dev"]
resources: ["windmillinstances", "windmillinstances/status"]
verbs: ["get", "list", "watch", "patch", "update"]
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"]
@@ -312,6 +320,8 @@ rules:
verbs: ["create", "patch"]
```
Note: This is now a namespace-scoped **Role** (not ClusterRole), since there is no CRD to manage.
### Running the operator
```bash
@@ -334,15 +344,3 @@ DATABASE_URL=postgres://... windmill operator
| Requires RBAC for Secrets | No | Yes |
**Recommendation**: Use `envRef` for portability across deployment targets. Use `secretKeyRef` when you want direct Kubernetes-native secret binding without intermediate env vars.
---
## Full Settings Reference
For a complete list of available settings fields, generate the CRD schema:
```bash
windmill operator crd
```
The CRD's OpenAPI schema documents every field, its type, and whether it's optional. The same schema applies to `sync-config` YAML files.
@@ -1,67 +1,67 @@
# Example: WindmillInstance CRD for the Kubernetes operator.
# Example: ConfigMap for the Windmill Kubernetes operator.
#
# Prerequisites:
# 1. Install the CRD: windmill operator crd | kubectl apply -f -
# 2. Create the Secret: kubectl apply -f k8s-secrets.yaml
# 3. Apply this file: kubectl apply -f k8s-windmill-instance.yaml
# 4. Check status: kubectl get wmi
# 1. Create the Secret: kubectl apply -f k8s-secrets.yaml
# 2. Apply this file: kubectl apply -f k8s-windmill-instance.yaml
# 3. Check events: kubectl get events --field-selector involvedObject.name=windmill-instance
apiVersion: windmill.dev/v1alpha1
kind: WindmillInstance
apiVersion: v1
kind: ConfigMap
metadata:
name: production
name: windmill-instance
namespace: windmill
spec:
global_settings:
base_url: "https://windmill.example.com"
data:
spec: |
global_settings:
base_url: "https://windmill.example.com"
# Secret reference: reads "license-key" from K8s Secret "windmill-secrets"
license_key:
secretKeyRef:
name: windmill-secrets
key: license-key
retention_period_secs: 2592000
job_default_timeout: 900
expose_metrics: true
smtp_settings:
smtp_host: "smtp.example.com"
smtp_port: 587
smtp_from: "windmill@example.com"
smtp_tls_implicit: false
smtp_password:
# Secret reference: reads "license-key" from K8s Secret "windmill-secrets"
license_key:
secretKeyRef:
name: windmill-secrets
key: smtp-password
key: license-key
oauths:
google:
id: "your-google-client-id"
secret:
retention_period_secs: 2592000
job_default_timeout: 900
expose_metrics: true
smtp_settings:
smtp_host: "smtp.example.com"
smtp_port: 587
smtp_from: "windmill@example.com"
smtp_tls_implicit: false
smtp_password:
secretKeyRef:
name: windmill-secrets
key: google-oauth-secret
login_config:
auth_url: "https://accounts.google.com/o/oauth2/v2/auth"
token_url: "https://oauth2.googleapis.com/token"
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo"
scopes: ["openid", "profile", "email"]
key: smtp-password
custom_tags:
- gpu
- high-mem
oauths:
google:
id: "your-google-client-id"
secret:
secretKeyRef:
name: windmill-secrets
key: google-oauth-secret
login_config:
auth_url: "https://accounts.google.com/o/oauth2/v2/auth"
token_url: "https://oauth2.googleapis.com/token"
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo"
scopes: ["openid", "profile", "email"]
worker_configs:
default:
worker_tags:
- deno
- python3
- bun
- go
- bash
- powershell
custom_tags:
- gpu
- high-mem
native:
worker_tags:
- nativets
worker_configs:
default:
worker_tags:
- deno
- python3
- bun
- go
- bash
- powershell
native:
worker_tags:
- nativets