Skip to main content

opssignal_core/
source.rs

1//! Where `signal.yaml` comes from, and how it is fetched and cached.
2//!
3//! `$SIGNAL_CONFIG` can name a local path or a remote object:
4//!
5//! - `/etc/opssignal/signal.yaml` (or any path): read from disk
6//! - `https://config.example.com/signal.yaml`: plain GET
7//! - `s3://bucket/key`: needs the `aws` cargo feature
8//! - `gs://bucket/object`: needs the `gcp` cargo feature
9//! - `az://container/blob`: needs the `azure` cargo feature; the storage
10//!   account comes from `$AZURE_STORAGE_ACCOUNT_NAME`
11//! - `appconfig://application/environment/profile`: AWS AppConfig,
12//!   needs the `aws` cargo feature
13//! - `azappconfig://store/key?label=prod`: Azure App Configuration,
14//!   needs the `azure` cargo feature
15//! - `dynamodb://table/id`: an Amazon DynamoDB item, needs `aws`
16//! - `firestore://project/collection/document`: a Firestore document,
17//!   needs `gcp`
18//! - `cosmos://account/database/container/id`: an Azure Cosmos DB item,
19//!   needs `azure`
20//!
21//! Cloud fetches use each platform's standard credential chain (IAM
22//! role / IRSA / env keys on AWS; workload identity / ADC / service
23//! account on GCP; workload identity / managed identity / service
24//! principal / SAS on Azure), so OpsSignal adds no keys of its own.
25//!
26//! A remote config is cached on local disk after every successful
27//! fetch, as the raw text *before* `${VAR}` interpolation, so no
28//! secret is ever written to the cache. If the source can't be reached
29//! or returns a broken file, the last good cached copy is used instead:
30//! a bucket outage must never be the reason an alert goes missing.
31//!
32//! `$SIGNAL_CONFIG` may also list several files, comma separated, and
33//! any file may `extends:` others; see [`Config::from_layers`] for how
34//! they merge. Each remote file in the chain is cached on its own.
35
36use 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    /// `https://`, or `http://` to a loopback host only.
55    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    /// An AWS AppConfig configuration profile.
69    AppConfig {
70        application: String,
71        environment: String,
72        profile: String,
73    },
74    /// One key-value of an Azure App Configuration store; no `label`
75    /// means the unlabelled value.
76    AzureAppConfig {
77        store: String,
78        key: String,
79        label: Option<String>,
80    },
81    /// A DynamoDB item, read by its string partition key (attribute
82    /// `key`, default `id`); the YAML is in attribute `value` (default
83    /// `config`).
84    DynamoDb {
85        table: String,
86        id: String,
87        key: Option<String>,
88        value: Option<String>,
89    },
90    /// A Firestore document (`collection/doc`, possibly nested); the
91    /// YAML is in string field `field` (default `config`).
92    Firestore {
93        project: String,
94        document: String,
95        database: Option<String>,
96        field: Option<String>,
97    },
98    /// A Cosmos DB item, read by id; its partition key value is `pk`, or
99    /// the id itself. The YAML is in string property `field` (default
100    /// `config`).
101    Cosmos {
102        account: String,
103        database: String,
104        container: String,
105        id: String,
106        pk: Option<String>,
107        field: Option<String>,
108    },
109}
110
111/// The URL schemes each cloud's add-on serves: config sources, then
112/// the secret stores of `${store:...}` placeholders (see `secrets`).
113pub 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    /// Parses a `$SIGNAL_CONFIG` value. Anything without a recognised
171    /// scheme is a local path.
172    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            // Plain HTTP would let anyone on the path rewrite the routing
269            // rules, so it is allowed only for local testing.
270            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    /// Parses a `$SIGNAL_CONFIG` value that lists one or more files,
292    /// comma separated, lowest priority first.
293    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    /// `$SIGNAL_CONFIG` if set, else `./signal.yaml` if it exists. A
307    /// local `$SIGNAL_CONFIG` path that is not a file is an error rather
308    /// than a silent fall back to defaults.
309    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    /// Resolves an `extends` entry of this file. A URL or an absolute
329    /// path is taken as is; anything else is relative to this file's
330    /// own location, in the same bucket, container or directory.
331    ///
332    /// A remote file may only extend other remote files: a config
333    /// served over the network must not be able to read local paths.
334    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            // Profiles have flat names: a relative entry names another
366            // profile of the same application and environment.
367            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            // Keys are often path-like (`opssignal/teams/payments`), so
384            // they resolve like paths, keeping the label.
385            ConfigSource::AzureAppConfig { store, key, label } => ConfigSource::AzureAppConfig {
386                store: store.clone(),
387                key: self.join_key(key, reference)?,
388                label: label.clone(),
389            },
390            // Another item of the same table, read the same way.
391            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            // Document paths resolve like paths, in the same database.
400            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            // Another item of the same container. Its partition key is
420            // assumed to be its id; use a full URL with ?pk= otherwise.
421            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    /// A relative `extends` entry that names a sibling item.
445    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    /// `reference` resolved against the "directory" of object `key`.
456    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    /// Whether this build can read the source at all, either natively
481    /// or through a registered [`ExternalFetcher`]. A missing one is an
482    /// install problem, not an outage, so it fails loudly instead of
483    /// falling back.
484    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    /// The URL scheme, or `file` for a local path.
502    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    /// The cloud whose add-on reads this source, if any.
519    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    /// Reads the raw (uninterpolated) config text from the source.
532    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    /// [`ConfigSource::fetch`] without the source named in the error,
539    /// for add-ons whose caller adds it.
540    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    /// A cloud source, from this build or from its add-on.
551    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
621/// Drops `.` and folds `..` into its parent without touching the disk,
622/// so two teams reaching one shared file by different relative paths
623/// load it once.
624fn 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
641/// Splits `path?a=1&b=2` into the path and the values of `allowed`, in
642/// that order. Unknown or repeated parameters are errors, so a typo
643/// can't silently read the wrong item.
644fn 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
710/// Reads one URL scheme for a build that was compiled without it.
711///
712/// Language bindings register these at runtime from add-on packages
713/// (the Python wheel ships local and HTTPS support only, and
714/// `pip install 'opssignal[aws]'` adds an S3 fetcher), so the base
715/// package stays small. Implementations may block; they run on their
716/// own thread and return the raw, uninterpolated file text.
717pub 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
728/// Registers `fetcher` for one URL scheme (such as `s3`), replacing any
729/// earlier one. A scheme compiled into this build ignores it.
730pub 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
737/// Registers `fetcher` for every scheme of `cloud` (`aws`, `gcp` or
738/// `azure`; see [`CLOUD_SCHEMES`]). This is what an add-on package does.
739pub 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    // A plain thread rather than spawn_blocking: the fetcher may start
765    // its own runtime, which tokio forbids on its blocking pool.
766    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    // The fetcher applies FETCH_TIMEOUT itself; this only guards
773    // against one that never returns.
774    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
800/// Fetches the raw text at `url` on a throwaway runtime, with errors
801/// in the form an [`ExternalFetcher`] returns them. This is what add-on
802/// packages call to serve a request. `url` is a config source or a
803/// secret reference without its `#field` (`aws-sm:prod/slack`).
804pub 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/// How a client loads and refreshes its config.
821#[derive(Debug, Clone)]
822pub struct LoadOptions {
823    /// How often to re-read the source and apply changes. `None` loads
824    /// once and never reloads.
825    pub refresh: Option<Duration>,
826    /// Where remote configs are cached. `None` disables the cache.
827    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    /// Defaults, overridden by `$SIGNAL_CONFIG_REFRESH_SECS` (0 turns
841    /// reloading off) and `$SIGNAL_CONFIG_CACHE_DIR`.
842    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    /// Load once, no reloading. What a one-shot CLI call wants.
859    pub fn once() -> Self {
860        LoadOptions {
861            refresh: None,
862            ..LoadOptions::default()
863        }
864    }
865
866    /// Where the cached copy of `source` lives, if it is remote and
867    /// caching is on.
868    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
888/// The config a client starts with, plus the raw text of every file it
889/// was built from so the reloader can tell when one has changed.
890pub(crate) struct Loaded {
891    pub config: Config,
892    pub texts: Option<Texts>,
893}
894
895/// `(source, raw text, digest of its secrets)` for each file of a
896/// chain, in merge order.
897pub(crate) type Texts = Vec<(String, String, String)>;
898
899/// Initial load. A local file must exist and parse, as before. A remote
900/// file falls back to its cached copy; if the chain still can't be
901/// built, the client starts on the zero-config default, logging why, so
902/// that startup never fails on a network blip.
903pub(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        // Every file fetched, but together they don't make a valid
917        // config (say a remote team file now routes to a provider
918        // nobody configures). The cached copies did, last time.
919        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
959/// [`load_initial`] on a throwaway runtime, for callers that are not
960/// async and have no client runtime yet.
961pub(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
972/// Write-then-rename so a crash mid-write never leaves a truncated
973/// cache behind. Failure only costs the fallback, so it is logged, not
974/// returned.
975pub(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
993/// The message of a config error, without the "config error:" prefix.
994pub(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}