1use std::path::Path;
49
50use serde_json::Value;
51
52pub mod guard;
53pub mod net;
54pub mod over;
55mod wire;
56
57pub use over::{Call, Exchange, Method, Over};
58pub use wire::{connect, Credential, Options};
59
60const ACCOUNT_BASE: &str = "https://api.upcloud.com/1.3";
64
65pub const ACCOUNT_BASE_FOR_DISPLAY: &str = ACCOUNT_BASE;
68
69pub const MOCK_BASE_ENV: &str = "UPCLOUD_API_BASE";
72
73pub const TF_MOCK_BASE_ENV: &str = "UPCLOUD_DEBUG_API_BASE_URL";
76
77pub const MOCK_ENVS: &[&str] = &[MOCK_BASE_ENV, TF_MOCK_BASE_ENV];
79
80#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum Endpoint {
86 Account,
88 Mock(MockBase),
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct MockBase(String);
97
98impl MockBase {
99 pub fn as_str(&self) -> &str {
100 &self.0
101 }
102}
103
104impl Endpoint {
105 pub fn account() -> Result<Endpoint, String> {
110 Endpoint::account_given(|k| std::env::var(k).ok())
111 }
112
113 pub fn account_given(env: impl Fn(&str) -> Option<String>) -> Result<Endpoint, String> {
115 let set: Vec<&str> = MOCK_ENVS.iter().copied().filter(|k| env(k).map(|v| !v.trim().is_empty()).unwrap_or(false)).collect();
116 if set.is_empty() {
117 return Ok(Endpoint::Account);
118 }
119 Err(format!(
120 "REFUSED [mock-variable-on-an-account-run] this shell carries {s}, which means it was set up to talk to \
121 a FAKE UpCloud — and this run was selected to talk to THE ACCOUNT. One of the two is wrong and this \
122 process will not guess which. Select the mock explicitly (the verb's `mock` word, or `--mock-api \
123 <loopback base>`), or unset {s} to use the account.",
124 s = set.join(" and ")
125 ))
126 }
127
128 pub fn mock(base: &str) -> Result<Endpoint, String> {
132 let mut b = base.trim().trim_end_matches('/').to_string();
133 if !is_loopback(&b) {
134 return Err(format!(
135 "REFUSED [mock-base-not-loopback] {b:?} does not name loopback (http://127.0.0.1:PORT or \
136 http://localhost:PORT). mock-upcloud binds 127.0.0.1 and nothing else, so nothing off this \
137 machine can be one."
138 ));
139 }
140 if !b.ends_with("/1.3") {
141 b.push_str("/1.3");
142 }
143 Ok(Endpoint::Mock(MockBase(b)))
144 }
145
146 pub fn mock_from_env() -> Result<Endpoint, String> {
149 let raw = std::env::var(MOCK_BASE_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).ok_or_else(|| {
150 format!(
151 "REFUSED [no-mock-base] the run says use the fake and {MOCK_BASE_ENV} is not set, so there is no \
152 fake to use. Start one — `mock-upcloud --port 8099 --speed 0` — and export \
153 {MOCK_BASE_ENV}=http://127.0.0.1:8099."
154 )
155 })?;
156 Endpoint::mock(&raw)
157 }
158
159 pub fn is_account(&self) -> bool {
160 matches!(self, Endpoint::Account)
161 }
162
163 pub fn base_for_display(&self) -> &str {
166 match self {
167 Endpoint::Account => ACCOUNT_BASE,
168 Endpoint::Mock(b) => b.as_str(),
169 }
170 }
171
172 pub fn banner(&self) -> String {
175 match self {
176 Endpoint::Account => format!("provider: THE ACCOUNT — {ACCOUNT_BASE}. Real machines, a real bill."),
177 Endpoint::Mock(b) => format!(
178 "provider: MOCK_UPCLOUD at {} — a FAKE. Nothing here is a machine, nothing here is a bill, and \
179 nothing measured here says anything about the account.",
180 b.as_str()
181 ),
182 }
183 }
184
185 pub fn child_args(&self) -> Vec<String> {
191 match self {
192 Endpoint::Account => Vec::new(),
193 Endpoint::Mock(b) => vec![MOCK_API_FLAG.to_string(), b.as_str().to_string()],
194 }
195 }
196}
197
198pub const MOCK_API_FLAG: &str = "--mock-api";
200
201impl Endpoint {
202 pub fn from_flag(mock_api: Option<&str>) -> Result<Endpoint, String> {
206 match mock_api {
207 Some(b) => Endpoint::mock(b),
208 None => Endpoint::account(),
209 }
210 }
211}
212
213pub fn is_loopback(url: &str) -> bool {
216 let host = url.strip_prefix("http://").unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
217 host == "127.0.0.1" || host == "localhost"
218}
219
220#[derive(Debug, Clone)]
227pub struct Reply {
228 pub status: u16,
229 pub body: Value,
231 pub text: String,
235}
236
237impl Reply {
238 pub fn ok(&self) -> bool {
239 (200..300).contains(&self.status)
240 }
241
242 pub fn error_code(&self) -> &str {
244 self.body.pointer("/error/error_code").and_then(Value::as_str).unwrap_or("")
245 }
246
247 pub fn error_message(&self) -> &str {
249 self.body.pointer("/error/error_message").and_then(Value::as_str).unwrap_or_else(|| self.text.trim())
250 }
251
252 pub fn describe_failure(&self, what: &str) -> String {
255 let code = self.error_code();
256 if code.is_empty() {
257 format!("{what} answered {} — {}", self.status, self.error_message())
258 } else {
259 format!("{what} answered {} {code} — {}", self.status, self.error_message())
260 }
261 }
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
269pub enum Stop {
270 Soft { timeout_s: u32 },
271 Hard,
272}
273
274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
277pub enum WithStorages {
278 AndTheirBackups,
281 AndKeepBackups,
284 LeaveThem,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
290pub enum Backups {
291 Unsaid,
294 Keep,
296 Delete,
298}
299
300#[derive(Debug, Clone, Copy, PartialEq, Eq)]
302pub enum DeviceKind {
303 Cdrom,
305 Disk,
307}
308
309impl DeviceKind {
310 pub fn as_str(self) -> &'static str {
311 match self {
312 DeviceKind::Cdrom => "cdrom",
313 DeviceKind::Disk => "disk",
314 }
315 }
316}
317
318#[derive(Debug, Clone, Copy, PartialEq, Eq)]
321pub enum BootOrder {
322 Cdrom,
323 Disk,
324}
325
326impl BootOrder {
327 pub fn as_str(self) -> &'static str {
328 match self {
329 BootOrder::Cdrom => "cdrom",
330 BootOrder::Disk => "disk",
331 }
332 }
333}
334
335#[derive(Debug, Clone, PartialEq, Eq)]
337pub enum Console<'a> {
338 Off,
339 Vnc { password: &'a str },
340}
341
342pub type Label<'a> = (&'a str, &'a str);
345
346#[derive(Debug, Clone, PartialEq, Eq)]
348pub struct NewStorage<'a> {
349 pub title: &'a str,
350 pub zone: &'a str,
351 pub size_gib: u64,
352 pub tier: &'a str,
354 pub labels: &'a [Label<'a>],
356}
357
358pub trait UpCloudApi {
363 fn describe(&self) -> String;
366
367 fn is_the_account(&self) -> bool;
370
371 fn account(&self) -> Result<Reply, String>;
375 fn price(&self) -> Result<Reply, String>;
377 fn servers(&self) -> Result<Reply, String>;
380 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
383 fn server(&self, uuid: &str) -> Result<Reply, String>;
385 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String>;
387 fn storages_private(&self) -> Result<Reply, String>;
390 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
393 fn storage(&self, uuid: &str) -> Result<Reply, String>;
396 fn zones(&self) -> Result<Reply, String>;
398 fn plans(&self) -> Result<Reply, String>;
400
401 fn create_server(&self, document: &Value) -> Result<Reply, String>;
406 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String>;
408 fn start_server(&self, uuid: &str) -> Result<Reply, String>;
410 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String>;
413 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String>;
415 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String>;
418 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String>;
422 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String>;
426 fn eject_cdrom(&self, server: &str) -> Result<Reply, String>;
429 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String>;
431
432 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String>;
436 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String>;
438 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String>;
442 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String>;
448 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
451 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String>;
454 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String>;
456}
457
458macro_rules! forward {
461 ($($ty:tt)*) => {
462 impl<T: UpCloudApi + ?Sized> UpCloudApi for $($ty)* {
463 fn describe(&self) -> String { (**self).describe() }
464 fn is_the_account(&self) -> bool { (**self).is_the_account() }
465 fn account(&self) -> Result<Reply, String> { (**self).account() }
466 fn price(&self) -> Result<Reply, String> { (**self).price() }
467 fn servers(&self) -> Result<Reply, String> { (**self).servers() }
468 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).servers_labelled(labels) }
469 fn server(&self, uuid: &str) -> Result<Reply, String> { (**self).server(uuid) }
470 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> { (**self).firewall_rules(uuid) }
471 fn storages_private(&self) -> Result<Reply, String> { (**self).storages_private() }
472 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).storages_labelled(labels) }
473 fn storage(&self, uuid: &str) -> Result<Reply, String> { (**self).storage(uuid) }
474 fn zones(&self) -> Result<Reply, String> { (**self).zones() }
475 fn plans(&self) -> Result<Reply, String> { (**self).plans() }
476 fn create_server(&self, document: &Value) -> Result<Reply, String> { (**self).create_server(document) }
477 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> { (**self).stop_server(uuid, stop) }
478 fn start_server(&self, uuid: &str) -> Result<Reply, String> { (**self).start_server(uuid) }
479 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> { (**self).modify_server_plan(uuid, plan) }
480 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> { (**self).set_boot_order(uuid, order) }
481 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> { (**self).set_console(uuid, console) }
482 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> { (**self).attach_storage(server, kind, storage, at) }
483 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> { (**self).detach_storage(server, address) }
484 fn eject_cdrom(&self, server: &str) -> Result<Reply, String> { (**self).eject_cdrom(server) }
485 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> { (**self).delete_server(uuid, with) }
486 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> { (**self).create_storage(new) }
487 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> { (**self).clone_storage(uuid, title, zone, tier) }
488 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> { (**self).import_direct_upload(uuid) }
489 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> { (**self).upload_direct(url, file) }
490 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> { (**self).modify_storage_size(uuid, gb) }
491 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> { (**self).resize_filesystem(uuid) }
492 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> { (**self).delete_storage(uuid, backups) }
493 }
494 };
495}
496forward!(&T);
497forward!(Box<T>);
498
499pub fn delete_server_query(with: WithStorages) -> &'static str {
502 match with {
503 WithStorages::AndTheirBackups => "?storages=1&backups=delete",
504 WithStorages::AndKeepBackups => "?storages=1&backups=keep",
505 WithStorages::LeaveThem => "",
506 }
507}
508
509pub fn delete_storage_query(backups: Backups) -> &'static str {
511 match backups {
512 Backups::Unsaid => "",
513 Backups::Keep => "?backups=keep",
514 Backups::Delete => "?backups=delete",
515 }
516}
517
518pub fn label_query(labels: &[Label<'_>]) -> String {
521 fn enc(s: &str, out: &mut String) {
522 for b in s.bytes() {
523 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
524 out.push(b as char);
525 } else {
526 out.push_str(&format!("%{b:02X}"));
527 }
528 }
529 }
530 let mut q = String::new();
531 for (k, v) in labels {
532 q.push(if q.is_empty() { '?' } else { '&' });
533 q.push_str("label=");
534 enc(&format!("{k}={v}"), &mut q);
535 }
536 q
537}
538
539pub mod body {
544 use super::{BootOrder, Console, DeviceKind, NewStorage, Stop};
545 use serde_json::{json, Value};
546
547 pub fn stop(stop: Stop) -> Value {
550 match stop {
551 Stop::Soft { timeout_s } => json!({"stop_server": {"stop_type": "soft", "timeout": timeout_s.to_string()}}),
552 Stop::Hard => json!({"stop_server": {"stop_type": "hard"}}),
553 }
554 }
555 pub fn storage_size(gb: u64) -> Value {
556 json!({"storage": {"size": gb.to_string()}})
557 }
558 pub fn server_plan(plan: &str) -> Value {
559 json!({"server": {"plan": plan}})
560 }
561 pub fn boot_order(order: BootOrder) -> Value {
562 json!({"server": {"boot_order": order.as_str()}})
563 }
564 pub fn console(c: &Console<'_>) -> Value {
565 match c {
566 Console::Off => json!({"server": {"remote_access_enabled": "no"}}),
567 Console::Vnc { password } => json!({"server": {
568 "remote_access_enabled": "yes",
569 "remote_access_type": "vnc",
570 "remote_access_password": password,
571 }}),
572 }
573 }
574 pub fn attach(kind: DeviceKind, storage: &str, at: Option<&str>) -> Value {
575 match at {
576 None => json!({"storage_device": {"type": kind.as_str(), "storage": storage}}),
577 Some(a) => json!({"storage_device": {"type": kind.as_str(), "address": a, "storage": storage}}),
578 }
579 }
580 pub fn detach(address: &str) -> Value {
582 json!({"storage_device": {"address": address}})
583 }
584 pub fn create_storage(n: &NewStorage<'_>) -> Value {
585 let mut v = json!({"storage": {"size": n.size_gib, "tier": n.tier, "title": n.title, "zone": n.zone}});
586 if !n.labels.is_empty() {
587 v["storage"]["labels"] = json!(n.labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>());
588 }
589 v
590 }
591 pub fn clone_storage(title: &str, zone: &str, tier: &str) -> Value {
593 json!({"storage": {"tier": tier, "title": title, "zone": zone}})
594 }
595 pub fn direct_upload() -> Value {
596 json!({"storage_import": {"source": "direct_upload"}})
597 }
598}
599
600pub fn redact_upload_url(url: &str) -> String {
604 match url.find("/session/") {
605 Some(i) => format!("{}/session/…", &url[..i]),
606 None => match url.rfind('/') {
607 Some(i) => format!("{}/…", &url[..i]),
608 None => "…".to_string(),
609 },
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616
617 #[test]
618 fn a_mock_endpoint_is_loopback_or_nothing() {
619 assert!(Endpoint::mock("http://127.0.0.1:8099").is_ok());
620 assert!(Endpoint::mock("http://localhost:8099/1.3/").is_ok());
621 for bad in ["https://api.upcloud.com/1.3", "http://10.13.0.247:8099", "http://[::1]:8099", "api.upcloud.com", ""] {
622 let e = Endpoint::mock(bad).unwrap_err();
623 assert!(e.contains("mock-base-not-loopback"), "{bad}: {e}");
624 }
625 }
626
627 #[test]
628 fn both_spellings_of_a_mock_base_reach_the_same_door() {
629 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099/1.3").unwrap());
630 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap().base_for_display(), "http://127.0.0.1:8099/1.3");
631 }
632
633 #[test]
637 fn an_account_run_refuses_in_a_shell_that_carries_a_mock_variable() {
638 for k in MOCK_ENVS {
639 let e = Endpoint::account_given(|q| (q == *k).then(|| "http://127.0.0.1:8099".to_string())).unwrap_err();
640 assert!(e.contains("mock-variable-on-an-account-run") && e.contains(k), "{e}");
641 }
642 assert_eq!(Endpoint::account_given(|_| None).unwrap(), Endpoint::Account);
643 assert_eq!(Endpoint::account_given(|_| Some(" ".into())).unwrap(), Endpoint::Account);
645 }
646
647 #[test]
648 fn the_choice_crosses_a_process_boundary_as_argv_and_only_as_argv() {
649 let m = Endpoint::mock("http://127.0.0.1:8099").unwrap();
650 let args = m.child_args();
651 assert_eq!(args, vec![MOCK_API_FLAG.to_string(), "http://127.0.0.1:8099/1.3".to_string()]);
652 assert_eq!(Endpoint::from_flag(Some(&args[1])).unwrap(), m);
653 assert!(Endpoint::Account.child_args().is_empty());
654 assert!(Endpoint::from_flag(Some("https://api.upcloud.com/1.3")).is_err(), "the flag cannot name the account");
655 }
656
657 #[test]
658 fn a_banner_for_a_fake_never_reads_as_a_measurement_of_the_account() {
659 let b = Endpoint::mock("http://127.0.0.1:8099").unwrap().banner();
660 assert!(b.contains("FAKE") && !b.contains("api.upcloud.com"), "{b}");
661 assert!(Endpoint::Account.banner().contains("a real bill"));
662 }
663
664 #[test]
665 fn the_stop_timeout_goes_out_as_a_string() {
666 let b = body::stop(Stop::Soft { timeout_s: 60 });
667 assert_eq!(b["stop_server"]["timeout"], serde_json::json!("60"));
668 assert_eq!(body::stop(Stop::Hard)["stop_server"]["stop_type"], serde_json::json!("hard"));
669 }
670
671 #[test]
672 fn a_label_filter_is_one_encoded_pair_per_label() {
673 assert_eq!(label_query(&[]), "");
674 assert_eq!(label_query(&[("monetize_ref", "abc")]), "?label=monetize_ref%3Dabc");
675 assert_eq!(label_query(&[("a", "b c"), ("k", "x&y")]), "?label=a%3Db%20c&label=k%3Dx%26y");
676 }
677
678 #[test]
679 fn a_delete_says_what_happens_to_backups_in_one_spelling() {
680 assert_eq!(delete_server_query(WithStorages::AndTheirBackups), "?storages=1&backups=delete");
681 assert_eq!(delete_server_query(WithStorages::AndKeepBackups), "?storages=1&backups=keep");
682 assert_eq!(delete_server_query(WithStorages::LeaveThem), "");
683 assert_eq!(delete_storage_query(Backups::Unsaid), "");
684 assert_eq!(delete_storage_query(Backups::Keep), "?backups=keep");
685 }
686
687 #[test]
688 fn an_attach_names_an_address_only_when_asked() {
689 assert!(body::attach(DeviceKind::Cdrom, "s", None)["storage_device"].get("address").is_none());
690 assert_eq!(body::attach(DeviceKind::Disk, "s", Some("virtio"))["storage_device"]["address"], serde_json::json!("virtio"));
691 let n = NewStorage { title: "t", zone: "z", size_gib: 1, tier: "maxiops", labels: &[] };
692 assert!(body::create_storage(&n)["storage"].get("labels").is_none(), "no labels, no key");
693 }
694
695 #[test]
696 fn an_upload_session_is_never_printed_whole() {
697 let r = redact_upload_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3cSECRET");
698 assert!(!r.contains("SECRET") && r.starts_with("https://fi-hel1.img.upcloud.com/uploader/session"), "{r}");
699 }
700
701 #[test]
702 fn a_failure_names_the_api_error_code() {
703 let r = Reply {
704 status: 409,
705 body: serde_json::json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
706 text: String::new(),
707 };
708 let m = r.describe_failure("POST /server/{uuid}/storage/attach");
709 assert!(m.contains("409 SERVER_STATE_ILLEGAL — server state is started"), "{m}");
710 let page = Reply { status: 502, body: Value::Null, text: "<html>bad gateway</html>".into() };
711 assert!(page.describe_failure("GET /x").contains("bad gateway"));
712 }
713}