From ea66636c052fdbe414d088b979ed14b2b0c156f1 Mon Sep 17 00:00:00 2001 From: Mahmoud-Emad Date: Thu, 17 Jul 2025 15:56:33 +0300 Subject: [PATCH] feat: Add server rebuild example using install_image - Added a new example script demonstrating server rebuilds using the `install_image` function. - The example shows how to list available images (system, backup, etc.), select an image, and initiate a rebuild. - Includes comprehensive error handling and progress monitoring. - Improved documentation with detailed explanations and usage instructions. --- README.md | 40 ++++++-- examples/07_install_image.rhai | 161 +++++++++++++++++++++++++++++++++ src/async_handler.rs | 48 +++++++--- src/hetzner_api.rs | 68 +++++++------- src/rhai_api.rs | 76 +++++++++++++--- 5 files changed, 325 insertions(+), 68 deletions(-) create mode 100644 examples/07_install_image.rhai diff --git a/README.md b/README.md index b7de94e..18361b5 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,37 @@ cargo run -- examples/01_create_server.rhai The `examples/` directory contains a collection of scripts demonstrating the available functionality. For detailed examples, please see the files in that directory: -- [`examples/01_create_server.rhai`](examples/01_create_server.rhai): Shows how to create a new server with various configuration options using a builder pattern. -- [`examples/02_list_servers.rhai`](examples/02_list_servers.rhai): Lists all servers in your project. -- [`examples/03_get_server_details.rhai`](examples/03_get_server_details.rhai): Fetches and displays detailed information for a single server. -- [`examples/04_server_actions.rhai`](examples/04_server_actions.rhai): Demonstrates how to reboot, reset, and manage rescue mode for a server. -- [`examples/05_list_ssh_keys.rhai`](examples/05_list_ssh_keys.rhai): Lists all SSH keys in your project. -- [`examples/06_list_images.rhai`](examples/06_list_images.rhai): Shows how to list system images and snapshots, with examples of filtering and sorting. +- [`examples/01_create_server.rhai`](examples/01_create_server.rhai): Shows how to create a new server with various configuration options using a builder pattern. +- [`examples/02_list_servers.rhai`](examples/02_list_servers.rhai): Lists all servers in your project. +- [`examples/03_get_server_details.rhai`](examples/03_get_server_details.rhai): Fetches and displays detailed information for a single server. +- [`examples/04_server_actions.rhai`](examples/04_server_actions.rhai): Demonstrates how to reboot, reset, and manage rescue mode for a server. +- [`examples/05_list_ssh_keys.rhai`](examples/05_list_ssh_keys.rhai): Lists all SSH keys in your project. +- [`examples/06_list_images.rhai`](examples/06_list_images.rhai): Shows how to list system images and snapshots, with examples of filtering and sorting. +- [`examples/07_install_image.rhai`](examples/07_install_image.rhai): Demonstrates server rebuild functionality using install_image (equivalent to traditional installimage). + +## Features + +### Server Management + +- **Create servers** with flexible configuration options +- **List and inspect** server details and status +- **Reboot and reset** servers +- **Rebuild servers** with new images (install_image functionality) +- **Rescue mode** management + +### Image Management + +- **List images** with advanced filtering (by type, status, architecture, etc.) +- **Support for all image types**: system, backup, snapshot, and app images +- **Flexible image selection** by name or ID + +### SSH Key Management + +- **List SSH keys** in your project +- **Automatic SSH key assignment** during server creation + +### Monitoring and Status + +- **Real-time server status** monitoring +- **Rebuild progress tracking** with automatic completion detection +- **Comprehensive error handling** and user feedback diff --git a/examples/07_install_image.rhai b/examples/07_install_image.rhai new file mode 100644 index 0000000..079c971 --- /dev/null +++ b/examples/07_install_image.rhai @@ -0,0 +1,161 @@ +// This script demonstrates how to rebuild a server with a new image using install_image. +// The install_image function is the Hetzner Cloud equivalent of the traditional installimage command. + +// Initialize the Hetzner client with your API token. +let client = new_hetzner_client(get_env("HETZNER_API_TOKEN")); + +// Replace this with the ID of the server you want to rebuild. +// WARNING: This will DESTROY ALL DATA on the target server! +let server_id = 1234567; // FIXME: Replace with a real server ID + +// The install_image function rebuilds a server by overwriting its disk with a new image. +// This is equivalent to the traditional installimage command used on dedicated servers. +// +// Available image types: +// - system: Official OS images (e.g., "ubuntu-22.04", "debian-12") +// - backup: Automatic backups of your servers +// - snapshot: Manual snapshots you've created +// - app: Application images (if available) +// +// The function accepts either an image name or image ID: +// client.install_image(server_id, "ubuntu-22.04"); // By name +// client.install_image(server_id, "15512617"); // By ID + +// Get current server information before rebuilding. +print("Getting current server information..."); +let server = client.get_server(server_id); +print(`Server: ${server.name} (ID: ${server.id})`); +print(`Current Status: ${server.status}`); +print(`Current Image: ${server.image.name}`); +print(""); + +// List available system images to choose from. +print("Available system images (first 5):"); +let system_images_params = new_list_images_params_builder() + .with_type("system") + .with_status("available"); +let system_images = client.list_images(system_images_params); + +for i in 0..5 { + if i < system_images.len() { + let img = system_images[i]; + print(` ${img.id}: ${img.name} (${img.type})`); + } +} +print(""); + +// List backup images for this server (if any). +print("Backup images for this server:"); +let backup_images_params = new_list_images_params_builder() + .with_type("backup") + .with_bound_to(server_id.to_string()); +let backup_images = client.list_images(backup_images_params); + +if backup_images.len() > 0 { + for i in 0..backup_images.len() { + let img = backup_images[i]; + print(` ${img.id}: ${img.name} (${img.type})`); + } +} else { + print(" No backup images found for this server"); +} +print(""); + +// --- Rebuild Server with System Image --- +// WARNING: This will DESTROY ALL DATA on the server! +// The server will be automatically powered off before the rebuild. + +// Example 1: Rebuild with Ubuntu 22.04 (by name) +// WARNING: Uncomment the following lines to perform the actual rebuild +// print("Initiating server rebuild with Ubuntu 22.04..."); +// client.install_image(server_id, "ubuntu-22.04"); +// print("✅ Server rebuild request sent successfully!"); +print("⚠️ install_image call is commented out for safety. Uncomment to execute."); +print(""); + +// Example 2: Rebuild with specific image ID +// client.install_image(server_id, "15512617"); // Replace with actual image ID +// print("Server rebuild initiated with image ID 15512617"); + +// --- Restore from Backup --- +// If backup images are available, you can restore from them: +if backup_images.len() > 0 { + let backup_img = backup_images[0]; + // client.install_image(server_id, backup_img.id.to_string()); + // print(`Server restore initiated from backup: ${backup_img.name}`); + // print(`Backup available: ${backup_img.name} (ID: ${backup_img.id})`); +} else { + // print("No backup images available for this server"); +} + +// --- Monitor Rebuild Progress --- +// The following section demonstrates how to monitor rebuild progress +// Uncomment when you uncomment the install_image call above +/* +print("Monitoring server status during rebuild..."); +print("Note: Server rebuild typically takes 1-3 minutes to complete."); +print(""); + +// Poll server status every 5 seconds to monitor progress +let max_attempts = 60; // Maximum 5 minutes of polling +let attempt = 0; +let rebuild_seen = false; +let running_count = 0; + +while attempt < max_attempts { + let current_server = client.get_server(server_id); + let status = current_server.status; // Use status directly + + print(`Attempt ${attempt + 1}: Server status is '${status}'`); + + // Check for rebuilding status (case-insensitive) + if status == "Rebuilding" || status == "rebuilding" { + rebuild_seen = true; + print(" → Server is currently being rebuilt..."); + } else if status == "Off" || status == "off" { + print(" → Server is powered off (normal during rebuild)"); + } else if status == "Running" || status == "running" { + if rebuild_seen { + print(""); + print("🎉 Server rebuild completed successfully!"); + print(`✅ Server is now running with image: ${current_server.image.name}`); + + // Verify the image changed + if current_server.image.name == "ubuntu-22.04" || current_server.image.name == "Ubuntu 22.04" { + print("✅ Image verification: Ubuntu 22.04 installation confirmed!"); + } else { + print(`⚠️ Image verification: Expected 'ubuntu-22.04', got '${current_server.image.name}'`); + } + break; // Exit immediately after rebuild completion + } else { + print(" → Server is running (waiting for rebuild to start...)"); + } + } else { + print(` → Server status: ${status}`); + } + + attempt = attempt + 1; + + if attempt < max_attempts { + print(" Waiting 5 seconds before next check..."); + sleep(5); // Wait 5 seconds before polling again + } +} + +if attempt >= max_attempts { + print(""); + print("⚠️ Timeout: Server rebuild is taking longer than expected."); + print(" Check the Hetzner Cloud console for current status."); +} + +print(""); +print("💡 Tip: You can also monitor rebuild progress in the Hetzner Cloud console."); +*/ + +print(""); +print("To use this example:"); +print("1. Replace server_id with your actual server ID"); +print("2. Uncomment the install_image call and monitoring section"); +print("3. Run the script to rebuild your server"); +print(""); +print("⚠️ Remember: install_image will DESTROY ALL DATA on the target server!"); diff --git a/src/async_handler.rs b/src/async_handler.rs index 486a070..5eb9bc3 100644 --- a/src/async_handler.rs +++ b/src/async_handler.rs @@ -1,6 +1,6 @@ use crate::hetzner_api::{ - ListImagesParamsBuilder, HetznerClient, ServerBuilder, WrappedCreateServerResponse, - WrappedServer, WrappedSshKey, WrappedImage, + HetznerClient, ListImagesParamsBuilder, ServerBuilder, WrappedCreateServerResponse, + WrappedImage, WrappedServer, WrappedSshKey, }; use std::sync::mpsc::{Receiver, Sender}; use tokio::runtime::Builder; @@ -18,6 +18,7 @@ pub enum Request { DisableRescueMode(HetznerClient, i64), ListSshKeys(HetznerClient), ListImages(HetznerClient, ListImagesParamsBuilder), + InstallImage(HetznerClient, i64, String), } pub enum Response { @@ -31,17 +32,15 @@ pub enum Response { EnableRescueMode(Result), DisableRescueMode(Result<(), String>), ListImages(Result, String>), + InstallImage(Result<(), String>), } -pub fn run_worker( - command_rx: Receiver, - reply_tx: Sender, -) { +pub fn run_worker(command_rx: Receiver, reply_tx: Sender) { std::thread::spawn(move || { let rt = Builder::new_current_thread() .enable_all() .build() - .unwrap(); + .expect("Failed to create async runtime"); while let Ok(request) = command_rx.recv() { let response = match request { @@ -58,7 +57,9 @@ pub fn run_worker( Response::CreateServer(result) } Request::ListServers(client) => { - let result = rt.block_on(client.list_servers()).map_err(|e| e.to_string()); + let result = rt + .block_on(client.list_servers()) + .map_err(|e| e.to_string()); Response::ListServers(result) } Request::GetServerStatus(client, server_id) => { @@ -68,15 +69,21 @@ pub fn run_worker( Response::GetServerStatus(result) } Request::GetServer(client, server_id) => { - let result = rt.block_on(client.get_server(server_id)).map_err(|e| e.to_string()); + let result = rt + .block_on(client.get_server(server_id)) + .map_err(|e| e.to_string()); Response::GetServer(result) } Request::RebootServer(client, server_id) => { - let result = rt.block_on(client.reboot_server(server_id)).map_err(|e| e.to_string()); + let result = rt + .block_on(client.reboot_server(server_id)) + .map_err(|e| e.to_string()); Response::RebootServer(result) } Request::ResetServer(client, server_id) => { - let result = rt.block_on(client.reset_server(server_id)).map_err(|e| e.to_string()); + let result = rt + .block_on(client.reset_server(server_id)) + .map_err(|e| e.to_string()); Response::ResetServer(result) } Request::EnableRescueMode(client, server_id, ssh_keys) => { @@ -89,7 +96,8 @@ pub fn run_worker( let result = rt .block_on(async { let ssh_keys = client.list_ssh_keys().await?; - let ssh_key_ids: Vec = ssh_keys.into_iter().map(|k| k.0.id).collect(); + let ssh_key_ids: Vec = + ssh_keys.into_iter().map(|k| k.0.id).collect(); println!("Passing in the following ssh key ids: {:#?}", ssh_key_ids); client .enable_rescue_mode_for_server(server_id, ssh_key_ids) @@ -99,15 +107,25 @@ pub fn run_worker( Response::EnableRescueMode(result) } Request::DisableRescueMode(client, server_id) => { - let result = rt.block_on(client.disable_rescue_mode_for_server(server_id)).map_err(|e| e.to_string()); + let result = rt + .block_on(client.disable_rescue_mode_for_server(server_id)) + .map_err(|e| e.to_string()); Response::DisableRescueMode(result) } Request::ListSshKeys(client) => { - let result = rt.block_on(client.list_ssh_keys()).map_err(|e| e.to_string()); + let result = rt + .block_on(client.list_ssh_keys()) + .map_err(|e| e.to_string()); Response::ListSshKeys(result) } + Request::InstallImage(client, server_id, image) => { + let result = rt + .block_on(client.install_image(server_id, image)) + .map_err(|e| e.to_string()); + Response::InstallImage(result) + } }; reply_tx.send(response).expect("Failed to send response"); } }); -} \ No newline at end of file +} diff --git a/src/hetzner_api.rs b/src/hetzner_api.rs index 1768b78..d9ea5c5 100644 --- a/src/hetzner_api.rs +++ b/src/hetzner_api.rs @@ -1,15 +1,15 @@ use hcloud::apis::{ configuration::Configuration, - images_api::{self, ListImagesParams}, servers_api::{ self, CreateServerParams, DisableRescueModeForServerParams, - EnableRescueModeForServerParams, ListServersParams, ResetServerParams, + EnableRescueModeForServerParams, ListServersParams, RebuildServerFromImageParams, + ResetServerParams, }, ssh_keys_api::{self, ListSshKeysParams}, }; use hcloud::models::{ - CreateServerRequest, CreateServerResponse, EnableRescueModeForServerRequest, Image, Server, - SshKey, + CreateServerRequest, CreateServerResponse, EnableRescueModeForServerRequest, Image, + RebuildServerFromImageRequest, Server, SshKey, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -155,21 +155,6 @@ impl ListImagesParamsBuilder { 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 { @@ -179,7 +164,7 @@ impl HetznerClient { let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(5)) .build() - .unwrap(); + .expect("Failed to create HTTP client"); configuration.client = client; Self { configuration } @@ -340,11 +325,11 @@ impl HetznerClient { 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)); } @@ -369,30 +354,35 @@ impl HetznerClient { 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()) + .bearer_auth( + self.configuration + .bearer_access_token + .as_ref() + .ok_or("API token not configured")?, + ) .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| { @@ -402,12 +392,12 @@ impl HetznerClient { } }) .collect(); - + all_images.extend(images); } else { break; } - + if response .get("meta") .and_then(|m| m.get("pagination")) @@ -417,10 +407,24 @@ impl HetznerClient { { break; } - + page += 1; } - + Ok(all_images) } + + pub async fn install_image( + &self, + server_id: i64, + image: String, + ) -> Result<(), Box> { + let params = RebuildServerFromImageParams { + id: server_id, + rebuild_server_from_image_request: Some(RebuildServerFromImageRequest::new(image)), + }; + + servers_api::rebuild_server_from_image(&self.configuration, params).await?; + Ok(()) + } } diff --git a/src/rhai_api.rs b/src/rhai_api.rs index 93a9828..74babbb 100644 --- a/src/rhai_api.rs +++ b/src/rhai_api.rs @@ -4,6 +4,7 @@ use crate::hetzner_api::{ HetznerClient, ListImagesParamsBuilder, ServerBuilder, WrappedCreateServerResponse, WrappedImage, WrappedServer, WrappedSshKey, }; +use hcloud::models::Image; use prettytable::{Cell, Row, Table}; use rhai::{Engine, EvalAltResult}; use std::env; @@ -122,7 +123,10 @@ pub fn register_hetzner_api( engine .register_type_with_name::("ListImagesParamsBuilder") - .register_fn("new_list_images_params_builder", ListImagesParamsBuilder::new) + .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) @@ -136,31 +140,53 @@ pub fn register_hetzner_api( "with_label_selector", ListImagesParamsBuilder::with_label_selector, ) - .register_fn("with_architecture", ListImagesParamsBuilder::with_architecture); + .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 { + bridge.call( + Request::ListImages(client.clone(), builder), + |response| match response { Response::ListImages(result) => result.map_err(|e| e.into()), _ => Err("Unexpected response".into()), - } - }) + }, + ) + } + }) + .register_fn("install_image", { + let bridge = api_bridge.clone(); + move |client: &mut HetznerClient, server_id: i64, image: &str| { + bridge.call( + Request::InstallImage(client.clone(), server_id, image.to_string()), + |response| match response { + Response::InstallImage(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("name", |image: &mut WrappedImage| { + image.0.name.clone().unwrap_or("Unknown".to_string()) + }) .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("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) }) @@ -170,6 +196,10 @@ pub fn register_hetzner_api( engine .register_iterator::>() + .register_fn("len", |list: &mut Vec| list.len() as i64) + .register_indexer_get(|list: &mut Vec, index: i64| { + list[index as usize].clone() + }) .register_fn( "show_table", |images: &mut Vec| -> Result> { @@ -215,12 +245,7 @@ pub fn register_hetzner_api( .register_fn( "with_ssh_keys", |builder: ServerBuilder, ssh_keys: rhai::Array| { - builder.with_ssh_keys( - ssh_keys - .into_iter() - .map(|k| k.as_int().unwrap()) - .collect(), - ) + builder.with_ssh_keys(ssh_keys.into_iter().map(|k| k.as_int().unwrap()).collect()) }, ); @@ -269,6 +294,22 @@ pub fn register_hetzner_api( }) .register_get("rescue_enabled", |server: &mut WrappedServer| { server.0.rescue_enabled + }) + .register_get("image", |server: &mut WrappedServer| { + server + .0 + .image + .as_ref() + .map(|img| WrappedImage((**img).clone())) + .unwrap_or_else(|| { + // Create a dummy image if none exists + WrappedImage(Image { + id: 0, + name: Some("No image".to_string()), + description: "No image associated with this server".to_string(), + ..Default::default() + }) + }) }); engine @@ -440,6 +481,11 @@ pub fn register_hetzner_api( env::var(key).unwrap_or("".to_string()) }); + // Register sleep function for polling operations + engine.register_fn("sleep", |seconds: i64| { + std::thread::sleep(std::time::Duration::from_secs(seconds as u64)); + }); + engine .register_type_with_name::("SshKey") .register_get("id", |key: &mut WrappedSshKey| key.0.id)