Compare commits
	
		
			82 Commits
		
	
	
		
			20250531
			...
			e4ceb1314c
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e4ceb1314c | |||
| 6e41d3c6df | |||
| f57f3f3c9a | |||
| 34fda42766 | |||
| a9536f4091 | |||
| 7770ad3ca7 | |||
| 02b3373f51 | |||
| 5153af0618 | |||
| d50c3a00a5 | |||
| 4618c2c081 | |||
| 34d78085c8 | |||
| 0381b73635 | |||
| 2e88529e3d | |||
| 84e3f61902 | |||
| 9623493aee | |||
| e5ad7d2fe4 | |||
| 940179ffe5 | |||
| f7b27a527b | |||
| caac7dc1bf | |||
| a0f815534b | |||
| 928c2c5cb4 | |||
| ddc8b65f8a | |||
| 0b67659efa | |||
| 0601d9cad9 | |||
| a1b6ebd9f5 | |||
| b8eedaab51 | |||
| 0afc3252c6 | |||
| 685f1bc502 | |||
| 3e642dd638 | |||
| 22ad68e43e | |||
| 1dd86807fd | |||
| 96747bda89 | |||
| e15514dd4f | |||
| 7556ee2c06 | |||
| 992a902590 | |||
| 100f12e7c1 | |||
| 3de66a5873 | |||
| 49360188f5 | |||
| 35c48ba846 | |||
| 1ad4262086 | |||
| b633694f74 | |||
| ab16bd7bcf | |||
| 1080ab5cb2 | |||
| a2845ddafe | |||
| c968b64b51 | |||
| 12833dc6da | |||
| 8c4f2a9f2d | |||
| 9a6b6cfb2d | |||
| b28ca5f27d | |||
| 92f187bf91 | |||
| 9f1f4b44ca | |||
| b00d46105b | |||
| 7d6ccd4ce6 | |||
| b5371421e1 | |||
| 07d305c54e | |||
| 78c525f47a | |||
| 978a881372 | |||
| ee733b04f3 | |||
| 0d8ef226c1 | |||
| feca07558e | |||
| 0de15af10e | |||
| d4bc92f562 | |||
| a1439689dd | |||
| 63126c75fa | |||
| 940302a825 | |||
| 9c374f849b | |||
| 2fa4d0e11b | |||
| d7796e1459 | |||
| 759361d9f6 | |||
| b2529c250a | |||
| 8a7712ec42 | |||
| 9609cfb33a | |||
| 1fe7c60f36 | |||
| f1339f0711 | |||
| b3f56cea81 | |||
| 9bd702d60f | |||
| c8b42626a9 | |||
| 4ef15507d9 | |||
| 8a4b3a4db6 | |||
| 8bce9ca9b7 | |||
| 5574037b73 | |||
| d1ca9aee39 | 
@@ -46,8 +46,9 @@ steps:
 | 
			
		||||
  - cd virtweb_backend
 | 
			
		||||
  - mv /tmp/web_build/dist static
 | 
			
		||||
  - cargo build --release
 | 
			
		||||
  - ls -lah target/release/virtweb_backend
 | 
			
		||||
  - cp target/release/virtweb_backend /tmp/release
 | 
			
		||||
  - cargo build --release --example api_curl
 | 
			
		||||
  - ls -lah target/release/virtweb_backend target/release/examples/api_curl
 | 
			
		||||
  - cp target/release/virtweb_backend target/release/examples/api_curl /tmp/release
 | 
			
		||||
 | 
			
		||||
- name: gitea_release
 | 
			
		||||
  image: plugins/gitea-release
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										312
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										312
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							@@ -435,6 +435,21 @@ dependencies = [
 | 
			
		||||
 "alloc-no-stdlib",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "android-tzdata"
 | 
			
		||||
version = "0.1.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "android_system_properties"
 | 
			
		||||
version = "0.1.5"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "libc",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "anstream"
 | 
			
		||||
version = "0.6.18"
 | 
			
		||||
@@ -496,6 +511,9 @@ name = "arbitrary"
 | 
			
		||||
version = "1.4.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "derive_arbitrary",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "arg_enum_proc_macro"
 | 
			
		||||
@@ -715,6 +733,25 @@ dependencies = [
 | 
			
		||||
 "bytes",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "bzip2"
 | 
			
		||||
version = "0.5.2"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "bzip2-sys",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "bzip2-sys"
 | 
			
		||||
version = "0.1.13+1.0.8"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "cc",
 | 
			
		||||
 "pkg-config",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "cc"
 | 
			
		||||
version = "1.2.23"
 | 
			
		||||
@@ -748,6 +785,20 @@ version = "0.2.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "chrono"
 | 
			
		||||
version = "0.4.41"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "android-tzdata",
 | 
			
		||||
 "iana-time-zone",
 | 
			
		||||
 "js-sys",
 | 
			
		||||
 "num-traits",
 | 
			
		||||
 "wasm-bindgen",
 | 
			
		||||
 "windows-link",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "cipher"
 | 
			
		||||
version = "0.4.4"
 | 
			
		||||
@@ -760,9 +811,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "clap"
 | 
			
		||||
version = "4.5.38"
 | 
			
		||||
version = "4.5.41"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000"
 | 
			
		||||
checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "clap_builder",
 | 
			
		||||
 "clap_derive",
 | 
			
		||||
@@ -770,9 +821,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "clap_builder"
 | 
			
		||||
version = "4.5.38"
 | 
			
		||||
version = "4.5.41"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120"
 | 
			
		||||
checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "anstream",
 | 
			
		||||
 "anstyle",
 | 
			
		||||
@@ -782,9 +833,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "clap_derive"
 | 
			
		||||
version = "4.5.32"
 | 
			
		||||
version = "4.5.41"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7"
 | 
			
		||||
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "heck",
 | 
			
		||||
 "proc-macro2",
 | 
			
		||||
@@ -816,6 +867,12 @@ version = "0.9.6"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "constant_time_eq"
 | 
			
		||||
version = "0.3.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "convert_case"
 | 
			
		||||
version = "0.4.0"
 | 
			
		||||
@@ -981,6 +1038,12 @@ dependencies = [
 | 
			
		||||
 "syn",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "deflate64"
 | 
			
		||||
version = "0.1.9"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "der"
 | 
			
		||||
version = "0.7.10"
 | 
			
		||||
@@ -1001,6 +1064,17 @@ dependencies = [
 | 
			
		||||
 "powerfmt",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "derive_arbitrary"
 | 
			
		||||
version = "1.4.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "proc-macro2",
 | 
			
		||||
 "quote",
 | 
			
		||||
 "syn",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "derive_more"
 | 
			
		||||
version = "0.99.20"
 | 
			
		||||
@@ -1221,6 +1295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "crc32fast",
 | 
			
		||||
 "libz-rs-sys",
 | 
			
		||||
 "miniz_oxide",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
@@ -1380,9 +1455,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "cfg-if",
 | 
			
		||||
 "js-sys",
 | 
			
		||||
 "libc",
 | 
			
		||||
 "r-efi",
 | 
			
		||||
 "wasi 0.14.2+wasi-0.2.4",
 | 
			
		||||
 "wasm-bindgen",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@@ -1622,18 +1699,47 @@ 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]]
 | 
			
		||||
name = "iana-time-zone"
 | 
			
		||||
version = "0.1.63"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "android_system_properties",
 | 
			
		||||
 "core-foundation-sys",
 | 
			
		||||
 "iana-time-zone-haiku",
 | 
			
		||||
 "js-sys",
 | 
			
		||||
 "log",
 | 
			
		||||
 "wasm-bindgen",
 | 
			
		||||
 "windows-core",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "iana-time-zone-haiku"
 | 
			
		||||
version = "0.1.2"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "cc",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@@ -1824,6 +1930,17 @@ dependencies = [
 | 
			
		||||
 "syn",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "io-uring"
 | 
			
		||||
version = "0.7.8"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "bitflags 2.9.1",
 | 
			
		||||
 "cfg-if",
 | 
			
		||||
 "libc",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "ipnet"
 | 
			
		||||
version = "2.11.0"
 | 
			
		||||
@@ -1839,6 +1956,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"
 | 
			
		||||
@@ -1982,6 +2109,45 @@ dependencies = [
 | 
			
		||||
 "cc",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "liblzma"
 | 
			
		||||
version = "0.4.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "66352d7a8ac12d4877b6e6ea5a9b7650ee094257dc40889955bea5bc5b08c1d0"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "liblzma-sys",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "liblzma-sys"
 | 
			
		||||
version = "0.4.4"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "01b9596486f6d60c3bbe644c0e1be1aa6ccc472ad630fe8927b456973d7cb736"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "cc",
 | 
			
		||||
 "libc",
 | 
			
		||||
 "pkg-config",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "libyml"
 | 
			
		||||
version = "0.0.5"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "anyhow",
 | 
			
		||||
 "version_check",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "libz-rs-sys"
 | 
			
		||||
version = "0.5.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "172a788537a2221661b480fee8dc5f96c580eb34fa88764d3205dc356c7e4221"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "zlib-rs",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "light-openid"
 | 
			
		||||
version = "1.0.4"
 | 
			
		||||
@@ -2404,6 +2570,16 @@ version = "1.0.15"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "pbkdf2"
 | 
			
		||||
version = "0.12.2"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "digest",
 | 
			
		||||
 "hmac",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "pem"
 | 
			
		||||
version = "3.0.5"
 | 
			
		||||
@@ -2773,9 +2949,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "reqwest"
 | 
			
		||||
version = "0.12.15"
 | 
			
		||||
version = "0.12.22"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb"
 | 
			
		||||
checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "base64 0.22.1",
 | 
			
		||||
 "bytes",
 | 
			
		||||
@@ -2790,31 +2966,28 @@ dependencies = [
 | 
			
		||||
 "hyper-rustls",
 | 
			
		||||
 "hyper-tls",
 | 
			
		||||
 "hyper-util",
 | 
			
		||||
 "ipnet",
 | 
			
		||||
 "js-sys",
 | 
			
		||||
 "log",
 | 
			
		||||
 "mime",
 | 
			
		||||
 "native-tls",
 | 
			
		||||
 "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 +3096,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"
 | 
			
		||||
@@ -3093,6 +3257,21 @@ dependencies = [
 | 
			
		||||
 "serde",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "serde_yml"
 | 
			
		||||
version = "0.0.12"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "indexmap",
 | 
			
		||||
 "itoa",
 | 
			
		||||
 "libyml",
 | 
			
		||||
 "memchr",
 | 
			
		||||
 "ryu",
 | 
			
		||||
 "serde",
 | 
			
		||||
 "version_check",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "sha1"
 | 
			
		||||
version = "0.10.6"
 | 
			
		||||
@@ -3253,9 +3432,9 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "sysinfo"
 | 
			
		||||
version = "0.35.1"
 | 
			
		||||
version = "0.35.2"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "79251336d17c72d9762b8b54be4befe38d2db56fbbc0241396d70f173c39d47a"
 | 
			
		||||
checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "libc",
 | 
			
		||||
 "memchr",
 | 
			
		||||
@@ -3413,17 +3592,19 @@ dependencies = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "tokio"
 | 
			
		||||
version = "1.45.0"
 | 
			
		||||
version = "1.46.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165"
 | 
			
		||||
checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "backtrace",
 | 
			
		||||
 "bytes",
 | 
			
		||||
 "io-uring",
 | 
			
		||||
 "libc",
 | 
			
		||||
 "mio",
 | 
			
		||||
 "parking_lot",
 | 
			
		||||
 "pin-project-lite",
 | 
			
		||||
 "signal-hook-registry",
 | 
			
		||||
 "slab",
 | 
			
		||||
 "socket2",
 | 
			
		||||
 "tokio-macros",
 | 
			
		||||
 "windows-sys 0.52.0",
 | 
			
		||||
@@ -3522,6 +3703,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"
 | 
			
		||||
@@ -3735,6 +3934,7 @@ dependencies = [
 | 
			
		||||
 "actix-ws",
 | 
			
		||||
 "anyhow",
 | 
			
		||||
 "basic-jwt",
 | 
			
		||||
 "chrono",
 | 
			
		||||
 "clap",
 | 
			
		||||
 "dotenvy",
 | 
			
		||||
 "env_logger",
 | 
			
		||||
@@ -3754,6 +3954,7 @@ dependencies = [
 | 
			
		||||
 "rust-embed",
 | 
			
		||||
 "serde",
 | 
			
		||||
 "serde_json",
 | 
			
		||||
 "serde_yml",
 | 
			
		||||
 "sysinfo",
 | 
			
		||||
 "tempfile",
 | 
			
		||||
 "thiserror 2.0.12",
 | 
			
		||||
@@ -3761,6 +3962,7 @@ dependencies = [
 | 
			
		||||
 "url",
 | 
			
		||||
 "uuid",
 | 
			
		||||
 "virt",
 | 
			
		||||
 "zip",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@@ -4299,6 +4501,20 @@ name = "zeroize"
 | 
			
		||||
version = "1.8.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "zeroize_derive",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "zeroize_derive"
 | 
			
		||||
version = "1.4.2"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "proc-macro2",
 | 
			
		||||
 "quote",
 | 
			
		||||
 "syn",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "zerotrie"
 | 
			
		||||
@@ -4333,6 +4549,50 @@ dependencies = [
 | 
			
		||||
 "syn",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "zip"
 | 
			
		||||
version = "4.1.0"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "af7dcdb4229c0e79c2531a24de7726a0e980417a74fb4d030a35f535665439a0"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "aes",
 | 
			
		||||
 "arbitrary",
 | 
			
		||||
 "bzip2",
 | 
			
		||||
 "constant_time_eq",
 | 
			
		||||
 "crc32fast",
 | 
			
		||||
 "deflate64",
 | 
			
		||||
 "flate2",
 | 
			
		||||
 "getrandom 0.3.3",
 | 
			
		||||
 "hmac",
 | 
			
		||||
 "indexmap",
 | 
			
		||||
 "liblzma",
 | 
			
		||||
 "memchr",
 | 
			
		||||
 "pbkdf2",
 | 
			
		||||
 "sha1",
 | 
			
		||||
 "time",
 | 
			
		||||
 "zeroize",
 | 
			
		||||
 "zopfli",
 | 
			
		||||
 "zstd",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "zlib-rs"
 | 
			
		||||
version = "0.5.1"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "626bd9fa9734751fc50d6060752170984d7053f5a39061f524cda68023d4db8a"
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "zopfli"
 | 
			
		||||
version = "0.8.2"
 | 
			
		||||
source = "registry+https://github.com/rust-lang/crates.io-index"
 | 
			
		||||
checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7"
 | 
			
		||||
dependencies = [
 | 
			
		||||
 "bumpalo",
 | 
			
		||||
 "crc32fast",
 | 
			
		||||
 "log",
 | 
			
		||||
 "simd-adler32",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "zstd"
 | 
			
		||||
version = "0.13.3"
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ edition = "2024"
 | 
			
		||||
[dependencies]
 | 
			
		||||
log = "0.4.27"
 | 
			
		||||
env_logger = "0.11.8"
 | 
			
		||||
clap = { version = "4.5.38", features = ["derive", "env"] }
 | 
			
		||||
clap = { version = "4.5.41", features = ["derive", "env"] }
 | 
			
		||||
light-openid = { version = "1.0.4", features = ["crypto-wrapper"] }
 | 
			
		||||
lazy_static = "1.5.0"
 | 
			
		||||
actix = "0.13.5"
 | 
			
		||||
@@ -19,24 +19,25 @@ actix-identity = "0.8.0"
 | 
			
		||||
actix-cors = "0.7.1"
 | 
			
		||||
actix-files = "0.6.6"
 | 
			
		||||
actix-ws = "0.3.0"
 | 
			
		||||
actix-http = "3.10.0"
 | 
			
		||||
actix-http = "3.11.0"
 | 
			
		||||
serde = { version = "1.0.219", features = ["derive"] }
 | 
			
		||||
serde_json = "1.0.140"
 | 
			
		||||
serde_yml = "0.0.12"
 | 
			
		||||
quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] }
 | 
			
		||||
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.22", features = ["stream"] }
 | 
			
		||||
url = "2.5.4"
 | 
			
		||||
virt = "0.4.2"
 | 
			
		||||
sysinfo = { version = "0.35.1", features = ["serde"] }
 | 
			
		||||
sysinfo = { version = "0.35.2", features = ["serde"] }
 | 
			
		||||
uuid = { version = "1.16.0", features = ["v4", "serde"] }
 | 
			
		||||
lazy-regex = "3.4.1"
 | 
			
		||||
thiserror = "2.0.12"
 | 
			
		||||
image = "0.25.6"
 | 
			
		||||
rand = "0.9.1"
 | 
			
		||||
tokio = { version = "1.45.0", features = ["rt", "time", "macros"] }
 | 
			
		||||
tokio = { version = "1.46.1", features = ["rt", "time", "macros"] }
 | 
			
		||||
futures = "0.3.31"
 | 
			
		||||
ipnetwork = { version = "0.21.1", features = ["serde"] }
 | 
			
		||||
num = "0.4.3"
 | 
			
		||||
@@ -44,3 +45,5 @@ rust-embed = { version = "8.7.2", features = ["mime-guess"] }
 | 
			
		||||
dotenvy = "0.15.7"
 | 
			
		||||
nix = { version = "0.30.1", features = ["net"] }
 | 
			
		||||
basic-jwt = "0.3.0"
 | 
			
		||||
zip = "4.1.0"
 | 
			
		||||
chrono = "0.4.41"
 | 
			
		||||
@@ -27,10 +27,7 @@ impl LibVirtActor {
 | 
			
		||||
    /// Connect to hypervisor
 | 
			
		||||
    pub async fn connect() -> anyhow::Result<Self> {
 | 
			
		||||
        let hypervisor_uri = AppConfig::get().hypervisor_uri.as_deref().unwrap_or("");
 | 
			
		||||
        log::info!(
 | 
			
		||||
            "Will connect to hypvervisor at address '{}'",
 | 
			
		||||
            hypervisor_uri
 | 
			
		||||
        );
 | 
			
		||||
        log::info!("Will connect to hypvervisor at address '{hypervisor_uri}'",);
 | 
			
		||||
        let conn = Connect::open(Some(hypervisor_uri))?;
 | 
			
		||||
 | 
			
		||||
        Ok(Self { m: conn })
 | 
			
		||||
@@ -102,7 +99,7 @@ impl Handler<GetDomainXMLReq> for LibVirtActor {
 | 
			
		||||
        log::debug!("Get domain XML:\n{}", msg.0.as_string());
 | 
			
		||||
        let domain = Domain::lookup_by_uuid_string(&self.m, &msg.0.as_string())?;
 | 
			
		||||
        let xml = domain.get_xml_desc(VIR_DOMAIN_XML_SECURE)?;
 | 
			
		||||
        log::debug!("XML = {}", xml);
 | 
			
		||||
        log::debug!("XML = {xml}");
 | 
			
		||||
        DomainXML::parse_xml(&xml)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -131,7 +128,7 @@ impl Handler<DefineDomainReq> for LibVirtActor {
 | 
			
		||||
    fn handle(&mut self, mut msg: DefineDomainReq, _ctx: &mut Self::Context) -> Self::Result {
 | 
			
		||||
        let xml = msg.1.as_xml()?;
 | 
			
		||||
 | 
			
		||||
        log::debug!("Define domain:\n{}", xml);
 | 
			
		||||
        log::debug!("Define domain:\n{xml}");
 | 
			
		||||
        let domain = Domain::define_xml(&self.m, &xml)?;
 | 
			
		||||
        let uuid = XMLUuid::parse_from_str(&domain.get_uuid_string()?)?;
 | 
			
		||||
 | 
			
		||||
@@ -182,6 +179,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);
 | 
			
		||||
@@ -439,7 +443,7 @@ impl Handler<GetNetworkXMLReq> for LibVirtActor {
 | 
			
		||||
        log::debug!("Get network XML:\n{}", msg.0.as_string());
 | 
			
		||||
        let network = Network::lookup_by_uuid_string(&self.m, &msg.0.as_string())?;
 | 
			
		||||
        let xml = network.get_xml_desc(0)?;
 | 
			
		||||
        log::debug!("XML = {}", xml);
 | 
			
		||||
        log::debug!("XML = {xml}");
 | 
			
		||||
        NetworkXML::parse_xml(&xml)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -595,7 +599,7 @@ impl Handler<GetNWFilterXMLReq> for LibVirtActor {
 | 
			
		||||
        log::debug!("Get network filter XML:\n{}", msg.0.as_string());
 | 
			
		||||
        let filter = NWFilter::lookup_by_uuid_string(&self.m, &msg.0.as_string())?;
 | 
			
		||||
        let xml = filter.get_xml_desc(0)?;
 | 
			
		||||
        log::debug!("XML = {}", xml);
 | 
			
		||||
        log::debug!("XML = {xml}");
 | 
			
		||||
        NetworkFilterXML::parse_xml(xml)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -610,7 +614,7 @@ impl Handler<DefineNWFilterReq> for LibVirtActor {
 | 
			
		||||
    fn handle(&mut self, mut msg: DefineNWFilterReq, _ctx: &mut Self::Context) -> Self::Result {
 | 
			
		||||
        let xml = msg.1.into_xml()?;
 | 
			
		||||
 | 
			
		||||
        log::debug!("Define network filter:\n{}", xml);
 | 
			
		||||
        log::debug!("Define network filter:\n{xml}");
 | 
			
		||||
        let filter = NWFilter::define_xml(&self.m, &xml)?;
 | 
			
		||||
        let uuid = XMLUuid::parse_from_str(&filter.get_uuid_string()?)?;
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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")
 | 
			
		||||
@@ -267,7 +280,7 @@ impl AppConfig {
 | 
			
		||||
 | 
			
		||||
    /// Get VM vnc sockets path for domain
 | 
			
		||||
    pub fn vnc_socket_for_domain(&self, name: &str) -> PathBuf {
 | 
			
		||||
        self.vnc_sockets_path().join(format!("vnc-{}", name))
 | 
			
		||||
        self.vnc_sockets_path().join(format!("vnc-{name}"))
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get VM root disks storage directory
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
 | 
			
		||||
@@ -122,19 +126,25 @@ pub const API_TOKEN_DESCRIPTION_MAX_LENGTH: usize = 30;
 | 
			
		||||
pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255;
 | 
			
		||||
 | 
			
		||||
/// Qemu image program path
 | 
			
		||||
pub const QEMU_IMAGE_PROGRAM: &str = "/usr/bin/qemu-img";
 | 
			
		||||
pub const PROGRAM_QEMU_IMAGE: &str = "/usr/bin/qemu-img";
 | 
			
		||||
 | 
			
		||||
/// IP program path
 | 
			
		||||
pub const IP_PROGRAM: &str = "/usr/sbin/ip";
 | 
			
		||||
pub const PROGRAM_IP: &str = "/usr/sbin/ip";
 | 
			
		||||
 | 
			
		||||
/// Copy program path
 | 
			
		||||
pub const COPY_PROGRAM: &str = "/bin/cp";
 | 
			
		||||
pub const PROGRAM_COPY: &str = "/bin/cp";
 | 
			
		||||
 | 
			
		||||
/// Gzip program path
 | 
			
		||||
pub const GZIP_PROGRAM: &str = "/usr/bin/gzip";
 | 
			
		||||
pub const PROGRAM_GZIP: &str = "/usr/bin/gzip";
 | 
			
		||||
 | 
			
		||||
/// XZ program path
 | 
			
		||||
pub const PROGRAM_XZ: &str = "/usr/bin/xz";
 | 
			
		||||
 | 
			
		||||
/// Bash program
 | 
			
		||||
pub const BASH_PROGRAM: &str = "/usr/bin/bash";
 | 
			
		||||
pub const PROGRAM_BASH: &str = "/usr/bin/bash";
 | 
			
		||||
 | 
			
		||||
/// DD program
 | 
			
		||||
pub const DD_PROGRAM: &str = "/usr/bin/dd";
 | 
			
		||||
pub const PROGRAM_DD: &str = "/usr/bin/dd";
 | 
			
		||||
 | 
			
		||||
/// cloud-localds program
 | 
			
		||||
pub const PROGRAM_CLOUD_LOCALDS: &str = "/usr/bin/cloud-localds";
 | 
			
		||||
 
 | 
			
		||||
@@ -34,8 +34,7 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>)
 | 
			
		||||
    if let Some(mime_type) = file.content_type {
 | 
			
		||||
        if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) {
 | 
			
		||||
            return Ok(HttpResponse::BadRequest().json(format!(
 | 
			
		||||
                "Unsupported file type for disk upload: {}",
 | 
			
		||||
                mime_type
 | 
			
		||||
                "Unsupported file type for disk upload: {mime_type}"
 | 
			
		||||
            )));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@@ -55,7 +54,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!"))
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -52,7 +52,7 @@ pub async fn upload_file(MultipartForm(mut form): MultipartForm<UploadIsoForm>)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let dest_file = AppConfig::get().iso_storage_path().join(file_name);
 | 
			
		||||
    log::info!("Will save ISO file {:?}", dest_file);
 | 
			
		||||
    log::info!("Will save ISO file {dest_file:?}");
 | 
			
		||||
 | 
			
		||||
    if dest_file.exists() {
 | 
			
		||||
        log::error!("Conflict with uploaded iso file name!");
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ use actix_web::body::BoxBody;
 | 
			
		||||
use actix_web::{HttpResponse, web};
 | 
			
		||||
use std::error::Error;
 | 
			
		||||
use std::fmt::{Display, Formatter};
 | 
			
		||||
use zip::result::ZipError;
 | 
			
		||||
 | 
			
		||||
pub mod api_tokens_controller;
 | 
			
		||||
pub mod auth_controller;
 | 
			
		||||
@@ -42,7 +43,7 @@ impl actix_web::error::ResponseError for HttpErr {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    fn error_response(&self) -> HttpResponse<BoxBody> {
 | 
			
		||||
        log::error!("Error while processing request! {}", self);
 | 
			
		||||
        log::error!("Error while processing request! {self}");
 | 
			
		||||
 | 
			
		||||
        HttpResponse::InternalServerError().body("Failed to execute request!")
 | 
			
		||||
    }
 | 
			
		||||
@@ -102,6 +103,12 @@ impl From<actix_web::Error> for HttpErr {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl From<ZipError> for HttpErr {
 | 
			
		||||
    fn from(value: ZipError) -> Self {
 | 
			
		||||
        HttpErr::Err(std::io::Error::other(value.to_string()).into())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl From<HttpResponse> for HttpErr {
 | 
			
		||||
    fn from(value: HttpResponse) -> Self {
 | 
			
		||||
        HttpErr::HTTPResponse(value)
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,24 @@
 | 
			
		||||
use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME;
 | 
			
		||||
use crate::app_config::AppConfig;
 | 
			
		||||
use crate::constants;
 | 
			
		||||
use crate::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN};
 | 
			
		||||
use crate::controllers::{HttpResult, LibVirtReq};
 | 
			
		||||
use crate::extractors::local_auth_extractor::LocalAuthEnabled;
 | 
			
		||||
use crate::libvirt_rest_structures::hypervisor::HypervisorInfo;
 | 
			
		||||
use crate::libvirt_rest_structures::net::NetworkInfo;
 | 
			
		||||
use crate::libvirt_rest_structures::nw_filter::NetworkFilter;
 | 
			
		||||
use crate::libvirt_rest_structures::vm::VMInfo;
 | 
			
		||||
use crate::nat::nat_hook;
 | 
			
		||||
use crate::utils::net_utils;
 | 
			
		||||
use actix_web::{HttpResponse, Responder};
 | 
			
		||||
use crate::utils::time_utils::{format_date, time};
 | 
			
		||||
use crate::{api_tokens, constants};
 | 
			
		||||
use actix_files::NamedFile;
 | 
			
		||||
use actix_web::{HttpRequest, HttpResponse, Responder};
 | 
			
		||||
use serde::Serialize;
 | 
			
		||||
use std::fs::File;
 | 
			
		||||
use std::io::Write;
 | 
			
		||||
use sysinfo::{Components, Disks, Networks, System};
 | 
			
		||||
use zip::ZipWriter;
 | 
			
		||||
use zip::write::SimpleFileOptions;
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Serialize)]
 | 
			
		||||
struct StaticConfig {
 | 
			
		||||
@@ -199,3 +209,85 @@ pub async fn networks_list() -> HttpResult {
 | 
			
		||||
pub async fn bridges_list() -> HttpResult {
 | 
			
		||||
    Ok(HttpResponse::Ok().json(net_utils::bridges_list()?))
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Add JSON file to ZIP
 | 
			
		||||
fn zip_json<E: Serialize, F>(
 | 
			
		||||
    zip: &mut ZipWriter<File>,
 | 
			
		||||
    dir: &str,
 | 
			
		||||
    content: &Vec<E>,
 | 
			
		||||
    file_name: F,
 | 
			
		||||
) -> anyhow::Result<()>
 | 
			
		||||
where
 | 
			
		||||
    F: Fn(&E) -> String,
 | 
			
		||||
{
 | 
			
		||||
    for entry in content {
 | 
			
		||||
        let file_encoded = serde_json::to_string(&entry)?;
 | 
			
		||||
 | 
			
		||||
        let options = SimpleFileOptions::default()
 | 
			
		||||
            .compression_method(zip::CompressionMethod::Deflated)
 | 
			
		||||
            .unix_permissions(0o750);
 | 
			
		||||
 | 
			
		||||
        zip.start_file(format!("{dir}/{}.json", file_name(entry)), options)?;
 | 
			
		||||
        zip.write_all(file_encoded.as_bytes())?;
 | 
			
		||||
    }
 | 
			
		||||
    Ok(())
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Export all configuration elements at once
 | 
			
		||||
pub async fn export_all_configs(req: HttpRequest, client: LibVirtReq) -> HttpResult {
 | 
			
		||||
    // Perform extractions
 | 
			
		||||
    let vms = client
 | 
			
		||||
        .get_full_domains_list()
 | 
			
		||||
        .await?
 | 
			
		||||
        .into_iter()
 | 
			
		||||
        .map(VMInfo::from_domain)
 | 
			
		||||
        .collect::<Result<Vec<_>, _>>()?;
 | 
			
		||||
    let networks = client
 | 
			
		||||
        .get_full_networks_list()
 | 
			
		||||
        .await?
 | 
			
		||||
        .into_iter()
 | 
			
		||||
        .map(NetworkInfo::from_xml)
 | 
			
		||||
        .collect::<Result<Vec<_>, _>>()?;
 | 
			
		||||
    let nw_filters = client
 | 
			
		||||
        .get_full_network_filters_list()
 | 
			
		||||
        .await?
 | 
			
		||||
        .into_iter()
 | 
			
		||||
        .map(NetworkFilter::lib2rest)
 | 
			
		||||
        .collect::<Result<Vec<_>, _>>()?;
 | 
			
		||||
    let tokens = api_tokens::full_list().await?;
 | 
			
		||||
 | 
			
		||||
    // Create ZIP file
 | 
			
		||||
    let dest_dir = tempfile::tempdir_in(&AppConfig::get().temp_dir)?;
 | 
			
		||||
    let zip_path = dest_dir.path().join("export.zip");
 | 
			
		||||
 | 
			
		||||
    let file = File::create(&zip_path)?;
 | 
			
		||||
    let mut zip = ZipWriter::new(file);
 | 
			
		||||
 | 
			
		||||
    // Encode entities to JSON
 | 
			
		||||
    zip_json(&mut zip, "vms", &vms, |v| v.name.to_string())?;
 | 
			
		||||
    zip_json(&mut zip, "networks", &networks, |v| v.name.0.to_string())?;
 | 
			
		||||
    zip_json(
 | 
			
		||||
        &mut zip,
 | 
			
		||||
        "nw_filters",
 | 
			
		||||
        &nw_filters,
 | 
			
		||||
        |v| match constants::BUILTIN_NETWORK_FILTER_RULES.contains(&v.name.0.as_str()) {
 | 
			
		||||
            true => format!("builtin/{}", v.name.0),
 | 
			
		||||
            false => v.name.0.to_string(),
 | 
			
		||||
        },
 | 
			
		||||
    )?;
 | 
			
		||||
    zip_json(&mut zip, "tokens", &tokens, |v| v.id.0.to_string())?;
 | 
			
		||||
 | 
			
		||||
    // Finalize ZIP and return response
 | 
			
		||||
    zip.finish()?;
 | 
			
		||||
    let file = File::open(zip_path)?;
 | 
			
		||||
 | 
			
		||||
    let file = NamedFile::from_file(
 | 
			
		||||
        file,
 | 
			
		||||
        format!(
 | 
			
		||||
            "export_{}.zip",
 | 
			
		||||
            format_date(time() as i64).unwrap().replace('/', "-")
 | 
			
		||||
        ),
 | 
			
		||||
    )?;
 | 
			
		||||
 | 
			
		||||
    Ok(file.into_response(&req))
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,5 @@
 | 
			
		||||
use crate::libvirt_lib_structures::XMLUuid;
 | 
			
		||||
use crate::utils::cloud_init_utils::CloudInitConfig;
 | 
			
		||||
 | 
			
		||||
/// VirtWeb specific metadata
 | 
			
		||||
#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)]
 | 
			
		||||
@@ -8,6 +9,8 @@ pub struct DomainMetadataVirtWebXML {
 | 
			
		||||
    pub ns: String,
 | 
			
		||||
    #[serde(skip_serializing_if = "Option::is_none")]
 | 
			
		||||
    pub group: Option<String>,
 | 
			
		||||
    #[serde(skip_serializing_if = "Option::is_none")]
 | 
			
		||||
    pub cloud_init: Option<CloudInitConfig>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Domain metadata
 | 
			
		||||
 
 | 
			
		||||
@@ -13,4 +13,6 @@ enum LibVirtStructError {
 | 
			
		||||
    ParseFilteringChain(String),
 | 
			
		||||
    #[error("NetworkFilterExtractionError: {0}")]
 | 
			
		||||
    NetworkFilterExtraction(String),
 | 
			
		||||
    #[error("CloudInitConfigurationError: {0}")]
 | 
			
		||||
    CloudInitConfiguration(String),
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -109,7 +109,7 @@ fn extract_ip_mask<const V: usize>(n: Option<u8>) -> anyhow::Result<Option<u8>>
 | 
			
		||||
fn extract_nw_filter_comment(n: &Option<String>) -> anyhow::Result<Option<String>> {
 | 
			
		||||
    if let Some(comment) = n {
 | 
			
		||||
        if comment.len() > 256 || comment.contains('\"') || comment.contains('\n') {
 | 
			
		||||
            return Err(NetworkFilterExtraction(format!("Invalid comment! {}", comment)).into());
 | 
			
		||||
            return Err(NetworkFilterExtraction(format!("Invalid comment! {comment}")).into());
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,10 @@ use crate::constants;
 | 
			
		||||
use crate::libvirt_lib_structures::XMLUuid;
 | 
			
		||||
use crate::libvirt_lib_structures::domain::*;
 | 
			
		||||
use crate::libvirt_rest_structures::LibVirtStructError;
 | 
			
		||||
use crate::libvirt_rest_structures::LibVirtStructError::StructureExtraction;
 | 
			
		||||
use crate::libvirt_rest_structures::LibVirtStructError::{
 | 
			
		||||
    CloudInitConfiguration, StructureExtraction,
 | 
			
		||||
};
 | 
			
		||||
use crate::utils::cloud_init_utils::CloudInitConfig;
 | 
			
		||||
use crate::utils::file_size_utils::FileSize;
 | 
			
		||||
use crate::utils::files_utils;
 | 
			
		||||
use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk};
 | 
			
		||||
@@ -94,6 +97,9 @@ pub struct VMInfo {
 | 
			
		||||
    pub tpm_module: bool,
 | 
			
		||||
    /// Strings injected as OEM Strings in SMBios configuration
 | 
			
		||||
    pub oem_strings: Vec<String>,
 | 
			
		||||
    /// Cloud init configuration
 | 
			
		||||
    #[serde(default)]
 | 
			
		||||
    pub cloud_init: CloudInitConfig,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl VMInfo {
 | 
			
		||||
@@ -138,9 +144,26 @@ impl VMInfo {
 | 
			
		||||
            return Err(StructureExtraction("Invalid number of vCPU specified!").into());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let mut disks = vec![];
 | 
			
		||||
        if let Some(e) = self.cloud_init.check_error() {
 | 
			
		||||
            return Err(CloudInitConfiguration(e).into());
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Add ISO files
 | 
			
		||||
        let mut iso_absolute_files = vec![];
 | 
			
		||||
 | 
			
		||||
        // 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());
 | 
			
		||||
@@ -152,6 +175,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(),
 | 
			
		||||
@@ -161,7 +191,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!(
 | 
			
		||||
@@ -178,6 +208,7 @@ impl VMInfo {
 | 
			
		||||
            })
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Configure VNC access, if requested
 | 
			
		||||
        let (vnc_graphics, vnc_video) = match self.vnc_access {
 | 
			
		||||
            true => (
 | 
			
		||||
                Some(GraphicsXML {
 | 
			
		||||
@@ -340,6 +371,7 @@ impl VMInfo {
 | 
			
		||||
                virtweb: DomainMetadataVirtWebXML {
 | 
			
		||||
                    ns: "https://virtweb.communiquons.org".to_string(),
 | 
			
		||||
                    group: self.group.clone().map(|g| g.0),
 | 
			
		||||
                    cloud_init: Some(self.cloud_init.clone()),
 | 
			
		||||
                },
 | 
			
		||||
            }),
 | 
			
		||||
            os: OSXML {
 | 
			
		||||
@@ -490,6 +522,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
 | 
			
		||||
@@ -582,6 +615,13 @@ impl VMInfo {
 | 
			
		||||
                .and_then(|s| s.oem_strings)
 | 
			
		||||
                .map(|s| s.entries.iter().map(|o| o.content.to_string()).collect())
 | 
			
		||||
                .unwrap_or_default(),
 | 
			
		||||
            cloud_init: domain
 | 
			
		||||
                .metadata
 | 
			
		||||
                .clone()
 | 
			
		||||
                .unwrap_or_default()
 | 
			
		||||
                .virtweb
 | 
			
		||||
                .cloud_init
 | 
			
		||||
                .unwrap_or_default(),
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -47,16 +47,22 @@ async fn main() -> std::io::Result<()> {
 | 
			
		||||
 | 
			
		||||
    log::debug!("Checking for required programs");
 | 
			
		||||
    exec_utils::check_program(
 | 
			
		||||
        constants::QEMU_IMAGE_PROGRAM,
 | 
			
		||||
        constants::PROGRAM_QEMU_IMAGE,
 | 
			
		||||
        "QEMU disk image utility is required to manipulate QCow2 files!",
 | 
			
		||||
    );
 | 
			
		||||
    exec_utils::check_program(
 | 
			
		||||
        constants::IP_PROGRAM,
 | 
			
		||||
        constants::PROGRAM_IP,
 | 
			
		||||
        "ip is required to access bridges information!",
 | 
			
		||||
    );
 | 
			
		||||
    exec_utils::check_program(
 | 
			
		||||
        constants::PROGRAM_CLOUD_LOCALDS,
 | 
			
		||||
        "cloud-localds from package cloud-image-utils is required to build cloud-init images!",
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    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();
 | 
			
		||||
@@ -151,6 +157,10 @@ async fn main() -> std::io::Result<()> {
 | 
			
		||||
                "/api/server/bridges",
 | 
			
		||||
                web::get().to(server_controller::bridges_list),
 | 
			
		||||
            )
 | 
			
		||||
            .route(
 | 
			
		||||
                "/api/server/export_configs",
 | 
			
		||||
                web::get().to(server_controller::export_all_configs),
 | 
			
		||||
            )
 | 
			
		||||
            // Auth controller
 | 
			
		||||
            .route(
 | 
			
		||||
                "/api/auth/local",
 | 
			
		||||
@@ -198,6 +208,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),
 | 
			
		||||
 
 | 
			
		||||
@@ -69,8 +69,7 @@ where
 | 
			
		||||
 | 
			
		||||
            if !AppConfig::get().is_allowed_ip(remote_ip.0) {
 | 
			
		||||
                log::error!(
 | 
			
		||||
                    "An attempt to access VirtWeb from an unauthorized network has been intercepted! {:?}",
 | 
			
		||||
                    remote_ip
 | 
			
		||||
                    "An attempt to access VirtWeb from an unauthorized network has been intercepted! {remote_ip:?}"
 | 
			
		||||
                );
 | 
			
		||||
                return Ok(req
 | 
			
		||||
                    .into_response(
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										117
									
								
								virtweb_backend/src/utils/cloud_init_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								virtweb_backend/src/utils/cloud_init_utils.rs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,117 @@
 | 
			
		||||
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 {
 | 
			
		||||
    pub attach_config: bool,
 | 
			
		||||
    /// Main user data
 | 
			
		||||
    pub user_data: String,
 | 
			
		||||
    /// Instance ID, set in metadata file
 | 
			
		||||
    #[serde(skip_serializing_if = "Option::is_none")]
 | 
			
		||||
    pub instance_id: Option<String>,
 | 
			
		||||
    /// Local hostname, set in metadata file
 | 
			
		||||
    #[serde(skip_serializing_if = "Option::is_none")]
 | 
			
		||||
    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")]
 | 
			
		||||
    pub network_configuration: Option<String>,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl CloudInitConfig {
 | 
			
		||||
    /// Check cloud init configuration
 | 
			
		||||
    pub fn check_error(&self) -> Option<String> {
 | 
			
		||||
        if !self.user_data.is_empty() {
 | 
			
		||||
            // Check YAML content
 | 
			
		||||
            if let Err(e) = serde_yml::from_str::<serde_json::Value>(&self.user_data) {
 | 
			
		||||
                return Some(format!(
 | 
			
		||||
                    "user data is an invalid YAML file! Deserialization error: {e}"
 | 
			
		||||
                ));
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Check first line
 | 
			
		||||
            if !self.user_data.starts_with("#cloud-config\n") {
 | 
			
		||||
                return Some(
 | 
			
		||||
                    "user data file MUST start with '#cloud-config' as first line!".to_string(),
 | 
			
		||||
                );
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        None
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// 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)?)
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -28,8 +28,10 @@ pub enum DiskFileFormat {
 | 
			
		||||
        #[serde(default)]
 | 
			
		||||
        virtual_size: FileSize,
 | 
			
		||||
    },
 | 
			
		||||
    CompressedRaw,
 | 
			
		||||
    CompressedQCow2,
 | 
			
		||||
    GzCompressedRaw,
 | 
			
		||||
    GzCompressedQCow2,
 | 
			
		||||
    XzCompressedRaw,
 | 
			
		||||
    XzCompressedQCow2,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
impl DiskFileFormat {
 | 
			
		||||
@@ -37,8 +39,10 @@ impl DiskFileFormat {
 | 
			
		||||
        match self {
 | 
			
		||||
            DiskFileFormat::Raw { .. } => &["raw", ""],
 | 
			
		||||
            DiskFileFormat::QCow2 { .. } => &["qcow2"],
 | 
			
		||||
            DiskFileFormat::CompressedRaw => &["raw.gz"],
 | 
			
		||||
            DiskFileFormat::CompressedQCow2 => &["qcow2.gz"],
 | 
			
		||||
            DiskFileFormat::GzCompressedRaw => &["raw.gz"],
 | 
			
		||||
            DiskFileFormat::GzCompressedQCow2 => &["qcow2.gz"],
 | 
			
		||||
            DiskFileFormat::XzCompressedRaw => &["raw.xz"],
 | 
			
		||||
            DiskFileFormat::XzCompressedQCow2 => &["qcow2.xz"],
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -81,9 +85,14 @@ impl DiskFileInfo {
 | 
			
		||||
            },
 | 
			
		||||
            "gz" if name.ends_with(".qcow2") => {
 | 
			
		||||
                name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
 | 
			
		||||
                DiskFileFormat::CompressedQCow2
 | 
			
		||||
                DiskFileFormat::GzCompressedQCow2
 | 
			
		||||
            }
 | 
			
		||||
            "gz" => DiskFileFormat::CompressedRaw,
 | 
			
		||||
            "gz" => DiskFileFormat::GzCompressedRaw,
 | 
			
		||||
            "xz" if name.ends_with(".qcow2") => {
 | 
			
		||||
                name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string();
 | 
			
		||||
                DiskFileFormat::XzCompressedQCow2
 | 
			
		||||
            }
 | 
			
		||||
            "xz" => DiskFileFormat::XzCompressedRaw,
 | 
			
		||||
            _ => anyhow::bail!("Unsupported disk extension: {ext}!"),
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
@@ -124,7 +133,7 @@ impl DiskFileInfo {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            DiskFileFormat::QCow2 { virtual_size } => {
 | 
			
		||||
                let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
 | 
			
		||||
                cmd.arg("create")
 | 
			
		||||
                    .arg("-f")
 | 
			
		||||
                    .arg("qcow2")
 | 
			
		||||
@@ -159,9 +168,9 @@ impl DiskFileInfo {
 | 
			
		||||
 | 
			
		||||
        // Prepare the conversion
 | 
			
		||||
        let mut cmd = match (self.format, dest_format) {
 | 
			
		||||
            // Decompress QCow2
 | 
			
		||||
            (DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::GZIP_PROGRAM);
 | 
			
		||||
            // Decompress QCow2 (GZIP)
 | 
			
		||||
            (DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_GZIP);
 | 
			
		||||
                cmd.arg("--keep")
 | 
			
		||||
                    .arg("--decompress")
 | 
			
		||||
                    .arg("--to-stdout")
 | 
			
		||||
@@ -170,9 +179,30 @@ impl DiskFileInfo {
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Compress QCow2
 | 
			
		||||
            (DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::GZIP_PROGRAM);
 | 
			
		||||
            // Decompress QCow2 (XZ)
 | 
			
		||||
            (DiskFileFormat::XzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_XZ);
 | 
			
		||||
                cmd.arg("--stdout")
 | 
			
		||||
                    .arg("--keep")
 | 
			
		||||
                    .arg("--decompress")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
                    .stdout(File::create(&temp_file)?);
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Compress QCow2 (Gzip)
 | 
			
		||||
            (DiskFileFormat::QCow2 { .. }, DiskFileFormat::GzCompressedQCow2) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_GZIP);
 | 
			
		||||
                cmd.arg("--keep")
 | 
			
		||||
                    .arg("--to-stdout")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
                    .stdout(File::create(&temp_file)?);
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Compress QCow2 (Xz)
 | 
			
		||||
            (DiskFileFormat::QCow2 { .. }, DiskFileFormat::XzCompressedQCow2) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_XZ);
 | 
			
		||||
                cmd.arg("--keep")
 | 
			
		||||
                    .arg("--to-stdout")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
@@ -182,8 +212,14 @@ impl DiskFileInfo {
 | 
			
		||||
 | 
			
		||||
            // Convert QCow2 to Raw file
 | 
			
		||||
            (DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
 | 
			
		||||
                cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
 | 
			
		||||
                cmd.arg("convert")
 | 
			
		||||
                    .arg("-f")
 | 
			
		||||
                    .arg("qcow2")
 | 
			
		||||
                    .arg("-O")
 | 
			
		||||
                    .arg("raw")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
                    .arg(&temp_file);
 | 
			
		||||
 | 
			
		||||
                if !is_sparse {
 | 
			
		||||
                    cmd.args(["-S", "0"]);
 | 
			
		||||
@@ -195,8 +231,10 @@ impl DiskFileInfo {
 | 
			
		||||
            // Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of
 | 
			
		||||
            // file
 | 
			
		||||
            (DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
 | 
			
		||||
                cmd.arg("convert")
 | 
			
		||||
                    .arg("-f")
 | 
			
		||||
                    .arg("qcow2")
 | 
			
		||||
                    .arg("-O")
 | 
			
		||||
                    .arg("qcow2")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
@@ -206,15 +244,21 @@ impl DiskFileInfo {
 | 
			
		||||
 | 
			
		||||
            // Convert Raw to QCow2 file
 | 
			
		||||
            (DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
 | 
			
		||||
                cmd.arg("convert").arg(&self.file_path).arg(&temp_file);
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
 | 
			
		||||
                cmd.arg("convert")
 | 
			
		||||
                    .arg("-f")
 | 
			
		||||
                    .arg("raw")
 | 
			
		||||
                    .arg("-O")
 | 
			
		||||
                    .arg("qcow2")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
                    .arg(&temp_file);
 | 
			
		||||
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Render raw file non sparse
 | 
			
		||||
            (DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::COPY_PROGRAM);
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_COPY);
 | 
			
		||||
                cmd.arg("--sparse=never")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
                    .arg(&temp_file);
 | 
			
		||||
@@ -223,16 +267,16 @@ impl DiskFileInfo {
 | 
			
		||||
 | 
			
		||||
            // Render raw file sparse
 | 
			
		||||
            (DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::DD_PROGRAM);
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_DD);
 | 
			
		||||
                cmd.arg("conv=sparse")
 | 
			
		||||
                    .arg(format!("if={}", self.file_path.display()))
 | 
			
		||||
                    .arg(format!("of={}", temp_file.display()));
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Compress Raw
 | 
			
		||||
            (DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::GZIP_PROGRAM);
 | 
			
		||||
            // Compress Raw (Gz)
 | 
			
		||||
            (DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_GZIP);
 | 
			
		||||
                cmd.arg("--keep")
 | 
			
		||||
                    .arg("--to-stdout")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
@@ -240,9 +284,29 @@ impl DiskFileInfo {
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Decompress Raw to not sparse file
 | 
			
		||||
            (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::GZIP_PROGRAM);
 | 
			
		||||
            // Compress Raw (Xz)
 | 
			
		||||
            (DiskFileFormat::Raw { .. }, DiskFileFormat::XzCompressedRaw) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_XZ);
 | 
			
		||||
                cmd.arg("--keep")
 | 
			
		||||
                    .arg("--to-stdout")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
                    .stdout(File::create(&temp_file)?);
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Decompress Raw (Gz) to not sparse file
 | 
			
		||||
            (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_GZIP);
 | 
			
		||||
                cmd.arg("--keep")
 | 
			
		||||
                    .arg("--decompress")
 | 
			
		||||
                    .arg("--to-stdout")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
                    .stdout(File::create(&temp_file)?);
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
            // Decompress Raw (Xz) to not sparse file
 | 
			
		||||
            (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_XZ);
 | 
			
		||||
                cmd.arg("--keep")
 | 
			
		||||
                    .arg("--decompress")
 | 
			
		||||
                    .arg("--to-stdout")
 | 
			
		||||
@@ -251,15 +315,29 @@ impl DiskFileInfo {
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Decompress Raw to sparse file
 | 
			
		||||
            // Decompress Raw (Gz) to sparse file
 | 
			
		||||
            // https://benou.fr/www/ben/decompressing-sparse-files.html
 | 
			
		||||
            (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::BASH_PROGRAM);
 | 
			
		||||
            (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_BASH);
 | 
			
		||||
                cmd.arg("-c").arg(format!(
 | 
			
		||||
                    "{} -d -c {} | {} conv=sparse of={}",
 | 
			
		||||
                    constants::GZIP_PROGRAM,
 | 
			
		||||
                    "{} --decompress --to-stdout {} | {} conv=sparse of={}",
 | 
			
		||||
                    constants::PROGRAM_GZIP,
 | 
			
		||||
                    self.file_path.display(),
 | 
			
		||||
                    constants::DD_PROGRAM,
 | 
			
		||||
                    constants::PROGRAM_DD,
 | 
			
		||||
                    temp_file.display()
 | 
			
		||||
                ));
 | 
			
		||||
                cmd
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Decompress Raw (XZ) to sparse file
 | 
			
		||||
            // https://benou.fr/www/ben/decompressing-sparse-files.html
 | 
			
		||||
            (DiskFileFormat::XzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => {
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_BASH);
 | 
			
		||||
                cmd.arg("-c").arg(format!(
 | 
			
		||||
                    "{} --decompress --to-stdout {} | {} conv=sparse of={}",
 | 
			
		||||
                    constants::PROGRAM_XZ,
 | 
			
		||||
                    self.file_path.display(),
 | 
			
		||||
                    constants::PROGRAM_DD,
 | 
			
		||||
                    temp_file.display()
 | 
			
		||||
                ));
 | 
			
		||||
                cmd
 | 
			
		||||
@@ -267,7 +345,7 @@ impl DiskFileInfo {
 | 
			
		||||
 | 
			
		||||
            // Dumb copy of file
 | 
			
		||||
            (a, b) if a == b => {
 | 
			
		||||
                let mut cmd = Command::new(constants::COPY_PROGRAM);
 | 
			
		||||
                let mut cmd = Command::new(constants::PROGRAM_COPY);
 | 
			
		||||
                cmd.arg("--sparse=auto")
 | 
			
		||||
                    .arg(&self.file_path)
 | 
			
		||||
                    .arg(&temp_file);
 | 
			
		||||
@@ -316,6 +394,44 @@ impl DiskFileInfo {
 | 
			
		||||
 | 
			
		||||
        Ok(())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Get disk virtual size, if available
 | 
			
		||||
    pub fn virtual_size(&self) -> Option<FileSize> {
 | 
			
		||||
        match self.format {
 | 
			
		||||
            DiskFileFormat::Raw { .. } => Some(self.file_size),
 | 
			
		||||
            DiskFileFormat::QCow2 { virtual_size } => Some(virtual_size),
 | 
			
		||||
            _ => None,
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /// Resize disk
 | 
			
		||||
    pub fn resize(&self, new_size: FileSize) -> anyhow::Result<()> {
 | 
			
		||||
        if new_size <= self.virtual_size().unwrap_or(new_size) {
 | 
			
		||||
            anyhow::bail!("Shrinking disk image file is not supported!");
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
 | 
			
		||||
        cmd.arg("resize")
 | 
			
		||||
            .arg("-f")
 | 
			
		||||
            .arg(match self.format {
 | 
			
		||||
                DiskFileFormat::QCow2 { .. } => "qcow2",
 | 
			
		||||
                DiskFileFormat::Raw { .. } => "raw",
 | 
			
		||||
                f => anyhow::bail!("Unsupported disk format for resize: {f:?}"),
 | 
			
		||||
            })
 | 
			
		||||
            .arg(&self.file_path)
 | 
			
		||||
            .arg(new_size.as_bytes().to_string());
 | 
			
		||||
 | 
			
		||||
        let output = cmd.output()?;
 | 
			
		||||
        if !output.status.success() {
 | 
			
		||||
            anyhow::bail!(
 | 
			
		||||
                "{} info failed, status: {}, stderr: {}",
 | 
			
		||||
                constants::PROGRAM_QEMU_IMAGE,
 | 
			
		||||
                output.status,
 | 
			
		||||
                String::from_utf8_lossy(&output.stderr)
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
        Ok(())
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#[derive(serde::Deserialize)]
 | 
			
		||||
@@ -327,7 +443,7 @@ struct QCowInfoOutput {
 | 
			
		||||
/// Get QCow2 virtual size
 | 
			
		||||
fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
 | 
			
		||||
    // Run qemu-img
 | 
			
		||||
    let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM);
 | 
			
		||||
    let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE);
 | 
			
		||||
    cmd.args([
 | 
			
		||||
        "info",
 | 
			
		||||
        path.to_str().unwrap_or(""),
 | 
			
		||||
@@ -339,7 +455,7 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> {
 | 
			
		||||
    if !output.status.success() {
 | 
			
		||||
        anyhow::bail!(
 | 
			
		||||
            "{} info failed, status: {}, stderr: {}",
 | 
			
		||||
            constants::QEMU_IMAGE_PROGRAM,
 | 
			
		||||
            constants::PROGRAM_QEMU_IMAGE,
 | 
			
		||||
            output.status,
 | 
			
		||||
            String::from_utf8_lossy(&output.stderr)
 | 
			
		||||
        );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
pub mod cloud_init_utils;
 | 
			
		||||
pub mod exec_utils;
 | 
			
		||||
pub mod file_disks_utils;
 | 
			
		||||
pub mod file_size_utils;
 | 
			
		||||
 
 | 
			
		||||
@@ -145,13 +145,13 @@ struct IPBridgeInfo {
 | 
			
		||||
 | 
			
		||||
/// Get the list of bridge interfaces
 | 
			
		||||
pub fn bridges_list() -> anyhow::Result<Vec<String>> {
 | 
			
		||||
    let mut cmd = Command::new(constants::IP_PROGRAM);
 | 
			
		||||
    let mut cmd = Command::new(constants::PROGRAM_IP);
 | 
			
		||||
    cmd.args(["-json", "link", "show", "type", "bridge"]);
 | 
			
		||||
    let output = cmd.output()?;
 | 
			
		||||
    if !output.status.success() {
 | 
			
		||||
        anyhow::bail!(
 | 
			
		||||
            "{} failed, status: {}, stderr: {}",
 | 
			
		||||
            constants::IP_PROGRAM,
 | 
			
		||||
            constants::PROGRAM_IP,
 | 
			
		||||
            output.status,
 | 
			
		||||
            String::from_utf8_lossy(&output.stderr)
 | 
			
		||||
        );
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,4 @@
 | 
			
		||||
use chrono::Datelike;
 | 
			
		||||
use std::time::{SystemTime, UNIX_EPOCH};
 | 
			
		||||
 | 
			
		||||
/// Get the current time since epoch
 | 
			
		||||
@@ -13,3 +14,15 @@ pub fn time() -> u64 {
 | 
			
		||||
        .unwrap()
 | 
			
		||||
        .as_secs()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Format given UNIX time in a simple format
 | 
			
		||||
pub fn format_date(time: i64) -> anyhow::Result<String> {
 | 
			
		||||
    let date = chrono::DateTime::from_timestamp(time, 0).ok_or(anyhow::anyhow!("invalid date"))?;
 | 
			
		||||
 | 
			
		||||
    Ok(format!(
 | 
			
		||||
        "{:0>2}/{:0>2}/{}",
 | 
			
		||||
        date.day(),
 | 
			
		||||
        date.month(),
 | 
			
		||||
        date.year()
 | 
			
		||||
    ))
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -44,6 +44,9 @@ pub struct VMFileDisk {
 | 
			
		||||
    /// When creating a new disk, specify the disk image template to use
 | 
			
		||||
    #[serde(skip_serializing_if = "Option::is_none")]
 | 
			
		||||
    pub from_image: Option<String>,
 | 
			
		||||
    /// Set this variable to true to resize disk image
 | 
			
		||||
    #[serde(skip_serializing_if = "Option::is_none")]
 | 
			
		||||
    pub resize: Option<bool>,
 | 
			
		||||
    /// Set this variable to true to delete the disk
 | 
			
		||||
    pub delete: bool,
 | 
			
		||||
}
 | 
			
		||||
@@ -78,6 +81,7 @@ impl VMFileDisk {
 | 
			
		||||
 | 
			
		||||
            delete: false,
 | 
			
		||||
            from_image: None,
 | 
			
		||||
            resize: None,
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -144,28 +148,40 @@ impl VMFileDisk {
 | 
			
		||||
 | 
			
		||||
        if file.exists() {
 | 
			
		||||
            log::debug!("File {file:?} does not exists, so it was not touched");
 | 
			
		||||
            return Ok(());
 | 
			
		||||
        }
 | 
			
		||||
        // Create disk if required
 | 
			
		||||
        else {
 | 
			
		||||
            // Determine file format
 | 
			
		||||
            let format = match self.format {
 | 
			
		||||
                VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
 | 
			
		||||
                VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
 | 
			
		||||
                    virtual_size: self.size,
 | 
			
		||||
                },
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            // Create / Restore disk file
 | 
			
		||||
            match &self.from_image {
 | 
			
		||||
                // Create disk file
 | 
			
		||||
                None => {
 | 
			
		||||
                    DiskFileInfo::create(&file, format, self.size)?;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // Restore disk image template
 | 
			
		||||
                Some(disk_img) => {
 | 
			
		||||
                    let src_file =
 | 
			
		||||
                        DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
 | 
			
		||||
                    src_file.convert(&file, format)?;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let format = match self.format {
 | 
			
		||||
            VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse },
 | 
			
		||||
            VMDiskFormat::QCow2 => DiskFileFormat::QCow2 {
 | 
			
		||||
                virtual_size: self.size,
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
        // Resize disk file if requested
 | 
			
		||||
        if self.resize == Some(true) {
 | 
			
		||||
            let disk = DiskFileInfo::load_file(&file)?;
 | 
			
		||||
 | 
			
		||||
        // Create / Restore disk file
 | 
			
		||||
        match &self.from_image {
 | 
			
		||||
            // Create disk file
 | 
			
		||||
            None => {
 | 
			
		||||
                DiskFileInfo::create(&file, format, self.size)?;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Restore disk image template
 | 
			
		||||
            Some(disk_img) => {
 | 
			
		||||
                let src_file =
 | 
			
		||||
                    DiskFileInfo::load_file(&AppConfig::get().disk_images_file_path(disk_img))?;
 | 
			
		||||
                src_file.convert(&file, format)?;
 | 
			
		||||
            // Can only increase disk size
 | 
			
		||||
            if let Err(e) = disk.resize(self.size) {
 | 
			
		||||
                log::error!("Failed to resize disk file {}: {e:?}", self.name);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -5,9 +5,9 @@
 | 
			
		||||
sudo apt install libvirt-dev
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
2. Libvirt must also be installed:
 | 
			
		||||
2. Libvirt and cloud image utilities must also be installed:
 | 
			
		||||
```bash
 | 
			
		||||
sudo apt install qemu-kvm libvirt-daemon-system
 | 
			
		||||
sudo apt install qemu-kvm libvirt-daemon-system cloud-image-utils
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
3. Allow the current user to manage VMs:
 | 
			
		||||
 
 | 
			
		||||
@@ -12,10 +12,10 @@ The release file will be available in `virtweb_backend/target/release/virtweb_ba
 | 
			
		||||
This is the only artifact that must be copied to the server. It is recommended to copy it to the `/usr/local/bin` directory.
 | 
			
		||||
 | 
			
		||||
## Install requirements
 | 
			
		||||
In order to work properly, VirtWeb relies on `libvirt`, `qemu` and `kvm`:
 | 
			
		||||
In order to work properly, VirtWeb relies on `libvirt`, `qemu`, `kvm` and `cloud-localds`:
 | 
			
		||||
 | 
			
		||||
```bash
 | 
			
		||||
sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils
 | 
			
		||||
sudo apt install qemu-kvm libvirt-daemon-system libvirt0 libvirt-clients libvirt-daemon bridge-utils cloud-image-utils
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## Dedicated user
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1058
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1058
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -11,42 +11,46 @@
 | 
			
		||||
  },
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@emotion/react": "^11.14.0",
 | 
			
		||||
    "@emotion/styled": "^11.14.0",
 | 
			
		||||
    "@fontsource/roboto": "^5.2.5",
 | 
			
		||||
    "@emotion/styled": "^11.14.1",
 | 
			
		||||
    "@fontsource/roboto": "^5.2.6",
 | 
			
		||||
    "@mdi/js": "^7.4.47",
 | 
			
		||||
    "@mdi/react": "^1.6.1",
 | 
			
		||||
    "@mui/icons-material": "^7.1.0",
 | 
			
		||||
    "@mui/material": "^7.1.0",
 | 
			
		||||
    "@monaco-editor/react": "^4.7.0",
 | 
			
		||||
    "@mui/icons-material": "^7.2.0",
 | 
			
		||||
    "@mui/material": "^7.2.0",
 | 
			
		||||
    "@mui/x-charts": "^8.3.1",
 | 
			
		||||
    "@mui/x-data-grid": "^8.3.1",
 | 
			
		||||
    "@mui/x-data-grid": "^8.8.0",
 | 
			
		||||
    "date-and-time": "^3.6.0",
 | 
			
		||||
    "filesize": "^10.1.6",
 | 
			
		||||
    "humanize-duration": "^3.32.2",
 | 
			
		||||
    "humanize-duration": "^3.33.0",
 | 
			
		||||
    "monaco-editor": "^0.52.2",
 | 
			
		||||
    "monaco-yaml": "^5.4.0",
 | 
			
		||||
    "react": "^19.1.0",
 | 
			
		||||
    "react-dom": "^19.1.0",
 | 
			
		||||
    "react-router-dom": "^7.6.0",
 | 
			
		||||
    "react-router-dom": "^7.7.0",
 | 
			
		||||
    "react-syntax-highlighter": "^15.6.1",
 | 
			
		||||
    "react-vnc": "^3.1.0",
 | 
			
		||||
    "uuid": "^11.1.0",
 | 
			
		||||
    "xml-formatter": "^3.6.6"
 | 
			
		||||
    "xml-formatter": "^3.6.6",
 | 
			
		||||
    "yaml": "^2.8.0"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@eslint/js": "^9.27.0",
 | 
			
		||||
    "@types/humanize-duration": "^3.27.4",
 | 
			
		||||
    "@types/jest": "^29.5.14",
 | 
			
		||||
    "@types/react": "^19.1.5",
 | 
			
		||||
    "@types/react-dom": "^19.1.5",
 | 
			
		||||
    "@types/react": "^19.1.8",
 | 
			
		||||
    "@types/react-dom": "^19.1.6",
 | 
			
		||||
    "@types/react-syntax-highlighter": "^15.5.13",
 | 
			
		||||
    "@types/uuid": "^10.0.0",
 | 
			
		||||
    "@vitejs/plugin-react": "^4.4.1",
 | 
			
		||||
    "eslint": "^9.27.0",
 | 
			
		||||
    "eslint-plugin-react-dom": "^1.49.0",
 | 
			
		||||
    "eslint-plugin-react-hooks": "^5.1.0",
 | 
			
		||||
    "@vitejs/plugin-react": "^4.6.0",
 | 
			
		||||
    "eslint": "^9.31.0",
 | 
			
		||||
    "eslint-plugin-react-dom": "^1.52.3",
 | 
			
		||||
    "eslint-plugin-react-hooks": "^5.2.0",
 | 
			
		||||
    "eslint-plugin-react-refresh": "^0.4.20",
 | 
			
		||||
    "eslint-plugin-react-x": "^1.49.0",
 | 
			
		||||
    "globals": "^16.1.0",
 | 
			
		||||
    "eslint-plugin-react-x": "^1.52.3",
 | 
			
		||||
    "globals": "^16.3.0",
 | 
			
		||||
    "typescript": "^5.8.3",
 | 
			
		||||
    "typescript-eslint": "^8.32.1",
 | 
			
		||||
    "typescript-eslint": "^8.37.0",
 | 
			
		||||
    "vite": "^6.3.5"
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -4,8 +4,10 @@ import { VMFileDisk, VMInfo } from "./VMApi";
 | 
			
		||||
export type DiskImageFormat =
 | 
			
		||||
  | { format: "Raw"; is_sparse: boolean }
 | 
			
		||||
  | { format: "QCow2"; virtual_size?: number }
 | 
			
		||||
  | { format: "CompressedQCow2" }
 | 
			
		||||
  | { format: "CompressedRaw" };
 | 
			
		||||
  | { format: "GzCompressedQCow2" }
 | 
			
		||||
  | { format: "GzCompressedRaw" }
 | 
			
		||||
  | { format: "XzCompressedQCow2" }
 | 
			
		||||
  | { format: "XzCompressedRaw" };
 | 
			
		||||
 | 
			
		||||
export type DiskImage = {
 | 
			
		||||
  file_size: number;
 | 
			
		||||
 
 | 
			
		||||
@@ -232,4 +232,16 @@ export class ServerApi {
 | 
			
		||||
      })
 | 
			
		||||
    ).data;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Export all server configs
 | 
			
		||||
   */
 | 
			
		||||
  static async ExportServerConfigs(): Promise<Blob> {
 | 
			
		||||
    return (
 | 
			
		||||
      await APIClient.exec({
 | 
			
		||||
        method: "GET",
 | 
			
		||||
        uri: "/server/export_configs",
 | 
			
		||||
      })
 | 
			
		||||
    ).data;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -31,8 +31,12 @@ export interface BaseFileVMDisk {
 | 
			
		||||
  // For new disk only
 | 
			
		||||
  from_image?: string;
 | 
			
		||||
 | 
			
		||||
  // Resize disk image after clone
 | 
			
		||||
  resize?: boolean;
 | 
			
		||||
 | 
			
		||||
  // application attributes
 | 
			
		||||
  new?: boolean;
 | 
			
		||||
  originalSize?: number;
 | 
			
		||||
  deleteType?: "keepfile" | "deletefile";
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -82,6 +86,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 +114,7 @@ interface VMInfoInterface {
 | 
			
		||||
  networks: VMNetInterface[];
 | 
			
		||||
  tpm_module: boolean;
 | 
			
		||||
  oem_strings: string[];
 | 
			
		||||
  cloud_init: VMCloudInit;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class VMInfo implements VMInfoInterface {
 | 
			
		||||
@@ -120,6 +134,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 +153,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 +169,7 @@ export class VMInfo implements VMInfoInterface {
 | 
			
		||||
      networks: [],
 | 
			
		||||
      tpm_module: true,
 | 
			
		||||
      oem_strings: [],
 | 
			
		||||
      cloud_init: { attach_config: false, user_data: "" },
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -42,13 +42,15 @@ export function ConvertDiskImageDialog(
 | 
			
		||||
    setFormat({ format: value ?? ("QCow2" as any) });
 | 
			
		||||
 | 
			
		||||
    if (value === "QCow2") setFilename(`${origFilename}.qcow2`);
 | 
			
		||||
    if (value === "CompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
 | 
			
		||||
    if (value === "GzCompressedQCow2") setFilename(`${origFilename}.qcow2.gz`);
 | 
			
		||||
    if (value === "XzCompressedQCow2") setFilename(`${origFilename}.qcow2.xz`);
 | 
			
		||||
    if (value === "Raw") {
 | 
			
		||||
      setFilename(`${origFilename}.raw`);
 | 
			
		||||
      // Check sparse checkbox by default
 | 
			
		||||
      setFormat({ format: "Raw", is_sparse: true });
 | 
			
		||||
    }
 | 
			
		||||
    if (value === "CompressedRaw") setFilename(`${origFilename}.raw.gz`);
 | 
			
		||||
    if (value === "GzCompressedRaw") setFilename(`${origFilename}.raw.gz`);
 | 
			
		||||
    if (value === "XzCompressedRaw") setFilename(`${origFilename}.raw.xz`);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleSubmit = async () => {
 | 
			
		||||
@@ -104,8 +106,10 @@ export function ConvertDiskImageDialog(
 | 
			
		||||
          options={[
 | 
			
		||||
            { value: "QCow2" },
 | 
			
		||||
            { value: "Raw" },
 | 
			
		||||
            { value: "CompressedRaw" },
 | 
			
		||||
            { value: "CompressedQCow2" },
 | 
			
		||||
            { value: "GzCompressedRaw" },
 | 
			
		||||
            { value: "XzCompressedRaw" },
 | 
			
		||||
            { value: "GzCompressedQCow2" },
 | 
			
		||||
            { value: "XzCompressedQCow2" },
 | 
			
		||||
          ]}
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,16 +3,44 @@ import "@fontsource/roboto/400.css";
 | 
			
		||||
import "@fontsource/roboto/500.css";
 | 
			
		||||
import "@fontsource/roboto/700.css";
 | 
			
		||||
 | 
			
		||||
import { ThemeProvider, createTheme } from "@mui/material";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import ReactDOM from "react-dom/client";
 | 
			
		||||
import { App } from "./App";
 | 
			
		||||
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
 | 
			
		||||
import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider";
 | 
			
		||||
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
 | 
			
		||||
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
 | 
			
		||||
import "./index.css";
 | 
			
		||||
import { LoadServerConfig } from "./widgets/LoadServerConfig";
 | 
			
		||||
import { ThemeProvider, createTheme } from "@mui/material";
 | 
			
		||||
import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider";
 | 
			
		||||
import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider";
 | 
			
		||||
import { SnackbarProvider } from "./hooks/providers/SnackbarProvider";
 | 
			
		||||
import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider";
 | 
			
		||||
 | 
			
		||||
import { loader } from "@monaco-editor/react";
 | 
			
		||||
import * as monaco from "monaco-editor";
 | 
			
		||||
import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker";
 | 
			
		||||
import { configureMonacoYaml } from "monaco-yaml";
 | 
			
		||||
import YamlWorker from "monaco-yaml/yaml.worker?worker";
 | 
			
		||||
 | 
			
		||||
// This allows to use a self hosted instance of Monaco editor
 | 
			
		||||
loader.config({ monaco });
 | 
			
		||||
 | 
			
		||||
// Add YAML support to Monaco
 | 
			
		||||
configureMonacoYaml(monaco, {
 | 
			
		||||
  enableSchemaRequest: false,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
/// YAML worker
 | 
			
		||||
window.MonacoEnvironment = {
 | 
			
		||||
  getWorker(_moduleId, label) {
 | 
			
		||||
    switch (label) {
 | 
			
		||||
      case "editorWorkerService":
 | 
			
		||||
        return new EditorWorker();
 | 
			
		||||
      case "yaml":
 | 
			
		||||
        return new YamlWorker();
 | 
			
		||||
      default:
 | 
			
		||||
        throw new Error(`Unknown label ${label}`);
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const darkTheme = createTheme({
 | 
			
		||||
  palette: {
 | 
			
		||||
@@ -20,9 +48,7 @@ const darkTheme = createTheme({
 | 
			
		||||
  },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const root = ReactDOM.createRoot(
 | 
			
		||||
  document.getElementById("root")!
 | 
			
		||||
);
 | 
			
		||||
const root = ReactDOM.createRoot(document.getElementById("root")!);
 | 
			
		||||
root.render(
 | 
			
		||||
  <React.StrictMode>
 | 
			
		||||
    <ThemeProvider theme={darkTheme}>
 | 
			
		||||
 
 | 
			
		||||
@@ -9,18 +9,21 @@ import {
 | 
			
		||||
import Icon from "@mdi/react";
 | 
			
		||||
import {
 | 
			
		||||
  Box,
 | 
			
		||||
  IconButton,
 | 
			
		||||
  LinearProgress,
 | 
			
		||||
  Table,
 | 
			
		||||
  TableBody,
 | 
			
		||||
  TableCell,
 | 
			
		||||
  TableHead,
 | 
			
		||||
  TableRow,
 | 
			
		||||
  Tooltip,
 | 
			
		||||
  Typography,
 | 
			
		||||
} from "@mui/material";
 | 
			
		||||
import Grid from "@mui/material/Grid";
 | 
			
		||||
import { PieChart } from "@mui/x-charts";
 | 
			
		||||
import { filesize } from "filesize";
 | 
			
		||||
import humanizeDuration from "humanize-duration";
 | 
			
		||||
import IosShareIcon from "@mui/icons-material/IosShare";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import {
 | 
			
		||||
  DiskInfo,
 | 
			
		||||
@@ -31,6 +34,8 @@ import {
 | 
			
		||||
import { AsyncWidget } from "../widgets/AsyncWidget";
 | 
			
		||||
import { VirtWebPaper } from "../widgets/VirtWebPaper";
 | 
			
		||||
import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer";
 | 
			
		||||
import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider";
 | 
			
		||||
import { useAlert } from "../hooks/providers/AlertDialogProvider";
 | 
			
		||||
 | 
			
		||||
export function SysInfoRoute(): React.ReactElement {
 | 
			
		||||
  const [info, setInfo] = React.useState<ServerSystemInfo>();
 | 
			
		||||
@@ -52,6 +57,23 @@ export function SysInfoRoute(): React.ReactElement {
 | 
			
		||||
export function SysInfoRouteInner(p: {
 | 
			
		||||
  info: ServerSystemInfo;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const alert = useAlert();
 | 
			
		||||
  const loadingMessage = useLoadingMessage();
 | 
			
		||||
  const downloadAllConfig = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      loadingMessage.show("Downloading server config...");
 | 
			
		||||
      const res = await ServerApi.ExportServerConfigs();
 | 
			
		||||
 | 
			
		||||
      const url = URL.createObjectURL(res);
 | 
			
		||||
      window.location.href = url;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.error("Failed to download server config!", e);
 | 
			
		||||
      alert(`Failed to download server config! ${e}`);
 | 
			
		||||
    } finally {
 | 
			
		||||
      loadingMessage.hide();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const sumDiskUsage = p.info.disks.reduce(
 | 
			
		||||
    (prev, disk) => {
 | 
			
		||||
      return {
 | 
			
		||||
@@ -63,7 +85,16 @@ export function SysInfoRouteInner(p: {
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <VirtWebRouteContainer label="Sysinfo">
 | 
			
		||||
    <VirtWebRouteContainer
 | 
			
		||||
      label="Sysinfo"
 | 
			
		||||
      actions={
 | 
			
		||||
        <Tooltip title="Export all server configs">
 | 
			
		||||
          <IconButton onClick={downloadAllConfig}>
 | 
			
		||||
            <IosShareIcon />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </Tooltip>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Grid container spacing={2}>
 | 
			
		||||
        {/* Memory */}
 | 
			
		||||
        <Grid size={{ xs: 4 }}>
 | 
			
		||||
@@ -288,7 +319,7 @@ function DiskDetailsTable(p: { disks: DiskInfo[] }): React.ReactElement {
 | 
			
		||||
          {p.disks.map((e, c) => (
 | 
			
		||||
            <TableRow hover key={c}>
 | 
			
		||||
              <TableCell>{e.name}</TableCell>
 | 
			
		||||
              <TableCell>{e.DiskKind}</TableCell>
 | 
			
		||||
              <TableCell>{String(e.DiskKind)}</TableCell>
 | 
			
		||||
              <TableCell>{e.mount_point}</TableCell>
 | 
			
		||||
              <TableCell>{filesize(e.total_space)}</TableCell>
 | 
			
		||||
              <TableCell>{filesize(e.available_space)}</TableCell>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
import VisibilityIcon from '@mui/icons-material/Visibility';
 | 
			
		||||
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
 | 
			
		||||
import VisibilityIcon from "@mui/icons-material/Visibility";
 | 
			
		||||
import VisibilityOffIcon from "@mui/icons-material/VisibilityOff";
 | 
			
		||||
import {
 | 
			
		||||
  Alert,
 | 
			
		||||
  CircularProgress,
 | 
			
		||||
@@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement {
 | 
			
		||||
  const canSubmit = username.length > 0 && password.length > 0;
 | 
			
		||||
 | 
			
		||||
  const [showPassword, setShowPassword] = React.useState(false);
 | 
			
		||||
  const handleClickShowPassword = () => { setShowPassword((show) => !show); };
 | 
			
		||||
  const handleClickShowPassword = () => {
 | 
			
		||||
    setShowPassword((show) => !show);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const handleMouseDownPassword = (
 | 
			
		||||
    event: React.MouseEvent<HTMLButtonElement>
 | 
			
		||||
@@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement {
 | 
			
		||||
              label="Username"
 | 
			
		||||
              name="username"
 | 
			
		||||
              value={username}
 | 
			
		||||
              onChange={(e) => { setUsername(e.target.value); }}
 | 
			
		||||
              onChange={(e) => {
 | 
			
		||||
                setUsername(e.target.value);
 | 
			
		||||
              }}
 | 
			
		||||
              autoComplete="username"
 | 
			
		||||
              autoFocus
 | 
			
		||||
            />
 | 
			
		||||
 | 
			
		||||
            <FormControl fullWidth variant="outlined">
 | 
			
		||||
            <FormControl required fullWidth variant="outlined">
 | 
			
		||||
              <InputLabel htmlFor="password">Password</InputLabel>
 | 
			
		||||
              <OutlinedInput
 | 
			
		||||
                required
 | 
			
		||||
@@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement {
 | 
			
		||||
                type={showPassword ? "text" : "password"}
 | 
			
		||||
                id="password"
 | 
			
		||||
                value={password}
 | 
			
		||||
                onChange={(e) => { setPassword(e.target.value); }}
 | 
			
		||||
                onChange={(e) => {
 | 
			
		||||
                  setPassword(e.target.value);
 | 
			
		||||
                }}
 | 
			
		||||
                autoComplete="current-password"
 | 
			
		||||
                endAdornment={
 | 
			
		||||
                  <InputAdornment position="end">
 | 
			
		||||
@@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement {
 | 
			
		||||
                        onMouseDown={handleMouseDownPassword}
 | 
			
		||||
                        edge="end"
 | 
			
		||||
                      >
 | 
			
		||||
                        {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />}
 | 
			
		||||
                        {showPassword ? (
 | 
			
		||||
                          <VisibilityOffIcon />
 | 
			
		||||
                        ) : (
 | 
			
		||||
                          <VisibilityIcon />
 | 
			
		||||
                        )}
 | 
			
		||||
                      </IconButton>
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  </InputAdornment>
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,9 @@ export function CheckboxInput(p: {
 | 
			
		||||
        <Checkbox
 | 
			
		||||
          disabled={!p.editable}
 | 
			
		||||
          checked={p.checked}
 | 
			
		||||
          onChange={(e) => { p.onValueChange(e.target.checked); }}
 | 
			
		||||
          onChange={(e) => {
 | 
			
		||||
            p.onValueChange(e.target.checked);
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      }
 | 
			
		||||
      label={p.label}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										341
									
								
								virtweb_frontend/src/widgets/forms/CloudInitEditor.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										341
									
								
								virtweb_frontend/src/widgets/forms/CloudInitEditor.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,341 @@
 | 
			
		||||
/* eslint-disable @typescript-eslint/no-base-to-string */
 | 
			
		||||
 | 
			
		||||
import Editor from "@monaco-editor/react";
 | 
			
		||||
import BookIcon from "@mui/icons-material/Book";
 | 
			
		||||
import RefreshIcon from "@mui/icons-material/Refresh";
 | 
			
		||||
import { Grid, IconButton, InputAdornment, Tooltip } from "@mui/material";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { v4 as uuidv4 } from "uuid";
 | 
			
		||||
import YAML from "yaml";
 | 
			
		||||
import { VMInfo } from "../../api/VMApi";
 | 
			
		||||
import { RouterLink } from "../RouterLink";
 | 
			
		||||
import { CheckboxInput } from "./CheckboxInput";
 | 
			
		||||
import { EditSection } from "./EditSection";
 | 
			
		||||
import { SelectInput } from "./SelectInput";
 | 
			
		||||
import { TextInput } from "./TextInput";
 | 
			
		||||
 | 
			
		||||
interface CloudInitProps {
 | 
			
		||||
  vm: VMInfo;
 | 
			
		||||
  onChange?: () => void;
 | 
			
		||||
  editable: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function CloudInitEditor(p: CloudInitProps): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <EditSection>
 | 
			
		||||
        {/* Attach cloud init disk */}
 | 
			
		||||
        <CheckboxInput
 | 
			
		||||
          {...p}
 | 
			
		||||
          label="Attach Cloud Init disk"
 | 
			
		||||
          checked={p.vm.cloud_init.attach_config}
 | 
			
		||||
          onValueChange={(v) => {
 | 
			
		||||
            p.vm.cloud_init.attach_config = v;
 | 
			
		||||
            p.onChange?.();
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </EditSection>
 | 
			
		||||
      <Grid container spacing={2}>
 | 
			
		||||
        <CloudInitMetadata
 | 
			
		||||
          {...p}
 | 
			
		||||
          editable={p.editable && p.vm.cloud_init.attach_config}
 | 
			
		||||
        />
 | 
			
		||||
        <CloudInitRawUserData
 | 
			
		||||
          {...p}
 | 
			
		||||
          editable={p.editable && p.vm.cloud_init.attach_config}
 | 
			
		||||
        />
 | 
			
		||||
        <CloudInitNetworkConfig
 | 
			
		||||
          {...p}
 | 
			
		||||
          editable={p.editable && p.vm.cloud_init.attach_config}
 | 
			
		||||
        />
 | 
			
		||||
        <CloudInitUserDataAssistant
 | 
			
		||||
          {...p}
 | 
			
		||||
          editable={p.editable && p.vm.cloud_init.attach_config}
 | 
			
		||||
        />
 | 
			
		||||
      </Grid>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CloudInitMetadata(p: CloudInitProps): React.ReactElement {
 | 
			
		||||
  // Regenerate instance id
 | 
			
		||||
  const reGenerateInstanceId = () => {
 | 
			
		||||
    p.vm.cloud_init.instance_id = uuidv4();
 | 
			
		||||
    p.onChange?.();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <EditSection title="Metadata">
 | 
			
		||||
      {/* Instance ID */}
 | 
			
		||||
      <TextInput
 | 
			
		||||
        {...p}
 | 
			
		||||
        label="Instance ID"
 | 
			
		||||
        value={p.vm.cloud_init.instance_id}
 | 
			
		||||
        onValueChange={(v) => {
 | 
			
		||||
          p.vm.cloud_init.instance_id = v;
 | 
			
		||||
          p.onChange?.();
 | 
			
		||||
        }}
 | 
			
		||||
        endAdornment={
 | 
			
		||||
          p.editable ? (
 | 
			
		||||
            <InputAdornment position="end">
 | 
			
		||||
              <Tooltip title="Generate a new instance ID">
 | 
			
		||||
                <IconButton onClick={reGenerateInstanceId}>
 | 
			
		||||
                  <RefreshIcon />
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            </InputAdornment>
 | 
			
		||||
          ) : (
 | 
			
		||||
            <></>
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      {/* Instance hostname */}
 | 
			
		||||
      <TextInput
 | 
			
		||||
        {...p}
 | 
			
		||||
        label="Local hostname"
 | 
			
		||||
        value={p.vm.cloud_init.local_hostname}
 | 
			
		||||
        onValueChange={(v) => {
 | 
			
		||||
          p.vm.cloud_init.local_hostname = v;
 | 
			
		||||
          p.onChange?.();
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      {/* Data source mode */}
 | 
			
		||||
      <SelectInput
 | 
			
		||||
        {...p}
 | 
			
		||||
        label="Data source mode"
 | 
			
		||||
        value={p.vm.cloud_init.dsmode}
 | 
			
		||||
        onValueChange={(v) => {
 | 
			
		||||
          p.vm.cloud_init.dsmode = v as any;
 | 
			
		||||
          p.onChange?.();
 | 
			
		||||
        }}
 | 
			
		||||
        options={[
 | 
			
		||||
          { label: "None", value: undefined },
 | 
			
		||||
          { value: "Net" },
 | 
			
		||||
          { value: "Local" },
 | 
			
		||||
        ]}
 | 
			
		||||
      />
 | 
			
		||||
    </EditSection>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CloudInitRawUserData(p: CloudInitProps): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <EditSection
 | 
			
		||||
      title="User data"
 | 
			
		||||
      actions={
 | 
			
		||||
        <RouterLink
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          to="https://cloudinit.readthedocs.io/en/latest/reference/index.html"
 | 
			
		||||
        >
 | 
			
		||||
          <Tooltip title="Official reference">
 | 
			
		||||
            <IconButton size="small">
 | 
			
		||||
              <BookIcon />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
        </RouterLink>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Editor
 | 
			
		||||
        theme="vs-dark"
 | 
			
		||||
        options={{
 | 
			
		||||
          readOnly: !p.editable,
 | 
			
		||||
          quickSuggestions: { other: true, comments: true, strings: true },
 | 
			
		||||
          wordWrap: "on",
 | 
			
		||||
        }}
 | 
			
		||||
        language="yaml"
 | 
			
		||||
        height={"30vh"}
 | 
			
		||||
        value={p.vm.cloud_init.user_data}
 | 
			
		||||
        onChange={(v) => {
 | 
			
		||||
          p.vm.cloud_init.user_data = v ?? "";
 | 
			
		||||
          p.onChange?.();
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </EditSection>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CloudInitNetworkConfig(p: CloudInitProps): React.ReactElement {
 | 
			
		||||
  if (!p.editable && !p.vm.cloud_init.network_configuration) return <></>;
 | 
			
		||||
  return (
 | 
			
		||||
    <EditSection
 | 
			
		||||
      title="Network configuration"
 | 
			
		||||
      actions={
 | 
			
		||||
        <RouterLink
 | 
			
		||||
          target="_blank"
 | 
			
		||||
          to="https://cloudinit.readthedocs.io/en/latest/reference/network-config-format-v2.html"
 | 
			
		||||
        >
 | 
			
		||||
          <Tooltip title="Official network configuration reference">
 | 
			
		||||
            <IconButton size="small">
 | 
			
		||||
              <BookIcon />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Tooltip>
 | 
			
		||||
        </RouterLink>
 | 
			
		||||
      }
 | 
			
		||||
    >
 | 
			
		||||
      <Editor
 | 
			
		||||
        theme="vs-dark"
 | 
			
		||||
        options={{
 | 
			
		||||
          readOnly: !p.editable,
 | 
			
		||||
          quickSuggestions: { other: true, comments: true, strings: true },
 | 
			
		||||
          wordWrap: "on",
 | 
			
		||||
        }}
 | 
			
		||||
        language="yaml"
 | 
			
		||||
        height={"30vh"}
 | 
			
		||||
        value={p.vm.cloud_init.network_configuration ?? ""}
 | 
			
		||||
        onChange={(v) => {
 | 
			
		||||
          if (v && v !== "") p.vm.cloud_init.network_configuration = v;
 | 
			
		||||
          else p.vm.cloud_init.network_configuration = undefined;
 | 
			
		||||
          p.onChange?.();
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </EditSection>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CloudInitUserDataAssistant(p: CloudInitProps): React.ReactElement {
 | 
			
		||||
  const user_data = React.useMemo(() => {
 | 
			
		||||
    return YAML.parseDocument(p.vm.cloud_init.user_data);
 | 
			
		||||
  }, [p.vm.cloud_init.user_data]);
 | 
			
		||||
 | 
			
		||||
  const onChange = () => {
 | 
			
		||||
    p.vm.cloud_init.user_data = user_data.toString();
 | 
			
		||||
 | 
			
		||||
    if (!p.vm.cloud_init.user_data.startsWith("#cloud-config"))
 | 
			
		||||
      p.vm.cloud_init.user_data = `#cloud-config\n${p.vm.cloud_init.user_data}`;
 | 
			
		||||
 | 
			
		||||
    p.onChange?.();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const SYSTEMD_NOT_SERIAL = `/bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && sed -i 's/quiet splash//g' /etc/default/grub && update-grub"`;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <EditSection title="User data assistant">
 | 
			
		||||
      <CloudInitTextInput
 | 
			
		||||
        editable={p.editable}
 | 
			
		||||
        name="Default user name"
 | 
			
		||||
        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
 | 
			
		||||
        attrPath={["user", "name"]}
 | 
			
		||||
        onChange={onChange}
 | 
			
		||||
        yaml={user_data}
 | 
			
		||||
      />
 | 
			
		||||
      <CloudInitTextInput
 | 
			
		||||
        editable={p.editable}
 | 
			
		||||
        name="Default user password"
 | 
			
		||||
        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
 | 
			
		||||
        attrPath={["password"]}
 | 
			
		||||
        onChange={onChange}
 | 
			
		||||
        yaml={user_data}
 | 
			
		||||
      />
 | 
			
		||||
      <CloudInitBooleanInput
 | 
			
		||||
        editable={p.editable}
 | 
			
		||||
        name="Expire password to require new password on next login"
 | 
			
		||||
        yaml={user_data}
 | 
			
		||||
        attrPath={["chpasswd", "expire"]}
 | 
			
		||||
        onChange={onChange}
 | 
			
		||||
        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
 | 
			
		||||
      />
 | 
			
		||||
      <br />
 | 
			
		||||
      <CloudInitBooleanInput
 | 
			
		||||
        editable={p.editable}
 | 
			
		||||
        name="Enable SSH password auth"
 | 
			
		||||
        yaml={user_data}
 | 
			
		||||
        attrPath={["ssh_pwauth"]}
 | 
			
		||||
        onChange={onChange}
 | 
			
		||||
        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#set-passwords"
 | 
			
		||||
      />
 | 
			
		||||
      <CloudInitTextInput
 | 
			
		||||
        editable={p.editable}
 | 
			
		||||
        name="Keyboard layout"
 | 
			
		||||
        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#keyboard"
 | 
			
		||||
        attrPath={["keyboard", "layout"]}
 | 
			
		||||
        onChange={onChange}
 | 
			
		||||
        yaml={user_data}
 | 
			
		||||
      />
 | 
			
		||||
      <CloudInitTextInput
 | 
			
		||||
        editable={p.editable}
 | 
			
		||||
        name="Final message"
 | 
			
		||||
        refUrl="https://cloudinit.readthedocs.io/en/latest/reference/modules.html#final-message"
 | 
			
		||||
        attrPath={["final_message"]}
 | 
			
		||||
        onChange={onChange}
 | 
			
		||||
        yaml={user_data}
 | 
			
		||||
      />
 | 
			
		||||
      {/* /bin/sh -c "rm -f /etc/default/grub.d/50-cloudimg-settings.cfg && update-grub" */}
 | 
			
		||||
      <CheckboxInput
 | 
			
		||||
        editable={p.editable}
 | 
			
		||||
        label="Show all startup messages on tty1, not serial"
 | 
			
		||||
        checked={
 | 
			
		||||
          !!(user_data.get("runcmd") as any)?.items.find(
 | 
			
		||||
            (a: any) => a.value === SYSTEMD_NOT_SERIAL
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
        onValueChange={(c) => {
 | 
			
		||||
          if (!user_data.getIn(["runcmd"])) user_data.addIn(["runcmd"], []);
 | 
			
		||||
 | 
			
		||||
          const runcmd = user_data.getIn(["runcmd"]) as any;
 | 
			
		||||
 | 
			
		||||
          if (c) {
 | 
			
		||||
            runcmd.addIn([], SYSTEMD_NOT_SERIAL);
 | 
			
		||||
          } else {
 | 
			
		||||
            const idx = runcmd.items.findIndex(
 | 
			
		||||
              (o: any) => o.value === SYSTEMD_NOT_SERIAL
 | 
			
		||||
            );
 | 
			
		||||
            runcmd.items.splice(idx, 1);
 | 
			
		||||
          }
 | 
			
		||||
          onChange();
 | 
			
		||||
        }}
 | 
			
		||||
      />
 | 
			
		||||
    </EditSection>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CloudInitTextInput(p: {
 | 
			
		||||
  editable: boolean;
 | 
			
		||||
  name: string;
 | 
			
		||||
  refUrl: string;
 | 
			
		||||
  attrPath: Iterable<unknown>;
 | 
			
		||||
  yaml: YAML.Document;
 | 
			
		||||
  onChange?: () => void;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <TextInput
 | 
			
		||||
      editable={p.editable}
 | 
			
		||||
      label={p.name}
 | 
			
		||||
      value={String(p.yaml.getIn(p.attrPath) ?? "")}
 | 
			
		||||
      onValueChange={(v) => {
 | 
			
		||||
        if (v !== undefined) p.yaml.setIn(p.attrPath, v);
 | 
			
		||||
        else p.yaml.deleteIn(p.attrPath);
 | 
			
		||||
        p.onChange?.();
 | 
			
		||||
      }}
 | 
			
		||||
      endAdornment={
 | 
			
		||||
        <RouterLink to={p.refUrl} target="_blank">
 | 
			
		||||
          <IconButton size="small">
 | 
			
		||||
            <BookIcon />
 | 
			
		||||
          </IconButton>
 | 
			
		||||
        </RouterLink>
 | 
			
		||||
      }
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function CloudInitBooleanInput(p: {
 | 
			
		||||
  editable: boolean;
 | 
			
		||||
  name: string;
 | 
			
		||||
  refUrl: string;
 | 
			
		||||
  attrPath: Iterable<unknown>;
 | 
			
		||||
  yaml: YAML.Document;
 | 
			
		||||
  onChange?: () => void;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <CheckboxInput
 | 
			
		||||
      editable={p.editable}
 | 
			
		||||
      label={p.name}
 | 
			
		||||
      checked={p.yaml.getIn(p.attrPath) === true}
 | 
			
		||||
      onValueChange={(v) => {
 | 
			
		||||
        p.yaml.setIn(p.attrPath, v);
 | 
			
		||||
        p.onChange?.();
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										25
									
								
								virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								virtweb_frontend/src/widgets/forms/DiskSizeInput.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,25 @@
 | 
			
		||||
import { ServerApi } from "../../api/ServerApi";
 | 
			
		||||
import { TextInput } from "./TextInput";
 | 
			
		||||
 | 
			
		||||
export function DiskSizeInput(p: {
 | 
			
		||||
  editable: boolean;
 | 
			
		||||
  label?: string;
 | 
			
		||||
  value: number;
 | 
			
		||||
  onChange?: (size: number) => void;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <TextInput
 | 
			
		||||
      editable={p.editable}
 | 
			
		||||
      label={p.label ?? "Disk size (GB)"}
 | 
			
		||||
      size={{
 | 
			
		||||
        min: ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
 | 
			
		||||
        max: ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
 | 
			
		||||
      }}
 | 
			
		||||
      value={(p.value / (1000 * 1000 * 1000)).toString()}
 | 
			
		||||
      onValueChange={(v) => {
 | 
			
		||||
        p.onChange?.(Number(v ?? "0") * 1000 * 1000 * 1000);
 | 
			
		||||
      }}
 | 
			
		||||
      type="number"
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -19,13 +19,10 @@ export function EditSection(
 | 
			
		||||
              display: "flex",
 | 
			
		||||
              justifyContent: "space-between",
 | 
			
		||||
              alignItems: "center",
 | 
			
		||||
              marginBottom: "15px",
 | 
			
		||||
            }}
 | 
			
		||||
          >
 | 
			
		||||
            {p.title && (
 | 
			
		||||
              <Typography variant="h5" style={{ marginBottom: "15px" }}>
 | 
			
		||||
                {p.title}
 | 
			
		||||
              </Typography>
 | 
			
		||||
            )}
 | 
			
		||||
            {p.title && <Typography variant="h5">{p.title}</Typography>}
 | 
			
		||||
            {p.actions}
 | 
			
		||||
          </span>
 | 
			
		||||
        )}
 | 
			
		||||
 
 | 
			
		||||
@@ -25,6 +25,8 @@ export function OEMStringFormWidget(p: {
 | 
			
		||||
    p.onChange?.();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  if (!p.editable && p.vm.oem_strings.length === 0) return <></>;
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <EditSection
 | 
			
		||||
      title="SMBIOS OEM Strings"
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,7 @@ export function TextInput(p: {
 | 
			
		||||
  style?: React.CSSProperties;
 | 
			
		||||
  helperText?: string;
 | 
			
		||||
  disabled?: boolean;
 | 
			
		||||
  endAdornment?: React.ReactNode;
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  if (!p.editable && (p.value ?? "") === "") return <></>;
 | 
			
		||||
 | 
			
		||||
@@ -51,6 +52,7 @@ export function TextInput(p: {
 | 
			
		||||
        input: {
 | 
			
		||||
          readOnly: !p.editable,
 | 
			
		||||
          type: p.type,
 | 
			
		||||
          endAdornment: p.endAdornment,
 | 
			
		||||
        },
 | 
			
		||||
      }}
 | 
			
		||||
      variant={"standard"}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,8 @@ import { mdiHarddiskPlus } from "@mdi/js";
 | 
			
		||||
import Icon from "@mdi/react";
 | 
			
		||||
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
 | 
			
		||||
import DeleteIcon from "@mui/icons-material/Delete";
 | 
			
		||||
import { Button, IconButton, Paper, Tooltip } from "@mui/material";
 | 
			
		||||
import ExpandIcon from "@mui/icons-material/Expand";
 | 
			
		||||
import { Button, IconButton, Paper, Tooltip, Typography } from "@mui/material";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { DiskImage } from "../../api/DiskImageApi";
 | 
			
		||||
import { ServerApi } from "../../api/ServerApi";
 | 
			
		||||
@@ -13,6 +14,7 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget";
 | 
			
		||||
import { CheckboxInput } from "./CheckboxInput";
 | 
			
		||||
import { DiskBusSelect } from "./DiskBusSelect";
 | 
			
		||||
import { DiskImageSelect } from "./DiskImageSelect";
 | 
			
		||||
import { DiskSizeInput } from "./DiskSizeInput";
 | 
			
		||||
import { SelectInput } from "./SelectInput";
 | 
			
		||||
import { TextInput } from "./TextInput";
 | 
			
		||||
 | 
			
		||||
@@ -67,6 +69,12 @@ export function VMDisksList(p: {
 | 
			
		||||
        />
 | 
			
		||||
      ))}
 | 
			
		||||
 | 
			
		||||
      {p.vm.file_disks.length === 0 && (
 | 
			
		||||
        <Typography style={{ textAlign: "center", paddingTop: "25px" }}>
 | 
			
		||||
          No disk file yet!
 | 
			
		||||
        </Typography>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>}
 | 
			
		||||
 | 
			
		||||
      {/* Disk backup */}
 | 
			
		||||
@@ -93,6 +101,19 @@ function DiskInfo(p: {
 | 
			
		||||
  diskImagesList: DiskImage[];
 | 
			
		||||
}): React.ReactElement {
 | 
			
		||||
  const confirm = useConfirm();
 | 
			
		||||
 | 
			
		||||
  const expandDisk = () => {
 | 
			
		||||
    if (p.disk.resize === true) {
 | 
			
		||||
      p.disk.resize = false;
 | 
			
		||||
      p.disk.size = p.disk.originalSize!;
 | 
			
		||||
    } else {
 | 
			
		||||
      p.disk.resize = true;
 | 
			
		||||
      p.disk.originalSize = p.disk.size!;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    p.onChange?.();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  const deleteDisk = async () => {
 | 
			
		||||
    if (p.disk.deleteType) {
 | 
			
		||||
      p.disk.deleteType = undefined;
 | 
			
		||||
@@ -115,42 +136,75 @@ function DiskInfo(p: {
 | 
			
		||||
 | 
			
		||||
  if (!p.editable || !p.disk.new)
 | 
			
		||||
    return (
 | 
			
		||||
      <VMDiskFileWidget
 | 
			
		||||
        {...p}
 | 
			
		||||
        secondaryAction={
 | 
			
		||||
          <>
 | 
			
		||||
            {p.editable && (
 | 
			
		||||
              <IconButton
 | 
			
		||||
                edge="end"
 | 
			
		||||
                aria-label="delete disk"
 | 
			
		||||
                onClick={deleteDisk}
 | 
			
		||||
              >
 | 
			
		||||
                {p.disk.deleteType ? (
 | 
			
		||||
                  <Tooltip title="Cancel disk removal">
 | 
			
		||||
                    <CheckCircleIcon />
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                ) : (
 | 
			
		||||
                  <Tooltip title="Remove disk">
 | 
			
		||||
                    <DeleteIcon />
 | 
			
		||||
                  </Tooltip>
 | 
			
		||||
                )}
 | 
			
		||||
              </IconButton>
 | 
			
		||||
            )}
 | 
			
		||||
 | 
			
		||||
            {p.canBackup && (
 | 
			
		||||
              <Tooltip title="Backup this disk">
 | 
			
		||||
      <>
 | 
			
		||||
        <VMDiskFileWidget
 | 
			
		||||
          {...p}
 | 
			
		||||
          secondaryAction={
 | 
			
		||||
            <>
 | 
			
		||||
              {p.editable && !p.disk.deleteType && (
 | 
			
		||||
                <IconButton
 | 
			
		||||
                  onClick={() => {
 | 
			
		||||
                    p.onRequestBackup(p.disk);
 | 
			
		||||
                  }}
 | 
			
		||||
                  edge="end"
 | 
			
		||||
                  aria-label="expand disk"
 | 
			
		||||
                  onClick={expandDisk}
 | 
			
		||||
                >
 | 
			
		||||
                  <Icon path={mdiHarddiskPlus} size={1} />
 | 
			
		||||
                  {p.disk.resize === true ? (
 | 
			
		||||
                    <Tooltip title="Cancel disk expansion">
 | 
			
		||||
                      <ExpandIcon color="error" />
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    <Tooltip title="Increase disk size">
 | 
			
		||||
                      <ExpandIcon />
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  )}
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              </Tooltip>
 | 
			
		||||
            )}
 | 
			
		||||
          </>
 | 
			
		||||
        }
 | 
			
		||||
      />
 | 
			
		||||
              )}
 | 
			
		||||
 | 
			
		||||
              {p.editable && (
 | 
			
		||||
                <IconButton
 | 
			
		||||
                  edge="end"
 | 
			
		||||
                  aria-label="delete disk"
 | 
			
		||||
                  onClick={deleteDisk}
 | 
			
		||||
                >
 | 
			
		||||
                  {p.disk.deleteType ? (
 | 
			
		||||
                    <Tooltip title="Cancel disk removal">
 | 
			
		||||
                      <CheckCircleIcon />
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  ) : (
 | 
			
		||||
                    <Tooltip title="Remove disk">
 | 
			
		||||
                      <DeleteIcon />
 | 
			
		||||
                    </Tooltip>
 | 
			
		||||
                  )}
 | 
			
		||||
                </IconButton>
 | 
			
		||||
              )}
 | 
			
		||||
 | 
			
		||||
              {p.canBackup && (
 | 
			
		||||
                <Tooltip title="Backup this disk">
 | 
			
		||||
                  <IconButton
 | 
			
		||||
                    onClick={() => {
 | 
			
		||||
                      p.onRequestBackup(p.disk);
 | 
			
		||||
                    }}
 | 
			
		||||
                  >
 | 
			
		||||
                    <Icon path={mdiHarddiskPlus} size={1} />
 | 
			
		||||
                  </IconButton>
 | 
			
		||||
                </Tooltip>
 | 
			
		||||
              )}
 | 
			
		||||
            </>
 | 
			
		||||
          }
 | 
			
		||||
        />
 | 
			
		||||
 | 
			
		||||
        {/* New disk size*/}
 | 
			
		||||
        {p.disk.resize && !p.disk.deleteType && (
 | 
			
		||||
          <DiskSizeInput
 | 
			
		||||
            editable
 | 
			
		||||
            label="New disk size (GB)"
 | 
			
		||||
            value={p.disk.size}
 | 
			
		||||
            onChange={(v) => {
 | 
			
		||||
              p.disk.size = v;
 | 
			
		||||
              p.onChange?.();
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </>
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
@@ -212,24 +266,32 @@ function DiskInfo(p: {
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <TextInput
 | 
			
		||||
        editable={true}
 | 
			
		||||
        label="Disk size (GB)"
 | 
			
		||||
        size={{
 | 
			
		||||
          min:
 | 
			
		||||
            ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000),
 | 
			
		||||
          max:
 | 
			
		||||
            ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000),
 | 
			
		||||
        }}
 | 
			
		||||
        value={(p.disk.size / (1000 * 1000 * 1000)).toString()}
 | 
			
		||||
        onValueChange={(v) => {
 | 
			
		||||
          p.disk.size = Number(v ?? "0") * 1000 * 1000 * 1000;
 | 
			
		||||
          p.onChange?.();
 | 
			
		||||
        }}
 | 
			
		||||
        type="number"
 | 
			
		||||
        disabled={!!p.disk.from_image}
 | 
			
		||||
      />
 | 
			
		||||
      {/* Resize disk image */}
 | 
			
		||||
      {!!p.disk.from_image && (
 | 
			
		||||
        <CheckboxInput
 | 
			
		||||
          editable
 | 
			
		||||
          checked={p.disk.resize}
 | 
			
		||||
          label="Resize disk file"
 | 
			
		||||
          onValueChange={(v) => {
 | 
			
		||||
            p.disk.resize = v;
 | 
			
		||||
            p.onChange?.();
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* Disk size */}
 | 
			
		||||
      {(!p.disk.from_image || p.disk.resize === true) && (
 | 
			
		||||
        <DiskSizeInput
 | 
			
		||||
          editable
 | 
			
		||||
          value={p.disk.size}
 | 
			
		||||
          onChange={(v) => {
 | 
			
		||||
            p.disk.size = v;
 | 
			
		||||
            p.onChange?.();
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {/* Disk image selection */}
 | 
			
		||||
      <DiskImageSelect
 | 
			
		||||
        label="Use disk image as template"
 | 
			
		||||
        list={p.diskImagesList}
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ import {
 | 
			
		||||
  ListItemAvatar,
 | 
			
		||||
  ListItemText,
 | 
			
		||||
  Tooltip,
 | 
			
		||||
  Typography,
 | 
			
		||||
} from "@mui/material";
 | 
			
		||||
import Grid from "@mui/material/Grid";
 | 
			
		||||
import { NWFilter } from "../../api/NWFilterApi";
 | 
			
		||||
@@ -49,6 +50,12 @@ export function VMNetworksList(p: {
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      {p.vm.networks.length === 0 && (
 | 
			
		||||
        <Typography style={{ textAlign: "center", paddingTop: "25px" }}>
 | 
			
		||||
          No network interface defined yet!
 | 
			
		||||
        </Typography>
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <Grid container spacing={2}>
 | 
			
		||||
        {/* networks list */}
 | 
			
		||||
        {p.vm.networks.map((n, num) => (
 | 
			
		||||
 
 | 
			
		||||
@@ -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={{
 | 
			
		||||
@@ -783,6 +799,11 @@ export function TokenRightsEditor(p: {
 | 
			
		||||
          right={{ verb: "GET", path: "/api/server/bridges" }}
 | 
			
		||||
          label="Get list of network bridges"
 | 
			
		||||
        />
 | 
			
		||||
        <RouteRight
 | 
			
		||||
          {...p}
 | 
			
		||||
          right={{ verb: "GET", path: "/api/server/export_configs" }}
 | 
			
		||||
          label="Export all configurations"
 | 
			
		||||
        />
 | 
			
		||||
      </RightsSection>
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ import Grid from "@mui/material/Grid";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { useNavigate } from "react-router-dom";
 | 
			
		||||
import { validate as validateUUID } from "uuid";
 | 
			
		||||
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
 | 
			
		||||
import { GroupApi } from "../../api/GroupApi";
 | 
			
		||||
import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi";
 | 
			
		||||
import { NWFilter, NWFilterApi } from "../../api/NWFilterApi";
 | 
			
		||||
@@ -18,6 +19,7 @@ import { AsyncWidget } from "../AsyncWidget";
 | 
			
		||||
import { TabsWidget } from "../TabsWidget";
 | 
			
		||||
import { XMLAsyncWidget } from "../XMLWidget";
 | 
			
		||||
import { CheckboxInput } from "../forms/CheckboxInput";
 | 
			
		||||
import { CloudInitEditor } from "../forms/CloudInitEditor";
 | 
			
		||||
import { EditSection } from "../forms/EditSection";
 | 
			
		||||
import { OEMStringFormWidget } from "../forms/OEMStringFormWidget";
 | 
			
		||||
import { ResAutostartInput } from "../forms/ResAutostartInput";
 | 
			
		||||
@@ -27,7 +29,6 @@ import { VMDisksList } from "../forms/VMDisksList";
 | 
			
		||||
import { VMNetworksList } from "../forms/VMNetworksList";
 | 
			
		||||
import { VMSelectIsoInput } from "../forms/VMSelectIsoInput";
 | 
			
		||||
import { VMScreenshot } from "./VMScreenshot";
 | 
			
		||||
import { DiskImage, DiskImageApi } from "../../api/DiskImageApi";
 | 
			
		||||
 | 
			
		||||
interface DetailsProps {
 | 
			
		||||
  vm: VMInfo;
 | 
			
		||||
@@ -89,6 +90,7 @@ enum VMTab {
 | 
			
		||||
  General = 0,
 | 
			
		||||
  Storage,
 | 
			
		||||
  Network,
 | 
			
		||||
  CloudInit,
 | 
			
		||||
  Advanced,
 | 
			
		||||
  XML,
 | 
			
		||||
  Danger,
 | 
			
		||||
@@ -116,6 +118,11 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
 | 
			
		||||
          { label: "General", value: VMTab.General, visible: true },
 | 
			
		||||
          { label: "Storage", value: VMTab.Storage, visible: true },
 | 
			
		||||
          { label: "Network", value: VMTab.Network, visible: true },
 | 
			
		||||
          {
 | 
			
		||||
            label: "Cloud Init",
 | 
			
		||||
            value: VMTab.CloudInit,
 | 
			
		||||
            visible: p.editable || p.vm.cloud_init.attach_config,
 | 
			
		||||
          },
 | 
			
		||||
          { label: "Avanced", value: VMTab.Advanced, visible: true },
 | 
			
		||||
 | 
			
		||||
          {
 | 
			
		||||
@@ -135,6 +142,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement {
 | 
			
		||||
      {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />}
 | 
			
		||||
      {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />}
 | 
			
		||||
      {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />}
 | 
			
		||||
      {currTab === VMTab.CloudInit && <VMDetailsTabCloudInit {...p} />}
 | 
			
		||||
      {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />}
 | 
			
		||||
      {currTab === VMTab.XML && <VMDetailsTabXML {...p} />}
 | 
			
		||||
      {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />}
 | 
			
		||||
@@ -381,6 +389,10 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement {
 | 
			
		||||
  return <VMNetworksList {...p} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function VMDetailsTabCloudInit(p: DetailsInnerProps): React.ReactElement {
 | 
			
		||||
  return <CloudInitEditor {...p} />;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement {
 | 
			
		||||
  return (
 | 
			
		||||
    <Grid container spacing={2}>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user