use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use foundation_core::valtron::valtron_test;
use foundation_db::core::state::traits::StateStore;
use foundation_db::core::state::FileStateStore;
use foundation_deployment::provider_client::ProviderClient;
use foundation_deployment::traits::Deployable;
use foundation_deployment_hetzner::generated::servers::{
CreateServerResponse, CreateServerResponseServer, CreateServerResponseServerPublicNet,
CreateServerResponseServerPublicNetIpv4, GetServerResponse, GetServerResponseServer,
GetServerResponseServerPublicNet, GetServerResponseServerPublicNetIpv4, ListServersResponse,
ListServersResponseServersItem, ListServersResponseServersItemPublicNet,
ListServersResponseServersItemPublicNetIpv4,
};
use foundation_deployment_hetzner::{HetznerClient, HetznerServer};
use foundation_netio::shared::client::dns::SystemDnsResolver;
use foundation_netio::http::NativeHttpClient;
use foundation_testing::http::{HttpResponse, TestHttpServer};
type Calls = Arc<Mutex<Vec<String>>>;
fn get_server_json(id: i64, name: &str, status: &str, ip: &str) -> String {
serde_json::to_string(&GetServerResponse {
server: Some(GetServerResponseServer {
id,
name: name.to_string(),
status: status.to_string(),
public_net: GetServerResponseServerPublicNet {
ipv4: Some(GetServerResponseServerPublicNetIpv4 {
ip: ip.to_string(),
..Default::default()
}),
..Default::default()
},
..Default::default()
}),
})
.expect("serializes")
}
fn create_server_json(id: i64, name: &str, status: &str, ip: &str) -> String {
serde_json::to_string(&CreateServerResponse {
server: CreateServerResponseServer {
id,
name: name.to_string(),
status: status.to_string(),
public_net: CreateServerResponseServerPublicNet {
ipv4: Some(CreateServerResponseServerPublicNetIpv4 {
ip: ip.to_string(),
..Default::default()
}),
..Default::default()
},
..Default::default()
},
root_password: Some("hunter2".to_string()),
..Default::default()
})
.expect("serializes")
}
fn list_servers_json(servers: &[(i64, &str, &str, &str)]) -> String {
serde_json::to_string(&ListServersResponse {
servers: servers
.iter()
.map(|(id, name, status, ip)| ListServersResponseServersItem {
id: *id,
name: (*name).to_string(),
status: (*status).to_string(),
public_net: ListServersResponseServersItemPublicNet {
ipv4: Some(ListServersResponseServersItemPublicNetIpv4 {
ip: (*ip).to_string(),
..Default::default()
}),
..Default::default()
},
..Default::default()
})
.collect(),
..Default::default()
})
.expect("serializes")
}
fn json(status: u16, body: &str) -> HttpResponse {
HttpResponse {
status,
status_text: "OK".to_string(),
headers: vec![
("Content-Type".to_string(), "application/json".to_string()),
("Content-Length".to_string(), body.len().to_string()),
],
body: body.as_bytes().to_vec(),
}
}
fn provider_client(dir: &std::path::Path) -> ProviderClient<FileStateStore, SystemDnsResolver> {
let store = FileStateStore::new(dir, "ewe-test", "dev");
store.init().expect("init state store");
ProviderClient::new("ewe-test", "dev", store, NativeHttpClient::default())
}
fn tmpdir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("ewe-hz-deploy-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn mock(responder: impl Fn(&str, usize) -> HttpResponse + Send + 'static) -> (TestHttpServer, Calls) {
let calls: Calls = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::clone(&calls);
let n = AtomicUsize::new(0);
let server = TestHttpServer::with_response(move |req| {
let call = format!("{:?} {}", req.method, req.path);
recorded.lock().unwrap().push(call.clone());
responder(&call, n.fetch_add(1, Ordering::SeqCst))
});
(server, calls)
}
fn is_list_query(call: &str) -> bool {
call.contains("label_selector") || call.contains("name=")
}
fn server_for(mock: &TestHttpServer, name: &str) -> HetznerServer {
HetznerServer::new(
HetznerClient::new("test-token").with_base_url(mock.base_url().to_string()),
name,
)
}
#[valtron_test]
async fn a_dying_server_does_not_count_as_alive() {
let dir = tmpdir("dying");
let (mock_server, calls) = mock(|call, _| {
if call.contains("POST") {
json(201, &create_server_json(43, "web-1", "initializing", "5.6.7.8"))
} else if is_list_query(call) {
json(200, &list_servers_json(&[]))
} else if call.contains("/servers/42") {
json(200, &get_server_json(42, "web-1", "deleting", "1.2.3.4"))
} else {
json(200, &get_server_json(43, "web-1", "running", "5.6.7.8"))
}
});
let deployable = server_for(&mock_server, "web-1");
let client = provider_client(&dir);
deployable
.store(&client)
.store_typed(
"0",
&foundation_deployment_hetzner::ServerDeployOutput {
provider: "hetzner".to_string(),
id: 42,
name: "web-1".to_string(),
public_ip: "1.2.3.4".to_string(),
identity: Some("ewe-deployment=hetzner/cloud/servers/0".to_string()),
declared: Some(foundation_deployment_hetzner::deployable::ServerDeclaration {
name: "web-1".to_string(),
server_type: "cx23".to_string(),
image: "ubuntu-24.04".to_string(),
location: None,
}),
},
)
.expect("seeds state");
let out = deployable.deploy(0, client.clone()).await.expect("replaces the corpse");
assert_eq!(out.id, 43, "did not return the deleting server");
assert!(
calls.lock().unwrap().iter().any(|c| c.contains("POST")),
"a dying box is gone, so a new one is created"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[valtron_test]
async fn a_failure_after_create_destroys_the_server_rather_than_stranding_a_bill() {
let dir = tmpdir("unwind");
let (mock_server, calls) = mock(|call, _| {
if call.contains("POST") {
json(201, &create_server_json(42, "web-1", "initializing", "1.2.3.4"))
} else if is_list_query(call) {
json(200, &list_servers_json(&[]))
} else if call.contains("DELETE") {
json(200, "{}")
} else {
json(200, &get_server_json(42, "web-1", "off", "1.2.3.4"))
}
});
let err = server_for(&mock_server, "web-1")
.deploy(0, provider_client(&dir))
.await
.expect_err("the box never came up");
assert!(err.to_string().contains("not running"), "{err}");
let seen = calls.lock().unwrap().clone();
assert!(
seen.iter().any(|c| c.contains("DELETE")),
"a created-but-unusable server must be destroyed, not left billing: {seen:?}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[valtron_test]
async fn keep_on_failure_leaves_the_server_for_inspection() {
let dir = tmpdir("keep");
let (mock_server, calls) = mock(|call, _| {
if call.contains("POST") {
json(201, &create_server_json(42, "web-1", "initializing", "1.2.3.4"))
} else if is_list_query(call) {
json(200, &list_servers_json(&[]))
} else if call.contains("DELETE") {
json(200, "{}")
} else {
json(200, &get_server_json(42, "web-1", "off", "1.2.3.4"))
}
});
let _ = server_for(&mock_server, "web-1")
.keep_on_failure(true)
.deploy(0, provider_client(&dir))
.await
.expect_err("still fails");
assert!(
!calls.lock().unwrap().iter().any(|c| c.contains("DELETE")),
"keep_on_failure means the box stays — and keeps billing, which the log says"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[valtron_test]
async fn a_server_with_no_public_ip_is_refused_rather_than_returned() {
let dir = tmpdir("no-ip");
let (mock_server, _) = mock(|call, _| {
if call.contains("POST") {
json(201, &create_server_json(42, "web-1", "initializing", ""))
} else if is_list_query(call) {
json(200, &list_servers_json(&[]))
} else if call.contains("DELETE") {
json(200, "{}")
} else {
json(200, &get_server_json(42, "web-1", "running", ""))
}
});
let err = server_for(&mock_server, "web-1")
.deploy(0, provider_client(&dir))
.await
.expect_err("no address");
assert!(err.to_string().contains("no public IPv4"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[valtron_test]
async fn a_create_whose_response_we_cannot_read_still_yields_the_server() {
let dir = tmpdir("create-unreadable");
let deleted: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let log = Arc::clone(&deleted);
let exists = Arc::new(Mutex::new(false));
let state = Arc::clone(&exists);
let (mock_server, _) = mock(move |call, _| {
if call.contains("POST") {
*state.lock().unwrap() = true;
json(201, r#"{"server":"this is not a server object"}"#)
} else if call.contains("DELETE") {
log.lock().unwrap().push(call.to_string());
json(200, "{}")
} else if is_list_query(call) {
if *state.lock().unwrap() {
json(200, &list_servers_json(&[(42, "web-1", "running", "1.2.3.4")]))
} else {
json(200, &list_servers_json(&[]))
}
} else {
json(200, &get_server_json(42, "web-1", "running", "1.2.3.4"))
}
});
let out = server_for(&mock_server, "web-1")
.deploy(0, provider_client(&dir))
.await
.expect("the server exists and carries our label — deploy must adopt it");
assert_eq!(out.id, 42, "adopted the server the create actually made");
assert_eq!(out.public_ip, "1.2.3.4", "and read its details back from Hetzner");
assert!(
deleted.lock().unwrap().is_empty(),
"it must NOT destroy a server the caller asked for just because the receipt was \
unreadable: {:?}",
deleted.lock().unwrap()
);
let _ = std::fs::remove_dir_all(&dir);
}