crates/mock-scaleway/src/account.rs
//! **The account as a state machine**: every route of the Instance API v1 the mock serves, as
//! one function of `(method, path, query, body)` over the account's records.
//!
//! Transitions are lazy: an action writes the state it starts (`starting`, `stopping`) and the
//! virtual time it ends at; [`Account::settle`] applies every transition whose time has come at
//! the start of each request. On a virtual clock (`--speed 0`) every request also moves the
//! clock by [`Account::step_ms`], so a client that polls sees the transition close after a few
//! polls, as it does against the provider.
use std::collections::BTreeMap;
use std::sync::Arc;
use mock_upcloud::kvm::{GuestEngine, Machine, MachineDisk, INSTALLER_PUBLIC_PORTS};
use mock_upcloud::Clock;
use serde_json::{json, Value};
use crate::catalog;
/// The zones the mock answers (Paris 1 and 2, Amsterdam 1, Warsaw 1).
pub const ZONES: &[&str] = &["fr-par-1", "fr-par-2", "nl-ams-1", "pl-waw-1"];
/// How long a transition takes on the provider (virtual ms).
#[derive(Clone, Copy, Debug)]
pub struct Timings {
pub poweron_ms: u64,
pub poweroff_ms: u64,
pub reboot_ms: u64,
pub terminate_ms: u64,
pub snapshot_ms: u64,
}
impl Default for Timings {
fn default() -> Self {
Timings {
poweron_ms: 30_000,
poweroff_ms: 15_000,
reboot_ms: 40_000,
terminate_ms: 15_000,
snapshot_ms: 20_000,
}
}
}
/// **The armable faults.** Each is a behaviour the provider HAS; armed, it is sticky until
/// disarmed, as the provider's are.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Fault {
/// Behaviour 3: `412 out_of_stock` on `poweron` (and `shortage` in the availability list)
/// for every commercial type, or only for the one named. Verified against fr-par-1 on
/// 2026-09-09: it lasted days.
OutOfStock(Option<String>),
}
impl Fault {
pub fn parse(name: &str, commercial_type: Option<&str>) -> Option<Fault> {
match name {
"out-of-stock" | "out_of_stock" => {
Some(Fault::OutOfStock(commercial_type.map(str::to_string)))
}
_ => None,
}
}
pub fn name(&self) -> &'static str {
match self {
Fault::OutOfStock(_) => "out-of-stock",
}
}
}
/// What a route answers.
#[derive(Debug, Clone, PartialEq)]
pub enum Answer {
Json {
status: u16,
body: Value,
total: Option<usize>,
},
Text {
status: u16,
body: Vec<u8>,
},
Empty {
status: u16,
},
}
fn ok(status: u16, body: Value) -> Answer {
Answer::Json {
status,
body,
total: None,
}
}
/// Scaleway's error body: `{"type": …, "message": …}` plus the type's own fields.
pub fn err(status: u16, kind: &str, message: &str) -> Answer {
ok(status, json!({ "type": kind, "message": message }))
}
fn not_found(resource: &str, id: &str) -> Answer {
ok(
404,
json!({ "type": "unknown_resource", "message": format!("\"{resource}\" not found"), "resource": resource, "resource_id": id }),
)
}
fn invalid_argument(arg: &str, help: &str) -> Answer {
ok(
400,
json!({ "type": "invalid_arguments", "message": "invalid argument(s)", "details": [{ "argument_name": arg, "reason": "constraint", "help_message": help }] }),
)
}
fn invalid_request(message: &str) -> Answer {
err(400, "invalid_request_error", message)
}
fn still_in_use(resource: &str, id: &str, help: &str) -> Answer {
ok(
412,
json!({ "type": "precondition_failed", "precondition": "resource_still_in_use", "message": "resource is still in use", "help_message": help, "resource": resource, "resource_id": id }),
)
}
fn transient(resource: &str, id: &str, state: &str) -> Answer {
ok(
409,
json!({ "type": "transient_state", "message": format!("resource is in a transient state: {state}"), "resource": resource, "resource_id": id, "current_state": state }),
)
}
fn permissions_denied(resource: &str, action: &str) -> Answer {
ok(
403,
json!({ "type": "permissions_denied", "message": "insufficient permissions", "details": [{ "resource": resource, "action": action }] }),
)
}
#[derive(Clone, Debug)]
pub struct Server {
pub id: String,
pub zone: String,
pub name: String,
pub project: String,
pub commercial_type: String,
pub state: String,
/// `(state it becomes, at virtual ms)`; `"gone"` = deleted (terminate).
pub pending: Option<(String, u64)>,
pub tags: Vec<String>,
/// Index → volume id.
pub volumes: BTreeMap<String, String>,
pub image: Option<String>,
pub security_group: String,
/// The DYNAMIC ip's address (released with the server), when it has one.
pub dynamic_ip: Option<String>,
pub private_ip: Option<String>,
pub boot_type: String,
pub created_ms: u64,
pub modified_ms: u64,
pub user_data: BTreeMap<String, Vec<u8>>,
}
#[derive(Clone, Debug)]
pub struct Volume {
pub id: String,
pub zone: String,
pub name: String,
pub project: String,
pub volume_type: String,
pub size: u64,
pub tags: Vec<String>,
pub server: Option<String>,
pub created_ms: u64,
}
#[derive(Clone, Debug)]
pub struct Snapshot {
pub id: String,
pub zone: String,
pub name: String,
pub project: String,
pub volume_type: String,
pub size: u64,
pub tags: Vec<String>,
pub base_volume: Option<(String, String)>,
pub state: String,
pub ready_ms: u64,
pub created_ms: u64,
}
#[derive(Clone, Debug)]
pub struct Image {
pub id: String,
pub zone: String,
pub name: String,
pub project: String,
pub root_volume: String,
pub extra_volumes: BTreeMap<String, String>,
pub tags: Vec<String>,
pub public: bool,
pub from_server: Option<String>,
pub created_ms: u64,
}
#[derive(Clone, Debug)]
pub struct Rule {
pub id: String,
pub action: String,
pub protocol: String,
pub direction: String,
pub ip_range: String,
pub dest_port_from: Option<u16>,
pub dest_port_to: Option<u16>,
pub position: u32,
pub editable: bool,
}
#[derive(Clone, Debug)]
pub struct SecurityGroup {
pub id: String,
pub zone: String,
pub name: String,
pub project: String,
pub inbound_default_policy: String,
pub outbound_default_policy: String,
pub stateful: bool,
pub project_default: bool,
pub tags: Vec<String>,
pub rules: Vec<Rule>,
pub created_ms: u64,
}
#[derive(Clone, Debug)]
pub struct Ip {
pub id: String,
pub zone: String,
pub address: String,
pub project: String,
pub tags: Vec<String>,
pub server: Option<String>,
/// A dynamic ip lives and dies with its server and is not listed under `/ips`.
pub dynamic: bool,
}
#[derive(Clone, Debug)]
pub struct Task {
pub id: String,
pub zone: String,
pub description: String,
pub href_from: String,
pub href_result: String,
pub done_ms: u64,
pub started_ms: u64,
}
/// **The account.**
pub struct Account {
pub clock: Arc<Clock>,
pub timings: Timings,
/// Virtual ms each request moves a virtual clock (`speed 0`); 0 = the clock is the caller's.
pub step_ms: u64,
pub organization: String,
pub project: String,
/// The secret keys the account accepts. Empty: any non-empty key.
pub tokens: Vec<String>,
/// Keys that WERE good and were revoked (behaviour 5).
pub revoked: Vec<String>,
pub faults: Vec<Fault>,
pub servers: BTreeMap<String, Server>,
pub volumes: BTreeMap<String, Volume>,
pub snapshots: BTreeMap<String, Snapshot>,
pub images: BTreeMap<String, Image>,
pub security_groups: BTreeMap<String, SecurityGroup>,
pub ips: BTreeMap<String, Ip>,
pub tasks: BTreeMap<String, Task>,
/// The mock's object store: what a snapshot IMPORT (`bucket` + `key`) reads.
pub objects: BTreeMap<(String, String), Vec<u8>>,
pub engine: Option<Arc<dyn GuestEngine>>,
/// Public addresses on loopback (`127.A.B.n`), so an engine can serve the installer's
/// ports on the address the API reports. `None`: TEST-NET 2, reached by nobody.
pub public_on_loopback: Option<(u8, u8)>,
/// The utility `X.Y` a container engine's network is on (`X.Y.8.97…`).
pub utility: String,
counter: u64,
addr_counter: u32,
/// Every request, in order (`METHOD path`), for a test's reading.
pub heard: Vec<String>,
}
fn now_iso(ms: u64) -> String {
// The account's epoch: 2026-10-24T00:00:00Z plus the virtual clock.
const BASE_S: i64 = 1_792_800_000;
let secs = BASE_S + (ms / 1000) as i64;
let micros = (ms % 1000) * 1000;
let days = secs.div_euclid(86_400);
let rem = secs.rem_euclid(86_400);
// Howard Hinnant's civil_from_days.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{micros:06}+00:00",
rem / 3600,
(rem % 3600) / 60,
rem % 60
)
}
fn tags_of(v: &Value) -> Vec<String> {
v.as_array()
.map(|a| {
a.iter()
.filter_map(|t| t.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
fn has_all(tags: &[String], want: &[String]) -> bool {
want.iter().all(|w| tags.contains(w))
}
/// Does `cidr` (`a.b.c.d/n`) hold `ip`?
pub fn cidr_holds(cidr: &str, ip: &str) -> bool {
let (net, bits) = cidr.split_once('/').unwrap_or((cidr, "32"));
let (Ok(net), Ok(ip), Ok(bits)) = (
net.parse::<std::net::Ipv4Addr>(),
ip.parse::<std::net::Ipv4Addr>(),
bits.parse::<u32>(),
) else {
return false;
};
if bits == 0 {
return true;
}
let mask = if bits >= 32 {
u32::MAX
} else {
!(u32::MAX >> bits)
};
(u32::from(net) & mask) == (u32::from(ip) & mask)
}
/// **What a security group admits** of the installer's public ports from `source`: the rules
/// in `position` order, first match wins, else the inbound default policy.
pub fn admitted(sg: &SecurityGroup, source: &str) -> Vec<(&'static str, u16)> {
let mut rules: Vec<&Rule> = sg
.rules
.iter()
.filter(|r| r.direction == "inbound")
.collect();
rules.sort_by_key(|r| r.position);
INSTALLER_PUBLIC_PORTS
.iter()
.copied()
.filter(|(proto, port)| {
let hit = rules.iter().find(|r| {
let p = r.protocol.as_str();
let proto_ok = p == "ANY" || p.eq_ignore_ascii_case(proto);
let from = r.dest_port_from.unwrap_or(0);
let to = r.dest_port_to.or(r.dest_port_from).unwrap_or(u16::MAX);
let port_ok = r.dest_port_from.is_none() || (from..=to).contains(port);
proto_ok && port_ok && cidr_holds(&r.ip_range, source)
});
match hit {
Some(r) => r.action == "accept",
None => sg.inbound_default_policy == "accept",
}
})
.collect()
}
impl Account {
pub fn new(clock: Arc<Clock>) -> Account {
let mut a = Account {
step_ms: if clock.speed_milli() == 0 { 5_000 } else { 0 },
clock,
timings: Timings::default(),
organization: "0rg00000-0000-4000-8000-000000000001".into(),
project: "pr0jec70-0000-4000-8000-000000000001".into(),
tokens: Vec::new(),
revoked: Vec::new(),
faults: Vec::new(),
servers: BTreeMap::new(),
volumes: BTreeMap::new(),
snapshots: BTreeMap::new(),
images: BTreeMap::new(),
security_groups: BTreeMap::new(),
ips: BTreeMap::new(),
tasks: BTreeMap::new(),
objects: BTreeMap::new(),
engine: None,
public_on_loopback: None,
utility: mock_upcloud::container::MOCK_UTILITY.into(),
counter: 0,
addr_counter: 0,
heard: Vec::new(),
};
// The public marketplace image every zone has (by label and by id).
for z in ZONES {
let snap = a.new_id("5a");
let now = 0;
a.snapshots.insert(
snap.clone(),
Snapshot {
id: snap.clone(),
zone: z.to_string(),
name: "ubuntu_noble root".into(),
project: "marketplace".into(),
volume_type: "l_ssd".into(),
size: 10 * catalog::GB,
tags: vec![],
base_volume: None,
state: "available".into(),
ready_ms: 0,
created_ms: now,
},
);
let img = a.new_id("1a");
a.images.insert(
img.clone(),
Image {
id: img,
zone: z.to_string(),
name: "Ubuntu 24.04 Noble Numbat".into(),
project: "marketplace".into(),
root_volume: snap,
extra_volumes: BTreeMap::new(),
tags: vec!["ubuntu_noble".into()],
public: true,
from_server: None,
created_ms: now,
},
);
}
a
}
fn now(&self) -> u64 {
self.clock.now_ms()
}
/// A fresh UUIDv4-shaped id; `kind` is the first two hex digits, so a reader can tell kinds apart.
fn new_id(&mut self, kind: &str) -> String {
self.counter += 1;
format!(
"{kind}{:06x}-0000-4000-8000-{:012x}",
self.counter, self.counter
)
}
fn new_public_address(&mut self) -> String {
self.addr_counter += 1;
let n = self.addr_counter;
match self.public_on_loopback {
Some((a, b)) => format!("127.{a}.{b}.{}", 10 + n),
None => format!("51.15.{}.{}", n / 250, 1 + n % 250),
}
}
pub fn out_of_stock(&self, commercial_type: &str) -> bool {
self.faults.iter().any(|f| matches!(f, Fault::OutOfStock(t) if t.as_deref().is_none_or(|t| t == commercial_type)))
}
/// The account's default security group in `zone`, made on first need, as Scaleway does.
fn default_sg(&mut self, zone: &str, project: &str) -> String {
if let Some(sg) = self
.security_groups
.values()
.find(|g| g.zone == zone && g.project == project && g.project_default)
{
return sg.id.clone();
}
let id = self.new_id("56");
let now = self.now();
self.security_groups.insert(
id.clone(),
SecurityGroup {
id: id.clone(),
zone: zone.into(),
name: "Default security group".into(),
project: project.into(),
inbound_default_policy: "accept".into(),
outbound_default_policy: "accept".into(),
stateful: true,
project_default: true,
tags: vec![],
rules: vec![],
created_ms: now,
},
);
id
}
// ── time ────────────────────────────────────────────────────────────────
/// Apply every transition whose time has come.
pub fn settle(&mut self) {
let now = self.now();
let due: Vec<(String, String)> = self
.servers
.values()
.filter_map(|s| {
s.pending
.as_ref()
.filter(|(_, at)| *at <= now)
.map(|(st, _)| (s.id.clone(), st.clone()))
})
.collect();
for (id, st) in due {
if st == "gone" {
self.remove_server(&id, true);
continue;
}
if let Some(s) = self.servers.get_mut(&id) {
s.state = st;
s.pending = None;
s.modified_ms = now;
}
}
for s in self.snapshots.values_mut() {
if s.state == "snapshotting" && s.ready_ms <= now {
s.state = "available".into();
}
}
// A machine that died under a running server stops it (the engine's word stands).
if let Some(e) = &self.engine {
for s in self.servers.values_mut() {
if s.state == "running" && e.running(&s.id) == Some(false) {
s.state = "stopped".into();
}
}
}
}
// ── rendering ───────────────────────────────────────────────────────────
fn volume_json(&self, v: &Volume) -> Value {
let server = v
.server
.as_ref()
.and_then(|id| self.servers.get(id))
.map(|s| json!({ "id": s.id, "name": s.name }));
json!({
"id": v.id, "name": v.name, "export_uri": null, "size": v.size, "volume_type": v.volume_type,
"creation_date": now_iso(v.created_ms), "modification_date": now_iso(v.created_ms),
"organization": self.organization, "project": v.project, "tags": v.tags,
"server": server, "state": if v.server.is_some() { "in_use" } else { "available" }, "zone": v.zone,
})
}
fn ip_json(&self, ip: &Ip) -> Value {
let server = ip
.server
.as_ref()
.and_then(|id| self.servers.get(id))
.map(|s| json!({ "id": s.id, "name": s.name }));
json!({
"id": ip.id, "address": ip.address, "reverse": null, "server": server, "organization": self.organization,
"project": ip.project, "zone": ip.zone, "type": "routed_ipv4", "state": if ip.server.is_some() { "attached" } else { "detached" },
"tags": ip.tags, "prefix": null, "ipam_id": null,
})
}
fn server_ips(&self, id: &str) -> Vec<&Ip> {
self.ips
.values()
.filter(|i| i.server.as_deref() == Some(id))
.collect()
}
pub fn server_json(&self, s: &Server) -> Value {
let mut vols = serde_json::Map::new();
for (k, vid) in &s.volumes {
if let Some(v) = self.volumes.get(vid) {
let mut j = self.volume_json(v);
j["boot"] = json!(k == "0");
vols.insert(k.clone(), j);
}
}
let ips = self.server_ips(&s.id);
let public_ip = ips.first().map(|i| json!({ "id": i.id, "address": i.address, "dynamic": i.dynamic, "family": "inet", "provisioning_mode": "dhcp", "state": "attached", "tags": i.tags }));
let (cpus, ram) = catalog::server_row(&s.commercial_type).unwrap_or((1, 1));
let sg = self
.security_groups
.get(&s.security_group)
.map(|g| json!({ "id": g.id, "name": g.name }));
let image = s
.image
.as_ref()
.and_then(|i| self.images.get(i))
.map(|i| json!({ "id": i.id, "name": i.name, "arch": "x86_64" }));
let allowed: &[&str] = match s.state.as_str() {
"running" => &["poweroff", "terminate", "reboot", "stop_in_place", "backup"],
"stopped" => &["poweron", "backup"],
"stopped in place" => &["poweron", "backup", "terminate"],
_ => &[],
};
json!({
"id": s.id, "name": s.name, "hostname": s.name, "organization": self.organization, "project": s.project,
"commercial_type": s.commercial_type, "arch": "x86_64", "state": s.state, "state_detail": "",
"tags": s.tags, "volumes": vols, "image": image, "protected": false, "dynamic_ip_required": s.dynamic_ip.is_some(),
"public_ip": public_ip, "public_ips": ips.iter().map(|i| json!({ "id": i.id, "address": i.address, "dynamic": i.dynamic })).collect::<Vec<_>>(),
"private_ip": s.private_ip, "security_group": sg, "boot_type": s.boot_type, "allowed_actions": allowed,
"creation_date": now_iso(s.created_ms), "modification_date": now_iso(s.modified_ms), "zone": s.zone,
"location": { "zone_id": s.zone, "cluster_id": "1", "hypervisor_id": "1", "node_id": "1", "platform_id": "14" },
"ipv6": null, "maintenances": [], "placement_group": null, "private_nics": [], "routed_ip_enabled": true,
"_ncpus": cpus, "_ram": ram * catalog::GIB,
})
}
fn snapshot_json(&self, s: &Snapshot) -> Value {
json!({
"id": s.id, "name": s.name, "organization": self.organization, "project": s.project, "tags": s.tags,
"volume_type": s.volume_type, "size": s.size, "state": s.state,
"base_volume": s.base_volume.as_ref().map(|(id, name)| json!({ "id": id, "name": name })),
"creation_date": now_iso(s.created_ms), "modification_date": now_iso(s.created_ms), "zone": s.zone, "error_reason": null,
})
}
fn image_json(&self, i: &Image) -> Value {
let snap = |id: &str| {
self.snapshots.get(id).map(|s| json!({ "id": s.id, "name": s.name, "size": s.size, "volume_type": s.volume_type }))
};
let extra: serde_json::Map<String, Value> = i
.extra_volumes
.iter()
.filter_map(|(k, v)| snap(v).map(|j| (k.clone(), j)))
.collect();
json!({
"id": i.id, "name": i.name, "arch": "x86_64", "organization": self.organization, "project": i.project, "tags": i.tags,
"root_volume": snap(&i.root_volume), "extra_volumes": extra, "public": i.public, "state": "available",
"from_server": i.from_server, "creation_date": now_iso(i.created_ms), "modification_date": now_iso(i.created_ms), "zone": i.zone,
})
}
fn sg_json(&self, g: &SecurityGroup) -> Value {
let servers: Vec<Value> = self
.servers
.values()
.filter(|s| s.security_group == g.id)
.map(|s| json!({ "id": s.id, "name": s.name }))
.collect();
json!({
"id": g.id, "name": g.name, "description": "", "organization": self.organization, "project": g.project, "tags": g.tags,
"inbound_default_policy": g.inbound_default_policy, "outbound_default_policy": g.outbound_default_policy,
"stateful": g.stateful, "project_default": g.project_default, "organization_default": false, "enable_default_security": true,
"servers": servers, "state": "available", "creation_date": now_iso(g.created_ms), "modification_date": now_iso(g.created_ms), "zone": g.zone,
})
}
fn rule_json(r: &Rule, zone: &str) -> Value {
json!({
"id": r.id, "action": r.action, "protocol": r.protocol, "direction": r.direction, "ip_range": r.ip_range,
"dest_port_from": r.dest_port_from, "dest_port_to": r.dest_port_to, "position": r.position, "editable": r.editable, "zone": zone,
})
}
fn task(
&mut self,
zone: &str,
description: &str,
href_from: String,
href_result: String,
takes_ms: u64,
) -> Value {
let id = self.new_id("7a");
let now = self.now();
let t = Task {
id: id.clone(),
zone: zone.into(),
description: description.into(),
href_from,
href_result,
done_ms: now + takes_ms,
started_ms: now,
};
let j = self.task_json(&t);
self.tasks.insert(id, t);
j
}
fn task_json(&self, t: &Task) -> Value {
let done = self.now() >= t.done_ms;
json!({
"id": t.id, "description": t.description, "status": if done { "success" } else { "pending" }, "progress": if done { 100 } else { 0 },
"href_from": t.href_from, "href_result": t.href_result, "started_at": now_iso(t.started_ms),
"terminated_at": if done { Value::String(now_iso(t.done_ms)) } else { Value::Null }, "zone": t.zone,
})
}
// ── the machine seam ────────────────────────────────────────────────────
fn machine_of(&self, s: &Server) -> Machine {
let (cores, ram) = catalog::server_row(&s.commercial_type).unwrap_or((1, 1));
let public_ip = self
.server_ips(&s.id)
.first()
.map(|i| i.address.clone())
.unwrap_or_default();
let admitted = self
.security_groups
.get(&s.security_group)
.map(|g| admitted(g, mock_upcloud::kvm::OPERATOR_SOURCE))
.unwrap_or_default();
Machine {
server: s.id.clone(),
mem_mb: (ram * 1024) as u32,
cores,
disks: s
.volumes
.values()
.filter_map(|v| self.volumes.get(v))
.map(|v| MachineDisk {
storage: v.id.clone(),
size_gib: v.size / catalog::GIB,
template: None,
})
.collect(),
medium: None,
cdrom_first: false,
disk_first: false,
rtc_skew_seconds: 0,
utility_mac: format!(
"02:00:{}",
s.id.chars()
.filter(|c| c.is_ascii_hexdigit())
.rev()
.take(8)
.collect::<String>()
.as_bytes()
.chunks(2)
.map(|c| String::from_utf8_lossy(c).into_owned())
.collect::<Vec<_>>()
.join(":")
),
utility_offer: mock_upcloud::net::DhcpOffer::for_address(
s.private_ip.as_deref().unwrap_or(""),
),
dhcp_late_first_boot_ms: 0,
hostname: s.name.clone(),
ssh_keys: Vec::new(),
user_data: s
.user_data
.get("cloud-init")
.map(|b| String::from_utf8_lossy(b).into_owned()),
public_ip,
public_admitted: admitted,
}
}
fn tell_firewall(&self, sg: &str) {
let Some(e) = &self.engine else { return };
let Some(g) = self.security_groups.get(sg) else {
return;
};
let ports = admitted(g, mock_upcloud::kvm::OPERATOR_SOURCE);
for s in self.servers.values().filter(|s| s.security_group == sg) {
let _ = e.firewall(&s.id, &ports);
}
}
fn remove_server(&mut self, id: &str, with_volumes: bool) {
let Some(s) = self.servers.remove(id) else {
return;
};
if let Some(e) = &self.engine {
e.forget_server(id);
}
for vid in s.volumes.values() {
if with_volumes {
self.volumes.remove(vid);
if let Some(e) = &self.engine {
e.forget_storage(vid);
}
} else if let Some(v) = self.volumes.get_mut(vid) {
v.server = None;
}
}
// The dynamic ip goes with the server; a flexible ip stays, detached (and billed).
self.ips
.retain(|_, i| !(i.dynamic && i.server.as_deref() == Some(id)));
for i in self.ips.values_mut() {
if i.server.as_deref() == Some(id) {
i.server = None;
}
}
}
// ── the router ──────────────────────────────────────────────────────────
/// One request. `token`: the `X-Auth-Token` header's value; `content_type`: the body's.
pub fn handle(
&mut self,
method: &str,
path: &str,
query: &[(String, String)],
token: Option<&str>,
content_type: Option<&str>,
body: &[u8],
) -> Answer {
self.heard.push(format!("{method} {path}"));
if self.step_ms > 0 {
self.clock.advance_ms(self.step_ms);
}
self.settle();
let seg: Vec<&str> = path.trim_matches('/').split('/').collect();
if seg.first() == Some(&"mock") {
return self.mock_door(method, &seg[1..], query, body);
}
// Auth.
let token = token.unwrap_or("").trim();
let revoked = !token.is_empty() && self.revoked.iter().any(|t| t == token);
if token.is_empty()
|| (!revoked && !self.tokens.is_empty() && !self.tokens.iter().any(|t| t == token))
{
return ok(
401,
json!({ "type": "denied_authentication", "message": "authentication is denied", "method": "api_key", "reason": "invalid_argument" }),
);
}
let ["instance", "v1", "zones", zone, rest @ ..] = seg.as_slice() else {
return not_found("api_route", path);
};
if !ZONES.contains(zone) {
return invalid_argument("zone", &format!("zone must be one of {}", ZONES.join(", ")));
}
let zone = zone.to_string();
let jbody: Value =
if body.is_empty() || content_type.is_some_and(|c| c.starts_with("text/plain")) {
Value::Null
} else {
match serde_json::from_slice(body) {
Ok(v) => v,
Err(e) => return invalid_request(&format!("the body is not JSON: {e}")),
}
};
// Behaviour 5: a revoked key lists NO servers (200, empty) and is refused 403 on other
// endpoints. Never a clean 401 (verified against Scaleway after the IAM change of 2026-08-26).
if revoked {
return match (method, rest) {
("GET", ["servers"]) => Answer::Json {
status: 200,
body: json!({ "servers": [] }),
total: Some(0),
},
(_, [kind, ..]) => permissions_denied(
&format!("instance_{}", kind.trim_end_matches('s')),
if method == "GET" { "read" } else { "write" },
),
_ => permissions_denied("instance", "read"),
};
}
let project_q = query
.iter()
.find(|(k, _)| k == "project")
.map(|(_, v)| v.clone());
let tags_q: Vec<String> = query
.iter()
.find(|(k, _)| k == "tags")
.map(|(_, v)| {
v.split(',')
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
let name_q = query
.iter()
.find(|(k, _)| k == "name")
.map(|(_, v)| v.clone());
let page = |items: Vec<Value>, key: &str| -> Answer {
let per_page = query
.iter()
.find(|(k, _)| k == "per_page")
.and_then(|(_, v)| v.parse::<usize>().ok())
.unwrap_or(50);
if per_page == 0 || per_page > 100 {
return invalid_argument("per_page", "per_page must be between 1 and 100");
}
let pg = query
.iter()
.find(|(k, _)| k == "page")
.and_then(|(_, v)| v.parse::<usize>().ok())
.unwrap_or(1)
.max(1);
let total = items.len();
let slice: Vec<Value> = items
.into_iter()
.skip((pg - 1) * per_page)
.take(per_page)
.collect();
Answer::Json {
status: 200,
body: json!({ key: slice }),
total: Some(total),
}
};
let keep = |p: &str, tags: &[String], name: &str| -> bool {
project_q.as_deref().is_none_or(|q| q == p)
&& has_all(tags, &tags_q)
&& name_q.as_deref().is_none_or(|n| name.contains(n))
};
match (method, rest) {
// ── catalog ──
("GET", ["products", "servers"]) => ok(200, catalog::servers_json()),
("GET", ["products", "servers", "availability"]) => {
let m: serde_json::Map<String, Value> = catalog::SERVERS.iter().map(|r| (r.0.to_string(), json!({ "availability": if self.out_of_stock(r.0) { "shortage" } else { "available" } }))).collect();
ok(200, json!({ "servers": m }))
}
("GET", ["products", "volumes"]) => ok(200, catalog::volumes_json()),
// ── servers ──
("GET", ["servers"]) => {
// Newest first, as the provider lists them by default.
let mut v: Vec<&Server> = self
.servers
.values()
.filter(|s| s.zone == zone && keep(&s.project, &s.tags, &s.name))
.collect();
v.sort_by(|a, b| b.created_ms.cmp(&a.created_ms).then(b.id.cmp(&a.id)));
let items = v.into_iter().map(|s| self.server_json(s)).collect();
page(items, "servers")
}
("POST", ["servers"]) => self.create_server(&zone, &jbody),
("GET", ["servers", id]) => match self.servers.get(*id).filter(|s| s.zone == zone) {
Some(s) => ok(200, json!({ "server": self.server_json(s) })),
None => not_found("instance_server", id),
},
("PATCH", ["servers", id]) => self.patch_server(&zone, id, &jbody),
("DELETE", ["servers", id]) => {
let Some(s) = self.servers.get(*id).filter(|s| s.zone == zone) else {
return not_found("instance_server", id);
};
if s.pending.is_some() {
return transient("instance_server", id, &s.state);
}
if s.state != "stopped" && s.state != "stopped in place" {
return invalid_request("instance must be stopped to be deleted; terminate deletes a running instance with its volumes");
}
// DELETE keeps the volumes, detached: they go on being billed until deleted.
self.remove_server(id, false);
Answer::Empty { status: 204 }
}
("POST", ["servers", id, "action"]) => self.action(&zone, id, &jbody),
("GET", ["servers", id, "user_data"]) => match self.servers.get(*id) {
Some(s) => ok(
200,
json!({ "user_data": s.user_data.keys().collect::<Vec<_>>() }),
),
None => not_found("instance_server", id),
},
("GET", ["servers", id, "user_data", key]) => {
match self.servers.get(*id).and_then(|s| s.user_data.get(*key)) {
Some(b) => Answer::Text {
status: 200,
body: b.clone(),
},
None => not_found("instance_user_data", key),
}
}
("PATCH", ["servers", id, "user_data", key]) => {
if !content_type.is_some_and(|c| c.starts_with("text/plain")) {
return invalid_request("user data must be sent as text/plain");
}
let Some(s) = self.servers.get_mut(*id) else {
return not_found("instance_server", id);
};
s.user_data.insert(key.to_string(), body.to_vec());
if *key == "cloud-init" {
if let Some(e) = &self.engine {
e.metadata(id, Some(&String::from_utf8_lossy(body)));
}
}
Answer::Empty { status: 204 }
}
("DELETE", ["servers", id, "user_data", key]) => {
let Some(s) = self.servers.get_mut(*id) else {
return not_found("instance_server", id);
};
if s.user_data.remove(*key).is_none() {
return not_found("instance_user_data", key);
}
Answer::Empty { status: 204 }
}
// ── volumes ──
("GET", ["volumes"]) => {
let items = self
.volumes
.values()
.filter(|v| v.zone == zone && keep(&v.project, &v.tags, &v.name))
.map(|v| self.volume_json(v))
.collect();
page(items, "volumes")
}
("POST", ["volumes"]) => self.create_volume(&zone, &jbody),
("GET", ["volumes", id]) => match self.volumes.get(*id) {
Some(v) => ok(200, json!({ "volume": self.volume_json(v) })),
None => not_found("instance_volume", id),
},
("PATCH", ["volumes", id]) => {
let Some(v) = self.volumes.get(*id).cloned() else {
return not_found("instance_volume", id);
};
let mut v = v;
if let Some(size) = jbody.get("size").and_then(Value::as_u64) {
let row = catalog::volume_row(&v.volume_type);
if !row.is_some_and(|r| r.3) {
return invalid_request(&format!(
"a {} volume cannot be resized",
v.volume_type
));
}
if size < v.size {
return invalid_argument("size", "a volume can only grow");
}
v.size = size;
if let Some(e) = &self.engine {
let _ = e.resize_disk(id, size / catalog::GIB);
}
}
if let Some(n) = jbody.get("name").and_then(Value::as_str) {
v.name = n.into();
}
if jbody.get("tags").is_some() {
v.tags = tags_of(&jbody["tags"]);
}
let j = self.volume_json(&v);
self.volumes.insert(v.id.clone(), v);
ok(200, json!({ "volume": j }))
}
("DELETE", ["volumes", id]) => {
let Some(v) = self.volumes.get(*id) else {
return not_found("instance_volume", id);
};
if v.server.is_some() {
return still_in_use(
"instance_volume",
id,
"detach the volume from its server first",
);
}
self.volumes.remove(*id);
if let Some(e) = &self.engine {
e.forget_storage(id);
}
Answer::Empty { status: 204 }
}
// ── snapshots (backups) ──
("GET", ["snapshots"]) => {
let items = self
.snapshots
.values()
.filter(|s| {
s.zone == zone
&& s.project != "marketplace"
&& keep(&s.project, &s.tags, &s.name)
})
.map(|s| self.snapshot_json(s))
.collect();
page(items, "snapshots")
}
("POST", ["snapshots"]) => self.create_snapshot(&zone, &jbody),
("GET", ["snapshots", id]) => match self.snapshots.get(*id) {
Some(s) => ok(200, json!({ "snapshot": self.snapshot_json(s) })),
None => not_found("instance_snapshot", id),
},
("DELETE", ["snapshots", id]) => {
if !self.snapshots.contains_key(*id) {
return not_found("instance_snapshot", id);
}
if self
.images
.values()
.any(|i| i.root_volume == *id || i.extra_volumes.values().any(|v| v == id))
{
return still_in_use(
"instance_snapshot",
id,
"the snapshot is the volume of an image: delete the image first",
);
}
self.snapshots.remove(*id);
Answer::Empty { status: 204 }
}
// ── images ──
("GET", ["images"]) => {
let public = query
.iter()
.find(|(k, _)| k == "public")
.map(|(_, v)| v == "true");
let items = self
.images
.values()
.filter(|i| {
i.zone == zone
&& public.is_none_or(|p| p == i.public)
&& (i.public || keep(&i.project, &i.tags, &i.name))
})
.map(|i| self.image_json(i))
.collect();
page(items, "images")
}
("POST", ["images"]) => self.create_image(&zone, &jbody),
("GET", ["images", id]) => match self.images.get(*id) {
Some(i) => ok(200, json!({ "image": self.image_json(i) })),
None => not_found("instance_image", id),
},
("DELETE", ["images", id]) => {
if self.images.get(*id).is_none_or(|i| i.public) {
return not_found("instance_image", id);
}
// THE TRAP: deleting an image keeps its snapshots (and they go on being billed).
self.images.remove(*id);
Answer::Empty { status: 204 }
}
// ── security groups ──
("GET", ["security_groups"]) => {
let items = self
.security_groups
.values()
.filter(|g| g.zone == zone && keep(&g.project, &g.tags, &g.name))
.map(|g| self.sg_json(g))
.collect();
page(items, "security_groups")
}
("POST", ["security_groups"]) => {
let Some(project) = jbody
.get("project")
.and_then(Value::as_str)
.map(str::to_string)
else {
return invalid_argument("project", "project is required");
};
let Some(name) = jbody
.get("name")
.and_then(Value::as_str)
.map(str::to_string)
else {
return invalid_argument("name", "name is required");
};
let pol = |k: &str| {
jbody
.get(k)
.and_then(Value::as_str)
.unwrap_or("accept")
.to_string()
};
let (inb, outb) = (
pol("inbound_default_policy"),
pol("outbound_default_policy"),
);
if ![&inb, &outb].iter().all(|p| *p == "accept" || *p == "drop") {
return invalid_argument("inbound_default_policy", "accept or drop");
}
let id = self.new_id("56");
let now = self.now();
let g = SecurityGroup {
id: id.clone(),
zone: zone.clone(),
name,
project,
inbound_default_policy: inb,
outbound_default_policy: outb,
stateful: jbody
.get("stateful")
.and_then(Value::as_bool)
.unwrap_or(true),
project_default: false,
tags: tags_of(&jbody["tags"]),
rules: vec![],
created_ms: now,
};
let j = self.sg_json(&g);
self.security_groups.insert(id, g);
ok(201, json!({ "security_group": j }))
}
("GET", ["security_groups", id]) => match self.security_groups.get(*id) {
Some(g) => ok(200, json!({ "security_group": self.sg_json(g) })),
None => not_found("instance_security_group", id),
},
("PATCH", ["security_groups", id]) => {
let Some(g) = self.security_groups.get_mut(*id) else {
return not_found("instance_security_group", id);
};
for k in ["inbound_default_policy", "outbound_default_policy"] {
if let Some(p) = jbody.get(k).and_then(Value::as_str) {
if k == "inbound_default_policy" {
g.inbound_default_policy = p.into()
} else {
g.outbound_default_policy = p.into()
}
}
}
if jbody.get("tags").is_some() {
g.tags = tags_of(&jbody["tags"]);
}
let id = id.to_string();
self.tell_firewall(&id);
ok(
200,
json!({ "security_group": self.sg_json(&self.security_groups[&id]) }),
)
}
("DELETE", ["security_groups", id]) => {
if !self.security_groups.contains_key(*id) {
return not_found("instance_security_group", id);
}
if self.servers.values().any(|s| s.security_group == *id) {
return still_in_use(
"instance_security_group",
id,
"the group is attached to a server",
);
}
self.security_groups.remove(*id);
Answer::Empty { status: 204 }
}
("GET", ["security_groups", id, "rules"]) => match self.security_groups.get(*id) {
Some(g) => {
let mut r = g.rules.clone();
r.sort_by_key(|r| r.position);
let items = r.iter().map(|r| Self::rule_json(r, &g.zone)).collect();
page(items, "rules")
}
None => not_found("instance_security_group", id),
},
("PUT", ["security_groups", id, "rules"]) => self.put_rules(id, &jbody),
// ── ips ──
("GET", ["ips"]) => {
let items = self
.ips
.values()
.filter(|i| !i.dynamic && i.zone == zone && keep(&i.project, &i.tags, ""))
.map(|i| self.ip_json(i))
.collect();
page(items, "ips")
}
("POST", ["ips"]) => {
let Some(project) = jbody
.get("project")
.and_then(Value::as_str)
.map(str::to_string)
else {
return invalid_argument("project", "project is required");
};
let server = jbody
.get("server")
.and_then(Value::as_str)
.map(str::to_string);
if let Some(s) = &server {
if !self.servers.contains_key(s) {
return not_found("instance_server", s);
}
}
let id = self.new_id("19");
let address = self.new_public_address();
let ip = Ip {
id: id.clone(),
zone: zone.clone(),
address,
project,
tags: tags_of(&jbody["tags"]),
server,
dynamic: false,
};
let j = self.ip_json(&ip);
self.ips.insert(id, ip);
ok(201, json!({ "ip": j }))
}
("GET", ["ips", id]) => match self.ips.get(*id).filter(|i| !i.dynamic) {
Some(i) => ok(200, json!({ "ip": self.ip_json(i) })),
None => not_found("instance_ip", id),
},
("PATCH", ["ips", id]) => {
let Some(mut ip) = self.ips.get(*id).filter(|i| !i.dynamic).cloned() else {
return not_found("instance_ip", id);
};
match jbody.get("server") {
Some(Value::Null) => ip.server = None,
Some(Value::String(s)) if self.servers.contains_key(s) => {
ip.server = Some(s.clone())
}
Some(Value::String(s)) => return not_found("instance_server", s),
_ => {}
}
if jbody.get("tags").is_some() {
ip.tags = tags_of(&jbody["tags"]);
}
let j = self.ip_json(&ip);
self.ips.insert(ip.id.clone(), ip);
ok(200, json!({ "ip": j }))
}
("DELETE", ["ips", id]) => {
if self.ips.get(*id).is_none_or(|i| i.dynamic) {
return not_found("instance_ip", id);
}
self.ips.remove(*id);
Answer::Empty { status: 204 }
}
// ── tasks ──
("GET", ["tasks", id]) => match self.tasks.get(*id) {
Some(t) => ok(200, json!({ "task": self.task_json(t) })),
None => not_found("instance_task", id),
},
_ => not_found("api_route", path),
}
}
fn create_server(&mut self, zone: &str, b: &Value) -> Answer {
let project = match (
b.get("project").and_then(Value::as_str),
b.get("organization").and_then(Value::as_str),
) {
(Some(p), _) => p.to_string(),
(None, Some(_)) => self.project.clone(),
(None, None) => {
return invalid_argument("project", "one of project or organization is required")
}
};
let Some(ct) = b
.get("commercial_type")
.and_then(Value::as_str)
.map(str::to_string)
else {
return invalid_argument("commercial_type", "commercial_type is required");
};
if catalog::server_row(&ct).is_none() {
return invalid_argument(
"commercial_type",
&format!("{ct} is not a commercial type of this zone"),
);
}
let name = b
.get("name")
.and_then(Value::as_str)
.unwrap_or("scw-mock")
.to_string();
// The image: by id, or by a marketplace label (`ubuntu_noble`).
let image = match b.get("image").and_then(Value::as_str) {
None | Some("") => None,
Some(i) => match self.images.values().find(|im| {
im.zone == zone && (im.id == i || (im.public && im.tags.iter().any(|t| t == i)))
}) {
Some(im) => Some(im.id.clone()),
None => return not_found("instance_image", i),
},
};
let vols_in = b
.get("volumes")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
if image.is_none() && !vols_in.contains_key("0") {
return invalid_argument("image", "an image or a root volume (volumes.0) is required");
}
let sg = match b.get("security_group") {
Some(Value::String(id)) => id.clone(),
Some(Value::Object(o)) => o
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
_ => self.default_sg(zone, &project),
};
if !self.security_groups.contains_key(&sg) {
return not_found("instance_security_group", &sg);
}
let id = self.new_id("5e");
let now = self.now();
// Volumes: existing ones by id, new ones by size and type; the root from the image.
let mut volumes = BTreeMap::new();
let mut made: Vec<Volume> = Vec::new();
let mut keys: Vec<&String> = vols_in.keys().collect();
keys.sort_by_key(|k| k.parse::<u32>().unwrap_or(u32::MAX));
for k in keys {
let v = &vols_in[k];
if let Some(vid) = v.get("id").and_then(Value::as_str) {
match self.volumes.get(vid) {
None => return not_found("instance_volume", vid),
Some(ex) if ex.server.is_some() => {
return still_in_use(
"instance_volume",
vid,
"the volume is attached to another server",
)
}
Some(_) => {
volumes.insert(k.clone(), vid.to_string());
}
}
} else {
let vt = v
.get("volume_type")
.and_then(Value::as_str)
.unwrap_or("l_ssd")
.to_string();
let Some(row) = catalog::volume_row(&vt) else {
return invalid_argument("volume_type", &format!("{vt} is not a volume type"));
};
let size = v
.get("size")
.and_then(Value::as_u64)
.unwrap_or(20 * catalog::GB);
if size < row.4 || size > row.5 {
return invalid_argument(
"size",
&format!("a {vt} volume is {}..{} bytes", row.4, row.5),
);
}
let vid = self.new_id("70");
made.push(Volume {
id: vid.clone(),
zone: zone.into(),
name: format!("{name}-vol-{k}"),
project: project.clone(),
volume_type: vt,
size,
tags: vec![],
server: Some(id.clone()),
created_ms: now,
});
volumes.insert(k.clone(), vid);
}
}
if !volumes.contains_key("0") {
let im = &self.images[image.as_ref().unwrap()];
let snap = self.snapshots.get(&im.root_volume).cloned();
let vid = self.new_id("70");
let (vt, size) = snap
.as_ref()
.map(|s| (s.volume_type.clone(), s.size))
.unwrap_or(("l_ssd".into(), 10 * catalog::GB));
made.push(Volume {
id: vid.clone(),
zone: zone.into(),
name: format!("{name}-root"),
project: project.clone(),
volume_type: vt,
size,
tags: vec![],
server: Some(id.clone()),
created_ms: now,
});
if let (Some(e), Some(s)) = (&self.engine, &snap) {
let _ = e.copy_disk(&s.id, &vid);
}
volumes.insert("0".into(), vid);
}
for v in made {
self.volumes.insert(v.id.clone(), v);
}
for vid in volumes.values() {
if let Some(v) = self.volumes.get_mut(vid) {
v.server = Some(id.clone());
}
}
let dynamic = b
.get("dynamic_ip_required")
.and_then(Value::as_bool)
.unwrap_or(true)
&& b.get("public_ip").is_none_or(Value::is_null);
let dynamic_ip = if dynamic {
let ipid = self.new_id("19");
let address = self.new_public_address();
self.ips.insert(
ipid.clone(),
Ip {
id: ipid,
zone: zone.into(),
address: address.clone(),
project: project.clone(),
tags: vec![],
server: Some(id.clone()),
dynamic: true,
},
);
Some(address)
} else {
None
};
if let Some(ipid) = b.get("public_ip").and_then(Value::as_str) {
match self.ips.get_mut(ipid) {
Some(ip) => ip.server = Some(id.clone()),
None => return not_found("instance_ip", ipid),
}
}
let n = self.servers.len() as u32 + 97;
let private_ip = Some(format!(
"{}.{}.{}",
self.utility,
8 + (n / 256) * 4,
n % 256
));
let s = Server {
id: id.clone(),
zone: zone.into(),
name,
project,
commercial_type: ct,
state: "stopped".into(),
pending: None,
tags: tags_of(&b["tags"]),
volumes,
image,
security_group: sg,
dynamic_ip,
private_ip,
boot_type: b
.get("boot_type")
.and_then(Value::as_str)
.unwrap_or("local")
.into(),
created_ms: now,
modified_ms: now,
user_data: BTreeMap::new(),
};
let j = self.server_json(&s);
self.servers.insert(id, s);
ok(201, json!({ "server": j }))
}
fn patch_server(&mut self, zone: &str, id: &str, b: &Value) -> Answer {
let Some(mut s) = self.servers.get(id).filter(|s| s.zone == zone).cloned() else {
return not_found("instance_server", id);
};
let stopped = s.state == "stopped" || s.state == "stopped in place";
if let Some(ct) = b.get("commercial_type").and_then(Value::as_str) {
if catalog::server_row(ct).is_none() {
return invalid_argument(
"commercial_type",
&format!("{ct} is not a commercial type of this zone"),
);
}
if s.pending.is_some() {
return transient("instance_server", id, &s.state);
}
if !stopped && ct != s.commercial_type {
return invalid_request(
"the instance must be stopped to change its commercial type",
);
}
s.commercial_type = ct.into();
}
if let Some(vols) = b.get("volumes").and_then(Value::as_object) {
if !stopped {
return invalid_request("the instance must be stopped to change its volumes");
}
let mut next = BTreeMap::new();
for (k, v) in vols {
let Some(vid) = v.get("id").and_then(Value::as_str) else {
return invalid_argument("volumes", "each volume is given by its id");
};
match self.volumes.get(vid) {
None => return not_found("instance_volume", vid),
Some(ex) if ex.server.as_deref().is_some_and(|o| o != id) => {
return still_in_use(
"instance_volume",
vid,
"the volume is attached to another server",
)
}
_ => {
next.insert(k.clone(), vid.to_string());
}
}
}
if !next.contains_key("0") {
return invalid_argument("volumes", "volumes.0 (the root) is required");
}
for old in s.volumes.values() {
if let Some(v) = self.volumes.get_mut(old) {
v.server = None;
}
}
for vid in next.values() {
if let Some(v) = self.volumes.get_mut(vid) {
v.server = Some(id.into());
}
}
s.volumes = next;
}
if let Some(v @ (Value::Object(_) | Value::String(_))) = b.get("security_group") {
let sg = v
.as_str()
.or_else(|| v.get("id").and_then(Value::as_str))
.unwrap_or("")
.to_string();
if !self.security_groups.contains_key(&sg) {
return not_found("instance_security_group", &sg);
}
s.security_group = sg;
}
if let Some(n) = b.get("name").and_then(Value::as_str) {
s.name = n.into();
}
if b.get("tags").is_some() {
s.tags = tags_of(&b["tags"]);
}
s.modified_ms = self.now();
let sg = s.security_group.clone();
let j = self.server_json(&s);
self.servers.insert(id.into(), s);
self.tell_firewall(&sg);
ok(200, json!({ "server": j }))
}
fn action(&mut self, zone: &str, id: &str, b: &Value) -> Answer {
let Some(s) = self.servers.get(id).filter(|s| s.zone == zone).cloned() else {
return not_found("instance_server", id);
};
let action = b.get("action").and_then(Value::as_str).unwrap_or("");
if s.pending.is_some() && action != "backup" {
return transient("instance_server", id, &s.state);
}
let now = self.now();
let t = self.timings;
let href = format!("/servers/{id}");
let bad_state = |s: &Server| {
invalid_request(&format!(
"action {action} is not allowed in state {}",
s.state
))
};
let (next, takes, desc) = match action {
"poweron" => {
if s.state == "running" {
return bad_state(&s);
}
if self.out_of_stock(&s.commercial_type) {
return ok(412, json!({ "type": "out_of_stock", "message": format!("no {} available in {zone}", s.commercial_type), "resource": "instance_server" }));
}
if let Some(e) = &self.engine {
if let Err(why) = e.power_on(&self.machine_of(&s)) {
return err(500, "internal_error", &format!("the machine did not start: {why}"));
}
}
("running", t.poweron_ms, "server_batch_poweron")
}
"poweroff" => {
if s.state != "running" {
return bad_state(&s);
}
if let Some(e) = &self.engine {
e.power_off(id, false);
}
("stopped", t.poweroff_ms, "server_poweroff")
}
"stop_in_place" => {
if s.state != "running" {
return bad_state(&s);
}
if let Some(e) = &self.engine {
e.power_off(id, false);
}
("stopped in place", t.poweroff_ms, "server_stop_in_place")
}
"reboot" => {
if s.state != "running" {
return bad_state(&s);
}
if let Some(e) = &self.engine {
e.power_off(id, true);
let _ = e.power_on(&self.machine_of(&s));
}
("running", t.reboot_ms, "server_reboot")
}
"terminate" => {
if s.state == "stopped" {
return invalid_request("a stopped instance is deleted with DELETE, not terminated");
}
if let Some(e) = &self.engine {
e.power_off(id, true);
}
("gone", t.terminate_ms, "server_terminate")
}
"backup" => {
// An image of the whole server: one snapshot per volume, the image over them.
let name = b.get("name").and_then(Value::as_str).map(str::to_string).unwrap_or_else(|| format!("{}-backup", s.name));
let mut root = String::new();
let mut extra = BTreeMap::new();
for (k, vid) in &s.volumes {
let Some(v) = self.volumes.get(vid).cloned() else { continue };
let sid = self.new_id("5a");
self.snapshots.insert(sid.clone(), Snapshot { id: sid.clone(), zone: zone.into(), name: format!("{name}-{k}"), project: s.project.clone(), volume_type: v.volume_type.clone(), size: v.size, tags: vec![], base_volume: Some((v.id.clone(), v.name.clone())), state: "snapshotting".into(), ready_ms: now + t.snapshot_ms, created_ms: now });
if let Some(e) = &self.engine {
let _ = e.copy_disk(&v.id, &sid);
}
if k == "0" { root = sid } else { extra.insert(k.clone(), sid); }
}
let iid = self.new_id("1a");
self.images.insert(iid.clone(), Image { id: iid.clone(), zone: zone.into(), name, project: s.project.clone(), root_volume: root, extra_volumes: extra, tags: vec![], public: false, from_server: Some(id.into()), created_ms: now });
let task = self.task(zone, "server_backup", href, format!("/images/{iid}"), t.snapshot_ms);
return ok(202, json!({ "task": task }));
}
other => return invalid_argument("action", &format!("{other:?} is not an action (poweron, poweroff, stop_in_place, reboot, terminate, backup)")),
};
let transitional = match next {
"running" => "starting",
_ => "stopping",
};
if let Some(sv) = self.servers.get_mut(id) {
sv.state = transitional.into();
sv.pending = Some((next.into(), now + takes));
sv.modified_ms = now;
}
let task = self.task(zone, desc, href.clone(), href, takes);
ok(202, json!({ "task": task }))
}
fn create_volume(&mut self, zone: &str, b: &Value) -> Answer {
let Some(project) = b.get("project").and_then(Value::as_str).map(str::to_string) else {
return invalid_argument("project", "project is required");
};
let vt = b
.get("volume_type")
.and_then(Value::as_str)
.unwrap_or("b_ssd")
.to_string();
let Some(row) = catalog::volume_row(&vt) else {
return invalid_argument("volume_type", &format!("{vt} is not a volume type"));
};
let from = b
.get("base_snapshot")
.and_then(Value::as_str)
.map(str::to_string);
let size = match (&from, b.get("size").and_then(Value::as_u64)) {
(Some(sid), s) => match self.snapshots.get(sid) {
Some(snap) => s.unwrap_or(snap.size).max(snap.size),
None => return not_found("instance_snapshot", sid),
},
(None, Some(s)) => s,
(None, None) => return invalid_argument("size", "size or base_snapshot is required"),
};
if size < row.4 || size > row.5 {
return invalid_argument(
"size",
&format!("a {vt} volume is {}..{} bytes", row.4, row.5),
);
}
let id = self.new_id("70");
let now = self.now();
if let (Some(e), Some(sid)) = (&self.engine, &from) {
let _ = e.copy_disk(sid, &id);
}
let v = Volume {
id: id.clone(),
zone: zone.into(),
name: b
.get("name")
.and_then(Value::as_str)
.unwrap_or("vol")
.into(),
project,
volume_type: vt,
size,
tags: tags_of(&b["tags"]),
server: None,
created_ms: now,
};
let j = self.volume_json(&v);
self.volumes.insert(id, v);
ok(201, json!({ "volume": j }))
}
fn create_snapshot(&mut self, zone: &str, b: &Value) -> Answer {
let Some(project) = b.get("project").and_then(Value::as_str).map(str::to_string) else {
return invalid_argument("project", "project is required");
};
let name = b
.get("name")
.and_then(Value::as_str)
.unwrap_or("snapshot")
.to_string();
let now = self.now();
let id = self.new_id("5a");
let snap = if let Some(vid) = b.get("volume_id").and_then(Value::as_str) {
let Some(v) = self.volumes.get(vid).cloned() else {
return not_found("instance_volume", vid);
};
if let Some(e) = &self.engine {
let _ = e.copy_disk(&v.id, &id);
}
Snapshot {
id: id.clone(),
zone: zone.into(),
name,
project,
volume_type: v.volume_type.clone(),
size: v.size,
tags: tags_of(&b["tags"]),
base_volume: Some((v.id, v.name)),
state: "snapshotting".into(),
ready_ms: now + self.timings.snapshot_ms,
created_ms: now,
}
} else if let (Some(bucket), Some(key)) = (
b.get("bucket").and_then(Value::as_str),
b.get("key").and_then(Value::as_str),
) {
// An IMPORT from object storage (a medium, qcow2 on the provider; here the bytes).
let Some(bytes) = self
.objects
.get(&(bucket.to_string(), key.to_string()))
.cloned()
else {
return invalid_request(&format!("the object s3://{bucket}/{key} cannot be read"));
};
if let Some(e) = &self.engine {
if let Err(why) = e.store_medium(&id, &bytes) {
return invalid_request(&format!("the object is not an image: {why}"));
}
}
let size = b
.get("size")
.and_then(Value::as_u64)
.unwrap_or(10 * catalog::GB);
Snapshot {
id: id.clone(),
zone: zone.into(),
name,
project,
volume_type: b
.get("volume_type")
.and_then(Value::as_str)
.unwrap_or("l_ssd")
.into(),
size,
tags: tags_of(&b["tags"]),
base_volume: None,
state: "snapshotting".into(),
ready_ms: now + self.timings.snapshot_ms,
created_ms: now,
}
} else {
return invalid_argument("volume_id", "volume_id, or bucket and key, is required");
};
let j = self.snapshot_json(&snap);
self.snapshots.insert(id.clone(), snap);
let task = self.task(
zone,
"volume_snapshot",
format!("/snapshots/{id}"),
format!("/snapshots/{id}"),
self.timings.snapshot_ms,
);
ok(201, json!({ "snapshot": j, "task": task }))
}
fn create_image(&mut self, zone: &str, b: &Value) -> Answer {
let Some(project) = b.get("project").and_then(Value::as_str).map(str::to_string) else {
return invalid_argument("project", "project is required");
};
let Some(root) = b
.get("root_volume")
.and_then(Value::as_str)
.map(str::to_string)
else {
return invalid_argument("root_volume", "root_volume (a snapshot id) is required");
};
match self.snapshots.get(&root) {
None => return not_found("instance_snapshot", &root),
Some(s) if s.state != "available" => {
return transient("instance_snapshot", &root, &s.state)
}
_ => {}
}
let id = self.new_id("1a");
let now = self.now();
let extra = b
.get("extra_volumes")
.and_then(Value::as_object)
.map(|m| {
m.iter()
.filter_map(|(k, v)| {
v.get("id")
.and_then(Value::as_str)
.map(|s| (k.clone(), s.to_string()))
})
.collect()
})
.unwrap_or_default();
let i = Image {
id: id.clone(),
zone: zone.into(),
name: b
.get("name")
.and_then(Value::as_str)
.unwrap_or("image")
.into(),
project,
root_volume: root,
extra_volumes: extra,
tags: tags_of(&b["tags"]),
public: false,
from_server: None,
created_ms: now,
};
let j = self.image_json(&i);
self.images.insert(id, i);
ok(201, json!({ "image": j }))
}
fn put_rules(&mut self, id: &str, b: &Value) -> Answer {
if !self.security_groups.contains_key(id) {
return not_found("instance_security_group", id);
}
let Some(rules) = b.get("rules").and_then(Value::as_array).cloned() else {
return invalid_argument("rules", "rules is required");
};
let mut out = Vec::new();
for (n, r) in rules.iter().enumerate() {
let s = |k: &str, d: &str| r.get(k).and_then(Value::as_str).unwrap_or(d).to_string();
let (action, protocol, direction, ip_range) = (
s("action", "accept"),
s("protocol", "TCP"),
s("direction", "inbound"),
s("ip_range", "0.0.0.0/0"),
);
if !["accept", "drop"].contains(&action.as_str()) {
return invalid_argument("action", "accept or drop");
}
if !["TCP", "UDP", "ICMP", "ANY"].contains(&protocol.as_str()) {
return invalid_argument("protocol", "TCP, UDP, ICMP or ANY");
}
if !["inbound", "outbound"].contains(&direction.as_str()) {
return invalid_argument("direction", "inbound or outbound");
}
let port = |k: &str| r.get(k).and_then(Value::as_u64).map(|p| p as u16);
let (from, to) = (port("dest_port_from"), port("dest_port_to"));
if let (Some(f), Some(t)) = (from, to) {
if t < f {
return invalid_argument(
"dest_port_to",
"dest_port_to must not be below dest_port_from",
);
}
}
if (protocol == "ICMP" || protocol == "ANY") && from.is_some() {
return invalid_argument("dest_port_from", "a port range needs TCP or UDP");
}
let rid = self.new_id("4b");
out.push(Rule {
id: rid,
action,
protocol,
direction,
ip_range,
dest_port_from: from,
dest_port_to: to,
position: r
.get("position")
.and_then(Value::as_u64)
.map(|p| p as u32)
.unwrap_or(n as u32 + 1),
editable: true,
});
}
let g = self.security_groups.get_mut(id).unwrap();
g.rules.retain(|r| !r.editable);
g.rules.extend(out);
let zone = g.zone.clone();
let mut rs = g.rules.clone();
rs.sort_by_key(|r| r.position);
self.tell_firewall(id);
ok(
200,
json!({ "rules": rs.iter().map(|r| Self::rule_json(r, &zone)).collect::<Vec<_>>() }),
)
}
/// The mock's own doors (`/mock/...`), never the provider's: a test's or an operator's hand.
fn mock_door(
&mut self,
method: &str,
seg: &[&str],
query: &[(String, String)],
body: &[u8],
) -> Answer {
let ct = query
.iter()
.find(|(k, _)| k == "commercial_type")
.map(|(_, v)| v.as_str());
match (method, seg) {
("GET", ["state"]) => ok(200, self.state_json()),
("POST", ["arm", name]) => match Fault::parse(name, ct) {
Some(f) => {
self.faults.push(f);
ok(200, json!({ "armed": name }))
}
None => invalid_argument("fault", "out-of-stock"),
},
("POST", ["disarm", name]) => {
self.faults.retain(|f| f.name() != *name);
ok(200, json!({ "disarmed": name }))
}
("POST", ["revoke", token]) => {
self.revoked.push(token.to_string());
ok(200, json!({ "revoked": true }))
}
("POST", ["advance", ms]) => {
self.clock.advance_ms(ms.parse().unwrap_or(0));
self.settle();
ok(200, json!({ "now_ms": self.now() }))
}
("PUT", ["objects", bucket, key @ ..]) => {
self.objects
.insert((bucket.to_string(), key.join("/")), body.to_vec());
Answer::Empty { status: 204 }
}
_ => not_found("mock_route", &seg.join("/")),
}
}
/// Everything the account holds, as one JSON document (`GET /mock/state`).
pub fn state_json(&self) -> Value {
json!({
"now_ms": self.now(),
"servers": self.servers.values().map(|s| self.server_json(s)).collect::<Vec<_>>(),
"volumes": self.volumes.values().map(|v| self.volume_json(v)).collect::<Vec<_>>(),
"snapshots": self.snapshots.values().filter(|s| s.project != "marketplace").map(|s| self.snapshot_json(s)).collect::<Vec<_>>(),
"images": self.images.values().filter(|i| !i.public).map(|i| self.image_json(i)).collect::<Vec<_>>(),
"security_groups": self.security_groups.values().map(|g| self.sg_json(g)).collect::<Vec<_>>(),
"ips": self.ips.values().map(|i| self.ip_json(i)).collect::<Vec<_>>(),
"faults": self.faults.iter().map(|f| f.name()).collect::<Vec<_>>(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_account_epoch_reads_as_a_date() {
assert_eq!(now_iso(0), "2026-10-24T00:00:00.000000+00:00");
assert_eq!(
now_iso(8 * 86_400_000 + 3_723_004),
"2026-11-01T01:02:03.004000+00:00"
);
}
#[test]
fn a_cidr_holds_what_it_should() {
assert!(cidr_holds("0.0.0.0/0", "127.0.0.1"));
assert!(cidr_holds("127.0.0.1/32", "127.0.0.1"));
assert!(!cidr_holds("10.0.0.0/8", "127.0.0.1"));
assert!(cidr_holds("127.0.0.0/8", "127.9.9.9"));
}
}