api-test: import struct tests

This is a bigger set of tests for the type-side (mostly for
`struct`s) of the #[api] macro, tasting serialization and
verifiers in various forms.

Signed-off-by: Wolfgang Bumiller <w.bumiller@proxmox.com>
This commit is contained in:
Wolfgang Bumiller 2019-08-01 14:40:12 +02:00
parent 75e90ebb25
commit e985dc8f84
15 changed files with 1246 additions and 73 deletions

View File

@ -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" }

View File

@ -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<Body>) -> Result<http::Response<Body>, 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<Body>) -> Result<http::Response<Body>, 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",
})]

4
api-test/src/lib.rs Normal file
View File

@ -0,0 +1,4 @@
//! PVE base library
pub mod lxc;
pub mod schema;

226
api-test/src/lxc/mod.rs Normal file
View File

@ -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/<ostype>.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::<crate::schema::memory::Mb>
},
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<schema::ConfigLock>,
pub onboot: Option<bool>,
pub startup: Option<crate::schema::StartupOrder>,
pub template: Option<bool>,
pub arch: Option<Architecture>,
pub ostype: Option<schema::OsType>,
pub console: Option<bool>,
pub tty: Option<usize>,
pub cores: Option<usize>,
pub cpulimit: Option<usize>,
pub cpuunits: Option<usize>,
pub memory: Option<Memory>,
pub swap: Option<Memory>,
pub hostname: Option<String>,
pub description: Option<String>,
pub searchdomain: Option<Vec<String>>,
pub nameserver: Option<Vec<String>>,
pub rootfs: Option<Rootfs>,
// pub parent: Option<String>,
// pub snaptime: Option<usize>,
pub cmode: Option<schema::ConsoleMode>,
pub protection: Option<bool>,
pub unprivileged: Option<bool>,
// pub features: Option<schema::Features>,
pub hookscript: Option<VolumeId>,
}
#[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<Memory>,
pub acl: Option<bool>,
pub ro: Option<bool>,
pub mountoptions: Option<HashSet<String>>,
pub quota: Option<bool>,
pub replicate: Option<bool>,
pub shared: Option<bool>,
}

View File

@ -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<T: crate::schema::tools::StringContainer>(value: &T) -> bool {
value.all(|s| VALID_MOUNT_OPTIONS.contains(&s))
}
}

View File

@ -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<Body>) -> Result<http::Response<Body>, 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<Body>) -> Result<http::Response<Body>, 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));
}

View File

@ -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<U: Unit>(PhantomData<U>);
impl<'de, U: Unit> serde::de::Visitor<'de> for MemoryVisitor<U> {
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<E: serde::de::Error>(self, v: u8) -> Result<Self::Value, E> {
Ok(Memory::from_bytes(v as u64 * U::FACTOR))
}
fn visit_u16<E: serde::de::Error>(self, v: u16) -> Result<Self::Value, E> {
Ok(Memory::from_bytes(v as u64 * U::FACTOR))
}
fn visit_u32<E: serde::de::Error>(self, v: u32) -> Result<Self::Value, E> {
Ok(Memory::from_bytes(v as u64 * U::FACTOR))
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(Memory::from_bytes(v as u64 * U::FACTOR))
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
match v.parse::<u64>() {
Ok(v) => Ok(Memory::from_bytes(v * U::FACTOR)),
Err(_) => v.parse().map_err(serde::de::Error::custom),
}
}
}
pub struct Parser<U: Unit>(PhantomData<U>);
impl<U: Unit> Parser<U> {
pub fn serialize<S>(value: &Memory, ser: S) -> Result<S::Ok, S::Error>
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<Memory, D::Error>
where
D: serde::de::Deserializer<'de>,
{
de.deserialize_any(MemoryVisitor::<U>(PhantomData))
}
}
pub mod optional {
use std::marker::PhantomData;
use super::Unit;
use crate::schema::types::Memory;
pub struct Parser<U: Unit>(PhantomData<U>);
impl<U: Unit> Parser<U> {
pub fn serialize<S>(value: &Option<Memory>, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
super::Parser::<U>::serialize::<S>(&value.unwrap(), ser)
}
pub fn deserialize<'de, D>(de: D) -> Result<Option<Memory>, D::Error>
where
D: serde::de::Deserializer<'de>,
{
super::Parser::<U>::deserialize::<'de, D>(de).map(Some)
}
}
}

View File

@ -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<usize>,
pub up: Option<usize>,
pub down: Option<usize>,
}
#[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<T: crate::schema::tools::StringContainer>(value: &T) -> bool {
value.all(|s| REGEX.is_match(s))
}
}
pub mod ip_address {
pub const NAME: &'static str = "IP Address";
pub fn verify<T: crate::schema::tools::StringContainer>(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<T: crate::schema::tools::StringContainer>(value: &T) -> bool {
value.all(|s| {
s != ".."
&& !s.starts_with("../")
&& !s.ends_with("/..")
&& !s.contains("/../")
})
}
}

View File

@ -0,0 +1,100 @@
//! Comma separated string list.
//!
//! Used as a proxy type for when a struct should contain a `Vec<String>` which should be
//! serialized as a single comma separated list.
use failure::{bail, Error};
pub trait ForEachStr {
fn for_each_str<F>(&self, func: F) -> Result<(), Error>
where
F: FnMut(&str) -> Result<(), Error>;
}
impl ForEachStr for Vec<String> {
fn for_each_str<F>(&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<S, T: ForEachStr>(value: &T, ser: S) -> Result<S::Ok, S::Error>
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<String>;
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<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(v.split(',').map(|i| i.trim().to_string()).collect())
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut out = seq.size_hint().map_or_else(Vec::new, |size| Vec::with_capacity(size));
loop {
match seq.next_element::<String>()? {
Some(el) => out.push(el),
None => break,
}
}
Ok(out)
}
}
pub fn deserialize<'de, D>(de: D) -> Result<Vec<String>, D::Error>
where
D: serde::de::Deserializer<'de>,
{
de.deserialize_any(StringListVisitor)
}
pub mod optional {
pub fn serialize<S, T: super::ForEachStr>(value: &Option<T>, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match value {
Some(value) => super::serialize(value, ser),
None => ser.serialize_none(),
}
}
pub fn deserialize<'de, D>(de: D) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::de::Deserializer<'de>,
{
super::deserialize(de).map(Some)
}
}

View File

@ -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<String>` 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<F>(&self, func: F) -> Result<(), Error>
where
F: FnMut(&str) -> Result<(), Error>;
}
impl ForEachStr for HashSet<String> {
fn for_each_str<F>(&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<S, T: ForEachStr>(value: &T, ser: S) -> Result<S::Ok, S::Error>
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<String>;
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<E: serde::de::Error>(self, v: &str) -> Result<Self::Value, E> {
Ok(v.split(';').map(|i| i.trim().to_string()).collect())
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut out = seq
.size_hint()
.map_or_else(HashSet::new, |size| HashSet::with_capacity(size));
loop {
match seq.next_element::<String>()? {
Some(el) => out.insert(el),
None => break,
};
}
Ok(out)
}
}
pub fn deserialize<'de, D>(de: D) -> Result<HashSet<String>, D::Error>
where
D: serde::de::Deserializer<'de>,
{
de.deserialize_any(StringSetVisitor)
}
pub mod optional {
use std::collections::HashSet;
pub fn serialize<S, T: super::ForEachStr>(value: &Option<T>, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match value {
Some(value) => super::serialize(value, ser),
None => ser.serialize_none(),
}
}
pub fn deserialize<'de, D>(de: D) -> Result<Option<HashSet<String>>, D::Error>
where
D: serde::de::Deserializer<'de>,
{
super::deserialize(de).map(Some)
}
}

View File

@ -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<F: Fn(&str) -> bool>(&self, pred: F) -> bool;
}
impl StringContainer for String {
fn all<F: Fn(&str) -> bool>(&self, pred: F) -> bool {
pred(&self)
}
}
impl StringContainer for Option<String> {
fn all<F: Fn(&str) -> bool>(&self, pred: F) -> bool {
match self {
Some(ref v) => pred(v),
None => true,
}
}
}
impl StringContainer for Vec<String> {
fn all<F: Fn(&str) -> bool>(&self, pred: F) -> bool {
self.iter().all(|s| pred(&s))
}
}
impl StringContainer for Option<Vec<String>> {
fn all<F: Fn(&str) -> bool>(&self, pred: F) -> bool {
self.as_ref().map(|c| StringContainer::all(c, pred)).unwrap_or(true)
}
}
impl StringContainer for HashSet<String> {
fn all<F: Fn(&str) -> bool>(&self, pred: F) -> bool {
self.iter().all(|s| pred(s))
}
}
impl StringContainer for Option<HashSet<String>> {
fn all<F: Fn(&str) -> bool>(&self, pred: F) -> bool {
self.as_ref().map(|c| StringContainer::all(c, pred)).unwrap_or(true)
}
}

View File

@ -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<Self, Self::Err> {
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<Memory> for Memory {
type Output = Memory;
fn add(self, rhs: Memory) -> Memory {
Self(self.0 + rhs.0)
}
}
impl std::ops::AddAssign<Memory> for Memory {
fn add_assign(&mut self, rhs: Memory) {
self.0 += rhs.0;
}
}
impl std::ops::Sub<Memory> for Memory {
type Output = Memory;
fn sub(self, rhs: Memory) -> Memory {
Self(self.0 - rhs.0)
}
}
impl std::ops::SubAssign<Memory> for Memory {
fn sub_assign(&mut self, rhs: Memory) {
self.0 -= rhs.0;
}
}
impl std::ops::Mul<u64> for Memory {
type Output = Memory;
fn mul(self, rhs: u64) -> Memory {
Self(self.0 * rhs)
}
}
impl std::ops::MulAssign<u64> 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::<Memory>().unwrap(), Memory::from_mebibytes(1));
assert_eq!("1MiB".parse::<Memory>().unwrap(), Memory::from_mebibytes(1));
}

View File

@ -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;

View File

@ -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<Self, Self::Err> {
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)
}
}

View File

@ -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<T>(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::<lxc::Config>(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(())
}