Skip to content

Commit 86f69b7

Browse files
Alex Holmbergclaude
authored andcommitted
fix(hetzner): use /api/v1/cloud-runner/hetzner/options endpoint
Switch to the same endpoint the frontend uses for Hetzner options: - Uses /api/v1/cloud-runner/hetzner/options?projectId=xxx - Returns both locations and server types in one API call - Same endpoint as getHetznerOptions in frontend client.ts This endpoint is verified to work with the current backend and provides real-time Hetzner data including: - Locations with city/country/network zone - Server types with pricing and availability per location - Deprecation status for server types Also added "token" to the NoCredentials detection patterns. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5799bb8 commit 86f69b7

3 files changed

Lines changed: 108 additions & 9 deletions

File tree

src/platform/api/client.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -855,6 +855,26 @@ impl PlatformApiClient {
855855
// Hetzner Availability API methods (Dynamic Resource Fetching)
856856
// =========================================================================
857857

858+
/// Get Hetzner options (locations and server types) with real-time data
859+
///
860+
/// Uses the /api/v1/cloud-runner/hetzner/options endpoint which returns
861+
/// both locations and server types in one call. This is the same endpoint
862+
/// used by the frontend for Hetzner infrastructure selection.
863+
///
864+
/// Endpoint: GET /api/v1/cloud-runner/hetzner/options?projectId=:projectId
865+
pub async fn get_hetzner_options(
866+
&self,
867+
project_id: &str,
868+
) -> Result<super::types::HetznerOptionsData> {
869+
let response: super::types::HetznerOptionsResponse = self
870+
.get(&format!(
871+
"/api/v1/cloud-runner/hetzner/options?projectId={}",
872+
urlencoding::encode(project_id)
873+
))
874+
.await?;
875+
Ok(response.data)
876+
}
877+
858878
/// Get Hetzner locations with real-time availability information
859879
///
860880
/// Returns all Hetzner locations with the server types currently available

src/platform/api/types.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,6 +1307,55 @@ pub struct ServerTypesResponse {
13071307
pub data: Vec<ServerTypeSummary>,
13081308
}
13091309

1310+
// =============================================================================
1311+
// Hetzner Options Types (from /api/v1/cloud-runner/hetzner/options)
1312+
// =============================================================================
1313+
1314+
/// Simple Hetzner location (from getHetznerOptions endpoint)
1315+
#[derive(Debug, Clone, Serialize, Deserialize)]
1316+
#[serde(rename_all = "camelCase")]
1317+
pub struct HetznerSimpleLocation {
1318+
pub id: i64,
1319+
pub name: String,
1320+
pub description: String,
1321+
pub city: String,
1322+
pub country: String,
1323+
pub network_zone: String,
1324+
}
1325+
1326+
/// Hetzner server type with pricing (from getHetznerOptions endpoint)
1327+
#[derive(Debug, Clone, Serialize, Deserialize)]
1328+
#[serde(rename_all = "camelCase")]
1329+
pub struct HetznerSimpleServerType {
1330+
pub id: i64,
1331+
pub name: String,
1332+
pub description: String,
1333+
pub cores: i32,
1334+
pub memory: f64,
1335+
pub disk: i64,
1336+
pub cpu_type: String,
1337+
pub architecture: String,
1338+
pub deprecated: bool,
1339+
#[serde(default)]
1340+
pub available_locations: Vec<String>,
1341+
#[serde(default)]
1342+
pub price_monthly: f64,
1343+
}
1344+
1345+
/// Combined Hetzner options response
1346+
#[derive(Debug, Clone, Deserialize)]
1347+
#[serde(rename_all = "camelCase")]
1348+
pub struct HetznerOptionsData {
1349+
pub locations: Vec<HetznerSimpleLocation>,
1350+
pub server_types: Vec<HetznerSimpleServerType>,
1351+
}
1352+
1353+
/// Wrapped response for getHetznerOptions
1354+
#[derive(Debug, Clone, Deserialize)]
1355+
pub struct HetznerOptionsResponse {
1356+
pub data: HetznerOptionsData,
1357+
}
1358+
13101359
#[cfg(test)]
13111360
mod tests {
13121361
use super::*;

src/wizard/cloud_provider_data.rs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ fn server_type_to_dynamic(st: &ServerTypeSummary) -> DynamicMachineType {
195195

196196
/// Fetch Hetzner regions dynamically with real-time availability
197197
///
198+
/// Uses the /api/v1/cloud-runner/hetzner/options endpoint (same as frontend).
198199
/// Returns regions with availability info directly from Hetzner API.
199200
/// The agent uses this to make smart deployment decisions based on actual capacity.
200201
///
@@ -204,13 +205,26 @@ pub async fn get_hetzner_regions_dynamic(
204205
client: &PlatformApiClient,
205206
project_id: &str,
206207
) -> HetznerFetchResult<Vec<DynamicCloudRegion>> {
207-
match client.get_hetzner_locations(project_id).await {
208-
Ok(locations) => {
209-
HetznerFetchResult::Success(locations.iter().map(location_to_dynamic_region).collect())
208+
match client.get_hetzner_options(project_id).await {
209+
Ok(options) => {
210+
let regions: Vec<DynamicCloudRegion> = options.locations.iter().map(|loc| {
211+
DynamicCloudRegion {
212+
id: loc.name.clone(),
213+
name: loc.city.clone(),
214+
location: loc.country.clone(),
215+
network_zone: loc.network_zone.clone(),
216+
// Find server types available at this location
217+
available_server_types: options.server_types.iter()
218+
.filter(|st| st.available_locations.contains(&loc.name))
219+
.map(|st| st.name.clone())
220+
.collect(),
221+
}
222+
}).collect();
223+
HetznerFetchResult::Success(regions)
210224
}
211225
Err(e) => {
212226
let error_msg = e.to_string();
213-
if error_msg.contains("credentials") || error_msg.contains("Unauthorized") {
227+
if error_msg.contains("credentials") || error_msg.contains("Unauthorized") || error_msg.contains("token") {
214228
HetznerFetchResult::NoCredentials
215229
} else {
216230
HetznerFetchResult::ApiError(error_msg)
@@ -221,6 +235,7 @@ pub async fn get_hetzner_regions_dynamic(
221235

222236
/// Fetch Hetzner server types dynamically with pricing and availability
223237
///
238+
/// Uses the /api/v1/cloud-runner/hetzner/options endpoint (same as frontend).
224239
/// Returns server types sorted by monthly price (cheapest first) with
225240
/// real-time availability per region. The agent uses this for cost-optimized
226241
/// resource selection.
@@ -230,15 +245,30 @@ pub async fn get_hetzner_regions_dynamic(
230245
pub async fn get_hetzner_server_types_dynamic(
231246
client: &PlatformApiClient,
232247
project_id: &str,
233-
preferred_location: Option<&str>,
248+
_preferred_location: Option<&str>,
234249
) -> HetznerFetchResult<Vec<DynamicMachineType>> {
235-
match client.get_hetzner_server_types(project_id, preferred_location).await {
236-
Ok(server_types) => {
237-
HetznerFetchResult::Success(server_types.iter().map(server_type_to_dynamic).collect())
250+
match client.get_hetzner_options(project_id).await {
251+
Ok(options) => {
252+
let mut server_types: Vec<DynamicMachineType> = options.server_types.iter()
253+
.filter(|st| !st.deprecated)
254+
.map(|st| DynamicMachineType {
255+
id: st.name.clone(),
256+
name: st.name.clone(),
257+
cores: st.cores,
258+
memory_gb: st.memory,
259+
disk_gb: st.disk,
260+
price_monthly: st.price_monthly,
261+
price_hourly: st.price_monthly / 730.0, // Approximate hourly from monthly
262+
available_in: st.available_locations.clone(),
263+
})
264+
.collect();
265+
// Sort by price (cheapest first)
266+
server_types.sort_by(|a, b| a.price_monthly.partial_cmp(&b.price_monthly).unwrap());
267+
HetznerFetchResult::Success(server_types)
238268
}
239269
Err(e) => {
240270
let error_msg = e.to_string();
241-
if error_msg.contains("credentials") || error_msg.contains("Unauthorized") {
271+
if error_msg.contains("credentials") || error_msg.contains("Unauthorized") || error_msg.contains("token") {
242272
HetznerFetchResult::NoCredentials
243273
} else {
244274
HetznerFetchResult::ApiError(error_msg)

0 commit comments

Comments
 (0)