use std::path::Path;
use serde_json::Value;
pub mod guard;
#[cfg(feature = "wire")]
pub mod mock_door;
#[cfg(feature = "wire")]
pub mod net;
pub mod over;
#[cfg(feature = "wire")]
mod wire;
pub use over::{Call, Exchange, Method, Over};
#[cfg(feature = "wire")]
pub use wire::{connect, Credential, Options};
const ACCOUNT_BASE: &str = "https://api.upcloud.com/1.3";
pub const ACCOUNT_BASE_FOR_DISPLAY: &str = ACCOUNT_BASE;
pub const MOCK_BASE_ENV: &str = "UPCLOUD_API_BASE";
pub const TF_MOCK_BASE_ENV: &str = "UPCLOUD_DEBUG_API_BASE_URL";
pub const MOCK_ENVS: &[&str] = &[MOCK_BASE_ENV, TF_MOCK_BASE_ENV];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Endpoint {
Account,
Mock(MockBase),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MockBase(String);
impl MockBase {
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Endpoint {
pub fn account() -> Result<Endpoint, String> {
Endpoint::account_given(|k| std::env::var(k).ok())
}
pub fn account_given(env: impl Fn(&str) -> Option<String>) -> Result<Endpoint, String> {
let set: Vec<&str> = MOCK_ENVS.iter().copied().filter(|k| env(k).map(|v| !v.trim().is_empty()).unwrap_or(false)).collect();
if set.is_empty() {
return Ok(Endpoint::Account);
}
Err(format!(
"REFUSED [mock-variable-on-an-account-run] this shell carries {s}, which means it was set up to talk to \
a FAKE UpCloud — and this run was selected to talk to THE ACCOUNT. One of the two is wrong and this \
process will not guess which. Select the mock explicitly (the verb's `mock` word, or `--mock-api \
<loopback base>`), or unset {s} to use the account.",
s = set.join(" and ")
))
}
pub fn mock(base: &str) -> Result<Endpoint, String> {
let mut b = base.trim().trim_end_matches('/').to_string();
if !is_loopback(&b) {
return Err(format!(
"REFUSED [mock-base-not-loopback] {b:?} does not name loopback (http://127.0.0.1:PORT or \
http://localhost:PORT). mock-upcloud binds 127.0.0.1 and nothing else, so nothing off this \
machine can be one."
));
}
if !b.ends_with("/1.3") {
b.push_str("/1.3");
}
Ok(Endpoint::Mock(MockBase(b)))
}
pub fn mock_from_env() -> Result<Endpoint, String> {
let raw = std::env::var(MOCK_BASE_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).ok_or_else(|| {
format!(
"REFUSED [no-mock-base] the run says use the fake and {MOCK_BASE_ENV} is not set, so there is no \
fake to use. Start one — `mock-upcloud --port 8099 --speed 0` — and export \
{MOCK_BASE_ENV}=http://127.0.0.1:8099."
)
})?;
Endpoint::mock(&raw)
}
pub fn is_account(&self) -> bool {
matches!(self, Endpoint::Account)
}
pub fn base_for_display(&self) -> &str {
match self {
Endpoint::Account => ACCOUNT_BASE,
Endpoint::Mock(b) => b.as_str(),
}
}
pub fn banner(&self) -> String {
match self {
Endpoint::Account => format!("provider: THE ACCOUNT — {ACCOUNT_BASE}. Real machines, a real bill."),
Endpoint::Mock(b) => format!(
"provider: MOCK_UPCLOUD at {} — a FAKE. Nothing here is a machine, nothing here is a bill, and \
nothing measured here says anything about the account.",
b.as_str()
),
}
}
pub fn child_args(&self) -> Vec<String> {
match self {
Endpoint::Account => Vec::new(),
Endpoint::Mock(b) => vec![MOCK_API_FLAG.to_string(), b.as_str().to_string()],
}
}
}
pub const MOCK_API_FLAG: &str = "--mock-api";
impl Endpoint {
pub fn from_flag(mock_api: Option<&str>) -> Result<Endpoint, String> {
match mock_api {
Some(b) => Endpoint::mock(b),
None => Endpoint::account(),
}
}
}
pub fn is_loopback(url: &str) -> bool {
let host = url.strip_prefix("http://").unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
host == "127.0.0.1" || host == "localhost"
}
#[derive(Debug, Clone)]
pub struct Reply {
pub status: u16,
pub body: Value,
pub text: String,
}
impl Reply {
pub fn ok(&self) -> bool {
(200..300).contains(&self.status)
}
pub fn error_code(&self) -> &str {
self.body.pointer("/error/error_code").and_then(Value::as_str).unwrap_or("")
}
pub fn error_message(&self) -> &str {
self.body.pointer("/error/error_message").and_then(Value::as_str).unwrap_or_else(|| self.text.trim())
}
pub fn describe_failure(&self, what: &str) -> String {
let code = self.error_code();
if code.is_empty() {
format!("{what} answered {} — {}", self.status, self.error_message())
} else {
format!("{what} answered {} {code} — {}", self.status, self.error_message())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Stop {
Soft { timeout_s: u32 },
Hard,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WithStorages {
AndTheirBackups,
AndKeepBackups,
LeaveThem,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backups {
Unsaid,
Keep,
Delete,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeviceKind {
Cdrom,
Disk,
}
impl DeviceKind {
pub fn as_str(self) -> &'static str {
match self {
DeviceKind::Cdrom => "cdrom",
DeviceKind::Disk => "disk",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BootOrder {
Cdrom,
Disk,
}
impl BootOrder {
pub fn as_str(self) -> &'static str {
match self {
BootOrder::Cdrom => "cdrom",
BootOrder::Disk => "disk",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Console<'a> {
Off,
Vnc { password: &'a str },
}
pub type Label<'a> = (&'a str, &'a str);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewStorage<'a> {
pub title: &'a str,
pub zone: &'a str,
pub size_gib: u64,
pub tier: &'a str,
pub labels: &'a [Label<'a>],
}
pub trait UpCloudApi {
fn describe(&self) -> String;
fn is_the_account(&self) -> bool;
fn account(&self) -> Result<Reply, String>;
fn price(&self) -> Result<Reply, String>;
fn servers(&self) -> Result<Reply, String>;
fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
fn server(&self, uuid: &str) -> Result<Reply, String>;
fn firewall_rules(&self, uuid: &str) -> Result<Reply, String>;
fn storages_private(&self) -> Result<Reply, String>;
fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
fn storage(&self, uuid: &str) -> Result<Reply, String>;
fn zones(&self) -> Result<Reply, String>;
fn plans(&self) -> Result<Reply, String>;
fn create_server(&self, document: &Value) -> Result<Reply, String>;
fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String>;
fn start_server(&self, uuid: &str) -> Result<Reply, String>;
fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String>;
fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String>;
fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String>;
fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String>;
fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String>;
fn eject_cdrom(&self, server: &str) -> Result<Reply, String>;
fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String>;
fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String>;
fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String>;
fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String>;
fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String>;
fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String>;
fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String>;
}
macro_rules! forward {
($($ty:tt)*) => {
impl<T: UpCloudApi + ?Sized> UpCloudApi for $($ty)* {
fn describe(&self) -> String { (**self).describe() }
fn is_the_account(&self) -> bool { (**self).is_the_account() }
fn account(&self) -> Result<Reply, String> { (**self).account() }
fn price(&self) -> Result<Reply, String> { (**self).price() }
fn servers(&self) -> Result<Reply, String> { (**self).servers() }
fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).servers_labelled(labels) }
fn server(&self, uuid: &str) -> Result<Reply, String> { (**self).server(uuid) }
fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> { (**self).firewall_rules(uuid) }
fn storages_private(&self) -> Result<Reply, String> { (**self).storages_private() }
fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).storages_labelled(labels) }
fn storage(&self, uuid: &str) -> Result<Reply, String> { (**self).storage(uuid) }
fn zones(&self) -> Result<Reply, String> { (**self).zones() }
fn plans(&self) -> Result<Reply, String> { (**self).plans() }
fn create_server(&self, document: &Value) -> Result<Reply, String> { (**self).create_server(document) }
fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> { (**self).stop_server(uuid, stop) }
fn start_server(&self, uuid: &str) -> Result<Reply, String> { (**self).start_server(uuid) }
fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> { (**self).modify_server_plan(uuid, plan) }
fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> { (**self).set_boot_order(uuid, order) }
fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> { (**self).set_console(uuid, console) }
fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> { (**self).attach_storage(server, kind, storage, at) }
fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> { (**self).detach_storage(server, address) }
fn eject_cdrom(&self, server: &str) -> Result<Reply, String> { (**self).eject_cdrom(server) }
fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> { (**self).delete_server(uuid, with) }
fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> { (**self).create_storage(new) }
fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> { (**self).clone_storage(uuid, title, zone, tier) }
fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> { (**self).import_direct_upload(uuid) }
fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> { (**self).upload_direct(url, file) }
fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> { (**self).modify_storage_size(uuid, gb) }
fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> { (**self).resize_filesystem(uuid) }
fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> { (**self).delete_storage(uuid, backups) }
}
};
}
forward!(&T);
forward!(Box<T>);
pub fn delete_server_query(with: WithStorages) -> &'static str {
match with {
WithStorages::AndTheirBackups => "?storages=1&backups=delete",
WithStorages::AndKeepBackups => "?storages=1&backups=keep",
WithStorages::LeaveThem => "",
}
}
pub fn delete_storage_query(backups: Backups) -> &'static str {
match backups {
Backups::Unsaid => "",
Backups::Keep => "?backups=keep",
Backups::Delete => "?backups=delete",
}
}
pub fn label_query(labels: &[Label<'_>]) -> String {
fn enc(s: &str, out: &mut String) {
for b in s.bytes() {
if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
out.push(b as char);
} else {
out.push_str(&format!("%{b:02X}"));
}
}
}
let mut q = String::new();
for (k, v) in labels {
q.push(if q.is_empty() { '?' } else { '&' });
q.push_str("label=");
enc(&format!("{k}={v}"), &mut q);
}
q
}
pub mod body {
use super::{BootOrder, Console, DeviceKind, NewStorage, Stop};
use serde_json::{json, Value};
pub fn stop(stop: Stop) -> Value {
match stop {
Stop::Soft { timeout_s } => json!({"stop_server": {"stop_type": "soft", "timeout": timeout_s.to_string()}}),
Stop::Hard => json!({"stop_server": {"stop_type": "hard"}}),
}
}
pub fn storage_size(gb: u64) -> Value {
json!({"storage": {"size": gb.to_string()}})
}
pub fn server_plan(plan: &str) -> Value {
json!({"server": {"plan": plan}})
}
pub fn boot_order(order: BootOrder) -> Value {
json!({"server": {"boot_order": order.as_str()}})
}
pub fn console(c: &Console<'_>) -> Value {
match c {
Console::Off => json!({"server": {"remote_access_enabled": "no"}}),
Console::Vnc { password } => json!({"server": {
"remote_access_enabled": "yes",
"remote_access_type": "vnc",
"remote_access_password": password,
}}),
}
}
pub fn attach(kind: DeviceKind, storage: &str, at: Option<&str>) -> Value {
match at {
None => json!({"storage_device": {"type": kind.as_str(), "storage": storage}}),
Some(a) => json!({"storage_device": {"type": kind.as_str(), "address": a, "storage": storage}}),
}
}
pub fn detach(address: &str) -> Value {
json!({"storage_device": {"address": address}})
}
pub fn create_storage(n: &NewStorage<'_>) -> Value {
let mut v = json!({"storage": {"size": n.size_gib, "tier": n.tier, "title": n.title, "zone": n.zone}});
if !n.labels.is_empty() {
v["storage"]["labels"] = json!(n.labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>());
}
v
}
pub fn clone_storage(title: &str, zone: &str, tier: &str) -> Value {
json!({"storage": {"tier": tier, "title": title, "zone": zone}})
}
pub fn direct_upload() -> Value {
json!({"storage_import": {"source": "direct_upload"}})
}
}
pub fn redact_upload_url(url: &str) -> String {
match url.find("/session/") {
Some(i) => format!("{}/session/…", &url[..i]),
None => match url.rfind('/') {
Some(i) => format!("{}/…", &url[..i]),
None => "…".to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_mock_endpoint_is_loopback_or_nothing() {
assert!(Endpoint::mock("http://127.0.0.1:8099").is_ok());
assert!(Endpoint::mock("http://localhost:8099/1.3/").is_ok());
for bad in ["https://api.upcloud.com/1.3", "http://10.13.0.247:8099", "http://[::1]:8099", "api.upcloud.com", ""] {
let e = Endpoint::mock(bad).unwrap_err();
assert!(e.contains("mock-base-not-loopback"), "{bad}: {e}");
}
}
#[test]
fn both_spellings_of_a_mock_base_reach_the_same_door() {
assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099/1.3").unwrap());
assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap().base_for_display(), "http://127.0.0.1:8099/1.3");
}
#[test]
fn an_account_run_refuses_in_a_shell_that_carries_a_mock_variable() {
for k in MOCK_ENVS {
let e = Endpoint::account_given(|q| (q == *k).then(|| "http://127.0.0.1:8099".to_string())).unwrap_err();
assert!(e.contains("mock-variable-on-an-account-run") && e.contains(k), "{e}");
}
assert_eq!(Endpoint::account_given(|_| None).unwrap(), Endpoint::Account);
assert_eq!(Endpoint::account_given(|_| Some(" ".into())).unwrap(), Endpoint::Account);
}
#[test]
fn the_choice_crosses_a_process_boundary_as_argv_and_only_as_argv() {
let m = Endpoint::mock("http://127.0.0.1:8099").unwrap();
let args = m.child_args();
assert_eq!(args, vec![MOCK_API_FLAG.to_string(), "http://127.0.0.1:8099/1.3".to_string()]);
assert_eq!(Endpoint::from_flag(Some(&args[1])).unwrap(), m);
assert!(Endpoint::Account.child_args().is_empty());
assert!(Endpoint::from_flag(Some("https://api.upcloud.com/1.3")).is_err(), "the flag cannot name the account");
}
#[test]
fn a_banner_for_a_fake_never_reads_as_a_measurement_of_the_account() {
let b = Endpoint::mock("http://127.0.0.1:8099").unwrap().banner();
assert!(b.contains("FAKE") && !b.contains("api.upcloud.com"), "{b}");
assert!(Endpoint::Account.banner().contains("a real bill"));
}
#[test]
fn the_stop_timeout_goes_out_as_a_string() {
let b = body::stop(Stop::Soft { timeout_s: 60 });
assert_eq!(b["stop_server"]["timeout"], serde_json::json!("60"));
assert_eq!(body::stop(Stop::Hard)["stop_server"]["stop_type"], serde_json::json!("hard"));
}
#[test]
fn a_label_filter_is_one_encoded_pair_per_label() {
assert_eq!(label_query(&[]), "");
assert_eq!(label_query(&[("monetize_ref", "abc")]), "?label=monetize_ref%3Dabc");
assert_eq!(label_query(&[("a", "b c"), ("k", "x&y")]), "?label=a%3Db%20c&label=k%3Dx%26y");
}
#[test]
fn a_delete_says_what_happens_to_backups_in_one_spelling() {
assert_eq!(delete_server_query(WithStorages::AndTheirBackups), "?storages=1&backups=delete");
assert_eq!(delete_server_query(WithStorages::AndKeepBackups), "?storages=1&backups=keep");
assert_eq!(delete_server_query(WithStorages::LeaveThem), "");
assert_eq!(delete_storage_query(Backups::Unsaid), "");
assert_eq!(delete_storage_query(Backups::Keep), "?backups=keep");
}
#[test]
fn an_attach_names_an_address_only_when_asked() {
assert!(body::attach(DeviceKind::Cdrom, "s", None)["storage_device"].get("address").is_none());
assert_eq!(body::attach(DeviceKind::Disk, "s", Some("virtio"))["storage_device"]["address"], serde_json::json!("virtio"));
let n = NewStorage { title: "t", zone: "z", size_gib: 1, tier: "maxiops", labels: &[] };
assert!(body::create_storage(&n)["storage"].get("labels").is_none(), "no labels, no key");
}
#[test]
fn an_upload_session_is_never_printed_whole() {
let r = redact_upload_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3cSECRET");
assert!(!r.contains("SECRET") && r.starts_with("https://fi-hel1.img.upcloud.com/uploader/session"), "{r}");
}
#[test]
fn a_failure_names_the_api_error_code() {
let r = Reply {
status: 409,
body: serde_json::json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
text: String::new(),
};
let m = r.describe_failure("POST /server/{uuid}/storage/attach");
assert!(m.contains("409 SERVER_STATE_ILLEGAL — server state is started"), "{m}");
let page = Reply { status: 502, body: Value::Null, text: "<html>bad gateway</html>".into() };
assert!(page.describe_failure("GET /x").contains("bad gateway"));
}
}