Skip to main content

cronoq_server/
config.rs

1//! Server configuration (TOML file, with environment variables for secrets).
2//!
3//! Every numeric option is validated by [`Config::validate`], which [`crate::run`] also calls,
4//! so a bad value is reported as an error at startup instead of panicking later.
5
6use std::collections::{HashMap, HashSet};
7use std::fmt;
8use std::net::SocketAddr;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12use anyhow::{Context, bail, ensure};
13use cronoq::{AutoscaleConfig, OverflowPolicy, PoolConfig, ResourceConfig, RetryPolicy};
14use serde::Deserialize;
15
16/// Longest duration accepted anywhere in the configuration (one year).
17const MAX_CONFIG_SECS: f64 = 365.0 * 86400.0;
18
19/// Top-level server configuration.
20#[derive(Clone, Debug, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct Config {
23    /// Listeners, TLS and shutdown behaviour.
24    #[serde(default)]
25    pub server: ServerConfig,
26    /// Bearer tokens and client-certificate role mappings.
27    #[serde(default)]
28    pub auth: AuthConfig,
29    /// Where scheduler state is kept.
30    #[serde(default)]
31    pub store: StoreConfig,
32    /// Request and resource limits.
33    #[serde(default)]
34    pub limits: LimitsConfig,
35    /// Worker pools (each served by remote workers).
36    #[serde(default = "default_pools")]
37    pub pools: Vec<PoolSection>,
38    /// Shared resources (semaphores and token buckets).
39    #[serde(default)]
40    pub resources: Vec<ResourceSection>,
41    /// In-memory cache of recent task outputs.
42    #[serde(default)]
43    pub outputs: OutputsConfig,
44    /// Groups of pools served by separate leaders. Every shard's servers read the same list
45    /// and pick their own with `server.shard`.
46    #[serde(default)]
47    pub shards: Vec<ShardSection>,
48}
49
50/// A group of pools served by its own leader (and standbys).
51#[derive(Clone, Debug, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct ShardSection {
54    /// Name: lower-case letters, digits and `_`, starting with a letter (at most 32).
55    pub name: String,
56    /// Number carried in the shard's task ids (0-255, unique).
57    pub number: u8,
58    /// The pools this shard serves; every pool belongs to exactly one shard.
59    pub pools: Vec<String>,
60    /// gRPC endpoint (`https://...` or `http://...`) that reaches the shard's leader, for other
61    /// servers and for workers.
62    pub endpoint: String,
63}
64
65fn valid_shard_name(s: &str) -> bool {
66    s.len() <= 32
67        && s.starts_with(|c: char| c.is_ascii_lowercase())
68        && s.chars()
69            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
70}
71
72/// The shard this process serves, and where every shard is.
73#[derive(Clone, Debug)]
74pub struct ShardPlan {
75    /// This process's shard.
76    pub own: ShardSection,
77    /// All shards, including this one.
78    pub all: Vec<ShardSection>,
79    /// Resource name to owning shard name.
80    pub resources: Vec<(String, String)>,
81}
82
83impl Default for Config {
84    fn default() -> Self {
85        Config {
86            server: ServerConfig::default(),
87            auth: AuthConfig::default(),
88            store: StoreConfig::default(),
89            limits: LimitsConfig::default(),
90            shards: Vec::new(),
91            pools: default_pools(),
92            resources: Vec::new(),
93            outputs: OutputsConfig::default(),
94        }
95    }
96}
97
98fn default_pools() -> Vec<PoolSection> {
99    vec![PoolSection::named("default")]
100}
101
102fn finite_secs(what: &str, v: f64, min: f64) -> anyhow::Result<()> {
103    ensure!(
104        v.is_finite() && v >= min && v <= MAX_CONFIG_SECS,
105        "{what} must be a number of seconds between {min} and {MAX_CONFIG_SECS} (got {v})"
106    );
107    Ok(())
108}
109
110fn opt_secs(what: &str, v: Option<f64>, min: f64) -> anyhow::Result<()> {
111    match v {
112        Some(v) => finite_secs(what, v, min),
113        None => Ok(()),
114    }
115}
116
117/// Names of pools and resources appear in metrics labels and logs: keep them simple.
118pub(crate) fn valid_name(s: &str) -> bool {
119    !s.is_empty()
120        && s.len() <= 64
121        && s.chars()
122            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':'))
123}
124
125impl Config {
126    /// Read and validate a TOML file.
127    pub fn load(path: &Path) -> anyhow::Result<Config> {
128        let cfg = Self::read(path)?;
129        cfg.validate()
130            .with_context(|| format!("in config file {}", path.display()))?;
131        Ok(cfg)
132    }
133
134    /// Read a TOML file without validating it, to apply command-line overrides first; call
135    /// [`Config::validate`] afterwards ([`crate::run`] does).
136    pub fn read(path: &Path) -> anyhow::Result<Config> {
137        let text = std::fs::read_to_string(path)
138            .with_context(|| format!("reading config file {}", path.display()))?;
139        toml::from_str(&text).with_context(|| format!("in config file {}", path.display()))
140    }
141
142    /// Parse and validate TOML text.
143    pub fn parse(text: &str) -> anyhow::Result<Config> {
144        let cfg: Config = toml::from_str(text)?;
145        cfg.validate()?;
146        Ok(cfg)
147    }
148
149    /// Check every option. Called by [`Config::parse`] and again by [`crate::run`].
150    pub fn validate(&self) -> anyhow::Result<()> {
151        ensure!(!self.pools.is_empty(), "at least one pool is required");
152        let s = &self.server;
153        finite_secs("server.shutdown_grace_s", s.shutdown_grace_s, 0.0)?;
154        ensure!(
155            self.outputs.cache_entries == 0 || self.outputs.cache_bytes > 0,
156            "outputs.cache_bytes must be positive (set outputs.cache_entries = 0 to disable the cache)"
157        );
158        if let Some(tls) = &s.tls {
159            ensure!(
160                !tls.cert.as_os_str().is_empty() && !tls.key.as_os_str().is_empty(),
161                "server.tls needs cert and key"
162            );
163        }
164        if let Some(t) = &s.peer_tls {
165            ensure!(
166                t.cert.is_some() == t.key.is_some(),
167                "server.peer_tls needs both cert and key, or neither"
168            );
169            // Without authentication here, forwarded calls carry no token, and another shard
170            // would authorize them as this server's certificate instead of as the caller.
171            ensure!(
172                t.cert.is_none() || !self.auth.tokens.is_empty() || !self.auth.certs.is_empty(),
173                "server.peer_tls.cert needs authentication on this server ([[auth.tokens]] or \
174                 [[auth.certs]]): otherwise calls forwarded to other shards would act as this \
175                 server's certificate"
176            );
177        }
178        if let Some(w) = &s.grpc_web {
179            ensure!(
180                (1..=64).contains(&w.allowed_origins.len()),
181                "server.grpc_web.allowed_origins needs 1 to 64 origins"
182            );
183            for o in &w.allowed_origins {
184                ensure!(
185                    valid_origin(o),
186                    "server.grpc_web.allowed_origins: {o:?} is not an origin \
187                     (scheme://host[:port], no path, no wildcard)"
188                );
189            }
190        }
191        let mut names = HashSet::new();
192        for p in &self.pools {
193            ensure!(
194                valid_name(&p.name),
195                "pool name {:?} is invalid: use 1-64 characters from [A-Za-z0-9_.:-]",
196                p.name
197            );
198            ensure!(names.insert(p.name.as_str()), "duplicate pool '{}'", p.name);
199            ensure!(p.max >= 1, "pool '{}': max must be at least 1", p.name);
200            ensure!(p.min <= p.max, "pool '{}': min > max", p.name);
201            let at = |k: &str| format!("pool '{}': {k}", p.name);
202            finite_secs(&at("lease_ttl_s"), p.lease_ttl_s, 0.1)?;
203            opt_secs(&at("default_duration_s"), p.default_duration_s, 0.0)?;
204            opt_secs(
205                &at("await_unknown_dependencies_s"),
206                p.await_unknown_dependencies_s,
207                0.0,
208            )?;
209            opt_secs(&at("drain_time_s"), p.drain_time_s, 0.001)?;
210            opt_secs(&at("cooldown_s"), p.cooldown_s, 0.0)?;
211            if let Some(f) = p.reservable_fraction {
212                ensure!(
213                    f.is_finite() && f > 0.0 && f <= 1.0,
214                    "{} must be in (0, 1]",
215                    at("reservable_fraction")
216                );
217            }
218            for (t, w) in &p.tenant_weights {
219                ensure!(
220                    w.is_finite() && *w > 0.0,
221                    "{} must be finite and > 0",
222                    at(&format!("tenant_weights.{t}"))
223                );
224            }
225            if let Some(u) = p.target_utilization {
226                ensure!(
227                    u.is_finite() && u > 0.0 && u <= 1.0,
228                    "{} must be in (0, 1]",
229                    at("target_utilization")
230                );
231            }
232            if let Some(c) = p.cost_per_worker_second {
233                ensure!(
234                    c.is_finite() && c >= 0.0,
235                    "{} must be a non-negative number",
236                    at("cost_per_worker_second")
237                );
238            }
239            if let Some(m) = p.max_pending {
240                ensure!(m >= 1, "{} must be at least 1", at("max_pending"));
241            }
242            if let Some(m) = p.max_slots_per_worker {
243                ensure!(m >= 1, "{} must be at least 1", at("max_slots_per_worker"));
244            }
245            if let Some(r) = &p.retry {
246                r.validate(&at("retry"))?;
247            }
248            if let ScalerSection::Webhook { url, .. } = &p.scaler {
249                ensure!(
250                    url.starts_with("https://") || url.starts_with("http://"),
251                    "{} must be an http(s) URL",
252                    at("scaler.url")
253                );
254            }
255        }
256        let mut rnames = HashSet::new();
257        for r in &self.resources {
258            let name = r.name();
259            ensure!(
260                valid_name(name),
261                "resource name {name:?} is invalid: use 1-64 characters from [A-Za-z0-9_.:-]"
262            );
263            ensure!(rnames.insert(name), "duplicate resource '{name}'");
264            match r {
265                ResourceSection::Semaphore { permits, .. } => {
266                    ensure!(
267                        *permits >= 1,
268                        "resource '{name}': permits must be at least 1"
269                    )
270                }
271                ResourceSection::TokenBucket { rate, burst, .. } => {
272                    ensure!(
273                        rate.is_finite() && *rate > 0.0,
274                        "resource '{name}': rate must be a positive number"
275                    );
276                    ensure!(*burst >= 1, "resource '{name}': burst must be at least 1");
277                }
278            }
279        }
280        let mut literal_tokens = HashSet::new();
281        for t in &self.auth.tokens {
282            ensure!(
283                t.token.is_some() != t.token_env.is_some(),
284                "each [[auth.tokens]] entry needs exactly one of `token` or `token_env`"
285            );
286            if let Some(v) = &t.token {
287                ensure!(
288                    literal_tokens.insert(v.as_str()),
289                    "duplicate auth token (each token must be unique)"
290                );
291            }
292            for pool in t.pools.iter().flatten() {
293                ensure!(
294                    names.contains(pool.as_str()),
295                    "auth token restricted to unknown pool '{pool}'"
296                );
297            }
298        }
299        for c in &self.auth.certs {
300            ensure!(
301                !c.dns_name.is_empty(),
302                "[[auth.certs]] entries need a dns_name"
303            );
304            for pool in c.pools.iter().flatten() {
305                ensure!(
306                    names.contains(pool.as_str()),
307                    "certificate mapping restricted to unknown pool '{pool}'"
308                );
309            }
310        }
311        if !self.auth.certs.is_empty() {
312            ensure!(
313                s.tls.as_ref().is_some_and(|t| t.client_ca.is_some()),
314                "[[auth.certs]] requires server.tls.client_ca (mTLS)"
315            );
316        }
317        self.limits.validate()?;
318        match &self.store {
319            StoreConfig::Memory => {}
320            StoreConfig::Postgres {
321                keep_finished_s,
322                url,
323                url_env,
324                ..
325            } => {
326                opt_secs("store.keep_finished_s", *keep_finished_s, 60.0)?;
327                ensure!(
328                    !(url.is_some() && url_env.is_some()),
329                    "store: set either url or url_env, not both"
330                );
331            }
332            StoreConfig::Sqlite {
333                path,
334                keep_finished_s,
335            } => {
336                opt_secs("store.keep_finished_s", *keep_finished_s, 60.0)?;
337                ensure!(
338                    !path.as_os_str().is_empty(),
339                    "store.path must name the database file"
340                );
341            }
342        }
343        self.validate_shards()?;
344        Ok(())
345    }
346
347    fn validate_shards(&self) -> anyhow::Result<()> {
348        if self.shards.is_empty() {
349            ensure!(
350                self.server.shard.is_none(),
351                "server.shard is set but no [[shards]] are configured"
352            );
353            if let Some(r) = self.resources.iter().find(|r| r.shard().is_some()) {
354                bail!(
355                    "resource '{}' names a shard but no [[shards]] are configured",
356                    r.name()
357                );
358            }
359            return Ok(());
360        }
361        let mut names = HashSet::new();
362        let mut numbers = HashSet::new();
363        let mut endpoints = HashSet::new();
364        let mut owner: HashMap<&str, &str> = HashMap::new();
365        for sh in &self.shards {
366            ensure!(
367                valid_shard_name(&sh.name),
368                "shard name {:?} is invalid: use 1-32 lower-case letters, digits and '_', \
369                 starting with a letter",
370                sh.name
371            );
372            ensure!(
373                names.insert(sh.name.as_str()),
374                "duplicate shard '{}'",
375                sh.name
376            );
377            ensure!(
378                numbers.insert(sh.number),
379                "shards '{}' and another share number {}",
380                sh.name,
381                sh.number
382            );
383            ensure!(
384                (sh.endpoint.starts_with("https://") || sh.endpoint.starts_with("http://"))
385                    && sh.endpoint.len() <= 256
386                    && !sh
387                        .endpoint
388                        .chars()
389                        .any(|c| c.is_whitespace() || c.is_control())
390                    && http::Uri::try_from(sh.endpoint.as_str()).is_ok_and(|u| u.host().is_some()),
391                "shard '{}': endpoint {:?} must be an http:// or https:// URL",
392                sh.name,
393                sh.endpoint
394            );
395            ensure!(
396                endpoints.insert(sh.endpoint.as_str()),
397                "shard '{}' repeats the endpoint {}",
398                sh.name,
399                sh.endpoint
400            );
401            ensure!(!sh.pools.is_empty(), "shard '{}' has no pools", sh.name);
402            for p in &sh.pools {
403                ensure!(
404                    self.pools.iter().any(|q| q.name == *p),
405                    "shard '{}' names unknown pool '{p}'",
406                    sh.name
407                );
408                if let Some(other) = owner.insert(p.as_str(), sh.name.as_str()) {
409                    bail!("pool '{p}' is in shards '{other}' and '{}'", sh.name);
410                }
411            }
412        }
413        for p in &self.pools {
414            ensure!(
415                owner.contains_key(p.name.as_str()),
416                "pool '{}' is in no shard: with [[shards]], every pool belongs to one",
417                p.name
418            );
419        }
420        // Shards count resources separately: a resource of several shards would have its full
421        // capacity in each.
422        for r in &self.resources {
423            match r.shard() {
424                Some(s) if names.contains(s) => {}
425                Some(s) => bail!("resource '{}' names unknown shard '{s}'", r.name()),
426                None => bail!(
427                    "resource '{}' needs a shard: with [[shards]], each resource belongs to one \
428                     (shards count resources separately; split a shared limit into one \
429                     resource per shard)",
430                    r.name()
431                ),
432            }
433        }
434        let Some(own) = &self.server.shard else {
435            bail!(
436                "[[shards]] are configured: choose this server's shard with server.shard or --shard"
437            );
438        };
439        ensure!(
440            names.contains(own.as_str()),
441            "server.shard '{own}' is not one of the configured [[shards]]"
442        );
443        match &self.store {
444            StoreConfig::Memory => {}
445            StoreConfig::Postgres { schema, .. } => ensure!(
446                schema.as_deref().is_none_or(|s| s.contains("{shard}")),
447                "store.schema must contain {{shard}} with [[shards]], so that each shard has \
448                 its own tables (the default is cronoq_{{shard}})"
449            ),
450            StoreConfig::Sqlite { path, .. } => ensure!(
451                path.to_string_lossy().contains("{shard}"),
452                "store.path must contain {{shard}} with [[shards]], so that each shard has its \
453                 own file"
454            ),
455        }
456        Ok(())
457    }
458
459    /// This server's shard and the full shard list, if the configuration has shards.
460    pub fn shard_plan(&self) -> Option<ShardPlan> {
461        let own = self.server.shard.as_ref()?;
462        Some(ShardPlan {
463            own: self.shards.iter().find(|s| &s.name == own)?.clone(),
464            all: self.shards.clone(),
465            resources: self
466                .resources
467                .iter()
468                .filter_map(|r| Some((r.name().to_string(), r.shard()?.to_string())))
469                .collect(),
470        })
471    }
472
473    /// The configuration this server runs: only its shard's pools, and the store namespace
474    /// (`{shard}` in the schema or path) filled in. Unchanged without shards.
475    pub fn for_own_shard(&self) -> Config {
476        let Some(plan) = self.shard_plan() else {
477            return self.clone();
478        };
479        let mut cfg = self.clone();
480        cfg.pools.retain(|p| plan.own.pools.contains(&p.name));
481        cfg.resources
482            .retain(|r| r.shard() == Some(plan.own.name.as_str()));
483        let fill = |s: &str| s.replace("{shard}", &plan.own.name);
484        cfg.store = match &self.store {
485            StoreConfig::Memory => StoreConfig::Memory,
486            StoreConfig::Postgres {
487                url,
488                url_env,
489                keep_finished_s,
490                schema,
491            } => StoreConfig::Postgres {
492                url: url.clone(),
493                url_env: url_env.clone(),
494                keep_finished_s: *keep_finished_s,
495                schema: Some(fill(schema.as_deref().unwrap_or("cronoq_{shard}"))),
496            },
497            StoreConfig::Sqlite {
498                path,
499                keep_finished_s,
500            } => StoreConfig::Sqlite {
501                path: fill(&path.to_string_lossy()).into(),
502                keep_finished_s: *keep_finished_s,
503            },
504        };
505        cfg
506    }
507
508    /// Validation that depends on the environment (token values) and the bind addresses.
509    pub(crate) fn check_auth_exposure(&self, auth_enabled: bool) -> anyhow::Result<()> {
510        if auth_enabled || self.server.insecure_no_auth {
511            return Ok(());
512        }
513        let public = |a: &SocketAddr| !a.ip().is_loopback();
514        let s = &self.server;
515        if public(&s.grpc_addr)
516            || public(&s.http_addr)
517            || s.metrics_addr.as_ref().is_some_and(public)
518        {
519            bail!(
520                "no auth tokens are configured but the server listens on a non-loopback address; \
521                 configure [[auth.tokens]] (or pass --insecure-no-auth for a trusted network)"
522            );
523        }
524        Ok(())
525    }
526}
527
528/// Listeners, TLS and shutdown behaviour.
529#[derive(Clone, Debug, Deserialize)]
530#[serde(deny_unknown_fields)]
531pub struct ServerConfig {
532    /// gRPC listener (default `127.0.0.1:7070`).
533    #[serde(default = "default_grpc")]
534    pub grpc_addr: SocketAddr,
535    /// HTTP/JSON listener (default `127.0.0.1:7071`).
536    #[serde(default = "default_http")]
537    pub http_addr: SocketAddr,
538    /// Optional unauthenticated listener serving only `/metrics` (bind it to an internal
539    /// network). Without it, `/metrics` on `http_addr` requires a metrics or admin token.
540    pub metrics_addr: Option<SocketAddr>,
541    /// TLS for both the gRPC and the HTTP listener (and mTLS with `client_ca`).
542    pub tls: Option<TlsConfig>,
543    /// Allow running without any auth token on non-loopback addresses.
544    #[serde(default)]
545    pub insecure_no_auth: bool,
546    /// Grace period for running attempts on shutdown.
547    #[serde(default = "default_grace")]
548    pub shutdown_grace_s: f64,
549    /// Serve gRPC-Web for browsers on the gRPC listener (off unless set).
550    pub grpc_web: Option<GrpcWebConfig>,
551    /// The shard this server serves, one of `[[shards]]` (also `--shard`).
552    pub shard: Option<String>,
553    /// TLS for calls to other shards' `https://` endpoints.
554    pub peer_tls: Option<PeerTlsConfig>,
555    /// Forward callers' tokens to other shards over plain `http://` endpoints on other hosts.
556    /// Off by default: such calls are refused with the shard's endpoint instead, so tokens do
557    /// not cross the network unencrypted. Trusted networks only.
558    #[serde(default)]
559    pub insecure_peer_http: bool,
560}
561
562/// How this server verifies (and identifies itself to) other shards' servers.
563#[derive(Clone, Debug, Deserialize)]
564#[serde(deny_unknown_fields)]
565pub struct PeerTlsConfig {
566    /// PEM CA bundle for the other shards' certificates (default: the system roots).
567    pub ca: Option<PathBuf>,
568    /// PEM client certificate, for shards that require mTLS (with `key`).
569    pub cert: Option<PathBuf>,
570    /// PEM private key of `cert`.
571    pub key: Option<PathBuf>,
572}
573
574/// gRPC-Web for browser clients.
575#[derive(Clone, Debug, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub struct GrpcWebConfig {
578    /// Origins (`https://dash.example.com`, scheme, host and optional port) whose pages may
579    /// call the API. Credentials are still required as for any other call.
580    pub allowed_origins: Vec<String>,
581}
582
583/// Whether `o` is an exact origin: `http` or `https`, a host, an optional port, nothing else.
584fn valid_origin(o: &str) -> bool {
585    let Some((scheme, rest)) = o.split_once("://") else {
586        return false;
587    };
588    if !matches!(scheme, "http" | "https") || rest.is_empty() || o.len() > 256 {
589        return false;
590    }
591    let (host, port) = match rest.strip_prefix('[') {
592        Some(v6) => match v6.split_once(']') {
593            Some((ip, port)) => {
594                if ip.parse::<std::net::Ipv6Addr>().is_err() {
595                    return false;
596                }
597                (ip, port)
598            }
599            None => return false,
600        },
601        None => match rest.find(':') {
602            Some(i) => (&rest[..i], &rest[i..]),
603            None => (rest, ""),
604        },
605    };
606    let port_ok = port.is_empty()
607        || port
608            .strip_prefix(':')
609            .is_some_and(|p| !p.is_empty() && p.parse::<u16>().is_ok());
610    let host_ok = !host.is_empty()
611        && host
612            .chars()
613            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | ':'));
614    port_ok && host_ok
615}
616
617impl Default for ServerConfig {
618    fn default() -> Self {
619        ServerConfig {
620            grpc_addr: default_grpc(),
621            http_addr: default_http(),
622            metrics_addr: None,
623            tls: None,
624            insecure_no_auth: false,
625            shutdown_grace_s: default_grace(),
626            grpc_web: None,
627            shard: None,
628            peer_tls: None,
629            insecure_peer_http: false,
630        }
631    }
632}
633
634fn default_grpc() -> SocketAddr {
635    "127.0.0.1:7070".parse().unwrap()
636}
637fn default_http() -> SocketAddr {
638    "127.0.0.1:7071".parse().unwrap()
639}
640fn default_grace() -> f64 {
641    10.0
642}
643
644/// TLS material, read once at startup.
645#[derive(Clone, Debug, Deserialize)]
646#[serde(deny_unknown_fields)]
647pub struct TlsConfig {
648    /// PEM certificate chain.
649    pub cert: PathBuf,
650    /// PEM private key.
651    pub key: PathBuf,
652    /// Require client certificates signed by this CA (mTLS) on both listeners.
653    pub client_ca: Option<PathBuf>,
654}
655
656/// Permission level of a token or client certificate. `Admin` may do everything.
657#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
658#[serde(rename_all = "snake_case")]
659pub enum Role {
660    /// Submit, inspect, wait for, cancel and watch tasks.
661    Client,
662    /// Serve leases on the `Workers` stream.
663    Worker,
664    /// Read snapshots and metrics.
665    Metrics,
666    /// Everything, including recurring schedules.
667    Admin,
668}
669
670impl Role {
671    /// Name used in configuration and errors.
672    pub fn name(self) -> &'static str {
673        match self {
674            Role::Client => "client",
675            Role::Worker => "worker",
676            Role::Metrics => "metrics",
677            Role::Admin => "admin",
678        }
679    }
680}
681
682/// Authentication settings.
683#[derive(Clone, Debug, Default, Deserialize)]
684#[serde(deny_unknown_fields)]
685pub struct AuthConfig {
686    /// Bearer tokens.
687    #[serde(default)]
688    pub tokens: Vec<TokenConfig>,
689    /// Roles for client certificates (mTLS), matched on a DNS subject alternative name.
690    #[serde(default)]
691    pub certs: Vec<CertRoleConfig>,
692}
693
694/// One bearer token.
695#[derive(Clone, Deserialize)]
696#[serde(deny_unknown_fields)]
697pub struct TokenConfig {
698    /// The token (prefer `token_env`; at least 16 characters, 32+ recommended).
699    pub token: Option<String>,
700    /// Read the token from this environment variable instead.
701    pub token_env: Option<String>,
702    /// What the token may do.
703    pub role: Role,
704    /// Restrict a client or worker token to these pools.
705    pub pools: Option<Vec<String>>,
706    /// Worker labels (`"name=value"`) a worker with this token may claim (default: any).
707    pub labels: Option<Vec<String>>,
708    /// Tenant of every task submitted with this token; submitting for another tenant is refused.
709    pub tenant: Option<String>,
710}
711
712impl fmt::Debug for TokenConfig {
713    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
714        f.debug_struct("TokenConfig")
715            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
716            .field("token_env", &self.token_env)
717            .field("role", &self.role)
718            .field("pools", &self.pools)
719            .finish()
720    }
721}
722
723/// Role for client certificates presenting a DNS subject alternative name.
724#[derive(Clone, Debug, Deserialize)]
725#[serde(deny_unknown_fields)]
726pub struct CertRoleConfig {
727    /// DNS SAN to match exactly (e.g. `worker-1.internal`).
728    pub dns_name: String,
729    /// Role granted.
730    pub role: Role,
731    /// Optional pool allow-list.
732    pub pools: Option<Vec<String>>,
733    /// Worker labels (`"name=value"`) a worker with this certificate may claim (default: any).
734    pub labels: Option<Vec<String>>,
735    /// Tenant of every task submitted with this certificate.
736    pub tenant: Option<String>,
737}
738
739/// Scheduler state storage.
740#[derive(Clone, Default, Deserialize)]
741#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
742pub enum StoreConfig {
743    /// In memory only (state is lost on restart).
744    #[default]
745    Memory,
746    /// PostgreSQL: durable state and active-passive HA.
747    Postgres {
748        /// Connection URL (prefer `url_env`; use `sslmode=verify-full` across networks).
749        url: Option<String>,
750        /// Read the URL from this environment variable (default `DATABASE_URL`).
751        url_env: Option<String>,
752        /// Delete finished tasks older than this (default 7 days).
753        keep_finished_s: Option<f64>,
754        /// PostgreSQL schema for cronoq's tables (default `public`). Instances sharing a schema
755        /// form one cluster. A dedicated schema that only cronoq's role can create objects in
756        /// keeps other database roles away from its tables.
757        schema: Option<String>,
758    },
759    /// SQLite: durable state in a local file; server processes sharing the file elect one
760    /// leader and the others stand by.
761    Sqlite {
762        /// Database file (created if missing). Keep it on a local filesystem.
763        path: std::path::PathBuf,
764        /// Delete finished tasks older than this (default 7 days).
765        keep_finished_s: Option<f64>,
766    },
767}
768
769impl fmt::Debug for StoreConfig {
770    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
771        match self {
772            StoreConfig::Memory => f.write_str("Memory"),
773            StoreConfig::Postgres {
774                url,
775                url_env,
776                keep_finished_s,
777                schema,
778            } => f
779                .debug_struct("Postgres")
780                .field("url", &url.as_ref().map(|_| "<redacted>"))
781                .field("url_env", url_env)
782                .field("keep_finished_s", keep_finished_s)
783                .field("schema", schema)
784                .finish(),
785            StoreConfig::Sqlite {
786                path,
787                keep_finished_s,
788            } => f
789                .debug_struct("Sqlite")
790                .field("path", path)
791                .field("keep_finished_s", keep_finished_s)
792                .finish(),
793        }
794    }
795}
796
797/// Request and resource limits (defaults are safe for a shared deployment).
798#[derive(Clone, Debug, Deserialize)]
799#[serde(deny_unknown_fields, default)]
800pub struct LimitsConfig {
801    /// Tasks per `SubmitBatch` call.
802    pub max_tasks_per_batch: usize,
803    /// Pending tasks across all pools.
804    pub max_pending_total: usize,
805    /// Default per-pool pending bound (overridable per pool with `max_pending`).
806    pub max_pending_per_pool: usize,
807    /// Largest gRPC message accepted or sent (payloads and outputs ride in these).
808    pub max_message_bytes: usize,
809    /// Largest HTTP request body.
810    pub max_http_body_bytes: usize,
811    /// Longest task key.
812    pub max_key_len: usize,
813    /// Longest task kind.
814    pub max_kind_len: usize,
815    /// Most tags per task.
816    pub max_tags: usize,
817    /// Longest tag key or value.
818    pub max_tag_len: usize,
819    /// Most dependencies per task.
820    pub max_deps: usize,
821    /// Most resource entries per task.
822    pub max_resources: usize,
823    /// Longest server-side wait for a task result (`Wait`, `Submit{wait}`).
824    pub max_wait_s: f64,
825    /// Timeout for any other request.
826    pub request_timeout_s: f64,
827    /// Requests processed concurrently per listener (excess requests queue).
828    pub max_concurrent_requests: usize,
829    /// HTTP/2 streams per connection.
830    pub max_concurrent_streams: u32,
831    /// Slots a single worker may register (also capped by the pool's `max`).
832    pub max_slots_per_worker: u32,
833    /// HTTP/2 keepalive ping interval (evicts half-open workers and clients).
834    pub keepalive_interval_s: f64,
835    /// HTTP/2 keepalive ping timeout.
836    pub keepalive_timeout_s: f64,
837    /// How long a finished task's key keeps deduplicating submissions: resubmitting it returns
838    /// the finished task (and its stored output). Also how far back a new leader loads
839    /// finished tasks from the store.
840    pub key_retention_s: f64,
841    /// Most finished tasks kept for key deduplication (bounds memory).
842    pub key_retention_max: usize,
843    /// Most tasks one `ListTasks` page returns.
844    pub max_list: usize,
845}
846
847impl Default for LimitsConfig {
848    fn default() -> Self {
849        LimitsConfig {
850            max_tasks_per_batch: 1000,
851            max_pending_total: 1_000_000,
852            max_pending_per_pool: 100_000,
853            max_message_bytes: 16 << 20,
854            max_http_body_bytes: 16 << 20,
855            max_key_len: 256,
856            max_kind_len: 128,
857            max_tags: 32,
858            max_tag_len: 1024,
859            max_deps: 256,
860            max_resources: 16,
861            max_wait_s: 300.0,
862            request_timeout_s: 30.0,
863            max_concurrent_requests: 4096,
864            max_concurrent_streams: 1024,
865            max_slots_per_worker: 1024,
866            keepalive_interval_s: 20.0,
867            keepalive_timeout_s: 20.0,
868            key_retention_s: 3600.0,
869            key_retention_max: 1_000_000,
870            max_list: 1000,
871        }
872    }
873}
874
875impl LimitsConfig {
876    fn validate(&self) -> anyhow::Result<()> {
877        ensure!(
878            self.max_tasks_per_batch >= 1,
879            "limits.max_tasks_per_batch must be at least 1"
880        );
881        ensure!(
882            self.max_pending_total >= 1,
883            "limits.max_pending_total must be at least 1"
884        );
885        ensure!(
886            self.max_pending_per_pool >= 1,
887            "limits.max_pending_per_pool must be at least 1"
888        );
889        ensure!(
890            self.max_message_bytes >= 4096,
891            "limits.max_message_bytes must be at least 4096"
892        );
893        ensure!(
894            self.max_http_body_bytes >= 4096,
895            "limits.max_http_body_bytes must be at least 4096"
896        );
897        ensure!(
898            self.max_key_len >= 1 && self.max_kind_len >= 1,
899            "key and kind limits must be at least 1"
900        );
901        ensure!(
902            self.max_tag_len >= 1,
903            "limits.max_tag_len must be at least 1"
904        );
905        ensure!(
906            self.max_concurrent_requests >= 1,
907            "limits.max_concurrent_requests must be at least 1"
908        );
909        ensure!(
910            self.max_concurrent_streams >= 1,
911            "limits.max_concurrent_streams must be at least 1"
912        );
913        ensure!(
914            self.max_slots_per_worker >= 1,
915            "limits.max_slots_per_worker must be at least 1"
916        );
917        ensure!(
918            (1..=100_000).contains(&self.max_list),
919            "limits.max_list must be between 1 and 100000"
920        );
921        finite_secs("limits.max_wait_s", self.max_wait_s, 0.0)?;
922        finite_secs("limits.request_timeout_s", self.request_timeout_s, 0.1)?;
923        finite_secs(
924            "limits.keepalive_interval_s",
925            self.keepalive_interval_s,
926            0.1,
927        )?;
928        finite_secs("limits.keepalive_timeout_s", self.keepalive_timeout_s, 0.1)?;
929        finite_secs("limits.key_retention_s", self.key_retention_s, 0.0)?;
930        Ok(())
931    }
932
933    /// `key_retention_s` as a duration.
934    pub fn key_retention(&self) -> Duration {
935        Duration::from_secs_f64(self.key_retention_s)
936    }
937
938    /// `max_wait_s` as a duration.
939    pub fn max_wait(&self) -> Duration {
940        Duration::from_secs_f64(self.max_wait_s)
941    }
942
943    /// `request_timeout_s` as a duration.
944    pub fn request_timeout(&self) -> Duration {
945        Duration::from_secs_f64(self.request_timeout_s)
946    }
947
948    /// `keepalive_interval_s` as a duration.
949    pub fn keepalive_interval(&self) -> Duration {
950        Duration::from_secs_f64(self.keepalive_interval_s)
951    }
952
953    /// `keepalive_timeout_s` as a duration.
954    pub fn keepalive_timeout(&self) -> Duration {
955        Duration::from_secs_f64(self.keepalive_timeout_s)
956    }
957}
958
959/// In-memory cache of recent outputs, for `Wait`/`Get` after completion.
960#[derive(Clone, Debug, Deserialize)]
961#[serde(deny_unknown_fields)]
962pub struct OutputsConfig {
963    /// Most outputs kept.
964    #[serde(default = "default_cache_entries")]
965    pub cache_entries: usize,
966    /// Most bytes kept.
967    #[serde(default = "default_cache_bytes")]
968    pub cache_bytes: usize,
969    /// Most task records kept for `Get` (pool, kind, timestamps, last error).
970    #[serde(default = "default_info_entries")]
971    pub info_entries: usize,
972}
973
974impl Default for OutputsConfig {
975    fn default() -> Self {
976        OutputsConfig {
977            cache_entries: default_cache_entries(),
978            cache_bytes: default_cache_bytes(),
979            info_entries: default_info_entries(),
980        }
981    }
982}
983
984fn default_cache_entries() -> usize {
985    10_000
986}
987fn default_cache_bytes() -> usize {
988    64 << 20
989}
990fn default_info_entries() -> usize {
991    100_000
992}
993
994/// How a pool's desired capacity reaches workers.
995#[derive(Clone, Debug, Default, Deserialize)]
996#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
997pub enum ScalerSection {
998    /// Capacity = slots of connected workers; the desired count is exported as a metric.
999    #[default]
1000    External,
1001    /// POST `{"pool": ..., "desired": n}` to `url` when the target changes.
1002    Webhook {
1003        /// Endpoint (https recommended).
1004        url: String,
1005        /// Bearer token for the webhook, from this environment variable (must be set).
1006        token_env: Option<String>,
1007    },
1008}
1009
1010/// One pool served by remote workers.
1011#[derive(Clone, Debug, Deserialize)]
1012#[serde(deny_unknown_fields)]
1013pub struct PoolSection {
1014    /// Pool name (1-64 characters from `[A-Za-z0-9_.:-]`).
1015    pub name: String,
1016    /// Autoscaler lower bound.
1017    #[serde(default)]
1018    pub min: u32,
1019    /// Autoscaler upper bound, reservation capacity basis, and per-worker slot cap.
1020    #[serde(default = "default_max")]
1021    pub max: u32,
1022    /// Compute a desired capacity from queue pressure.
1023    #[serde(default = "default_true")]
1024    pub autoscale: bool,
1025    /// How the desired capacity is communicated.
1026    #[serde(default)]
1027    pub scaler: ScalerSection,
1028    /// Remote workers must heartbeat each lease within this many seconds.
1029    #[serde(default = "default_lease_ttl")]
1030    pub lease_ttl_s: f64,
1031    /// Pending-task bound (default `limits.max_pending_per_pool`).
1032    pub max_pending: Option<usize>,
1033    /// Behaviour when the bound is reached.
1034    #[serde(default = "default_overflow")]
1035    pub overflow: Overflow,
1036    /// Default retry policy.
1037    pub retry: Option<RetrySection>,
1038    /// Duration estimate when nothing is known.
1039    pub default_duration_s: Option<f64>,
1040    /// Share of `max` that fixed-start reservations may occupy, in (0, 1].
1041    pub reservable_fraction: Option<f64>,
1042    /// Allow dependencies on keys that do not exist yet (wait up to this many seconds).
1043    pub await_unknown_dependencies_s: Option<f64>,
1044    /// Autoscaler utilization target, in (0, 1].
1045    pub target_utilization: Option<f64>,
1046    /// Autoscaler backlog drain target.
1047    pub drain_time_s: Option<f64>,
1048    /// Autoscaler scale-down cooldown.
1049    pub cooldown_s: Option<f64>,
1050    /// Cost per worker-second, for the cost metric.
1051    pub cost_per_worker_second: Option<f64>,
1052    /// Slots a single worker may register in this pool (default `limits.max_slots_per_worker`,
1053    /// never above `max`).
1054    pub max_slots_per_worker: Option<u32>,
1055    /// Share the pool fairly between tenants (implied by `tenant_weights`).
1056    #[serde(default)]
1057    pub fair_share: bool,
1058    /// Weight of each tenant in the fair share (others weigh 1).
1059    #[serde(default)]
1060    pub tenant_weights: std::collections::BTreeMap<String, f64>,
1061}
1062
1063fn default_max() -> u32 {
1064    10
1065}
1066fn default_true() -> bool {
1067    true
1068}
1069fn default_lease_ttl() -> f64 {
1070    30.0
1071}
1072fn default_overflow() -> Overflow {
1073    Overflow::Reject
1074}
1075
1076impl PoolSection {
1077    /// A pool with default settings.
1078    pub fn named(name: &str) -> Self {
1079        PoolSection {
1080            name: name.into(),
1081            min: 0,
1082            max: default_max(),
1083            autoscale: true,
1084            scaler: ScalerSection::External,
1085            lease_ttl_s: default_lease_ttl(),
1086            max_pending: None,
1087            overflow: Overflow::Reject,
1088            retry: None,
1089            default_duration_s: None,
1090            reservable_fraction: None,
1091            await_unknown_dependencies_s: None,
1092            target_utilization: None,
1093            drain_time_s: None,
1094            cooldown_s: None,
1095            cost_per_worker_second: None,
1096            max_slots_per_worker: None,
1097            fair_share: false,
1098            tenant_weights: Default::default(),
1099        }
1100    }
1101
1102    /// Slots one worker may register in this pool.
1103    pub fn slot_cap(&self, limits: &LimitsConfig) -> u32 {
1104        self.max_slots_per_worker
1105            .unwrap_or(limits.max_slots_per_worker)
1106            .min(limits.max_slots_per_worker)
1107            .min(self.max)
1108            .max(1)
1109    }
1110
1111    /// Engine configuration. Assumes [`Config::validate`] passed.
1112    pub fn to_pool_config(&self, limits: &LimitsConfig) -> PoolConfig {
1113        // Capacity comes from connected workers; `min` bounds only the desired capacity.
1114        let mut c = PoolConfig::new(self.name.as_str()).workers(self.min, 0, self.max);
1115        c.lease_ttl = Some(Duration::from_secs_f64(self.lease_ttl_s));
1116        c.bounds.max_pending = Some(self.max_pending.unwrap_or(limits.max_pending_per_pool));
1117        c.overflow = self.overflow.into();
1118        if let Some(r) = &self.retry {
1119            c.retry = r.to_policy();
1120        }
1121        if let Some(d) = self.default_duration_s {
1122            c.default_duration = Duration::from_secs_f64(d);
1123        }
1124        if let Some(f) = self.reservable_fraction {
1125            c.reservable_fraction = f;
1126        }
1127        if let Some(t) = self.await_unknown_dependencies_s {
1128            c.unknown_dependency = cronoq::UnknownDependency::Await {
1129                timeout: Some(Duration::from_secs_f64(t)),
1130            };
1131        }
1132        if self.fair_share || !self.tenant_weights.is_empty() {
1133            c.fairness = Some(cronoq::Fairness::weights(
1134                self.tenant_weights.iter().map(|(t, w)| (t.as_str(), *w)),
1135            ));
1136        }
1137        c.autoscale = self.autoscale.then(|| {
1138            let mut a = AutoscaleConfig::default();
1139            if let Some(v) = self.target_utilization {
1140                a.target_utilization = v;
1141            }
1142            if let Some(v) = self.drain_time_s {
1143                a.drain_time = Duration::from_secs_f64(v);
1144            }
1145            if let Some(v) = self.cooldown_s {
1146                a.cooldown = Duration::from_secs_f64(v);
1147            }
1148            if let Some(v) = self.cost_per_worker_second {
1149                a.cost_per_worker_second = v;
1150            }
1151            a
1152        });
1153        c
1154    }
1155}
1156
1157/// Behaviour when a pool's pending bound is reached.
1158#[derive(Copy, Clone, Debug, Deserialize)]
1159#[serde(rename_all = "snake_case")]
1160pub enum Overflow {
1161    /// Hold the request until there is room (bounded by the request timeout).
1162    Wait,
1163    /// Reject with `RESOURCE_EXHAUSTED` / HTTP 429.
1164    Reject,
1165    /// Evict the newest pending task of the lowest lower class.
1166    ShedLowest,
1167    /// Evict the oldest pending low-priority task.
1168    ShedOldest,
1169}
1170
1171impl From<Overflow> for OverflowPolicy {
1172    fn from(o: Overflow) -> Self {
1173        match o {
1174            Overflow::Wait => OverflowPolicy::Wait,
1175            Overflow::Reject => OverflowPolicy::Reject,
1176            Overflow::ShedLowest => OverflowPolicy::ShedLowest,
1177            Overflow::ShedOldest => OverflowPolicy::ShedOldest,
1178        }
1179    }
1180}
1181
1182/// Retry policy in seconds.
1183#[derive(Clone, Debug, Deserialize)]
1184#[serde(deny_unknown_fields)]
1185pub struct RetrySection {
1186    /// Attempts including the first.
1187    pub max_attempts: u32,
1188    /// First backoff.
1189    #[serde(default = "one")]
1190    pub initial_backoff_s: f64,
1191    /// Backoff growth factor (at least 1).
1192    #[serde(default = "two")]
1193    pub multiplier: f64,
1194    /// Backoff ceiling.
1195    #[serde(default = "sixty")]
1196    pub max_backoff_s: f64,
1197    /// Full jitter.
1198    #[serde(default = "default_true")]
1199    pub jitter: bool,
1200}
1201
1202fn one() -> f64 {
1203    1.0
1204}
1205fn two() -> f64 {
1206    2.0
1207}
1208fn sixty() -> f64 {
1209    60.0
1210}
1211
1212impl RetrySection {
1213    fn validate(&self, at: &str) -> anyhow::Result<()> {
1214        ensure!(
1215            self.max_attempts >= 1,
1216            "{at}.max_attempts must be at least 1"
1217        );
1218        finite_secs(
1219            &format!("{at}.initial_backoff_s"),
1220            self.initial_backoff_s,
1221            0.0,
1222        )?;
1223        finite_secs(&format!("{at}.max_backoff_s"), self.max_backoff_s, 0.0)?;
1224        ensure!(
1225            self.multiplier.is_finite() && (1.0..=1000.0).contains(&self.multiplier),
1226            "{at}.multiplier must be between 1 and 1000"
1227        );
1228        Ok(())
1229    }
1230
1231    /// Engine retry policy. Assumes validation passed.
1232    pub fn to_policy(&self) -> RetryPolicy {
1233        RetryPolicy {
1234            max_attempts: self.max_attempts.max(1),
1235            initial_backoff: Duration::from_secs_f64(self.initial_backoff_s),
1236            multiplier: self.multiplier,
1237            max_backoff: Duration::from_secs_f64(self.max_backoff_s),
1238            jitter: self.jitter,
1239        }
1240    }
1241}
1242
1243/// A shared resource.
1244#[derive(Clone, Debug, Deserialize)]
1245#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1246pub enum ResourceSection {
1247    /// At most `permits` units held at once.
1248    Semaphore {
1249        /// Resource name.
1250        name: String,
1251        /// Concurrent units.
1252        permits: u32,
1253        /// With `[[shards]]`, the shard that owns the resource.
1254        #[serde(default)]
1255        shard: Option<String>,
1256    },
1257    /// Units refill at `rate` per second up to `burst`.
1258    TokenBucket {
1259        /// Resource name.
1260        name: String,
1261        /// Refill rate per second.
1262        rate: f64,
1263        /// Bucket size.
1264        burst: u32,
1265        /// With `[[shards]]`, the shard that owns the resource.
1266        #[serde(default)]
1267        shard: Option<String>,
1268    },
1269}
1270
1271impl ResourceSection {
1272    /// The resource name.
1273    pub fn name(&self) -> &str {
1274        match self {
1275            ResourceSection::Semaphore { name, .. } | ResourceSection::TokenBucket { name, .. } => {
1276                name
1277            }
1278        }
1279    }
1280
1281    /// The shard that owns the resource, if set.
1282    pub fn shard(&self) -> Option<&str> {
1283        match self {
1284            ResourceSection::Semaphore { shard, .. }
1285            | ResourceSection::TokenBucket { shard, .. } => shard.as_deref(),
1286        }
1287    }
1288
1289    /// Engine configuration.
1290    pub fn to_config(&self) -> ResourceConfig {
1291        match self {
1292            ResourceSection::Semaphore { name, permits, .. } => {
1293                ResourceConfig::semaphore(name.as_str(), *permits)
1294            }
1295            ResourceSection::TokenBucket {
1296                name, rate, burst, ..
1297            } => ResourceConfig::token_bucket(name.as_str(), *rate, *burst),
1298        }
1299    }
1300}
1301
1302#[cfg(test)]
1303mod tests {
1304    use super::*;
1305
1306    #[test]
1307    fn peer_certificates_need_local_authentication() {
1308        let base =
1309            "[[pools]]\nname = \"default\"\n[server.peer_tls]\ncert = \"c.pem\"\nkey = \"k.pem\"\n";
1310        let open: Config = toml::from_str(base).unwrap();
1311        let e = open.validate().unwrap_err().to_string();
1312        assert!(e.contains("needs authentication"), "{e}");
1313        let with_tokens: Config = toml::from_str(&format!(
1314            "{base}[[auth.tokens]]\ntoken = \"0123456789abcdef0123456789\"\nrole = \"client\"\n"
1315        ))
1316        .unwrap();
1317        with_tokens.validate().unwrap();
1318    }
1319
1320    #[test]
1321    fn shard_layouts_are_checked() {
1322        const POOLS: &str = "[[pools]]\nname = \"default\"\n[[pools]]\nname = \"llm\"\n";
1323        let shards = |a: &str, b: &str| {
1324            format!(
1325                "[[shards]]\nname = \"cpu\"\nnumber = 1\npools = [{a}]\nendpoint = \"http://cpu:7070\"\n\
1326                 [[shards]]\nname = \"gpu\"\nnumber = 2\npools = [{b}]\nendpoint = \"http://gpu:7070\"\n"
1327            )
1328        };
1329        let ok = shards("\"default\"", "\"llm\"");
1330        let cfg = |server: &str, store: &str, sh: &str| {
1331            let mut c: Config = toml::from_str(&format!("{server}{store}{POOLS}{sh}")).unwrap();
1332            if c.server.shard.is_none() {
1333                c.server.shard = Some("gpu".into());
1334            }
1335            c
1336        };
1337        let good = cfg("", "", &ok);
1338        good.validate().unwrap();
1339        let own = good.for_own_shard();
1340        assert_eq!(own.pools.len(), 1);
1341        assert_eq!(own.pools[0].name, "llm");
1342        assert_eq!(good.shard_plan().unwrap().own.number, 2);
1343
1344        // Store namespaces are filled in per shard.
1345        let pg = cfg("", "[store]\nkind = \"postgres\"\nurl_env = \"X\"\n", &ok).for_own_shard();
1346        assert!(
1347            matches!(pg.store, StoreConfig::Postgres { schema: Some(ref s), .. } if s == "cronoq_gpu")
1348        );
1349        let pg = cfg(
1350            "",
1351            "[store]\nkind = \"postgres\"\nurl_env = \"X\"\nschema = \"q_{shard}\"\n",
1352            &ok,
1353        )
1354        .for_own_shard();
1355        assert!(
1356            matches!(pg.store, StoreConfig::Postgres { schema: Some(ref s), .. } if s == "q_gpu")
1357        );
1358        let lite = cfg(
1359            "",
1360            "[store]\nkind = \"sqlite\"\npath = \"/d/{shard}.db\"\n",
1361            &ok,
1362        )
1363        .for_own_shard();
1364        assert!(
1365            matches!(lite.store, StoreConfig::Sqlite { ref path, .. } if path.to_str() == Some("/d/gpu.db"))
1366        );
1367
1368        let extra_pool = "[[pools]]\nname = \"extra\"\n";
1369        for (c, want) in [
1370            (
1371                cfg("", "", &shards("\"default\"", "\"llm\", \"nope\"")),
1372                "unknown pool 'nope'",
1373            ),
1374            (
1375                cfg("", "", &shards("\"default\", \"llm\"", "\"llm\"")),
1376                "is in shards",
1377            ),
1378            (cfg("", extra_pool, &ok), "pool 'extra' is in no shard"),
1379            (
1380                cfg("", "", &shards("\"default\", \"llm\"", "")),
1381                "has no pools",
1382            ),
1383            (
1384                cfg("", "", &ok.replace("number = 2", "number = 1")),
1385                "share number",
1386            ),
1387            (
1388                cfg("", "", &ok.replace("http://gpu:7070", "http://cpu:7070")),
1389                "repeats the endpoint",
1390            ),
1391            (
1392                cfg("", "", &ok.replace("http://gpu:7070", "gpu:7070")),
1393                "must be an http",
1394            ),
1395            (cfg("", "", &ok.replace("\"gpu\"", "\"GPU\"")), "is invalid"),
1396            (
1397                cfg("[server]\nshard = \"tpu\"\n", "", &ok),
1398                "not one of the configured",
1399            ),
1400            (
1401                cfg(
1402                    "",
1403                    "[store]\nkind = \"postgres\"\nurl_env = \"X\"\nschema = \"one\"\n",
1404                    &ok,
1405                ),
1406                "store.schema must contain {shard}",
1407            ),
1408            (
1409                cfg(
1410                    "",
1411                    "[store]\nkind = \"sqlite\"\npath = \"/d/one.db\"\n",
1412                    &ok,
1413                ),
1414                "store.path must contain {shard}",
1415            ),
1416            (
1417                cfg(
1418                    "",
1419                    "[[resources]]\nkind = \"semaphore\"\nname = \"api\"\npermits = 2\n",
1420                    &ok,
1421                ),
1422                "resource 'api' needs a shard",
1423            ),
1424            (
1425                cfg(
1426                    "",
1427                    "[[resources]]\nkind = \"semaphore\"\nname = \"api\"\npermits = 2\nshard = \"tpu\"\n",
1428                    &ok,
1429                ),
1430                "unknown shard 'tpu'",
1431            ),
1432        ] {
1433            let e = c.validate().unwrap_err().to_string();
1434            assert!(e.contains(want), "{e:?} lacks {want:?}");
1435        }
1436        let mut no_shard = cfg("", "", &ok);
1437        no_shard.server.shard = None;
1438        assert!(
1439            no_shard
1440                .validate()
1441                .unwrap_err()
1442                .to_string()
1443                .contains("--shard")
1444        );
1445        let mut stray: Config =
1446            toml::from_str(&format!("[server]\nshard = \"x\"\n{POOLS}")).unwrap();
1447        assert!(stray.validate().is_err());
1448        stray.server.shard = None;
1449        stray.validate().unwrap();
1450        let bound: Config = toml::from_str(&format!(
1451            "{POOLS}[[resources]]\nkind = \"semaphore\"\nname = \"api\"\npermits = 2\nshard = \"gpu\"\n"
1452        ))
1453        .unwrap();
1454        assert!(
1455            bound
1456                .validate()
1457                .unwrap_err()
1458                .to_string()
1459                .contains("no [[shards]]")
1460        );
1461        // A shard runs only its own resources.
1462        let two = cfg(
1463            "",
1464            "[[resources]]\nkind = \"semaphore\"\nname = \"a\"\npermits = 1\nshard = \"cpu\"\n\
1465             [[resources]]\nkind = \"token_bucket\"\nname = \"b\"\nrate = 1.0\nburst = 1\nshard = \"gpu\"\n",
1466            &ok,
1467        );
1468        two.validate().unwrap();
1469        let own = two.for_own_shard();
1470        assert_eq!(own.resources.len(), 1);
1471        assert_eq!(own.resources[0].name(), "b");
1472        assert_eq!(stray.for_own_shard().pools.len(), 2);
1473    }
1474
1475    #[test]
1476    fn grpc_web_origins_must_be_exact() {
1477        for ok in [
1478            "https://dash.example.com",
1479            "http://localhost:3000",
1480            "https://[::1]:8443",
1481            "http://127.0.0.1",
1482        ] {
1483            assert!(valid_origin(ok), "{ok}");
1484        }
1485        for bad in [
1486            "*",
1487            "https://*.example.com",
1488            "https://dash.example.com/",
1489            "https://dash.example.com/app",
1490            "dash.example.com",
1491            "ftp://dash.example.com",
1492            "https://",
1493            "https://host:",
1494            "https://host:99999",
1495            "https://[nope]:1",
1496            "https://a b",
1497        ] {
1498            assert!(!valid_origin(bad), "{bad}");
1499        }
1500        let base = "[[pools]]\nname = \"default\"\n[server.grpc_web]\n";
1501        assert!(Config::parse(&format!("{base}allowed_origins = []\n")).is_err());
1502        assert!(Config::parse(&format!("{base}allowed_origins = [\"*\"]\n")).is_err());
1503        let c = Config::parse(&format!(
1504            "{base}allowed_origins = [\"https://x.example\"]\n"
1505        ))
1506        .unwrap();
1507        assert_eq!(
1508            c.server.grpc_web.unwrap().allowed_origins,
1509            ["https://x.example"]
1510        );
1511    }
1512
1513    #[test]
1514    fn parses_full_config() {
1515        let cfg = Config::parse(
1516            r#"
1517            [server]
1518            grpc_addr = "127.0.0.1:9000"
1519            [[auth.tokens]]
1520            token = "abcdefghijklmnopqrstuvwxyz012345"
1521            role = "client"
1522            pools = ["llm"]
1523            [[auth.tokens]]
1524            token_env = "WORKER_TOKEN"
1525            role = "worker"
1526            [store]
1527            kind = "postgres"
1528            url_env = "DB"
1529            [limits]
1530            max_tasks_per_batch = 50
1531            [[pools]]
1532            name = "llm"
1533            max = 20
1534            lease_ttl_s = 10
1535            overflow = "shed_lowest"
1536            scaler = { kind = "webhook", url = "http://scaler/scale" }
1537            retry = { max_attempts = 5 }
1538            [[resources]]
1539            kind = "token_bucket"
1540            name = "openai"
1541            rate = 50
1542            burst = 100
1543            "#,
1544        )
1545        .unwrap();
1546        assert_eq!(cfg.pools[0].name, "llm");
1547        assert!(matches!(cfg.pools[0].scaler, ScalerSection::Webhook { .. }));
1548        assert_eq!(
1549            cfg.pools[0].to_pool_config(&cfg.limits).retry.max_attempts,
1550            5
1551        );
1552        assert_eq!(
1553            cfg.pools[0].to_pool_config(&cfg.limits).bounds.max_pending,
1554            Some(100_000)
1555        );
1556        assert!(matches!(cfg.store, StoreConfig::Postgres { .. }));
1557        assert_eq!(cfg.auth.tokens.len(), 2);
1558        assert_eq!(cfg.limits.max_tasks_per_batch, 50);
1559        assert!(Config::parse("[[pools]]\nname='x'\nmin=5\nmax=1").is_err());
1560        assert!(Config::parse("bogus = 1").is_err());
1561    }
1562
1563    #[test]
1564    fn defaults_are_secure() {
1565        let c = Config::default();
1566        assert!(c.server.grpc_addr.ip().is_loopback());
1567        assert!(c.server.http_addr.ip().is_loopback());
1568        assert!(
1569            c.check_auth_exposure(false).is_ok(),
1570            "loopback without auth is fine"
1571        );
1572        let mut public = c.clone();
1573        public.server.http_addr = "0.0.0.0:7071".parse().unwrap();
1574        assert!(public.check_auth_exposure(false).is_err());
1575        assert!(public.check_auth_exposure(true).is_ok());
1576        public.server.insecure_no_auth = true;
1577        assert!(public.check_auth_exposure(false).is_ok());
1578    }
1579
1580    /// Every one of these used to parse and then panic (or misbehave) at runtime.
1581    #[test]
1582    fn rejects_invalid_values() {
1583        let cases = [
1584            (
1585                "negative backoff",
1586                "[[pools]]\nname='a'\nretry = { max_attempts = 3, initial_backoff_s = -1 }",
1587            ),
1588            (
1589                "huge multiplier",
1590                "[[pools]]\nname='a'\nretry = { max_attempts = 3, multiplier = 1e10 }",
1591            ),
1592            (
1593                "negative default_duration",
1594                "[[pools]]\nname='a'\ndefault_duration_s = -1",
1595            ),
1596            ("nan drain", "[[pools]]\nname='a'\ndrain_time_s = nan"),
1597            (
1598                "negative await deps",
1599                "[[pools]]\nname='a'\nawait_unknown_dependencies_s = -5",
1600            ),
1601            ("inf lease ttl", "[[pools]]\nname='a'\nlease_ttl_s = inf"),
1602            ("dup pools", "[[pools]]\nname='a'\n[[pools]]\nname='a'"),
1603            ("empty pool name", "[[pools]]\nname=''"),
1604            ("odd pool name", "[[pools]]\nname='we\"ird'"),
1605            ("max 0", "[[pools]]\nname='a'\nmax = 0"),
1606            (
1607                "reservable 7",
1608                "[[pools]]\nname='a'\nreservable_fraction = 7.0",
1609            ),
1610            (
1611                "dup resource",
1612                "[[resources]]\nkind='semaphore'\nname='r'\npermits=1\n[[resources]]\nkind='semaphore'\nname='r'\npermits=2",
1613            ),
1614            (
1615                "zero-rate bucket",
1616                "[[resources]]\nkind='token_bucket'\nname='r'\nrate=0\nburst=0",
1617            ),
1618            (
1619                "dup token",
1620                "[[auth.tokens]]\ntoken='tttttttttttttttttt'\nrole='admin'\n[[auth.tokens]]\ntoken='tttttttttttttttttt'\nrole='client'",
1621            ),
1622            (
1623                "token pool unknown",
1624                "[[auth.tokens]]\ntoken='tttttttttttttttttt'\nrole='client'\npools=['nope']",
1625            ),
1626            ("grace 1e30", "[server]\nshutdown_grace_s = 1e30"),
1627            ("max wait nan", "[limits]\nmax_wait_s = nan"),
1628            (
1629                "certs without mtls",
1630                "[[auth.certs]]\ndns_name='w'\nrole='worker'",
1631            ),
1632        ];
1633        for (name, toml) in cases {
1634            assert!(Config::parse(toml).is_err(), "{name} should be rejected");
1635        }
1636    }
1637
1638    #[test]
1639    fn debug_redacts_secrets() {
1640        let c = Config::parse(
1641            "[[auth.tokens]]\ntoken='supersecretsupersecret'\nrole='admin'\n[store]\nkind='postgres'\nurl='postgres://u:pw@h/db'",
1642        )
1643        .unwrap();
1644        let s = format!("{c:?}");
1645        assert!(!s.contains("supersecret") && !s.contains("pw@h"), "{s}");
1646    }
1647}