use foundation_deployment::providers::common::ApiError;
use foundation_netio::shared::http::{SimpleHeader, SimpleHeaders};
use foundation_netio::PreparedRequestBuilder;
use crate::client::HetznerClient;
use crate::generated::servers::{
create_server_request, delete_server_request, get_server_request, list_servers_request,
CreateServerArgs, CreateServerRequest as WireCreateServerRequest, DeleteServerArgs,
GetServerArgs, ListServersArgs,
};
use crate::generated::ssh_keys::{
create_ssh_key_request, list_ssh_keys_request, CreateSshKeyArgs, CreateSshKeyRequest,
ListSshKeysArgs,
};
use crate::types::{CreateServerRequest, HetznerError, Server, ServerStatus, SshKey};
const AWAIT_RUNNING_TIMEOUT_SECS: u64 = 300;
const POLL_INTERVAL_SECS: u64 = 2;
fn auth_mod(token: &str) -> impl FnOnce(&mut PreparedRequestBuilder) {
let token = token.to_string();
move |b: &mut PreparedRequestBuilder| {
b.set_header(SimpleHeader::AUTHORIZATION, format!("Bearer {token}"));
}
}
pub(crate) fn map_api_error(err: ApiError) -> HetznerError {
match err {
ApiError::HttpStatus { code, headers, body } => match code {
401 | 403 => HetznerError::Unauthorized,
429 => HetznerError::RateLimited {
retry_after_secs: rate_limit_reset_in(&headers),
},
_ => {
let (error_code, message) = parse_error_body(body.as_deref());
HetznerError::Api {
status: code,
code: error_code,
message,
}
}
},
ApiError::RequestBuildFailed(e) | ApiError::RequestSendFailed(e) => {
HetznerError::Transport(e)
}
ApiError::ParseFailed(e) => HetznerError::Decode(e),
}
}
fn parse_error_body(body: Option<&str>) -> (String, String) {
let Some(body) = body else {
return ("unknown".to_string(), "no response body".to_string());
};
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return ("unparseable".to_string(), body.to_string());
};
let error = value.get("error");
let code = error
.and_then(|e| e.get("code"))
.and_then(|c| c.as_str())
.unwrap_or("unknown")
.to_string();
let message = error
.and_then(|e| e.get("message"))
.and_then(|m| m.as_str())
.unwrap_or(body)
.to_string();
(code, message)
}
fn rate_limit_reset_in(headers: &SimpleHeaders) -> Option<u64> {
let reset = headers
.get(&SimpleHeader::Custom("RateLimit-Reset".to_string()))
.or_else(|| headers.get(&SimpleHeader::Custom("ratelimit-reset".to_string())))?
.first()?
.parse::<u64>()
.ok()?;
Some(reset.saturating_sub(now_secs()))
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
impl HetznerClient {
pub async fn create_server(&self, req: &CreateServerRequest) -> Result<Server, HetznerError> {
let args = CreateServerArgs {
body: WireCreateServerRequest {
name: req.name.clone(),
server_type: req.server_type.clone(),
image: req.image.clone(),
location: req.location.clone(),
ssh_keys: Some(req.ssh_keys.iter().map(i64::to_string).collect()),
user_data: req.user_data.clone(),
labels: (!req.labels.is_empty())
.then(|| serde_json::to_value(&req.labels).unwrap_or_default()),
start_after_create: Some(true),
..Default::default()
},
};
let response = create_server_request(
self.http(),
&args,
self.base_url(),
Some(auth_mod(&self.token())),
)
.await
.map_err(map_api_error)?;
let server = &response.body.server;
Ok(Server {
id: server.id,
name: server.name.clone(),
status: parse_status(&server.status),
public_ipv4: server
.public_net
.ipv4
.as_ref()
.and_then(|v4| non_empty(&v4.ip)),
})
}
pub async fn get_server(&self, id: i64) -> Result<Option<Server>, HetznerError> {
let args = GetServerArgs { id: id.to_string() };
let response = match get_server_request(
self.http(),
&args,
self.base_url(),
Some(auth_mod(&self.token())),
)
.await
{
Ok(response) => response,
Err(ApiError::HttpStatus { code: 404, .. }) => return Ok(None),
Err(e) => return Err(map_api_error(e)),
};
let Some(server) = &response.body.server else {
return Ok(None);
};
Ok(Some(Server {
id: server.id,
name: server.name.clone(),
status: parse_status(&server.status),
public_ipv4: server
.public_net
.ipv4
.as_ref()
.and_then(|v4| non_empty(&v4.ip)),
}))
}
pub async fn list_servers(&self, name: Option<&str>) -> Result<Vec<Server>, HetznerError> {
let args = ListServersArgs {
name: name.map(str::to_string),
..Default::default()
};
let response = list_servers_request(
self.http(),
&args,
self.base_url(),
Some(auth_mod(&self.token())),
)
.await
.map_err(map_api_error)?;
Ok(response
.body
.servers
.iter()
.map(|server| Server {
id: server.id,
name: server.name.clone(),
status: parse_status(&server.status),
public_ipv4: server
.public_net
.ipv4
.as_ref()
.and_then(|v4| non_empty(&v4.ip)),
})
.collect())
}
pub async fn find_servers_by_label(
&self,
key: &str,
value: &str,
) -> Result<Vec<Server>, HetznerError> {
let args = ListServersArgs {
label_selector: Some(format!("{key}={value}")),
..Default::default()
};
let response = list_servers_request(
self.http(),
&args,
self.base_url(),
Some(auth_mod(&self.token())),
)
.await
.map_err(map_api_error)?;
Ok(response
.body
.servers
.iter()
.map(|server| Server {
id: server.id,
name: server.name.clone(),
status: parse_status(&server.status),
public_ipv4: server
.public_net
.ipv4
.as_ref()
.and_then(|v4| non_empty(&v4.ip)),
})
.collect())
}
pub async fn find_server_by_name(&self, name: &str) -> Result<Option<Server>, HetznerError> {
Ok(self
.list_servers(Some(name))
.await?
.into_iter()
.find(|s| s.name == name))
}
pub async fn delete_server(&self, id: i64) -> Result<(), HetznerError> {
let args = DeleteServerArgs { id: id.to_string() };
match delete_server_request(
self.http(),
&args,
self.base_url(),
Some(auth_mod(&self.token())),
)
.await
{
Ok(_) => Ok(()),
Err(ApiError::HttpStatus { code: 404, .. }) => Ok(()),
Err(e) => Err(map_api_error(e)),
}
}
pub async fn list_ssh_keys(&self) -> Result<Vec<SshKey>, HetznerError> {
let response = list_ssh_keys_request(
self.http(),
&ListSshKeysArgs::default(),
self.base_url(),
Some(auth_mod(&self.token())),
)
.await
.map_err(map_api_error)?;
Ok(response
.body
.ssh_keys
.iter()
.map(|key| SshKey {
id: key.id,
name: key.name.clone(),
fingerprint: key.fingerprint.clone(),
})
.collect())
}
pub async fn create_ssh_key(
&self,
name: &str,
public_key: &str,
) -> Result<SshKey, HetznerError> {
let args = CreateSshKeyArgs {
body: CreateSshKeyRequest {
name: name.to_string(),
public_key: public_key.to_string(),
..Default::default()
},
};
let response = create_ssh_key_request(
self.http(),
&args,
self.base_url(),
Some(auth_mod(&self.token())),
)
.await
.map_err(map_api_error)?;
let key = &response.body.ssh_key;
Ok(SshKey {
id: key.id,
name: key.name.clone(),
fingerprint: key.fingerprint.clone(),
})
}
pub async fn ensure_ssh_key(
&self,
name: &str,
public_key: &str,
) -> Result<SshKey, HetznerError> {
match self.create_ssh_key(name, public_key).await {
Ok(key) => Ok(key),
Err(HetznerError::Api { ref code, .. }) if code == "uniqueness_error" => {
let existing = self.list_ssh_keys().await?;
existing
.iter()
.find(|k| k.name == name)
.cloned()
.ok_or_else(|| HetznerError::Api {
status: 409,
code: "uniqueness_error".to_string(),
message: format!(
"Hetzner rejected SSH key {name:?} as a duplicate, but no key of that \
name is in the project — the public key likely matches a key \
registered under a different name"
),
})
}
Err(e) => Err(e),
}
}
pub async fn await_running(&self, id: i64) -> Result<Server, HetznerError> {
let deadline = now_secs() + AWAIT_RUNNING_TIMEOUT_SECS;
loop {
match self.get_server(id).await {
Ok(Some(server)) if server.status.is_settled() => return Ok(server),
Ok(Some(_)) => {}
Ok(None) => {
return Err(HetznerError::Api {
status: 404,
code: "not_found".to_string(),
message: format!("server {id} disappeared while waiting for it to boot"),
})
}
Err(HetznerError::RateLimited { .. }) => {}
Err(e) => return Err(e),
}
if now_secs() >= deadline {
return Err(HetznerError::Timeout {
waiting_for: format!("server {id} to finish building"),
after_secs: AWAIT_RUNNING_TIMEOUT_SECS,
});
}
sleep_secs(POLL_INTERVAL_SECS).await;
}
}
}
fn parse_status(status: &str) -> ServerStatus {
serde_json::from_value(serde_json::Value::String(status.to_string()))
.unwrap_or(ServerStatus::Other)
}
fn non_empty(value: &str) -> Option<String> {
(!value.trim().is_empty()).then(|| value.to_string())
}
async fn sleep_secs(secs: u64) {
foundation_core::valtron::sleep_async(std::time::Duration::from_secs(secs)).await;
}