Compare commits
	
		
			101 Commits
		
	
	
		
			20250531v2
			...
			adc1121a7f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| adc1121a7f | |||
| 807dfc6635 | |||
| 4e1a2dcdba | |||
| 843e53e83a | |||
| 5f911e6fb4 | |||
| 6c42cd62df | |||
| a4a4954344 | |||
| 5d20081fca | |||
| f46abedd1a | |||
| 0548e480b4 | |||
| 6b9455f6ea | |||
| 17108a52a3 | |||
| 47ebc77458 | |||
| 87e57dea56 | |||
| 24b4c02d9f | |||
| f2a3e16a1c | |||
| 6530a6d8e9 | |||
| b741d79ba7 | |||
| eeda3ca4ec | |||
| 992f91bb8a | |||
| 2f3ad231c7 | |||
| 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 | 
| @@ -46,8 +46,9 @@ steps: | |||||||
|   - cd virtweb_backend |   - cd virtweb_backend | ||||||
|   - mv /tmp/web_build/dist static |   - mv /tmp/web_build/dist static | ||||||
|   - cargo build --release |   - cargo build --release | ||||||
|   - ls -lah target/release/virtweb_backend |   - cargo build --release --example api_curl | ||||||
|   - cp target/release/virtweb_backend /tmp/release |   - 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 | - name: gitea_release | ||||||
|   image: plugins/gitea-release |   image: plugins/gitea-release | ||||||
|   | |||||||
							
								
								
									
										362
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										362
									
								
								virtweb_backend/Cargo.lock
									
									
									
										generated
									
									
									
								
							| @@ -112,7 +112,7 @@ dependencies = [ | |||||||
|  "mime", |  "mime", | ||||||
|  "percent-encoding", |  "percent-encoding", | ||||||
|  "pin-project-lite", |  "pin-project-lite", | ||||||
|  "rand 0.9.1", |  "rand 0.9.2", | ||||||
|  "sha1", |  "sha1", | ||||||
|  "smallvec", |  "smallvec", | ||||||
|  "tokio", |  "tokio", | ||||||
| @@ -233,7 +233,7 @@ dependencies = [ | |||||||
|  "futures-core", |  "futures-core", | ||||||
|  "futures-util", |  "futures-util", | ||||||
|  "mio", |  "mio", | ||||||
|  "socket2", |  "socket2 0.5.9", | ||||||
|  "tokio", |  "tokio", | ||||||
|  "tracing", |  "tracing", | ||||||
| ] | ] | ||||||
| @@ -312,7 +312,7 @@ dependencies = [ | |||||||
|  "serde_json", |  "serde_json", | ||||||
|  "serde_urlencoded", |  "serde_urlencoded", | ||||||
|  "smallvec", |  "smallvec", | ||||||
|  "socket2", |  "socket2 0.5.9", | ||||||
|  "time", |  "time", | ||||||
|  "tracing", |  "tracing", | ||||||
|  "url", |  "url", | ||||||
| @@ -435,6 +435,21 @@ dependencies = [ | |||||||
|  "alloc-no-stdlib", |  "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]] | [[package]] | ||||||
| name = "anstream" | name = "anstream" | ||||||
| version = "0.6.18" | version = "0.6.18" | ||||||
| @@ -496,6 +511,9 @@ name = "arbitrary" | |||||||
| version = "1.4.1" | version = "1.4.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" | checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223" | ||||||
|  | dependencies = [ | ||||||
|  |  "derive_arbitrary", | ||||||
|  | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "arg_enum_proc_macro" | name = "arg_enum_proc_macro" | ||||||
| @@ -598,7 +616,7 @@ dependencies = [ | |||||||
|  "elliptic-curve", |  "elliptic-curve", | ||||||
|  "jsonwebtoken", |  "jsonwebtoken", | ||||||
|  "p384", |  "p384", | ||||||
|  "rand 0.9.1", |  "rand 0.9.2", | ||||||
|  "serde", |  "serde", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| @@ -715,6 +733,25 @@ dependencies = [ | |||||||
|  "bytes", |  "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]] | [[package]] | ||||||
| name = "cc" | name = "cc" | ||||||
| version = "1.2.23" | version = "1.2.23" | ||||||
| @@ -748,6 +785,20 @@ version = "0.2.1" | |||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" | 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]] | [[package]] | ||||||
| name = "cipher" | name = "cipher" | ||||||
| version = "0.4.4" | version = "0.4.4" | ||||||
| @@ -760,9 +811,9 @@ dependencies = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "clap" | name = "clap" | ||||||
| version = "4.5.38" | version = "4.5.41" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" | checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "clap_builder", |  "clap_builder", | ||||||
|  "clap_derive", |  "clap_derive", | ||||||
| @@ -770,9 +821,9 @@ dependencies = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "clap_builder" | name = "clap_builder" | ||||||
| version = "4.5.38" | version = "4.5.41" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" | checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "anstream", |  "anstream", | ||||||
|  "anstyle", |  "anstyle", | ||||||
| @@ -782,9 +833,9 @@ dependencies = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "clap_derive" | name = "clap_derive" | ||||||
| version = "4.5.32" | version = "4.5.41" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "09176aae279615badda0765c0c0b3f6ed53f4709118af73cf4655d85d1530cd7" | checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "heck", |  "heck", | ||||||
|  "proc-macro2", |  "proc-macro2", | ||||||
| @@ -816,6 +867,12 @@ version = "0.9.6" | |||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "constant_time_eq" | ||||||
|  | version = "0.3.1" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "convert_case" | name = "convert_case" | ||||||
| version = "0.4.0" | version = "0.4.0" | ||||||
| @@ -981,6 +1038,12 @@ dependencies = [ | |||||||
|  "syn", |  "syn", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "deflate64" | ||||||
|  | version = "0.1.9" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "der" | name = "der" | ||||||
| version = "0.7.10" | version = "0.7.10" | ||||||
| @@ -1001,6 +1064,17 @@ dependencies = [ | |||||||
|  "powerfmt", |  "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]] | [[package]] | ||||||
| name = "derive_more" | name = "derive_more" | ||||||
| version = "0.99.20" | version = "0.99.20" | ||||||
| @@ -1221,6 +1295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||||||
| checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" | checksum = "7ced92e76e966ca2fd84c8f7aa01a4aea65b0eb6648d72f7c8f3e2764a67fece" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "crc32fast", |  "crc32fast", | ||||||
|  |  "libz-rs-sys", | ||||||
|  "miniz_oxide", |  "miniz_oxide", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| @@ -1380,9 +1455,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||||||
| checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" | checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "cfg-if", |  "cfg-if", | ||||||
|  |  "js-sys", | ||||||
|  "libc", |  "libc", | ||||||
|  "r-efi", |  "r-efi", | ||||||
|  "wasi 0.14.2+wasi-0.2.4", |  "wasi 0.14.2+wasi-0.2.4", | ||||||
|  |  "wasm-bindgen", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| @@ -1622,18 +1699,47 @@ version = "0.1.12" | |||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" | checksum = "cf9f1e950e0d9d1d3c47184416723cf29c0d1f93bd8cccf37e4beb6b44f31710" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  |  "base64 0.22.1", | ||||||
|  "bytes", |  "bytes", | ||||||
|  "futures-channel", |  "futures-channel", | ||||||
|  "futures-util", |  "futures-util", | ||||||
|  "http 1.3.1", |  "http 1.3.1", | ||||||
|  "http-body", |  "http-body", | ||||||
|  "hyper", |  "hyper", | ||||||
|  |  "ipnet", | ||||||
|  "libc", |  "libc", | ||||||
|  |  "percent-encoding", | ||||||
|  "pin-project-lite", |  "pin-project-lite", | ||||||
|  "socket2", |  "socket2 0.5.9", | ||||||
|  |  "system-configuration", | ||||||
|  "tokio", |  "tokio", | ||||||
|  "tower-service", |  "tower-service", | ||||||
|  "tracing", |  "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]] | [[package]] | ||||||
| @@ -1824,6 +1930,17 @@ dependencies = [ | |||||||
|  "syn", |  "syn", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "io-uring" | ||||||
|  | version = "0.7.9" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" | ||||||
|  | dependencies = [ | ||||||
|  |  "bitflags 2.9.1", | ||||||
|  |  "cfg-if", | ||||||
|  |  "libc", | ||||||
|  | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "ipnet" | name = "ipnet" | ||||||
| version = "2.11.0" | version = "2.11.0" | ||||||
| @@ -1839,6 +1956,16 @@ dependencies = [ | |||||||
|  "serde", |  "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]] | [[package]] | ||||||
| name = "is_terminal_polyfill" | name = "is_terminal_polyfill" | ||||||
| version = "1.70.1" | version = "1.70.1" | ||||||
| @@ -1968,9 +2095,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "libc" | name = "libc" | ||||||
| version = "0.2.172" | version = "0.2.174" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" | checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "libfuzzer-sys" | name = "libfuzzer-sys" | ||||||
| @@ -1982,6 +2109,45 @@ dependencies = [ | |||||||
|  "cc", |  "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]] | [[package]] | ||||||
| name = "light-openid" | name = "light-openid" | ||||||
| version = "1.0.4" | version = "1.0.4" | ||||||
| @@ -1992,7 +2158,7 @@ dependencies = [ | |||||||
|  "base64 0.22.1", |  "base64 0.22.1", | ||||||
|  "bincode", |  "bincode", | ||||||
|  "log", |  "log", | ||||||
|  "rand 0.9.1", |  "rand 0.9.2", | ||||||
|  "reqwest", |  "reqwest", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_json", |  "serde_json", | ||||||
| @@ -2404,6 +2570,16 @@ version = "1.0.15" | |||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" | 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]] | [[package]] | ||||||
| name = "pem" | name = "pem" | ||||||
| version = "3.0.5" | version = "3.0.5" | ||||||
| @@ -2575,9 +2751,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "quick-xml" | name = "quick-xml" | ||||||
| version = "0.37.5" | version = "0.38.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" | checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "memchr", |  "memchr", | ||||||
|  "serde", |  "serde", | ||||||
| @@ -2611,9 +2787,9 @@ dependencies = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "rand" | name = "rand" | ||||||
| version = "0.9.1" | version = "0.9.2" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" | checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "rand_chacha 0.9.0", |  "rand_chacha 0.9.0", | ||||||
|  "rand_core 0.9.3", |  "rand_core 0.9.3", | ||||||
| @@ -2773,9 +2949,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "reqwest" | name = "reqwest" | ||||||
| version = "0.12.15" | version = "0.12.22" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" | checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "base64 0.22.1", |  "base64 0.22.1", | ||||||
|  "bytes", |  "bytes", | ||||||
| @@ -2790,31 +2966,28 @@ dependencies = [ | |||||||
|  "hyper-rustls", |  "hyper-rustls", | ||||||
|  "hyper-tls", |  "hyper-tls", | ||||||
|  "hyper-util", |  "hyper-util", | ||||||
|  "ipnet", |  | ||||||
|  "js-sys", |  "js-sys", | ||||||
|  "log", |  "log", | ||||||
|  "mime", |  "mime", | ||||||
|  "native-tls", |  "native-tls", | ||||||
|  "once_cell", |  | ||||||
|  "percent-encoding", |  "percent-encoding", | ||||||
|  "pin-project-lite", |  "pin-project-lite", | ||||||
|  "rustls-pemfile", |  "rustls-pki-types", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_json", |  "serde_json", | ||||||
|  "serde_urlencoded", |  "serde_urlencoded", | ||||||
|  "sync_wrapper", |  "sync_wrapper", | ||||||
|  "system-configuration", |  | ||||||
|  "tokio", |  "tokio", | ||||||
|  "tokio-native-tls", |  "tokio-native-tls", | ||||||
|  "tokio-util", |  "tokio-util", | ||||||
|  "tower", |  "tower", | ||||||
|  |  "tower-http", | ||||||
|  "tower-service", |  "tower-service", | ||||||
|  "url", |  "url", | ||||||
|  "wasm-bindgen", |  "wasm-bindgen", | ||||||
|  "wasm-bindgen-futures", |  "wasm-bindgen-futures", | ||||||
|  "wasm-streams", |  "wasm-streams", | ||||||
|  "web-sys", |  "web-sys", | ||||||
|  "windows-registry", |  | ||||||
| ] | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| @@ -2923,15 +3096,6 @@ dependencies = [ | |||||||
|  "zeroize", |  "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]] | [[package]] | ||||||
| name = "rustls-pki-types" | name = "rustls-pki-types" | ||||||
| version = "1.12.0" | version = "1.12.0" | ||||||
| @@ -3053,9 +3217,9 @@ dependencies = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "serde_json" | name = "serde_json" | ||||||
| version = "1.0.140" | version = "1.0.141" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" | checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "itoa", |  "itoa", | ||||||
|  "memchr", |  "memchr", | ||||||
| @@ -3093,6 +3257,21 @@ dependencies = [ | |||||||
|  "serde", |  "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]] | [[package]] | ||||||
| name = "sha1" | name = "sha1" | ||||||
| version = "0.10.6" | version = "0.10.6" | ||||||
| @@ -3192,6 +3371,16 @@ dependencies = [ | |||||||
|  "windows-sys 0.52.0", |  "windows-sys 0.52.0", | ||||||
| ] | ] | ||||||
|  |  | ||||||
|  | [[package]] | ||||||
|  | name = "socket2" | ||||||
|  | version = "0.6.0" | ||||||
|  | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
|  | checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" | ||||||
|  | dependencies = [ | ||||||
|  |  "libc", | ||||||
|  |  "windows-sys 0.59.0", | ||||||
|  | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "spki" | name = "spki" | ||||||
| version = "0.7.3" | version = "0.7.3" | ||||||
| @@ -3253,9 +3442,9 @@ dependencies = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "sysinfo" | name = "sysinfo" | ||||||
| version = "0.35.1" | version = "0.36.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "79251336d17c72d9762b8b54be4befe38d2db56fbbc0241396d70f173c39d47a" | checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "libc", |  "libc", | ||||||
|  "memchr", |  "memchr", | ||||||
| @@ -3413,20 +3602,22 @@ dependencies = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "tokio" | name = "tokio" | ||||||
| version = "1.45.0" | version = "1.47.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" | checksum = "43864ed400b6043a4757a25c7a64a8efde741aed79a056a2fb348a406701bb35" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "backtrace", |  "backtrace", | ||||||
|  "bytes", |  "bytes", | ||||||
|  |  "io-uring", | ||||||
|  "libc", |  "libc", | ||||||
|  "mio", |  "mio", | ||||||
|  "parking_lot", |  "parking_lot", | ||||||
|  "pin-project-lite", |  "pin-project-lite", | ||||||
|  "signal-hook-registry", |  "signal-hook-registry", | ||||||
|  "socket2", |  "slab", | ||||||
|  |  "socket2 0.6.0", | ||||||
|  "tokio-macros", |  "tokio-macros", | ||||||
|  "windows-sys 0.52.0", |  "windows-sys 0.59.0", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| @@ -3522,6 +3713,24 @@ dependencies = [ | |||||||
|  "tower-service", |  "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]] | [[package]] | ||||||
| name = "tower-layer" | name = "tower-layer" | ||||||
| version = "0.3.3" | version = "0.3.3" | ||||||
| @@ -3649,12 +3858,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "uuid" | name = "uuid" | ||||||
| version = "1.16.0" | version = "1.17.0" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" | checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  "getrandom 0.3.3", |  "getrandom 0.3.3", | ||||||
|  |  "js-sys", | ||||||
|  "serde", |  "serde", | ||||||
|  |  "wasm-bindgen", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| @@ -3735,6 +3946,7 @@ dependencies = [ | |||||||
|  "actix-ws", |  "actix-ws", | ||||||
|  "anyhow", |  "anyhow", | ||||||
|  "basic-jwt", |  "basic-jwt", | ||||||
|  |  "chrono", | ||||||
|  "clap", |  "clap", | ||||||
|  "dotenvy", |  "dotenvy", | ||||||
|  "env_logger", |  "env_logger", | ||||||
| @@ -3749,11 +3961,12 @@ dependencies = [ | |||||||
|  "nix", |  "nix", | ||||||
|  "num", |  "num", | ||||||
|  "quick-xml", |  "quick-xml", | ||||||
|  "rand 0.9.1", |  "rand 0.9.2", | ||||||
|  "reqwest", |  "reqwest", | ||||||
|  "rust-embed", |  "rust-embed", | ||||||
|  "serde", |  "serde", | ||||||
|  "serde_json", |  "serde_json", | ||||||
|  |  "serde_yml", | ||||||
|  "sysinfo", |  "sysinfo", | ||||||
|  "tempfile", |  "tempfile", | ||||||
|  "thiserror 2.0.12", |  "thiserror 2.0.12", | ||||||
| @@ -3761,6 +3974,7 @@ dependencies = [ | |||||||
|  "url", |  "url", | ||||||
|  "uuid", |  "uuid", | ||||||
|  "virt", |  "virt", | ||||||
|  |  "zip", | ||||||
| ] | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| @@ -4299,6 +4513,20 @@ name = "zeroize" | |||||||
| version = "1.8.1" | version = "1.8.1" | ||||||
| source = "registry+https://github.com/rust-lang/crates.io-index" | source = "registry+https://github.com/rust-lang/crates.io-index" | ||||||
| checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" | 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]] | [[package]] | ||||||
| name = "zerotrie" | name = "zerotrie" | ||||||
| @@ -4333,6 +4561,50 @@ dependencies = [ | |||||||
|  "syn", |  "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]] | [[package]] | ||||||
| name = "zstd" | name = "zstd" | ||||||
| version = "0.13.3" | version = "0.13.3" | ||||||
|   | |||||||
| @@ -8,7 +8,7 @@ edition = "2024" | |||||||
| [dependencies] | [dependencies] | ||||||
| log = "0.4.27" | log = "0.4.27" | ||||||
| env_logger = "0.11.8" | 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"] } | light-openid = { version = "1.0.4", features = ["crypto-wrapper"] } | ||||||
| lazy_static = "1.5.0" | lazy_static = "1.5.0" | ||||||
| actix = "0.13.5" | actix = "0.13.5" | ||||||
| @@ -19,24 +19,25 @@ actix-identity = "0.8.0" | |||||||
| actix-cors = "0.7.1" | actix-cors = "0.7.1" | ||||||
| actix-files = "0.6.6" | actix-files = "0.6.6" | ||||||
| actix-ws = "0.3.0" | actix-ws = "0.3.0" | ||||||
| actix-http = "3.10.0" | actix-http = "3.11.0" | ||||||
| serde = { version = "1.0.219", features = ["derive"] } | serde = { version = "1.0.219", features = ["derive"] } | ||||||
| serde_json = "1.0.140" | serde_json = "1.0.141" | ||||||
| quick-xml = { version = "0.37.5", features = ["serialize", "overlapped-lists"] } | serde_yml = "0.0.12" | ||||||
|  | quick-xml = { version = "0.38.0", features = ["serialize", "overlapped-lists"] } | ||||||
| futures-util = "0.3.31" | futures-util = "0.3.31" | ||||||
| anyhow = "1.0.98" | anyhow = "1.0.98" | ||||||
| actix-multipart = "0.7.2" | actix-multipart = "0.7.2" | ||||||
| tempfile = "3.20.0" | tempfile = "3.20.0" | ||||||
| reqwest = { version = "0.12.15", features = ["stream"] } | reqwest = { version = "0.12.22", features = ["stream"] } | ||||||
| url = "2.5.4" | url = "2.5.4" | ||||||
| virt = "0.4.2" | virt = "0.4.2" | ||||||
| sysinfo = { version = "0.35.1", features = ["serde"] } | sysinfo = { version = "0.36.1", features = ["serde"] } | ||||||
| uuid = { version = "1.16.0", features = ["v4", "serde"] } | uuid = { version = "1.17.0", features = ["v4", "serde"] } | ||||||
| lazy-regex = "3.4.1" | lazy-regex = "3.4.1" | ||||||
| thiserror = "2.0.12" | thiserror = "2.0.12" | ||||||
| image = "0.25.6" | image = "0.25.6" | ||||||
| rand = "0.9.1" | rand = "0.9.2" | ||||||
| tokio = { version = "1.45.0", features = ["rt", "time", "macros"] } | tokio = { version = "1.47.0", features = ["rt", "time", "macros"] } | ||||||
| futures = "0.3.31" | futures = "0.3.31" | ||||||
| ipnetwork = { version = "0.21.1", features = ["serde"] } | ipnetwork = { version = "0.21.1", features = ["serde"] } | ||||||
| num = "0.4.3" | num = "0.4.3" | ||||||
| @@ -44,3 +45,5 @@ rust-embed = { version = "8.7.2", features = ["mime-guess"] } | |||||||
| dotenvy = "0.15.7" | dotenvy = "0.15.7" | ||||||
| nix = { version = "0.30.1", features = ["net"] } | nix = { version = "0.30.1", features = ["net"] } | ||||||
| basic-jwt = "0.3.0" | basic-jwt = "0.3.0" | ||||||
|  | zip = "4.1.0" | ||||||
|  | chrono = "0.4.41" | ||||||
| @@ -27,10 +27,7 @@ impl LibVirtActor { | |||||||
|     /// Connect to hypervisor |     /// Connect to hypervisor | ||||||
|     pub async fn connect() -> anyhow::Result<Self> { |     pub async fn connect() -> anyhow::Result<Self> { | ||||||
|         let hypervisor_uri = AppConfig::get().hypervisor_uri.as_deref().unwrap_or(""); |         let hypervisor_uri = AppConfig::get().hypervisor_uri.as_deref().unwrap_or(""); | ||||||
|         log::info!( |         log::info!("Will connect to hypvervisor at address '{hypervisor_uri}'",); | ||||||
|             "Will connect to hypvervisor at address '{}'", |  | ||||||
|             hypervisor_uri |  | ||||||
|         ); |  | ||||||
|         let conn = Connect::open(Some(hypervisor_uri))?; |         let conn = Connect::open(Some(hypervisor_uri))?; | ||||||
|  |  | ||||||
|         Ok(Self { m: conn }) |         Ok(Self { m: conn }) | ||||||
| @@ -102,7 +99,7 @@ impl Handler<GetDomainXMLReq> for LibVirtActor { | |||||||
|         log::debug!("Get domain XML:\n{}", msg.0.as_string()); |         log::debug!("Get domain XML:\n{}", msg.0.as_string()); | ||||||
|         let domain = Domain::lookup_by_uuid_string(&self.m, &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)?; |         let xml = domain.get_xml_desc(VIR_DOMAIN_XML_SECURE)?; | ||||||
|         log::debug!("XML = {}", xml); |         log::debug!("XML = {xml}"); | ||||||
|         DomainXML::parse_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 { |     fn handle(&mut self, mut msg: DefineDomainReq, _ctx: &mut Self::Context) -> Self::Result { | ||||||
|         let xml = msg.1.as_xml()?; |         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 domain = Domain::define_xml(&self.m, &xml)?; | ||||||
|         let uuid = XMLUuid::parse_from_str(&domain.get_uuid_string()?)?; |         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, |             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 { |         if !msg.keep_files { | ||||||
|             log::info!("Delete storage associated with the domain"); |             log::info!("Delete storage associated with the domain"); | ||||||
|             let path = AppConfig::get().vm_storage_path(msg.id); |             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()); |         log::debug!("Get network XML:\n{}", msg.0.as_string()); | ||||||
|         let network = Network::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; |         let network = Network::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; | ||||||
|         let xml = network.get_xml_desc(0)?; |         let xml = network.get_xml_desc(0)?; | ||||||
|         log::debug!("XML = {}", xml); |         log::debug!("XML = {xml}"); | ||||||
|         NetworkXML::parse_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()); |         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 filter = NWFilter::lookup_by_uuid_string(&self.m, &msg.0.as_string())?; | ||||||
|         let xml = filter.get_xml_desc(0)?; |         let xml = filter.get_xml_desc(0)?; | ||||||
|         log::debug!("XML = {}", xml); |         log::debug!("XML = {xml}"); | ||||||
|         NetworkFilterXML::parse_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 { |     fn handle(&mut self, mut msg: DefineNWFilterReq, _ctx: &mut Self::Context) -> Self::Result { | ||||||
|         let xml = msg.1.into_xml()?; |         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 filter = NWFilter::define_xml(&self.m, &xml)?; | ||||||
|         let uuid = XMLUuid::parse_from_str(&filter.get_uuid_string()?)?; |         let uuid = XMLUuid::parse_from_str(&filter.get_uuid_string()?)?; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -250,6 +250,19 @@ impl AppConfig { | |||||||
|         self.storage_path().join("iso") |         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 |     /// Get disk images storage directory | ||||||
|     pub fn disk_images_storage_path(&self) -> PathBuf { |     pub fn disk_images_storage_path(&self) -> PathBuf { | ||||||
|         self.storage_path().join("disk_images") |         self.storage_path().join("disk_images") | ||||||
| @@ -267,7 +280,7 @@ impl AppConfig { | |||||||
|  |  | ||||||
|     /// Get VM vnc sockets path for domain |     /// Get VM vnc sockets path for domain | ||||||
|     pub fn vnc_socket_for_domain(&self, name: &str) -> PathBuf { |     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 |     /// 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); | pub const ISO_MAX_SIZE: FileSize = FileSize::from_gb(10); | ||||||
|  |  | ||||||
| /// Allowed uploaded disk images formats | /// 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-qemu-disk", | ||||||
|  |     "application/x-raw-disk-image", | ||||||
|     "application/gzip", |     "application/gzip", | ||||||
|     "application/octet-stream", |     "application/octet-stream", | ||||||
| ]; | ]; | ||||||
| @@ -57,6 +58,9 @@ pub const DISK_SIZE_MIN: FileSize = FileSize::from_mb(50); | |||||||
| /// Disk size max (B) | /// Disk size max (B) | ||||||
| pub const DISK_SIZE_MAX: FileSize = FileSize::from_gb(20000); | 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 | /// Net nat entry comment max size | ||||||
| pub const NET_NAT_COMMENT_MAX_SIZE: usize = 250; | 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; | pub const API_TOKEN_RIGHT_PATH_MAX_LENGTH: usize = 255; | ||||||
|  |  | ||||||
| /// Qemu image program path | /// 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 | /// IP program path | ||||||
| pub const IP_PROGRAM: &str = "/usr/sbin/ip"; | pub const PROGRAM_IP: &str = "/usr/sbin/ip"; | ||||||
|  |  | ||||||
| /// Copy program path | /// Copy program path | ||||||
| pub const COPY_PROGRAM: &str = "/bin/cp"; | pub const PROGRAM_COPY: &str = "/bin/cp"; | ||||||
|  |  | ||||||
| /// Gzip program path | /// 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 | /// Bash program | ||||||
| pub const BASH_PROGRAM: &str = "/usr/bin/bash"; | pub const PROGRAM_BASH: &str = "/usr/bin/bash"; | ||||||
|  |  | ||||||
| /// DD program | /// 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 let Some(mime_type) = file.content_type { | ||||||
|         if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) { |         if !constants::ALLOWED_DISK_IMAGES_MIME_TYPES.contains(&mime_type.as_ref()) { | ||||||
|             return Ok(HttpResponse::BadRequest().json(format!( |             return Ok(HttpResponse::BadRequest().json(format!( | ||||||
|                 "Unsupported file type for disk upload: {}", |                 "Unsupported file type for disk upload: {mime_type}" | ||||||
|                 mime_type |  | ||||||
|             ))); |             ))); | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
| @@ -55,7 +54,15 @@ pub async fn upload(MultipartForm(mut form): MultipartForm<UploadDiskImageForm>) | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     // Copy the file to the destination |     // 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!")) |     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); |     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() { |     if dest_file.exists() { | ||||||
|         log::error!("Conflict with uploaded iso file name!"); |         log::error!("Conflict with uploaded iso file name!"); | ||||||
|   | |||||||
| @@ -4,6 +4,7 @@ use actix_web::body::BoxBody; | |||||||
| use actix_web::{HttpResponse, web}; | use actix_web::{HttpResponse, web}; | ||||||
| use std::error::Error; | use std::error::Error; | ||||||
| use std::fmt::{Display, Formatter}; | use std::fmt::{Display, Formatter}; | ||||||
|  | use zip::result::ZipError; | ||||||
|  |  | ||||||
| pub mod api_tokens_controller; | pub mod api_tokens_controller; | ||||||
| pub mod auth_controller; | pub mod auth_controller; | ||||||
| @@ -42,7 +43,7 @@ impl actix_web::error::ResponseError for HttpErr { | |||||||
|         } |         } | ||||||
|     } |     } | ||||||
|     fn error_response(&self) -> HttpResponse<BoxBody> { |     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!") |         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 { | impl From<HttpResponse> for HttpErr { | ||||||
|     fn from(value: HttpResponse) -> Self { |     fn from(value: HttpResponse) -> Self { | ||||||
|         HttpErr::HTTPResponse(value) |         HttpErr::HTTPResponse(value) | ||||||
|   | |||||||
| @@ -1,14 +1,24 @@ | |||||||
| use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME; | use crate::actors::vnc_tokens_actor::VNC_TOKEN_LIFETIME; | ||||||
| use crate::app_config::AppConfig; | 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::constants::{DISK_NAME_MAX_LEN, DISK_NAME_MIN_LEN, DISK_SIZE_MAX, DISK_SIZE_MIN}; | ||||||
| use crate::controllers::{HttpResult, LibVirtReq}; | use crate::controllers::{HttpResult, LibVirtReq}; | ||||||
| use crate::extractors::local_auth_extractor::LocalAuthEnabled; | use crate::extractors::local_auth_extractor::LocalAuthEnabled; | ||||||
| use crate::libvirt_rest_structures::hypervisor::HypervisorInfo; | 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::nat::nat_hook; | ||||||
| use crate::utils::net_utils; | 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 sysinfo::{Components, Disks, Networks, System}; | ||||||
|  | use zip::ZipWriter; | ||||||
|  | use zip::write::SimpleFileOptions; | ||||||
|  |  | ||||||
| #[derive(serde::Serialize)] | #[derive(serde::Serialize)] | ||||||
| struct StaticConfig { | struct StaticConfig { | ||||||
| @@ -199,3 +209,85 @@ pub async fn networks_list() -> HttpResult { | |||||||
| pub async fn bridges_list() -> HttpResult { | pub async fn bridges_list() -> HttpResult { | ||||||
|     Ok(HttpResponse::Ok().json(net_utils::bridges_list()?)) |     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)) |         .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 | /// Update a VM information | ||||||
| pub async fn update( | pub async fn update( | ||||||
|     client: LibVirtReq, |     client: LibVirtReq, | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| use crate::libvirt_lib_structures::XMLUuid; | use crate::libvirt_lib_structures::XMLUuid; | ||||||
|  | use crate::utils::cloud_init_utils::CloudInitConfig; | ||||||
|  |  | ||||||
| /// VirtWeb specific metadata | /// VirtWeb specific metadata | ||||||
| #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] | #[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] | ||||||
| @@ -8,6 +9,8 @@ pub struct DomainMetadataVirtWebXML { | |||||||
|     pub ns: String, |     pub ns: String, | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|     pub group: Option<String>, |     pub group: Option<String>, | ||||||
|  |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|  |     pub cloud_init: Option<CloudInitConfig>, | ||||||
| } | } | ||||||
|  |  | ||||||
| /// Domain metadata | /// Domain metadata | ||||||
|   | |||||||
| @@ -13,4 +13,6 @@ enum LibVirtStructError { | |||||||
|     ParseFilteringChain(String), |     ParseFilteringChain(String), | ||||||
|     #[error("NetworkFilterExtractionError: {0}")] |     #[error("NetworkFilterExtractionError: {0}")] | ||||||
|     NetworkFilterExtraction(String), |     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>> { | fn extract_nw_filter_comment(n: &Option<String>) -> anyhow::Result<Option<String>> { | ||||||
|     if let Some(comment) = n { |     if let Some(comment) = n { | ||||||
|         if comment.len() > 256 || comment.contains('\"') || comment.contains('\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::XMLUuid; | ||||||
| use crate::libvirt_lib_structures::domain::*; | use crate::libvirt_lib_structures::domain::*; | ||||||
| use crate::libvirt_rest_structures::LibVirtStructError; | 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::file_size_utils::FileSize; | ||||||
| use crate::utils::files_utils; | use crate::utils::files_utils; | ||||||
| use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk}; | use crate::utils::vm_file_disks_utils::{VMDiskBus, VMDiskFormat, VMFileDisk}; | ||||||
| @@ -94,6 +97,9 @@ pub struct VMInfo { | |||||||
|     pub tpm_module: bool, |     pub tpm_module: bool, | ||||||
|     /// Strings injected as OEM Strings in SMBios configuration |     /// Strings injected as OEM Strings in SMBios configuration | ||||||
|     pub oem_strings: Vec<String>, |     pub oem_strings: Vec<String>, | ||||||
|  |     /// Cloud init configuration | ||||||
|  |     #[serde(default)] | ||||||
|  |     pub cloud_init: CloudInitConfig, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl VMInfo { | impl VMInfo { | ||||||
| @@ -138,9 +144,26 @@ impl VMInfo { | |||||||
|             return Err(StructureExtraction("Invalid number of vCPU specified!").into()); |             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 { |         for iso_file in &self.iso_files { | ||||||
|             if !files_utils::check_file_name(iso_file) { |             if !files_utils::check_file_name(iso_file) { | ||||||
|                 return Err(StructureExtraction("ISO filename is invalid!").into()); |                 return Err(StructureExtraction("ISO filename is invalid!").into()); | ||||||
| @@ -152,6 +175,13 @@ impl VMInfo { | |||||||
|                 return Err(StructureExtraction("Specified ISO file does not exists!").into()); |                 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 { |             disks.push(DiskXML { | ||||||
|                 r#type: "file".to_string(), |                 r#type: "file".to_string(), | ||||||
|                 device: "cdrom".to_string(), |                 device: "cdrom".to_string(), | ||||||
| @@ -161,7 +191,7 @@ impl VMInfo { | |||||||
|                     cache: "none".to_string(), |                     cache: "none".to_string(), | ||||||
|                 }, |                 }, | ||||||
|                 source: DiskSourceXML { |                 source: DiskSourceXML { | ||||||
|                     file: path.to_string_lossy().to_string(), |                     file: iso_path.to_string_lossy().to_string(), | ||||||
|                 }, |                 }, | ||||||
|                 target: DiskTargetXML { |                 target: DiskTargetXML { | ||||||
|                     dev: format!( |                     dev: format!( | ||||||
| @@ -178,6 +208,7 @@ impl VMInfo { | |||||||
|             }) |             }) | ||||||
|         } |         } | ||||||
|  |  | ||||||
|  |         // Configure VNC access, if requested | ||||||
|         let (vnc_graphics, vnc_video) = match self.vnc_access { |         let (vnc_graphics, vnc_video) = match self.vnc_access { | ||||||
|             true => ( |             true => ( | ||||||
|                 Some(GraphicsXML { |                 Some(GraphicsXML { | ||||||
| @@ -340,6 +371,7 @@ impl VMInfo { | |||||||
|                 virtweb: DomainMetadataVirtWebXML { |                 virtweb: DomainMetadataVirtWebXML { | ||||||
|                     ns: "https://virtweb.communiquons.org".to_string(), |                     ns: "https://virtweb.communiquons.org".to_string(), | ||||||
|                     group: self.group.clone().map(|g| g.0), |                     group: self.group.clone().map(|g| g.0), | ||||||
|  |                     cloud_init: Some(self.cloud_init.clone()), | ||||||
|                 }, |                 }, | ||||||
|             }), |             }), | ||||||
|             os: OSXML { |             os: OSXML { | ||||||
| @@ -490,6 +522,7 @@ impl VMInfo { | |||||||
|                 .iter() |                 .iter() | ||||||
|                 .filter(|d| d.device == "cdrom") |                 .filter(|d| d.device == "cdrom") | ||||||
|                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) |                 .map(|d| d.source.file.rsplit_once('/').unwrap().1.to_string()) | ||||||
|  |                 .filter(|d| !d.starts_with(constants::CLOUD_INIT_IMAGE_PREFIX_NAME)) | ||||||
|                 .collect(), |                 .collect(), | ||||||
|  |  | ||||||
|             file_disks: domain |             file_disks: domain | ||||||
| @@ -582,6 +615,13 @@ impl VMInfo { | |||||||
|                 .and_then(|s| s.oem_strings) |                 .and_then(|s| s.oem_strings) | ||||||
|                 .map(|s| s.entries.iter().map(|o| o.content.to_string()).collect()) |                 .map(|s| s.entries.iter().map(|o| o.content.to_string()).collect()) | ||||||
|                 .unwrap_or_default(), |                 .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"); |     log::debug!("Checking for required programs"); | ||||||
|     exec_utils::check_program( |     exec_utils::check_program( | ||||||
|         constants::QEMU_IMAGE_PROGRAM, |         constants::PROGRAM_QEMU_IMAGE, | ||||||
|         "QEMU disk image utility is required to manipulate QCow2 files!", |         "QEMU disk image utility is required to manipulate QCow2 files!", | ||||||
|     ); |     ); | ||||||
|     exec_utils::check_program( |     exec_utils::check_program( | ||||||
|         constants::IP_PROGRAM, |         constants::PROGRAM_IP, | ||||||
|         "ip is required to access bridges information!", |         "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"); |     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().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().disk_images_storage_path()).unwrap(); | ||||||
|     files_utils::create_directory_if_missing(AppConfig::get().vnc_sockets_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(); |     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", |                 "/api/server/bridges", | ||||||
|                 web::get().to(server_controller::bridges_list), |                 web::get().to(server_controller::bridges_list), | ||||||
|             ) |             ) | ||||||
|  |             .route( | ||||||
|  |                 "/api/server/export_configs", | ||||||
|  |                 web::get().to(server_controller::export_all_configs), | ||||||
|  |             ) | ||||||
|             // Auth controller |             // Auth controller | ||||||
|             .route( |             .route( | ||||||
|                 "/api/auth/local", |                 "/api/auth/local", | ||||||
| @@ -198,6 +208,10 @@ async fn main() -> std::io::Result<()> { | |||||||
|                 "/api/vm/{uid}/src", |                 "/api/vm/{uid}/src", | ||||||
|                 web::get().to(vm_controller::get_single_src_def), |                 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( |             .route( | ||||||
|                 "/api/vm/{uid}/autostart", |                 "/api/vm/{uid}/autostart", | ||||||
|                 web::get().to(vm_controller::get_autostart), |                 web::get().to(vm_controller::get_autostart), | ||||||
|   | |||||||
| @@ -69,8 +69,7 @@ where | |||||||
|  |  | ||||||
|             if !AppConfig::get().is_allowed_ip(remote_ip.0) { |             if !AppConfig::get().is_allowed_ip(remote_ip.0) { | ||||||
|                 log::error!( |                 log::error!( | ||||||
|                     "An attempt to access VirtWeb from an unauthorized network has been intercepted! {:?}", |                     "An attempt to access VirtWeb from an unauthorized network has been intercepted! {remote_ip:?}" | ||||||
|                     remote_ip |  | ||||||
|                 ); |                 ); | ||||||
|                 return Ok(req |                 return Ok(req | ||||||
|                     .into_response( |                     .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)] |         #[serde(default)] | ||||||
|         virtual_size: FileSize, |         virtual_size: FileSize, | ||||||
|     }, |     }, | ||||||
|     CompressedRaw, |     GzCompressedRaw, | ||||||
|     CompressedQCow2, |     GzCompressedQCow2, | ||||||
|  |     XzCompressedRaw, | ||||||
|  |     XzCompressedQCow2, | ||||||
| } | } | ||||||
|  |  | ||||||
| impl DiskFileFormat { | impl DiskFileFormat { | ||||||
| @@ -37,8 +39,10 @@ impl DiskFileFormat { | |||||||
|         match self { |         match self { | ||||||
|             DiskFileFormat::Raw { .. } => &["raw", ""], |             DiskFileFormat::Raw { .. } => &["raw", ""], | ||||||
|             DiskFileFormat::QCow2 { .. } => &["qcow2"], |             DiskFileFormat::QCow2 { .. } => &["qcow2"], | ||||||
|             DiskFileFormat::CompressedRaw => &["raw.gz"], |             DiskFileFormat::GzCompressedRaw => &["raw.gz"], | ||||||
|             DiskFileFormat::CompressedQCow2 => &["qcow2.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") => { |             "gz" if name.ends_with(".qcow2") => { | ||||||
|                 name = name.strip_suffix(".qcow2").unwrap_or(&name).to_string(); |                 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}!"), |             _ => anyhow::bail!("Unsupported disk extension: {ext}!"), | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
| @@ -124,7 +133,7 @@ impl DiskFileInfo { | |||||||
|             } |             } | ||||||
|  |  | ||||||
|             DiskFileFormat::QCow2 { virtual_size } => { |             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") |                 cmd.arg("create") | ||||||
|                     .arg("-f") |                     .arg("-f") | ||||||
|                     .arg("qcow2") |                     .arg("qcow2") | ||||||
| @@ -159,9 +168,9 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|         // Prepare the conversion |         // Prepare the conversion | ||||||
|         let mut cmd = match (self.format, dest_format) { |         let mut cmd = match (self.format, dest_format) { | ||||||
|             // Decompress QCow2 |             // Decompress QCow2 (GZIP) | ||||||
|             (DiskFileFormat::CompressedQCow2, DiskFileFormat::QCow2 { .. }) => { |             (DiskFileFormat::GzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); |                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||||
|                 cmd.arg("--keep") |                 cmd.arg("--keep") | ||||||
|                     .arg("--decompress") |                     .arg("--decompress") | ||||||
|                     .arg("--to-stdout") |                     .arg("--to-stdout") | ||||||
| @@ -170,9 +179,30 @@ impl DiskFileInfo { | |||||||
|                 cmd |                 cmd | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Compress QCow2 |             // Decompress QCow2 (XZ) | ||||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::CompressedQCow2) => { |             (DiskFileFormat::XzCompressedQCow2, DiskFileFormat::QCow2 { .. }) => { | ||||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); |                 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") |                 cmd.arg("--keep") | ||||||
|                     .arg("--to-stdout") |                     .arg("--to-stdout") | ||||||
|                     .arg(&self.file_path) |                     .arg(&self.file_path) | ||||||
| @@ -182,7 +212,7 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Convert QCow2 to Raw file |             // Convert QCow2 to Raw file | ||||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => { |             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::Raw { is_sparse }) => { | ||||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); |                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||||
|                 cmd.arg("convert") |                 cmd.arg("convert") | ||||||
|                     .arg("-f") |                     .arg("-f") | ||||||
|                     .arg("qcow2") |                     .arg("qcow2") | ||||||
| @@ -201,7 +231,7 @@ impl DiskFileInfo { | |||||||
|             // Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of |             // Clone a QCow file, using qemu-image instead of cp might improve "sparsification" of | ||||||
|             // file |             // file | ||||||
|             (DiskFileFormat::QCow2 { .. }, DiskFileFormat::QCow2 { .. }) => { |             (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") |                 cmd.arg("convert") | ||||||
|                     .arg("-f") |                     .arg("-f") | ||||||
|                     .arg("qcow2") |                     .arg("qcow2") | ||||||
| @@ -214,7 +244,7 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Convert Raw to QCow2 file |             // Convert Raw to QCow2 file | ||||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => { |             (DiskFileFormat::Raw { .. }, DiskFileFormat::QCow2 { .. }) => { | ||||||
|                 let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); |                 let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||||
|                 cmd.arg("convert") |                 cmd.arg("convert") | ||||||
|                     .arg("-f") |                     .arg("-f") | ||||||
|                     .arg("raw") |                     .arg("raw") | ||||||
| @@ -228,7 +258,7 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Render raw file non sparse |             // Render raw file non sparse | ||||||
|             (DiskFileFormat::Raw { is_sparse: true }, DiskFileFormat::Raw { is_sparse: false }) => { |             (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") |                 cmd.arg("--sparse=never") | ||||||
|                     .arg(&self.file_path) |                     .arg(&self.file_path) | ||||||
|                     .arg(&temp_file); |                     .arg(&temp_file); | ||||||
| @@ -237,16 +267,16 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Render raw file sparse |             // Render raw file sparse | ||||||
|             (DiskFileFormat::Raw { is_sparse: false }, DiskFileFormat::Raw { is_sparse: true }) => { |             (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") |                 cmd.arg("conv=sparse") | ||||||
|                     .arg(format!("if={}", self.file_path.display())) |                     .arg(format!("if={}", self.file_path.display())) | ||||||
|                     .arg(format!("of={}", temp_file.display())); |                     .arg(format!("of={}", temp_file.display())); | ||||||
|                 cmd |                 cmd | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Compress Raw |             // Compress Raw (Gz) | ||||||
|             (DiskFileFormat::Raw { .. }, DiskFileFormat::CompressedRaw) => { |             (DiskFileFormat::Raw { .. }, DiskFileFormat::GzCompressedRaw) => { | ||||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); |                 let mut cmd = Command::new(constants::PROGRAM_GZIP); | ||||||
|                 cmd.arg("--keep") |                 cmd.arg("--keep") | ||||||
|                     .arg("--to-stdout") |                     .arg("--to-stdout") | ||||||
|                     .arg(&self.file_path) |                     .arg(&self.file_path) | ||||||
| @@ -254,9 +284,29 @@ impl DiskFileInfo { | |||||||
|                 cmd |                 cmd | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Decompress Raw to not sparse file |             // Compress Raw (Xz) | ||||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: false }) => { |             (DiskFileFormat::Raw { .. }, DiskFileFormat::XzCompressedRaw) => { | ||||||
|                 let mut cmd = Command::new(constants::GZIP_PROGRAM); |                 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") |                 cmd.arg("--keep") | ||||||
|                     .arg("--decompress") |                     .arg("--decompress") | ||||||
|                     .arg("--to-stdout") |                     .arg("--to-stdout") | ||||||
| @@ -265,15 +315,29 @@ impl DiskFileInfo { | |||||||
|                 cmd |                 cmd | ||||||
|             } |             } | ||||||
|  |  | ||||||
|             // Decompress Raw to sparse file |             // Decompress Raw (Gz) to sparse file | ||||||
|             // https://benou.fr/www/ben/decompressing-sparse-files.html |             // https://benou.fr/www/ben/decompressing-sparse-files.html | ||||||
|             (DiskFileFormat::CompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { |             (DiskFileFormat::GzCompressedRaw, DiskFileFormat::Raw { is_sparse: true }) => { | ||||||
|                 let mut cmd = Command::new(constants::BASH_PROGRAM); |                 let mut cmd = Command::new(constants::PROGRAM_BASH); | ||||||
|                 cmd.arg("-c").arg(format!( |                 cmd.arg("-c").arg(format!( | ||||||
|                     "{} -d -c {} | {} conv=sparse of={}", |                     "{} --decompress --to-stdout {} | {} conv=sparse of={}", | ||||||
|                     constants::GZIP_PROGRAM, |                     constants::PROGRAM_GZIP, | ||||||
|                     self.file_path.display(), |                     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() |                     temp_file.display() | ||||||
|                 )); |                 )); | ||||||
|                 cmd |                 cmd | ||||||
| @@ -281,7 +345,7 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|             // Dumb copy of file |             // Dumb copy of file | ||||||
|             (a, b) if a == b => { |             (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") |                 cmd.arg("--sparse=auto") | ||||||
|                     .arg(&self.file_path) |                     .arg(&self.file_path) | ||||||
|                     .arg(&temp_file); |                     .arg(&temp_file); | ||||||
| @@ -330,6 +394,44 @@ impl DiskFileInfo { | |||||||
|  |  | ||||||
|         Ok(()) |         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)] | #[derive(serde::Deserialize)] | ||||||
| @@ -341,7 +443,7 @@ struct QCowInfoOutput { | |||||||
| /// Get QCow2 virtual size | /// Get QCow2 virtual size | ||||||
| fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | ||||||
|     // Run qemu-img |     // Run qemu-img | ||||||
|     let mut cmd = Command::new(constants::QEMU_IMAGE_PROGRAM); |     let mut cmd = Command::new(constants::PROGRAM_QEMU_IMAGE); | ||||||
|     cmd.args([ |     cmd.args([ | ||||||
|         "info", |         "info", | ||||||
|         path.to_str().unwrap_or(""), |         path.to_str().unwrap_or(""), | ||||||
| @@ -353,7 +455,7 @@ fn qcow_virt_size(path: &Path) -> anyhow::Result<FileSize> { | |||||||
|     if !output.status.success() { |     if !output.status.success() { | ||||||
|         anyhow::bail!( |         anyhow::bail!( | ||||||
|             "{} info failed, status: {}, stderr: {}", |             "{} info failed, status: {}, stderr: {}", | ||||||
|             constants::QEMU_IMAGE_PROGRAM, |             constants::PROGRAM_QEMU_IMAGE, | ||||||
|             output.status, |             output.status, | ||||||
|             String::from_utf8_lossy(&output.stderr) |             String::from_utf8_lossy(&output.stderr) | ||||||
|         ); |         ); | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | pub mod cloud_init_utils; | ||||||
| pub mod exec_utils; | pub mod exec_utils; | ||||||
| pub mod file_disks_utils; | pub mod file_disks_utils; | ||||||
| pub mod file_size_utils; | pub mod file_size_utils; | ||||||
|   | |||||||
| @@ -145,13 +145,13 @@ struct IPBridgeInfo { | |||||||
|  |  | ||||||
| /// Get the list of bridge interfaces | /// Get the list of bridge interfaces | ||||||
| pub fn bridges_list() -> anyhow::Result<Vec<String>> { | 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"]); |     cmd.args(["-json", "link", "show", "type", "bridge"]); | ||||||
|     let output = cmd.output()?; |     let output = cmd.output()?; | ||||||
|     if !output.status.success() { |     if !output.status.success() { | ||||||
|         anyhow::bail!( |         anyhow::bail!( | ||||||
|             "{} failed, status: {}, stderr: {}", |             "{} failed, status: {}, stderr: {}", | ||||||
|             constants::IP_PROGRAM, |             constants::PROGRAM_IP, | ||||||
|             output.status, |             output.status, | ||||||
|             String::from_utf8_lossy(&output.stderr) |             String::from_utf8_lossy(&output.stderr) | ||||||
|         ); |         ); | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | use chrono::Datelike; | ||||||
| use std::time::{SystemTime, UNIX_EPOCH}; | use std::time::{SystemTime, UNIX_EPOCH}; | ||||||
|  |  | ||||||
| /// Get the current time since epoch | /// Get the current time since epoch | ||||||
| @@ -13,3 +14,15 @@ pub fn time() -> u64 { | |||||||
|         .unwrap() |         .unwrap() | ||||||
|         .as_secs() |         .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 |     /// When creating a new disk, specify the disk image template to use | ||||||
|     #[serde(skip_serializing_if = "Option::is_none")] |     #[serde(skip_serializing_if = "Option::is_none")] | ||||||
|     pub from_image: Option<String>, |     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 |     /// Set this variable to true to delete the disk | ||||||
|     pub delete: bool, |     pub delete: bool, | ||||||
| } | } | ||||||
| @@ -78,6 +81,7 @@ impl VMFileDisk { | |||||||
|  |  | ||||||
|             delete: false, |             delete: false, | ||||||
|             from_image: None, |             from_image: None, | ||||||
|  |             resize: None, | ||||||
|         }) |         }) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -144,28 +148,40 @@ impl VMFileDisk { | |||||||
|  |  | ||||||
|         if file.exists() { |         if file.exists() { | ||||||
|             log::debug!("File {file:?} does not exists, so it was not touched"); |             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 { |         // Resize disk file if requested | ||||||
|             VMDiskFormat::Raw { is_sparse } => DiskFileFormat::Raw { is_sparse }, |         if self.resize == Some(true) { | ||||||
|             VMDiskFormat::QCow2 => DiskFileFormat::QCow2 { |             let disk = DiskFileInfo::load_file(&file)?; | ||||||
|                 virtual_size: self.size, |  | ||||||
|             }, |  | ||||||
|         }; |  | ||||||
|  |  | ||||||
|         // Create / Restore disk file |             // Can only increase disk size | ||||||
|         match &self.from_image { |             if let Err(e) = disk.resize(self.size) { | ||||||
|             // Create disk file |                 log::error!("Failed to resize disk file {}: {e:?}", self.name); | ||||||
|             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)?; |  | ||||||
|             } |             } | ||||||
|         } |         } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -5,9 +5,9 @@ | |||||||
| sudo apt install libvirt-dev | sudo apt install libvirt-dev | ||||||
| ``` | ``` | ||||||
|  |  | ||||||
| 2. Libvirt must also be installed: | 2. Libvirt and cloud image utilities must also be installed: | ||||||
| ```bash | ```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: | 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. | 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 | ## 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 | ```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 | ## Dedicated user | ||||||
|   | |||||||
							
								
								
									
										1100
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1100
									
								
								virtweb_frontend/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -11,42 +11,46 @@ | |||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@emotion/react": "^11.14.0", |     "@emotion/react": "^11.14.0", | ||||||
|     "@emotion/styled": "^11.14.0", |     "@emotion/styled": "^11.14.1", | ||||||
|     "@fontsource/roboto": "^5.2.5", |     "@fontsource/roboto": "^5.2.6", | ||||||
|     "@mdi/js": "^7.4.47", |     "@mdi/js": "^7.4.47", | ||||||
|     "@mdi/react": "^1.6.1", |     "@mdi/react": "^1.6.1", | ||||||
|     "@mui/icons-material": "^7.1.0", |     "@monaco-editor/react": "^4.7.0", | ||||||
|     "@mui/material": "^7.1.0", |     "@mui/icons-material": "^7.2.0", | ||||||
|  |     "@mui/material": "^7.2.0", | ||||||
|     "@mui/x-charts": "^8.3.1", |     "@mui/x-charts": "^8.3.1", | ||||||
|     "@mui/x-data-grid": "^8.3.1", |     "@mui/x-data-grid": "^8.9.1", | ||||||
|     "date-and-time": "^3.6.0", |     "date-and-time": "^3.6.0", | ||||||
|     "filesize": "^10.1.6", |     "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": "^19.1.0", | ||||||
|     "react-dom": "^19.1.0", |     "react-dom": "^19.1.0", | ||||||
|     "react-router-dom": "^7.6.0", |     "react-router-dom": "^7.7.1", | ||||||
|     "react-syntax-highlighter": "^15.6.1", |     "react-syntax-highlighter": "^15.6.1", | ||||||
|     "react-vnc": "^3.1.0", |     "react-vnc": "^3.1.0", | ||||||
|     "uuid": "^11.1.0", |     "uuid": "^11.1.0", | ||||||
|     "xml-formatter": "^3.6.6" |     "xml-formatter": "^3.6.6", | ||||||
|  |     "yaml": "^2.8.0" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@eslint/js": "^9.27.0", |     "@eslint/js": "^9.27.0", | ||||||
|     "@types/humanize-duration": "^3.27.4", |     "@types/humanize-duration": "^3.27.4", | ||||||
|     "@types/jest": "^29.5.14", |     "@types/jest": "^29.5.14", | ||||||
|     "@types/react": "^19.1.5", |     "@types/react": "^19.1.8", | ||||||
|     "@types/react-dom": "^19.1.5", |     "@types/react-dom": "^19.1.6", | ||||||
|     "@types/react-syntax-highlighter": "^15.5.13", |     "@types/react-syntax-highlighter": "^15.5.13", | ||||||
|     "@types/uuid": "^10.0.0", |     "@types/uuid": "^10.0.0", | ||||||
|     "@vitejs/plugin-react": "^4.4.1", |     "@vitejs/plugin-react": "^4.7.0", | ||||||
|     "eslint": "^9.27.0", |     "eslint": "^9.32.0", | ||||||
|     "eslint-plugin-react-dom": "^1.49.0", |     "eslint-plugin-react-dom": "^1.52.3", | ||||||
|     "eslint-plugin-react-hooks": "^5.1.0", |     "eslint-plugin-react-hooks": "^5.2.0", | ||||||
|     "eslint-plugin-react-refresh": "^0.4.20", |     "eslint-plugin-react-refresh": "^0.4.20", | ||||||
|     "eslint-plugin-react-x": "^1.49.0", |     "eslint-plugin-react-x": "^1.52.3", | ||||||
|     "globals": "^16.1.0", |     "globals": "^16.3.0", | ||||||
|     "typescript": "^5.8.3", |     "typescript": "^5.8.3", | ||||||
|     "typescript-eslint": "^8.32.1", |     "typescript-eslint": "^8.38.0", | ||||||
|     "vite": "^6.3.5" |     "vite": "^6.3.5" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,8 +4,10 @@ import { VMFileDisk, VMInfo } from "./VMApi"; | |||||||
| export type DiskImageFormat = | export type DiskImageFormat = | ||||||
|   | { format: "Raw"; is_sparse: boolean } |   | { format: "Raw"; is_sparse: boolean } | ||||||
|   | { format: "QCow2"; virtual_size?: number } |   | { format: "QCow2"; virtual_size?: number } | ||||||
|   | { format: "CompressedQCow2" } |   | { format: "GzCompressedQCow2" } | ||||||
|   | { format: "CompressedRaw" }; |   | { format: "GzCompressedRaw" } | ||||||
|  |   | { format: "XzCompressedQCow2" } | ||||||
|  |   | { format: "XzCompressedRaw" }; | ||||||
|  |  | ||||||
| export type DiskImage = { | export type DiskImage = { | ||||||
|   file_size: number; |   file_size: number; | ||||||
|   | |||||||
| @@ -232,4 +232,16 @@ export class ServerApi { | |||||||
|       }) |       }) | ||||||
|     ).data; |     ).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 |   // For new disk only | ||||||
|   from_image?: string; |   from_image?: string; | ||||||
|  |  | ||||||
|  |   // Resize disk image after clone | ||||||
|  |   resize?: boolean; | ||||||
|  |  | ||||||
|   // application attributes |   // application attributes | ||||||
|   new?: boolean; |   new?: boolean; | ||||||
|  |   originalSize?: number; | ||||||
|   deleteType?: "keepfile" | "deletefile"; |   deleteType?: "keepfile" | "deletefile"; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -82,6 +86,15 @@ export interface VMNetBridge { | |||||||
|   bridge: string; |   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"; | export type VMBootType = "UEFI" | "UEFISecureBoot" | "Legacy"; | ||||||
|  |  | ||||||
| interface VMInfoInterface { | interface VMInfoInterface { | ||||||
| @@ -101,6 +114,7 @@ interface VMInfoInterface { | |||||||
|   networks: VMNetInterface[]; |   networks: VMNetInterface[]; | ||||||
|   tpm_module: boolean; |   tpm_module: boolean; | ||||||
|   oem_strings: string[]; |   oem_strings: string[]; | ||||||
|  |   cloud_init: VMCloudInit; | ||||||
| } | } | ||||||
|  |  | ||||||
| export class VMInfo implements VMInfoInterface { | export class VMInfo implements VMInfoInterface { | ||||||
| @@ -120,6 +134,7 @@ export class VMInfo implements VMInfoInterface { | |||||||
|   networks: VMNetInterface[]; |   networks: VMNetInterface[]; | ||||||
|   tpm_module: boolean; |   tpm_module: boolean; | ||||||
|   oem_strings: string[]; |   oem_strings: string[]; | ||||||
|  |   cloud_init: VMCloudInit; | ||||||
|  |  | ||||||
|   constructor(int: VMInfoInterface) { |   constructor(int: VMInfoInterface) { | ||||||
|     this.name = int.name; |     this.name = int.name; | ||||||
| @@ -138,6 +153,7 @@ export class VMInfo implements VMInfoInterface { | |||||||
|     this.networks = int.networks; |     this.networks = int.networks; | ||||||
|     this.tpm_module = int.tpm_module; |     this.tpm_module = int.tpm_module; | ||||||
|     this.oem_strings = int.oem_strings; |     this.oem_strings = int.oem_strings; | ||||||
|  |     this.cloud_init = int.cloud_init; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   static NewEmpty(): VMInfo { |   static NewEmpty(): VMInfo { | ||||||
| @@ -153,6 +169,7 @@ export class VMInfo implements VMInfoInterface { | |||||||
|       networks: [], |       networks: [], | ||||||
|       tpm_module: true, |       tpm_module: true, | ||||||
|       oem_strings: [], |       oem_strings: [], | ||||||
|  |       cloud_init: { attach_config: false, user_data: "" }, | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -42,13 +42,15 @@ export function ConvertDiskImageDialog( | |||||||
|     setFormat({ format: value ?? ("QCow2" as any) }); |     setFormat({ format: value ?? ("QCow2" as any) }); | ||||||
|  |  | ||||||
|     if (value === "QCow2") setFilename(`${origFilename}.qcow2`); |     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") { |     if (value === "Raw") { | ||||||
|       setFilename(`${origFilename}.raw`); |       setFilename(`${origFilename}.raw`); | ||||||
|       // Check sparse checkbox by default |       // Check sparse checkbox by default | ||||||
|       setFormat({ format: "Raw", is_sparse: true }); |       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 () => { |   const handleSubmit = async () => { | ||||||
| @@ -104,8 +106,10 @@ export function ConvertDiskImageDialog( | |||||||
|           options={[ |           options={[ | ||||||
|             { value: "QCow2" }, |             { value: "QCow2" }, | ||||||
|             { value: "Raw" }, |             { value: "Raw" }, | ||||||
|             { value: "CompressedRaw" }, |             { value: "GzCompressedRaw" }, | ||||||
|             { value: "CompressedQCow2" }, |             { value: "XzCompressedRaw" }, | ||||||
|  |             { value: "GzCompressedQCow2" }, | ||||||
|  |             { value: "XzCompressedQCow2" }, | ||||||
|           ]} |           ]} | ||||||
|         /> |         /> | ||||||
|  |  | ||||||
|   | |||||||
| @@ -3,16 +3,44 @@ import "@fontsource/roboto/400.css"; | |||||||
| import "@fontsource/roboto/500.css"; | import "@fontsource/roboto/500.css"; | ||||||
| import "@fontsource/roboto/700.css"; | import "@fontsource/roboto/700.css"; | ||||||
|  |  | ||||||
|  | import { ThemeProvider, createTheme } from "@mui/material"; | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import ReactDOM from "react-dom/client"; | import ReactDOM from "react-dom/client"; | ||||||
| import { App } from "./App"; | 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 "./index.css"; | ||||||
| import { LoadServerConfig } from "./widgets/LoadServerConfig"; | import { LoadServerConfig } from "./widgets/LoadServerConfig"; | ||||||
| import { ThemeProvider, createTheme } from "@mui/material"; |  | ||||||
| import { LoadingMessageProvider } from "./hooks/providers/LoadingMessageProvider"; | import { loader } from "@monaco-editor/react"; | ||||||
| import { AlertDialogProvider } from "./hooks/providers/AlertDialogProvider"; | import * as monaco from "monaco-editor"; | ||||||
| import { SnackbarProvider } from "./hooks/providers/SnackbarProvider"; | import EditorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; | ||||||
| import { ConfirmDialogProvider } from "./hooks/providers/ConfirmDialogProvider"; | 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({ | const darkTheme = createTheme({ | ||||||
|   palette: { |   palette: { | ||||||
| @@ -20,9 +48,7 @@ const darkTheme = createTheme({ | |||||||
|   }, |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const root = ReactDOM.createRoot( | const root = ReactDOM.createRoot(document.getElementById("root")!); | ||||||
|   document.getElementById("root")! |  | ||||||
| ); |  | ||||||
| root.render( | root.render( | ||||||
|   <React.StrictMode> |   <React.StrictMode> | ||||||
|     <ThemeProvider theme={darkTheme}> |     <ThemeProvider theme={darkTheme}> | ||||||
|   | |||||||
| @@ -9,18 +9,21 @@ import { | |||||||
| import Icon from "@mdi/react"; | import Icon from "@mdi/react"; | ||||||
| import { | import { | ||||||
|   Box, |   Box, | ||||||
|  |   IconButton, | ||||||
|   LinearProgress, |   LinearProgress, | ||||||
|   Table, |   Table, | ||||||
|   TableBody, |   TableBody, | ||||||
|   TableCell, |   TableCell, | ||||||
|   TableHead, |   TableHead, | ||||||
|   TableRow, |   TableRow, | ||||||
|  |   Tooltip, | ||||||
|   Typography, |   Typography, | ||||||
| } from "@mui/material"; | } from "@mui/material"; | ||||||
| import Grid from "@mui/material/Grid"; | import Grid from "@mui/material/Grid"; | ||||||
| import { PieChart } from "@mui/x-charts"; | import { PieChart } from "@mui/x-charts"; | ||||||
| import { filesize } from "filesize"; | import { filesize } from "filesize"; | ||||||
| import humanizeDuration from "humanize-duration"; | import humanizeDuration from "humanize-duration"; | ||||||
|  | import IosShareIcon from "@mui/icons-material/IosShare"; | ||||||
| import React from "react"; | import React from "react"; | ||||||
| import { | import { | ||||||
|   DiskInfo, |   DiskInfo, | ||||||
| @@ -31,6 +34,8 @@ import { | |||||||
| import { AsyncWidget } from "../widgets/AsyncWidget"; | import { AsyncWidget } from "../widgets/AsyncWidget"; | ||||||
| import { VirtWebPaper } from "../widgets/VirtWebPaper"; | import { VirtWebPaper } from "../widgets/VirtWebPaper"; | ||||||
| import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; | import { VirtWebRouteContainer } from "../widgets/VirtWebRouteContainer"; | ||||||
|  | import { useLoadingMessage } from "../hooks/providers/LoadingMessageProvider"; | ||||||
|  | import { useAlert } from "../hooks/providers/AlertDialogProvider"; | ||||||
|  |  | ||||||
| export function SysInfoRoute(): React.ReactElement { | export function SysInfoRoute(): React.ReactElement { | ||||||
|   const [info, setInfo] = React.useState<ServerSystemInfo>(); |   const [info, setInfo] = React.useState<ServerSystemInfo>(); | ||||||
| @@ -52,6 +57,23 @@ export function SysInfoRoute(): React.ReactElement { | |||||||
| export function SysInfoRouteInner(p: { | export function SysInfoRouteInner(p: { | ||||||
|   info: ServerSystemInfo; |   info: ServerSystemInfo; | ||||||
| }): React.ReactElement { | }): 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( |   const sumDiskUsage = p.info.disks.reduce( | ||||||
|     (prev, disk) => { |     (prev, disk) => { | ||||||
|       return { |       return { | ||||||
| @@ -63,7 +85,16 @@ export function SysInfoRouteInner(p: { | |||||||
|   ); |   ); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <VirtWebRouteContainer label="Sysinfo"> |     <VirtWebRouteContainer | ||||||
|  |       label="Sysinfo" | ||||||
|  |       actions={ | ||||||
|  |         <Tooltip title="Export all server configs"> | ||||||
|  |           <IconButton onClick={downloadAllConfig}> | ||||||
|  |             <IosShareIcon /> | ||||||
|  |           </IconButton> | ||||||
|  |         </Tooltip> | ||||||
|  |       } | ||||||
|  |     > | ||||||
|       <Grid container spacing={2}> |       <Grid container spacing={2}> | ||||||
|         {/* Memory */} |         {/* Memory */} | ||||||
|         <Grid size={{ xs: 4 }}> |         <Grid size={{ xs: 4 }}> | ||||||
| @@ -288,7 +319,7 @@ function DiskDetailsTable(p: { disks: DiskInfo[] }): React.ReactElement { | |||||||
|           {p.disks.map((e, c) => ( |           {p.disks.map((e, c) => ( | ||||||
|             <TableRow hover key={c}> |             <TableRow hover key={c}> | ||||||
|               <TableCell>{e.name}</TableCell> |               <TableCell>{e.name}</TableCell> | ||||||
|               <TableCell>{e.DiskKind}</TableCell> |               <TableCell>{String(e.DiskKind)}</TableCell> | ||||||
|               <TableCell>{e.mount_point}</TableCell> |               <TableCell>{e.mount_point}</TableCell> | ||||||
|               <TableCell>{filesize(e.total_space)}</TableCell> |               <TableCell>{filesize(e.total_space)}</TableCell> | ||||||
|               <TableCell>{filesize(e.available_space)}</TableCell> |               <TableCell>{filesize(e.available_space)}</TableCell> | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import VisibilityIcon from '@mui/icons-material/Visibility'; | import VisibilityIcon from "@mui/icons-material/Visibility"; | ||||||
| import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; | import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; | ||||||
| import { | import { | ||||||
|   Alert, |   Alert, | ||||||
|   CircularProgress, |   CircularProgress, | ||||||
| @@ -36,7 +36,9 @@ export function LoginRoute(): React.ReactElement { | |||||||
|   const canSubmit = username.length > 0 && password.length > 0; |   const canSubmit = username.length > 0 && password.length > 0; | ||||||
|  |  | ||||||
|   const [showPassword, setShowPassword] = React.useState(false); |   const [showPassword, setShowPassword] = React.useState(false); | ||||||
|   const handleClickShowPassword = () => { setShowPassword((show) => !show); }; |   const handleClickShowPassword = () => { | ||||||
|  |     setShowPassword((show) => !show); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|   const handleMouseDownPassword = ( |   const handleMouseDownPassword = ( | ||||||
|     event: React.MouseEvent<HTMLButtonElement> |     event: React.MouseEvent<HTMLButtonElement> | ||||||
| @@ -105,12 +107,14 @@ export function LoginRoute(): React.ReactElement { | |||||||
|               label="Username" |               label="Username" | ||||||
|               name="username" |               name="username" | ||||||
|               value={username} |               value={username} | ||||||
|               onChange={(e) => { setUsername(e.target.value); }} |               onChange={(e) => { | ||||||
|  |                 setUsername(e.target.value); | ||||||
|  |               }} | ||||||
|               autoComplete="username" |               autoComplete="username" | ||||||
|               autoFocus |               autoFocus | ||||||
|             /> |             /> | ||||||
|  |  | ||||||
|             <FormControl fullWidth variant="outlined"> |             <FormControl required fullWidth variant="outlined"> | ||||||
|               <InputLabel htmlFor="password">Password</InputLabel> |               <InputLabel htmlFor="password">Password</InputLabel> | ||||||
|               <OutlinedInput |               <OutlinedInput | ||||||
|                 required |                 required | ||||||
| @@ -120,7 +124,9 @@ export function LoginRoute(): React.ReactElement { | |||||||
|                 type={showPassword ? "text" : "password"} |                 type={showPassword ? "text" : "password"} | ||||||
|                 id="password" |                 id="password" | ||||||
|                 value={password} |                 value={password} | ||||||
|                 onChange={(e) => { setPassword(e.target.value); }} |                 onChange={(e) => { | ||||||
|  |                   setPassword(e.target.value); | ||||||
|  |                 }} | ||||||
|                 autoComplete="current-password" |                 autoComplete="current-password" | ||||||
|                 endAdornment={ |                 endAdornment={ | ||||||
|                   <InputAdornment position="end"> |                   <InputAdornment position="end"> | ||||||
| @@ -131,7 +137,11 @@ export function LoginRoute(): React.ReactElement { | |||||||
|                         onMouseDown={handleMouseDownPassword} |                         onMouseDown={handleMouseDownPassword} | ||||||
|                         edge="end" |                         edge="end" | ||||||
|                       > |                       > | ||||||
|                         {showPassword ? <VisibilityOffIcon /> : <VisibilityIcon />} |                         {showPassword ? ( | ||||||
|  |                           <VisibilityOffIcon /> | ||||||
|  |                         ) : ( | ||||||
|  |                           <VisibilityIcon /> | ||||||
|  |                         )} | ||||||
|                       </IconButton> |                       </IconButton> | ||||||
|                     </Tooltip> |                     </Tooltip> | ||||||
|                   </InputAdornment> |                   </InputAdornment> | ||||||
|   | |||||||
| @@ -17,7 +17,9 @@ export function CheckboxInput(p: { | |||||||
|         <Checkbox |         <Checkbox | ||||||
|           disabled={!p.editable} |           disabled={!p.editable} | ||||||
|           checked={p.checked} |           checked={p.checked} | ||||||
|           onChange={(e) => { p.onValueChange(e.target.checked); }} |           onChange={(e) => { | ||||||
|  |             p.onValueChange(e.target.checked); | ||||||
|  |           }} | ||||||
|         /> |         /> | ||||||
|       } |       } | ||||||
|       label={p.label} |       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", |               display: "flex", | ||||||
|               justifyContent: "space-between", |               justifyContent: "space-between", | ||||||
|               alignItems: "center", |               alignItems: "center", | ||||||
|  |               marginBottom: "15px", | ||||||
|             }} |             }} | ||||||
|           > |           > | ||||||
|             {p.title && ( |             {p.title && <Typography variant="h5">{p.title}</Typography>} | ||||||
|               <Typography variant="h5" style={{ marginBottom: "15px" }}> |  | ||||||
|                 {p.title} |  | ||||||
|               </Typography> |  | ||||||
|             )} |  | ||||||
|             {p.actions} |             {p.actions} | ||||||
|           </span> |           </span> | ||||||
|         )} |         )} | ||||||
|   | |||||||
| @@ -25,6 +25,8 @@ export function OEMStringFormWidget(p: { | |||||||
|     p.onChange?.(); |     p.onChange?.(); | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|  |   if (!p.editable && p.vm.oem_strings.length === 0) return <></>; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <EditSection |     <EditSection | ||||||
|       title="SMBIOS OEM Strings" |       title="SMBIOS OEM Strings" | ||||||
|   | |||||||
| @@ -18,6 +18,7 @@ export function TextInput(p: { | |||||||
|   style?: React.CSSProperties; |   style?: React.CSSProperties; | ||||||
|   helperText?: string; |   helperText?: string; | ||||||
|   disabled?: boolean; |   disabled?: boolean; | ||||||
|  |   endAdornment?: React.ReactNode; | ||||||
| }): React.ReactElement { | }): React.ReactElement { | ||||||
|   if (!p.editable && (p.value ?? "") === "") return <></>; |   if (!p.editable && (p.value ?? "") === "") return <></>; | ||||||
|  |  | ||||||
| @@ -51,6 +52,7 @@ export function TextInput(p: { | |||||||
|         input: { |         input: { | ||||||
|           readOnly: !p.editable, |           readOnly: !p.editable, | ||||||
|           type: p.type, |           type: p.type, | ||||||
|  |           endAdornment: p.endAdornment, | ||||||
|         }, |         }, | ||||||
|       }} |       }} | ||||||
|       variant={"standard"} |       variant={"standard"} | ||||||
|   | |||||||
| @@ -2,7 +2,8 @@ import { mdiHarddiskPlus } from "@mdi/js"; | |||||||
| import Icon from "@mdi/react"; | import Icon from "@mdi/react"; | ||||||
| import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | import CheckCircleIcon from "@mui/icons-material/CheckCircle"; | ||||||
| import DeleteIcon from "@mui/icons-material/Delete"; | 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 React from "react"; | ||||||
| import { DiskImage } from "../../api/DiskImageApi"; | import { DiskImage } from "../../api/DiskImageApi"; | ||||||
| import { ServerApi } from "../../api/ServerApi"; | import { ServerApi } from "../../api/ServerApi"; | ||||||
| @@ -13,6 +14,7 @@ import { VMDiskFileWidget } from "../vms/VMDiskFileWidget"; | |||||||
| import { CheckboxInput } from "./CheckboxInput"; | import { CheckboxInput } from "./CheckboxInput"; | ||||||
| import { DiskBusSelect } from "./DiskBusSelect"; | import { DiskBusSelect } from "./DiskBusSelect"; | ||||||
| import { DiskImageSelect } from "./DiskImageSelect"; | import { DiskImageSelect } from "./DiskImageSelect"; | ||||||
|  | import { DiskSizeInput } from "./DiskSizeInput"; | ||||||
| import { SelectInput } from "./SelectInput"; | import { SelectInput } from "./SelectInput"; | ||||||
| import { TextInput } from "./TextInput"; | 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>} |       {p.editable && <Button onClick={addNewDisk}>Add new disk</Button>} | ||||||
|  |  | ||||||
|       {/* Disk backup */} |       {/* Disk backup */} | ||||||
| @@ -93,6 +101,19 @@ function DiskInfo(p: { | |||||||
|   diskImagesList: DiskImage[]; |   diskImagesList: DiskImage[]; | ||||||
| }): React.ReactElement { | }): React.ReactElement { | ||||||
|   const confirm = useConfirm(); |   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 () => { |   const deleteDisk = async () => { | ||||||
|     if (p.disk.deleteType) { |     if (p.disk.deleteType) { | ||||||
|       p.disk.deleteType = undefined; |       p.disk.deleteType = undefined; | ||||||
| @@ -115,42 +136,75 @@ function DiskInfo(p: { | |||||||
|  |  | ||||||
|   if (!p.editable || !p.disk.new) |   if (!p.editable || !p.disk.new) | ||||||
|     return ( |     return ( | ||||||
|       <VMDiskFileWidget |       <> | ||||||
|         {...p} |         <VMDiskFileWidget | ||||||
|         secondaryAction={ |           {...p} | ||||||
|           <> |           secondaryAction={ | ||||||
|             {p.editable && ( |             <> | ||||||
|               <IconButton |               {p.editable && !p.disk.deleteType && ( | ||||||
|                 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 |                 <IconButton | ||||||
|                   onClick={() => { |                   edge="end" | ||||||
|                     p.onRequestBackup(p.disk); |                   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> |                 </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 ( |   return ( | ||||||
| @@ -212,24 +266,32 @@ function DiskInfo(p: { | |||||||
|         /> |         /> | ||||||
|       )} |       )} | ||||||
|  |  | ||||||
|       <TextInput |       {/* Resize disk image */} | ||||||
|         editable={true} |       {!!p.disk.from_image && ( | ||||||
|         label="Disk size (GB)" |         <CheckboxInput | ||||||
|         size={{ |           editable | ||||||
|           min: |           checked={p.disk.resize} | ||||||
|             ServerApi.Config.constraints.disk_size.min / (1000 * 1000 * 1000), |           label="Resize disk file" | ||||||
|           max: |           onValueChange={(v) => { | ||||||
|             ServerApi.Config.constraints.disk_size.max / (1000 * 1000 * 1000), |             p.disk.resize = v; | ||||||
|         }} |             p.onChange?.(); | ||||||
|         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} |  | ||||||
|       /> |  | ||||||
|  |  | ||||||
|  |       {/* 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 |       <DiskImageSelect | ||||||
|         label="Use disk image as template" |         label="Use disk image as template" | ||||||
|         list={p.diskImagesList} |         list={p.diskImagesList} | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ import { | |||||||
|   ListItemAvatar, |   ListItemAvatar, | ||||||
|   ListItemText, |   ListItemText, | ||||||
|   Tooltip, |   Tooltip, | ||||||
|  |   Typography, | ||||||
| } from "@mui/material"; | } from "@mui/material"; | ||||||
| import Grid from "@mui/material/Grid"; | import Grid from "@mui/material/Grid"; | ||||||
| import { NWFilter } from "../../api/NWFilterApi"; | import { NWFilter } from "../../api/NWFilterApi"; | ||||||
| @@ -49,6 +50,12 @@ export function VMNetworksList(p: { | |||||||
|         </div> |         </div> | ||||||
|       )} |       )} | ||||||
|  |  | ||||||
|  |       {p.vm.networks.length === 0 && ( | ||||||
|  |         <Typography style={{ textAlign: "center", paddingTop: "25px" }}> | ||||||
|  |           No network interface defined yet! | ||||||
|  |         </Typography> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|       <Grid container spacing={2}> |       <Grid container spacing={2}> | ||||||
|         {/* networks list */} |         {/* networks list */} | ||||||
|         {p.vm.networks.map((n, num) => ( |         {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 XML definition</TableCell> | ||||||
|               <TableCell align="center">Get autostart</TableCell> |               <TableCell align="center">Get autostart</TableCell> | ||||||
|               <TableCell align="center">Set autostart</TableCell> |               <TableCell align="center">Set autostart</TableCell> | ||||||
|  |               <TableCell align="center">Get CloudInit disk</TableCell> | ||||||
|               <TableCell align="center">Backup disk</TableCell> |               <TableCell align="center">Backup disk</TableCell> | ||||||
|             </TableRow> |             </TableRow> | ||||||
|           </TableHead> |           </TableHead> | ||||||
| @@ -84,6 +85,13 @@ export function TokenRightsEditor(p: { | |||||||
|                 {...p} |                 {...p} | ||||||
|                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} |                 right={{ verb: "PUT", path: "/api/vm/*/autostart" }} | ||||||
|               /> |               /> | ||||||
|  |               <CellRight | ||||||
|  |                 {...p} | ||||||
|  |                 right={{ | ||||||
|  |                   verb: "GET", | ||||||
|  |                   path: "/api/vm/*/cloud_init_disk", | ||||||
|  |                 }} | ||||||
|  |               /> | ||||||
|               <CellRight |               <CellRight | ||||||
|                 {...p} |                 {...p} | ||||||
|                 right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} |                 right={{ verb: "POST", path: "/api/vm/*/disk/*/backup" }} | ||||||
| @@ -123,7 +131,15 @@ export function TokenRightsEditor(p: { | |||||||
|                   {...p} |                   {...p} | ||||||
|                   right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} |                   right={{ verb: "PUT", path: `/api/vm/${v.uuid}/autostart` }} | ||||||
|                   parent={{ verb: "PUT", path: "/api/vm/*/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 |                 <CellRight | ||||||
|                   {...p} |                   {...p} | ||||||
|                   right={{ |                   right={{ | ||||||
| @@ -783,6 +799,11 @@ export function TokenRightsEditor(p: { | |||||||
|           right={{ verb: "GET", path: "/api/server/bridges" }} |           right={{ verb: "GET", path: "/api/server/bridges" }} | ||||||
|           label="Get list of network bridges" |           label="Get list of network bridges" | ||||||
|         /> |         /> | ||||||
|  |         <RouteRight | ||||||
|  |           {...p} | ||||||
|  |           right={{ verb: "GET", path: "/api/server/export_configs" }} | ||||||
|  |           label="Export all configurations" | ||||||
|  |         /> | ||||||
|       </RightsSection> |       </RightsSection> | ||||||
|     </> |     </> | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import Grid from "@mui/material/Grid"; | |||||||
| import React from "react"; | import React from "react"; | ||||||
| import { useNavigate } from "react-router-dom"; | import { useNavigate } from "react-router-dom"; | ||||||
| import { validate as validateUUID } from "uuid"; | import { validate as validateUUID } from "uuid"; | ||||||
|  | import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; | ||||||
| import { GroupApi } from "../../api/GroupApi"; | import { GroupApi } from "../../api/GroupApi"; | ||||||
| import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; | import { IsoFile, IsoFilesApi } from "../../api/IsoFilesApi"; | ||||||
| import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; | import { NWFilter, NWFilterApi } from "../../api/NWFilterApi"; | ||||||
| @@ -18,6 +19,7 @@ import { AsyncWidget } from "../AsyncWidget"; | |||||||
| import { TabsWidget } from "../TabsWidget"; | import { TabsWidget } from "../TabsWidget"; | ||||||
| import { XMLAsyncWidget } from "../XMLWidget"; | import { XMLAsyncWidget } from "../XMLWidget"; | ||||||
| import { CheckboxInput } from "../forms/CheckboxInput"; | import { CheckboxInput } from "../forms/CheckboxInput"; | ||||||
|  | import { CloudInitEditor } from "../forms/CloudInitEditor"; | ||||||
| import { EditSection } from "../forms/EditSection"; | import { EditSection } from "../forms/EditSection"; | ||||||
| import { OEMStringFormWidget } from "../forms/OEMStringFormWidget"; | import { OEMStringFormWidget } from "../forms/OEMStringFormWidget"; | ||||||
| import { ResAutostartInput } from "../forms/ResAutostartInput"; | import { ResAutostartInput } from "../forms/ResAutostartInput"; | ||||||
| @@ -27,7 +29,6 @@ import { VMDisksList } from "../forms/VMDisksList"; | |||||||
| import { VMNetworksList } from "../forms/VMNetworksList"; | import { VMNetworksList } from "../forms/VMNetworksList"; | ||||||
| import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; | import { VMSelectIsoInput } from "../forms/VMSelectIsoInput"; | ||||||
| import { VMScreenshot } from "./VMScreenshot"; | import { VMScreenshot } from "./VMScreenshot"; | ||||||
| import { DiskImage, DiskImageApi } from "../../api/DiskImageApi"; |  | ||||||
|  |  | ||||||
| interface DetailsProps { | interface DetailsProps { | ||||||
|   vm: VMInfo; |   vm: VMInfo; | ||||||
| @@ -89,6 +90,7 @@ enum VMTab { | |||||||
|   General = 0, |   General = 0, | ||||||
|   Storage, |   Storage, | ||||||
|   Network, |   Network, | ||||||
|  |   CloudInit, | ||||||
|   Advanced, |   Advanced, | ||||||
|   XML, |   XML, | ||||||
|   Danger, |   Danger, | ||||||
| @@ -116,6 +118,11 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | |||||||
|           { label: "General", value: VMTab.General, visible: true }, |           { label: "General", value: VMTab.General, visible: true }, | ||||||
|           { label: "Storage", value: VMTab.Storage, visible: true }, |           { label: "Storage", value: VMTab.Storage, visible: true }, | ||||||
|           { label: "Network", value: VMTab.Network, 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 }, |           { label: "Avanced", value: VMTab.Advanced, visible: true }, | ||||||
|  |  | ||||||
|           { |           { | ||||||
| @@ -135,6 +142,7 @@ function VMDetailsInner(p: DetailsInnerProps): React.ReactElement { | |||||||
|       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} |       {currTab === VMTab.General && <VMDetailsTabGeneral {...p} />} | ||||||
|       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} |       {currTab === VMTab.Storage && <VMDetailsTabStorage {...p} />} | ||||||
|       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} |       {currTab === VMTab.Network && <VMDetailsTabNetwork {...p} />} | ||||||
|  |       {currTab === VMTab.CloudInit && <VMDetailsTabCloudInit {...p} />} | ||||||
|       {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />} |       {currTab === VMTab.Advanced && <VMDetailsTabAdvanced {...p} />} | ||||||
|       {currTab === VMTab.XML && <VMDetailsTabXML {...p} />} |       {currTab === VMTab.XML && <VMDetailsTabXML {...p} />} | ||||||
|       {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} |       {currTab === VMTab.Danger && <VMDetailsTabDanger {...p} />} | ||||||
| @@ -381,6 +389,10 @@ function VMDetailsTabNetwork(p: DetailsInnerProps): React.ReactElement { | |||||||
|   return <VMNetworksList {...p} />; |   return <VMNetworksList {...p} />; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function VMDetailsTabCloudInit(p: DetailsInnerProps): React.ReactElement { | ||||||
|  |   return <CloudInitEditor {...p} />; | ||||||
|  | } | ||||||
|  |  | ||||||
| function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement { | function VMDetailsTabAdvanced(p: DetailsInnerProps): React.ReactElement { | ||||||
|   return ( |   return ( | ||||||
|     <Grid container spacing={2}> |     <Grid container spacing={2}> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user