1use crate::estate::{BootOrder, Estate, Label, Refusal, StorageKind};
75use crate::faults::{Fault, Faults};
76use crate::render;
77use serde_json::{json, Value};
78use std::sync::{Arc, Mutex};
79use tokio::io::{AsyncReadExt, AsyncWriteExt};
80use tokio::net::{TcpListener, TcpStream};
81
82pub struct Mock {
86 pub estate: Mutex<Estate>,
87 pub heard: Mutex<std::collections::BTreeMap<String, Heard>>,
95 pub calls: Option<Mutex<Vec<(u16, String, String, String)>>>,
99 pub log: bool,
111 #[cfg(feature = "test-inject")]
115 pub injections: Mutex<Vec<Injection>>,
116}
117
118impl Mock {
119 fn build(estate: Estate, calls: Option<Mutex<Vec<(u16, String, String, String)>>>, log: bool) -> Arc<Mock> {
120 Arc::new(Mock {
121 estate: Mutex::new(estate),
122 heard: Mutex::default(),
123 calls,
124 log,
125 #[cfg(feature = "test-inject")]
126 injections: Mutex::default(),
127 })
128 }
129
130 pub fn new(estate: Estate) -> Arc<Mock> {
131 Mock::build(estate, None, false)
132 }
133
134 pub fn recording(estate: Estate) -> Arc<Mock> {
136 Mock::build(estate, Some(Mutex::default()), false)
137 }
138
139 pub fn logging(estate: Estate) -> Arc<Mock> {
141 Mock::build(estate, None, true)
142 }
143}
144
145#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
147pub struct Heard {
148 pub requests: u64,
149 pub account_calls: u64,
150}
151
152impl Mock {
153 fn heard(&self, credential: &str, method: &str, path: &str) {
154 let key = crate::digest::sha256_hex(credential.as_bytes());
155 let mut h = self.heard.lock().unwrap();
156 let e = h.entry(key).or_default();
157 e.requests += 1;
158 if method == "GET" && path.trim_end_matches('/') == "/1.3/account" {
159 e.account_calls += 1;
160 }
161 }
162
163 pub fn heard_from(&self, digest: &str) -> Heard {
165 self.heard.lock().unwrap().get(digest).copied().unwrap_or_default()
166 }
167}
168
169pub async fn serve(mock: Arc<Mock>, addr: &str) -> std::io::Result<(u16, tokio::task::JoinHandle<()>)> {
172 let listener = TcpListener::bind(addr).await?;
173 let port = listener.local_addr()?.port();
174 mock.estate.lock().unwrap().upload_base = format!("http://127.0.0.1:{port}");
178 let h = tokio::spawn(async move {
179 loop {
180 let Ok((sock, _)) = listener.accept().await else { continue };
181 let _ = sock.set_nodelay(true);
190 let m = mock.clone();
191 tokio::spawn(async move {
192 let _ = connection(m, sock).await;
193 });
194 }
195 });
196 Ok((port, h))
197}
198
199struct Req {
202 method: String,
203 path: String,
204 query: Vec<(String, String)>,
205 authorized: bool,
206 body: Value,
207 raw: Vec<u8>,
210}
211
212pub(crate) enum Outcome {
213 Reply { status: u16, body: Value },
214 Reset,
218}
219
220fn reply(status: u16, body: Value) -> Outcome {
221 Outcome::Reply { status, body }
222}
223
224fn refuse(r: Refusal) -> Outcome {
225 reply(r.status, render::error(r.code, &r.message))
226}
227
228async fn connection(mock: Arc<Mock>, mut sock: TcpStream) -> std::io::Result<()> {
229 let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
230 loop {
231 let head_end = loop {
233 if let Some(i) = find_crlfcrlf(&buf) {
234 break i;
235 }
236 let mut chunk = [0u8; 4096];
237 let n = sock.read(&mut chunk).await?;
238 if n == 0 {
239 return Ok(());
240 }
241 buf.extend_from_slice(&chunk[..n]);
242 };
243 let head = String::from_utf8_lossy(&buf[..head_end]).to_string();
244 let mut lines = head.split("\r\n");
245 let Some(start) = lines.next() else { return Ok(()) };
246 let mut parts = start.split_whitespace();
247 let method = parts.next().unwrap_or("").to_string();
248 let target = parts.next().unwrap_or("/").to_string();
249
250 let mut content_length = 0usize;
251 let mut authorized = false;
252 let mut credential = String::new();
253 let mut chunked = false;
254 let mut keep_alive = true;
255 for l in lines {
256 let Some((k, v)) = l.split_once(':') else { continue };
257 let (k, v) = (k.trim().to_ascii_lowercase(), v.trim());
258 match k.as_str() {
259 "content-length" => content_length = v.parse().unwrap_or(0),
260 "authorization" => {
261 authorized = !v.is_empty();
262 credential = v.strip_prefix("Bearer ").unwrap_or(v).trim().to_string();
263 }
264 "transfer-encoding" => chunked = v.eq_ignore_ascii_case("chunked"),
265 "connection" => keep_alive = !v.eq_ignore_ascii_case("close"),
266 _ => {}
267 }
268 }
269
270 let body_start = head_end + 4;
271 while buf.len() < body_start + content_length {
272 let mut chunk = [0u8; 4096];
273 let n = sock.read(&mut chunk).await?;
274 if n == 0 {
275 return Ok(());
276 }
277 buf.extend_from_slice(&chunk[..n]);
278 }
279 let raw: Vec<u8> = buf[body_start..body_start + content_length].to_vec();
280 buf.drain(..body_start + content_length);
281
282 let outcome = if chunked && target.starts_with("/uploader/") {
283 reply(411, render::error("LENGTH_REQUIRED", "Content-Length is required"))
287 } else if chunked {
288 reply(
289 400,
290 render::error(
291 "MOCK_UPCLOUD_CHUNKED_REQUEST",
292 "the real API is never sent a chunked request by this estate, and the mock refuses to guess",
293 ),
294 )
295 } else {
296 answer(&mock, &method, &target, authorized.then_some(credential.as_str()), raw)
297 };
298
299 match outcome {
300 Outcome::Reset => return Ok(()),
301 Outcome::Reply { status, body } => {
302 let text = if body.is_null() { String::new() } else { body.to_string() };
303 let head = format!(
304 "HTTP/1.1 {status} {}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: {}\r\n\r\n",
305 reason(status),
306 if status == 403 && text.contains("correlation_id") {
307 "application/problem+json"
309 } else {
310 "application/json"
311 },
312 text.len(),
313 if keep_alive { "keep-alive" } else { "close" }
314 );
315 let mut out = Vec::with_capacity(head.len() + text.len());
318 out.extend_from_slice(head.as_bytes());
319 out.extend_from_slice(text.as_bytes());
320 sock.write_all(&out).await?;
321 sock.flush().await?;
322 if !keep_alive {
323 return Ok(());
324 }
325 }
326 }
327 }
328}
329
330fn reason(s: u16) -> &'static str {
331 match s {
332 200 => "OK",
333 201 => "Created",
334 202 => "Accepted",
335 204 => "No Content",
336 400 => "Bad Request",
337 401 => "Unauthorized",
338 402 => "Payment Required",
339 403 => "Forbidden",
340 404 => "Not Found",
341 409 => "Conflict",
342 411 => "Length Required",
343 412 => "Precondition Failed",
344 429 => "Too Many Requests",
345 502 => "Bad Gateway",
346 503 => "Service Unavailable",
347 511 => "Network Authentication Required",
348 _ => "Error",
349 }
350}
351
352fn find_crlfcrlf(b: &[u8]) -> Option<usize> {
353 b.windows(4).position(|w| w == b"\r\n\r\n")
354}
355
356fn split_target(t: &str) -> (String, Vec<(String, String)>) {
357 match t.split_once('?') {
358 None => (t.to_string(), vec![]),
359 Some((p, q)) => {
360 let params = q
361 .split('&')
362 .filter(|s| !s.is_empty())
363 .map(|kv| {
364 let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
365 (percent_decode(k), percent_decode(v))
366 })
367 .collect();
368 (p.to_string(), params)
369 }
370 }
371}
372
373fn percent_decode(s: &str) -> String {
376 let b = s.as_bytes();
377 let mut out = Vec::with_capacity(b.len());
378 let mut i = 0;
379 while i < b.len() {
380 match b[i] {
381 b'%' if i + 2 < b.len() => {
382 let h = (hex(b[i + 1]), hex(b[i + 2]));
383 if let (Some(a), Some(c)) = h {
384 out.push(a * 16 + c);
385 i += 3;
386 continue;
387 }
388 out.push(b[i]);
389 i += 1;
390 }
391 b'+' => {
392 out.push(b' ');
393 i += 1;
394 }
395 c => {
396 out.push(c);
397 i += 1;
398 }
399 }
400 }
401 String::from_utf8_lossy(&out).to_string()
402}
403
404fn hex(c: u8) -> Option<u8> {
405 match c {
406 b'0'..=b'9' => Some(c - b'0'),
407 b'a'..=b'f' => Some(c - b'a' + 10),
408 b'A'..=b'F' => Some(c - b'A' + 10),
409 _ => None,
410 }
411}
412
413fn route(mock: &Arc<Mock>, r: Req) -> Outcome {
416 if r.path.starts_with("/mock/") {
417 return mock_door(mock, &r);
418 }
419 if let Some(uuid) = r.path.strip_prefix("/uploader/session/") {
425 let uuid = uuid.to_string();
426 let mut e = mock.estate.lock().unwrap();
427 e.tick();
428 return match e.upload(&uuid, &r.raw) {
429 Err(x) => refuse(x),
430 Ok(im) => reply(200, render::import(&im)),
443 };
444 }
445 if !r.authorized {
446 return reply(
450 401,
451 render::error("AUTHENTICATION_FAILED", "no Authorization header was sent"),
452 );
453 }
454 let Some(rest) = r.path.strip_prefix("/1.3") else {
455 return reply(404, render::not_implemented(&r.method, &r.path));
456 };
457 if rest.contains("//") {
461 return reply(404, render::error("NOT_FOUND", "Not found."));
462 }
463 {
464 let e = mock.estate.lock().unwrap();
465 if e.faults.fires(Fault::DeadToken) {
468 return reply(401, render::error("AUTHENTICATION_FAILED", "Authentication failed using the given username and password."));
469 }
470 if r.method == "GET" && e.faults.fires(Fault::ReadBadGateway) {
472 return reply(502, render::error("BAD_GATEWAY", "Bad Gateway"));
473 }
474 }
475 let seg: Vec<&str> = rest.trim_matches('/').split('/').filter(|s| !s.is_empty()).collect();
476 let mut e = mock.estate.lock().unwrap();
477 e.tick();
478 let m = r.method.as_str();
479
480 match (m, seg.as_slice()) {
481 ("GET", ["price"]) => {
483 if e.faults.fires(Fault::PriceTransportReset) {
484 return Outcome::Reset;
485 }
486 let zone = e.zone.clone();
487 reply(200, render::price(&zone))
488 }
489
490 ("GET", ["account"]) => {
494 if e.faults.fires(Fault::RevokedCredential) {
495 let cid = e.next_correlation_id();
496 return reply(403, render::auth_failed(&cid));
497 }
498 reply(200, json!({"account": {"username": "mock", "credits": 100_000.0}}))
499 }
500
501 ("GET", ["zone"]) => reply(
502 200,
503 json!({"zones": {"zone": [{"id": e.zone, "description": "Stockholm #1", "public": "yes"}]}}),
504 ),
505
506 ("GET", ["server"]) => {
508 let labels = label_filters(&r.query);
509 let created = !e.faults.fires(Fault::WithholdCreatedField);
510 let rows: Vec<Value> = e
511 .servers_matching(&labels)
512 .into_iter()
513 .map(|s| render::server(s, false, created))
514 .collect();
515 reply(200, json!({"servers": {"server": rows}}))
516 }
517
518 ("GET", ["server", uuid]) => {
519 if e.faults.fires(Fault::RevokedCredential) {
520 let cid = e.next_correlation_id();
521 return reply(403, render::auth_failed(&cid));
522 }
523 let created = !e.faults.fires(Fault::WithholdCreatedField);
524 match e.server(uuid) {
525 Some(_) if e.faults.fires(Fault::DetailNotFoundForListedServer) => {
530 reply(404, render::error("SERVER_NOT_FOUND", &format!("server {uuid} not found")))
531 }
532 Some(s) => reply(200, json!({"server": render::server(s, true, created)})),
533 None => reply(404, render::error("SERVER_NOT_FOUND", &format!("server {uuid} not found"))),
534 }
535 }
536
537 ("GET", ["server", _uuid, "firewall_rule"]) | ("GET", ["server", _uuid, "firewall_rule", _]) => {
547 let uuid = seg[1];
548 if e.server(uuid).is_some() && !e.faults.fires(Fault::FirewallForbidden) {
549 let rules = e.rules(uuid).unwrap_or(&[]).to_vec();
550 if let Some(pos) = seg.get(3) {
552 return match rules.iter().find(|r| r.position == *pos) {
553 Some(r) => reply(200, json!({ "firewall_rule": crate::tf::render_rule(r) })),
554 None => reply(
555 404,
556 render::error("FIREWALL_RULE_NOT_FOUND", &format!("no rule at position {pos}")),
557 ),
558 };
559 }
560 return reply(200, crate::tf::render_rules(&rules));
561 }
562 let cid = e.next_correlation_id();
563 reply(403, render::auth_failed(&cid))
564 }
565
566 ("PUT", ["server", _uuid, "firewall_rule"]) | ("POST", ["server", _uuid, "firewall_rule"]) => {
578 let uuid = seg[1].to_string();
579 let mut rules = crate::tf::rules_from_body(&r.body);
580 if m == "POST" {
583 let mut existing = e.rules(&uuid).unwrap_or(&[]).to_vec();
584 existing.append(&mut rules);
585 rules = existing;
586 }
587 match e.set_rules(&uuid, rules) {
588 Err(x) => refuse(x),
589 Ok(()) => {
590 let rules = e.rules(&uuid).unwrap_or(&[]).to_vec();
591 reply(if m == "POST" { 201 } else { 200 }, crate::tf::render_rules(&rules))
592 }
593 }
594 }
595
596 ("DELETE", ["server", _uuid, "firewall_rule", _pos]) => {
597 let uuid = seg[1].to_string();
598 let pos = seg[3].to_string();
599 let kept: Vec<_> = e.rules(&uuid).unwrap_or(&[]).iter().filter(|r| r.position != pos).cloned().collect();
600 match e.set_rules(&uuid, kept) {
601 Err(x) => refuse(x),
602 Ok(()) => reply(204, Value::Null),
603 }
604 }
605
606 ("GET", ["plan"]) => reply(200, crate::tf::plans()),
610
611 ("POST", ["server"]) => {
612 let b = &r.body["server"];
613 let plan = b["plan"].as_str().unwrap_or("1xCPU-1GB").to_string();
614 if !render::plan_known(&plan) {
615 return refuse(Refusal::new(400, "INVALID_PLAN", format!("no such plan: {plan}")));
616 }
617 let title = b["title"].as_str().unwrap_or("").to_string();
618 let hostname = b["hostname"].as_str().unwrap_or(&title).to_string();
619 let zone = b["zone"].as_str().unwrap_or(&e.zone).to_string();
620 let labels = read_server_labels(&b["labels"]);
621 let devs: Vec<Value> = b["storage_devices"]["storage_device"].as_array().cloned().unwrap_or_default();
622 let dev = devs.first().cloned().unwrap_or(Value::Null);
623 let disk_title = dev["title"].as_str().unwrap_or("boot").to_string();
624 let disk_gib = dev["size"].as_u64().unwrap_or(20);
625 let mut attach: Vec<(String, String, Option<String>)> = Vec::new();
630 for d in devs.iter().skip(1) {
631 let Some(st) = d["storage"].as_str().map(str::to_string) else { continue };
632 let kind = if d["type"].as_str() == Some("cdrom") { "cdrom" } else { "disk" }.to_string();
633 match e.storage(&st) {
634 None => {
635 return refuse(Refusal::new(404, "STORAGE_NOT_FOUND", format!("storage {st} not found")))
636 }
637 Some(x) if x.state != "online" => {
638 return refuse(Refusal::new(
639 409,
640 "STORAGE_STATE_ILLEGAL",
641 format!("storage {st} is {} — wait for online", x.state),
642 ))
643 }
644 Some(_) => attach.push((st, kind, d["address"].as_str().map(str::to_string))),
645 }
646 }
647 match e.create_server(&title, &hostname, &plan, &zone, labels, &disk_title, disk_gib) {
648 Err(x) => refuse(x),
649 Ok(uuid) => {
650 let ifaces = crate::tf::interfaces_from_body(b);
661 let boot = b["boot_order"].as_str().and_then(BootOrder::parse);
662 let firewall_on = b["firewall"].as_str().unwrap_or("off") == "on";
663 let metadata = b["metadata"].as_str().unwrap_or("yes") != "no";
664 let tz = b["timezone"].as_str().unwrap_or("UTC").to_string();
665 if let Err(x) = e.configure_server(&uuid, ifaces, boot, firewall_on, metadata, &tz) {
666 return refuse(x);
667 }
668 for (st, kind, want) in &attach {
675 if let Err(x) = e.attach_at_create(&uuid, st, kind, want.as_deref()) {
676 return refuse(x);
677 }
678 }
679 if e.faults.fires(Fault::CommitThenDropReply) {
683 return Outcome::Reset;
684 }
685 let created = !e.faults.fires(Fault::WithholdCreatedField);
686 let s = e.server(&uuid).expect("just created");
687 reply(201, json!({"server": render::server(s, true, created)}))
688 }
689 }
690 }
691
692 ("PUT", ["server", uuid]) => {
693 let b = &r.body["server"];
694 let plan = b["plan"].as_str().map(str::to_string);
695 let bo = b["boot_order"].as_str().and_then(parse_boot_order);
696 let labels = if b["labels"].is_null() { None } else { Some(read_server_labels(&b["labels"])) };
697 let ra = b["remote_access_enabled"].as_str().map(|s| s == "yes");
699 let rap = b["remote_access_password"].as_str().map(str::to_string);
700 let rename = (b["hostname"].as_str().map(str::to_string), b["title"].as_str().map(str::to_string));
705 if let Some(p) = &plan {
706 if !render::plan_known(p) {
707 return refuse(Refusal::new(400, "INVALID_PLAN", format!("no such plan: {p}")));
708 }
709 }
710 match e.modify_server(uuid, plan.as_deref(), bo, labels, ra, rap.as_deref()) {
711 Err(x) => refuse(x),
712 Ok(()) => {
713 e.rename_server(uuid, rename.0.as_deref(), rename.1.as_deref());
714 let created = !e.faults.fires(Fault::WithholdCreatedField);
715 let s = e.server(uuid).expect("modified");
716 reply(202, json!({"server": render::server(s, true, created)}))
718 }
719 }
720 }
721
722 ("DELETE", ["server", uuid]) => {
723 let with_storages = r
724 .query
725 .iter()
726 .any(|(k, v)| k == "storages" && (v == "1" || v == "true"));
727 match e.delete_server(uuid, with_storages) {
728 Err(x) => refuse(x),
729 Ok(()) => reply(204, Value::Null),
733 }
734 }
735
736 ("POST", ["server", uuid, "start"]) => match e.start_server(uuid) {
737 Err(x) => refuse(x),
738 Ok(()) => {
739 let created = !e.faults.fires(Fault::WithholdCreatedField);
740 let s = e.server(uuid).expect("started");
741 reply(200, json!({"server": render::server(s, true, created)}))
742 }
743 },
744
745 ("POST", ["server", uuid, "stop"]) => {
746 if !r.body["stop_server"]["timeout_action"].is_null() {
750 return refuse(Refusal::new(
751 400,
752 "INVALID_STOP_SERVER",
753 "stop_server has no attribute timeout_action (it belongs to restart_server)",
754 ));
755 }
756 let hard = r.body["stop_server"]["stop_type"].as_str() == Some("hard");
757 match e.stop_server(uuid, hard) {
758 Err(x) => refuse(x),
759 Ok(()) => {
760 let created = !e.faults.fires(Fault::WithholdCreatedField);
761 let s = e.server(uuid).expect("stopping");
762 reply(200, json!({"server": render::server(s, true, created)}))
763 }
764 }
765 }
766
767 ("POST", ["server", uuid, "restart"]) => {
772 let uuid = uuid.to_string();
773 if let Err(x) = e.stop_server(&uuid, false) {
774 return refuse(x);
775 }
776 e.run_to_quiet();
777 match e.start_server(&uuid) {
778 Err(x) => refuse(x),
779 Ok(()) => reply(200, json!({"server": {"uuid": uuid}})),
780 }
781 }
782
783 ("POST", ["server", uuid, "storage", "attach"]) => {
784 let d = &r.body["storage_device"];
785 let storage = d["storage"].as_str().unwrap_or("").to_string();
786 let kind = d["type"].as_str().unwrap_or("disk").to_string();
787 let want = d["address"].as_str().map(str::to_string);
788 match e.attach_at(uuid, &storage, &kind, want.as_deref()) {
789 Err(x) => refuse(x),
790 Ok(_) => {
791 let created = !e.faults.fires(Fault::WithholdCreatedField);
792 let s = e.server(uuid).expect("attached");
793 reply(200, json!({"server": render::server(s, true, created)}))
794 }
795 }
796 }
797
798 ("POST", ["server", uuid, "storage", "detach"]) => {
799 let address = r.body["storage_device"]["address"].as_str().unwrap_or("").to_string();
800 match e.detach(uuid, &address) {
801 Err(x) => refuse(x),
802 Ok(()) => {
803 let created = !e.faults.fires(Fault::WithholdCreatedField);
804 let s = e.server(uuid).expect("detached");
805 reply(200, json!({"server": render::server(s, true, created)}))
806 }
807 }
808 }
809
810 ("POST", ["server", uuid, "cdrom", "eject"]) => match e.eject(uuid) {
811 Err(x) => refuse(x),
812 Ok(()) => {
813 let created = !e.faults.fires(Fault::WithholdCreatedField);
814 let s = e.server(uuid).expect("ejected");
815 reply(200, json!({"server": render::server(s, true, created)}))
816 }
817 },
818
819 ("POST", ["server", uuid, "cdrom", "load"]) => {
820 let storage = r.body["storage_device"]["storage"].as_str().unwrap_or("").to_string();
821 match e.load_cdrom(uuid, &storage) {
822 Err(x) => refuse(x),
823 Ok(()) => {
824 let created = !e.faults.fires(Fault::WithholdCreatedField);
825 let s = e.server(uuid).expect("loaded");
826 reply(200, json!({"server": render::server(s, true, created)}))
827 }
828 }
829 }
830
831 ("GET", ["storage"]) | ("GET", ["storage", "private"]) => {
836 let private_only = seg.len() == 2;
837 let labels = label_filters(&r.query);
838 let created = !e.faults.fires(Fault::WithholdCreatedField);
839 let rows: Vec<Value> = e
840 .storages_matching(&labels, private_only)
841 .into_iter()
842 .map(|s| render::storage(&e, s, false, created))
843 .collect();
844 reply(200, json!({"storages": {"storage": rows}}))
845 }
846
847 ("GET", ["storage", filter @ ("public" | "template" | "favorite")]) => {
855 let labels = label_filters(&r.query);
856 let created = !e.faults.fires(Fault::WithholdCreatedField);
857 let rows: Vec<Value> = e
858 .storages_matching(&labels, false)
859 .into_iter()
860 .filter(|s| match *filter {
861 "favorite" => false,
864 _ => s.kind == StorageKind::Template,
865 })
866 .map(|s| render::storage(&e, s, false, created))
867 .collect();
868 reply(200, json!({"storages": {"storage": rows}}))
869 }
870
871 ("GET", ["storage", uuid]) => {
872 if e.faults.fires(Fault::RevokedCredential) {
873 let cid = e.next_correlation_id();
874 return reply(403, render::auth_failed(&cid));
875 }
876 let created = !e.faults.fires(Fault::WithholdCreatedField);
877 match e.storage(uuid) {
878 Some(s) => reply(200, json!({"storage": render::storage(&e, s, true, created)})),
879 None => reply(404, render::error("STORAGE_NOT_FOUND", &format!("storage {uuid} not found"))),
880 }
881 }
882
883 ("POST", ["storage"]) => {
884 let b = &r.body["storage"];
885 let title = b["title"].as_str().unwrap_or("").to_string();
886 let size = b["size"].as_u64().or_else(|| b["size"].as_str().and_then(|s| s.parse().ok())).unwrap_or(0);
887 let tier = b["tier"].as_str().unwrap_or("maxiops").to_string();
888 let zone = b["zone"].as_str().unwrap_or(&e.zone).to_string();
889 let labels = read_flat_labels(&b["labels"]);
890 match e.create_storage(&title, size, &tier, &zone, labels) {
891 Err(x) => refuse(x),
892 Ok(uuid) => {
893 if e.faults.fires(Fault::CommitThenDropReply) {
894 return Outcome::Reset;
895 }
896 let created = !e.faults.fires(Fault::WithholdCreatedField);
897 let s = e.storage(&uuid).expect("just created");
898 reply(201, json!({"storage": render::storage(&e, s, true, created)}))
899 }
900 }
901 }
902
903 ("PUT", ["storage", uuid]) => {
904 let b = &r.body["storage"];
905 let size = b["size"].as_u64().or_else(|| b["size"].as_str().and_then(|s| s.parse().ok()));
906 let title = b["title"].as_str().map(str::to_string);
907 match e.modify_storage(uuid, size, title.as_deref()) {
908 Err(x) => refuse(x),
909 Ok(()) => {
910 let created = !e.faults.fires(Fault::WithholdCreatedField);
911 let s = e.storage(uuid).expect("modified");
912 reply(200, json!({"storage": render::storage(&e, s, true, created)}))
913 }
914 }
915 }
916
917 ("DELETE", ["storage", uuid]) => match e.delete_storage(uuid) {
918 Err(x) => refuse(x),
919 Ok(()) => reply(204, Value::Null),
920 },
921
922 ("POST", ["storage", uuid, "resize"]) => match e.resize_filesystem(uuid) {
927 Err(x) => refuse(x),
928 Ok(backup) => {
929 let created = !e.faults.fires(Fault::WithholdCreatedField);
930 let b = e.storage(&backup).expect("just minted");
931 reply(200, json!({"resize_backup": render::storage(&e, b, true, created)}))
932 }
933 },
934
935 ("POST", ["storage", uuid, "import"]) => {
940 let source = r.body["storage_import"]["source"].as_str().unwrap_or("direct_upload").to_string();
941 match e.start_import(uuid, &source) {
942 Err(x) => refuse(x),
943 Ok(im) => reply(201, json!({"storage_import": render::import(&im)})),
944 }
945 }
946
947 ("GET", ["storage", uuid, "import"]) => match e.storage(uuid).and_then(|s| s.import.as_ref()) {
948 None => reply(404, render::error("STORAGE_IMPORT_NOT_FOUND", &format!("no import session on {uuid}"))),
949 Some(im) => reply(200, json!({"storage_import": render::import(im)})),
950 },
951
952 ("POST", ["storage", uuid, "clone"]) => {
956 let title = r.body["storage"]["title"].as_str().unwrap_or("clone").to_string();
957 match e.clone_storage(uuid, &title) {
958 Err(x) => refuse(x),
959 Ok(new) => {
960 let created = !e.faults.fires(Fault::WithholdCreatedField);
961 let s = e.storage(&new).expect("just cloned");
962 reply(201, json!({"storage": render::storage(&e, s, true, created)}))
963 }
964 }
965 }
966
967 _ => reply(404, render::not_implemented(m, &r.path)),
968 }
969}
970
971pub fn probe(mock: &Arc<Mock>, method: &str, target: &str, body: Value) -> (u16, Value) {
988 let (path, query) = split_target(target);
989 let raw = if body.is_null() { vec![] } else { body.to_string().into_bytes() };
990 let r = Req { method: method.to_string(), path, query, authorized: true, body, raw };
991 match route(mock, r) {
992 Outcome::Reset => (0, Value::Null),
993 Outcome::Reply { status, body } => (status, body),
994 }
995}
996
997fn parse_boot_order(s: &str) -> Option<BootOrder> {
1000 if let Some(b) = BootOrder::parse(s) {
1003 return Some(b);
1004 }
1005 match s.split(',').next().map(str::trim) {
1006 Some("cdrom") => Some(BootOrder::Cdrom),
1007 Some("disk") => Some(BootOrder::Disk),
1008 _ => None,
1009 }
1010}
1011
1012fn label_filters(q: &[(String, String)]) -> Vec<(String, String)> {
1014 q.iter()
1015 .filter(|(k, _)| k == "label")
1016 .filter_map(|(_, v)| v.split_once('=').map(|(a, b)| (a.to_string(), b.to_string())))
1017 .collect()
1018}
1019
1020fn read_flat_labels(v: &Value) -> Vec<Label> {
1021 v.as_array()
1022 .map(|a| {
1023 a.iter()
1024 .filter_map(|l| {
1025 Some(Label {
1026 key: l["key"].as_str()?.to_string(),
1027 value: l["value"].as_str().unwrap_or("").to_string(),
1028 })
1029 })
1030 .collect()
1031 })
1032 .unwrap_or_default()
1033}
1034
1035fn read_server_labels(v: &Value) -> Vec<Label> {
1038 if v["label"].is_array() {
1039 read_flat_labels(&v["label"])
1040 } else {
1041 read_flat_labels(v)
1042 }
1043}
1044
1045fn mock_door(mock: &Arc<Mock>, r: &Req) -> Outcome {
1048 let seg: Vec<&str> = r.path.trim_matches('/').split('/').skip(1).collect();
1049 let mut e = mock.estate.lock().unwrap();
1050 match (r.method.as_str(), seg.as_slice()) {
1051 ("GET", ["seed"]) => reply(200, json!({"seed": e.seed(), "lays": e.lays(), "now_ms": e.clock.now_ms()})),
1052 ("GET", ["heard"]) => {
1057 let d = q(&r.query, "token_sha256");
1058 let h = mock.heard_from(&d);
1059 reply(200, json!({"token_sha256": d, "requests": h.requests, "account_calls": h.account_calls}))
1060 }
1061 ("GET", ["inbound", uuid]) => {
1063 e.settle();
1064 let port: u16 = q(&r.query, "port").parse().unwrap_or(22);
1065 let proto = { let p = q(&r.query, "proto"); if p.is_empty() { "tcp".to_string() } else { p } };
1066 match e.inbound(&q(&r.query, "from"), uuid, &proto, port) {
1067 Err(x) => reply(x.status, json!({"error": x.message})),
1068 Ok(reach) => reply(200, json!({"ok": reach.is_ok(), "why": reach.why()})),
1069 }
1070 }
1071 ("GET", ["udp-reply", uuid]) => {
1072 let port: u16 = q(&r.query, "port").parse().unwrap_or(53);
1073 match e.udp_reply_arrives(uuid, &q(&r.query, "from"), port) {
1074 Err(x) => reply(x.status, json!({"error": x.message})),
1075 Ok(yes) => reply(200, json!({"arrives": yes})),
1076 }
1077 }
1078 ("GET", ["disks", uuid]) => reply(
1080 200,
1081 json!({"disks": e.guest_disk_names(uuid).into_iter().map(|(a, n)| json!({"address": a, "name": n})).collect::<Vec<_>>()}),
1082 ),
1083 ("GET", ["estate"]) => {
1084 if !r.query.iter().any(|(k, _)| k == "as_is") {
1089 e.run_to_quiet();
1090 }
1091 let servers: Vec<Value> = e
1092 .all_servers()
1093 .map(|s| json!({"uuid": s.uuid, "title": s.title, "state": s.state, "plan": s.plan,
1094 "guest": format!("{:?}", s.guest), "public_ip": s.public_ip,
1095 "utility_ip": s.utility_ip, "vnc_port": s.vnc_port,
1096 "reported_vnc_port": s.reported_vnc_port, "boot_order": s.boot_order.as_str()}))
1097 .collect();
1098 let storages: Vec<Value> = e
1099 .all_storages()
1100 .filter(|s| s.kind != StorageKind::Template)
1101 .map(|s| json!({"uuid": s.uuid, "title": s.title, "state": s.state, "size": s.size_gib,
1102 "type": s.kind.as_str(), "origin": s.origin,
1103 "labels": s.labels.iter().map(|l| format!("{}={}", l.key, l.value)).collect::<Vec<_>>()}))
1104 .collect();
1105 reply(200, json!({"servers": servers, "storages": storages}))
1106 }
1107 ("POST", ["advance", ms]) => {
1110 let ms: u64 = ms.parse().unwrap_or(0);
1111 e.clock.advance_ms(ms);
1112 e.settle();
1113 reply(200, json!({"now_ms": e.clock.now_ms()}))
1114 }
1115 ("POST", ["fault", name, verb]) => match Fault::parse(name) {
1116 None => reply(404, json!({"error": format!("no such fault: {name}")})),
1117 Some(f) => {
1118 match *verb {
1119 "arm" => e.faults.arm(f),
1120 "disarm" => e.faults.disarm(f),
1121 _ => return reply(400, json!({"error": "arm or disarm"})),
1122 }
1123 reply(200, json!({"fault": f.name(), "armed": e.faults.is_armed(f)}))
1124 }
1125 },
1126 ("GET", ["clock", uuid]) => {
1131 e.settle();
1132 let Some(s) = e.server(uuid) else {
1133 return reply(404, json!({"error": format!("no such server: {uuid}")}));
1134 };
1135 let t = crate::guest_clock::days_from_civil(2026, 9, 20) * 86_400;
1137 let skew = s.clock_skew_ms(t);
1138 let reds = crate::guest_clock::cascade(skew, crate::guest_clock::SkewWindow::default());
1139 reply(
1140 200,
1141 json!({
1142 "uuid": s.uuid,
1143 "zone": s.zone,
1144 "hypervisor_rtc": "UTC",
1145 "guest_reads_rtc_as": format!("{:?}", s.rtc),
1146 "guest_clock_skew_ms": skew,
1147 "udp_reply_arrives": crate::guest_clock::udp_reply_arrives(&e.faults),
1148 "red": reds.iter().map(|r| json!({"row": r.row, "why": r.why})).collect::<Vec<_>>(),
1149 }),
1150 )
1151 }
1152 ("GET", ["reach", from]) => {
1156 e.settle();
1157 let dest = r.query.iter().find(|(k, _)| k == "dest").map(|(_, v)| v.clone()).unwrap_or_default();
1158 let port: u16 = r
1159 .query
1160 .iter()
1161 .find(|(k, _)| k == "port")
1162 .and_then(|(_, v)| v.parse().ok())
1163 .unwrap_or(22);
1164 match e.reach(from, &dest, port) {
1165 Err(x) => reply(x.status, json!({"error": x.message})),
1166 Ok(reach) => reply(
1167 200,
1168 json!({
1169 "from": from, "dest": dest, "port": port,
1170 "ok": reach.is_ok(),
1171 "why": reach.why(),
1172 "kind": match &reach {
1173 crate::net::Reach::Ok => "ok",
1174 crate::net::Reach::NoRouteOutbound { .. } => "no-route-outbound",
1175 crate::net::Reach::NoHairpin { .. } => "no-hairpin",
1176 crate::net::Reach::Refused { .. } => "refused",
1177 crate::net::Reach::Dropped { .. } => "dropped",
1178 },
1179 "inbound_ok": crate::net::inbound_reaches(true),
1183 }),
1184 ),
1185 }
1186 }
1187
1188 ("GET", ["dhcp", uuid]) => match e.server(uuid) {
1191 None => reply(404, json!({"error": format!("no such server: {uuid}")})),
1192 Some(s) => {
1193 let o = s.dhcp_offer();
1194 reply(
1195 200,
1196 json!({
1197 "address": o.address,
1198 "prefix": o.prefix,
1199 "router": o.router,
1200 "option_121": o.classless_static_routes.iter().map(|x| x.to_string()).collect::<Vec<_>>(),
1201 "guest_dhcp_client": format!("{:?}", s.dhcp_client),
1202 }),
1203 )
1204 }
1205 },
1206
1207 ("POST", ["dnat", on_server, port, to_address]) => {
1208 let port: u16 = port.parse().unwrap_or(0);
1209 e.dnat.push(crate::net::Dnat {
1210 on_server: on_server.to_string(),
1211 port,
1212 to_address: to_address.to_string(),
1213 to_port: port,
1214 });
1215 reply(200, json!({"rules": e.dnat.len()}))
1216 }
1217
1218 ("GET", ["hostkey", uuid]) => {
1223 e.settle();
1224 let mut keys = serde_json::Map::new();
1225 for p in crate::estate::HostKeyPath::ALL {
1226 match e.host_key_via(uuid, p) {
1227 Err(x) => return reply(x.status, json!({"error": x.message})),
1228 Ok(k) => {
1229 keys.insert(p.name().to_string(), json!(k));
1230 }
1231 }
1232 }
1233 let distinct: std::collections::BTreeSet<&str> =
1234 keys.values().filter_map(|v| v.as_str()).collect();
1235 reply(
1236 200,
1237 json!({
1238 "paths": keys,
1239 "agree": distinct.len() == 1,
1240 "verdict": if distinct.len() == 1 {
1241 "one key on three paths: this is the machine that was re-imaged"
1242 } else {
1243 "the paths disagree: a name is answering for a machine that is not behind the DNAT"
1244 },
1245 }),
1246 )
1247 }
1248
1249 ("GET", ["reimage", uuid]) => {
1251 let (uart, wall) = e.timings.reimage_observers(e.seed(), uuid);
1252 reply(
1253 200,
1254 json!({
1255 "guest_uart_ms": uart,
1256 "ladder_wall_ms": wall,
1257 "ratio": wall / uart.max(1),
1258 "note": "the installer is not slow; the provider is. create, media sync, firmware, boot order, DHCP.",
1259 "observers": {
1260 "guest_uart_ms": "the guest's own PID 1, from inside",
1261 "ladder_wall_ms": "the ladder's `install-time`, wall, from outside"
1262 }
1263 }),
1264 )
1265 }
1266
1267 ("GET", ["guest", uuid]) => {
1270 e.tick();
1271 match e.engine.evidence(uuid) {
1272 Some(v) => reply(200, json!({"engine": e.engine.name(), "guest": v})),
1273 None => reply(404, json!({"error": format!("engine {} has no machine for {uuid}", e.engine.name())})),
1274 }
1275 }
1276
1277 ("POST", ["relay"]) => {
1278 e.relay();
1279 reply(200, json!({"lays": e.lays()}))
1280 }
1281 ("POST", ["seed", s]) => {
1282 let seed: u64 = s.parse().unwrap_or(0);
1283 let speed = e.clock.speed_milli();
1284 let engine = e.engine.clone();
1287 engine.forget_all();
1288 *e = Estate::new(crate::Clock::new(speed), Faults::seeded(seed), seed).with_engine(engine);
1289 reply(200, json!({"seed": seed}))
1290 }
1291 _ => reply(404, json!({"error": format!("no such mock door: {}", r.path)})),
1292 }
1293}
1294
1295fn q(query: &[(String, String)], key: &str) -> String {
1297 query.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()).unwrap_or_default()
1298}
1299
1300pub(crate) fn answer(mock: &Arc<Mock>, method: &str, target: &str, credential: Option<&str>, raw: Vec<u8>) -> Outcome {
1313 let (path, query) = split_target(target);
1314 let body: Value = if raw.is_empty() { Value::Null } else { serde_json::from_slice(&raw).unwrap_or(Value::Null) };
1315 let said = format!("{method} {path}");
1316 let (m_rec, p_rec) = (method.to_string(), path.clone());
1317 let authorized = credential.is_some();
1318 if let Some(c) = credential {
1319 mock.heard(c, method, &path);
1320 }
1321 #[cfg(feature = "test-inject")]
1322 let injected = mock.injected(method, &path);
1323 #[cfg(not(feature = "test-inject"))]
1324 let injected: Option<Outcome> = None;
1325 let out = match injected {
1326 Some(o) => o,
1327 None => route(mock, Req { method: method.to_string(), path, query, authorized, body, raw }),
1328 };
1329 if let Some(calls) = &mock.calls {
1330 let (st, code) = match &out {
1331 Outcome::Reset => (0, String::new()),
1332 Outcome::Reply { status, body } => (*status, body["error"]["error_code"].as_str().unwrap_or("").to_string()),
1333 };
1334 calls.lock().unwrap().push((st, m_rec, p_rec, code));
1335 }
1336 if mock.log {
1337 let states = {
1341 let e = mock.estate.lock().unwrap();
1342 e.all_servers().map(|s| format!("{}={}", &s.uuid[..4], s.state)).collect::<Vec<_>>().join(" ")
1343 };
1344 let code = match &out {
1345 Outcome::Reset => 0,
1346 Outcome::Reply { status, .. } => *status,
1347 };
1348 eprintln!(" {code:>3} {said:<52} [{states}]");
1349 }
1350 out
1351}
1352
1353#[cfg(feature = "test-inject")]
1366pub struct Injection {
1367 pub method: String,
1369 pub matches: Box<dyn Fn(&str, &Estate) -> bool + Send + Sync>,
1372 pub status: u16,
1373 pub code: String,
1374 pub message: String,
1375 pub times: u32,
1377}
1378
1379#[cfg(feature = "test-inject")]
1380impl Mock {
1381 pub fn inject(&self, i: Injection) {
1383 self.injections.lock().unwrap().push(i);
1384 }
1385
1386 fn injected(&self, method: &str, path: &str) -> Option<Outcome> {
1387 let e = self.estate.lock().unwrap();
1388 let mut all = self.injections.lock().unwrap();
1389 let hit = all.iter_mut().find(|i| i.times > 0 && i.method == method && (i.matches)(path, &e))?;
1390 hit.times -= 1;
1391 Some(reply(hit.status, render::error(&hit.code, &hit.message)))
1392 }
1393}