Repository: bottlerocket-os/bottlerocket
File: sources/models/src/modeled_types/ecs.rs (Rust)

Code before:
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
// Just need serde's Error in scope to get its trait methods
use super::error;
use serde::de::Error as _;
use snafu::{ensure, ResultExt};
use std::borrow::Borrow;
use std::convert::TryFrom;
use std::fmt;
use std::ops::Deref;

/// ECSAttributeKey represents a string that contains a valid ECS attribute key.  It stores
/// the original string and makes it accessible through standard traits.
// https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_Attribute.html
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct ECSAttributeKey {
    inner: String,
}

// The name of the attribute. The name must contain between 1 and 128
// characters and name may contain letters (uppercase and lowercase), numbers,
// hyphens, underscores, forward slashes, back slashes, or periods.
lazy_static! {
    pub(crate) static ref ECS_ATTRIBUTE_KEY: Regex = Regex::new(
        r"(?x)^
          [a-zA-Z0-9._/-]{1,128}
          $"
    )
    .unwrap();
}

impl TryFrom<&str> for ECSAttributeKey {
    type Error = error::Error;

    fn try_from(input: &str) -> Result<Self, Self::Error> {
        ensure!(
            ECS_ATTRIBUTE_KEY.is_match(input),
            error::BigPatternSnafu {
                thing: "ECS attribute key",
                input
            }
        );
        Ok(ECSAttributeKey {
            inner: input.to_string(),
        })
    }
}

string_impls_for!(ECSAttributeKey, "ECSAttributeKey");

#[cfg(test)]
mod test_ecs_attribute_key {
    use super::ECSAttributeKey;
    use std::convert::TryFrom;

    #[test]
    fn good_keys() {
        for key in &[
            "a",
            "alphabetical",
            "1234567890",
            "with-dash",
            "have.period/slash",
            "have_underscore_too",
            &"a".repeat(128),
            ".leadingperiod",
            "trailingperiod.",
        ] {
            ECSAttributeKey::try_from(*key).unwrap();
        }
    }

    #[test]
    fn bad_keys() {
        for key in &[
            "",
            &"a".repeat(129),
            "@",
            "$",
            "%",
            ":",
            "no spaces allowed",
        ] {
            ECSAttributeKey::try_from(*key).unwrap_err();
        }
    }
}

// =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=

/// ECSAttributeValue represents a string that contains a valid ECS attribute value.  It stores
/// the original string and makes it accessible through standard traits.
// https://docs.aws.amazon.com/AmazonECS/latest/APIReference/API_Attribute.html
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct ECSAttributeValue {
    inner: String,
}

// The value of the attribute. The value must contain between 1 and 128
// characters and may contain letters (uppercase and lowercase), numbers,
// hyphens, underscores, periods, at signs (@), forward slashes, back slashes,
// colons, or spaces. The value cannot contain any leading or trailing
// whitespace.
lazy_static! {
    pub(crate) static ref ECS_ATTRIBUTE_VALUE: Regex = Regex::new(
        r"(?x)^
          [a-zA-Z0-9.@:_/\\-] # at least one non-space
          (
            ([a-zA-Z0-9.@:\ _/\\-]{0,126})? # spaces allowed
            [a-zA-Z0-9.@:_/\\-] # end with non-space
          )?
          $"
    )
    .unwrap();
}

impl TryFrom<&str> for ECSAttributeValue {
    type Error = error::Error;

    fn try_from(input: &str) -> Result<Self, Self::Error> {
        ensure!(
            ECS_ATTRIBUTE_VALUE.is_match(input),
            error::BigPatternSnafu {
                thing: "ECS attribute value",
                input
            }
        );
        Ok(ECSAttributeValue {
            inner: input.to_string(),
        })
    }
}

string_impls_for!(ECSAttributeValue, "ECSAttributeValue");

#[cfg(test)]
mod test_ecs_attribute_value {
    use super::ECSAttributeValue;
    use std::convert::TryFrom;

    #[test]
    fn good_vals() {
        for val in &[
            "a",
            "alphabetical",
            "1234567890",
            "with-dash",
            "have.period/slash",
            "have/slash\\backslash",
            "have_underscore_too",
            "with spaces in between",
            &"a".repeat(128),
            ".leadingperiod",
            "trailingperiod.",
            "@ and : allowed too",
            "\\",
            "\\ \\",
        ] {
            ECSAttributeValue::try_from(*val).unwrap();
        }
    }

    #[test]
    fn bad_vals() {
        for val in &[
            "",
            &"a".repeat(129),
            "$",
            "%",
            " leading space",
            "trailing space ",
        ] {
            ECSAttributeValue::try_from(*val).unwrap_err();
        }
    }
}

// =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=

/// ECSAgentLogLevel represents a string that contains a valid ECS log level for the ECS agent.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct ECSAgentLogLevel {
    inner: String,
}

#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "lowercase")]
enum ECSLogLevel {
    Debug,
    Info,
    Warn,
    Error,
    Crit,
}

string_impls_for!(ECSAgentLogLevel, "ECSAgentLogLevel");

impl TryFrom<&str> for ECSAgentLogLevel {
    type Error = error::Error;

    fn try_from(input: &str) -> Result<Self, Self::Error> {
        serde_plain::from_str::<ECSLogLevel>(&input).context(error::InvalidPlainValueSnafu {
            field: "ecs.loglevel",
        })?;
        Ok(ECSAgentLogLevel {
            inner: input.to_string(),
        })
    }
}

#[cfg(test)]
mod test_ecs_agent_log_level {
    use super::ECSAgentLogLevel;
    use std::convert::TryFrom;

    #[test]
    fn good_vals() {
        for val in &["debug", "info", "warn"] {
            ECSAgentLogLevel::try_from(*val).unwrap();
        }
    }

    #[test]
    fn bad_vals() {
        for val in &["", "warning", "errors", " "] {
            ECSAgentLogLevel::try_from(*val).unwrap_err();
        }
    }
}

// =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=   =^..^=

/// ECSAgentImagePullBehavior represents a string that contains a valid ECS Image Pull Behavior for the ECS agent.
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct ECSAgentImagePullBehavior {
    inner: String,
}

#[derive(Debug, Clone, Eq, PartialEq, Hash, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ECSImagePullBehavior {
    Default = 0,
    Always,
    Once,
    PreferCached,
}

impl TryFrom<&str> for ECSImagePullBehavior {
    type Error = error::Error;

    fn try_from(input: &str) -> Result<Self, Self::Error> {
        let image_pull_behavior = serde_plain::from_str::<ECSImagePullBehavior>(&input).context(
            error::InvalidPlainValueSnafu {
                field: "ecs.image_pull_behavior",
            },
        )?;
        Ok(image_pull_behavior)
    }
}

string_impls_for!(ECSAgentImagePullBehavior, "ECSAgentImagePullBehavior");

impl TryFrom<&str> for ECSAgentImagePullBehavior {
    type Error = error::Error;

    fn try_from(input: &str) -> Result<Self, Self::Error> {
        ECSImagePullBehavior::try_from(input)?;
        Ok(ECSAgentImagePullBehavior {
            inner: input.to_string(),
        })
    }
}

#[cfg(test)]
mod test_ecs_agent_image_pull_behavior {
    use super::ECSAgentImagePullBehavior;
    use std::convert::TryFrom;

    #[test]
    fn good_vals() {
        for val in &["default", "always", "once", "prefer-cached"] {
            ECSAgentImagePullBehavior::try_from(*val).unwrap();
        }
    }

    #[test]
    fn bad_vals() {
        for val in &["", "tomorrow", "never", " "] {
            ECSAgentImagePullBehavior::try_from(*val).unwrap_err();
        }
    }
}


Review comment (suggestion):
Curious how this regex was derived? If we're going off [the Go `time.ParseDuration` API](https://pkg.go.dev/time#ParseDuration) (where this duration string will be consumed):

> A duration string is a possibly signed sequence of decimal numbers, each with optional fraction and a unit suffix, such as "300ms", "-1.5h" or "2h45m". Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".

This doesn't support negative time durations (like `-1h` with the leading sign) which probably makes sense for this use case but would be a valid duration to be consumed by the Go serialization. I think we should support it just in case customers are doing something with negative time duration (and if the ECS agent supports it)

---

[The Go source code](https://cs.opensource.google/go/go/+/refs/tags/go1.19.2:src/time/format.go;l=1523) does offer a regex we could reference:
```
[-+]?([0-9]*(\.[0-9]*)?[a-z]+)+
```
although this regex is more permissive of the unit types. Instead, [they reference a static in memory unit slice and valid units that way.](https://cs.opensource.google/go/go/+/refs/tags/go1.19.2:src/time/format.go;l=1506-1515)

Code after:
    use std::convert::TryFrom;

    #[test]
    fn good_vals() {
        for val in &["default", "always", "once", "prefer-cached"] {
            ECSAgentImagePullBehavior::try_from(*val).unwrap();
        }
    }

    #[test]
    fn bad_vals() {
        for val in &["", "tomorrow", "never", " "] {
            ECSAgentImagePullBehavior::try_from(*val).unwrap_err();
        }
    }
}

/// ECSDurationValue represents a string that contains a valid ECS duration value
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub struct ECSDurationValue {
    inner: String,
}

lazy_static! {
    pub(crate) static ref ECS_DURATION_VALUE: Regex =
        Regex::new(r"^(([0-9]+\.)?[0-9]+h)?(([0-9]+\.)?[0-9]+m)?(([0-9]+\.)?[0-9]+s)?(([0-9]+\.)?[0-9]+ms)?(([0-9]+\.)?[0-9]+(u|µ)s)?(([0-9]+\.)?[0-9]+ns)?$").unwrap();
}

impl TryFrom<&str> for ECSDurationValue {
    type Error = error::Error;

    fn try_from(input: &str) -> Result<Self, Self::Error> {
        ensure!(
            !input.is_empty() && ECS_DURATION_VALUE.is_match(input),
            error::InvalidECSDurationValueSnafu { input }
        );
        Ok(ECSDurationValue {
            inner: input.to_string(),
        })
    }
}

string_impls_for!(ECSDurationValue, "ECSDurationValue");

#[cfg(test)]
mod test_ecs_duration_value {
    use super::ECSDurationValue;
    use std::convert::TryFrom;

    #[test]
    fn valid_values() {