use foundation_db::core::state::FileStateStore;
use foundation_deployment::provider_client::ProviderClient;
use foundation_deployment::traits::{BoxFuture, Deployable};
use foundation_netio::shared::client::dns::SystemDnsResolver;
use serde::{Deserialize, Serialize};
use crate::client::HetznerClient;
use crate::types::{CreateServerRequest, HetznerError, Server, ServerStatus};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerDeclaration {
pub name: String,
pub server_type: String,
pub image: String,
pub location: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerDeployOutput {
#[serde(default = "hetzner_provider")]
pub provider: String,
pub id: i64,
pub name: String,
pub public_ip: String,
#[serde(default)]
pub identity: Option<String>,
#[serde(default)]
pub declared: Option<ServerDeclaration>,
}
fn hetzner_provider() -> String {
"hetzner".to_string()
}
pub const IDENTITY_LABEL: &str = "ewe.dev/deployment";
fn label_value(namespace: &str, instance_id: usize) -> String {
let mut value: String = format!("{namespace}.{instance_id}")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'.'
}
})
.collect();
value.truncate(63);
let start = value
.find(|c: char| c.is_ascii_alphanumeric())
.unwrap_or(value.len());
value.drain(..start);
while value
.chars()
.next_back()
.is_some_and(|c| !c.is_ascii_alphanumeric())
{
value.pop();
}
value
}
impl ServerDeployOutput {
#[must_use]
pub fn host(&self) -> String {
self.public_ip.clone()
}
}
#[derive(Debug, Clone)]
pub struct HetznerServer {
pub client: HetznerClient,
pub name: String,
pub server_type: String,
pub image: String,
pub location: Option<String>,
pub ssh_keys: Vec<i64>,
pub user_data: Option<String>,
pub keep_on_failure: bool,
}
impl HetznerServer {
#[must_use]
pub fn new(client: HetznerClient, name: impl Into<String>) -> Self {
Self {
client,
name: name.into(),
server_type: "cx23".to_string(),
image: "ubuntu-24.04".to_string(),
location: None,
ssh_keys: Vec::new(),
user_data: None,
keep_on_failure: false,
}
}
#[must_use]
pub fn server_type(mut self, server_type: impl Into<String>) -> Self {
self.server_type = server_type.into();
self
}
#[must_use]
pub fn image(mut self, image: impl Into<String>) -> Self {
self.image = image.into();
self
}
#[must_use]
pub fn location(mut self, location: impl Into<String>) -> Self {
self.location = Some(location.into());
self
}
#[must_use]
pub fn ssh_keys(mut self, ids: impl IntoIterator<Item = i64>) -> Self {
self.ssh_keys = ids.into_iter().collect();
self
}
#[must_use]
pub fn user_data(mut self, user_data: impl Into<String>) -> Self {
self.user_data = Some(user_data.into());
self
}
#[must_use]
pub fn keep_on_failure(mut self, keep: bool) -> Self {
self.keep_on_failure = keep;
self
}
fn is_usable(server: &Server) -> bool {
matches!(server.status, ServerStatus::Running | ServerStatus::Starting)
|| server.status == ServerStatus::Initializing
}
fn identity(instance_id: usize) -> String {
label_value(Self::NAMESPACE, instance_id)
}
fn declaration(&self) -> ServerDeclaration {
ServerDeclaration {
name: self.name.clone(),
server_type: self.server_type.clone(),
image: self.image.clone(),
location: self.location.clone(),
}
}
fn output_for(
server: &Server,
declared: &ServerDeclaration,
identity: &str,
) -> Result<ServerDeployOutput, HetznerError> {
let Some(public_ip) = server.public_ipv4.clone() else {
return Err(HetznerError::Api {
status: 200,
code: "no_public_ipv4".to_string(),
message: format!(
"server {} ({}) has no public IPv4 address — nothing can reach it over SSH",
server.id, server.name
),
});
};
if server.status != ServerStatus::Running {
return Err(HetznerError::Api {
status: 200,
code: "not_running".to_string(),
message: format!(
"server {} settled as {:?}, not running — it will not accept connections",
server.id, server.status
),
});
}
Ok(ServerDeployOutput {
provider: hetzner_provider(),
id: server.id,
name: server.name.clone(),
public_ip,
identity: Some(format!("{IDENTITY_LABEL}={identity}")),
declared: Some(declared.clone()),
})
}
}
impl Deployable for HetznerServer {
const NAMESPACE: &'static str = "hetzner/cloud/servers";
type DeployOutput = ServerDeployOutput;
type DestroyOutput = ();
type Error = HetznerError;
type Store = FileStateStore;
type Resolver = SystemDnsResolver;
fn deploy(
&self,
instance_id: usize,
client: ProviderClient<Self::Store, Self::Resolver>,
) -> BoxFuture<'static, Result<Self::DeployOutput, Self::Error>> {
let hetzner = self.client.clone();
let store = self.store(&client);
let request = CreateServerRequest {
name: self.name.clone(),
server_type: self.server_type.clone(),
image: self.image.clone(),
location: self.location.clone(),
ssh_keys: self.ssh_keys.clone(),
user_data: self.user_data.clone(),
labels: [(IDENTITY_LABEL.to_string(), Self::identity(instance_id))]
.into_iter()
.collect(),
};
let keep_on_failure = self.keep_on_failure;
let name = self.name.clone();
let declaration = self.declaration();
let identity = Self::identity(instance_id);
Box::pin(async move {
let key = instance_id.to_string();
let recorded: Option<ServerDeployOutput> = store
.get_typed(&key)
.map_err(|e| HetznerError::Transport(format!("load deploy state: {e}")))?;
if let Some(recorded) = &recorded {
if let Some(declared) = &recorded.declared {
if declared != &declaration {
return Err(HetznerError::Api {
status: 409,
code: "state_declaration_mismatch".to_string(),
message: format!(
"instance {instance_id}'s recorded state is stale against this \
declaration: it holds server {} built as {declared:?}, but the \
code now asks for {declaration:?}. Destroy instance \
{instance_id} first (which removes server {}), or deploy under \
a different instance_id — otherwise the recorded server would \
keep billing unnoticed.",
recorded.id, recorded.id
),
});
}
}
match hetzner.get_server(recorded.id).await? {
Some(server) if HetznerServer::is_usable(&server) => {
tracing::debug!(id = server.id, %name, "server already exists; reusing it");
let settled = hetzner.await_running(server.id).await?;
return HetznerServer::output_for(&settled, &declaration, &identity);
}
found => {
tracing::warn!(
id = recorded.id,
%name,
status = ?found.map(|s| s.status),
"recorded server no longer usable; creating a replacement (this bills a new instance)"
);
}
}
}
let ours = hetzner.find_servers_by_label(IDENTITY_LABEL, &identity).await?;
if let Some(existing) = ours.iter().find(|s| HetznerServer::is_usable(s)) {
{
tracing::debug!(
id = existing.id,
%identity,
"found an unrecorded server carrying this slot's label; adopting it \
rather than creating a second"
);
let settled = hetzner.await_running(existing.id).await?;
let output = HetznerServer::output_for(&settled, &declaration, &identity)?;
store
.store_typed(&key, &output)
.map_err(|e| HetznerError::Transport(format!("persist deploy state: {e}")))?;
return Ok(output);
}
}
let created_id = match hetzner.create_server(&request).await {
Ok(created) => created.id,
Err(e) => {
let ours = hetzner
.find_servers_by_label(IDENTITY_LABEL, &identity)
.await
.map_err(|lookup| {
tracing::error!(
%identity,
create_error = %e,
lookup_error = %lookup,
"create failed AND we cannot check whether it landed — check the \
console; a server may be billing"
);
e.clone()
})?;
match ours.first() {
Some(server) => {
tracing::warn!(
id = server.id,
%identity,
error = %e,
"create's response was unusable, but the server exists and \
carries this slot's label — adopting it"
);
server.id
}
None => return Err(e),
}
}
};
let finish = async {
let settled = hetzner.await_running(created_id).await?;
let output = HetznerServer::output_for(&settled, &declaration, &identity)?;
store
.store_typed(&key, &output)
.map_err(|e| HetznerError::Transport(format!("persist deploy state: {e}")))?;
Ok::<_, HetznerError>(output)
}
.await;
match finish {
Ok(output) => Ok(output),
Err(e) if keep_on_failure => {
tracing::warn!(
id = created_id,
error = %e,
"deploy failed; keeping the server because keep_on_failure is set — it is still billing"
);
Err(e)
}
Err(e) => {
tracing::warn!(id = created_id, error = %e, "deploy failed; destroying the server");
if let Err(cleanup) = hetzner.delete_server(created_id).await {
tracing::error!(
id = created_id,
error = %cleanup,
"could not destroy the server after a failed deploy — IT IS STILL BILLING"
);
}
let _ = store.remove(&key);
Err(e)
}
}
})
}
fn destroy(
&self,
instance_id: usize,
client: ProviderClient<Self::Store, Self::Resolver>,
) -> BoxFuture<'static, Result<Self::DestroyOutput, Self::Error>> {
let hetzner = self.client.clone();
let store = self.store(&client);
let name = self.name.clone();
let identity = Self::identity(instance_id);
Box::pin(async move {
let key = instance_id.to_string();
let recorded: Option<ServerDeployOutput> = store
.get_typed(&key)
.map_err(|e| HetznerError::Transport(format!("load deploy state: {e}")))?;
if let Some(recorded) = &recorded {
hetzner.delete_server(recorded.id).await?;
store
.remove(&key)
.map_err(|e| HetznerError::Transport(format!("clear deploy state: {e}")))?;
tracing::debug!(id = recorded.id, %name, "destroyed");
return Ok(());
}
let orphans = hetzner.find_servers_by_label(IDENTITY_LABEL, &identity).await?;
if orphans.is_empty() {
tracing::debug!(%identity, instance_id, "nothing recorded and nothing at Hetzner");
return Ok(());
}
for orphan in orphans {
tracing::warn!(
id = orphan.id,
%identity,
"nothing recorded, but Hetzner has a server with this slot's label — \
deleting it. Usually means a create succeeded and we failed to read the \
response."
);
hetzner.delete_server(orphan.id).await?;
}
Ok(())
})
}
}
#[cfg(test)]
mod tests {
use super::{label_value, HetznerServer, IDENTITY_LABEL};
#[test]
fn an_identity_is_a_legal_hetzner_label_value() {
let value = HetznerServer::identity(0);
assert_eq!(value, "hetzner.cloud.servers.0");
assert_valid(&value);
assert!(IDENTITY_LABEL.contains('/'), "the key is prefixed: {IDENTITY_LABEL}");
assert!(
!IDENTITY_LABEL.starts_with("hetzner.cloud/"),
"that prefix is reserved by Hetzner"
);
}
#[test]
fn a_hostile_namespace_still_yields_a_legal_value() {
for (ns, id) in [
("a/b/c", 0),
("with spaces", 3),
("sym+bols!", 9),
("trailing/", 1),
("ünicode", 2),
] {
let value = label_value(ns, id);
assert_valid(&value);
}
}
#[test]
fn an_over_long_namespace_is_truncated_and_still_ends_alphanumeric() {
let value = label_value(&"x".repeat(80), 0);
assert!(value.len() <= 63, "{} chars", value.len());
assert_valid(&value);
let value = label_value(&format!("{}.", "y".repeat(62)), 0);
assert_valid(&value);
}
fn assert_valid(value: &str) {
assert!(!value.is_empty(), "empty");
assert!(value.len() <= 63, "{value:?} is {} chars", value.len());
assert!(
value.chars().next().is_some_and(|c| c.is_ascii_alphanumeric()),
"{value:?} must begin alphanumeric"
);
assert!(
value.chars().next_back().is_some_and(|c| c.is_ascii_alphanumeric()),
"{value:?} must end alphanumeric"
);
assert!(
value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.'),
"{value:?} has an illegal character"
);
}
}