glrcfg/
global.rs

1// Copyright 2024 bmc::labs GmbH. All rights reserved.
2
3use std::{fmt, num::NonZeroU32, str::FromStr};
4
5use once_cell::sync::Lazy;
6use regex::Regex;
7use serde::Serialize;
8use thiserror::Error;
9use url::Url;
10
11static GOLANG_DURATION_REGEX_STR: &str = r"([+-]?(\d+(h|m|s|ms|us|µs|ns))+|0)";
12static GOLANG_DURATION_REGEX: Lazy<Regex> = Lazy::new(|| {
13    Regex::new(&format!(r"^{GOLANG_DURATION_REGEX_STR}$"))
14        .expect("instantiating GOLANG_DURATION_REGEX from given static string must not fail")
15});
16
17/// Defines the log level. Options are `debug`, `info`, `warn`, `error`, `fatal`, and `panic`. This
18/// setting has lower priority than the level set by the command-line arguments `--debug`, `-l`, or
19/// `--log-level`.
20///
21/// Further documentation found in [the GitLab
22/// docs](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section).
23#[derive(Debug, PartialEq, Serialize)]
24#[serde(rename_all = "lowercase")]
25pub enum LogLevel {
26    Debug,
27    Info,
28    Warn,
29    Error,
30    Fatal,
31    Panic,
32}
33
34/// Specifies the log format. Options are `runner`, `text`, and `json`. This setting has lower
35/// priority than the format set by command-line argument `--log-format`. The default value is
36/// `runner`, which contains ANSI escape codes for coloring.
37///
38/// Further documentation found in [the GitLab
39/// docs](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section).
40#[derive(Debug, PartialEq, Serialize)]
41#[serde(rename_all = "lowercase")]
42pub enum LogFormat {
43    Runner,
44    Text,
45    Json,
46}
47
48#[derive(Debug, PartialEq, Eq, Error)]
49#[error("invalid Golang duration (which look like 15m, 1h, 1h15m, etc.)")]
50pub struct GolangDurationParseError;
51
52/// The Golang standard library [has a `Duration` type](https://pkg.go.dev/time#Duration), which
53/// has a function called `ParseDuration` that accepts formatted strings like these: `15m` for 15
54/// minutes, `1h` for 1 hour, `1h15m` for 1 hour and 15 minutes. This type enforces that format.
55///
56/// # Example
57///
58/// ```
59/// # use glrcfg::GolangDuration;
60/// let duration = GolangDuration::parse("15m").unwrap();
61/// assert_eq!(duration.as_str(), "15m");
62/// assert!(GolangDuration::parse("42hours").is_err());
63/// ```
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65#[serde(transparent)]
66pub struct GolangDuration(String);
67
68impl GolangDuration {
69    /// Parses a Golang durection from an `Into<String>`, e.g. a `&str` or `String`.
70    pub fn parse<S>(duration: S) -> Result<Self, GolangDurationParseError>
71    where
72        S: Into<String>,
73    {
74        let duration = duration.into();
75
76        if !GOLANG_DURATION_REGEX.is_match(&duration) {
77            #[cfg(feature = "tracing")]
78            tracing::error!("invalid Golang duration: {duration}");
79            return Err(GolangDurationParseError);
80        }
81
82        Ok(Self(duration))
83    }
84
85    /// Returns the Golang duration as a string slice.
86    pub fn as_str(&self) -> &str {
87        &self.0
88    }
89}
90
91impl fmt::Display for GolangDuration {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        self.0.fmt(f)
94    }
95}
96
97impl FromStr for GolangDuration {
98    type Err = GolangDurationParseError;
99
100    fn from_str(duration: &str) -> Result<Self, Self::Err> {
101        Self::parse(duration)
102    }
103}
104
105/// These settings are global. They apply to all runners.
106///
107/// See the [`Default` implementation](Self::default) for the default values.
108///
109/// Further documentation found in [the GitLab
110/// docs](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-global-section).
111#[derive(Debug, Serialize)]
112pub struct GlobalSection {
113    pub concurrent: NonZeroU32,
114    pub log_level: LogLevel,
115    pub log_format: LogFormat,
116    pub check_interval: u32,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub sentry_dsn: Option<Url>,
119    pub connection_max_age: GolangDuration,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub listen_address: Option<Url>,
122    pub shutdown_timeout: u32,
123}
124
125impl Default for GlobalSection {
126    fn default() -> Self {
127        Self {
128            concurrent: NonZeroU32::new(1).expect("1 is not zero"),
129            log_level: LogLevel::Error,
130            log_format: LogFormat::Json,
131            check_interval: 3,
132            sentry_dsn: None,
133            connection_max_age: GolangDuration::parse("15m").expect("15m is a valid duration"),
134            listen_address: None,
135            shutdown_timeout: 30,
136        }
137    }
138}
139
140#[cfg(test)]
141mod test {
142    use pretty_assertions::assert_eq;
143    use test_strategy::proptest;
144
145    use super::{GlobalSection, GolangDuration, GOLANG_DURATION_REGEX, GOLANG_DURATION_REGEX_STR};
146
147    #[test]
148    fn test_default() {
149        let global_section = GlobalSection::default();
150
151        let toml = toml::to_string_pretty(&global_section).expect("could not serialize to TOML");
152
153        assert_eq!(
154            toml,
155            indoc::indoc! {r#"
156                concurrent = 1
157                log_level = "error"
158                log_format = "json"
159                check_interval = 3
160                connection_max_age = "15m"
161                shutdown_timeout = 30
162            "#}
163        );
164    }
165
166    #[proptest]
167    fn parse_valid_golang_durations(#[strategy(GOLANG_DURATION_REGEX_STR)] duration: String) {
168        assert_eq!(duration, GolangDuration::parse(&duration).unwrap().as_str());
169    }
170
171    #[proptest]
172    fn parse_invalid_golang_durations(
173        #[filter(|s| !GOLANG_DURATION_REGEX.is_match(s))] duration: String,
174    ) {
175        assert!(GolangDuration::parse(duration).is_err());
176    }
177}