1use std::path::Path;
46
47use serde_json::Value;
48
49pub mod guard;
50pub mod net;
51mod wire;
52
53pub use wire::{connect, Credential, Options};
54
55const ACCOUNT_BASE: &str = "https://api.upcloud.com/1.3";
59
60pub const ACCOUNT_BASE_FOR_DISPLAY: &str = ACCOUNT_BASE;
63
64pub const MOCK_BASE_ENV: &str = "UPCLOUD_API_BASE";
67
68pub const TF_MOCK_BASE_ENV: &str = "UPCLOUD_DEBUG_API_BASE_URL";
71
72pub const MOCK_ENVS: &[&str] = &[MOCK_BASE_ENV, TF_MOCK_BASE_ENV];
74
75#[derive(Debug, Clone, PartialEq, Eq)]
80pub enum Endpoint {
81 Account,
83 Mock(MockBase),
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct MockBase(String);
92
93impl MockBase {
94 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97}
98
99impl Endpoint {
100 pub fn account() -> Result<Endpoint, String> {
105 Endpoint::account_given(|k| std::env::var(k).ok())
106 }
107
108 pub fn account_given(env: impl Fn(&str) -> Option<String>) -> Result<Endpoint, String> {
110 let set: Vec<&str> = MOCK_ENVS.iter().copied().filter(|k| env(k).map(|v| !v.trim().is_empty()).unwrap_or(false)).collect();
111 if set.is_empty() {
112 return Ok(Endpoint::Account);
113 }
114 Err(format!(
115 "REFUSED [mock-variable-on-an-account-run] this shell carries {s}, which means it was set up to talk to \
116 a FAKE UpCloud — and this run was selected to talk to THE ACCOUNT. One of the two is wrong and this \
117 process will not guess which. Select the mock explicitly (the verb's `mock` word, or `--mock-api \
118 <loopback base>`), or unset {s} to use the account.",
119 s = set.join(" and ")
120 ))
121 }
122
123 pub fn mock(base: &str) -> Result<Endpoint, String> {
127 let mut b = base.trim().trim_end_matches('/').to_string();
128 if !is_loopback(&b) {
129 return Err(format!(
130 "REFUSED [mock-base-not-loopback] {b:?} does not name loopback (http://127.0.0.1:PORT or \
131 http://localhost:PORT). mock-upcloud binds 127.0.0.1 and nothing else, so nothing off this \
132 machine can be one."
133 ));
134 }
135 if !b.ends_with("/1.3") {
136 b.push_str("/1.3");
137 }
138 Ok(Endpoint::Mock(MockBase(b)))
139 }
140
141 pub fn mock_from_env() -> Result<Endpoint, String> {
144 let raw = std::env::var(MOCK_BASE_ENV).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()).ok_or_else(|| {
145 format!(
146 "REFUSED [no-mock-base] the run says use the fake and {MOCK_BASE_ENV} is not set, so there is no \
147 fake to use. Start one — `mock-upcloud --port 8099 --speed 0` — and export \
148 {MOCK_BASE_ENV}=http://127.0.0.1:8099."
149 )
150 })?;
151 Endpoint::mock(&raw)
152 }
153
154 pub fn is_account(&self) -> bool {
155 matches!(self, Endpoint::Account)
156 }
157
158 pub fn base_for_display(&self) -> &str {
161 match self {
162 Endpoint::Account => ACCOUNT_BASE,
163 Endpoint::Mock(b) => b.as_str(),
164 }
165 }
166
167 pub fn banner(&self) -> String {
170 match self {
171 Endpoint::Account => format!("provider: THE ACCOUNT — {ACCOUNT_BASE}. Real machines, a real bill."),
172 Endpoint::Mock(b) => format!(
173 "provider: MOCK_UPCLOUD at {} — a FAKE. Nothing here is a machine, nothing here is a bill, and \
174 nothing measured here says anything about the account.",
175 b.as_str()
176 ),
177 }
178 }
179
180 pub fn child_args(&self) -> Vec<String> {
186 match self {
187 Endpoint::Account => Vec::new(),
188 Endpoint::Mock(b) => vec![MOCK_API_FLAG.to_string(), b.as_str().to_string()],
189 }
190 }
191}
192
193pub const MOCK_API_FLAG: &str = "--mock-api";
195
196impl Endpoint {
197 pub fn from_flag(mock_api: Option<&str>) -> Result<Endpoint, String> {
201 match mock_api {
202 Some(b) => Endpoint::mock(b),
203 None => Endpoint::account(),
204 }
205 }
206}
207
208pub fn is_loopback(url: &str) -> bool {
211 let host = url.strip_prefix("http://").unwrap_or("").split('/').next().unwrap_or("").split(':').next().unwrap_or("");
212 host == "127.0.0.1" || host == "localhost"
213}
214
215#[derive(Debug, Clone)]
222pub struct Reply {
223 pub status: u16,
224 pub body: Value,
226 pub text: String,
230}
231
232impl Reply {
233 pub fn ok(&self) -> bool {
234 (200..300).contains(&self.status)
235 }
236
237 pub fn error_code(&self) -> &str {
239 self.body.pointer("/error/error_code").and_then(Value::as_str).unwrap_or("")
240 }
241
242 pub fn error_message(&self) -> &str {
244 self.body.pointer("/error/error_message").and_then(Value::as_str).unwrap_or_else(|| self.text.trim())
245 }
246
247 pub fn describe_failure(&self, what: &str) -> String {
250 let code = self.error_code();
251 if code.is_empty() {
252 format!("{what} answered {} — {}", self.status, self.error_message())
253 } else {
254 format!("{what} answered {} {code} — {}", self.status, self.error_message())
255 }
256 }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
264pub enum Stop {
265 Soft { timeout_s: u32 },
266 Hard,
267}
268
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum WithStorages {
273 AndTheirBackups,
276 AndKeepBackups,
279 LeaveThem,
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum Backups {
286 Unsaid,
289 Keep,
291 Delete,
293}
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum DeviceKind {
298 Cdrom,
300 Disk,
302}
303
304impl DeviceKind {
305 pub fn as_str(self) -> &'static str {
306 match self {
307 DeviceKind::Cdrom => "cdrom",
308 DeviceKind::Disk => "disk",
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
316pub enum BootOrder {
317 Cdrom,
318 Disk,
319}
320
321impl BootOrder {
322 pub fn as_str(self) -> &'static str {
323 match self {
324 BootOrder::Cdrom => "cdrom",
325 BootOrder::Disk => "disk",
326 }
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
332pub enum Console<'a> {
333 Off,
334 Vnc { password: &'a str },
335}
336
337pub type Label<'a> = (&'a str, &'a str);
340
341#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct NewStorage<'a> {
344 pub title: &'a str,
345 pub zone: &'a str,
346 pub size_gib: u64,
347 pub tier: &'a str,
349 pub labels: &'a [Label<'a>],
351}
352
353pub trait UpCloudApi {
358 fn describe(&self) -> String;
361
362 fn is_the_account(&self) -> bool;
365
366 fn account(&self) -> Result<Reply, String>;
370 fn price(&self) -> Result<Reply, String>;
372 fn servers(&self) -> Result<Reply, String>;
375 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
378 fn server(&self, uuid: &str) -> Result<Reply, String>;
380 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String>;
382 fn storages_private(&self) -> Result<Reply, String>;
385 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String>;
388 fn storage(&self, uuid: &str) -> Result<Reply, String>;
391 fn zones(&self) -> Result<Reply, String>;
393 fn plans(&self) -> Result<Reply, String>;
395
396 fn create_server(&self, document: &Value) -> Result<Reply, String>;
401 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String>;
403 fn start_server(&self, uuid: &str) -> Result<Reply, String>;
405 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String>;
408 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String>;
410 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String>;
413 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String>;
417 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String>;
421 fn eject_cdrom(&self, server: &str) -> Result<Reply, String>;
424 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String>;
426
427 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String>;
431 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String>;
433 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String>;
437 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String>;
443 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String>;
446 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String>;
449 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String>;
451}
452
453macro_rules! forward {
456 ($($ty:tt)*) => {
457 impl<T: UpCloudApi + ?Sized> UpCloudApi for $($ty)* {
458 fn describe(&self) -> String { (**self).describe() }
459 fn is_the_account(&self) -> bool { (**self).is_the_account() }
460 fn account(&self) -> Result<Reply, String> { (**self).account() }
461 fn price(&self) -> Result<Reply, String> { (**self).price() }
462 fn servers(&self) -> Result<Reply, String> { (**self).servers() }
463 fn servers_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).servers_labelled(labels) }
464 fn server(&self, uuid: &str) -> Result<Reply, String> { (**self).server(uuid) }
465 fn firewall_rules(&self, uuid: &str) -> Result<Reply, String> { (**self).firewall_rules(uuid) }
466 fn storages_private(&self) -> Result<Reply, String> { (**self).storages_private() }
467 fn storages_labelled(&self, labels: &[Label<'_>]) -> Result<Reply, String> { (**self).storages_labelled(labels) }
468 fn storage(&self, uuid: &str) -> Result<Reply, String> { (**self).storage(uuid) }
469 fn zones(&self) -> Result<Reply, String> { (**self).zones() }
470 fn plans(&self) -> Result<Reply, String> { (**self).plans() }
471 fn create_server(&self, document: &Value) -> Result<Reply, String> { (**self).create_server(document) }
472 fn stop_server(&self, uuid: &str, stop: Stop) -> Result<Reply, String> { (**self).stop_server(uuid, stop) }
473 fn start_server(&self, uuid: &str) -> Result<Reply, String> { (**self).start_server(uuid) }
474 fn modify_server_plan(&self, uuid: &str, plan: &str) -> Result<Reply, String> { (**self).modify_server_plan(uuid, plan) }
475 fn set_boot_order(&self, uuid: &str, order: BootOrder) -> Result<Reply, String> { (**self).set_boot_order(uuid, order) }
476 fn set_console(&self, uuid: &str, console: Console<'_>) -> Result<Reply, String> { (**self).set_console(uuid, console) }
477 fn attach_storage(&self, server: &str, kind: DeviceKind, storage: &str, at: Option<&str>) -> Result<Reply, String> { (**self).attach_storage(server, kind, storage, at) }
478 fn detach_storage(&self, server: &str, address: &str) -> Result<Reply, String> { (**self).detach_storage(server, address) }
479 fn eject_cdrom(&self, server: &str) -> Result<Reply, String> { (**self).eject_cdrom(server) }
480 fn delete_server(&self, uuid: &str, with: WithStorages) -> Result<Reply, String> { (**self).delete_server(uuid, with) }
481 fn create_storage(&self, new: &NewStorage<'_>) -> Result<Reply, String> { (**self).create_storage(new) }
482 fn clone_storage(&self, uuid: &str, title: &str, zone: &str, tier: &str) -> Result<Reply, String> { (**self).clone_storage(uuid, title, zone, tier) }
483 fn import_direct_upload(&self, uuid: &str) -> Result<Reply, String> { (**self).import_direct_upload(uuid) }
484 fn upload_direct(&self, url: &str, file: &Path) -> Result<Reply, String> { (**self).upload_direct(url, file) }
485 fn modify_storage_size(&self, uuid: &str, gb: u64) -> Result<Reply, String> { (**self).modify_storage_size(uuid, gb) }
486 fn resize_filesystem(&self, uuid: &str) -> Result<Reply, String> { (**self).resize_filesystem(uuid) }
487 fn delete_storage(&self, uuid: &str, backups: Backups) -> Result<Reply, String> { (**self).delete_storage(uuid, backups) }
488 }
489 };
490}
491forward!(&T);
492forward!(Box<T>);
493
494pub fn delete_server_query(with: WithStorages) -> &'static str {
497 match with {
498 WithStorages::AndTheirBackups => "?storages=1&backups=delete",
499 WithStorages::AndKeepBackups => "?storages=1&backups=keep",
500 WithStorages::LeaveThem => "",
501 }
502}
503
504pub fn delete_storage_query(backups: Backups) -> &'static str {
506 match backups {
507 Backups::Unsaid => "",
508 Backups::Keep => "?backups=keep",
509 Backups::Delete => "?backups=delete",
510 }
511}
512
513pub fn label_query(labels: &[Label<'_>]) -> String {
516 fn enc(s: &str, out: &mut String) {
517 for b in s.bytes() {
518 if b.is_ascii_alphanumeric() || b"-._~".contains(&b) {
519 out.push(b as char);
520 } else {
521 out.push_str(&format!("%{b:02X}"));
522 }
523 }
524 }
525 let mut q = String::new();
526 for (k, v) in labels {
527 q.push(if q.is_empty() { '?' } else { '&' });
528 q.push_str("label=");
529 enc(&format!("{k}={v}"), &mut q);
530 }
531 q
532}
533
534pub mod body {
539 use super::{BootOrder, Console, DeviceKind, NewStorage, Stop};
540 use serde_json::{json, Value};
541
542 pub fn stop(stop: Stop) -> Value {
545 match stop {
546 Stop::Soft { timeout_s } => json!({"stop_server": {"stop_type": "soft", "timeout": timeout_s.to_string()}}),
547 Stop::Hard => json!({"stop_server": {"stop_type": "hard"}}),
548 }
549 }
550 pub fn storage_size(gb: u64) -> Value {
551 json!({"storage": {"size": gb.to_string()}})
552 }
553 pub fn server_plan(plan: &str) -> Value {
554 json!({"server": {"plan": plan}})
555 }
556 pub fn boot_order(order: BootOrder) -> Value {
557 json!({"server": {"boot_order": order.as_str()}})
558 }
559 pub fn console(c: &Console<'_>) -> Value {
560 match c {
561 Console::Off => json!({"server": {"remote_access_enabled": "no"}}),
562 Console::Vnc { password } => json!({"server": {
563 "remote_access_enabled": "yes",
564 "remote_access_type": "vnc",
565 "remote_access_password": password,
566 }}),
567 }
568 }
569 pub fn attach(kind: DeviceKind, storage: &str, at: Option<&str>) -> Value {
570 match at {
571 None => json!({"storage_device": {"type": kind.as_str(), "storage": storage}}),
572 Some(a) => json!({"storage_device": {"type": kind.as_str(), "address": a, "storage": storage}}),
573 }
574 }
575 pub fn detach(address: &str) -> Value {
577 json!({"storage_device": {"address": address}})
578 }
579 pub fn create_storage(n: &NewStorage<'_>) -> Value {
580 let mut v = json!({"storage": {"size": n.size_gib, "tier": n.tier, "title": n.title, "zone": n.zone}});
581 if !n.labels.is_empty() {
582 v["storage"]["labels"] = json!(n.labels.iter().map(|(k, v)| json!({"key": k, "value": v})).collect::<Vec<_>>());
583 }
584 v
585 }
586 pub fn clone_storage(title: &str, zone: &str, tier: &str) -> Value {
588 json!({"storage": {"tier": tier, "title": title, "zone": zone}})
589 }
590 pub fn direct_upload() -> Value {
591 json!({"storage_import": {"source": "direct_upload"}})
592 }
593}
594
595pub fn redact_upload_url(url: &str) -> String {
599 match url.find("/session/") {
600 Some(i) => format!("{}/session/…", &url[..i]),
601 None => match url.rfind('/') {
602 Some(i) => format!("{}/…", &url[..i]),
603 None => "…".to_string(),
604 },
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611
612 #[test]
613 fn a_mock_endpoint_is_loopback_or_nothing() {
614 assert!(Endpoint::mock("http://127.0.0.1:8099").is_ok());
615 assert!(Endpoint::mock("http://localhost:8099/1.3/").is_ok());
616 for bad in ["https://api.upcloud.com/1.3", "http://10.13.0.247:8099", "http://[::1]:8099", "api.upcloud.com", ""] {
617 let e = Endpoint::mock(bad).unwrap_err();
618 assert!(e.contains("mock-base-not-loopback"), "{bad}: {e}");
619 }
620 }
621
622 #[test]
623 fn both_spellings_of_a_mock_base_reach_the_same_door() {
624 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap(), Endpoint::mock("http://127.0.0.1:8099/1.3").unwrap());
625 assert_eq!(Endpoint::mock("http://127.0.0.1:8099").unwrap().base_for_display(), "http://127.0.0.1:8099/1.3");
626 }
627
628 #[test]
632 fn an_account_run_refuses_in_a_shell_that_carries_a_mock_variable() {
633 for k in MOCK_ENVS {
634 let e = Endpoint::account_given(|q| (q == *k).then(|| "http://127.0.0.1:8099".to_string())).unwrap_err();
635 assert!(e.contains("mock-variable-on-an-account-run") && e.contains(k), "{e}");
636 }
637 assert_eq!(Endpoint::account_given(|_| None).unwrap(), Endpoint::Account);
638 assert_eq!(Endpoint::account_given(|_| Some(" ".into())).unwrap(), Endpoint::Account);
640 }
641
642 #[test]
643 fn the_choice_crosses_a_process_boundary_as_argv_and_only_as_argv() {
644 let m = Endpoint::mock("http://127.0.0.1:8099").unwrap();
645 let args = m.child_args();
646 assert_eq!(args, vec![MOCK_API_FLAG.to_string(), "http://127.0.0.1:8099/1.3".to_string()]);
647 assert_eq!(Endpoint::from_flag(Some(&args[1])).unwrap(), m);
648 assert!(Endpoint::Account.child_args().is_empty());
649 assert!(Endpoint::from_flag(Some("https://api.upcloud.com/1.3")).is_err(), "the flag cannot name the account");
650 }
651
652 #[test]
653 fn a_banner_for_a_fake_never_reads_as_a_measurement_of_the_account() {
654 let b = Endpoint::mock("http://127.0.0.1:8099").unwrap().banner();
655 assert!(b.contains("FAKE") && !b.contains("api.upcloud.com"), "{b}");
656 assert!(Endpoint::Account.banner().contains("a real bill"));
657 }
658
659 #[test]
660 fn the_stop_timeout_goes_out_as_a_string() {
661 let b = body::stop(Stop::Soft { timeout_s: 60 });
662 assert_eq!(b["stop_server"]["timeout"], serde_json::json!("60"));
663 assert_eq!(body::stop(Stop::Hard)["stop_server"]["stop_type"], serde_json::json!("hard"));
664 }
665
666 #[test]
667 fn a_label_filter_is_one_encoded_pair_per_label() {
668 assert_eq!(label_query(&[]), "");
669 assert_eq!(label_query(&[("monetize_ref", "abc")]), "?label=monetize_ref%3Dabc");
670 assert_eq!(label_query(&[("a", "b c"), ("k", "x&y")]), "?label=a%3Db%20c&label=k%3Dx%26y");
671 }
672
673 #[test]
674 fn a_delete_says_what_happens_to_backups_in_one_spelling() {
675 assert_eq!(delete_server_query(WithStorages::AndTheirBackups), "?storages=1&backups=delete");
676 assert_eq!(delete_server_query(WithStorages::AndKeepBackups), "?storages=1&backups=keep");
677 assert_eq!(delete_server_query(WithStorages::LeaveThem), "");
678 assert_eq!(delete_storage_query(Backups::Unsaid), "");
679 assert_eq!(delete_storage_query(Backups::Keep), "?backups=keep");
680 }
681
682 #[test]
683 fn an_attach_names_an_address_only_when_asked() {
684 assert!(body::attach(DeviceKind::Cdrom, "s", None)["storage_device"].get("address").is_none());
685 assert_eq!(body::attach(DeviceKind::Disk, "s", Some("virtio"))["storage_device"]["address"], serde_json::json!("virtio"));
686 let n = NewStorage { title: "t", zone: "z", size_gib: 1, tier: "maxiops", labels: &[] };
687 assert!(body::create_storage(&n)["storage"].get("labels").is_none(), "no labels, no key");
688 }
689
690 #[test]
691 fn an_upload_session_is_never_printed_whole() {
692 let r = redact_upload_url("https://fi-hel1.img.upcloud.com/uploader/session/9f2b3cSECRET");
693 assert!(!r.contains("SECRET") && r.starts_with("https://fi-hel1.img.upcloud.com/uploader/session"), "{r}");
694 }
695
696 #[test]
697 fn a_failure_names_the_api_error_code() {
698 let r = Reply {
699 status: 409,
700 body: serde_json::json!({"error":{"error_code":"SERVER_STATE_ILLEGAL","error_message":"server state is started"}}),
701 text: String::new(),
702 };
703 let m = r.describe_failure("POST /server/{uuid}/storage/attach");
704 assert!(m.contains("409 SERVER_STATE_ILLEGAL — server state is started"), "{m}");
705 let page = Reply { status: 502, body: Value::Null, text: "<html>bad gateway</html>".into() };
706 assert!(page.describe_failure("GET /x").contains("bad gateway"));
707 }
708}