1use crate::chain::{self, Mode};
37use crate::config::{Config, CONFIG_ENV_VAR, DEFAULT_CONFIG_FILE};
38use crate::signal::SignalError;
39use sha2::{Digest, Sha256};
40use std::collections::HashMap;
41use std::fmt;
42use std::path::{Path, PathBuf};
43use std::sync::{Arc, OnceLock, RwLock};
44use std::time::Duration;
45
46pub const REFRESH_ENV_VAR: &str = "SIGNAL_CONFIG_REFRESH_SECS";
47pub const CACHE_DIR_ENV_VAR: &str = "SIGNAL_CONFIG_CACHE_DIR";
48pub const DEFAULT_REFRESH: Duration = Duration::from_secs(60);
49pub(crate) const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum ConfigSource {
53 File(PathBuf),
54 Http(String),
56 S3 {
57 bucket: String,
58 key: String,
59 },
60 Gcs {
61 bucket: String,
62 object: String,
63 },
64 Azure {
65 container: String,
66 blob: String,
67 },
68 AppConfig {
70 application: String,
71 environment: String,
72 profile: String,
73 },
74 AzureAppConfig {
77 store: String,
78 key: String,
79 label: Option<String>,
80 },
81 DynamoDb {
85 table: String,
86 id: String,
87 key: Option<String>,
88 value: Option<String>,
89 },
90 Firestore {
93 project: String,
94 document: String,
95 database: Option<String>,
96 field: Option<String>,
97 },
98 Cosmos {
102 account: String,
103 database: String,
104 container: String,
105 id: String,
106 pk: Option<String>,
107 field: Option<String>,
108 },
109}
110
111pub const CLOUD_SCHEMES: [(&str, &[&str]); 3] = [
114 ("aws", &["s3", "appconfig", "dynamodb", "aws-sm"]),
115 ("gcp", &["gs", "firestore", "gcp-sm"]),
116 ("azure", &["az", "azappconfig", "cosmos", "azure-kv"]),
117];
118
119impl fmt::Display for ConfigSource {
120 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121 match self {
122 ConfigSource::File(p) => write!(f, "{}", p.display()),
123 ConfigSource::Http(url) => f.write_str(url),
124 ConfigSource::S3 { bucket, key } => write!(f, "s3://{bucket}/{key}"),
125 ConfigSource::Gcs { bucket, object } => write!(f, "gs://{bucket}/{object}"),
126 ConfigSource::Azure { container, blob } => write!(f, "az://{container}/{blob}"),
127 ConfigSource::AppConfig {
128 application,
129 environment,
130 profile,
131 } => write!(f, "appconfig://{application}/{environment}/{profile}"),
132 ConfigSource::AzureAppConfig { store, key, label } => {
133 write!(f, "azappconfig://{store}/{key}")?;
134 write_query(f, &[("label", label)])
135 }
136 ConfigSource::DynamoDb {
137 table,
138 id,
139 key,
140 value,
141 } => {
142 write!(f, "dynamodb://{table}/{id}")?;
143 write_query(f, &[("key", key), ("value", value)])
144 }
145 ConfigSource::Firestore {
146 project,
147 document,
148 database,
149 field,
150 } => {
151 write!(f, "firestore://{project}/{document}")?;
152 write_query(f, &[("database", database), ("field", field)])
153 }
154 ConfigSource::Cosmos {
155 account,
156 database,
157 container,
158 id,
159 pk,
160 field,
161 } => {
162 write!(f, "cosmos://{account}/{database}/{container}/{id}")?;
163 write_query(f, &[("pk", pk), ("field", field)])
164 }
165 }
166 }
167}
168
169impl ConfigSource {
170 pub fn parse(raw: &str) -> Result<Self, SignalError> {
173 let raw = raw.trim();
174 if let Some(rest) = raw.strip_prefix("s3://") {
175 let (bucket, key) = split_bucket(rest, raw)?;
176 return Ok(ConfigSource::S3 { bucket, key });
177 }
178 if let Some(rest) = raw.strip_prefix("gs://") {
179 let (bucket, object) = split_bucket(rest, raw)?;
180 return Ok(ConfigSource::Gcs { bucket, object });
181 }
182 if let Some(rest) = raw.strip_prefix("az://") {
183 let (container, blob) = split_bucket(rest, raw)?;
184 return Ok(ConfigSource::Azure { container, blob });
185 }
186 if let Some(rest) = raw.strip_prefix("appconfig://") {
187 let parts: Vec<&str> = rest.split('/').collect();
188 if let [application, environment, profile] = parts[..] {
189 if ![application, environment, profile]
190 .iter()
191 .any(|p| p.is_empty())
192 {
193 return Ok(ConfigSource::AppConfig {
194 application: application.to_string(),
195 environment: environment.to_string(),
196 profile: profile.to_string(),
197 });
198 }
199 }
200 return Err(config_err(format!(
201 "{raw}: expected appconfig://<application>/<environment>/<configuration profile>"
202 )));
203 }
204 if let Some(rest) = raw.strip_prefix("azappconfig://") {
205 let (path, [label]) = split_query(rest, raw, ["label"])?;
206 let (store, key) = split_bucket(path, raw)?;
207 return Ok(ConfigSource::AzureAppConfig { store, key, label });
208 }
209 if let Some(rest) = raw.strip_prefix("dynamodb://") {
210 let (path, [key, value]) = split_query(rest, raw, ["key", "value"])?;
211 return match path.split_once('/') {
212 Some((table, id)) if !table.is_empty() && !id.is_empty() && !id.contains('/') => {
213 Ok(ConfigSource::DynamoDb {
214 table: table.to_string(),
215 id: id.to_string(),
216 key,
217 value,
218 })
219 }
220 _ => Err(config_err(format!(
221 "{raw}: expected dynamodb://<table>/<item id>"
222 ))),
223 };
224 }
225 if let Some(rest) = raw.strip_prefix("firestore://") {
226 let (path, [database, field]) = split_query(rest, raw, ["database", "field"])?;
227 let (project, document) = split_bucket(path, raw)?;
228 let segments: Vec<&str> = document.split('/').collect();
229 if !segments.len().is_multiple_of(2) || segments.iter().any(|s| s.is_empty()) {
230 return Err(config_err(format!(
231 "{raw}: expected firestore://<project>/<collection>/<document>"
232 )));
233 }
234 return Ok(ConfigSource::Firestore {
235 project,
236 document,
237 database,
238 field,
239 });
240 }
241 if let Some(rest) = raw.strip_prefix("cosmos://") {
242 let (path, [pk, field]) = split_query(rest, raw, ["pk", "field"])?;
243 let parts: Vec<&str> = path.split('/').collect();
244 return match parts[..] {
245 [account, database, container, id]
246 if ![account, database, container, id]
247 .iter()
248 .any(|p| p.is_empty()) =>
249 {
250 Ok(ConfigSource::Cosmos {
251 account: account.to_string(),
252 database: database.to_string(),
253 container: container.to_string(),
254 id: id.to_string(),
255 pk,
256 field,
257 })
258 }
259 _ => Err(config_err(format!(
260 "{raw}: expected cosmos://<account>/<database>/<container>/<item id>"
261 ))),
262 };
263 }
264 if raw.starts_with("https://") {
265 return Ok(ConfigSource::Http(raw.to_string()));
266 }
267 if let Some(rest) = raw.strip_prefix("http://") {
268 let authority = rest.split('/').next().unwrap_or("");
271 let host = match authority.find(']') {
272 Some(end) if authority.starts_with('[') => &authority[..=end],
273 _ => authority.split(':').next().unwrap_or(""),
274 };
275 if matches!(host, "localhost" | "127.0.0.1" | "[::1]") {
276 return Ok(ConfigSource::Http(raw.to_string()));
277 }
278 return Err(config_err(format!(
279 "{raw}: plain http:// is only allowed for localhost; use https://"
280 )));
281 }
282 if raw.contains("://") {
283 return Err(config_err(format!(
284 "{raw}: unsupported scheme (use a local path, https://, s3://, gs://, az://, \
285 appconfig://, azappconfig://, dynamodb://, firestore:// or cosmos://)"
286 )));
287 }
288 Ok(ConfigSource::File(PathBuf::from(raw)))
289 }
290
291 pub fn parse_list(raw: &str) -> Result<Vec<Self>, SignalError> {
294 let sources = raw
295 .split(',')
296 .map(str::trim)
297 .filter(|s| !s.is_empty())
298 .map(Self::parse)
299 .collect::<Result<Vec<_>, _>>()?;
300 if sources.is_empty() {
301 return Err(config_err(format!("'{raw}' names no config file")));
302 }
303 Ok(sources)
304 }
305
306 pub fn discover() -> Result<Option<Vec<Self>>, SignalError> {
310 if let Some(raw) = std::env::var_os(CONFIG_ENV_VAR) {
311 let sources = Self::parse_list(&raw.to_string_lossy())?;
312 for source in &sources {
313 if let ConfigSource::File(path) = source {
314 if !path.is_file() {
315 return Err(config_err(format!(
316 "${CONFIG_ENV_VAR} points to {}, which is not a file",
317 path.display()
318 )));
319 }
320 }
321 }
322 return Ok(Some(sources));
323 }
324 let local = PathBuf::from(DEFAULT_CONFIG_FILE);
325 Ok(local.is_file().then(|| vec![ConfigSource::File(local)]))
326 }
327
328 pub fn join(&self, reference: &str) -> Result<Self, SignalError> {
335 let reference = reference.trim();
336 let absolute = reference.contains("://") || Path::new(reference).is_absolute();
337 let joined = match self {
338 _ if absolute => Self::parse(reference)?,
339 ConfigSource::File(path) => ConfigSource::File(normalize(
340 &path
341 .parent()
342 .unwrap_or_else(|| Path::new(""))
343 .join(reference),
344 )),
345 ConfigSource::Http(url) => {
346 let joined = reqwest::Url::parse(url)
347 .and_then(|base| base.join(reference))
348 .map_err(|e| {
349 config_err(format!("{self}: cannot resolve '{reference}': {e}"))
350 })?;
351 Self::parse(joined.as_str())?
352 }
353 ConfigSource::S3 { bucket, key } => ConfigSource::S3 {
354 bucket: bucket.clone(),
355 key: self.join_key(key, reference)?,
356 },
357 ConfigSource::Gcs { bucket, object } => ConfigSource::Gcs {
358 bucket: bucket.clone(),
359 object: self.join_key(object, reference)?,
360 },
361 ConfigSource::Azure { container, blob } => ConfigSource::Azure {
362 container: container.clone(),
363 blob: self.join_key(blob, reference)?,
364 },
365 ConfigSource::AppConfig {
368 application,
369 environment,
370 ..
371 } => {
372 if reference.is_empty() || reference.contains('/') {
373 return Err(config_err(format!(
374 "{self}: '{reference}' is not a profile name; use a full appconfig:// URL"
375 )));
376 }
377 ConfigSource::AppConfig {
378 application: application.clone(),
379 environment: environment.clone(),
380 profile: reference.to_string(),
381 }
382 }
383 ConfigSource::AzureAppConfig { store, key, label } => ConfigSource::AzureAppConfig {
386 store: store.clone(),
387 key: self.join_key(key, reference)?,
388 label: label.clone(),
389 },
390 ConfigSource::DynamoDb {
392 table, key, value, ..
393 } => ConfigSource::DynamoDb {
394 table: table.clone(),
395 id: self.flat_name(reference)?,
396 key: key.clone(),
397 value: value.clone(),
398 },
399 ConfigSource::Firestore {
401 project,
402 document,
403 database,
404 field,
405 } => {
406 let document = self.join_key(document, reference)?;
407 if !document.split('/').count().is_multiple_of(2) {
408 return Err(config_err(format!(
409 "{self}: '{reference}' is a collection, not a document"
410 )));
411 }
412 ConfigSource::Firestore {
413 project: project.clone(),
414 document,
415 database: database.clone(),
416 field: field.clone(),
417 }
418 }
419 ConfigSource::Cosmos {
422 account,
423 database,
424 container,
425 field,
426 ..
427 } => ConfigSource::Cosmos {
428 account: account.clone(),
429 database: database.clone(),
430 container: container.clone(),
431 id: self.flat_name(reference)?,
432 pk: None,
433 field: field.clone(),
434 },
435 };
436 if self.is_remote() && !joined.is_remote() {
437 return Err(config_err(format!(
438 "{self}: a remote config cannot extend the local file {joined}"
439 )));
440 }
441 Ok(joined)
442 }
443
444 fn flat_name(&self, reference: &str) -> Result<String, SignalError> {
446 if reference.is_empty() || reference.contains('/') || reference.contains('?') {
447 return Err(config_err(format!(
448 "{self}: '{reference}' is not an item id; use a full {}:// URL",
449 self.scheme()
450 )));
451 }
452 Ok(reference.to_string())
453 }
454
455 fn join_key(&self, key: &str, reference: &str) -> Result<String, SignalError> {
457 let mut parts: Vec<&str> = key.split('/').collect();
458 parts.pop();
459 for segment in reference.split('/') {
460 match segment {
461 "" | "." => {}
462 ".." => {
463 if parts.pop().is_none() {
464 return Err(config_err(format!(
465 "{self}: '{reference}' points outside the bucket"
466 )));
467 }
468 }
469 name => parts.push(name),
470 }
471 }
472 if parts.is_empty() {
473 return Err(config_err(format!(
474 "{self}: '{reference}' does not name a file"
475 )));
476 }
477 Ok(parts.join("/"))
478 }
479
480 pub fn check_supported(&self) -> Result<(), SignalError> {
485 let Some(cloud) = self.cloud() else {
486 return Ok(());
487 };
488 let built_in = (cloud == "aws" && cfg!(feature = "aws"))
489 || (cloud == "gcp" && cfg!(feature = "gcp"))
490 || (cloud == "azure" && cfg!(feature = "azure"));
491 if built_in || has_fetcher(self.scheme()) {
492 Ok(())
493 } else {
494 Err(config_err(format!(
495 "{self}: {}",
496 missing_support(self.scheme())
497 )))
498 }
499 }
500
501 pub fn scheme(&self) -> &'static str {
503 match self {
504 ConfigSource::File(_) => "file",
505 ConfigSource::Http(url) if url.starts_with("http://") => "http",
506 ConfigSource::Http(_) => "https",
507 ConfigSource::S3 { .. } => "s3",
508 ConfigSource::Gcs { .. } => "gs",
509 ConfigSource::Azure { .. } => "az",
510 ConfigSource::AppConfig { .. } => "appconfig",
511 ConfigSource::AzureAppConfig { .. } => "azappconfig",
512 ConfigSource::DynamoDb { .. } => "dynamodb",
513 ConfigSource::Firestore { .. } => "firestore",
514 ConfigSource::Cosmos { .. } => "cosmos",
515 }
516 }
517
518 pub fn cloud(&self) -> Option<&'static str> {
520 let scheme = self.scheme();
521 CLOUD_SCHEMES
522 .iter()
523 .find(|(_, schemes)| schemes.contains(&scheme))
524 .map(|(cloud, _)| *cloud)
525 }
526
527 pub fn is_remote(&self) -> bool {
528 !matches!(self, ConfigSource::File(_))
529 }
530
531 pub async fn fetch(&self) -> Result<String, SignalError> {
533 self.fetch_unprefixed()
534 .await
535 .map_err(|e| config_err(format!("{self}: {}", message(&e))))
536 }
537
538 async fn fetch_unprefixed(&self) -> Result<String, SignalError> {
541 match self {
542 ConfigSource::File(path) => tokio::fs::read_to_string(path)
543 .await
544 .map_err(|e| config_err(format!("cannot read {}: {e}", path.display()))),
545 ConfigSource::Http(url) => fetch_http(url).await,
546 _ => self.fetch_cloud().await,
547 }
548 }
549
550 async fn fetch_cloud(&self) -> Result<String, SignalError> {
552 match self {
553 #[cfg(feature = "aws")]
554 ConfigSource::S3 { bucket, key } => crate::cloud::aws::fetch_s3(bucket, key).await,
555 #[cfg(feature = "aws")]
556 ConfigSource::AppConfig {
557 application,
558 environment,
559 profile,
560 } => crate::cloud::aws::fetch_appconfig(application, environment, profile).await,
561 #[cfg(feature = "aws")]
562 ConfigSource::DynamoDb {
563 table,
564 id,
565 key,
566 value,
567 } => {
568 crate::cloud::aws::fetch_dynamodb(table, id, key.as_deref(), value.as_deref()).await
569 }
570 #[cfg(feature = "gcp")]
571 ConfigSource::Gcs { bucket, object } => {
572 crate::cloud::gcp::fetch_gcs(bucket, object).await
573 }
574 #[cfg(feature = "gcp")]
575 ConfigSource::Firestore {
576 project,
577 document,
578 database,
579 field,
580 } => {
581 crate::cloud::gcp::fetch_firestore(
582 project,
583 document,
584 database.as_deref(),
585 field.as_deref(),
586 )
587 .await
588 }
589 #[cfg(feature = "azure")]
590 ConfigSource::Cosmos {
591 account,
592 database,
593 container,
594 id,
595 pk,
596 field,
597 } => {
598 crate::cloud::azure::fetch_cosmos(
599 account,
600 database,
601 container,
602 id,
603 pk.as_deref(),
604 field.as_deref(),
605 )
606 .await
607 }
608 #[cfg(feature = "azure")]
609 ConfigSource::Azure { container, blob } => {
610 crate::cloud::azure::fetch_blob(container, blob).await
611 }
612 #[cfg(feature = "azure")]
613 ConfigSource::AzureAppConfig { store, key, label } => {
614 crate::cloud::azure::fetch_app_config(store, key, label.as_deref()).await
615 }
616 _ => fetch_external(self.scheme(), self.to_string()).await,
617 }
618 }
619}
620
621fn normalize(path: &Path) -> PathBuf {
625 use std::path::Component;
626 let mut out = PathBuf::new();
627 for part in path.components() {
628 match part {
629 Component::CurDir => {}
630 Component::ParentDir
631 if matches!(out.components().next_back(), Some(Component::Normal(_))) =>
632 {
633 out.pop();
634 }
635 other => out.push(other),
636 }
637 }
638 out
639}
640
641fn split_query<'a, const N: usize>(
645 rest: &'a str,
646 raw: &str,
647 allowed: [&str; N],
648) -> Result<(&'a str, [Option<String>; N]), SignalError> {
649 let mut values: [Option<String>; N] = std::array::from_fn(|_| None);
650 let Some((path, query)) = rest.split_once('?') else {
651 return Ok((rest, values));
652 };
653 for pair in query.split('&') {
654 let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
655 let slot = allowed.iter().position(|a| *a == name);
656 match slot {
657 Some(i) if values[i].is_none() && !value.is_empty() => {
658 values[i] = Some(value.to_string());
659 }
660 _ => {
661 let allowed: Vec<String> = allowed.iter().map(|a| format!("{a}=")).collect();
662 return Err(config_err(format!(
663 "{raw}: unexpected '{pair}' (allowed once each, with a value: {})",
664 allowed.join(", ")
665 )));
666 }
667 }
668 }
669 Ok((path, values))
670}
671
672fn write_query(f: &mut fmt::Formatter<'_>, pairs: &[(&str, &Option<String>)]) -> fmt::Result {
673 let mut sep = '?';
674 for (name, value) in pairs {
675 if let Some(value) = value {
676 write!(f, "{sep}{name}={value}")?;
677 sep = '&';
678 }
679 }
680 Ok(())
681}
682
683fn split_bucket(rest: &str, raw: &str) -> Result<(String, String), SignalError> {
684 match rest.split_once('/') {
685 Some((bucket, key)) if !bucket.is_empty() && !key.is_empty() => {
686 Ok((bucket.to_string(), key.to_string()))
687 }
688 _ => Err(config_err(format!(
689 "{raw}: expected <scheme>://<bucket>/<path to signal.yaml>"
690 ))),
691 }
692}
693
694async fn fetch_http(url: &str) -> Result<String, SignalError> {
695 let client = reqwest::Client::builder()
696 .timeout(FETCH_TIMEOUT)
697 .build()
698 .map_err(|e| config_err(e.to_string()))?;
699 let resp = client
700 .get(url)
701 .send()
702 .await
703 .map_err(|e| config_err(e.to_string()))?;
704 if !resp.status().is_success() {
705 return Err(config_err(format!("server returned {}", resp.status())));
706 }
707 resp.text().await.map_err(|e| config_err(e.to_string()))
708}
709
710pub trait ExternalFetcher: Send + Sync {
718 fn fetch(&self, url: &str) -> Result<String, String>;
719}
720
721type FetcherRegistry = RwLock<HashMap<String, Arc<dyn ExternalFetcher>>>;
722
723fn fetchers() -> &'static FetcherRegistry {
724 static FETCHERS: OnceLock<FetcherRegistry> = OnceLock::new();
725 FETCHERS.get_or_init(Default::default)
726}
727
728pub fn register_fetcher(scheme: &str, fetcher: Arc<dyn ExternalFetcher>) {
731 fetchers()
732 .write()
733 .unwrap_or_else(|e| e.into_inner())
734 .insert(scheme.to_string(), fetcher);
735}
736
737pub fn register_cloud_fetcher(cloud: &str, fetcher: Arc<dyn ExternalFetcher>) {
740 for (name, schemes) in CLOUD_SCHEMES {
741 if name == cloud {
742 for scheme in schemes {
743 register_fetcher(scheme, Arc::clone(&fetcher));
744 }
745 }
746 }
747}
748
749pub(crate) fn has_fetcher(scheme: &str) -> bool {
750 registered_fetcher(scheme).is_some()
751}
752
753fn registered_fetcher(scheme: &str) -> Option<Arc<dyn ExternalFetcher>> {
754 fetchers()
755 .read()
756 .unwrap_or_else(|e| e.into_inner())
757 .get(scheme)
758 .cloned()
759}
760
761pub(crate) async fn fetch_external(scheme: &str, url: String) -> Result<String, SignalError> {
762 let fetcher = registered_fetcher(scheme).ok_or_else(|| config_err(missing_support(scheme)))?;
763 let (tx, rx) = tokio::sync::oneshot::channel();
764 std::thread::Builder::new()
767 .name("opssignal-fetch".into())
768 .spawn(move || {
769 let _ = tx.send(fetcher.fetch(&url));
770 })
771 .map_err(|e| config_err(format!("cannot start fetch thread: {e}")))?;
772 match tokio::time::timeout(FETCH_TIMEOUT * 2, rx).await {
775 Ok(Ok(result)) => result.map_err(config_err),
776 Ok(Err(_)) => Err(config_err("the add-on fetcher failed without an answer")),
777 Err(_) => Err(config_err(format!(
778 "timed out after {:?}",
779 FETCH_TIMEOUT * 2
780 ))),
781 }
782}
783
784pub(crate) fn missing_support(scheme: &str) -> String {
785 let cloud = CLOUD_SCHEMES
786 .iter()
787 .find(|(_, schemes)| schemes.contains(&scheme))
788 .map_or("aws", |(cloud, _)| *cloud);
789 let what = match crate::secrets::STORES.iter().find(|(s, _)| *s == scheme) {
790 Some((_, store)) => format!("{store} secrets ({scheme}:)"),
791 None => format!("{scheme}:// configs"),
792 };
793 format!(
794 "this build cannot read {what}; install the {cloud} add-on: \
795 pip install 'opssignal[{cloud}]' for Python, or a CLI or C library built with \
796 `--features {cloud}` (the `{cloud}` cargo feature of opssignal-core)"
797 )
798}
799
800pub fn fetch_blocking(url: &str) -> Result<String, String> {
805 let runtime = tokio::runtime::Builder::new_current_thread()
806 .enable_all()
807 .build()
808 .map_err(|e| format!("failed to start runtime: {e}"))?;
809 let is_secret = url
810 .split_once(':')
811 .is_some_and(|(scheme, _)| crate::secrets::STORES.iter().any(|(s, _)| *s == scheme));
812 let fetched = if is_secret {
813 runtime.block_on(crate::secrets::read(url))
814 } else {
815 ConfigSource::parse(url).and_then(|source| runtime.block_on(source.fetch_unprefixed()))
816 };
817 fetched.map_err(|e| message(&e))
818}
819
820#[derive(Debug, Clone)]
822pub struct LoadOptions {
823 pub refresh: Option<Duration>,
826 pub cache_dir: Option<PathBuf>,
828}
829
830impl Default for LoadOptions {
831 fn default() -> Self {
832 LoadOptions {
833 refresh: Some(DEFAULT_REFRESH),
834 cache_dir: default_cache_dir(),
835 }
836 }
837}
838
839impl LoadOptions {
840 pub fn from_env() -> Result<Self, SignalError> {
843 let mut opts = LoadOptions::default();
844 if let Ok(raw) = std::env::var(REFRESH_ENV_VAR) {
845 let secs: u64 = raw.trim().parse().map_err(|_| {
846 config_err(format!(
847 "${REFRESH_ENV_VAR} must be a whole number of seconds, got '{raw}'"
848 ))
849 })?;
850 opts.refresh = (secs > 0).then(|| Duration::from_secs(secs));
851 }
852 if let Some(dir) = std::env::var_os(CACHE_DIR_ENV_VAR) {
853 opts.cache_dir = Some(PathBuf::from(dir));
854 }
855 Ok(opts)
856 }
857
858 pub fn once() -> Self {
860 LoadOptions {
861 refresh: None,
862 ..LoadOptions::default()
863 }
864 }
865
866 pub fn cache_path(&self, source: &ConfigSource) -> Option<PathBuf> {
869 if !source.is_remote() {
870 return None;
871 }
872 let digest = Sha256::digest(source.to_string().as_bytes());
873 let name: String = digest[..8].iter().map(|b| format!("{b:02x}")).collect();
874 self.cache_dir
875 .as_ref()
876 .map(|dir| dir.join(format!("signal-{name}.yaml")))
877 }
878}
879
880fn default_cache_dir() -> Option<PathBuf> {
881 let base = std::env::var_os("XDG_CACHE_HOME")
882 .map(PathBuf::from)
883 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
884 .unwrap_or_else(std::env::temp_dir);
885 Some(base.join("opssignal"))
886}
887
888pub(crate) struct Loaded {
891 pub config: Config,
892 pub texts: Option<Texts>,
893}
894
895pub(crate) type Texts = Vec<(String, String, String)>;
898
899pub(crate) async fn load_initial(
904 roots: &[ConfigSource],
905 opts: &LoadOptions,
906) -> Result<Loaded, SignalError> {
907 for root in roots {
908 root.check_supported()?;
909 }
910 let built = match chain::walk(roots, Mode::Startup(opts)).await {
911 Ok(files) => chain::merge(&files).map(|config| (config, files)),
912 Err(e) => Err(e),
913 };
914 let built = match built {
915 Ok(built) => Ok(built),
916 Err(e) if !e.fatal && opts.cache_dir.is_some() => {
920 match chain::walk(roots, Mode::CacheOnly(opts)).await {
921 Ok(files) => match chain::merge(&files) {
922 Ok(config) => {
923 tracing::warn!("{}; using the cached copies", e.err);
924 Ok((config, files))
925 }
926 Err(_) => Err(e),
927 },
928 Err(_) => Err(e),
929 }
930 }
931 Err(e) => Err(e),
932 };
933 match built {
934 Ok((config, files)) => {
935 for (fetched, _) in &files {
936 if let Some(path) = opts.cache_path(&fetched.source) {
937 write_cache(&path, &fetched.text);
938 }
939 }
940 Ok(Loaded {
941 config,
942 texts: Some(chain::texts(&files)),
943 })
944 }
945 Err(e) if e.fatal => Err(e.err),
946 Err(e) => {
947 tracing::error!(
948 "{}; no usable cached copy, starting with the default config (console only)",
949 e.err
950 );
951 Ok(Loaded {
952 config: Config::default(),
953 texts: None,
954 })
955 }
956 }
957}
958
959pub(crate) fn load_blocking(
962 roots: &[ConfigSource],
963 opts: &LoadOptions,
964) -> Result<Loaded, SignalError> {
965 tokio::runtime::Builder::new_current_thread()
966 .enable_all()
967 .build()
968 .map_err(|e| SignalError::Internal(format!("failed to start runtime: {e}")))?
969 .block_on(load_initial(roots, opts))
970}
971
972pub(crate) fn write_cache(path: &Path, text: &str) {
976 let result = (|| {
977 if let Some(dir) = path.parent() {
978 std::fs::create_dir_all(dir)?;
979 }
980 let tmp = path.with_extension(format!("tmp-{}", std::process::id()));
981 std::fs::write(&tmp, text)?;
982 std::fs::rename(&tmp, path)
983 })();
984 if let Err(e) = result {
985 tracing::warn!("cannot write config cache {}: {e}", path.display());
986 }
987}
988
989fn config_err(msg: impl Into<String>) -> SignalError {
990 SignalError::Config(msg.into())
991}
992
993pub(crate) fn message(e: &SignalError) -> String {
995 match e {
996 SignalError::Config(msg) => msg.clone(),
997 other => other.to_string(),
998 }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use super::*;
1004
1005 #[test]
1006 fn parses_every_supported_scheme() {
1007 assert_eq!(
1008 ConfigSource::parse("s3://ops-config/prod/signal.yaml").expect("valid source"),
1009 ConfigSource::S3 {
1010 bucket: "ops-config".into(),
1011 key: "prod/signal.yaml".into()
1012 }
1013 );
1014 assert_eq!(
1015 ConfigSource::parse("gs://ops-config/signal.yaml").expect("valid source"),
1016 ConfigSource::Gcs {
1017 bucket: "ops-config".into(),
1018 object: "signal.yaml".into()
1019 }
1020 );
1021 assert_eq!(
1022 ConfigSource::parse("az://ops-config/prod/signal.yaml").expect("valid source"),
1023 ConfigSource::Azure {
1024 container: "ops-config".into(),
1025 blob: "prod/signal.yaml".into()
1026 }
1027 );
1028 assert_eq!(
1029 ConfigSource::parse("https://cfg.example.test/signal.yaml").expect("valid source"),
1030 ConfigSource::Http("https://cfg.example.test/signal.yaml".into())
1031 );
1032 assert_eq!(
1033 ConfigSource::parse("/etc/opssignal/signal.yaml").expect("valid source"),
1034 ConfigSource::File("/etc/opssignal/signal.yaml".into())
1035 );
1036 }
1037
1038 #[test]
1039 fn plain_http_is_only_allowed_for_loopback() {
1040 assert!(ConfigSource::parse("http://127.0.0.1:8080/signal.yaml").is_ok());
1041 assert!(ConfigSource::parse("http://localhost/signal.yaml").is_ok());
1042 let err = ConfigSource::parse("http://cfg.example.test/signal.yaml")
1043 .expect_err("must be rejected");
1044 assert!(err.to_string().contains("https://"));
1045 }
1046
1047 #[test]
1048 fn bucket_urls_need_a_bucket_and_a_key() {
1049 assert!(ConfigSource::parse("s3://bucket-only").is_err());
1050 assert!(ConfigSource::parse("s3://bucket/").is_err());
1051 assert!(ConfigSource::parse("gs:///signal.yaml").is_err());
1052 assert!(ConfigSource::parse("az://container-only").is_err());
1053 }
1054
1055 #[test]
1056 fn unknown_schemes_are_rejected() {
1057 let err = ConfigSource::parse("ftp://host/signal.yaml").expect_err("must be rejected");
1058 assert!(err.to_string().contains("unsupported scheme"));
1059 }
1060
1061 #[test]
1062 fn display_round_trips_through_parse() {
1063 for raw in [
1064 "s3://b/k/signal.yaml",
1065 "gs://b/signal.yaml",
1066 "az://c/signal.yaml",
1067 "https://x.test/a",
1068 ] {
1069 assert_eq!(
1070 ConfigSource::parse(raw).expect("valid source").to_string(),
1071 raw
1072 );
1073 }
1074 }
1075
1076 #[test]
1077 fn extends_resolves_against_the_parent_location() {
1078 let join = |base: &str, reference: &str| {
1079 ConfigSource::parse(base)
1080 .expect("valid source")
1081 .join(reference)
1082 .map(|s| s.to_string())
1083 };
1084 assert_eq!(
1085 join("s3://b/teams/payments.yaml", "../default.yaml").expect("joined"),
1086 "s3://b/default.yaml"
1087 );
1088 assert_eq!(
1089 join("gs://b/teams/a.yaml", "./shared/db.yaml").expect("joined"),
1090 "gs://b/teams/shared/db.yaml"
1091 );
1092 assert_eq!(
1093 join("az://c/a.yaml", "s3://other/x.yaml").expect("joined"),
1094 "s3://other/x.yaml"
1095 );
1096 assert_eq!(
1097 join("https://cfg.test/teams/a.yaml", "../default.yaml").expect("joined"),
1098 "https://cfg.test/default.yaml"
1099 );
1100 assert_eq!(
1101 join("/etc/opssignal/teams/a.yaml", "../default.yaml").expect("joined"),
1102 "/etc/opssignal/default.yaml"
1103 );
1104 assert_eq!(join("a.yaml", "../x.yaml").expect("joined"), "../x.yaml");
1105 assert!(join("s3://b/a.yaml", "../../x.yaml").is_err());
1106 assert!(join("s3://b/a.yaml", "/etc/passwd").is_err());
1107 assert!(join("https://cfg.test/a.yaml", "/etc/passwd").is_err());
1108 }
1109
1110 #[test]
1111 fn parses_managed_config_sources() {
1112 assert_eq!(
1113 ConfigSource::parse("appconfig://opssignal/prod/payments").expect("valid source"),
1114 ConfigSource::AppConfig {
1115 application: "opssignal".into(),
1116 environment: "prod".into(),
1117 profile: "payments".into()
1118 }
1119 );
1120 assert_eq!(
1121 ConfigSource::parse("azappconfig://ops/opssignal/teams/a?label=prod")
1122 .expect("valid source"),
1123 ConfigSource::AzureAppConfig {
1124 store: "ops".into(),
1125 key: "opssignal/teams/a".into(),
1126 label: Some("prod".into())
1127 }
1128 );
1129 for bad in [
1130 "appconfig://app/env",
1131 "appconfig://app//profile",
1132 "appconfig://a/b/c/d",
1133 "azappconfig://store-only",
1134 "azappconfig://s/k?x=1",
1135 "azappconfig://s/k?label=",
1136 ] {
1137 assert!(ConfigSource::parse(bad).is_err(), "{bad}");
1138 }
1139 for raw in [
1140 "appconfig://a/b/c",
1141 "azappconfig://s/k/x",
1142 "azappconfig://s/k?label=dev",
1143 ] {
1144 assert_eq!(ConfigSource::parse(raw).expect("valid").to_string(), raw);
1145 }
1146 assert_eq!(
1147 ConfigSource::parse("appconfig://a/b/c")
1148 .expect("valid")
1149 .cloud(),
1150 Some("aws")
1151 );
1152 assert_eq!(
1153 ConfigSource::parse("azappconfig://s/k")
1154 .expect("valid")
1155 .cloud(),
1156 Some("azure")
1157 );
1158 }
1159
1160 #[test]
1161 fn managed_config_extends_stay_in_the_same_store() {
1162 let app = ConfigSource::parse("appconfig://ops/prod/payments").expect("valid");
1163 assert_eq!(
1164 app.join("default").expect("joined").to_string(),
1165 "appconfig://ops/prod/default"
1166 );
1167 assert!(app.join("../default").is_err());
1168 let kv =
1169 ConfigSource::parse("azappconfig://ops/opssignal/teams/a?label=prod").expect("valid");
1170 assert_eq!(
1171 kv.join("../default").expect("joined").to_string(),
1172 "azappconfig://ops/opssignal/default?label=prod"
1173 );
1174 }
1175
1176 #[test]
1177 fn parses_database_sources() {
1178 assert_eq!(
1179 ConfigSource::parse("dynamodb://cfg/payments?key=team&value=yaml")
1180 .expect("valid source"),
1181 ConfigSource::DynamoDb {
1182 table: "cfg".into(),
1183 id: "payments".into(),
1184 key: Some("team".into()),
1185 value: Some("yaml".into())
1186 }
1187 );
1188 assert_eq!(
1189 ConfigSource::parse("firestore://proj/teams/a/configs/main").expect("valid source"),
1190 ConfigSource::Firestore {
1191 project: "proj".into(),
1192 document: "teams/a/configs/main".into(),
1193 database: None,
1194 field: None
1195 }
1196 );
1197 assert_eq!(
1198 ConfigSource::parse("cosmos://acct/db/coll/payments?pk=team-a").expect("valid"),
1199 ConfigSource::Cosmos {
1200 account: "acct".into(),
1201 database: "db".into(),
1202 container: "coll".into(),
1203 id: "payments".into(),
1204 pk: Some("team-a".into()),
1205 field: None
1206 }
1207 );
1208 for bad in [
1209 "dynamodb://table-only",
1210 "dynamodb://t/a/b",
1211 "dynamodb://t/a?key=",
1212 "dynamodb://t/a?colour=red",
1213 "dynamodb://t/a?key=x&key=y",
1214 "firestore://proj/collection-only",
1215 "firestore://proj/a//b",
1216 "cosmos://acct/db/coll",
1217 "cosmos://acct/db/coll/id/extra",
1218 ] {
1219 assert!(ConfigSource::parse(bad).is_err(), "{bad}");
1220 }
1221 for raw in [
1222 "dynamodb://t/a",
1223 "dynamodb://t/a?key=k&value=v",
1224 "firestore://p/c/d?database=x&field=f",
1225 "cosmos://a/d/c/i?pk=p&field=f",
1226 ] {
1227 assert_eq!(ConfigSource::parse(raw).expect("valid").to_string(), raw);
1228 }
1229 for (raw, cloud) in [
1230 ("dynamodb://t/a", "aws"),
1231 ("firestore://p/c/d", "gcp"),
1232 ("cosmos://a/d/c/i", "azure"),
1233 ] {
1234 assert_eq!(
1235 ConfigSource::parse(raw).expect("valid").cloud(),
1236 Some(cloud)
1237 );
1238 }
1239 }
1240
1241 #[test]
1242 fn database_extends_stay_in_the_same_table() {
1243 let join = |base: &str, reference: &str| {
1244 ConfigSource::parse(base)
1245 .expect("valid source")
1246 .join(reference)
1247 .map(|s| s.to_string())
1248 };
1249 assert_eq!(
1250 join("dynamodb://cfg/payments?key=team", "default").expect("joined"),
1251 "dynamodb://cfg/default?key=team"
1252 );
1253 assert!(join("dynamodb://cfg/payments", "a/b").is_err());
1254 assert_eq!(
1255 join("firestore://p/opssignal/payments?database=x", "default").expect("joined"),
1256 "firestore://p/opssignal/default?database=x"
1257 );
1258 assert_eq!(
1259 join("firestore://p/teams/a/configs/main", "../../b/configs/main").expect("joined"),
1260 "firestore://p/teams/b/configs/main"
1261 );
1262 assert!(join("firestore://p/teams/a/configs/main", "../x").is_err());
1263 assert_eq!(
1264 join("cosmos://a/d/c/payments?pk=team-a&field=f", "default").expect("joined"),
1265 "cosmos://a/d/c/default?field=f"
1266 );
1267 }
1268
1269 #[test]
1270 fn a_config_list_is_comma_separated() {
1271 let list = ConfigSource::parse_list(" a.yaml, s3://b/k.yaml ,").expect("valid list");
1272 assert_eq!(
1273 list,
1274 [
1275 ConfigSource::File("a.yaml".into()),
1276 ConfigSource::S3 {
1277 bucket: "b".into(),
1278 key: "k.yaml".into()
1279 }
1280 ]
1281 );
1282 assert!(ConfigSource::parse_list(" , ").is_err());
1283 }
1284
1285 #[test]
1286 fn cache_path_is_per_source_and_only_for_remote_sources() {
1287 let opts = LoadOptions {
1288 refresh: None,
1289 cache_dir: Some(PathBuf::from("/cache")),
1290 };
1291 let a = opts.cache_path(&ConfigSource::parse("s3://b/a.yaml").expect("valid source"));
1292 let b = opts.cache_path(&ConfigSource::parse("s3://b/b.yaml").expect("valid source"));
1293 assert!(a.is_some() && b.is_some() && a != b);
1294 assert!(a.expect("valid source").starts_with("/cache"));
1295 assert_eq!(
1296 opts.cache_path(&ConfigSource::parse("./signal.yaml").expect("valid source")),
1297 None
1298 );
1299 }
1300
1301 #[cfg(not(feature = "aws"))]
1302 #[tokio::test]
1303 async fn s3_without_the_feature_or_an_add_on_says_how_to_get_it() {
1304 let source = ConfigSource::parse("s3://b/k").expect("valid source");
1305 let err = source.check_supported().expect_err("unsupported");
1306 assert!(err.to_string().contains("opssignal[aws]"));
1307 assert!(err.to_string().contains("`aws` cargo feature"));
1308 let err = source.fetch().await.expect_err("must be rejected");
1309 assert!(err.to_string().contains("opssignal[aws]"));
1310 }
1311
1312 #[cfg(not(feature = "gcp"))]
1313 struct Fixed(&'static str);
1314
1315 #[cfg(not(feature = "gcp"))]
1316 impl ExternalFetcher for Fixed {
1317 fn fetch(&self, url: &str) -> Result<String, String> {
1318 Ok(format!("{}{url}", self.0))
1319 }
1320 }
1321
1322 #[cfg(not(feature = "azure"))]
1323 struct Panics;
1324
1325 #[cfg(not(feature = "azure"))]
1326 impl ExternalFetcher for Panics {
1327 fn fetch(&self, _url: &str) -> Result<String, String> {
1328 panic!("add-on bug")
1329 }
1330 }
1331
1332 #[cfg(not(feature = "gcp"))]
1333 #[tokio::test]
1334 async fn a_registered_add_on_serves_its_scheme() {
1335 let source = ConfigSource::parse("gs://b/k.yaml").expect("valid source");
1336 assert!(source.check_supported().is_err());
1337
1338 register_fetcher("gs", Arc::new(Fixed("fetched ")));
1339
1340 assert!(source.check_supported().is_ok());
1341 assert_eq!(
1342 source.fetch().await.expect("fetched"),
1343 "fetched gs://b/k.yaml"
1344 );
1345 }
1346
1347 #[cfg(not(feature = "azure"))]
1348 #[tokio::test]
1349 async fn a_panicking_add_on_is_a_fetch_error() {
1350 register_fetcher("az", Arc::new(Panics));
1351 let err = ConfigSource::parse("az://c/k")
1352 .expect("valid source")
1353 .fetch()
1354 .await
1355 .expect_err("must fail");
1356 assert!(err.to_string().contains("without an answer"));
1357 }
1358}