use crate::estate::{Estate, Server, Storage, StorageKind};
use serde_json::{json, Value};
pub fn labels_flat(s: &Storage) -> Value {
Value::Array(s.labels.iter().map(|l| json!({"key": l.key, "value": l.value})).collect())
}
pub fn labels_enveloped(s: &Server) -> Value {
json!({ "label": s.labels.iter().map(|l| json!({"key": l.key, "value": l.value})).collect::<Vec<_>>() })
}
pub fn storage(e: &Estate, s: &Storage, detail: bool, with_created: bool) -> Value {
let mut v = json!({
"uuid": s.uuid,
"title": s.title,
"size": s.size_gib,
"state": s.state,
"tier": s.tier,
"type": s.kind.as_str(),
"zone": s.zone,
"access": if s.kind == StorageKind::Template { "public" } else { "private" },
"labels": labels_flat(s),
});
if let Some(o) = &s.origin {
v["origin"] = json!(o);
}
if with_created {
v["created"] = json!(iso8601(s.created_ms));
}
if let Some(im) = &s.import {
v["storage_import"] = import(im);
}
if detail {
let servers = e.attached_servers(&s.uuid);
v["servers"] = json!({ "server": servers });
v["license"] = json!(0);
v["backup_rule"] = json!({});
}
v
}
pub fn server(s: &Server, detail: bool, with_created: bool) -> Value {
let mut v = json!({
"uuid": s.uuid,
"title": s.title,
"hostname": s.hostname,
"plan": s.plan,
"zone": s.zone,
"state": s.state,
"core_number": cores_of(&s.plan).to_string(),
"memory_amount": memory_of(&s.plan).to_string(),
"labels": labels_enveloped(s),
"license": 0,
});
if with_created {
v["created"] = json!(iso8601(s.created_ms));
}
if !detail {
return v;
}
v["boot_order"] = json!(s.boot_order.as_str());
v["remote_access_enabled"] = json!(if s.remote_access_enabled { "yes" } else { "no" });
v["remote_access_type"] = json!("vnc");
if s.remote_access_enabled {
v["remote_access_host"] = json!(s.reported_vnc_host);
v["remote_access_port"] = json!(s.reported_vnc_port.to_string());
v["remote_access_password"] = json!(s.remote_access_password);
}
v["firmware"] = json!("bios");
v["timezone"] = json!("UTC");
v["storage_devices"] = json!({
"storage_device": s.devices.iter().map(|d| json!({
"address": d.address,
"part_of_plan": "no",
"storage": d.storage,
"storage_size": d.storage_size,
"storage_title": d.storage_title,
"storage_tier": "maxiops",
"type": d.kind,
"boot_disk": if d.boot_disk { "1" } else { "0" },
})).collect::<Vec<_>>()
});
v["ip_addresses"] = json!({
"ip_address": [
{"access": "public", "address": s.public_ip, "family": "IPv4"},
{"access": "utility", "address": s.utility_ip, "family": "IPv4"},
]
});
for i in s.ifaces.iter().filter(|i| i.kind == "public" && i.family == "IPv6") {
if let Some(a) = v["ip_addresses"]["ip_address"].as_array_mut() {
a.push(json!({"access": "public", "address": ipv6_of(s, i.index), "family": "IPv6"}));
}
}
v["networking"] = json!({
"interfaces": {
"interface": s.ifaces.iter().map(|i| {
let v6 = i.kind == "public" && i.family == "IPv6";
let addr = if v6 { ipv6_of(s, i.index) } else { address_of(s, &i.kind) };
let family = if v6 { "IPv6" } else { "IPv4" };
json!({
"index": i.index,
"type": i.kind,
"mac": mac_for(&s.uuid, i.index),
"network": "",
"bootable": "no",
"source_ip_filtering": "yes",
"ip_addresses": {"ip_address": [
{"address": addr, "family": family, "floating": "no"}
]},
})
}).collect::<Vec<_>>()
}
});
v["firewall"] = json!(if s.firewall_on { "on" } else { "off" });
v["metadata"] = json!(if s.metadata { "yes" } else { "no" });
v["timezone"] = json!(s.timezone);
v["simple_backup"] = json!("no");
v["nic_model"] = json!("virtio");
v["video_model"] = json!("vga");
v["host"] = json!(0);
v["server_group"] = json!("");
v
}
fn address_of(s: &Server, kind: &str) -> String {
match kind {
"public" => s.public_ip.clone(),
_ => s.utility_ip.clone(),
}
}
fn ipv6_of(s: &Server, index: u32) -> String {
let m = mac_for(&s.uuid, index).replace(':', "");
format!("2a04:3540:1000:310:{}:{}:{}:{}", &m[0..4], &m[4..8], &m[8..12], index)
}
fn mac_for(uuid: &str, index: u32) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in uuid.as_bytes().iter().chain(&index.to_le_bytes()) {
h ^= *b as u64;
h = h.wrapping_mul(0x100_0000_01b3);
}
format!(
"0a:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
(h >> 32) as u8,
(h >> 24) as u8,
(h >> 16) as u8,
(h >> 8) as u8,
h as u8
)
}
pub fn cores_of(plan: &str) -> u32 {
let before = match plan.find("xCPU") {
Some(i) => &plan[..i],
None => return 1,
};
let mut digits: Vec<char> = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
digits.reverse();
digits.into_iter().collect::<String>().parse().unwrap_or(1)
}
pub fn memory_of(plan: &str) -> u32 {
plan.rsplit('-')
.next()
.and_then(|g| g.trim_end_matches("GB").parse::<u32>().ok())
.map(|g| g * 1024)
.unwrap_or(1024)
}
pub const PLANS: &[&str] = &[
"1xCPU-1GB",
"1xCPU-2GB",
"2xCPU-4GB",
"4xCPU-8GB",
"6xCPU-16GB",
"8xCPU-32GB",
"DEV-1xCPU-1GB",
"DEV-2xCPU-4GB",
"STARTER-1xCPU-1GB",
"STARTER-4xCPU-8GB",
"STARTER-4xCPU-16GB",
];
pub fn plan_known(plan: &str) -> bool {
PLANS.contains(&plan)
}
pub fn price(zone: &str) -> Value {
json!({
"prices": {
"zone": [{
"name": zone,
"server_core": {"amount": 1, "price": 1.3},
"server_memory": {"amount": 256, "price": 0.45},
"storage_maxiops": {"amount": 1, "price": 0.0274},
"storage_hdd": {"amount": 1, "price": 0.0082},
"storage_standard": {"amount": 1, "price": 0.0154},
"storage_backup": {"amount": 1, "price": 0.0082},
"public_ipv4_address": {"amount": 1, "price": 0.416},
"ipv4_address": {"amount": 1, "price": 0.416},
"server_plan_1xCPU-1GB": {"amount": 1, "price": 0.744},
"server_plan_1xCPU-2GB": {"amount": 1, "price": 1.24},
"server_plan_2xCPU-4GB": {"amount": 1, "price": 2.48},
"server_plan_4xCPU-8GB": {"amount": 1, "price": 4.96},
"server_plan_6xCPU-16GB": {"amount": 1, "price": 9.92},
"server_plan_8xCPU-32GB": {"amount": 1, "price": 19.84},
"server_plan_DEV-1xCPU-1GB": {"amount": 1, "price": 0.372},
"server_plan_DEV-2xCPU-4GB": {"amount": 1, "price": 1.24},
"server_plan_STARTER-1xCPU-1GB": {"amount": 1, "price": 0.685},
"server_plan_STARTER-4xCPU-8GB": {"amount": 1, "price": 5.205},
"server_plan_STARTER-4xCPU-16GB": {"amount": 1, "price": 8.493}
}]
}
})
}
pub fn import(im: &crate::estate::Import) -> Value {
let mut v = json!({
"source": im.source,
"state": im.state,
"created": iso8601(im.created_ms),
"uuid": "",
"client_content_length": im.client_content_length,
"read_bytes": im.read_bytes,
"written_bytes": im.written_bytes,
});
if !im.direct_upload_url.is_empty() {
v["direct_upload_url"] = json!(im.direct_upload_url);
}
if let Some(c) = im.completed_ms {
v["completed"] = json!(iso8601(c));
}
if let Some(m) = &im.md5sum {
v["md5sum"] = json!(m);
}
if let Some(h) = &im.sha256sum {
v["sha256sum"] = json!(h);
}
if let Some(c) = &im.error_code {
v["error_code"] = json!(c);
}
if let Some(m) = &im.error_message {
v["error_message"] = json!(m);
}
v
}
pub fn error(code: &str, message: &str) -> Value {
json!({"error": {"error_code": code, "error_message": message}})
}
pub fn auth_failed(correlation_id: &str) -> Value {
json!({
"type": "https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED",
"title": "Authentication failed using the given username and password.",
"correlation_id": correlation_id,
"status": 403
})
}
pub fn not_implemented(method: &str, path: &str) -> Value {
json!({"error": {
"error_code": "MOCK_UPCLOUD_NOT_IMPLEMENTED",
"error_message": format!("mock-upcloud does not implement {method} {path}; it is not part of the surface this estate drives")
}})
}
pub fn iso8601(ms: u64) -> String {
const EPOCH_DAYS: u64 = 20454; let secs = ms / 1000;
let days = EPOCH_DAYS + secs / 86_400;
let rem = secs % 86_400;
let (y, m, d) = civil_from_days(days);
format!(
"{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z",
rem / 3600,
(rem % 3600) / 60,
rem % 60
)
}
fn civil_from_days(z: u64) -> (u64, u64, u64) {
let z = z + 719_468;
let era = z / 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 };
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_epoch_is_the_first_of_january() {
assert_eq!(iso8601(0), "2026-01-01T00:00:00Z");
assert_eq!(iso8601(86_400_000), "2026-01-02T00:00:00Z");
assert_eq!(iso8601(105_000), "2026-01-01T00:01:45Z");
}
#[test]
fn a_plan_is_read_into_cores_and_megabytes() {
assert_eq!(cores_of("2xCPU-4GB"), 2);
assert_eq!(memory_of("2xCPU-4GB"), 4096);
assert_eq!(memory_of("8xCPU-32GB"), 32768);
}
#[test]
fn maxiops_is_twenty_cents_a_gigabyte_month() {
let p = price("se-sto1");
let credits_per_gb_hour = p["prices"]["zone"][0]["storage_maxiops"]["price"].as_f64().unwrap();
let cents_per_gb_month = credits_per_gb_hour * 730.0;
assert!((cents_per_gb_month - 20.0).abs() < 0.5, "{cents_per_gb_month}");
}
#[test]
fn the_auth_failed_body_is_the_one_terraform_stops_on() {
let v = auth_failed("abc123");
assert_eq!(
v["type"],
"https://developers.upcloud.com/1.3/errors#ERROR_AUTHENTICATION_FAILED"
);
assert_eq!(v["correlation_id"], "abc123");
assert!(v["error"].is_null(), "it is problem+json, not the error envelope");
}
}