use std::borrow::Cow;
use std::collections::BTreeMap;
use std::fmt;
use std::net::IpAddr;
use std::str::FromStr;
use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::identity::Provider;
use crate::names::{Name, OsName};
use crate::path::GuestPath;
use crate::port::Port;
use crate::template::Template;
use crate::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Arch {
Amd64,
Arm64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum Role {
Target,
Job,
Infra,
Access,
Tool,
Check,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Cidr {
address: IpAddr,
prefix: u8,
}
impl Cidr {
pub fn parse(text: &str) -> Result<Cidr> {
let err = || Error::Cidr { value: text.to_owned() };
let (address, prefix) = text.split_once('/').ok_or_else(err)?;
let address: IpAddr = address.parse().map_err(|_| err())?;
let prefix: u8 = prefix.parse().map_err(|_| err())?;
let max = if address.is_ipv4() { 32 } else { 128 };
if prefix > max {
return Err(err());
}
Ok(Cidr { address, prefix })
}
}
impl FromStr for Cidr {
type Err = Error;
fn from_str(text: &str) -> Result<Cidr> {
Cidr::parse(text)
}
}
impl fmt::Display for Cidr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}/{}", self.address, self.prefix)
}
}
impl Serialize for Cidr {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for Cidr {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Cidr, D::Error> {
Cidr::parse(&String::deserialize(d)?).map_err(serde::de::Error::custom)
}
}
impl JsonSchema for Cidr {
fn schema_name() -> Cow<'static, str> {
"Cidr".into()
}
fn json_schema(_: &mut SchemaGenerator) -> Schema {
json_schema!({
"description": "A network block: an IPv4 or IPv6 address and a prefix length (`10.30.0.0/24`).",
"type": "string",
"pattern": "^[0-9A-Fa-f.:]+/[0-9]{1,3}$",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Network {
pub name: Name,
pub cidr: Cidr,
pub internet: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native: Option<Template>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Resources {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpus: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_mb: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Native {
Compose {
service: Name,
},
Vagrant {
vagrant: Name,
vm_names: BTreeMap<Provider, String>,
},
}
#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
#[schemars(
title = "Native",
description = "A machine's name where the target creates it: the Compose `service`, or the `vagrant` machine with its VM name per provider (what orphan clean-up matches, exactly).",
extend("oneOf" = [{ "required": ["service"] }, { "required": ["vagrant"] }], "dependentRequired" = { "vm_names": ["vagrant"] })
)]
struct NativeFields {
#[serde(default, skip_serializing_if = "Option::is_none")]
service: Option<Name>,
#[serde(default, skip_serializing_if = "Option::is_none")]
vagrant: Option<Name>,
#[serde(default, skip_serializing_if = "Option::is_none")]
vm_names: Option<BTreeMap<Provider, String>>,
}
impl Serialize for Native {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let fields = match self {
Native::Compose { service } => NativeFields {
service: Some(service.clone()),
vagrant: None,
vm_names: None,
},
Native::Vagrant { vagrant, vm_names } => NativeFields {
service: None,
vagrant: Some(vagrant.clone()),
vm_names: Some(vm_names.clone()),
},
};
fields.serialize(s)
}
}
impl<'de> Deserialize<'de> for Native {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Native, D::Error> {
match NativeFields::deserialize(d)? {
NativeFields {
service: Some(service),
vagrant: None,
vm_names: None,
} => Ok(Native::Compose { service }),
NativeFields {
service: None,
vagrant: Some(vagrant),
vm_names,
} => Ok(Native::Vagrant {
vagrant,
vm_names: vm_names.unwrap_or_default(),
}),
_ => Err(serde::de::Error::custom(Error::NativeShape)),
}
}
}
impl JsonSchema for Native {
fn schema_name() -> Cow<'static, str> {
"Native".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
NativeFields::json_schema(generator)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Volume {
pub name: Name,
pub path: GuestPath,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native: Option<Template>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Machine {
pub name: Name,
pub role: Role,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub of: Option<Name>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub os: Option<OsName>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub windows: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arch: Option<Arch>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resources: Option<Resources>,
pub addresses: BTreeMap<Name, IpAddr>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub keep_running: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub on_demand: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub native: Option<Native>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub volumes: Vec<Volume>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ports: Vec<Port>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_block_has_an_address_and_a_prefix() {
assert_eq!(Cidr::parse("10.30.0.0/24").unwrap().to_string(), "10.30.0.0/24");
assert_eq!(Cidr::parse("fd00:30::/64").unwrap().to_string(), "fd00:30::/64");
assert_eq!(Cidr::parse("10.30.0.1/32").unwrap().to_string(), "10.30.0.1/32");
assert_eq!("fd00::1/128".parse::<Cidr>().unwrap().to_string(), "fd00::1/128");
for bad in ["10.30.0.0", "10.30.0.0/33", "fd00::/129", "lab/24", "10.30.0.0/x"] {
assert_eq!(Cidr::parse(bad).unwrap_err().code(), "cidr", "{bad}");
}
}
#[test]
fn native_is_a_service_or_a_vagrant_machine() {
let c: Native = serde_json::from_str(r#"{"service": "db"}"#).unwrap();
assert!(matches!(c, Native::Compose { .. }));
let v: Native = serde_json::from_str(r#"{"vagrant": "dc01", "vm_names": {"hyperv": "mini-ad-dc01"}}"#).unwrap();
assert!(matches!(v, Native::Vagrant { ref vm_names, .. } if vm_names[&Provider::Hyperv] == "mini-ad-dc01"));
let both = serde_json::from_str::<Native>(r#"{"service": "db", "vagrant": "db"}"#).unwrap_err();
assert!(both.to_string().contains("not both"), "{both}");
}
}