1use std::collections::BTreeSet;
5use std::fmt;
6use std::net::SocketAddr;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use serde::Deserialize;
11
12use crate::retention::Retention;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Config {
19 pub listen: SocketAddr,
21 pub tls_cert: PathBuf,
23 pub tls_key: PathBuf,
25 pub data_dir: PathBuf,
27 pub master_key: PathBuf,
29 pub retention_default: Retention,
31 pub purge_interval: Duration,
33 pub rotation_warn: Duration,
36 pub body_limit: usize,
38 pub inflated_limit: u64,
40 pub read_timeout: Duration,
42 pub supported_critical: BTreeSet<String>,
44 pub mcp: crate::mcp::Limits,
46 pub storage: Storage,
48 pub export: Export,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
54pub struct Export {
55 pub s3: Option<ExportS3>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct ExportS3 {
65 pub endpoint: Option<String>,
67 pub region: String,
69 pub path_style: bool,
71 pub access_key_id: Option<String>,
73 pub secret_access_key_file: Option<PathBuf>,
75 pub allow_http: bool,
77 pub ca_file: Option<PathBuf>,
80 pub timeout: Duration,
82}
83
84impl ExportS3 {
85 #[must_use]
87 pub fn for_bucket(&self, bucket: &str, prefix: &str) -> S3Settings {
88 S3Settings {
89 endpoint: self.endpoint.clone(),
90 region: self.region.clone(),
91 bucket: bucket.to_owned(),
92 prefix: prefix.to_owned(),
93 path_style: self.path_style,
94 access_key_id: self.access_key_id.clone(),
95 secret_access_key_file: self.secret_access_key_file.clone(),
96 allow_http: self.allow_http,
97 ca_file: self.ca_file.clone(),
98 allow_versioning: false,
99 timeout: self.timeout,
100 }
101 }
102}
103
104impl From<&S3Settings> for ExportS3 {
105 fn from(s: &S3Settings) -> ExportS3 {
107 ExportS3 {
108 endpoint: s.endpoint.clone(),
109 region: s.region.clone(),
110 path_style: s.path_style,
111 access_key_id: s.access_key_id.clone(),
112 secret_access_key_file: s.secret_access_key_file.clone(),
113 allow_http: s.allow_http,
114 ca_file: s.ca_file.clone(),
115 timeout: s.timeout,
116 }
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
123#[must_use]
124pub struct StorageError(pub String);
125
126impl fmt::Display for StorageError {
127 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
128 f.write_str(&self.0)
129 }
130}
131
132impl std::error::Error for StorageError {}
133
134#[derive(Debug, Clone, PartialEq, Eq, Default)]
137pub struct Storage {
138 pub objects: ObjectStore,
140 pub parquet: Option<ParquetSettings>,
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ParquetSettings {
148 pub pack_interval: Duration,
150 pub pack_limit: u64,
153 pub reader_grace: Duration,
156}
157
158pub const READER_GRACE: Duration = Duration::from_secs(900);
161
162pub const PACK_INTERVAL: Duration = Duration::from_secs(300);
164
165pub const PACK_LIMIT: u64 = 16 << 20;
168
169#[derive(Debug, Clone, PartialEq, Eq, Default)]
171pub enum ObjectStore {
172 #[default]
174 Local,
175 S3(S3Settings),
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct S3Settings {
182 pub endpoint: Option<String>,
184 pub region: String,
186 pub bucket: String,
188 pub prefix: String,
190 pub path_style: bool,
192 pub access_key_id: Option<String>,
194 pub secret_access_key_file: Option<PathBuf>,
196 pub allow_http: bool,
198 pub ca_file: Option<PathBuf>,
201 pub allow_versioning: bool,
203 pub timeout: Duration,
205}
206
207pub const PURGE_INTERVAL: Duration = Duration::from_secs(3600);
210
211pub const ROTATION_WARN: Duration = Duration::from_secs(30 * 86_400);
214
215pub const BODY_LIMIT: usize = 16 << 20;
220
221pub const INFLATED_LIMIT: u64 = 4 * BODY_LIMIT as u64;
225
226pub const READ_TIMEOUT: Duration = Duration::from_secs(60);
229
230#[derive(Debug, Clone, PartialEq, Eq)]
232pub struct ConfigError(pub String);
233
234impl fmt::Display for ConfigError {
235 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
236 f.write_str(&self.0)
237 }
238}
239
240impl std::error::Error for ConfigError {}
241
242pub fn load(path: &Path) -> Result<Config, ConfigError> {
249 let text = std::fs::read_to_string(path)
250 .map_err(|e| ConfigError(format!("{}: {e}", path.display())))?;
251 parse(&text)
252}
253
254pub fn parse(text: &str) -> Result<Config, ConfigError> {
262 let raw: Raw = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
263 let retention_default = match raw.retention_default {
264 None => Retention::Years(7),
265 Some(text) => Retention::parse(&text).ok_or_else(|| {
266 ConfigError(format!(
267 "retention_default: {text:?} is not a period such as 7y, 18m or 90d"
268 ))
269 })?,
270 };
271 Ok(Config {
272 listen: raw.listen,
273 tls_cert: raw.tls.cert,
274 tls_key: raw.tls.key,
275 data_dir: raw.data_dir,
276 master_key: raw.master_key,
277 retention_default,
278 purge_interval: seconds("purge_interval", raw.purge_interval, PURGE_INTERVAL)?,
279 rotation_warn: seconds("rotation_warn", raw.rotation_warn, ROTATION_WARN)?,
280 body_limit: match raw.limits.body {
281 None => BODY_LIMIT,
282 Some(body) => usize::try_from(positive("limits.body", body)?)
283 .map_err(|_| ConfigError("limits.body is too large".to_owned()))?,
284 },
285 inflated_limit: match raw.limits.inflated {
286 None => INFLATED_LIMIT,
287 Some(inflated) => positive("limits.inflated", inflated)?,
288 },
289 read_timeout: seconds(
290 "limits.timeouts.read",
291 raw.limits.timeouts.read,
292 READ_TIMEOUT,
293 )?,
294 supported_critical: raw.supported_critical.into_iter().collect(),
295 mcp: mcp_limits(&raw.mcp)?,
296 storage: storage(raw.storage)?,
297 export: Export {
298 s3: raw.export.s3.map(export_s3).transpose()?,
299 },
300 })
301}
302
303fn mcp_limits(raw: &RawMcp) -> Result<crate::mcp::Limits, ConfigError> {
305 let default = crate::mcp::Limits::DEFAULT;
306 let narrow = |key: &str, value: u64| {
307 u32::try_from(positive(key, value)?).map_err(|_| ConfigError(format!("{key} is too large")))
308 };
309 Ok(crate::mcp::Limits {
310 calls_per_second: match raw.calls_per_second {
311 None => default.calls_per_second,
312 Some(value) => narrow("mcp.calls_per_second", value)?,
313 },
314 concurrent: match raw.concurrent {
315 None => default.concurrent,
316 Some(value) => narrow("mcp.concurrent", value)?,
317 },
318 response_bytes: match raw.response_bytes {
319 None => default.response_bytes,
320 Some(value) => usize::try_from(positive("mcp.response_bytes", value)?)
321 .map_err(|_| ConfigError("mcp.response_bytes is too large".to_owned()))?,
322 },
323 })
324}
325
326pub const S3_TIMEOUT: Duration = Duration::from_secs(30);
328
329fn storage(raw: RawStorage) -> Result<Storage, ConfigError> {
333 let objects = match (raw.objects.as_deref(), raw.s3) {
334 (None | Some("local"), None) => ObjectStore::Local,
335 (None | Some("local"), Some(_)) => {
336 return Err(ConfigError(
337 "storage.s3 is set but storage.objects is not \"s3\"".to_owned(),
338 ));
339 }
340 (Some("s3"), None) => {
341 return Err(ConfigError(
342 "storage.objects is \"s3\" but there is no storage.s3 table".to_owned(),
343 ));
344 }
345 (Some("s3"), Some(s3)) => ObjectStore::S3(s3_settings(s3)?),
346 (Some(other), _) => {
347 return Err(ConfigError(format!(
348 "storage.objects: {other:?} is not \"local\" or \"s3\""
349 )));
350 }
351 };
352 let parquet = match (&objects, raw.parquet) {
353 (_, None) => None,
354 (ObjectStore::S3(_), Some(p)) => Some(ParquetSettings {
355 pack_interval: seconds(
356 "storage.parquet.pack_interval",
357 p.pack_interval,
358 PACK_INTERVAL,
359 )?,
360 pack_limit: match p.pack_limit {
361 None => PACK_LIMIT,
362 Some(limit) => positive("storage.parquet.pack_limit", limit)?,
363 },
364 reader_grace: seconds("storage.parquet.reader_grace", p.reader_grace, READER_GRACE)?,
365 }),
366 (ObjectStore::Local, Some(_)) => {
367 return Err(ConfigError(
368 "storage.parquet packs the objects of a bucket, so it needs \
369 storage.objects = \"s3\""
370 .to_owned(),
371 ));
372 }
373 };
374 Ok(Storage { objects, parquet })
375}
376
377fn export_s3(raw: RawExportS3) -> Result<ExportS3, ConfigError> {
379 check_service(
380 "export.s3",
381 raw.endpoint.as_deref(),
382 raw.allow_http,
383 raw.ca_file.is_some(),
384 raw.access_key_id.is_some(),
385 raw.secret_access_key_file.is_some(),
386 )?;
387 Ok(ExportS3 {
388 endpoint: raw.endpoint,
389 region: raw.region,
390 path_style: raw.path_style,
391 access_key_id: raw.access_key_id,
392 secret_access_key_file: raw.secret_access_key_file,
393 allow_http: raw.allow_http,
394 ca_file: raw.ca_file,
395 timeout: seconds("export.s3.timeout", raw.timeout, S3_TIMEOUT)?,
396 })
397}
398
399fn check_service(
404 table: &str,
405 endpoint: Option<&str>,
406 allow_http: bool,
407 ca_file: bool,
408 access_key_id: bool,
409 secret_access_key_file: bool,
410) -> Result<(), ConfigError> {
411 if let Some(endpoint) = endpoint {
412 if endpoint.starts_with("http://") {
413 if ca_file {
414 return Err(ConfigError(format!(
415 "{table}.ca_file is set, but {table}.endpoint {endpoint:?} is plain \
416 HTTP, which uses no certificate"
417 )));
418 }
419 if !allow_http {
420 return Err(ConfigError(format!(
421 "{table}.endpoint {endpoint:?} is plain HTTP; set {table}.allow_http = \
422 true only for a test service on this host"
423 )));
424 }
425 } else if !endpoint.starts_with("https://") {
426 return Err(ConfigError(format!(
427 "{table}.endpoint {endpoint:?} is not an https:// URL"
428 )));
429 }
430 }
431 match (access_key_id, secret_access_key_file) {
432 (true, false) => Err(ConfigError(format!(
433 "{table}.access_key_id is set without {table}.secret_access_key_file"
434 ))),
435 (false, true) => Err(ConfigError(format!(
436 "{table}.secret_access_key_file is set without {table}.access_key_id"
437 ))),
438 _ => Ok(()),
439 }
440}
441
442fn s3_settings(raw: RawS3) -> Result<S3Settings, ConfigError> {
443 if raw.bucket.is_empty() {
444 return Err(ConfigError("storage.s3.bucket is empty".to_owned()));
445 }
446 if !raw.prefix.is_empty() && (raw.prefix.starts_with('/') || !raw.prefix.ends_with('/')) {
447 return Err(ConfigError(format!(
448 "storage.s3.prefix {:?} must not start with / and must end with /, \
449 such as \"server-a/\"",
450 raw.prefix
451 )));
452 }
453 check_service(
454 "storage.s3",
455 raw.endpoint.as_deref(),
456 raw.allow_http,
457 raw.ca_file.is_some(),
458 raw.access_key_id.is_some(),
459 raw.secret_access_key_file.is_some(),
460 )?;
461 Ok(S3Settings {
462 endpoint: raw.endpoint,
463 region: raw.region,
464 bucket: raw.bucket,
465 prefix: raw.prefix,
466 path_style: raw.path_style,
467 access_key_id: raw.access_key_id,
468 secret_access_key_file: raw.secret_access_key_file,
469 allow_http: raw.allow_http,
470 ca_file: raw.ca_file,
471 allow_versioning: raw.allow_versioning,
472 timeout: seconds("storage.s3.timeout", raw.timeout, S3_TIMEOUT)?,
473 })
474}
475
476fn positive(key: &str, value: u64) -> Result<u64, ConfigError> {
477 if value == 0 {
478 return Err(ConfigError(format!("{key} must be more than 0")));
479 }
480 Ok(value)
481}
482
483fn seconds(key: &str, value: Option<u64>, default: Duration) -> Result<Duration, ConfigError> {
485 match value {
486 None => Ok(default),
487 Some(value) => positive(key, value).map(Duration::from_secs),
488 }
489}
490
491#[derive(Deserialize)]
493#[serde(deny_unknown_fields)]
494struct Raw {
495 listen: SocketAddr,
496 tls: RawTls,
497 data_dir: PathBuf,
498 master_key: PathBuf,
499 retention_default: Option<String>,
500 purge_interval: Option<u64>,
501 rotation_warn: Option<u64>,
502 #[serde(default)]
503 limits: RawLimits,
504 #[serde(default)]
505 supported_critical: Vec<String>,
506 #[serde(default)]
507 mcp: RawMcp,
508 #[serde(default)]
509 storage: RawStorage,
510 #[serde(default)]
511 export: RawExport,
512}
513
514#[derive(Default, Deserialize)]
515#[serde(deny_unknown_fields)]
516struct RawExport {
517 s3: Option<RawExportS3>,
518}
519
520#[derive(Deserialize)]
521#[serde(deny_unknown_fields)]
522struct RawExportS3 {
523 endpoint: Option<String>,
524 region: String,
525 #[serde(default)]
526 path_style: bool,
527 access_key_id: Option<String>,
528 secret_access_key_file: Option<PathBuf>,
529 #[serde(default)]
530 allow_http: bool,
531 ca_file: Option<PathBuf>,
532 timeout: Option<u64>,
533}
534
535#[derive(Default, Deserialize)]
536#[serde(deny_unknown_fields)]
537struct RawStorage {
538 objects: Option<String>,
539 s3: Option<RawS3>,
540 parquet: Option<RawParquet>,
541}
542
543#[derive(Deserialize)]
544#[serde(deny_unknown_fields)]
545struct RawParquet {
546 pack_interval: Option<u64>,
547 pack_limit: Option<u64>,
548 reader_grace: Option<u64>,
549}
550
551#[derive(Deserialize)]
552#[serde(deny_unknown_fields)]
553struct RawS3 {
554 endpoint: Option<String>,
555 region: String,
556 bucket: String,
557 #[serde(default)]
558 prefix: String,
559 #[serde(default)]
560 path_style: bool,
561 access_key_id: Option<String>,
562 secret_access_key_file: Option<PathBuf>,
563 #[serde(default)]
564 allow_http: bool,
565 ca_file: Option<PathBuf>,
566 #[serde(default)]
567 allow_versioning: bool,
568 timeout: Option<u64>,
569}
570
571#[derive(Default, Deserialize)]
572#[serde(deny_unknown_fields)]
573struct RawMcp {
574 calls_per_second: Option<u64>,
575 concurrent: Option<u64>,
576 response_bytes: Option<u64>,
577}
578
579#[derive(Deserialize)]
580#[serde(deny_unknown_fields)]
581struct RawTls {
582 cert: PathBuf,
583 key: PathBuf,
584}
585
586#[derive(Default, Deserialize)]
587#[serde(deny_unknown_fields)]
588struct RawLimits {
589 body: Option<u64>,
590 inflated: Option<u64>,
591 #[serde(default)]
592 timeouts: RawTimeouts,
593}
594
595#[derive(Default, Deserialize)]
596#[serde(deny_unknown_fields)]
597struct RawTimeouts {
598 read: Option<u64>,
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 const FULL: &str = r#"
606listen = "127.0.0.1:8443"
607data_dir = "/var/lib/vcon-server"
608master_key = "/etc/vcon-server/master.key"
609retention_default = "18m"
610purge_interval = 1800
611rotation_warn = 604800
612supported_critical = ["known", "other"]
613
614[tls]
615cert = "/etc/vcon-server/cert.pem"
616key = "/etc/vcon-server/key.pem"
617
618[limits]
619body = 10485760
620inflated = 104857600
621
622[limits.timeouts]
623read = 30
624
625[mcp]
626calls_per_second = 20
627concurrent = 8
628response_bytes = 262144
629
630[storage]
631objects = "s3"
632
633[storage.s3]
634endpoint = "https://objects.example.com:9000"
635region = "eu-west-1"
636bucket = "vcon"
637prefix = "server-a/"
638path_style = true
639access_key_id = "vcon-server"
640secret_access_key_file = "/etc/vcon-server/s3.secret"
641allow_http = true
642ca_file = "/etc/vcon-server/s3-ca.pem"
643allow_versioning = true
644timeout = 60
645
646[storage.parquet]
647pack_interval = 600
648pack_limit = 8388608
649reader_grace = 1800
650
651[export.s3]
652endpoint = "https://analytics.example.com"
653region = "eu-west-2"
654path_style = true
655access_key_id = "vcon-export"
656secret_access_key_file = "/etc/vcon-server/export.secret"
657allow_http = true
658ca_file = "/etc/vcon-server/export-ca.pem"
659timeout = 120
660"#;
661
662 #[test]
663 fn a_full_configuration_is_read() {
664 assert_eq!(
665 parse(FULL),
666 Ok(Config {
667 listen: "127.0.0.1:8443".parse().expect("address"),
668 tls_cert: "/etc/vcon-server/cert.pem".into(),
669 tls_key: "/etc/vcon-server/key.pem".into(),
670 data_dir: "/var/lib/vcon-server".into(),
671 master_key: "/etc/vcon-server/master.key".into(),
672 retention_default: Retention::Months(18),
673 purge_interval: Duration::from_secs(1800),
674 rotation_warn: Duration::from_secs(604_800),
675 body_limit: 10_485_760,
676 inflated_limit: 104_857_600,
677 read_timeout: Duration::from_secs(30),
678 supported_critical: ["known", "other"].map(String::from).into(),
679 mcp: crate::mcp::Limits {
680 calls_per_second: 20,
681 concurrent: 8,
682 response_bytes: 262_144,
683 },
684 storage: Storage {
685 objects: ObjectStore::S3(S3Settings {
686 endpoint: Some("https://objects.example.com:9000".to_owned()),
687 region: "eu-west-1".to_owned(),
688 bucket: "vcon".to_owned(),
689 prefix: "server-a/".to_owned(),
690 path_style: true,
691 access_key_id: Some("vcon-server".to_owned()),
692 secret_access_key_file: Some("/etc/vcon-server/s3.secret".into()),
693 allow_http: true,
694 ca_file: Some("/etc/vcon-server/s3-ca.pem".into()),
695 allow_versioning: true,
696 timeout: Duration::from_secs(60),
697 }),
698 parquet: Some(ParquetSettings {
699 pack_interval: Duration::from_secs(600),
700 pack_limit: 8_388_608,
701 reader_grace: Duration::from_secs(1800),
702 }),
703 },
704 export: Export {
705 s3: Some(ExportS3 {
706 endpoint: Some("https://analytics.example.com".to_owned()),
707 region: "eu-west-2".to_owned(),
708 path_style: true,
709 access_key_id: Some("vcon-export".to_owned()),
710 secret_access_key_file: Some("/etc/vcon-server/export.secret".into()),
711 allow_http: true,
712 ca_file: Some("/etc/vcon-server/export-ca.pem".into()),
713 timeout: Duration::from_secs(120),
714 }),
715 },
716 }),
717 "every key the file writes is read into its field"
718 );
719 }
720
721 #[test]
722 fn retention_and_supported_extensions_have_defaults() {
723 let text = FULL
724 .replace("retention_default = \"18m\"\n", "")
725 .replace("supported_critical = [\"known\", \"other\"]\n", "");
726 let config = parse(&text).expect("parses");
727 assert_eq!(
728 config.retention_default,
729 Retention::Years(7),
730 "with no retention_default, vCons are kept seven years"
731 );
732 assert_eq!(
733 config.supported_critical,
734 BTreeSet::new(),
735 "with no supported_critical, no extension is supported"
736 );
737 }
738
739 const LEAST: &str = r#"
741listen = "127.0.0.1:8443"
742data_dir = "/var/lib/vcon-server"
743master_key = "/etc/vcon-server/master.key"
744
745[tls]
746cert = "/etc/vcon-server/cert.pem"
747key = "/etc/vcon-server/key.pem"
748"#;
749
750 #[test]
751 fn periods_and_limits_have_defaults() {
752 let config = parse(LEAST).expect("parses");
753 assert_eq!(
754 config.purge_interval,
755 Duration::from_secs(3600),
756 "the purge sweep runs hourly by default"
757 );
758 assert_eq!(
759 config.rotation_warn,
760 Duration::from_secs(30 * 86_400),
761 "an overdue rotation is warned of after 30 days by default"
762 );
763 assert_eq!(
764 config.body_limit,
765 16 << 20,
766 "a body may be 16 MiB by default"
767 );
768 assert_eq!(
769 config.inflated_limit,
770 64 << 20,
771 "a body may inflate to 64 MiB by default"
772 );
773 assert_eq!(
774 config.read_timeout,
775 Duration::from_secs(60),
776 "a request has 60 seconds to arrive by default"
777 );
778 assert_eq!(
779 config.mcp,
780 crate::mcp::Limits::DEFAULT,
781 "with no [mcp] table, the MCP endpoint takes its default limits"
782 );
783 }
784
785 #[test]
788 fn each_limit_defaults_alone() {
789 let base = parse(FULL).expect("parses");
790 for (line, default) in [
791 (
792 "body = 10485760\n",
793 Config {
794 body_limit: 16 << 20,
795 ..base.clone()
796 },
797 ),
798 (
799 "inflated = 104857600\n",
800 Config {
801 inflated_limit: 64 << 20,
802 ..base.clone()
803 },
804 ),
805 (
806 "read = 30\n",
807 Config {
808 read_timeout: Duration::from_secs(60),
809 ..base.clone()
810 },
811 ),
812 (
813 "purge_interval = 1800\n",
814 Config {
815 purge_interval: Duration::from_secs(3600),
816 ..base.clone()
817 },
818 ),
819 (
820 "rotation_warn = 604800\n",
821 Config {
822 rotation_warn: Duration::from_secs(30 * 86_400),
823 ..base.clone()
824 },
825 ),
826 (
827 "[limits.timeouts]\nread = 30\n",
828 Config {
829 read_timeout: Duration::from_secs(60),
830 ..base.clone()
831 },
832 ),
833 (
834 "calls_per_second = 20\n",
835 Config {
836 mcp: crate::mcp::Limits {
837 calls_per_second: 100,
838 ..base.mcp
839 },
840 ..base.clone()
841 },
842 ),
843 (
844 "concurrent = 8\n",
845 Config {
846 mcp: crate::mcp::Limits {
847 concurrent: 100,
848 ..base.mcp
849 },
850 ..base.clone()
851 },
852 ),
853 (
854 "response_bytes = 262144\n",
855 Config {
856 mcp: crate::mcp::Limits {
857 response_bytes: 1 << 20,
858 ..base.mcp
859 },
860 ..base.clone()
861 },
862 ),
863 ] {
864 assert!(FULL.contains(line), "{line}");
865 assert_eq!(parse(&FULL.replace(line, "")), Ok(default), "{line}");
866 }
867 }
868
869 #[test]
872 fn the_full_file_sets_no_value_equal_to_its_default() {
873 let (full, least) = (parse(FULL).expect("full"), parse(LEAST).expect("least"));
874 assert_ne!(
875 full.purge_interval, least.purge_interval,
876 "FULL writes purge_interval as a value other than its default"
877 );
878 assert_ne!(
879 full.rotation_warn, least.rotation_warn,
880 "FULL writes rotation_warn as a value other than its default"
881 );
882 assert_ne!(
883 full.body_limit, least.body_limit,
884 "FULL writes limits.body as a value other than its default"
885 );
886 assert_ne!(
887 full.inflated_limit, least.inflated_limit,
888 "FULL writes limits.inflated as a value other than its default"
889 );
890 assert_ne!(
891 full.read_timeout, least.read_timeout,
892 "FULL writes limits.timeouts.read as a value other than its default"
893 );
894 assert_ne!(
895 full.mcp.calls_per_second, least.mcp.calls_per_second,
896 "FULL writes mcp.calls_per_second as a value other than its default"
897 );
898 assert_ne!(
899 full.mcp.concurrent, least.mcp.concurrent,
900 "FULL writes mcp.concurrent as a value other than its default"
901 );
902 assert_ne!(
903 full.mcp.response_bytes, least.mcp.response_bytes,
904 "FULL writes mcp.response_bytes as a value other than its default"
905 );
906 assert_ne!(
907 full.storage, least.storage,
908 "FULL writes storage.objects as a value other than its default"
909 );
910 let parquet = full.storage.parquet.expect("FULL has storage.parquet");
911 let least_parquet = parquet_least();
912 for (key, differs) in [
913 (
914 "storage.parquet.pack_interval",
915 parquet.pack_interval != least_parquet.pack_interval,
916 ),
917 (
918 "storage.parquet.pack_limit",
919 parquet.pack_limit != least_parquet.pack_limit,
920 ),
921 (
922 "storage.parquet.reader_grace",
923 parquet.reader_grace != least_parquet.reader_grace,
924 ),
925 ] {
926 assert!(
927 differs,
928 "FULL writes {key} as a value other than its default"
929 );
930 }
931 let ObjectStore::S3(s3) = full.storage.objects else {
932 panic!("FULL keeps its objects in S3")
933 };
934 let least_s3 = s3_least();
935 for (key, differs) in [
936 ("storage.s3.endpoint", s3.endpoint != least_s3.endpoint),
937 ("storage.s3.prefix", s3.prefix != least_s3.prefix),
938 (
939 "storage.s3.path_style",
940 s3.path_style != least_s3.path_style,
941 ),
942 (
943 "storage.s3.access_key_id",
944 s3.access_key_id != least_s3.access_key_id,
945 ),
946 (
947 "storage.s3.secret_access_key_file",
948 s3.secret_access_key_file != least_s3.secret_access_key_file,
949 ),
950 (
951 "storage.s3.allow_http",
952 s3.allow_http != least_s3.allow_http,
953 ),
954 ("storage.s3.ca_file", s3.ca_file != least_s3.ca_file),
955 (
956 "storage.s3.allow_versioning",
957 s3.allow_versioning != least_s3.allow_versioning,
958 ),
959 ("storage.s3.timeout", s3.timeout != least_s3.timeout),
960 ] {
961 assert!(
962 differs,
963 "FULL writes {key} as a value other than its default"
964 );
965 }
966 let export = full.export.s3.expect("FULL has export.s3");
967 let least_export = parse(&format!("{LEAST}\n[export.s3]\nregion = \"us-east-1\"\n"))
968 .expect("least export")
969 .export
970 .s3
971 .expect("export.s3");
972 for (key, differs) in [
973 (
974 "export.s3.endpoint",
975 export.endpoint != least_export.endpoint,
976 ),
977 ("export.s3.region", export.region != least_export.region),
978 (
979 "export.s3.path_style",
980 export.path_style != least_export.path_style,
981 ),
982 (
983 "export.s3.access_key_id",
984 export.access_key_id != least_export.access_key_id,
985 ),
986 (
987 "export.s3.secret_access_key_file",
988 export.secret_access_key_file != least_export.secret_access_key_file,
989 ),
990 (
991 "export.s3.allow_http",
992 export.allow_http != least_export.allow_http,
993 ),
994 ("export.s3.ca_file", export.ca_file != least_export.ca_file),
995 ("export.s3.timeout", export.timeout != least_export.timeout),
996 ] {
997 assert!(
998 differs,
999 "FULL writes {key} as a value other than its default"
1000 );
1001 }
1002 }
1003
1004 fn s3_least() -> S3Settings {
1006 let text = format!(
1007 "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\nbucket = \"vcon\"\n"
1008 );
1009 match parse(&text)
1010 .expect("the least S3 configuration reads")
1011 .storage
1012 .objects
1013 {
1014 ObjectStore::S3(s3) => s3,
1015 ObjectStore::Local => panic!("objects = \"s3\" keeps the objects in S3"),
1016 }
1017 }
1018
1019 #[test]
1020 fn without_a_storage_table_everything_is_local() {
1021 assert_eq!(
1022 parse(LEAST).expect("parses").storage,
1023 Storage {
1024 objects: ObjectStore::Local,
1025 parquet: None,
1026 },
1027 "a file written before storage backends keeps its meaning"
1028 );
1029 }
1030
1031 fn parquet_least() -> ParquetSettings {
1033 let text = format!(
1034 "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\n\
1035 bucket = \"vcon\"\n[storage.parquet]\n"
1036 );
1037 parse(&text)
1038 .expect("the least Parquet configuration reads")
1039 .storage
1040 .parquet
1041 .expect("an empty [storage.parquet] turns Parquet storage on")
1042 }
1043
1044 #[test]
1045 fn parquet_settings_have_defaults() {
1046 assert_eq!(
1047 parquet_least(),
1048 ParquetSettings {
1049 pack_interval: PACK_INTERVAL,
1050 pack_limit: PACK_LIMIT,
1051 reader_grace: READER_GRACE,
1052 },
1053 "an empty table takes every default"
1054 );
1055 assert_eq!(READER_GRACE, Duration::from_secs(900), "15 minutes");
1056 assert_eq!(PACK_INTERVAL, Duration::from_secs(300), "five minutes");
1057 assert_eq!(PACK_LIMIT, 16 << 20, "16 MiB");
1058 }
1059
1060 #[test]
1061 fn s3_settings_have_defaults() {
1062 assert_eq!(
1063 s3_least(),
1064 S3Settings {
1065 endpoint: None,
1066 region: "us-east-1".to_owned(),
1067 bucket: "vcon".to_owned(),
1068 prefix: String::new(),
1069 path_style: false,
1070 access_key_id: None,
1071 secret_access_key_file: None,
1072 allow_http: false,
1073 ca_file: None,
1074 allow_versioning: false,
1075 timeout: Duration::from_secs(30),
1076 },
1077 "only region and bucket are required"
1078 );
1079 }
1080
1081 #[test]
1085 fn export_s3_settings_are_read_and_checked_as_storage_s3_is() {
1086 let export = |extra: &str| format!("{LEAST}\n[export.s3]\nregion = \"us-east-1\"\n{extra}");
1087 assert_eq!(
1088 parse(LEAST).expect("least").export,
1089 Export::default(),
1090 "no table"
1091 );
1092 assert_eq!(
1093 parse(&export("")).expect("least export").export.s3,
1094 Some(ExportS3 {
1095 endpoint: None,
1096 region: "us-east-1".to_owned(),
1097 path_style: false,
1098 access_key_id: None,
1099 secret_access_key_file: None,
1100 allow_http: false,
1101 ca_file: None,
1102 timeout: S3_TIMEOUT,
1103 }),
1104 "only region is required"
1105 );
1106 for (extra, key) in [
1107 (
1108 "endpoint = \"http://127.0.0.1:9000\"\n",
1109 "export.s3.allow_http",
1110 ),
1111 ("endpoint = \"objects.example.com\"\n", "export.s3.endpoint"),
1112 (
1113 "access_key_id = \"vcon\"\n",
1114 "export.s3.secret_access_key_file",
1115 ),
1116 (
1117 "secret_access_key_file = \"/s\"\n",
1118 "export.s3.access_key_id",
1119 ),
1120 ("timeout = 0\n", "export.s3.timeout"),
1121 (
1122 "endpoint = \"http://127.0.0.1:9000\"\nallow_http = true\nca_file = \"/c.pem\"\n",
1123 "export.s3.ca_file",
1124 ),
1125 ("bucket = \"vcon\"\n", "bucket"),
1126 ("prefix = \"a/\"\n", "prefix"),
1127 ("allow_versioning = true\n", "allow_versioning"),
1128 ] {
1129 let ConfigError(message) = parse(&export(extra)).expect_err(extra);
1130 assert!(message.contains(key), "{extra}: {message}");
1131 }
1132 assert!(
1133 parse(&format!("{LEAST}\n[export.s3]\n"))
1134 .expect_err("no region")
1135 .0
1136 .contains("region"),
1137 "region is required"
1138 );
1139 }
1140
1141 #[test]
1144 fn an_export_service_names_its_bucket_and_prefix() {
1145 let full = parse(FULL).expect("full");
1146 let export = full.export.s3.expect("export.s3");
1147 let s = export.for_bucket("reports", "vcon/");
1148 assert_eq!(
1149 (s.bucket.as_str(), s.prefix.as_str()),
1150 ("reports", "vcon/"),
1151 "the target's bucket and prefix"
1152 );
1153 assert_eq!(s.endpoint, export.endpoint, "the export's endpoint");
1154 assert_eq!(s.region, export.region, "the export's region");
1155 assert_eq!(
1156 s.access_key_id, export.access_key_id,
1157 "the export's access key id"
1158 );
1159 assert_eq!(
1160 s.secret_access_key_file, export.secret_access_key_file,
1161 "the export's secret file"
1162 );
1163 assert_eq!(s.ca_file, export.ca_file, "the export's CA file");
1164 assert_eq!(
1165 (s.path_style, s.allow_http),
1166 (true, true),
1167 "FULL sets path_style and allow_http"
1168 );
1169 assert_eq!(s.timeout, Duration::from_secs(120), "the export's timeout");
1170 assert!(!s.allow_versioning, "never read for an export");
1171 let ObjectStore::S3(storage) = full.storage.objects else {
1172 panic!("FULL keeps its objects in S3")
1173 };
1174 let from = ExportS3::from(&storage).for_bucket(&storage.bucket, &storage.prefix);
1175 assert_eq!(
1176 from,
1177 S3Settings {
1178 allow_versioning: false,
1179 ..storage
1180 },
1181 "storage.s3's service and keys, whole"
1182 );
1183 }
1184
1185 #[test]
1186 fn s3_settings_that_do_not_hold_together_are_named() {
1187 let s3 = |extra: &str| {
1188 format!(
1189 "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\nbucket = \"vcon\"\n{extra}"
1190 )
1191 };
1192 for (text, key) in [
1193 (
1194 format!("{LEAST}\n[storage]\nobjects = \"s3\"\n"),
1195 "storage.s3",
1196 ),
1197 (
1198 format!("{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nbucket = \"vcon\"\n"),
1199 "region",
1200 ),
1201 (
1202 format!(
1203 "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\n"
1204 ),
1205 "bucket",
1206 ),
1207 (
1208 s3("endpoint = \"http://127.0.0.1:9000\"\n"),
1209 "storage.s3.allow_http",
1210 ),
1211 (
1212 s3("endpoint = \"objects.example.com\"\n"),
1213 "storage.s3.endpoint",
1214 ),
1215 (
1216 s3("access_key_id = \"vcon-server\"\n"),
1217 "storage.s3.secret_access_key_file",
1218 ),
1219 (
1220 s3("secret_access_key_file = \"/etc/vcon-server/s3.secret\"\n"),
1221 "storage.s3.access_key_id",
1222 ),
1223 (s3("timeout = 0\n"), "storage.s3.timeout"),
1224 (
1225 s3(
1226 "endpoint = \"http://127.0.0.1:9000\"\nallow_http = true\nca_file = \"/etc/vcon-server/s3-ca.pem\"\n",
1227 ),
1228 "storage.s3.ca_file",
1229 ),
1230 (s3("prefix = \"/server-a/\"\n"), "storage.s3.prefix"),
1231 (s3("prefix = \"server-a\"\n"), "storage.s3.prefix"),
1232 (
1233 s3("bucket = \"\"\n").replace("bucket = \"vcon\"\n", ""),
1234 "storage.s3.bucket",
1235 ),
1236 (
1237 format!("{LEAST}\n[storage]\nobjects = \"tape\"\n"),
1238 "objects",
1239 ),
1240 (format!("{LEAST}\n[storage.parquet]\n"), "storage.parquet"),
1241 (
1242 format!("{LEAST}\n[storage]\nobjects = \"local\"\n[storage.parquet]\n"),
1243 "storage.parquet",
1244 ),
1245 (
1246 s3("[storage.parquet]\npack_interval = 0\n"),
1247 "storage.parquet.pack_interval",
1248 ),
1249 (
1250 s3("[storage.parquet]\npack_limit = 0\n"),
1251 "storage.parquet.pack_limit",
1252 ),
1253 (
1254 s3("[storage.parquet]\nreader_grace = 0\n"),
1255 "storage.parquet.reader_grace",
1256 ),
1257 (
1258 format!(
1259 "{LEAST}\n[storage]\nobjects = \"local\"\n[storage.s3]\nregion = \"us-east-1\"\nbucket = \"vcon\"\n"
1260 ),
1261 "storage.s3",
1262 ),
1263 ] {
1264 let error = parse(&text).expect_err(key);
1265 assert!(error.0.contains(key), "the error names {key}: {error}");
1266 }
1267 }
1268
1269 #[test]
1270 fn an_http_endpoint_reads_when_allowed() {
1271 let text = format!(
1272 "{LEAST}\n[storage]\nobjects = \"s3\"\n[storage.s3]\nregion = \"us-east-1\"\nbucket = \"vcon\"\nendpoint = \"http://127.0.0.1:9000\"\nallow_http = true\n"
1273 );
1274 assert!(
1275 parse(&text).is_ok(),
1276 "allow_http lets a test container's plain endpoint through"
1277 );
1278 }
1279
1280 #[test]
1282 fn every_missing_key_without_a_default_is_named() {
1283 for (line, key) in [
1284 ("listen = \"127.0.0.1:8443\"\n", "listen"),
1285 ("data_dir = \"/var/lib/vcon-server\"\n", "data_dir"),
1286 (
1287 "master_key = \"/etc/vcon-server/master.key\"\n",
1288 "master_key",
1289 ),
1290 ("cert = \"/etc/vcon-server/cert.pem\"\n", "cert"),
1291 ("key = \"/etc/vcon-server/key.pem\"\n", "key"),
1292 ] {
1293 assert!(FULL.contains(line), "{line}");
1294 let error = parse(&FULL.replace(line, "")).expect_err(key);
1295 assert!(error.0.contains(&format!("`{key}`")), "{key}: {error}");
1296 }
1297 }
1298
1299 #[test]
1300 fn an_unknown_key_is_named() {
1301 for (text, key) in [
1302 (format!("colour = \"blue\"\n{FULL}"), "colour"),
1303 (FULL.replace("read = 30", "read = 30\nwrite = 30"), "write"),
1304 (FULL.replace("[tls]", "[tls]\nca = \"x\""), "ca"),
1305 (
1306 FULL.replace("[mcp]", "[mcp]\nresources = true"),
1307 "resources",
1308 ),
1309 ] {
1310 let error = parse(&text).expect_err(key);
1311 assert!(error.0.contains(&format!("`{key}`")), "{key}: {error}");
1312 }
1313 }
1314
1315 #[test]
1316 fn a_bad_value_is_named() {
1317 for (from, to, key) in [
1318 ("\"127.0.0.1:8443\"", "\"localhost\"", "listen"),
1319 ("\"18m\"", "\"7w\"", "retention_default"),
1320 (
1321 "purge_interval = 1800",
1322 "purge_interval = 0",
1323 "purge_interval",
1324 ),
1325 (
1326 "rotation_warn = 604800",
1327 "rotation_warn = 0",
1328 "rotation_warn",
1329 ),
1330 ("body = 10485760", "body = 0", "limits.body"),
1331 ("inflated = 104857600", "inflated = 0", "limits.inflated"),
1332 (
1333 "calls_per_second = 20",
1334 "calls_per_second = 0",
1335 "mcp.calls_per_second",
1336 ),
1337 ("concurrent = 8", "concurrent = 0", "mcp.concurrent"),
1338 (
1339 "response_bytes = 262144",
1340 "response_bytes = 0",
1341 "mcp.response_bytes",
1342 ),
1343 (
1344 "concurrent = 8",
1345 "concurrent = 4294967296",
1346 "mcp.concurrent",
1347 ),
1348 ("read = 30", "read = 0", "limits.timeouts.read"),
1349 ("read = 30", "read = -1", "read"),
1350 ] {
1351 let error = parse(&FULL.replace(from, to)).expect_err(key);
1352 assert!(error.0.contains(key), "{key}: {error}");
1353 }
1354 }
1355
1356 mod site_page {
1360 use super::*;
1361
1362 fn page(name: &str) -> String {
1363 let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1364 .join("../../website/content/docs")
1365 .join(name);
1366 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
1367 }
1368
1369 fn keys(table: &toml::Table, prefix: &str, out: &mut Vec<String>) {
1371 for (key, value) in table {
1372 let name = format!("{prefix}{key}");
1373 match value {
1374 toml::Value::Table(inner) => keys(inner, &format!("{name}."), out),
1375 _ => out.push(name),
1376 }
1377 }
1378 }
1379
1380 fn row<'a>(text: &'a str, key: &str) -> &'a str {
1382 let start = format!("| `{key}` |");
1383 let mut rows = text.lines().filter(|l| l.starts_with(&start));
1384 let row = rows.next().unwrap_or_else(|| panic!("no row for {key}"));
1385 assert!(rows.next().is_none(), "two rows for {key}");
1386 row
1387 }
1388
1389 #[test]
1390 fn every_key_is_listed_with_its_default() {
1391 let text = page("configuration.md");
1392 let defaults = parse(LEAST).expect("least");
1393 let mut all = Vec::new();
1394 keys(&FULL.parse::<toml::Table>().expect("toml"), "", &mut all);
1395 assert_eq!(all.len(), 38, "{all:?}");
1396 for key in all {
1397 let default = match key.as_str() {
1398 "listen" | "data_dir" | "master_key" | "tls.cert" | "tls.key" => {
1399 "required".to_owned()
1400 }
1401 "retention_default" => format!("`{}`", defaults.retention_default),
1402 "purge_interval" => format!("`{}`", defaults.purge_interval.as_secs()),
1403 "rotation_warn" => format!("`{}`", defaults.rotation_warn.as_secs()),
1404 "limits.body" => format!("`{}`", defaults.body_limit),
1405 "limits.inflated" => format!("`{}`", defaults.inflated_limit),
1406 "limits.timeouts.read" => format!("`{}`", defaults.read_timeout.as_secs()),
1407 "mcp.calls_per_second" => format!("`{}`", defaults.mcp.calls_per_second),
1408 "mcp.concurrent" => format!("`{}`", defaults.mcp.concurrent),
1409 "mcp.response_bytes" => format!("`{}`", defaults.mcp.response_bytes),
1410 "storage.objects" => "`local`".to_owned(),
1411 "storage.s3.region" | "storage.s3.bucket" => "required with `s3`".to_owned(),
1412 "storage.s3.endpoint"
1413 | "storage.s3.access_key_id"
1414 | "storage.s3.secret_access_key_file"
1415 | "storage.s3.ca_file" => "none".to_owned(),
1416 "storage.s3.prefix" => "empty".to_owned(),
1417 "storage.s3.path_style" => format!("`{}`", s3_least().path_style),
1418 "storage.s3.allow_http" => format!("`{}`", s3_least().allow_http),
1419 "storage.s3.allow_versioning" => {
1420 format!("`{}`", s3_least().allow_versioning)
1421 }
1422 "storage.s3.timeout" => format!("`{}`", s3_least().timeout.as_secs()),
1423 "storage.parquet.pack_interval" => {
1424 format!("`{}`", parquet_least().pack_interval.as_secs())
1425 }
1426 "storage.parquet.pack_limit" => format!("`{}`", parquet_least().pack_limit),
1427 "storage.parquet.reader_grace" => {
1428 format!("`{}`", parquet_least().reader_grace.as_secs())
1429 }
1430 "export.s3.region" => "required with `export.s3`".to_owned(),
1431 "export.s3.endpoint"
1432 | "export.s3.access_key_id"
1433 | "export.s3.secret_access_key_file"
1434 | "export.s3.ca_file" => "none".to_owned(),
1435 "export.s3.path_style" => format!("`{}`", s3_least().path_style),
1436 "export.s3.allow_http" => format!("`{}`", s3_least().allow_http),
1437 "export.s3.timeout" => format!("`{}`", S3_TIMEOUT.as_secs()),
1438 "supported_critical" => {
1439 assert!(
1440 defaults.supported_critical.is_empty(),
1441 "the page says none, which holds only while the default is empty"
1442 );
1443 "none".to_owned()
1444 }
1445 other => panic!("a key this test does not know: {other}"),
1446 };
1447 let row = row(&text, &key);
1448 let last = row
1449 .trim_end_matches('|')
1450 .rsplit('|')
1451 .next()
1452 .expect("a last cell")
1453 .trim();
1454 assert!(
1455 last.starts_with(&default),
1456 "{key}: `{last}` is not {default}"
1457 );
1458 }
1459 }
1460
1461 #[test]
1463 fn every_fixed_limit_is_listed_with_its_value() {
1464 let text = page("configuration.md");
1465 for (limit, value) in [
1466 ("Signed layers in one body", vcon::read::MAX_SIGNED_LAYERS),
1467 (
1468 "Arrays and objects open at once",
1469 vcon::read::MAX_JSON_DEPTH,
1470 ),
1471 ] {
1472 let start = format!("| {limit} |");
1473 let rows: Vec<&str> = text.lines().filter(|l| l.starts_with(&start)).collect();
1474 assert_eq!(rows.len(), 1, "one row for {limit}");
1475 let cell = rows[0].split('|').nth(2).expect("a value cell").trim();
1476 assert_eq!(cell, format!("`{value}`"), "{limit}");
1477 }
1478 }
1479
1480 #[test]
1481 fn every_example_file_on_the_site_reads() {
1482 let mut examples = 0;
1483 for name in ["configuration.md", "getting-started.md", "container.md"] {
1484 let text = page(name);
1485 for block in text.split("```toml\n").skip(1) {
1486 let toml = block.split("```").next().expect("a block");
1487 parse(toml).unwrap_or_else(|e| panic!("{name}: {e}\n{toml}"));
1488 examples += 1;
1489 }
1490 }
1491 assert_eq!(
1492 examples, 5,
1493 "three on the configuration page, one each on getting started and the \
1494 container page, so a block the split misses is caught"
1495 );
1496 }
1497 }
1498
1499 #[test]
1500 fn a_missing_file_is_named() {
1501 let error = load(Path::new("/nonexistent/vcon-server.toml")).expect_err("missing");
1502 assert!(error.0.contains("/nonexistent/vcon-server.toml"), "{error}");
1503 }
1504}