1use 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
16const MAX_CONFIG_SECS: f64 = 365.0 * 86400.0;
18
19#[derive(Clone, Debug, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct Config {
23 #[serde(default)]
25 pub server: ServerConfig,
26 #[serde(default)]
28 pub auth: AuthConfig,
29 #[serde(default)]
31 pub store: StoreConfig,
32 #[serde(default)]
34 pub limits: LimitsConfig,
35 #[serde(default = "default_pools")]
37 pub pools: Vec<PoolSection>,
38 #[serde(default)]
40 pub resources: Vec<ResourceSection>,
41 #[serde(default)]
43 pub outputs: OutputsConfig,
44 #[serde(default)]
47 pub shards: Vec<ShardSection>,
48}
49
50#[derive(Clone, Debug, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct ShardSection {
54 pub name: String,
56 pub number: u8,
58 pub pools: Vec<String>,
60 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#[derive(Clone, Debug)]
74pub struct ShardPlan {
75 pub own: ShardSection,
77 pub all: Vec<ShardSection>,
79 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
117pub(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 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 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 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 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 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 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 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 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 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#[derive(Clone, Debug, Deserialize)]
530#[serde(deny_unknown_fields)]
531pub struct ServerConfig {
532 #[serde(default = "default_grpc")]
534 pub grpc_addr: SocketAddr,
535 #[serde(default = "default_http")]
537 pub http_addr: SocketAddr,
538 pub metrics_addr: Option<SocketAddr>,
541 pub tls: Option<TlsConfig>,
543 #[serde(default)]
545 pub insecure_no_auth: bool,
546 #[serde(default = "default_grace")]
548 pub shutdown_grace_s: f64,
549 pub grpc_web: Option<GrpcWebConfig>,
551 pub shard: Option<String>,
553 pub peer_tls: Option<PeerTlsConfig>,
555 #[serde(default)]
559 pub insecure_peer_http: bool,
560}
561
562#[derive(Clone, Debug, Deserialize)]
564#[serde(deny_unknown_fields)]
565pub struct PeerTlsConfig {
566 pub ca: Option<PathBuf>,
568 pub cert: Option<PathBuf>,
570 pub key: Option<PathBuf>,
572}
573
574#[derive(Clone, Debug, Deserialize)]
576#[serde(deny_unknown_fields)]
577pub struct GrpcWebConfig {
578 pub allowed_origins: Vec<String>,
581}
582
583fn 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#[derive(Clone, Debug, Deserialize)]
646#[serde(deny_unknown_fields)]
647pub struct TlsConfig {
648 pub cert: PathBuf,
650 pub key: PathBuf,
652 pub client_ca: Option<PathBuf>,
654}
655
656#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)]
658#[serde(rename_all = "snake_case")]
659pub enum Role {
660 Client,
662 Worker,
664 Metrics,
666 Admin,
668}
669
670impl Role {
671 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#[derive(Clone, Debug, Default, Deserialize)]
684#[serde(deny_unknown_fields)]
685pub struct AuthConfig {
686 #[serde(default)]
688 pub tokens: Vec<TokenConfig>,
689 #[serde(default)]
691 pub certs: Vec<CertRoleConfig>,
692}
693
694#[derive(Clone, Deserialize)]
696#[serde(deny_unknown_fields)]
697pub struct TokenConfig {
698 pub token: Option<String>,
700 pub token_env: Option<String>,
702 pub role: Role,
704 pub pools: Option<Vec<String>>,
706 pub labels: Option<Vec<String>>,
708 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#[derive(Clone, Debug, Deserialize)]
725#[serde(deny_unknown_fields)]
726pub struct CertRoleConfig {
727 pub dns_name: String,
729 pub role: Role,
731 pub pools: Option<Vec<String>>,
733 pub labels: Option<Vec<String>>,
735 pub tenant: Option<String>,
737}
738
739#[derive(Clone, Default, Deserialize)]
741#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
742pub enum StoreConfig {
743 #[default]
745 Memory,
746 Postgres {
748 url: Option<String>,
750 url_env: Option<String>,
752 keep_finished_s: Option<f64>,
754 schema: Option<String>,
758 },
759 Sqlite {
762 path: std::path::PathBuf,
764 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#[derive(Clone, Debug, Deserialize)]
799#[serde(deny_unknown_fields, default)]
800pub struct LimitsConfig {
801 pub max_tasks_per_batch: usize,
803 pub max_pending_total: usize,
805 pub max_pending_per_pool: usize,
807 pub max_message_bytes: usize,
809 pub max_http_body_bytes: usize,
811 pub max_key_len: usize,
813 pub max_kind_len: usize,
815 pub max_tags: usize,
817 pub max_tag_len: usize,
819 pub max_deps: usize,
821 pub max_resources: usize,
823 pub max_wait_s: f64,
825 pub request_timeout_s: f64,
827 pub max_concurrent_requests: usize,
829 pub max_concurrent_streams: u32,
831 pub max_slots_per_worker: u32,
833 pub keepalive_interval_s: f64,
835 pub keepalive_timeout_s: f64,
837 pub key_retention_s: f64,
841 pub key_retention_max: usize,
843 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 pub fn key_retention(&self) -> Duration {
935 Duration::from_secs_f64(self.key_retention_s)
936 }
937
938 pub fn max_wait(&self) -> Duration {
940 Duration::from_secs_f64(self.max_wait_s)
941 }
942
943 pub fn request_timeout(&self) -> Duration {
945 Duration::from_secs_f64(self.request_timeout_s)
946 }
947
948 pub fn keepalive_interval(&self) -> Duration {
950 Duration::from_secs_f64(self.keepalive_interval_s)
951 }
952
953 pub fn keepalive_timeout(&self) -> Duration {
955 Duration::from_secs_f64(self.keepalive_timeout_s)
956 }
957}
958
959#[derive(Clone, Debug, Deserialize)]
961#[serde(deny_unknown_fields)]
962pub struct OutputsConfig {
963 #[serde(default = "default_cache_entries")]
965 pub cache_entries: usize,
966 #[serde(default = "default_cache_bytes")]
968 pub cache_bytes: usize,
969 #[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#[derive(Clone, Debug, Default, Deserialize)]
996#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
997pub enum ScalerSection {
998 #[default]
1000 External,
1001 Webhook {
1003 url: String,
1005 token_env: Option<String>,
1007 },
1008}
1009
1010#[derive(Clone, Debug, Deserialize)]
1012#[serde(deny_unknown_fields)]
1013pub struct PoolSection {
1014 pub name: String,
1016 #[serde(default)]
1018 pub min: u32,
1019 #[serde(default = "default_max")]
1021 pub max: u32,
1022 #[serde(default = "default_true")]
1024 pub autoscale: bool,
1025 #[serde(default)]
1027 pub scaler: ScalerSection,
1028 #[serde(default = "default_lease_ttl")]
1030 pub lease_ttl_s: f64,
1031 pub max_pending: Option<usize>,
1033 #[serde(default = "default_overflow")]
1035 pub overflow: Overflow,
1036 pub retry: Option<RetrySection>,
1038 pub default_duration_s: Option<f64>,
1040 pub reservable_fraction: Option<f64>,
1042 pub await_unknown_dependencies_s: Option<f64>,
1044 pub target_utilization: Option<f64>,
1046 pub drain_time_s: Option<f64>,
1048 pub cooldown_s: Option<f64>,
1050 pub cost_per_worker_second: Option<f64>,
1052 pub max_slots_per_worker: Option<u32>,
1055 #[serde(default)]
1057 pub fair_share: bool,
1058 #[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 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 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 pub fn to_pool_config(&self, limits: &LimitsConfig) -> PoolConfig {
1113 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#[derive(Copy, Clone, Debug, Deserialize)]
1159#[serde(rename_all = "snake_case")]
1160pub enum Overflow {
1161 Wait,
1163 Reject,
1165 ShedLowest,
1167 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#[derive(Clone, Debug, Deserialize)]
1184#[serde(deny_unknown_fields)]
1185pub struct RetrySection {
1186 pub max_attempts: u32,
1188 #[serde(default = "one")]
1190 pub initial_backoff_s: f64,
1191 #[serde(default = "two")]
1193 pub multiplier: f64,
1194 #[serde(default = "sixty")]
1196 pub max_backoff_s: f64,
1197 #[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 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#[derive(Clone, Debug, Deserialize)]
1245#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
1246pub enum ResourceSection {
1247 Semaphore {
1249 name: String,
1251 permits: u32,
1253 #[serde(default)]
1255 shard: Option<String>,
1256 },
1257 TokenBucket {
1259 name: String,
1261 rate: f64,
1263 burst: u32,
1265 #[serde(default)]
1267 shard: Option<String>,
1268 },
1269}
1270
1271impl ResourceSection {
1272 pub fn name(&self) -> &str {
1274 match self {
1275 ResourceSection::Semaphore { name, .. } | ResourceSection::TokenBucket { name, .. } => {
1276 name
1277 }
1278 }
1279 }
1280
1281 pub fn shard(&self) -> Option<&str> {
1283 match self {
1284 ResourceSection::Semaphore { shard, .. }
1285 | ResourceSection::TokenBucket { shard, .. } => shard.as_deref(),
1286 }
1287 }
1288
1289 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 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 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 #[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}