1use std::path::Path;
49
50use serde_json::Value;
51
52pub mod guard;
53#[cfg(feature = "wire")]
54pub mod mock_door;
55#[cfg(feature = "wire")]
56pub mod net;
57pub mod over;
58#[cfg(feature = "wire")]
59mod wire;
60
61pub use over::{Call, Exchange, Method, Over};
62#[cfg(feature = "wire")]
63pub use wire::{connect, Credential, Options};
64
65const ACCOUNT_BASE: &str = "https://api.upcloud.com/1.3";
69
70pub const ACCOUNT_BASE_FOR_DISPLAY: &str = ACCOUNT_BASE;
73
74pub const ACCOUNT_ROOT_FOR_DISPLAY: &str = "https://api.upcloud.com";
78
79pub const MOCK_BASE_ENV: &str = "UPCLOUD_API_BASE";
82
83pub const TF_MOCK_BASE_ENV: &str = "UPCLOUD_DEBUG_API_BASE_URL";
86
87pub const MOCK_ENVS: &[&str] = &[MOCK_BASE_ENV, TF_MOCK_BASE_ENV];
89
90#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum Endpoint {
96 Account,
98 Mock(MockBase),
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct MockBase(String);
107
108impl MockBase {
109 pub fn as_str(&self) -> &str {
110 &self.0
111 }
112}
113
114impl Endpoint {
115 pub fn account() -> Result<Endpoint, String> {
120 Endpoint::account_given(|k| std::env::var(k).ok())
121 }
122
123 pub fn account_given(env: impl Fn(&str) -> Option<String>) -> Result<Endpoint, String> {
125 let set: Vec<&str> = MOCK_ENVS.iter().copied().filter(|k| env(k).map(|v| !v.trim().is_empty()).unwrap_or(false)).collect();
126 if set.is_empty() {
127 return Ok(Endpoint::Account);
128 }
129 Err(format!(
130 "REFUSED [mock-variable-on-an-account-run] this shell carries {s}, which means it was set up to talk to \
131 a FAKE UpCloud — and this run was selected to talk to THE ACCOUNT. One of the two is wrong and this \
132 process will not guess which. Select the mock explicitly (the verb's `mock` word, or `--mock-api \
133 <loopback base>`), or unset {s} to use the account.",
134 s = set.join(" and ")
135 ))
136 }
137
138 pub fn mock(base: &str) -> Result<Endpoint, String> {
142 let mut b = base.trim().trim_end_matches('/').to_string();
143 if !is_loopback(&b) {
144 return Err(format!(
145 "REFUSED [mock-base-not-loopback] {b:?} does not name loopback (http://127.0.0.1:PORT or \
146 http://localhost:PORT). mock-upcloud binds 127.0.0.1 and nothing else, so nothing off this \
147 machine can be one."
148 ));
149 }
150 if !b.ends_with("/1.3") {
151 b.push_str("/1.3");
152 }
153 Ok(Endpoint::Mock(MockBase(b)))
154 }
155
156
157 pub fn for_base(base: &str) -> Result<Endpoint, String> {
164 let b = base.trim().trim_end_matches('/');
165 let root = b.strip_suffix("/1.3").unwrap_or(b);
166 if root == ACCOUNT_ROOT_FOR_DISPLAY {
167 return Ok(Endpoint::Account);
168 }
169 Endpoint::mock(root).map_err(|e| format!("{base:?} is neither the account nor a loopback mock — {e}"))
170 }
171 pub fn mock_from_env() -> Result<Endpoint, String> {
174 let raw = std::env::var(MOCK_BASE_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).ok_or_else(|| {
175 format!(
176 "REFUSED [no-mock-base] the run says use the fake and {MOCK_BASE_ENV} is not set, so there is no \
177 fake to use. Start one — `mock-upcloud --port 8099 --speed 0` — and export \
178 {MOCK_BASE_ENV}=http://127.0.0.1:8099."
179 )
180 })?;
181 Endpoint::mock(&raw)
182 }
183
184 pub fn is_account(&self) -> bool {
185 matches!(self, Endpoint::Account)
186 }
187
188 pub fn base_for_display(&self) -> &str {
191 match self {
192 Endpoint::Account => ACCOUNT_BASE,
193 Endpoint::Mock(b) => b.as_str(),
194 }
195 }
196
197 pub fn banner(&self) -> String {
200 match self {
201 Endpoint::Account => format!("provider: THE ACCOUNT — {ACCOUNT_BASE}. Real machines, a real bill."),
202 Endpoint::Mock(b) => format!(
203 "provider: MOCK_UPCLOUD at {} — a FAKE. Nothing here is a machine, nothing here is a bill, and \
204 nothing measured here says anything about the account.",
205 b.as_str()
206 ),
207 }
208 }
209
210 pub fn child_args(&self) -> Vec<String> {
216 match self {
217 Endpoint::Account => Vec::new(),
218 Endpoint::Mock(b) => vec![MOCK_API_FLAG.to_string(), b.as_str().to_string()],
219 }
220 }
221}
222
223pub const MOCK_API_FLAG: &str = "--mock-api";
225
226impl Endpoint {
227 pub fn from_flag(mock_api: Option<&str>) -> Result<Endpoint, String> {
231 match mock_api {
232 Some(b) => Endpoint::mock(b),
233 None => Endpoint::account(),
234 }
235 }
236}
237
238pub fn is_loopback(url: &str) -> bool {
241 let host = url.strip_prefix("http://").unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
242 host == "127.0.0.1" || host == "localhost"
243}
244
245#[derive(Debug, Clone)]
252pub struct Reply {
253 pub status: u16,
254 pub body: Value,
256 pub text: String,
260}
261
262impl Reply {
263 pub fn ok(&self) -> bool {
264 (200..300).contains(&self.status)
265 }
266
267 pub fn error_code(&self) -> &str {
269 self.body.pointer("/error/error_code").and_then(Value::as_str).unwrap_or("")
270 }
271
272 pub fn error_message(&self) -> &str {
274 self.body.pointer("/error/error_message").and_then(Value::as_str).unwrap_or_else(|| self.text.trim())
275 }
276
277 pub fn describe_failure(&self, what: &str) -> String {
280 let code = self.error_code();
281 if code.is_empty() {
282 format!("{what} answered {} — {}", self.status, self.error_message())
283 } else {
284 format!("{what} answered {} {code} — {}", self.status, self.error_message())
285 }
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub enum Stop {
295 Soft { timeout_s: u32 },
296 Hard,
297}
298
299#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum WithStorages {
303 AndTheirBackups,
306 AndKeepBackups,
309 LeaveThem,
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
315pub enum Backups {
316 Unsaid,
319 Keep,
321 Delete,
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
327pub enum DeviceKind {
328 Cdrom,
330 Disk,
332}
333
334impl DeviceKind {
335 pub fn as_str(self) -> &'static str {
336 match self {
337 DeviceKind::Cdrom => "cdrom",
338 DeviceKind::Disk => "disk",
339 }
340 }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
346pub enum BootOrder {
347 Cdrom,
348 Disk,
349}
350
351impl BootOrder {
352 pub fn as_str(self) -> &'static str {
353 match self {
354 BootOrder::Cdrom => "cdrom",
355 BootOrder::Disk => "disk",
356 }
357 }
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum Console<'a> {
363 Off,
364 Vnc { password: &'a str },
365}
366
367pub type Label<'a> = (&'a str, &'a str);
370
371#[derive(Debug, Clone, PartialEq, Eq)]
373pub struct NewStorage<'a> {
374 pub title: &'a str,
375 pub zone: &'a str,
376 pub size_gib: u64,
377 pub tier: &'a str,
379 pub labels: &'a [Label<'a>],
381}
382
383pub trait UpCloudApi {
388 fn describe(&self) -> String;
391
392 fn is_the_account(&self) -> bool;
395
396 fn account(&self) -> Result<Reply, String>;
400 fn price(&self) -> Result<Reply, String>;
402 fn servers(&self) -> Result<Reply, String>;
405 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
408 fn server(&self, uuid: &str) -> Result<Reply, String>;
410 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String>;
412 fn storages_private(&self) -> Result<Reply, String>;
415 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
418 fn storage(&self, uuid: &str) -> Result<Reply, String>;
421 fn zones(&self) -> Result<Reply, String>;
423 fn plans(&self) -> Result<Reply, String>;
425
426 fn create_server(&self, document: &Value) -> Result<Reply, String>;
431 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String>;
433 fn start_server(&self, uuid: &str) -> Result<Reply, String>;
435 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String>;
438 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String>;
440 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String>;
443 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String>;
447 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String>;
451 fn eject_cdrom(&self, server: &str) -> Result<Reply, String>;
454 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String>;
456
457 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String>;
461 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String>;
463 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String>;
467 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String>;
473 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
476 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String>;
479 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String>;
481}
482
483macro_rules! forward {
486 ($($ty:tt)*) => {
487 impl<T: UpCloudApi + ?Sized> UpCloudApi for $($ty)* {
488 fn describe(&self) -> String { (**self).describe() }
489 fn is_the_account(&self) -> bool { (**self).is_the_account() }
490 fn account(&self) -> Result<Reply, String> { (**self).account() }
491 fn price(&self) -> Result<Reply, String> { (**self).price() }
492 fn servers(&self) -> Result<Reply, String> { (**self).servers() }
493 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).servers_labelled(labels) }
494 fn server(&self, uuid: &str) -> Result<Reply, String> { (**self).server(uuid) }
495 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> { (**self).firewall_rules(uuid) }
496 fn storages_private(&self) -> Result<Reply, String> { (**self).storages_private() }
497 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).storages_labelled(labels) }
498 fn storage(&self, uuid: &str) -> Result<Reply, String> { (**self).storage(uuid) }
499 fn zones(&self) -> Result<Reply, String> { (**self).zones() }
500 fn plans(&self) -> Result<Reply, String> { (**self).plans() }
501 fn create_server(&self, document: &Value) -> Result<Reply, String> { (**self).create_server(document) }
502 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> { (**self).stop_server(uuid, stop) }
503 fn start_server(&self, uuid: &str) -> Result<Reply, String> { (**self).start_server(uuid) }
504 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> { (**self).modify_server_plan(uuid, plan) }
505 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> { (**self).set_boot_order(uuid, order) }
506 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> { (**self).set_console(uuid, console) }
507 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> { (**self).attach_storage(server, kind, storage, at) }
508 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> { (**self).detach_storage(server, address) }
509 fn eject_cdrom(&self, server: &str) -> Result<Reply, String> { (**self).eject_cdrom(server) }
510 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> { (**self).delete_server(uuid, with) }
511 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> { (**self).create_storage(new) }
512 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> { (**self).clone_storage(uuid, title, zone, tier) }
513 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> { (**self).import_direct_upload(uuid) }
514 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> { (**self).upload_direct(url, file) }
515 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> { (**self).modify_storage_size(uuid, gb) }
516 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> { (**self).resize_filesystem(uuid) }
517 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> { (**self).delete_storage(uuid, backups) }
518 }
519 };
520}
521forward!(&T);
522forward!(Box<T>);
523
524pub fn delete_server_query(with: WithStorages) -> &'static str {
527 match with {
528 WithStorages::AndTheirBackups => "?storages=1&backups=delete",
529 WithStorages::AndKeepBackups => "?storages=1&backups=keep",
530 WithStorages::LeaveThem => "",
531 }
532}
533
534pub fn delete_storage_query(backups: Backups) -> &'static str {
536 match backups {
537 Backups::Unsaid => "",
538 Backups::Keep => "?backups=keep",
539 Backups::Delete => "?backups=delete",
540 }
541}
542
543pub fn label_query(labels: &[Label<'_>]) -> String {
546 fn enc(s: &str, out: &mut String) {
547 for b in s.bytes() {
548 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
549 out.push(b as char);
550 } else {
551 out.push_str(&format!("%{b:02X}"));
552 }
553 }
554 }
555 let mut q = String::new();
556 for (k, v) in labels {
557 q.push(if q.is_empty() { '?' } else { '&' });
558 q.push_str("label=");
559 enc(&format!("{k}={v}"), &mut q);
560 }
561 q
562}
563
564pub mod body {
569 use super::{BootOrder, Console, DeviceKind, NewStorage, Stop};
570 use serde_json::{json, Value};
571
572 pub fn stop(stop: Stop) -> Value {
575 match stop {
576 Stop::Soft { timeout_s } => json!({"stop_server": {"stop_type": "soft", "timeout": timeout_s.to_string()}}),
577 Stop::Hard => json!({"stop_server": {"stop_type": "hard"}}),
578 }
579 }
580 pub fn storage_size(gb: u64) -> Value {
581 json!({"storage": {"size": gb.to_string()}})
582 }
583 pub fn server_plan(plan: &str) -> Value {
584 json!({"server": {"plan": plan}})
585 }
586 pub fn boot_order(order: BootOrder) -> Value {
587 json!({"server": {"boot_order": order.as_str()}})
588 }
589 pub fn console(c: &Console<'_>) -> Value {
590 match c {
591 Console::Off => json!({"server": {"remote_access_enabled": "no"}}),
592 Console::Vnc { password } => json!({"server": {
593 "remote_access_enabled": "yes",
594 "remote_access_type": "vnc",
595 "remote_access_password": password,
596 }}),
597 }
598 }
599 pub fn attach(kind: DeviceKind, storage: &str, at: Option<&str>) -> Value {
600 match at {
601 None => json!({"storage_device": {"type": kind.as_str(), "storage": storage}}),
602 Some(a) => json!({"storage_device": {"type": kind.as_str(), "address": a, "storage": storage}}),
603 }
604 }
605 pub fn detach(address: &str) -> Value {
607 json!({"storage_device": {"address": address}})
608 }
609 pub fn create_storage(n: &NewStorage<'_>) -> Value {
610 let mut v = json!({"storage": {"size": n.size_gib, "tier": n.tier, "title": n.title, "zone": n.zone}});
611 if !n.labels.is_empty() {
612 v["storage"]["labels"] = json!(n.labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>());
613 }
614 v
615 }
616 pub fn clone_storage(title: &str, zone: &str, tier: &str) -> Value {
618 json!({"storage": {"tier": tier, "title": title, "zone": zone}})
619 }
620 pub fn direct_upload() -> Value {
621 json!({"storage_import": {"source": "direct_upload"}})
622 }
623}
624
625pub fn redact_upload_url(url: &str) -> String {
629 match url.find("/session/") {
630 Some(i) => format!("{}/session/…", &url[..i]),
631 None => match url.rfind('/') {
632 Some(i) => format!("{}/…", &url[..i]),
633 None => "…".to_string(),
634 },
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn a_mock_endpoint_is_loopback_or_nothing() {
644 assert!(Endpoint::mock("http://127.0.0.1:8099").is_ok());
645 assert!(Endpoint::mock("http://localhost:8099/1.3/").is_ok());
646 for bad in ["https://api.upcloud.com/1.3", "http://10.13.0.247:8099", "http://[::1]:8099", "api.upcloud.com", ""] {
647 let e = Endpoint::mock(bad).unwrap_err();
648 assert!(e.contains("mock-base-not-loopback"), "{bad}: {e}");
649 }
650 }
651
652 #[test]
653 fn a_configured_base_is_the_account_a_mock_or_refused() {
654 for a in ["https://api.upcloud.com", "https://api.upcloud.com/", "https://api.upcloud.com/1.3", "https://api.upcloud.com/1.3/"] {
655 assert_eq!(Endpoint::for_base(a).unwrap(), Endpoint::Account, "{a}");
656 }
657 assert_eq!(Endpoint::for_base("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099").unwrap());
658 assert!(Endpoint::for_base("https://api.upcloud.com.evil.example").is_err());
659 assert!(Endpoint::for_base("http://10.13.0.247:8099").is_err());
660 assert_eq!(ACCOUNT_BASE_FOR_DISPLAY, format!("{ACCOUNT_ROOT_FOR_DISPLAY}/1.3"));
661 }
662
663 #[test]
664 fn both_spellings_of_a_mock_base_reach_the_same_door() {
665 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099/1.3").unwrap());
666 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap().base_for_display(), "http://127.0.0.1:8099/1.3");
667 }
668
669 #[test]
673 fn an_account_run_refuses_in_a_shell_that_carries_a_mock_variable() {
674 for k in MOCK_ENVS {
675 let e = Endpoint::account_given(|q| (q == *k).then(|| "http://127.0.0.1:8099".to_string())).unwrap_err();
676 assert!(e.contains("mock-variable-on-an-account-run") && e.contains(k), "{e}");
677 }
678 assert_eq!(Endpoint::account_given(|_| None).unwrap(), Endpoint::Account);
679 assert_eq!(Endpoint::account_given(|_| Some(" ".into())).unwrap(), Endpoint::Account);
681 }
682
683 #[test]
684 fn the_choice_crosses_a_process_boundary_as_argv_and_only_as_argv() {
685 let m = Endpoint::mock("http://127.0.0.1:8099").unwrap();
686 let args = m.child_args();
687 assert_eq!(args, vec![MOCK_API_FLAG.to_string(), "http://127.0.0.1:8099/1.3".to_string()]);
688 assert_eq!(Endpoint::from_flag(Some(&args[1])).unwrap(), m);
689 assert!(Endpoint::Account.child_args().is_empty());
690 assert!(Endpoint::from_flag(Some("https://api.upcloud.com/1.3")).is_err(), "the flag cannot name the account");
691 }
692
693 #[test]
694 fn a_banner_for_a_fake_never_reads_as_a_measurement_of_the_account() {
695 let b = Endpoint::mock("http://127.0.0.1:8099").unwrap().banner();
696 assert!(b.contains("FAKE") && !b.contains("api.upcloud.com"), "{b}");
697 assert!(Endpoint::Account.banner().contains("a real bill"));
698 }
699
700 #[test]
701 fn the_stop_timeout_goes_out_as_a_string() {
702 let b = body::stop(Stop::Soft { timeout_s: 60 });
703 assert_eq!(b["stop_server"]["timeout"], serde_json::json!("60"));
704 assert_eq!(body::stop(Stop::Hard)["stop_server"]["stop_type"], serde_json::json!("hard"));
705 }
706
707 #[test]
708 fn a_label_filter_is_one_encoded_pair_per_label() {
709 assert_eq!(label_query(&[]), "");
710 assert_eq!(label_query(&[("monetize_ref", "abc")]), "?label=monetize_ref%3Dabc");
711 assert_eq!(label_query(&[("a", "b c"), ("k", "x&y")]), "?label=a%3Db%20c&label=k%3Dx%26y");
712 }
713
714 #[test]
715 fn a_delete_says_what_happens_to_backups_in_one_spelling() {
716 assert_eq!(delete_server_query(WithStorages::AndTheirBackups), "?storages=1&backups=delete");
717 assert_eq!(delete_server_query(WithStorages::AndKeepBackups), "?storages=1&backups=keep");
718 assert_eq!(delete_server_query(WithStorages::LeaveThem), "");
719 assert_eq!(delete_storage_query(Backups::Unsaid), "");
720 assert_eq!(delete_storage_query(Backups::Keep), "?backups=keep");
721 }
722
723 #[test]
724 fn an_attach_names_an_address_only_when_asked() {
725 assert!(body::attach(DeviceKind::Cdrom, "s", None)["storage_device"].get("address").is_none());
726 assert_eq!(body::attach(DeviceKind::Disk, "s", Some("virtio"))["storage_device"]["address"], serde_json::json!("virtio"));
727 let n = NewStorage { title: "t", zone: "z", size_gib: 1, tier: "maxiops", labels: &[] };
728 assert!(body::create_storage(&n)["storage"].get("labels").is_none(), "no labels, no key");
729 }
730
731 #[test]
732 fn an_upload_session_is_never_printed_whole() {
733 let r = redact_upload_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3cSECRET");
734 assert!(!r.contains("SECRET") && r.starts_with("https://fi-hel1.img.upcloud.com/uploader/session"), "{r}");
735 }
736
737 #[test]
738 fn a_failure_names_the_api_error_code() {
739 let r = Reply {
740 status: 409,
741 body: serde_json::json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
742 text: String::new(),
743 };
744 let m = r.describe_failure("POST /server/{uuid}/storage/attach");
745 assert!(m.contains("409 SERVER_STATE_ILLEGAL — server state is started"), "{m}");
746 let page = Reply { status: 502, body: Value::Null, text: "<html>bad gateway</html>".into() };
747 assert!(page.describe_failure("GET /x").contains("bad gateway"));
748 }
749}