From 45b561fadf718d6c9d04ffac219c8ac812e50ef6 Mon Sep 17 00:00:00 2001 From: Maxime Van Hees Date: Thu, 17 Jul 2025 13:38:16 +0200 Subject: [PATCH] added functionality to list different images --- Cargo.lock | 1 + Cargo.toml | 1 + src/async_handler.rs | 11 ++- src/hetzner_api.rs | 179 ++++++++++++++++++++++++++++++++++++++++++- src/rhai_api.rs | 80 ++++++++++++++++++- test.rhai | 8 ++ 6 files changed, 275 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c264294..b1c3819 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -467,6 +467,7 @@ dependencies = [ "reqwest", "rhai", "serde", + "serde_json", "tokio", ] diff --git a/Cargo.toml b/Cargo.toml index d6ba18c..bd9ceb6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,4 @@ tokio = { version = "1.46.1", features = ["full"] } ping = "0.6.1" prettytable-rs = "0.10.0" serde = "1.0.219" +serde_json = "1.0.122" diff --git a/src/async_handler.rs b/src/async_handler.rs index 43cdb35..486a070 100644 --- a/src/async_handler.rs +++ b/src/async_handler.rs @@ -1,5 +1,6 @@ use crate::hetzner_api::{ - HetznerClient, ServerBuilder, WrappedCreateServerResponse, WrappedServer, WrappedSshKey, + ListImagesParamsBuilder, HetznerClient, ServerBuilder, WrappedCreateServerResponse, + WrappedServer, WrappedSshKey, WrappedImage, }; use std::sync::mpsc::{Receiver, Sender}; use tokio::runtime::Builder; @@ -16,6 +17,7 @@ pub enum Request { EnableRescueModeWithAllKeys(HetznerClient, i64), DisableRescueMode(HetznerClient, i64), ListSshKeys(HetznerClient), + ListImages(HetznerClient, ListImagesParamsBuilder), } pub enum Response { @@ -28,6 +30,7 @@ pub enum Response { ResetServer(Result<(), String>), EnableRescueMode(Result), DisableRescueMode(Result<(), String>), + ListImages(Result, String>), } pub fn run_worker( @@ -42,6 +45,12 @@ pub fn run_worker( while let Ok(request) = command_rx.recv() { let response = match request { + Request::ListImages(client, builder) => { + let result = rt + .block_on(client.list_images(builder)) + .map_err(|e| e.to_string()); + Response::ListImages(result) + } Request::CreateServer(client, builder) => { let result = rt .block_on(client.create_server(builder)) diff --git a/src/hetzner_api.rs b/src/hetzner_api.rs index e75bdfe..1768b78 100644 --- a/src/hetzner_api.rs +++ b/src/hetzner_api.rs @@ -1,15 +1,18 @@ use hcloud::apis::{ configuration::Configuration, + images_api::{self, ListImagesParams}, servers_api::{ - self, DisableRescueModeForServerParams, EnableRescueModeForServerParams, ListServersParams, - ResetServerParams, CreateServerParams, + self, CreateServerParams, DisableRescueModeForServerParams, + EnableRescueModeForServerParams, ListServersParams, ResetServerParams, }, ssh_keys_api::{self, ListSshKeysParams}, }; use hcloud::models::{ - CreateServerRequest, CreateServerResponse, EnableRescueModeForServerRequest, Server, SshKey, + CreateServerRequest, CreateServerResponse, EnableRescueModeForServerRequest, Image, Server, + SshKey, }; use serde::{Deserialize, Serialize}; +use serde_json::Value; #[derive(Debug, Clone)] pub struct HetznerClient { @@ -22,6 +25,9 @@ pub struct WrappedServer(pub Server); #[derive(Clone)] pub struct WrappedSshKey(pub SshKey); +#[derive(Clone)] +pub struct WrappedImage(pub Image); + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ServerBuilder { pub name: String, @@ -93,6 +99,79 @@ impl ServerBuilder { #[derive(Clone)] pub struct WrappedCreateServerResponse(pub CreateServerResponse); +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct ListImagesParamsBuilder { + pub sort: Option, + pub r#type: Option, + pub status: Option, + pub bound_to: Option, + pub include_deprecated: Option, + pub name: Option, + pub label_selector: Option, + pub architecture: Option, +} + +impl ListImagesParamsBuilder { + pub fn new() -> Self { + Default::default() + } + + pub fn with_sort(mut self, sort: String) -> Self { + self.sort = Some(sort); + self + } + + pub fn with_type(mut self, r#type: String) -> Self { + self.r#type = Some(r#type); + self + } + + pub fn with_status(mut self, status: String) -> Self { + self.status = Some(status); + self + } + + pub fn with_bound_to(mut self, bound_to: String) -> Self { + self.bound_to = Some(bound_to); + self + } + + pub fn with_include_deprecated(mut self, include_deprecated: bool) -> Self { + self.include_deprecated = Some(include_deprecated); + self + } + + pub fn with_name(mut self, name: String) -> Self { + self.name = Some(name); + self + } + + pub fn with_label_selector(mut self, label_selector: String) -> Self { + self.label_selector = Some(label_selector); + self + } + + pub fn with_architecture(mut self, architecture: String) -> Self { + self.architecture = Some(architecture); + self + } + + fn build(self, page: i64, per_page: i64) -> ListImagesParams { + ListImagesParams { + sort: self.sort, + r#type: self.r#type, + status: self.status, + bound_to: self.bound_to, + include_deprecated: self.include_deprecated, + name: self.name, + label_selector: self.label_selector, + architecture: self.architecture, + page: Some(page), + per_page: Some(per_page), + } + } +} + impl HetznerClient { pub fn new(api_token: &str) -> Self { let mut configuration = Configuration::new(); @@ -250,4 +329,98 @@ impl HetznerClient { Ok(all_keys) } + // This function manually calls the Hetzner Cloud API to work around an issue in the + // `hcloud` crate where the `OsFlavor` enum is missing the `opensuse` variant. + // By parsing the JSON response ourselves, we can gracefully skip any images with + // unrecognized OS flavors, preventing deserialization errors. + pub async fn list_images( + &self, + builder: ListImagesParamsBuilder, + ) -> Result, Box> { + let mut all_images = Vec::new(); + let mut page = 1; + let per_page = 50; + + loop { + let mut url = "https://api.hetzner.cloud/v1/images".to_string(); + let mut query_params = Vec::new(); + + if let Some(sort) = &builder.sort { + query_params.push(format!("sort={}", sort)); + } + if let Some(r#type) = &builder.r#type { + query_params.push(format!("type={}", r#type)); + } + if let Some(status) = &builder.status { + query_params.push(format!("status={}", status)); + } + if let Some(bound_to) = &builder.bound_to { + query_params.push(format!("bound_to={}", bound_to)); + } + if let Some(include_deprecated) = builder.include_deprecated { + query_params.push(format!("include_deprecated={}", include_deprecated)); + } + if let Some(name) = &builder.name { + query_params.push(format!("name={}", name)); + } + if let Some(label_selector) = &builder.label_selector { + query_params.push(format!("label_selector={}", label_selector)); + } + if let Some(architecture) = &builder.architecture { + query_params.push(format!("architecture={}", architecture)); + } + + query_params.push(format!("page={}", page)); + query_params.push(format!("per_page={}", per_page)); + + if !query_params.is_empty() { + url.push('?'); + url.push_str(&query_params.join("&")); + } + + let response: Value = self + .configuration + .client + .get(&url) + .bearer_auth(self.configuration.bearer_access_token.as_ref().unwrap()) + .send() + .await? + .json() + .await?; + + if let Some(images_json) = response.get("images").and_then(|i| i.as_array()) { + if images_json.is_empty() { + break; + } + + let images: Vec = images_json + .iter() + .filter_map(|image_value| { + match serde_json::from_value::(image_value.clone()) { + Ok(image) => Some(WrappedImage(image)), + Err(_) => None, // Silently ignore images that can't be deserialized + } + }) + .collect(); + + all_images.extend(images); + } else { + break; + } + + if response + .get("meta") + .and_then(|m| m.get("pagination")) + .and_then(|p| p.get("next_page")) + .and_then(|np| np.as_null()) + .is_some() + { + break; + } + + page += 1; + } + + Ok(all_images) + } } diff --git a/src/rhai_api.rs b/src/rhai_api.rs index 47009cd..93a9828 100644 --- a/src/rhai_api.rs +++ b/src/rhai_api.rs @@ -1,7 +1,8 @@ use crate::async_handler::Request; use crate::async_handler::Response; use crate::hetzner_api::{ - HetznerClient, ServerBuilder, WrappedCreateServerResponse, WrappedServer, WrappedSshKey, + HetznerClient, ListImagesParamsBuilder, ServerBuilder, WrappedCreateServerResponse, + WrappedImage, WrappedServer, WrappedSshKey, }; use prettytable::{Cell, Row, Table}; use rhai::{Engine, EvalAltResult}; @@ -119,6 +120,83 @@ pub fn register_hetzner_api( } }); + engine + .register_type_with_name::("ListImagesParamsBuilder") + .register_fn("new_list_images_params_builder", ListImagesParamsBuilder::new) + .register_fn("with_sort", ListImagesParamsBuilder::with_sort) + .register_fn("with_type", ListImagesParamsBuilder::with_type) + .register_fn("with_status", ListImagesParamsBuilder::with_status) + .register_fn("with_bound_to", ListImagesParamsBuilder::with_bound_to) + .register_fn( + "with_include_deprecated", + ListImagesParamsBuilder::with_include_deprecated, + ) + .register_fn("with_name", ListImagesParamsBuilder::with_name) + .register_fn( + "with_label_selector", + ListImagesParamsBuilder::with_label_selector, + ) + .register_fn("with_architecture", ListImagesParamsBuilder::with_architecture); + + engine + .register_fn("list_images", { + let bridge = api_bridge.clone(); + move |client: &mut HetznerClient, builder: ListImagesParamsBuilder| { + bridge.call(Request::ListImages(client.clone(), builder), |response| { + match response { + Response::ListImages(result) => result.map_err(|e| e.into()), + _ => Err("Unexpected response".into()), + } + }) + } + }) + .register_type_with_name::("Image") + .register_get("id", |image: &mut WrappedImage| image.0.id) + .register_get("name", |image: &mut WrappedImage| image.0.name.clone()) + .register_get("description", |image: &mut WrappedImage| { + image.0.description.clone() + }) + .register_get("status", |image: &mut WrappedImage| { + format!("{:?}", image.0.status) + }) + .register_get("type", |image: &mut WrappedImage| format!("{:?}", image.0.r#type)) + .register_get("created", |image: &mut WrappedImage| image.0.created.clone()) + .register_get("os_flavor", |image: &mut WrappedImage| { + format!("{:?}", image.0.os_flavor) + }) + .register_get("os_version", |image: &mut WrappedImage| { + image.0.os_version.clone() + }); + + engine + .register_iterator::>() + .register_fn( + "show_table", + |images: &mut Vec| -> Result> { + let mut table = Table::new(); + table.set_titles(Row::new(vec![Cell::new("Images").style_spec("c")])); + table.add_row(Row::new(vec![ + Cell::new("ID"), + Cell::new("Name"), + Cell::new("Description"), + Cell::new("Type"), + Cell::new("OS Flavor"), + Cell::new("OS Version"), + ])); + for image in images { + table.add_row(Row::new(vec![ + Cell::new(&image.0.id.to_string()), + Cell::new(&image.0.name.clone().unwrap_or("".to_string())), + Cell::new(&image.0.description), + Cell::new(&format!("{:?}", image.0.r#type)), + Cell::new(&format!("{:?}", image.0.os_flavor)), + Cell::new(&image.0.os_version.clone().unwrap_or("".to_string())), + ])); + } + Ok(table.to_string()) + }, + ); + engine .register_type_with_name::("ServerBuilder") .register_fn( diff --git a/test.rhai b/test.rhai index 3a92306..7f67856 100644 --- a/test.rhai +++ b/test.rhai @@ -5,11 +5,19 @@ let client = new_hetzner_client(HETZNER_API_TOKEN); print("Listing all servers..."); let servers = client.list_servers(); print(servers.show_table()); + // List all SSH keys and print in table print("Listing all SSH keys..."); let ssh_keys = client.list_ssh_keys(); print(ssh_keys.show_table()); +// List all images +let params = new_list_images_params_builder() + // .with_type("snapshot") + .with_status("available"); +let images = client.list_images(params); +print(images.show_table()); + // Get server through ID and print details in table print("Listing details from server with ID 104301883..."); let test_server = client.get_server(104301883);