crates/mock-scaleway/src/lib.rs
//! **`mock-scaleway` — the Scaleway Instance API v1, faithfully wrong.**
//!
//! A real HTTP server that answers, out of a state machine, the part of Scaleway's Instance
//! API v1 a cloud guest calls. Point the Scaleway guest's `api_base` at it and it lays,
//! changes, boots, fences and destroys an estate without an account. Nothing here reaches the
//! network. It sits beside `mock-upcloud` and reuses that crate's machine seam
//! ([`mock_upcloud::kvm::GuestEngine`]) and container engine
//! ([`mock_upcloud::container::ContainerEngine`]): with `--engine container` every running
//! server is a rootless podman container with its `cloud-init` user data as its pairing seed.
//!
//! # Routes (base `http://127.0.0.1:P`, every one under `/instance/v1/zones/{zone}`, auth `X-Auth-Token`)
//!
//! | route | what |
//! |---|---|
//! | `GET /products/servers`, `GET /products/servers/availability`, `GET /products/volumes` | the catalog: `hourly_price`/`monthly_price` (EUR, floats), `ncpus`, `ram` (bytes); availability; volume types (`hourly_price_per_gb` is the MOCK's field, see [`catalog`]) |
//! | `GET/POST /servers`, `GET/PATCH/DELETE /servers/{id}` | instances; list filters `tags` (ALL of them), `project`, `name`, paging `page`/`per_page` (≤ 100, default 50) with `X-Total-Count` |
//! | `POST /servers/{id}/action` | `poweron`, `poweroff`, `stop_in_place`, `reboot`, `terminate`, `backup` → `{"task": …}` |
//! | `GET /servers/{id}/user_data`, `GET/PATCH/DELETE /servers/{id}/user_data/{key}` | user data, `text/plain` only (`cloud-init` is the seed) |
//! | `GET/POST /volumes`, `GET/PATCH/DELETE /volumes/{id}` | volumes (`size` in bytes; `base_snapshot` restores one) |
//! | `GET/POST /snapshots`, `GET/DELETE /snapshots/{id}` | snapshots: of a volume (`volume_id`) or an import (`bucket` + `key`, from the mock's object store) |
//! | `GET/POST /images`, `GET/DELETE /images/{id}` | images over snapshots; the marketplace `ubuntu_noble` is there in every zone |
//! | `GET/POST /security_groups`, `GET/PATCH/DELETE /security_groups/{id}`, `GET/PUT /security_groups/{id}/rules` | security groups and their rule sets |
//! | `GET/POST /ips`, `GET/PATCH/DELETE /ips/{id}` | flexible IPs |
//! | `GET /tasks/{id}` | an action's task |
//! | `/mock/state`, `/mock/arm/{fault}`, `/mock/disarm/{fault}`, `/mock/revoke/{token}`, `/mock/advance/{ms}`, `PUT /mock/objects/{bucket}/{key}` | the MOCK's own doors (no auth): never the provider's |
//!
//! Object storage itself (S3) is NOT served; a snapshot import reads the mock's own object
//! store, filled through `PUT /mock/objects/…` or [`Mock::put_object`].
//!
//! # The behaviours (the traps a guest must survive)
//!
//! | # | behaviour | where |
//! |---|---|---|
//! | 1 | a server is created `stopped`; `poweron` is `starting` for 30 s, then `running` | `Account::action`, [`account::Timings`] |
//! | 2 | an action on a server in transition answers `409 transient_state` | `Account::action` |
//! | 3 | `412 out_of_stock` on `poweron`, sticky for days (fr-par-1, 2026-09-09) | [`Fault::OutOfStock`] |
//! | 4 | a commercial type changes only while `stopped`: `400 invalid_request_error` | `Account::patch_server` |
//! | 5 | a REVOKED key lists 0 servers (200) and is refused `403 permissions_denied` elsewhere, never a clean 401 (the IAM change of 2026-08-26) | [`Mock::revoke`] |
//! | 6 | `terminate` deletes the server, its volumes and its dynamic IP — and KEEPS its snapshots, its images and its flexible IPs, which go on being billed | `Account::remove_server` |
//! | 7 | `DELETE /servers/{id}` needs `stopped` and leaves the volumes behind, detached | the DELETE route |
//! | 8 | a snapshot that is an image's volume cannot be deleted (`412 resource_still_in_use`); deleting the IMAGE keeps its snapshots | the snapshot and image routes |
//! | 9 | an attached volume, or a group a server uses, cannot be deleted (`412`) | the volume and group routes |
//! | 10 | lists are newest first and paged at 50 by default | `Account::handle` |
//! | 11 | a new server has a dynamic public IP unless `dynamic_ip_required: false` | `Account::create_server` |
//! | 12 | user data must be `text/plain` | the user-data route |
//! | 13 | a 404 is `{"type": "unknown_resource", "resource": …, "resource_id": …}` | `account::not_found` |
//!
//! # In-process
//!
//! ```no_run
//! let mock = mock_scaleway::Mock::start(); // virtual clock, loopback, any port
//! let base = mock.base_url(); // http://127.0.0.1:P
//! let key = mock.token(); // the one secret key it accepts
//! mock.arm(mock_scaleway::Fault::OutOfStock(None));
//! assert_eq!(mock.servers().len(), 0);
//! ```
pub mod account;
pub mod catalog;
pub mod http;
use std::sync::{Arc, Mutex, OnceLock};
pub use account::{Account, Answer, Fault, Timings, ZONES};
pub use http::{call, serve, Reply};
pub use mock_upcloud::Clock;
/// The secret key a mock started by [`Mock::start`] accepts.
pub const TOKEN: &str = "scw-mock-secret-key-0000";
/// **The mock**: the account behind a lock, and the port once it is served.
pub struct Mock {
account: Mutex<Account>,
port: OnceLock<u16>,
}
impl Mock {
/// An account on `clock`, accepting [`TOKEN`].
pub fn new(clock: Clock) -> Arc<Mock> {
let mut a = Account::new(Arc::new(clock));
a.tokens = vec![TOKEN.to_string()];
Arc::new(Mock {
account: Mutex::new(a),
port: OnceLock::new(),
})
}
/// The account, as it is set up before it is served (an engine, loopback publics…).
pub fn with_account(clock: Clock, set: impl FnOnce(&mut Account)) -> Arc<Mock> {
let m = Mock::new(clock);
m.with(set);
m
}
/// **Started**: a virtual clock, served on `127.0.0.1:<any>`.
pub fn start() -> Arc<Mock> {
let m = Mock::new(Clock::virtual_only());
m.serve_on("127.0.0.1:0").expect("a loopback port");
m
}
/// Serve this mock on `addr`; the port.
pub fn serve_on(self: &Arc<Mock>, addr: &str) -> std::io::Result<u16> {
let p = serve(self.clone(), addr)?;
let _ = self.port.set(p);
Ok(p)
}
/// `http://127.0.0.1:P` — what a guest's `api_base` is set to.
pub fn base_url(&self) -> String {
format!("http://127.0.0.1:{}", self.port.get().copied().unwrap_or(0))
}
/// The secret key it accepts.
pub fn token(&self) -> String {
self.with(|a| a.tokens.first().cloned().unwrap_or_default())
}
/// The account's default project (what a guest's `project` setting is).
pub fn project(&self) -> String {
self.with(|a| a.project.clone())
}
/// Run `f` on the account, under its lock (every state reader is this).
pub fn with<T>(&self, f: impl FnOnce(&mut Account) -> T) -> T {
let mut a = self.account.lock().unwrap_or_else(|p| p.into_inner());
a.settle();
f(&mut a)
}
pub fn arm(&self, f: Fault) {
self.with(|a| a.faults.push(f));
}
pub fn disarm(&self, f: &Fault) {
self.with(|a| a.faults.retain(|x| x != f));
}
/// Revoke `token` (behaviour 5).
pub fn revoke(&self, token: &str) {
self.with(|a| a.revoked.push(token.to_string()));
}
/// Revoke the key [`Mock::token`] answers.
pub fn revoke_token(&self) {
let t = self.token();
self.revoke(&t);
}
/// Put an object a snapshot import can read (`bucket` + `key`).
pub fn put_object(&self, bucket: &str, key: &str, bytes: &[u8]) {
self.with(|a| {
a.objects
.insert((bucket.to_string(), key.to_string()), bytes.to_vec())
});
}
/// Move the virtual clock (and settle what came due).
pub fn advance_ms(&self, ms: u64) {
self.with(|a| {
a.clock.advance_ms(ms);
a.settle();
});
}
/// A snapshot of `volume_id`, as `POST /snapshots` makes one (a backup to be found at
/// destroy); its id. `None`: no such volume.
pub fn snapshot_of(&self, volume_id: &str, tags: &[&str]) -> Option<String> {
self.with(|a| {
let v = a.volumes.get(volume_id)?.clone();
let body = serde_json::json!({ "name": format!("{}-snap", v.name), "volume_id": v.id, "project": v.project, "tags": tags });
let tok = a.tokens.first().cloned().unwrap_or_else(|| TOKEN.to_string());
match a.handle("POST", &format!("/instance/v1/zones/{}/snapshots", v.zone), &[], Some(&tok), Some("application/json"), &serde_json::to_vec(&body).unwrap()) {
Answer::Json { body, .. } => body["snapshot"]["id"].as_str().map(str::to_string),
_ => None,
}
})
}
fn section(&self, key: &str) -> Vec<serde_json::Value> {
self.with(|a| a.state_json()[key].as_array().cloned().unwrap_or_default())
}
/// Every server, as the API renders it.
pub fn servers(&self) -> Vec<serde_json::Value> {
self.section("servers")
}
pub fn volumes(&self) -> Vec<serde_json::Value> {
self.section("volumes")
}
/// The account's own snapshots (the marketplace's are not listed).
pub fn snapshots(&self) -> Vec<serde_json::Value> {
self.section("snapshots")
}
/// The account's own images.
pub fn images(&self) -> Vec<serde_json::Value> {
self.section("images")
}
pub fn security_groups(&self) -> Vec<serde_json::Value> {
self.section("security_groups")
}
/// Every IP, the dynamic ones included.
pub fn ips(&self) -> Vec<serde_json::Value> {
self.section("ips")
}
/// The whole account as one document.
pub fn state(&self) -> serde_json::Value {
self.with(|a| a.state_json())
}
/// Every request heard, `METHOD path`, in order.
pub fn heard(&self) -> Vec<String> {
self.with(|a| a.heard.clone())
}
}