diff --git a/api-test/Cargo.toml b/api-test/Cargo.toml index a328638c..67a5e4fa 100644 --- a/api-test/Cargo.toml +++ b/api-test/Cargo.toml @@ -13,6 +13,10 @@ endian_trait = { version = "0.6", features = [ "arrays" ] } failure = "0.1" http = "0.1" hyper = { version = "0.13.0-a.0", git = "https://github.com/hyperium/hyper" } +lazy_static = "1.3" proxmox = { path = "../proxmox" } +regex = "1.1" +serde = "1.0" serde_json = "1.0" +serde_plain = "0.3" tokio = { version = "0.2", git = "https://github.com/tokio-rs/tokio" } diff --git a/api-test/src/api.rs b/api-test/src/bin/api-test.rs similarity index 51% rename from api-test/src/api.rs rename to api-test/src/bin/api-test.rs index 84b84822..522c4d21 100644 --- a/api-test/src/api.rs +++ b/api-test/src/bin/api-test.rs @@ -1,13 +1,79 @@ +#![feature(async_await)] + use std::io; use std::path::Path; -use failure::{bail, Error}; +use failure::{bail, format_err, Error}; +use http::Request; use http::Response; -use hyper::Body; +use hyper::service::{make_service_fn, service_fn}; +use hyper::{Body, Server}; +use serde_json::Value; use tokio::io::AsyncReadExt; use proxmox::api::{api, router}; +async fn run_request(request: Request) -> Result, hyper::Error> { + match route_request(request).await { + Ok(r) => Ok(r), + Err(err) => Ok(Response::builder() + .status(400) + .body(Body::from(format!("ERROR: {}", err))) + .expect("building an error response...")), + } +} + +async fn route_request(request: Request) -> Result, Error> { + let path = request.uri().path(); + + let (target, params) = ROUTER + .lookup(path) + .ok_or_else(|| format_err!("missing path: {}", path))?; + + target + .get + .as_ref() + .ok_or_else(|| format_err!("no GET method for: {}", path))? + .call(params.unwrap_or(Value::Null)) + .await +} + +async fn main_do(www_dir: String) { + // Construct our SocketAddr to listen on... + let addr = ([0, 0, 0, 0], 3000).into(); + + // And a MakeService to handle each connection... + let service = make_service_fn(|_| async { Ok::<_, hyper::Error>(service_fn(run_request)) }); + + // Then bind and serve... + let server = Server::bind(&addr).serve(service); + + println!("Serving {} under http://localhost:3000/www/", www_dir); + + if let Err(e) = server.await { + eprintln!("server error: {}", e); + } +} + +fn main() { + // We expect a path, where to find our files we expose via the www/ dir: + let mut args = std::env::args(); + + // real code should have better error handling + let _program_name = args.next(); + let www_dir = args.next().expect("expected a www/ subdirectory"); + set_www_dir(www_dir.to_string()); + + // show our api info: + println!( + "{}", + serde_json::to_string_pretty(&ROUTER.api_dump()).unwrap() + ); + + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(main_do(www_dir)); +} + #[api({ description: "Hello API call", })] diff --git a/api-test/src/lib.rs b/api-test/src/lib.rs new file mode 100644 index 00000000..35d66fbd --- /dev/null +++ b/api-test/src/lib.rs @@ -0,0 +1,4 @@ +//! PVE base library + +pub mod lxc; +pub mod schema; diff --git a/api-test/src/lxc/mod.rs b/api-test/src/lxc/mod.rs new file mode 100644 index 00000000..cb4e2ace --- /dev/null +++ b/api-test/src/lxc/mod.rs @@ -0,0 +1,226 @@ +//! PVE LXC module + +use std::collections::HashSet; + +use proxmox::api::api; + +use crate::schema::{ + types::{Memory, VolumeId}, + Architecture, +}; + +pub mod schema; + +#[api({ + description: "The PVE side of an lxc container configuration.", + cli: false, + fields: { + lock: "The current long-term lock held on this container by another operation.", + onboot: { + description: "Specifies whether a VM will be started during system bootup.", + default: false, + }, + startup: "The container's startup order.", + template: { + description: "Whether this is a template.", + default: false, + }, + arch: { + description: "The container's architecture type.", + default: Architecture::Amd64, + }, + ostype: { + description: + "OS type. This is used to setup configuration inside the container, \ + and corresponds to lxc setup scripts in \ + /usr/share/lxc/config/.common.conf. \ + Value 'unmanaged' can be used to skip and OS specific setup.", + }, + console: { + description: "Attach a console device (/dev/console) to the container.", + default: true, + }, + tty: { + description: "Number of ttys available to the container", + minimum: 0, + maximum: 6, + default: 2, + }, + cores: { + description: + "The number of cores assigned to the container. \ + A container can use all available cores by default.", + minimum: 1, + maximum: 128, + }, + cpulimit: { + description: + "Limit of CPU usage.\ + \n\n\ + NOTE: If the computer has 2 CPUs, it has a total of '2' CPU time. \ + Value '0' indicates no CPU limit.", + minimum: 0, + maximum: 128, + default: 0, + }, + cpuunits: { + description: + "CPU weight for a VM. Argument is used in the kernel fair scheduler. \ + The larger the number is, the more CPU time this VM gets. \ + Number is relative to the weights of all the other running VMs.\ + \n\n\ + NOTE: You can disable fair-scheduler configuration by setting this to 0.", + minimum: 0, + maximum: 500000, + default: 1024, + }, + memory: { + description: "Amount of RAM for the VM.", + minimum: Memory::from_mebibytes(16), + default: Memory::from_mebibytes(512), + serialization: crate::schema::memory::optional::Parser:: + }, + swap: { + description: "Amount of SWAP for the VM.", + minimum: Memory::from_bytes(0), + default: Memory::from_mebibytes(512), + }, + hostname: { + description: "Set a host name for the container.", + maximum_length: 255, + minimum_length: 3, + format: crate::schema::dns_name, + }, + description: "Container description. Only used on the configuration web interface.", + searchdomain: { + description: + "Sets DNS search domains for a container. Create will automatically use the \ + setting from the host if you neither set searchdomain nor nameserver.", + format: crate::schema::dns_name, + serialization: crate::schema::string_list::optional, + }, + nameserver: { + description: + "Sets DNS server IP address for a container. Create will automatically use the \ + setting from the host if you neither set searchdomain nor nameserver.", + format: crate::schema::ip_address, + serialization: crate::schema::string_list::optional, + }, + rootfs: "Container root volume", + cmode: { + description: + "Console mode. By default, the console command tries to open a connection to one \ + of the available tty devices. By setting cmode to 'console' it tries to attach \ + to /dev/console instead. \ + If you set cmode to 'shell', it simply invokes a shell inside the container \ + (no login).", + default: schema::ConsoleMode::Tty, + }, + protection: { + description: + "Sets the protection flag of the container. \ + This will prevent the CT or CT's disk remove/update operation.", + default: false, + }, + unprivileged: { + description: + "Makes the container run as unprivileged user. (Should not be modified manually.)", + default: false, + }, + hookscript: { + description: + "Script that will be exectued during various steps in the containers lifetime.", + }, + }, +})] +#[derive(Default)] +pub struct Config { + // FIXME: short form? Since all the type info is literally factored out into the ConfigLock + // type already... + //#[api("The current long-term lock held on this container by another operation.")] + pub lock: Option, + pub onboot: Option, + pub startup: Option, + pub template: Option, + pub arch: Option, + pub ostype: Option, + pub console: Option, + pub tty: Option, + pub cores: Option, + pub cpulimit: Option, + pub cpuunits: Option, + pub memory: Option, + pub swap: Option, + pub hostname: Option, + pub description: Option, + pub searchdomain: Option>, + pub nameserver: Option>, + pub rootfs: Option, + // pub parent: Option, + // pub snaptime: Option, + pub cmode: Option, + pub protection: Option, + pub unprivileged: Option, + // pub features: Option, + pub hookscript: Option, +} + +#[api({ + description: "Container's rootfs definition", + cli: false, + fields: { + volume: { + description: "Volume, device or directory to mount into the container.", + format: crate::schema::safe_path, + // format_description: 'volume', + // default_key: 1, + }, + size: { + description: "Volume size (read only value).", + // format_description: 'DiskSize', + }, + acl: { + description: "Explicitly enable or disable ACL support.", + default: false, + }, + ro: { + description: "Read-only mount point.", + default: false, + }, + mountoptions: { + description: "Extra mount options for rootfs/mps.", + //format_description: "opt[;opt...]", + format: schema::mount_options, + serialization: crate::schema::string_set::optional, + }, + quota: { + description: + "Enable user quotas inside the container (not supported with zfs subvolumes)", + default: false, + }, + replicate: { + description: "Will include this volume to a storage replica job.", + default: true, + }, + shared: { + description: + "Mark this non-volume mount point as available on multiple nodes (see 'nodes')", + //verbose_description: + // "Mark this non-volume mount point as available on all nodes.\n\ + // \n\ + // WARNING: This option does not share the mount point automatically, it assumes \ + // it is shared already!", + default: false, + }, + }, +})] +pub struct Rootfs { + pub volume: String, + pub size: Option, + pub acl: Option, + pub ro: Option, + pub mountoptions: Option>, + pub quota: Option, + pub replicate: Option, + pub shared: Option, +} diff --git a/api-test/src/lxc/schema/mod.rs b/api-test/src/lxc/schema/mod.rs new file mode 100644 index 00000000..c5cf9e66 --- /dev/null +++ b/api-test/src/lxc/schema/mod.rs @@ -0,0 +1,55 @@ +//! PVE LXC related schema module. + +use proxmox::api::api; + +#[api({ + description: "A long-term lock on a container", +})] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ConfigLock { + Backup, + Create, + Disk, + Fstrim, + Migrate, + Mounted, + Rollback, + Snapshot, + #[api(rename = "snapshot-delete")] + SnapshotDelete, +} + +#[api({ + description: "Operating System Type.", +})] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OsType { + Unmanaged, + Debian, + //... +} + +#[api({ + description: "Console mode.", +})] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConsoleMode { + Tty, + Console, + Shell, +} + +pub mod mount_options { + pub const NAME: &'static str = "mount options"; + + const VALID_MOUNT_OPTIONS: &[&'static str] = &[ + "noatime", + "nodev", + "noexec", + "nosuid", + ]; + + pub fn verify(value: &T) -> bool { + value.all(|s| VALID_MOUNT_OPTIONS.contains(&s)) + } +} diff --git a/api-test/src/main.rs b/api-test/src/main.rs deleted file mode 100644 index 4d8a7d35..00000000 --- a/api-test/src/main.rs +++ /dev/null @@ -1,71 +0,0 @@ -#![feature(async_await)] - -use failure::{format_err, Error}; -use http::Request; -use http::Response; -use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Server}; -use serde_json::Value; - -mod api; - -async fn run_request(request: Request) -> Result, hyper::Error> { - match route_request(request).await { - Ok(r) => Ok(r), - Err(err) => Ok(Response::builder() - .status(400) - .body(Body::from(format!("ERROR: {}", err))) - .expect("building an error response...")), - } -} - -async fn route_request(request: Request) -> Result, Error> { - let path = request.uri().path(); - - let (target, params) = api::ROUTER - .lookup(path) - .ok_or_else(|| format_err!("missing path: {}", path))?; - - target - .get - .as_ref() - .ok_or_else(|| format_err!("no GET method for: {}", path))? - .call(params.unwrap_or(Value::Null)) - .await -} - -async fn main_do(www_dir: String) { - // Construct our SocketAddr to listen on... - let addr = ([0, 0, 0, 0], 3000).into(); - - // And a MakeService to handle each connection... - let service = make_service_fn(|_| async { Ok::<_, hyper::Error>(service_fn(run_request)) }); - - // Then bind and serve... - let server = Server::bind(&addr).serve(service); - - println!("Serving {} under http://localhost:3000/www/", www_dir); - - if let Err(e) = server.await { - eprintln!("server error: {}", e); - } -} - -fn main() { - // We expect a path, where to find our files we expose via the www/ dir: - let mut args = std::env::args(); - - // real code should have better error handling - let _program_name = args.next(); - let www_dir = args.next().expect("expected a www/ subdirectory"); - api::set_www_dir(www_dir.to_string()); - - // show our api info: - println!( - "{}", - serde_json::to_string_pretty(&api::ROUTER.api_dump()).unwrap() - ); - - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(main_do(www_dir)); -} diff --git a/api-test/src/schema/memory.rs b/api-test/src/schema/memory.rs new file mode 100644 index 00000000..a214cb13 --- /dev/null +++ b/api-test/src/schema/memory.rs @@ -0,0 +1,110 @@ +//! Serialization/deserialization for memory values with specific units. + +use std::marker::PhantomData; + +use super::types::Memory; + +pub trait Unit { + const FACTOR: u64; + const NAME: &'static str; +} + +pub struct B; +impl Unit for B { + const FACTOR: u64 = 1; + const NAME: &'static str = "bytes"; +} + +pub struct Kb; +impl Unit for Kb { + const FACTOR: u64 = 1024; + const NAME: &'static str = "kilobytes"; +} + +pub struct Mb; +impl Unit for Mb { + const FACTOR: u64 = 1024 * 1024; + const NAME: &'static str = "megabytes"; +} + +pub struct Gb; +impl Unit for Gb { + const FACTOR: u64 = 1024 * 1024 * 1024; + const NAME: &'static str = "gigabytes"; +} + +struct MemoryVisitor(PhantomData); +impl<'de, U: Unit> serde::de::Visitor<'de> for MemoryVisitor { + type Value = Memory; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "amount of memory in {}", U::NAME) + } + + fn visit_u8(self, v: u8) -> Result { + Ok(Memory::from_bytes(v as u64 * U::FACTOR)) + } + fn visit_u16(self, v: u16) -> Result { + Ok(Memory::from_bytes(v as u64 * U::FACTOR)) + } + fn visit_u32(self, v: u32) -> Result { + Ok(Memory::from_bytes(v as u64 * U::FACTOR)) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Memory::from_bytes(v as u64 * U::FACTOR)) + } + + fn visit_str(self, v: &str) -> Result { + match v.parse::() { + Ok(v) => Ok(Memory::from_bytes(v * U::FACTOR)), + Err(_) => v.parse().map_err(serde::de::Error::custom), + } + } +} + +pub struct Parser(PhantomData); + +impl Parser { + pub fn serialize(value: &Memory, ser: S) -> Result + where + S: serde::Serializer, + { + if (value.as_bytes() % U::FACTOR) == 0 { + ser.serialize_u64(value.as_bytes() / U::FACTOR) + } else { + ser.serialize_str(&value.to_string()) + } + } + + pub fn deserialize<'de, D>(de: D) -> Result + where + D: serde::de::Deserializer<'de>, + { + de.deserialize_any(MemoryVisitor::(PhantomData)) + } +} + +pub mod optional { + use std::marker::PhantomData; + + use super::Unit; + use crate::schema::types::Memory; + + pub struct Parser(PhantomData); + + impl Parser { + pub fn serialize(value: &Option, ser: S) -> Result + where + S: serde::Serializer, + { + super::Parser::::serialize::(&value.unwrap(), ser) + } + + pub fn deserialize<'de, D>(de: D) -> Result, D::Error> + where + D: serde::de::Deserializer<'de>, + { + super::Parser::::deserialize::<'de, D>(de).map(Some) + } + } +} diff --git a/api-test/src/schema/mod.rs b/api-test/src/schema/mod.rs new file mode 100644 index 00000000..16417648 --- /dev/null +++ b/api-test/src/schema/mod.rs @@ -0,0 +1,82 @@ +//! Common schema definitions. + +use proxmox::api::api; + +pub mod memory; +pub mod string_list; +pub mod string_set; +pub mod tools; +pub mod types; + +#[api({ + cli: false, + description: + r"Startup and shutdown behavior. \ + Order is a non-negative number defining the general startup order. \ + Shutdown in done with reverse ordering. \ + Additionally you can set the 'up' or 'down' delay in seconds, which specifies a delay \ + to wait before the next VM is started or stopped.", + fields: { + order: "Absolute ordering", + up: "Delay to wait before moving on to the next VM during startup.", + down: "Delay to wait before moving on to the next VM during shutdown.", + }, +})] +#[derive(Default)] +pub struct StartupOrder { + pub order: Option, + pub up: Option, + pub down: Option, +} + +#[api({description: "Architecture."})] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Architecture { + // FIXME: suppport: #[api(alternatives = ["x86_64"])] + Amd64, + I386, + Arm64, + Armhf, +} + +pub mod dns_name { + use lazy_static::lazy_static; + use regex::Regex; + + pub const NAME: &'static str = "DNS name"; + + lazy_static! { + //static ref DNS_BASE_RE: Regex = + // Regex::new(r#"(?:[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?)"#).unwrap(); + static ref REGEX: Regex = + Regex::new(r#"^(?x) + (?:[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?) + (?:\.(?:[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?))* + $"#).unwrap(); + } + + pub fn verify(value: &T) -> bool { + value.all(|s| REGEX.is_match(s)) + } +} + +pub mod ip_address { + pub const NAME: &'static str = "IP Address"; + + pub fn verify(value: &T) -> bool { + value.all(|s| proxmox::tools::common_regex::IP_REGEX.is_match(s)) + } +} + +pub mod safe_path { + pub const NAME: &'static str = "A canonical, absolute file system path"; + + pub fn verify(value: &T) -> bool { + value.all(|s| { + s != ".." + && !s.starts_with("../") + && !s.ends_with("/..") + && !s.contains("/../") + }) + } +} diff --git a/api-test/src/schema/string_list.rs b/api-test/src/schema/string_list.rs new file mode 100644 index 00000000..f49ee557 --- /dev/null +++ b/api-test/src/schema/string_list.rs @@ -0,0 +1,100 @@ +//! Comma separated string list. +//! +//! Used as a proxy type for when a struct should contain a `Vec` which should be +//! serialized as a single comma separated list. + +use failure::{bail, Error}; + +pub trait ForEachStr { + fn for_each_str(&self, func: F) -> Result<(), Error> + where + F: FnMut(&str) -> Result<(), Error>; +} + +impl ForEachStr for Vec { + fn for_each_str(&self, mut func: F) -> Result<(), Error> + where + F: FnMut(&str) -> Result<(), Error>, + { + for i in self.iter() { + func(i.as_str())?; + } + Ok(()) + } +} + +pub fn serialize(value: &T, ser: S) -> Result +where + S: serde::Serializer, +{ + let mut data = String::new(); + value + .for_each_str(|s| { + if s.contains(',') { + bail!("cannot include value \"{}\" in a comma separated list", s); + } + + if !data.is_empty() { + data.push_str(", "); + } + data.push_str(s); + Ok(()) + }) + .map_err(serde::ser::Error::custom)?; + ser.serialize_str(&data) +} + +// maybe a custom visitor can also decode arrays by implementing visit_seq? +struct StringListVisitor; + +impl<'de> serde::de::Visitor<'de> for StringListVisitor { + type Value = Vec; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "a comma separated list as a string, or an array of strings" + ) + } + + fn visit_str(self, v: &str) -> Result { + Ok(v.split(',').map(|i| i.trim().to_string()).collect()) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = seq.size_hint().map_or_else(Vec::new, |size| Vec::with_capacity(size)); + loop { + match seq.next_element::()? { + Some(el) => out.push(el), + None => break, + } + } + Ok(out) + } +} + +pub fn deserialize<'de, D>(de: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + de.deserialize_any(StringListVisitor) +} + +pub mod optional { + pub fn serialize(value: &Option, ser: S) -> Result + where + S: serde::Serializer, + { + match value { + Some(value) => super::serialize(value, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D>(de: D) -> Result>, D::Error> + where + D: serde::de::Deserializer<'de>, + { + super::deserialize(de).map(Some) + } +} diff --git a/api-test/src/schema/string_set.rs b/api-test/src/schema/string_set.rs new file mode 100644 index 00000000..0d68c153 --- /dev/null +++ b/api-test/src/schema/string_set.rs @@ -0,0 +1,106 @@ +//! A "set" of strings, semicolon separated, loaded into a `HashSet`. +//! +//! Used as a proxy type for when a struct should contain a `HashSet` which should be +//! serialized as a single comma separated list. + +use std::collections::HashSet; + +use failure::{bail, Error}; + +pub trait ForEachStr { + fn for_each_str(&self, func: F) -> Result<(), Error> + where + F: FnMut(&str) -> Result<(), Error>; +} + +impl ForEachStr for HashSet { + fn for_each_str(&self, mut func: F) -> Result<(), Error> + where + F: FnMut(&str) -> Result<(), Error>, + { + for i in self.iter() { + func(i.as_str())?; + } + Ok(()) + } +} + +pub fn serialize(value: &T, ser: S) -> Result +where + S: serde::Serializer, +{ + let mut data = String::new(); + value + .for_each_str(|s| { + if s.contains(';') { + bail!("cannot include value \"{}\" in a semicolon separated list", s); + } + + if !data.is_empty() { + data.push_str(";"); + } + data.push_str(s); + Ok(()) + }) + .map_err(serde::ser::Error::custom)?; + ser.serialize_str(&data) +} + +// maybe a custom visitor can also decode arrays by implementing visit_seq? +struct StringSetVisitor; + +impl<'de> serde::de::Visitor<'de> for StringSetVisitor { + type Value = HashSet; + + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + f, + "a string containing semicolon separated elements, or an array of strings" + ) + } + + fn visit_str(self, v: &str) -> Result { + Ok(v.split(';').map(|i| i.trim().to_string()).collect()) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = seq + .size_hint() + .map_or_else(HashSet::new, |size| HashSet::with_capacity(size)); + loop { + match seq.next_element::()? { + Some(el) => out.insert(el), + None => break, + }; + } + Ok(out) + } +} + +pub fn deserialize<'de, D>(de: D) -> Result, D::Error> +where + D: serde::de::Deserializer<'de>, +{ + de.deserialize_any(StringSetVisitor) +} + +pub mod optional { + use std::collections::HashSet; + + pub fn serialize(value: &Option, ser: S) -> Result + where + S: serde::Serializer, + { + match value { + Some(value) => super::serialize(value, ser), + None => ser.serialize_none(), + } + } + + pub fn deserialize<'de, D>(de: D) -> Result>, D::Error> + where + D: serde::de::Deserializer<'de>, + { + super::deserialize(de).map(Some) + } +} diff --git a/api-test/src/schema/tools.rs b/api-test/src/schema/tools.rs new file mode 100644 index 00000000..2909fb4a --- /dev/null +++ b/api-test/src/schema/tools.rs @@ -0,0 +1,50 @@ +//! Helper module to perform the same format checks on various string types. +//! +//! This is used for formats which should be checked on strings, arrays of strings, and optional +//! variants of both. + +use std::collections::HashSet; + +/// Allows testing predicates on all the contained strings of a type. +pub trait StringContainer { + fn all bool>(&self, pred: F) -> bool; +} + +impl StringContainer for String { + fn all bool>(&self, pred: F) -> bool { + pred(&self) + } +} + +impl StringContainer for Option { + fn all bool>(&self, pred: F) -> bool { + match self { + Some(ref v) => pred(v), + None => true, + } + } +} + +impl StringContainer for Vec { + fn all bool>(&self, pred: F) -> bool { + self.iter().all(|s| pred(&s)) + } +} + +impl StringContainer for Option> { + fn all bool>(&self, pred: F) -> bool { + self.as_ref().map(|c| StringContainer::all(c, pred)).unwrap_or(true) + } +} + +impl StringContainer for HashSet { + fn all bool>(&self, pred: F) -> bool { + self.iter().all(|s| pred(s)) + } +} + +impl StringContainer for Option> { + fn all bool>(&self, pred: F) -> bool { + self.as_ref().map(|c| StringContainer::all(c, pred)).unwrap_or(true) + } +} diff --git a/api-test/src/schema/types/memory.rs b/api-test/src/schema/types/memory.rs new file mode 100644 index 00000000..cfd72f2a --- /dev/null +++ b/api-test/src/schema/types/memory.rs @@ -0,0 +1,191 @@ +//! 'Memory' type, represents an amount of memory. + +use failure::Error; + +use proxmox::api::api; + +// TODO: manually implement Serialize/Deserialize to support both numeric and string +// representations. Numeric always being bytes, string having suffixes. +#[api({ + description: "Represents an amount of memory and can be expressed with suffixes such as MiB.", +})] +#[derive(Clone, Copy, PartialEq, PartialOrd, Eq, Ord, Debug)] +#[repr(transparent)] +pub struct Memory(pub u64); + +impl std::str::FromStr for Memory { + type Err = Error; + + fn from_str(s: &str) -> Result { + if s.ends_with("KiB") { + Ok(Self::from_kibibytes(s[..s.len() - 3].parse()?)) + } else if s.ends_with("MiB") { + Ok(Self::from_mebibytes(s[..s.len() - 3].parse()?)) + } else if s.ends_with("GiB") { + Ok(Self::from_gibibytes(s[..s.len() - 3].parse()?)) + } else if s.ends_with("TiB") { + Ok(Self::from_tebibytes(s[..s.len() - 3].parse()?)) + } else if s.ends_with("K") { + Ok(Self::from_kibibytes(s[..s.len() - 1].parse()?)) + } else if s.ends_with("M") { + Ok(Self::from_mebibytes(s[..s.len() - 1].parse()?)) + } else if s.ends_with("G") { + Ok(Self::from_gibibytes(s[..s.len() - 1].parse()?)) + } else if s.ends_with("T") { + Ok(Self::from_tebibytes(s[..s.len() - 1].parse()?)) + } else if s.ends_with("b") || s.ends_with("B") { + Ok(Self::from_bytes(s[..s.len() - 1].parse()?)) + } else { + Ok(Self::from_bytes(s[..s.len() - 1].parse()?)) + } + } +} +serde_plain::derive_deserialize_from_str!(Memory, "valid memory amount description"); +proxmox::api::derive_parse_cli_from_str!(Memory); + +impl std::fmt::Display for Memory { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + const SUFFIXES: &'static [&'static str] = &["b", "KiB", "MiB", "GiB", "TiB"]; + let mut n = self.0; + let mut i = 0; + while i < SUFFIXES.len() && (n & 0x3ff) == 0 { + n >>= 10; + i += 1; + } + write!(f, "{}{}", n, SUFFIXES[i]) + } +} +serde_plain::derive_serialize_from_display!(Memory); + +impl Memory { + pub const fn from_bytes(v: u64) -> Self { + Self(v) + } + + pub const fn as_bytes(&self) -> u64 { + self.0 + } + + pub const fn from_kibibytes(v: u64) -> Self { + Self(v * 1024) + } + + pub const fn as_kibibytes(&self) -> u64 { + self.0 / 1024 + } + + pub const fn from_si_kilobytes(v: u64) -> Self { + Self(v * 1_000) + } + + pub const fn as_si_kilobytes(&self) -> u64 { + self.0 / 1_000 + } + + pub const fn from_mebibytes(v: u64) -> Self { + Self(v * 1024 * 1024) + } + + pub const fn as_mebibytes(&self) -> u64 { + self.0 / 1024 / 1024 + } + + pub const fn from_si_megabytes(v: u64) -> Self { + Self(v * 1_000_000) + } + + pub const fn as_si_megabytes(&self) -> u64 { + self.0 / 1_000_000 + } + + pub const fn from_gibibytes(v: u64) -> Self { + Self(v * 1024 * 1024 * 1024) + } + + pub const fn as_gibibytes(&self) -> u64 { + self.0 / 1024 / 1024 / 1024 + } + + pub const fn from_si_gigabytes(v: u64) -> Self { + Self(v * 1_000_000_000) + } + + pub const fn as_si_gigabytes(&self) -> u64 { + self.0 / 1_000_000_000 + } + + pub const fn from_tebibytes(v: u64) -> Self { + Self(v * 1024 * 1024 * 1024 * 1024) + } + + pub const fn as_tebibytes(&self) -> u64 { + self.0 / 1024 / 1024 / 1024 / 1024 + } + + pub const fn from_si_terabytes(v: u64) -> Self { + Self(v * 1_000_000_000_000) + } + + pub const fn as_si_terabytes(&self) -> u64 { + self.0 / 1_000_000_000_000 + } +} + +impl std::ops::Add for Memory { + type Output = Memory; + + fn add(self, rhs: Memory) -> Memory { + Self(self.0 + rhs.0) + } +} + +impl std::ops::AddAssign for Memory { + fn add_assign(&mut self, rhs: Memory) { + self.0 += rhs.0; + } +} + +impl std::ops::Sub for Memory { + type Output = Memory; + + fn sub(self, rhs: Memory) -> Memory { + Self(self.0 - rhs.0) + } +} + +impl std::ops::SubAssign for Memory { + fn sub_assign(&mut self, rhs: Memory) { + self.0 -= rhs.0; + } +} + +impl std::ops::Mul for Memory { + type Output = Memory; + + fn mul(self, rhs: u64) -> Memory { + Self(self.0 * rhs) + } +} + +impl std::ops::MulAssign for Memory { + fn mul_assign(&mut self, rhs: u64) { + self.0 *= rhs; + } +} + +#[test] +fn memory() { + assert_eq!(Memory::from_mebibytes(1).as_kibibytes(), 1024); + assert_eq!(Memory::from_mebibytes(1).as_bytes(), 1024 * 1024); + assert_eq!(Memory::from_si_megabytes(1).as_bytes(), 1_000_000); + assert_eq!(Memory::from_tebibytes(1), Memory::from_gibibytes(1024)); + assert_eq!(Memory::from_gibibytes(1), Memory::from_mebibytes(1024)); + assert_eq!(Memory::from_mebibytes(1), Memory::from_kibibytes(1024)); + assert_eq!(Memory::from_kibibytes(1), Memory::from_bytes(1024)); + assert_eq!( + Memory::from_kibibytes(1) + Memory::from_bytes(6), + Memory::from_bytes(1030) + ); + assert_eq!("1M".parse::().unwrap(), Memory::from_mebibytes(1)); + assert_eq!("1MiB".parse::().unwrap(), Memory::from_mebibytes(1)); +} diff --git a/api-test/src/schema/types/mod.rs b/api-test/src/schema/types/mod.rs new file mode 100644 index 00000000..8e97b3f0 --- /dev/null +++ b/api-test/src/schema/types/mod.rs @@ -0,0 +1,7 @@ +//! Commonly used basic types, such as a type safe `Memory` type. + +mod memory; +pub use memory::Memory; + +mod volume_id; +pub use volume_id::VolumeId; diff --git a/api-test/src/schema/types/volume_id.rs b/api-test/src/schema/types/volume_id.rs new file mode 100644 index 00000000..d14fadad --- /dev/null +++ b/api-test/src/schema/types/volume_id.rs @@ -0,0 +1,53 @@ +//! A 'VolumeId' is a storage + volume combination. + +use failure::{format_err, Error}; + +use proxmox::api::api; + +#[api({ + serialize_as_string: true, + cli: FromStr, + description: "A volume ID consisting of a storage name and a volume name", + fields: { + storage: { + description: "A storage name", + pattern: r#"^[a-z][a-z0-9\-_.]*[a-z0-9]$"#, + }, + volume: "A volume name", + }, +})] +#[derive(Clone)] +pub struct VolumeId { + storage: String, + volume: String, +} + +impl std::fmt::Display for VolumeId { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}:{}", self.storage, self.volume) + } +} + +impl std::str::FromStr for VolumeId { + type Err = Error; + + fn from_str(s: &str) -> Result { + let mut parts = s.splitn(2, ':'); + + let this = Self { + storage: parts + .next() + .ok_or_else(|| format_err!("not a volume id: {}", s))? + .to_string(), + volume: parts + .next() + .ok_or_else(|| format_err!("not a volume id: {}", s))? + .to_string(), + }; + assert!(parts.next().is_none()); + + proxmox::api::ApiType::verify(&this)?; + + Ok(this) + } +} diff --git a/api-test/tests/lxc-config.rs b/api-test/tests/lxc-config.rs new file mode 100644 index 00000000..c65220d8 --- /dev/null +++ b/api-test/tests/lxc-config.rs @@ -0,0 +1,190 @@ +use failure::Error; + +use proxmox::api::ApiType; + +use api_test::lxc; + +/// This just checks the string in order to avoid `T: Eq` as requirement. +/// in other words: +/// assert that serialize(value) == serialize(deserialize(serialize(value))) +/// We assume that serialize(value) has already been checked before entering this function. +fn check_ser_de(value: &T) -> Result<(), Error> +where + T: ApiType + serde::Serialize + serde::de::DeserializeOwned, +{ + assert!(value.verify().is_ok()); + let s1 = serde_json::to_string(value)?; + let v2: T = serde_json::from_str(&s1)?; + assert!(v2.verify().is_ok()); + let s2 = serde_json::to_string(&v2)?; + assert_eq!(s1, s2); + Ok(()) +} + +#[test] +fn lxc_config() -> Result<(), Error> { + let mut config = lxc::Config::default(); + assert!(config.verify().is_ok()); + assert_eq!(serde_json::to_string(&config)?, "{}"); + check_ser_de(&config)?; + assert_eq!(*config.onboot(), false); + assert_eq!(*config.template(), false); + assert_eq!(*config.arch(), api_test::schema::Architecture::Amd64); + assert_eq!(*config.console(), true); + assert_eq!(*config.tty(), 2); + assert_eq!(*config.cmode(), api_test::lxc::schema::ConsoleMode::Tty); + assert_eq!(config.memory().as_bytes(), 512 << 20); + + config.lock = Some(lxc::schema::ConfigLock::Backup); + check_ser_de(&config)?; + assert_eq!(serde_json::to_string(&config)?, r#"{"lock":"backup"}"#); + + // test the renamed one: + config.lock = Some(lxc::schema::ConfigLock::SnapshotDelete); + check_ser_de(&config)?; + assert_eq!( + serde_json::to_string(&config)?, + r#"{"lock":"snapshot-delete"}"# + ); + + config.onboot = Some(true); + check_ser_de(&config)?; + assert_eq!( + serde_json::to_string(&config)?, + r#"{"lock":"snapshot-delete","onboot":true}"# + ); + assert_eq!(*config.onboot(), true); + + config.lock = None; + config.onboot = Some(false); + check_ser_de(&config)?; + assert_eq!(serde_json::to_string(&config)?, r#"{"onboot":false}"#); + assert_eq!(*config.onboot(), false); + + config.onboot = None; + check_ser_de(&config)?; + assert_eq!(*config.onboot(), false); + + config.set_onboot(true); + check_ser_de(&config)?; + assert_eq!(*config.onboot(), true); + assert_eq!(serde_json::to_string(&config)?, r#"{"onboot":true}"#); + + config.set_onboot(false); + check_ser_de(&config)?; + assert_eq!(*config.onboot(), false); + assert_eq!(serde_json::to_string(&config)?, r#"{"onboot":false}"#); + + config.set_template(true); + check_ser_de(&config)?; + assert_eq!(*config.template(), true); + assert_eq!( + serde_json::to_string(&config)?, + r#"{"onboot":false,"template":true}"# + ); + + config.onboot = None; + config.template = None; + + config.startup = Some(api_test::schema::StartupOrder { + order: Some(5), + ..Default::default() + }); + check_ser_de(&config)?; + assert_eq!( + serde_json::to_string(&config)?, + r#"{"startup":{"order":5}}"# + ); + + config = serde_json::from_str(r#"{"memory":"123MiB"}"#)?; + assert!(config.verify().is_ok()); + assert_eq!(serde_json::to_string(&config)?, r#"{"memory":123}"#); + + config = serde_json::from_str(r#"{"memory":"1024MiB"}"#)?; + assert!(config.verify().is_ok()); + assert_eq!(serde_json::to_string(&config)?, r#"{"memory":1024}"#); + + config = serde_json::from_str(r#"{"memory":"1300001KiB"}"#)?; + assert!(config.verify().is_ok()); + assert_eq!( + serde_json::to_string(&config)?, + r#"{"memory":"1300001KiB"}"# + ); + + // test numeric values + config = serde_json::from_str(r#"{"tty":3}"#)?; + assert!(config.verify().is_ok()); + assert_eq!(serde_json::to_string(&config)?, r#"{"tty":3}"#); + assert!(serde_json::from_str::(r#"{"tty":"3"}"#).is_err()); // string as int + + config = serde_json::from_str(r#"{"tty":9}"#)?; + assert_eq!( + config.verify().map_err(|e| e.to_string()), + Err("field tty out of range, must be <= 6".to_string()) + ); + + config = serde_json::from_str(r#"{"hostname":"xx"}"#)?; + assert_eq!( + config.verify().map_err(|e| e.to_string()), + Err("field hostname too short, must be >= 3 characters".to_string()) + ); + + config = serde_json::from_str(r#"{"hostname":"foo.bar.com"}"#)?; + assert_eq!( + serde_json::to_string(&config)?, + r#"{"hostname":"foo.bar.com"}"# + ); + assert!(config.verify().is_ok()); + + config = serde_json::from_str(r#"{"hostname":"foo"}"#)?; + assert!(config.verify().is_ok()); + + config = serde_json::from_str(r#"{"hostname":"..."}"#)?; + assert_eq!( + config.verify().map_err(|e| e.to_string()), + Err("field hostname does not match format DNS name".to_string()), + ); + + config = serde_json::from_str(r#"{"searchdomain":"foo.bar"}"#)?; + assert_eq!( + serde_json::to_string(&config)?, + r#"{"searchdomain":"foo.bar"}"# + ); + + config = serde_json::from_str(r#"{"searchdomain":"foo.."}"#)?; + assert_eq!( + config.verify().map_err(|e| e.to_string()), + Err("field searchdomain does not match format DNS name".to_string()), + ); + + config = serde_json::from_str(r#"{"searchdomain":"foo.com, bar.com"}"#)?; + assert!(config.verify().is_ok()); + assert_eq!( + serde_json::to_string(&config)?, + r#"{"searchdomain":"foo.com, bar.com"}"# + ); + config = serde_json::from_str(r#"{"searchdomain":["foo.com", "bar.com"]}"#)?; + assert!(config.verify().is_ok()); + assert_eq!( + serde_json::to_string(&config)?, + r#"{"searchdomain":"foo.com, bar.com"}"# + ); + + config = serde_json::from_str(r#"{"nameserver":["127.0.0.1", "::1"]}"#)?; + check_ser_de(&config)?; + + config = serde_json::from_str(r#"{"nameserver":"127.0.0.1, foo"}"#)?; + assert_eq!( + config.verify().map_err(|e| e.to_string()), + Err("field nameserver does not match format IP Address".to_string()), + ); + + config = serde_json::from_str(r#"{"cmode":"tty"}"#)?; + check_ser_de(&config)?; + config = serde_json::from_str(r#"{"cmode":"shell"}"#)?; + check_ser_de(&config)?; + config = serde_json::from_str(r#"{"hookscript":"local:snippets/foo.sh"}"#)?; + check_ser_de(&config)?; + + Ok(()) +}