Skip to content

Commit 24cc8b6

Browse files
Separate hetzner-cli binary and fix worker provisioning
- Move Hetzner commands to separate hetzner-cli binary - Add list-server-types and list-datacenters commands - Fix server type/location compatibility (use ccx23 in nbg1) - Fix response parsing for Hetzner API - Fix worker background image path to /root/gpc-bg.png Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent b30e382 commit 24cc8b6

File tree

5 files changed

+314
-147
lines changed

5 files changed

+314
-147
lines changed

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,11 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
3636

3737
# HTTP client for Hetzner API
3838
base64 = "0.22"
39+
40+
[[bin]]
41+
name = "ffmpeg-gpc"
42+
path = "src/main.rs"
43+
44+
[[bin]]
45+
name = "hetzner-cli"
46+
path = "src/hetzner_cli.rs"

src/hetzner.rs

Lines changed: 167 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ impl Default for ServerConfig {
2020
fn default() -> Self {
2121
Self {
2222
name: "ffmpeg-worker".to_string(),
23-
server_type: "cpx21".to_string(), // 4 CPU, 8 GB RAM
23+
server_type: "cpx11".to_string(), // 4 CPU, 8 GB RAM
2424
image: "ubuntu-24.04".to_string(),
25-
location: "fsn1".to_string(), // Falkenstein
25+
location: "nbg1".to_string(), // Falkenstein
2626
ssh_keys: vec![],
2727
user_data: String::new(),
2828
labels: vec![("worker".to_string(), "ffmpeg-gpc".to_string())],
@@ -53,30 +53,39 @@ pub struct Ipv4 {
5353
pub ip: String,
5454
}
5555

56+
#[derive(Debug)]
57+
pub struct ServerType {
58+
pub name: String,
59+
pub cores: u32,
60+
pub memory: u32,
61+
pub disk: u32,
62+
pub locations: Vec<String>,
63+
}
64+
65+
#[derive(Debug)]
66+
pub struct Datacenter {
67+
pub name: String,
68+
pub location: String,
69+
pub server_types: Vec<String>,
70+
}
71+
5672
#[derive(Debug, Serialize)]
5773
struct CreateServerRequest {
5874
name: String,
5975
server_type: String,
6076
image: String,
61-
location: String,
77+
#[serde(skip_serializing_if = "Option::is_none")]
78+
location: Option<String>,
79+
#[serde(skip_serializing_if = "Option::is_none")]
6280
ssh_keys: Option<Vec<String>>,
81+
#[serde(skip_serializing_if = "Option::is_none")]
6382
user_data: Option<String>,
64-
labels: Option<Vec<Label>>,
65-
}
66-
67-
#[derive(Debug, Serialize, Deserialize)]
68-
struct Label {
69-
key: String,
70-
value: String,
83+
#[serde(skip_serializing_if = "Option::is_none")]
84+
labels: Option<std::collections::HashMap<String, String>>,
7185
}
7286

7387
#[derive(Debug, Deserialize)]
7488
struct CreateServerResponse {
75-
server: ServerData,
76-
}
77-
78-
#[derive(Debug, Deserialize)]
79-
struct ServerData {
8089
server: Server,
8190
}
8291

@@ -109,16 +118,7 @@ impl HetznerClient {
109118
let labels = if config.labels.is_empty() {
110119
None
111120
} else {
112-
Some(
113-
config
114-
.labels
115-
.iter()
116-
.map(|(k, v)| Label {
117-
key: k.clone(),
118-
value: v.clone(),
119-
})
120-
.collect(),
121-
)
121+
Some(config.labels.iter().cloned().collect())
122122
};
123123

124124
let ssh_keys = if config.ssh_keys.is_empty() {
@@ -133,17 +133,24 @@ impl HetznerClient {
133133
Some(config.user_data.clone())
134134
};
135135

136+
let location = if config.location.is_empty() {
137+
None
138+
} else {
139+
Some(config.location.clone())
140+
};
141+
136142
let payload = CreateServerRequest {
137143
name: config.name.clone(),
138144
server_type: config.server_type.clone(),
139145
image: config.image.clone(),
140-
location: config.location.clone(),
146+
location,
141147
ssh_keys,
142148
user_data,
143149
labels,
144150
};
145151

146-
debug!("Creating server: {}", config.name);
152+
debug!("Creating server: {} with type: {}, location: {:?}", config.name, config.server_type, &payload.location);
153+
debug!("Request payload: {:?}", serde_json::to_string(&payload));
147154

148155
let response = self
149156
.client
@@ -173,10 +180,10 @@ impl HetznerClient {
173180

174181
info!(
175182
"Server created: {} (ID: {}, IP: {})",
176-
result.server.server.name, result.server.server.id, result.server.server.public_net.ipv4.ip
183+
result.server.name, result.server.id, result.server.public_net.ipv4.ip
177184
);
178185

179-
Ok(result.server.server)
186+
Ok(result.server)
180187
}
181188

182189
pub async fn delete_server(&self, id: u64) -> Result<()> {
@@ -231,15 +238,136 @@ impl HetznerClient {
231238

232239
#[derive(Debug, Deserialize)]
233240
struct ListServersResponse {
234-
servers: Vec<ServerData>,
241+
servers: Vec<Server>,
235242
}
236243

237244
let result: ListServersResponse = response
238245
.json()
239246
.await
240247
.map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?;
241248

242-
Ok(result.servers.into_iter().map(|s| s.server).collect())
249+
Ok(result.servers)
250+
}
251+
252+
pub async fn list_server_types(&self) -> Result<Vec<ServerType>> {
253+
let url = format!("{}/server_types", HETZNER_API_BASE);
254+
255+
let response = self
256+
.client
257+
.get(&url)
258+
.header(AUTHORIZATION, format!("Bearer {}", self.api_token))
259+
.send()
260+
.await
261+
.map_err(|e| anyhow::anyhow!("Failed to send request: {}", e))?;
262+
263+
let status = response.status();
264+
265+
if !status.is_success() {
266+
let error_text = response.text().await.unwrap_or_default();
267+
return Err(anyhow::anyhow!(
268+
"Failed to list server types: {} - {}",
269+
status,
270+
error_text
271+
));
272+
}
273+
274+
#[derive(Debug, Deserialize)]
275+
struct ServerTypeResponse {
276+
name: String,
277+
cores: u32,
278+
memory: f64,
279+
disk: u32,
280+
prices: Vec<PriceInfo>,
281+
}
282+
283+
#[derive(Debug, Deserialize)]
284+
struct PriceInfo {
285+
location: String,
286+
}
287+
288+
#[derive(Debug, Deserialize)]
289+
struct ListServerTypesResponse {
290+
server_types: Vec<ServerTypeResponse>,
291+
}
292+
293+
let result: ListServerTypesResponse = response
294+
.json()
295+
.await
296+
.map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?;
297+
298+
Ok(result
299+
.server_types
300+
.into_iter()
301+
.map(|st| ServerType {
302+
name: st.name,
303+
cores: st.cores,
304+
memory: st.memory as u32,
305+
disk: st.disk,
306+
locations: st.prices.into_iter().map(|p| p.location).collect(),
307+
})
308+
.collect())
309+
}
310+
311+
pub async fn list_datacenters(&self) -> Result<Vec<Datacenter>> {
312+
let url = format!("{}/datacenters", HETZNER_API_BASE);
313+
314+
let response = self
315+
.client
316+
.get(&url)
317+
.header(AUTHORIZATION, format!("Bearer {}", self.api_token))
318+
.send()
319+
.await
320+
.map_err(|e| anyhow::anyhow!("Failed to send request: {}", e))?;
321+
322+
let status = response.status();
323+
324+
if !status.is_success() {
325+
let error_text = response.text().await.unwrap_or_default();
326+
return Err(anyhow::anyhow!(
327+
"Failed to list datacenters: {} - {}",
328+
status,
329+
error_text
330+
));
331+
}
332+
333+
#[derive(Debug, Deserialize)]
334+
struct Location {
335+
name: String,
336+
}
337+
338+
#[derive(Debug, Deserialize)]
339+
struct ServerTypesAvailable {
340+
available: Vec<u64>,
341+
available_for_migration: Vec<u64>,
342+
supported: Vec<u64>,
343+
}
344+
345+
#[derive(Debug, Deserialize)]
346+
struct DatacenterResponse {
347+
name: String,
348+
location: Location,
349+
server_types: ServerTypesAvailable,
350+
}
351+
352+
#[derive(Debug, Deserialize)]
353+
struct ListDatacentersResponse {
354+
datacenters: Vec<DatacenterResponse>,
355+
}
356+
357+
let result: ListDatacentersResponse = response
358+
.json()
359+
.await
360+
.map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?;
361+
362+
Ok(result
363+
.datacenters
364+
.into_iter()
365+
.map(|dc| Datacenter {
366+
name: dc.name,
367+
location: dc.location.name,
368+
server_types: dc.server_types.available.iter().map(|id| id.to_string()).collect(),
369+
})
370+
.collect())
243371
}
244372

245373
pub async fn get_server(&self, id: u64) -> Result<Server> {
@@ -264,7 +392,12 @@ impl HetznerClient {
264392
));
265393
}
266394

267-
let result: ServerData = response
395+
#[derive(Debug, Deserialize)]
396+
struct GetServerResponse {
397+
server: Server,
398+
}
399+
400+
let result: GetServerResponse = response
268401
.json()
269402
.await
270403
.map_err(|e| anyhow::anyhow!("Failed to parse response: {}", e))?;
@@ -340,9 +473,9 @@ pub async fn provision_worker(
340473

341474
let config = ServerConfig {
342475
name,
343-
server_type: "cpx21".to_string(),
476+
server_type: "ccx23".to_string(), // 4 dedicated vCPUs, 16GB RAM
344477
image: "ubuntu-24.04".to_string(),
345-
location: "fsn1".to_string(),
478+
location: "nbg1".to_string(), // Nuremberg, Germany
346479
user_data,
347480
..Default::default()
348481
};

0 commit comments

Comments
 (0)