5 Commits

Author SHA1 Message Date
3680ceed61 Update Rust crate reqwest to 0.12.19
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2025-06-09 00:25:15 +00:00
8a7712ec42 Add dsmode cloud-init metadata
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-07 11:50:22 +02:00
9609cfb33a Check if file can be loaded during disk image upload
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-07 11:28:39 +02:00
1fe7c60f36 Automatically generate cloud disk image when updating domains configuration
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-07 11:15:55 +02:00
f1339f0711 Generate cloud init disk image
All checks were successful
continuous-integration/drone/push Build is passing
2025-06-07 10:32:39 +02:00
12 changed files with 237 additions and 26 deletions

View File

@ -1622,18 +1622,23 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-util",
"http 1.3.1",
"http-body",
"hyper",
"ipnet",
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@ -1839,6 +1844,16 @@ dependencies = [
"serde",
]
[[package]]
name = "iri-string"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
dependencies = [
"memchr",
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
@ -2773,9 +2788,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
version = "0.12.15"
version = "0.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
dependencies = [
"base64 0.22.1",
"bytes",
@ -2798,23 +2813,22 @@ dependencies = [
"once_cell",
"percent-encoding",
"pin-project-lite",
"rustls-pemfile",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"system-configuration",
"tokio",
"tokio-native-tls",
"tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"windows-registry",
]
[[package]]
@ -2923,15 +2937,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.12.0"
@ -3522,6 +3527,24 @@ dependencies = [
"tower-service",
]
[[package]]
name = "tower-http"
version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.1",
"bytes",
"futures-util",
"http 1.3.1",
"http-body",
"iri-string",
"pin-project-lite",
"tower",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"

View File

@ -27,7 +27,7 @@ futures-util = "0.3.31"
anyhow = "1.0.98"
actix-multipart = "0.7.2"
tempfile = "3.20.0"
reqwest = { version = "0.12.15", features = ["stream"] }
reqwest = { version = "0.12.19", features = ["stream"] }
url = "2.5.4"
virt = "0.4.2"
sysinfo = { version = "0.35.1", features = ["serde"] }

View File

@ -182,6 +182,13 @@ impl Handler<DeleteDomainReq> for LibVirtActor {
false => sys::VIR_DOMAIN_UNDEFINE_NVRAM,
})?;
// Delete associated cloud init disk
let cloud_init_disk = AppConfig::get().cloud_init_disk_path_for_vm(&domain_name);
if cloud_init_disk.exists() {
std::fs::remove_file(cloud_init_disk)?;
}
// If requested, delete block storage associated with the VM
if !msg.keep_files {
log::info!("Delete storage associated with the domain");
let path = AppConfig::get().vm_storage_path(msg.id);

View File

@ -250,6 +250,19 @@ impl AppConfig {
self.storage_path().join("iso")
}
/// Get the path where generated cloud init disk image are stored
pub fn cloud_init_disk_storage_path(&self) -> PathBuf {
self.storage_path().join("cloud_init_disks")
}
/// Get the path where the disk image of a VM is stored
pub fn cloud_init_disk_path_for_vm(&self, name: &str) -> PathBuf {
self.cloud_init_disk_storage_path().join(format!(
"{}-{name}.iso",
constants::CLOUD_INIT_IMAGE_PREFIX_NAME
))
}
/// Get disk images storage directory
pub fn disk_images_storage_path(&self) -> PathBuf {
self.storage_path().join("disk_images")

View File

@ -30,8 +30,9 @@ pub const ALLOWED_ISO_MIME_TYPES: [&str; 4] = [
pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10);
/// Allowed uploaded disk images formats
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 3] = [
pub const ALLOWED_DISK_IMAGES_MIME_TYPES: [&str; 4] = [
"application/x-qemu-disk",
"application/x-raw-disk-image",
"application/gzip",
"application/octet-stream",
];
@ -57,6 +58,9 @@ pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50);
/// Disk size max (B)
pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000);
/// Cloud init generated disk image prefix
pub const CLOUD_INIT_IMAGE_PREFIX_NAME: &str = "virtweb-cloudinit-autogen-image";
/// Net nat entry comment max size
pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250;

View File

@ -55,7 +55,15 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
}
// Copy the file to the destination
file.file.persist(dest_path)?;
file.file.persist(&dest_path)?;
// Check if file information can be loaded
if let Err(e) = DiskFileInfo::load_file(&dest_path) {
log::error!("Failed to get information about uploaded disk file! {e}");
std::fs::remove_file(&dest_path)?;
return Ok(HttpResponse::InternalServerError()
.json(format!("Unable to process uploaded file! {e}")));
}
Ok(HttpResponse::Ok().json("Successfully uploaded disk image!"))
}

View File

@ -109,6 +109,28 @@ pub async fn get_single_src_def(client: LibVirtReq, id: web::Path<SingleVMUUidRe
.body(info))
}
/// Get the generated cloud init configuration disk of a vm
pub async fn get_cloud_init_disk(client: LibVirtReq, id: web::Path<SingleVMUUidReq>) -> HttpResult {
let info = match client.get_single_domain(id.uid).await {
Ok(i) => i,
Err(e) => {
log::error!("Failed to get domain information! {e}");
return Ok(HttpResponse::InternalServerError().json(e.to_string()));
}
};
let vm = VMInfo::from_domain(info)?;
let disk = vm.cloud_init.generate_nocloud_disk()?;
Ok(HttpResponse::Ok()
.content_type("application/x-iso9660-image")
.insert_header((
"Content-Disposition",
format!("attachment; filename=\"cloud_init_{}.iso\"", vm.name),
))
.body(disk))
}
/// Update a VM information
pub async fn update(
client: LibVirtReq,

View File

@ -142,9 +142,22 @@ impl VMInfo {
return Err(StructureExtraction("Invalid number of vCPU specified!").into());
}
let mut disks = vec![];
let mut iso_absolute_files = vec![];
// Add ISO files
// Process cloud init image
if self.cloud_init.attach_config {
let cloud_init_disk_path = AppConfig::get().cloud_init_disk_path_for_vm(&self.name);
// Apply latest cloud init configuration
std::fs::write(
&cloud_init_disk_path,
self.cloud_init.generate_nocloud_disk()?,
)?;
iso_absolute_files.push(cloud_init_disk_path);
}
// Process uploaded ISO files
for iso_file in &self.iso_files {
if !files_utils::check_file_name(iso_file) {
return Err(StructureExtraction("ISO filename is invalid!").into());
@ -156,6 +169,13 @@ impl VMInfo {
return Err(StructureExtraction("Specified ISO file does not exists!").into());
}
iso_absolute_files.push(path);
}
let mut disks = vec![];
// Add ISO disk files
for iso_path in iso_absolute_files {
disks.push(DiskXML {
r#type: "file".to_string(),
device: "cdrom".to_string(),
@ -165,7 +185,7 @@ impl VMInfo {
cache: "none".to_string(),
},
source: DiskSourceXML {
file: path.to_string_lossy().to_string(),
file: iso_path.to_string_lossy().to_string(),
},
target: DiskTargetXML {
dev: format!(
@ -182,6 +202,7 @@ impl VMInfo {
})
}
// Configure VNC access, if requested
let (vnc_graphics, vnc_video) = match self.vnc_access {
true => (
Some(GraphicsXML {
@ -495,6 +516,7 @@ impl VMInfo {
.iter()
.filter(|d| d.device == "cdrom")
.map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string())
.filter(|d| !d.starts_with(constants::CLOUD_INIT_IMAGE_PREFIX_NAME))
.collect(),
file_disks: domain

View File

@ -61,6 +61,8 @@ async fn main() -> std::io::Result<()> {
log::debug!("Create required directory, if missing");
files_utils::create_directory_if_missing(AppConfig::get().iso_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().cloud_init_disk_storage_path())
.unwrap();
files_utils::create_directory_if_missing(AppConfig::get().disk_images_storage_path()).unwrap();
files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_path()).unwrap();
files_utils::set_file_permission(AppConfig::get().vnc_sockets_path(), 0o777).unwrap();
@ -202,6 +204,10 @@ async fn main() -> std::io::Result<()> {
"/api/vm/{uid}/src",
web::get().to(vm_controller::get_single_src_def),
)
.route(
"/api/vm/{uid}/cloud_init_disk",
web::get().to(vm_controller::get_cloud_init_disk),
)
.route(
"/api/vm/{uid}/autostart",
web::get().to(vm_controller::get_autostart),

View File

@ -1,19 +1,96 @@
use crate::app_config::AppConfig;
use crate::constants;
use std::process::Command;
/// Cloud init DS Mode
#[derive(Copy, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum CloudInitDSMode {
/// Networking is required
Net,
/// Does not require networking to be up before user-data actions are run
Local,
}
/// VM Cloud Init configuration
///
/// RedHat documentation: https://docs.redhat.com/fr/documentation/red_hat_enterprise_linux/9/html/configuring_and_managing_cloud-init_for_rhel_9/configuring-cloud-init_cloud-content
/// cloud-localds source code: https://github.com/canonical/cloud-utils/blob/main/bin/cloud-localds
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct CloudInitConfig {
attach_config: bool,
pub attach_config: bool,
/// Main user data
user_data: String,
pub user_data: String,
/// Instance ID, set in metadata file
#[serde(skip_serializing_if = "Option::is_none")]
instance_id: Option<String>,
pub instance_id: Option<String>,
/// Local hostname, set in metadata file
#[serde(skip_serializing_if = "Option::is_none")]
local_hostname: Option<String>,
pub local_hostname: Option<String>,
/// Data source mode
#[serde(skip_serializing_if = "Option::is_none")]
pub dsmode: Option<CloudInitDSMode>,
/// Network configuration
#[serde(skip_serializing_if = "Option::is_none")]
network_configuration: Option<String>,
pub network_configuration: Option<String>,
}
impl CloudInitConfig {
/// Generate disk image for nocloud usage
pub fn generate_nocloud_disk(&self) -> anyhow::Result<Vec<u8>> {
let temp_path = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
let mut cmd = Command::new(constants::PROGRAM_CLOUD_LOCALDS);
// ISO destination path
let temp_iso = temp_path.path().join("disk.iso");
cmd.arg(&temp_iso);
// Process network configuration
if let Some(net_conf) = &self.network_configuration {
let net_conf_path = temp_path.path().join("network");
std::fs::write(&net_conf_path, net_conf)?;
cmd.arg("--network-config").arg(&net_conf_path);
}
// Process user data
let user_data_path = temp_path.path().join("user-data");
std::fs::write(&user_data_path, &self.user_data)?;
cmd.arg(user_data_path);
// Process metadata
let mut metadatas = vec![];
if let Some(inst_id) = &self.instance_id {
metadatas.push(format!("instance-id: {}", inst_id));
}
if let Some(local_hostname) = &self.local_hostname {
metadatas.push(format!("local-hostname: {}", local_hostname));
}
if let Some(dsmode) = &self.dsmode {
metadatas.push(format!(
"dsmode: {}",
match dsmode {
CloudInitDSMode::Net => "net",
CloudInitDSMode::Local => "local",
}
));
}
let meta_data_path = temp_path.path().join("meta-data");
std::fs::write(&meta_data_path, metadatas.join("\n"))?;
cmd.arg(meta_data_path);
// Execute command
let output = cmd.output()?;
if !output.status.success() {
anyhow::bail!(
"{} exited with status {}!\nStdout: {}\nStderr: {}",
constants::PROGRAM_CLOUD_LOCALDS,
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
// Read generated ISO file
Ok(std::fs::read(temp_iso)?)
}
}

View File

@ -82,6 +82,15 @@ export interface VMNetBridge {
bridge: string;
}
export interface VMCloudInit {
attach_config: boolean;
user_data: string;
instance_id?: string;
local_hostname?: string;
dsmode?: "Net" | "Local";
network_configuration?: string;
}
export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy";
interface VMInfoInterface {
@ -101,6 +110,7 @@ interface VMInfoInterface {
networks: VMNetInterface[];
tpm_module: boolean;
oem_strings: string[];
cloud_init: VMCloudInit;
}
export class VMInfo implements VMInfoInterface {
@ -120,6 +130,7 @@ export class VMInfo implements VMInfoInterface {
networks: VMNetInterface[];
tpm_module: boolean;
oem_strings: string[];
cloud_init: VMCloudInit;
constructor(int: VMInfoInterface) {
this.name = int.name;
@ -138,6 +149,7 @@ export class VMInfo implements VMInfoInterface {
this.networks = int.networks;
this.tpm_module = int.tpm_module;
this.oem_strings = int.oem_strings;
this.cloud_init = int.cloud_init;
}
static NewEmpty(): VMInfo {
@ -153,6 +165,7 @@ export class VMInfo implements VMInfoInterface {
networks: [],
tpm_module: true,
oem_strings: [],
cloud_init: { attach_config: false, user_data: "" },
});
}

View File

@ -60,6 +60,7 @@ export function TokenRightsEditor(p: {
<TableCell align="center">Get XML definition</TableCell>
<TableCell align="center">Get autostart</TableCell>
<TableCell align="center">Set autostart</TableCell>
<TableCell align="center">Get CloudInit disk</TableCell>
<TableCell align="center">Backup disk</TableCell>
</TableRow>
</TableHead>
@ -84,6 +85,13 @@ export function TokenRightsEditor(p: {
{...p}
right={{ verb: "PUT", path: "/api/vm/*/autostart" }}
/>
<CellRight
{...p}
right={{
verb: "GET",
path: "/api/vm/*/cloud_init_disk",
}}
/>
<CellRight
{...p}
right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }}
@ -123,7 +131,15 @@ export function TokenRightsEditor(p: {
{...p}
right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }}
parent={{ verb: "PUT", path: "/api/vm/*/autostart" }}
/>{" "}
/>
<CellRight
{...p}
right={{
verb: "GET",
path: `/api/vm/${v.uuid}/cloud_init_disk`,
}}
parent={{ verb: "GET", path: "/api/vm/*/cloud_init_disk" }}
/>
<CellRight
{...p}
right={{