From: Aaron Lauterer <a.lauterer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [RFC installer 2/6] add proxmox-auto-installer
Date: Tue, 5 Sep 2023 15:28:28 +0200 [thread overview]
Message-ID: <20230905132832.3179097-3-a.lauterer@proxmox.com> (raw)
In-Reply-To: <20230905132832.3179097-1-a.lauterer@proxmox.com>
The auto installer, in this first iteration expects an answer file on
stdin. It needs to be in TOML format.
It then translates the information from the answer file into the JSON
needed for the low level installer. Basically all options that can be
chosen during a normal installation (GUI/TUI) can be configured in the
answer file.
Additionally it is possible to select the NIC and disks by matching them
against UDEV device properties. For example, if one wants to use a NIC
with a specific MAC address. Or the disks for the OS can be matched
against a vendor or model number.
It supports basic globbing/wildcard is supported at the beginning and
end of the search string. The matching is implemented by us as it isn't
that difficult and we can avoid additional crates.
The answer file has options to configure commands to be run pre- and
post-installation. The idea is that one could for example clean up the
disks or send a status update to some dashboard, or modify the
installation further before rebooting.
Technically it is reusing a lot of the TUI installer. All the source
files needed are in the 'tui' subdirectory. The idea is, that we can
factor out common code into a dedicated library crate. To make it
easier, unused parts are removed.
Some changes were made as well, for example changing HashMaps to
BTreeMaps to avoid random ordering. Some structs got their properties
made public, but with a refactor, we can probably rework that and
implement additional From methods.
For the tests, I used the information from one of our benchmark servers
to have a realistic starting point.
Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
---
I am not too happy that the json files created by the low level
installer are currently all stored as is in the test directory. E.g.
locales, iso-info and so forth. Those could probably be created on
demand. While the others, run-env-{info,udev}, need to be stable for the
tests to work as expected.
Cargo.toml | 1 +
proxmox-auto-installer/Cargo.toml | 13 +
proxmox-auto-installer/answer.toml | 36 ++
.../resources/test/iso-info.json | 1 +
.../resources/test/locales.json | 1 +
.../test/parse_answer/disk_match.json | 28 ++
.../test/parse_answer/disk_match.toml | 14 +
.../test/parse_answer/disk_match_all.json | 25 +
.../test/parse_answer/disk_match_all.toml | 16 +
.../test/parse_answer/disk_match_any.json | 32 ++
.../test/parse_answer/disk_match_any.toml | 16 +
.../resources/test/parse_answer/minimal.json | 17 +
.../resources/test/parse_answer/minimal.toml | 14 +
.../test/parse_answer/nic_matching.json | 17 +
.../test/parse_answer/nic_matching.toml | 19 +
.../resources/test/parse_answer/readme | 4 +
.../test/parse_answer/specific_nic.json | 17 +
.../test/parse_answer/specific_nic.toml | 19 +
.../resources/test/parse_answer/zfs.json | 26 +
.../resources/test/parse_answer/zfs.toml | 19 +
.../resources/test/run-env-info.json | 1 +
.../resources/test/run-env-udev.json | 1 +
proxmox-auto-installer/src/answer.rs | 144 ++++++
proxmox-auto-installer/src/main.rs | 412 ++++++++++++++++
proxmox-auto-installer/src/tui/mod.rs | 3 +
proxmox-auto-installer/src/tui/options.rs | 302 ++++++++++++
proxmox-auto-installer/src/tui/setup.rs | 447 ++++++++++++++++++
proxmox-auto-installer/src/tui/utils.rs | 268 +++++++++++
proxmox-auto-installer/src/udevinfo.rs | 9 +
proxmox-auto-installer/src/utils.rs | 325 +++++++++++++
30 files changed, 2247 insertions(+)
create mode 100644 proxmox-auto-installer/Cargo.toml
create mode 100644 proxmox-auto-installer/answer.toml
create mode 100644 proxmox-auto-installer/resources/test/iso-info.json
create mode 100644 proxmox-auto-installer/resources/test/locales.json
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match.json
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match.toml
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_all.json
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_all.toml
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_any.json
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_any.toml
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/minimal.json
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/minimal.toml
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/nic_matching.json
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/nic_matching.toml
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/readme
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/specific_nic.json
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/specific_nic.toml
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/zfs.json
create mode 100644 proxmox-auto-installer/resources/test/parse_answer/zfs.toml
create mode 100644 proxmox-auto-installer/resources/test/run-env-info.json
create mode 100644 proxmox-auto-installer/resources/test/run-env-udev.json
create mode 100644 proxmox-auto-installer/src/answer.rs
create mode 100644 proxmox-auto-installer/src/main.rs
create mode 100644 proxmox-auto-installer/src/tui/mod.rs
create mode 100644 proxmox-auto-installer/src/tui/options.rs
create mode 100644 proxmox-auto-installer/src/tui/setup.rs
create mode 100644 proxmox-auto-installer/src/tui/utils.rs
create mode 100644 proxmox-auto-installer/src/udevinfo.rs
create mode 100644 proxmox-auto-installer/src/utils.rs
diff --git a/Cargo.toml b/Cargo.toml
index fd151ba..a942636 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,5 +1,6 @@
[workspace]
members = [
+ "proxmox-auto-installer",
"proxmox-tui-installer",
]
diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Cargo.toml
new file mode 100644
index 0000000..fd38d28
--- /dev/null
+++ b/proxmox-auto-installer/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "proxmox-auto-installer"
+version = "0.1.0"
+edition = "2021"
+authors = [ "Aaron Lauerer <a.lauterer@proxmox.com" ]
+license = "AGPL-3"
+exclude = [ "build", "debian" ]
+homepage = "https://www.proxmox.com"
+
+[dependencies]
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+toml = "0.5.11"
diff --git a/proxmox-auto-installer/answer.toml b/proxmox-auto-installer/answer.toml
new file mode 100644
index 0000000..1033d1c
--- /dev/null
+++ b/proxmox-auto-installer/answer.toml
@@ -0,0 +1,36 @@
+# example answer file for reference
+
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pve.intern"
+mailto = "mail@example.invalid"
+timezone = "Europe/Vienna"
+password = "123456"
+pre_command = [ "sgdisk -Z /dev/sd*" ] # maybe run commands before calling installer to clean up some stuff?
+post_command = [ "wget http://setup/done" ] # maybe give the option to do some things afterwards? Like sending an HTTP request to indicate that the setup is done?
+
+[network]
+use_dhcp = true # optional makes everything else obsolete in this section
+cidr = "10.9.9.240/24"
+dns = "10.9.9.2"
+gateway = "10.9.9.1"
+nic = "enp6s0" # takes precedence over filter
+filter.ID_NET_NAME = "enp6s18"
+#filter.ID_VENDOR_FROM_DATABASE = "Realtek*"
+
+[disks]
+filesystem = "zfs-raid1"
+disk_selection = ["sda", "sdb"]
+filter_match = "any" # "all" as other option, default is "any"
+filter.ID_SERIAL = "*_SN850X_*"
+zfs.ashift = 12
+zfs.checksum = "on"
+zfs.compress = "lz4"
+zfs.copies = 2
+#lvm.hdsize = 80.0
+#lvm.swapsize = 6
+#lvm.maxroot = 20
+#lvm.maxvz = 50
+#lvm.minfree = 4
+#btrfs.hdsize = 40
diff --git a/proxmox-auto-installer/resources/test/iso-info.json b/proxmox-auto-installer/resources/test/iso-info.json
new file mode 100644
index 0000000..33cb79b
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/iso-info.json
@@ -0,0 +1 @@
+{"iso-info":{"isoname":"proxmox-ve","isorelease":"2","product":"pve","productlong":"Proxmox VE","release":"8.0"},"locations":{"iso":"/cdrom","lib":"/var/lib/proxmox-installer","pkg":"/cdrom/proxmox/packages/","run":"/run/proxmox-installer"},"product":"pve","product-cfg":{"bridged_network":1,"enable_btrfs":1,"fullname":"Proxmox VE","port":"8006","product":"pve"},"run-env-cache-file":"/run/proxmox-installer/run-env-info.json"}
diff --git a/proxmox-auto-installer/resources/test/locales.json b/proxmox-auto-installer/resources/test/locales.json
new file mode 100644
index 0000000..220a18c
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/locales.json
@@ -0,0 +1 @@
+{"cczones":{"ad":{"Europe/Andorra":1},"ae":{"Asia/Dubai":1},"af":{"Asia/Kabul":1},"ag":{"America/Antigua":1},"ai":{"America/Anguilla":1},"al":{"Europe/Tirane":1},"am":{"Asia/Yerevan":1},"ao":{"Africa/Luanda":1},"aq":{"Antarctica/Casey":1,"Antarctica/Davis":1,"Antarctica/DumontDUrville":1,"Antarctica/Mawson":1,"Antarctica/McMurdo":1,"Antarctica/Palmer":1,"Antarctica/Rothera":1,"Antarctica/Syowa":1,"Antarctica/Troll":1,"Antarctica/Vostok":1},"ar":{"America/Argentina/Buenos_Aires":1,"America/Argentina/Catamarca":1,"America/Argentina/Cordoba":1,"America/Argentina/Jujuy":1,"America/Argentina/La_Rioja":1,"America/Argentina/Mendoza":1,"America/Argentina/Rio_Gallegos":1,"America/Argentina/Salta":1,"America/Argentina/San_Juan":1,"America/Argentina/San_Luis":1,"America/Argentina/Tucuman":1,"America/Argentina/Ushuaia":1},"as":{"Pacific/Pago_Pago":1},"at":{"Europe/Vienna":1},"au":{"Antarctica/Macquarie":1,"Australia/Adelaide":1,"Australia/Brisbane":1,"Australia/Broken_Hill":1,"Australia/Darwin":1,"Australia/Eucla":1,"Australia/Hobart":1,"Australia/Lindeman":1,"Australia/Lord_Howe":1,"Australia/Melbourne":1,"Australia/Perth":1,"Australia/Sydney":1},"aw":{"America/Aruba":1},"ax":{"Europe/Mariehamn":1},"az":{"Asia/Baku":1},"ba":{"Europe/Sarajevo":1},"bb":{"America/Barbados":1},"bd":{"Asia/Dhaka":1},"be":{"Europe/Brussels":1},"bf":{"Africa/Ouagadougou":1},"bg":{"Europe/Sofia":1},"bh":{"Asia/Bahrain":1},"bi":{"Africa/Bujumbura":1},"bj":{"Africa/Porto-Novo":1},"bl":{"America/St_Barthelemy":1},"bm":{"Atlantic/Bermuda":1},"bn":{"Asia/Brunei":1},"bo":{"America/La_Paz":1},"bq":{"America/Kralendijk":1},"br":{"America/Araguaina":1,"America/Bahia":1,"America/Belem":1,"America/Boa_Vista":1,"America/Campo_Grande":1,"America/Cuiaba":1,"America/Eirunepe":1,"America/Fortaleza":1,"America/Maceio":1,"America/Manaus":1,"America/Noronha":1,"America/Porto_Velho":1,"America/Recife":1,"America/Rio_Branco":1,"America/Santarem":1,"America/Sao_Paulo":1},"bs":{"America/Nassau":1},"bt":{"Asia/Thimphu":1},"bw":{"Africa/Gaborone":1},"by":{"Europe/Minsk":1},"bz":{"America/Belize":1},"ca":{"America/Atikokan":1,"America/Blanc-Sablon":1,"America/Cambridge_Bay":1,"America/Creston":1,"America/Dawson":1,"America/Dawson_Creek":1,"America/Edmonton":1,"America/Fort_Nelson":1,"America/Glace_Bay":1,"America/Goose_Bay":1,"America/Halifax":1,"America/Inuvik":1,"America/Iqaluit":1,"America/Moncton":1,"America/Rankin_Inlet":1,"America/Regina":1,"America/Resolute":1,"America/St_Johns":1,"America/Swift_Current":1,"America/Toronto":1,"America/Vancouver":1,"America/Whitehorse":1,"America/Winnipeg":1},"cc":{"Indian/Cocos":1},"cd":{"Africa/Kinshasa":1,"Africa/Lubumbashi":1},"cf":{"Africa/Bangui":1},"cg":{"Africa/Brazzaville":1},"ch":{"Europe/Zurich":1},"ci":{"Africa/Abidjan":1},"ck":{"Pacific/Rarotonga":1},"cl":{"America/Punta_Arenas":1,"America/Santiago":1,"Pacific/Easter":1},"cm":{"Africa/Douala":1},"cn":{"Asia/Shanghai":1,"Asia/Urumqi":1},"co":{"America/Bogota":1},"cr":{"America/Costa_Rica":1},"cu":{"America/Havana":1},"cv":{"Atlantic/Cape_Verde":1},"cw":{"America/Curacao":1},"cx":{"Indian/Christmas":1},"cy":{"Asia/Famagusta":1,"Asia/Nicosia":1},"cz":{"Europe/Prague":1},"de":{"Europe/Berlin":1,"Europe/Busingen":1},"dj":{"Africa/Djibouti":1},"dk":{"Europe/Copenhagen":1},"dm":{"America/Dominica":1},"do":{"America/Santo_Domingo":1},"dz":{"Africa/Algiers":1},"ec":{"America/Guayaquil":1,"Pacific/Galapagos":1},"ee":{"Europe/Tallinn":1},"eg":{"Africa/Cairo":1},"eh":{"Africa/El_Aaiun":1},"er":{"Africa/Asmara":1},"es":{"Africa/Ceuta":1,"Atlantic/Canary":1,"Europe/Madrid":1},"et":{"Africa/Addis_Ababa":1},"fi":{"Europe/Helsinki":1},"fj":{"Pacific/Fiji":1},"fk":{"Atlantic/Stanley":1},"fm":{"Pacific/Chuuk":1,"Pacific/Kosrae":1,"Pacific/Pohnpei":1},"fo":{"Atlantic/Faroe":1},"fr":{"Europe/Paris":1},"ga":{"Africa/Libreville":1},"gb":{"Europe/London":1},"gd":{"America/Grenada":1},"ge":{"Asia/Tbilisi":1},"gf":{"America/Cayenne":1},"gg":{"Europe/Guernsey":1},"gh":{"Africa/Accra":1},"gi":{"Europe/Gibraltar":1},"gl":{"America/Danmarkshavn":1,"America/Nuuk":1,"America/Scoresbysund":1,"America/Thule":1},"gm":{"Africa/Banjul":1},"gn":{"Africa/Conakry":1},"gp":{"America/Guadeloupe":1},"gq":{"Africa/Malabo":1},"gr":{"Europe/Athens":1},"gs":{"Atlantic/South_Georgia":1},"gt":{"America/Guatemala":1},"gu":{"Pacific/Guam":1},"gw":{"Africa/Bissau":1},"gy":{"America/Guyana":1},"hk":{"Asia/Hong_Kong":1},"hn":{"America/Tegucigalpa":1},"hr":{"Europe/Zagreb":1},"ht":{"America/Port-au-Prince":1},"hu":{"Europe/Budapest":1},"id":{"Asia/Jakarta":1,"Asia/Jayapura":1,"Asia/Makassar":1,"Asia/Pontianak":1},"ie":{"Europe/Dublin":1},"il":{"Asia/Jerusalem":1},"im":{"Europe/Isle_of_Man":1},"in":{"Asia/Kolkata":1},"io":{"Indian/Chagos":1},"iq":{"Asia/Baghdad":1},"ir":{"Asia/Tehran":1},"is":{"Atlantic/Reykjavik":1},"it":{"Europe/Rome":1},"je":{"Europe/Jersey":1},"jm":{"America/Jamaica":1},"jo":{"Asia/Amman":1},"jp":{"Asia/Tokyo":1},"ke":{"Africa/Nairobi":1},"kg":{"Asia/Bishkek":1},"kh":{"Asia/Phnom_Penh":1},"ki":{"Pacific/Kanton":1,"Pacific/Kiritimati":1,"Pacific/Tarawa":1},"km":{"Indian/Comoro":1},"kn":{"America/St_Kitts":1},"kp":{"Asia/Pyongyang":1},"kr":{"Asia/Seoul":1},"kw":{"Asia/Kuwait":1},"ky":{"America/Cayman":1},"kz":{"Asia/Almaty":1,"Asia/Aqtau":1,"Asia/Aqtobe":1,"Asia/Atyrau":1,"Asia/Oral":1,"Asia/Qostanay":1,"Asia/Qyzylorda":1},"la":{"Asia/Vientiane":1},"lb":{"Asia/Beirut":1},"lc":{"America/St_Lucia":1},"li":{"Europe/Vaduz":1},"lk":{"Asia/Colombo":1},"lr":{"Africa/Monrovia":1},"ls":{"Africa/Maseru":1},"lt":{"Europe/Vilnius":1},"lu":{"Europe/Luxembourg":1},"lv":{"Europe/Riga":1},"ly":{"Africa/Tripoli":1},"ma":{"Africa/Casablanca":1},"mc":{"Europe/Monaco":1},"md":{"Europe/Chisinau":1},"me":{"Europe/Podgorica":1},"mf":{"America/Marigot":1},"mg":{"Indian/Antananarivo":1},"mh":{"Pacific/Kwajalein":1,"Pacific/Majuro":1},"mk":{"Europe/Skopje":1},"ml":{"Africa/Bamako":1},"mm":{"Asia/Yangon":1},"mn":{"Asia/Choibalsan":1,"Asia/Hovd":1,"Asia/Ulaanbaatar":1},"mo":{"Asia/Macau":1},"mp":{"Pacific/Saipan":1},"mq":{"America/Martinique":1},"mr":{"Africa/Nouakchott":1},"ms":{"America/Montserrat":1},"mt":{"Europe/Malta":1},"mu":{"Indian/Mauritius":1},"mv":{"Indian/Maldives":1},"mw":{"Africa/Blantyre":1},"mx":{"America/Bahia_Banderas":1,"America/Cancun":1,"America/Chihuahua":1,"America/Ciudad_Juarez":1,"America/Hermosillo":1,"America/Matamoros":1,"America/Mazatlan":1,"America/Merida":1,"America/Mexico_City":1,"America/Monterrey":1,"America/Ojinaga":1,"America/Tijuana":1},"my":{"Asia/Kuala_Lumpur":1,"Asia/Kuching":1},"mz":{"Africa/Maputo":1},"na":{"Africa/Windhoek":1},"nc":{"Pacific/Noumea":1},"ne":{"Africa/Niamey":1},"nf":{"Pacific/Norfolk":1},"ng":{"Africa/Lagos":1},"ni":{"America/Managua":1},"nl":{"Europe/Amsterdam":1},"no":{"Europe/Oslo":1},"np":{"Asia/Kathmandu":1},"nr":{"Pacific/Nauru":1},"nu":{"Pacific/Niue":1},"nz":{"Pacific/Auckland":1,"Pacific/Chatham":1},"om":{"Asia/Muscat":1},"pa":{"America/Panama":1},"pe":{"America/Lima":1},"pf":{"Pacific/Gambier":1,"Pacific/Marquesas":1,"Pacific/Tahiti":1},"pg":{"Pacific/Bougainville":1,"Pacific/Port_Moresby":1},"ph":{"Asia/Manila":1},"pk":{"Asia/Karachi":1},"pl":{"Europe/Warsaw":1},"pm":{"America/Miquelon":1},"pn":{"Pacific/Pitcairn":1},"pr":{"America/Puerto_Rico":1},"ps":{"Asia/Gaza":1,"Asia/Hebron":1},"pt":{"Atlantic/Azores":1,"Atlantic/Madeira":1,"Europe/Lisbon":1},"pw":{"Pacific/Palau":1},"py":{"America/Asuncion":1},"qa":{"Asia/Qatar":1},"re":{"Indian/Reunion":1},"ro":{"Europe/Bucharest":1},"rs":{"Europe/Belgrade":1},"ru":{"Asia/Anadyr":1,"Asia/Barnaul":1,"Asia/Chita":1,"Asia/Irkutsk":1,"Asia/Kamchatka":1,"Asia/Khandyga":1,"Asia/Krasnoyarsk":1,"Asia/Magadan":1,"Asia/Novokuznetsk":1,"Asia/Novosibirsk":1,"Asia/Omsk":1,"Asia/Sakhalin":1,"Asia/Srednekolymsk":1,"Asia/Tomsk":1,"Asia/Ust-Nera":1,"Asia/Vladivostok":1,"Asia/Yakutsk":1,"Asia/Yekaterinburg":1,"Europe/Astrakhan":1,"Europe/Kaliningrad":1,"Europe/Kirov":1,"Europe/Moscow":1,"Europe/Samara":1,"Europe/Saratov":1,"Europe/Ulyanovsk":1,"Europe/Volgograd":1},"rw":{"Africa/Kigali":1},"sa":{"Asia/Riyadh":1},"sb":{"Pacific/Guadalcanal":1},"sc":{"Indian/Mahe":1},"sd":{"Africa/Khartoum":1},"se":{"Europe/Stockholm":1},"sg":{"Asia/Singapore":1},"sh":{"Atlantic/St_Helena":1},"si":{"Europe/Ljubljana":1},"sj":{"Arctic/Longyearbyen":1},"sk":{"Europe/Bratislava":1},"sl":{"Africa/Freetown":1},"sm":{"Europe/San_Marino":1},"sn":{"Africa/Dakar":1},"so":{"Africa/Mogadishu":1},"sr":{"America/Paramaribo":1},"ss":{"Africa/Juba":1},"st":{"Africa/Sao_Tome":1},"sv":{"America/El_Salvador":1},"sx":{"America/Lower_Princes":1},"sy":{"Asia/Damascus":1},"sz":{"Africa/Mbabane":1},"tc":{"America/Grand_Turk":1},"td":{"Africa/Ndjamena":1},"tf":{"Indian/Kerguelen":1},"tg":{"Africa/Lome":1},"th":{"Asia/Bangkok":1},"tj":{"Asia/Dushanbe":1},"tk":{"Pacific/Fakaofo":1},"tl":{"Asia/Dili":1},"tm":{"Asia/Ashgabat":1},"tn":{"Africa/Tunis":1},"to":{"Pacific/Tongatapu":1},"tr":{"Europe/Istanbul":1},"tt":{"America/Port_of_Spain":1},"tv":{"Pacific/Funafuti":1},"tw":{"Asia/Taipei":1},"tz":{"Africa/Dar_es_Salaam":1},"ua":{"Europe/Kyiv":1,"Europe/Simferopol":1},"ug":{"Africa/Kampala":1},"um":{"Pacific/Midway":1,"Pacific/Wake":1},"us":{"America/Adak":1,"America/Anchorage":1,"America/Boise":1,"America/Chicago":1,"America/Denver":1,"America/Detroit":1,"America/Indiana/Indianapolis":1,"America/Indiana/Knox":1,"America/Indiana/Marengo":1,"America/Indiana/Petersburg":1,"America/Indiana/Tell_City":1,"America/Indiana/Vevay":1,"America/Indiana/Vincennes":1,"America/Indiana/Winamac":1,"America/Juneau":1,"America/Kentucky/Louisville":1,"America/Kentucky/Monticello":1,"America/Los_Angeles":1,"America/Menominee":1,"America/Metlakatla":1,"America/New_York":1,"America/Nome":1,"America/North_Dakota/Beulah":1,"America/North_Dakota/Center":1,"America/North_Dakota/New_Salem":1,"America/Phoenix":1,"America/Sitka":1,"America/Yakutat":1,"Pacific/Honolulu":1},"uy":{"America/Montevideo":1},"uz":{"Asia/Samarkand":1,"Asia/Tashkent":1},"va":{"Europe/Vatican":1},"vc":{"America/St_Vincent":1},"ve":{"America/Caracas":1},"vg":{"America/Tortola":1},"vi":{"America/St_Thomas":1},"vn":{"Asia/Ho_Chi_Minh":1},"vu":{"Pacific/Efate":1},"wf":{"Pacific/Wallis":1},"ws":{"Pacific/Apia":1},"ye":{"Asia/Aden":1},"yt":{"Indian/Mayotte":1},"za":{"Africa/Johannesburg":1},"zm":{"Africa/Lusaka":1},"zw":{"Africa/Harare":1}},"country":{"ad":{"kmap":"","mirror":"","name":"Andorra","zone":"Europe/Andorra"},"ae":{"kmap":"","mirror":"","name":"United Arab Emirates","zone":"Asia/Dubai"},"af":{"kmap":"","mirror":"","name":"Afghanistan","zone":"Asia/Kabul"},"ag":{"kmap":"","mirror":"","name":"Antigua and Barbuda","zone":"America/Antigua"},"ai":{"kmap":"","mirror":"","name":"Anguilla","zone":"America/Anguilla"},"al":{"kmap":"","mirror":"","name":"Albania","zone":"Europe/Tirane"},"am":{"kmap":"","mirror":"","name":"Armenia","zone":"Asia/Yerevan"},"ao":{"kmap":"","mirror":"","name":"Angola","zone":"Africa/Luanda"},"aq":{"kmap":"","mirror":"","name":"Antarctica","zone":"Antarctica/McMurdo"},"ar":{"kmap":"","mirror":"","name":"Argentina","zone":"America/Argentina/Buenos_Aires"},"as":{"kmap":"","mirror":"","name":"American Samoa","zone":"Pacific/Pago_Pago"},"at":{"kmap":"de","mirror":"ftp.at.debian.org","name":"Austria","zone":"Europe/Vienna"},"au":{"kmap":"","mirror":"ftp.au.debian.org","name":"Australia","zone":"Australia/Lord_Howe"},"aw":{"kmap":"","mirror":"","name":"Aruba","zone":"America/Aruba"},"ax":{"kmap":"","mirror":"","name":"Åland Islands","zone":"Europe/Mariehamn"},"az":{"kmap":"","mirror":"","name":"Azerbaijan","zone":"Asia/Baku"},"ba":{"kmap":"","mirror":"","name":"Bosnia and Herzegovina","zone":"Europe/Sarajevo"},"bb":{"kmap":"","mirror":"","name":"Barbados","zone":"America/Barbados"},"bd":{"kmap":"","mirror":"","name":"Bangladesh","zone":"Asia/Dhaka"},"be":{"kmap":"fr-be","mirror":"ftp.be.debian.org","name":"Belgium","zone":"Europe/Brussels"},"bf":{"kmap":"","mirror":"","name":"Burkina Faso","zone":"Africa/Ouagadougou"},"bg":{"kmap":"","mirror":"ftp.bg.debian.org","name":"Bulgaria","zone":"Europe/Sofia"},"bh":{"kmap":"","mirror":"","name":"Bahrain","zone":"Asia/Bahrain"},"bi":{"kmap":"","mirror":"","name":"Burundi","zone":"Africa/Bujumbura"},"bj":{"kmap":"","mirror":"","name":"Benin","zone":"Africa/Porto-Novo"},"bl":{"kmap":"","mirror":"","name":"Saint Barthélemy","zone":"America/St_Barthelemy"},"bm":{"kmap":"","mirror":"","name":"Bermuda","zone":"Atlantic/Bermuda"},"bn":{"kmap":"","mirror":"","name":"Brunei Darussalam","zone":"Asia/Brunei"},"bo":{"kmap":"","mirror":"","name":"Bolivia","zone":"America/La_Paz"},"bq":{"kmap":"","mirror":"","name":"Bonaire, Sint Eustatius and Saba","zone":"America/Kralendijk"},"br":{"kmap":"pt-br","mirror":"ftp.br.debian.org","name":"Brazil","zone":"America/Noronha"},"bs":{"kmap":"","mirror":"","name":"Bahamas","zone":"America/Nassau"},"bt":{"kmap":"","mirror":"","name":"Bhutan","zone":"Asia/Thimphu"},"bv":{"kmap":"","mirror":"","name":"Bouvet Island"},"bw":{"kmap":"","mirror":"","name":"Botswana","zone":"Africa/Gaborone"},"by":{"kmap":"","mirror":"","name":"Belarus","zone":"Europe/Minsk"},"bz":{"kmap":"","mirror":"","name":"Belize","zone":"America/Belize"},"ca":{"kmap":"en-us","mirror":"ftp.ca.debian.org","name":"Canada","zone":"America/St_Johns"},"cc":{"kmap":"","mirror":"","name":"Cocos (Keeling) Islands","zone":"Indian/Cocos"},"cd":{"kmap":"","mirror":"","name":"Congo, The Democratic Republic of the","zone":"Africa/Kinshasa"},"cf":{"kmap":"","mirror":"","name":"Central African Republic","zone":"Africa/Bangui"},"cg":{"kmap":"","mirror":"","name":"Congo","zone":"Africa/Brazzaville"},"ch":{"kmap":"de-ch","mirror":"ftp.ch.debian.org","name":"Switzerland","zone":"Europe/Zurich"},"ci":{"kmap":"","mirror":"","name":"Côte d'Ivoire","zone":"Africa/Abidjan"},"ck":{"kmap":"","mirror":"","name":"Cook Islands","zone":"Pacific/Rarotonga"},"cl":{"kmap":"","mirror":"ftp.cl.debian.org","name":"Chile","zone":"America/Santiago"},"cm":{"kmap":"","mirror":"","name":"Cameroon","zone":"Africa/Douala"},"cn":{"kmap":"","mirror":"","name":"China","zone":"Asia/Shanghai"},"co":{"kmap":"","mirror":"","name":"Colombia","zone":"America/Bogota"},"cr":{"kmap":"","mirror":"","name":"Costa Rica","zone":"America/Costa_Rica"},"cu":{"kmap":"","mirror":"","name":"Cuba","zone":"America/Havana"},"cv":{"kmap":"","mirror":"","name":"Cabo Verde","zone":"Atlantic/Cape_Verde"},"cw":{"kmap":"","mirror":"","name":"Curaçao","zone":"America/Curacao"},"cx":{"kmap":"","mirror":"","name":"Christmas Island","zone":"Indian/Christmas"},"cy":{"kmap":"","mirror":"","name":"Cyprus","zone":"Asia/Nicosia"},"cz":{"kmap":"","mirror":"ftp.cz.debian.org","name":"Czechia","zone":"Europe/Prague"},"de":{"kmap":"de","mirror":"ftp.de.debian.org","name":"Germany","zone":"Europe/Berlin"},"dj":{"kmap":"","mirror":"","name":"Djibouti","zone":"Africa/Djibouti"},"dk":{"kmap":"dk","mirror":"ftp.dk.debian.org","name":"Denmark","zone":"Europe/Copenhagen"},"dm":{"kmap":"","mirror":"","name":"Dominica","zone":"America/Dominica"},"do":{"kmap":"","mirror":"","name":"Dominican Republic","zone":"America/Santo_Domingo"},"dz":{"kmap":"","mirror":"","name":"Algeria","zone":"Africa/Algiers"},"ec":{"kmap":"","mirror":"","name":"Ecuador","zone":"America/Guayaquil"},"ee":{"kmap":"","mirror":"ftp.ee.debian.org","name":"Estonia","zone":"Europe/Tallinn"},"eg":{"kmap":"","mirror":"","name":"Egypt","zone":"Africa/Cairo"},"eh":{"kmap":"","mirror":"","name":"Western Sahara","zone":"Africa/El_Aaiun"},"er":{"kmap":"","mirror":"","name":"Eritrea","zone":"Africa/Asmara"},"es":{"kmap":"es","mirror":"ftp.es.debian.org","name":"Spain","zone":"Europe/Madrid"},"et":{"kmap":"","mirror":"","name":"Ethiopia","zone":"Africa/Addis_Ababa"},"fi":{"kmap":"fi","mirror":"ftp.fi.debian.org","name":"Finland","zone":"Europe/Helsinki"},"fj":{"kmap":"","mirror":"","name":"Fiji","zone":"Pacific/Fiji"},"fk":{"kmap":"","mirror":"","name":"Falkland Islands (Malvinas)","zone":"Atlantic/Stanley"},"fm":{"kmap":"","mirror":"","name":"Micronesia, Federated States of","zone":"Pacific/Chuuk"},"fo":{"kmap":"","mirror":"","name":"Faroe Islands","zone":"Atlantic/Faroe"},"fr":{"kmap":"fr","mirror":"ftp.fr.debian.org","name":"France","zone":"Europe/Paris"},"ga":{"kmap":"","mirror":"","name":"Gabon","zone":"Africa/Libreville"},"gb":{"kmap":"en-gb","mirror":"ftp.uk.debian.org","name":"United Kingdom","zone":"Europe/London"},"gd":{"kmap":"","mirror":"","name":"Grenada","zone":"America/Grenada"},"ge":{"kmap":"","mirror":"","name":"Georgia","zone":"Asia/Tbilisi"},"gf":{"kmap":"","mirror":"","name":"French Guiana","zone":"America/Cayenne"},"gg":{"kmap":"","mirror":"","name":"Guernsey","zone":"Europe/Guernsey"},"gh":{"kmap":"","mirror":"","name":"Ghana","zone":"Africa/Accra"},"gi":{"kmap":"es","mirror":"","name":"Gibraltar","zone":"Europe/Gibraltar"},"gl":{"kmap":"","mirror":"","name":"Greenland","zone":"America/Nuuk"},"gm":{"kmap":"","mirror":"","name":"Gambia","zone":"Africa/Banjul"},"gn":{"kmap":"","mirror":"","name":"Guinea","zone":"Africa/Conakry"},"gp":{"kmap":"","mirror":"","name":"Guadeloupe","zone":"America/Guadeloupe"},"gq":{"kmap":"","mirror":"","name":"Equatorial Guinea","zone":"Africa/Malabo"},"gr":{"kmap":"","mirror":"ftp.gr.debian.org","name":"Greece","zone":"Europe/Athens"},"gs":{"kmap":"","mirror":"","name":"South Georgia and the South Sandwich Islands","zone":"Atlantic/South_Georgia"},"gt":{"kmap":"","mirror":"","name":"Guatemala","zone":"America/Guatemala"},"gu":{"kmap":"","mirror":"","name":"Guam","zone":"Pacific/Guam"},"gw":{"kmap":"","mirror":"","name":"Guinea-Bissau","zone":"Africa/Bissau"},"gy":{"kmap":"","mirror":"","name":"Guyana","zone":"America/Guyana"},"hk":{"kmap":"","mirror":"ftp.hk.debian.org","name":"Hong Kong","zone":"Asia/Hong_Kong"},"hm":{"kmap":"","mirror":"","name":"Heard Island and McDonald Islands"},"hn":{"kmap":"","mirror":"","name":"Honduras","zone":"America/Tegucigalpa"},"hr":{"kmap":"","mirror":"ftp.hr.debian.org","name":"Croatia","zone":"Europe/Zagreb"},"ht":{"kmap":"","mirror":"","name":"Haiti","zone":"America/Port-au-Prince"},"hu":{"kmap":"hu","mirror":"ftp.hu.debian.org","name":"Hungary","zone":"Europe/Budapest"},"id":{"kmap":"","mirror":"","name":"Indonesia","zone":"Asia/Jakarta"},"ie":{"kmap":"","mirror":"ftp.ie.debian.org","name":"Ireland","zone":"Europe/Dublin"},"il":{"kmap":"","mirror":"","name":"Israel","zone":"Asia/Jerusalem"},"im":{"kmap":"","mirror":"","name":"Isle of Man","zone":"Europe/Isle_of_Man"},"in":{"kmap":"","mirror":"","name":"India","zone":"Asia/Kolkata"},"io":{"kmap":"","mirror":"","name":"British Indian Ocean Territory","zone":"Indian/Chagos"},"iq":{"kmap":"","mirror":"","name":"Iraq","zone":"Asia/Baghdad"},"ir":{"kmap":"","mirror":"","name":"Iran","zone":"Asia/Tehran"},"is":{"kmap":"is","mirror":"ftp.is.debian.org","name":"Iceland","zone":"Atlantic/Reykjavik"},"it":{"kmap":"it","mirror":"ftp.it.debian.org","name":"Italy","zone":"Europe/Rome"},"je":{"kmap":"","mirror":"","name":"Jersey","zone":"Europe/Jersey"},"jm":{"kmap":"","mirror":"","name":"Jamaica","zone":"America/Jamaica"},"jo":{"kmap":"","mirror":"","name":"Jordan","zone":"Asia/Amman"},"jp":{"kmap":"jp","mirror":"ftp.jp.debian.org","name":"Japan","zone":"Asia/Tokyo"},"ke":{"kmap":"","mirror":"","name":"Kenya","zone":"Africa/Nairobi"},"kg":{"kmap":"","mirror":"","name":"Kyrgyzstan","zone":"Asia/Bishkek"},"kh":{"kmap":"","mirror":"","name":"Cambodia","zone":"Asia/Phnom_Penh"},"ki":{"kmap":"","mirror":"","name":"Kiribati","zone":"Pacific/Tarawa"},"km":{"kmap":"","mirror":"","name":"Comoros","zone":"Indian/Comoro"},"kn":{"kmap":"","mirror":"","name":"Saint Kitts and Nevis","zone":"America/St_Kitts"},"kp":{"kmap":"","mirror":"","name":"North Korea","zone":"Asia/Pyongyang"},"kr":{"kmap":"","mirror":"ftp.kr.debian.org","name":"South Korea","zone":"Asia/Seoul"},"kw":{"kmap":"","mirror":"","name":"Kuwait","zone":"Asia/Kuwait"},"ky":{"kmap":"","mirror":"","name":"Cayman Islands","zone":"America/Cayman"},"kz":{"kmap":"","mirror":"","name":"Kazakhstan","zone":"Asia/Almaty"},"la":{"kmap":"","mirror":"","name":"Laos","zone":"Asia/Vientiane"},"lb":{"kmap":"","mirror":"","name":"Lebanon","zone":"Asia/Beirut"},"lc":{"kmap":"","mirror":"","name":"Saint Lucia","zone":"America/St_Lucia"},"li":{"kmap":"de-ch","mirror":"","name":"Liechtenstein","zone":"Europe/Vaduz"},"lk":{"kmap":"","mirror":"","name":"Sri Lanka","zone":"Asia/Colombo"},"lr":{"kmap":"","mirror":"","name":"Liberia","zone":"Africa/Monrovia"},"ls":{"kmap":"","mirror":"","name":"Lesotho","zone":"Africa/Maseru"},"lt":{"kmap":"lt","mirror":"","name":"Lithuania","zone":"Europe/Vilnius"},"lu":{"kmap":"fr-ch","mirror":"","name":"Luxembourg","zone":"Europe/Luxembourg"},"lv":{"kmap":"","mirror":"","name":"Latvia","zone":"Europe/Riga"},"ly":{"kmap":"","mirror":"","name":"Libya","zone":"Africa/Tripoli"},"ma":{"kmap":"","mirror":"","name":"Morocco","zone":"Africa/Casablanca"},"mc":{"kmap":"","mirror":"","name":"Monaco","zone":"Europe/Monaco"},"md":{"kmap":"","mirror":"","name":"Moldova","zone":"Europe/Chisinau"},"me":{"kmap":"","mirror":"","name":"Montenegro","zone":"Europe/Podgorica"},"mf":{"kmap":"","mirror":"","name":"Saint Martin (French part)","zone":"America/Marigot"},"mg":{"kmap":"","mirror":"","name":"Madagascar","zone":"Indian/Antananarivo"},"mh":{"kmap":"","mirror":"","name":"Marshall Islands","zone":"Pacific/Majuro"},"mk":{"kmap":"mk","mirror":"","name":"North Macedonia","zone":"Europe/Skopje"},"ml":{"kmap":"","mirror":"","name":"Mali","zone":"Africa/Bamako"},"mm":{"kmap":"","mirror":"","name":"Myanmar","zone":"Asia/Yangon"},"mn":{"kmap":"","mirror":"","name":"Mongolia","zone":"Asia/Ulaanbaatar"},"mo":{"kmap":"","mirror":"","name":"Macao","zone":"Asia/Macau"},"mp":{"kmap":"","mirror":"","name":"Northern Mariana Islands","zone":"Pacific/Saipan"},"mq":{"kmap":"","mirror":"","name":"Martinique","zone":"America/Martinique"},"mr":{"kmap":"","mirror":"","name":"Mauritania","zone":"Africa/Nouakchott"},"ms":{"kmap":"","mirror":"","name":"Montserrat","zone":"America/Montserrat"},"mt":{"kmap":"","mirror":"","name":"Malta","zone":"Europe/Malta"},"mu":{"kmap":"","mirror":"","name":"Mauritius","zone":"Indian/Mauritius"},"mv":{"kmap":"","mirror":"","name":"Maldives","zone":"Indian/Maldives"},"mw":{"kmap":"","mirror":"","name":"Malawi","zone":"Africa/Blantyre"},"mx":{"kmap":"","mirror":"ftp.mx.debian.org","name":"Mexico","zone":"America/Mexico_City"},"my":{"kmap":"","mirror":"","name":"Malaysia","zone":"Asia/Kuala_Lumpur"},"mz":{"kmap":"","mirror":"","name":"Mozambique","zone":"Africa/Maputo"},"na":{"kmap":"","mirror":"","name":"Namibia","zone":"Africa/Windhoek"},"nc":{"kmap":"","mirror":"","name":"New Caledonia","zone":"Pacific/Noumea"},"ne":{"kmap":"","mirror":"","name":"Niger","zone":"Africa/Niamey"},"nf":{"kmap":"","mirror":"","name":"Norfolk Island","zone":"Pacific/Norfolk"},"ng":{"kmap":"","mirror":"","name":"Nigeria","zone":"Africa/Lagos"},"ni":{"kmap":"","mirror":"","name":"Nicaragua","zone":"America/Managua"},"nl":{"kmap":"en-us","mirror":"ftp.nl.debian.org","name":"Netherlands","zone":"Europe/Amsterdam"},"no":{"kmap":"no","mirror":"ftp.no.debian.org","name":"Norway","zone":"Europe/Oslo"},"np":{"kmap":"","mirror":"","name":"Nepal","zone":"Asia/Kathmandu"},"nr":{"kmap":"","mirror":"","name":"Nauru","zone":"Pacific/Nauru"},"nu":{"kmap":"","mirror":"","name":"Niue","zone":"Pacific/Niue"},"nz":{"kmap":"","mirror":"ftp.nz.debian.org","name":"New Zealand","zone":"Pacific/Auckland"},"om":{"kmap":"","mirror":"","name":"Oman","zone":"Asia/Muscat"},"pa":{"kmap":"","mirror":"","name":"Panama","zone":"America/Panama"},"pe":{"kmap":"","mirror":"","name":"Peru","zone":"America/Lima"},"pf":{"kmap":"","mirror":"","name":"French Polynesia","zone":"Pacific/Tahiti"},"pg":{"kmap":"","mirror":"","name":"Papua New Guinea","zone":"Pacific/Port_Moresby"},"ph":{"kmap":"","mirror":"","name":"Philippines","zone":"Asia/Manila"},"pk":{"kmap":"","mirror":"","name":"Pakistan","zone":"Asia/Karachi"},"pl":{"kmap":"pl","mirror":"ftp.pl.debian.org","name":"Poland","zone":"Europe/Warsaw"},"pm":{"kmap":"","mirror":"","name":"Saint Pierre and Miquelon","zone":"America/Miquelon"},"pn":{"kmap":"","mirror":"","name":"Pitcairn","zone":"Pacific/Pitcairn"},"pr":{"kmap":"","mirror":"","name":"Puerto Rico","zone":"America/Puerto_Rico"},"ps":{"kmap":"","mirror":"","name":"Palestine, State of","zone":"Asia/Gaza"},"pt":{"kmap":"pt","mirror":"ftp.pt.debian.org","name":"Portugal","zone":"Europe/Lisbon"},"pw":{"kmap":"","mirror":"","name":"Palau","zone":"Pacific/Palau"},"py":{"kmap":"","mirror":"","name":"Paraguay","zone":"America/Asuncion"},"qa":{"kmap":"","mirror":"","name":"Qatar","zone":"Asia/Qatar"},"re":{"kmap":"","mirror":"","name":"Réunion","zone":"Indian/Reunion"},"ro":{"kmap":"","mirror":"ftp.ro.debian.org","name":"Romania","zone":"Europe/Bucharest"},"rs":{"kmap":"","mirror":"","name":"Serbia","zone":"Europe/Belgrade"},"ru":{"kmap":"","mirror":"ftp.ru.debian.org","name":"Russian Federation","zone":"Europe/Kaliningrad"},"rw":{"kmap":"","mirror":"","name":"Rwanda","zone":"Africa/Kigali"},"sa":{"kmap":"","mirror":"","name":"Saudi Arabia","zone":"Asia/Riyadh"},"sb":{"kmap":"","mirror":"","name":"Solomon Islands","zone":"Pacific/Guadalcanal"},"sc":{"kmap":"","mirror":"","name":"Seychelles","zone":"Indian/Mahe"},"sd":{"kmap":"","mirror":"","name":"Sudan","zone":"Africa/Khartoum"},"se":{"kmap":"","mirror":"ftp.se.debian.org","name":"Sweden","zone":"Europe/Stockholm"},"sg":{"kmap":"","mirror":"","name":"Singapore","zone":"Asia/Singapore"},"sh":{"kmap":"","mirror":"","name":"Saint Helena, Ascension and Tristan da Cunha","zone":"Atlantic/St_Helena"},"si":{"kmap":"si","mirror":"ftp.si.debian.org","name":"Slovenia","zone":"Europe/Ljubljana"},"sj":{"kmap":"","mirror":"","name":"Svalbard and Jan Mayen","zone":"Arctic/Longyearbyen"},"sk":{"kmap":"","mirror":"ftp.sk.debian.org","name":"Slovakia","zone":"Europe/Bratislava"},"sl":{"kmap":"","mirror":"","name":"Sierra Leone","zone":"Africa/Freetown"},"sm":{"kmap":"","mirror":"","name":"San Marino","zone":"Europe/San_Marino"},"sn":{"kmap":"","mirror":"","name":"Senegal","zone":"Africa/Dakar"},"so":{"kmap":"","mirror":"","name":"Somalia","zone":"Africa/Mogadishu"},"sr":{"kmap":"","mirror":"","name":"Suriname","zone":"America/Paramaribo"},"ss":{"kmap":"","mirror":"","name":"South Sudan","zone":"Africa/Juba"},"st":{"kmap":"","mirror":"","name":"Sao Tome and Principe","zone":"Africa/Sao_Tome"},"sv":{"kmap":"","mirror":"","name":"El Salvador","zone":"America/El_Salvador"},"sx":{"kmap":"","mirror":"","name":"Sint Maarten (Dutch part)","zone":"America/Lower_Princes"},"sy":{"kmap":"","mirror":"","name":"Syria","zone":"Asia/Damascus"},"sz":{"kmap":"","mirror":"","name":"Eswatini","zone":"Africa/Mbabane"},"tc":{"kmap":"","mirror":"","name":"Turks and Caicos Islands","zone":"America/Grand_Turk"},"td":{"kmap":"","mirror":"","name":"Chad","zone":"Africa/Ndjamena"},"tf":{"kmap":"","mirror":"","name":"French Southern Territories","zone":"Indian/Kerguelen"},"tg":{"kmap":"","mirror":"","name":"Togo","zone":"Africa/Lome"},"th":{"kmap":"","mirror":"","name":"Thailand","zone":"Asia/Bangkok"},"tj":{"kmap":"","mirror":"","name":"Tajikistan","zone":"Asia/Dushanbe"},"tk":{"kmap":"","mirror":"","name":"Tokelau","zone":"Pacific/Fakaofo"},"tl":{"kmap":"","mirror":"","name":"Timor-Leste","zone":"Asia/Dili"},"tm":{"kmap":"","mirror":"","name":"Turkmenistan","zone":"Asia/Ashgabat"},"tn":{"kmap":"","mirror":"","name":"Tunisia","zone":"Africa/Tunis"},"to":{"kmap":"","mirror":"","name":"Tonga","zone":"Pacific/Tongatapu"},"tr":{"kmap":"","mirror":"ftp.tr.debian.org","name":"Türkiye","zone":"Europe/Istanbul"},"tt":{"kmap":"","mirror":"","name":"Trinidad and Tobago","zone":"America/Port_of_Spain"},"tv":{"kmap":"","mirror":"","name":"Tuvalu","zone":"Pacific/Funafuti"},"tw":{"kmap":"","mirror":"ftp.tw.debian.org","name":"Taiwan","zone":"Asia/Taipei"},"tz":{"kmap":"","mirror":"","name":"Tanzania","zone":"Africa/Dar_es_Salaam"},"ua":{"kmap":"","mirror":"","name":"Ukraine","zone":"Europe/Simferopol"},"ug":{"kmap":"","mirror":"","name":"Uganda","zone":"Africa/Kampala"},"um":{"kmap":"","mirror":"","name":"United States Minor Outlying Islands","zone":"Pacific/Midway"},"us":{"kmap":"en-us","mirror":"ftp.us.debian.org","name":"United States","zone":"America/New_York"},"uy":{"kmap":"","mirror":"","name":"Uruguay","zone":"America/Montevideo"},"uz":{"kmap":"","mirror":"","name":"Uzbekistan","zone":"Asia/Samarkand"},"va":{"kmap":"it","mirror":"","name":"Holy See (Vatican City State)","zone":"Europe/Vatican"},"vc":{"kmap":"","mirror":"","name":"Saint Vincent and the Grenadines","zone":"America/St_Vincent"},"ve":{"kmap":"","mirror":"","name":"Venezuela","zone":"America/Caracas"},"vg":{"kmap":"","mirror":"","name":"Virgin Islands, British","zone":"America/Tortola"},"vi":{"kmap":"","mirror":"","name":"Virgin Islands, U.S.","zone":"America/St_Thomas"},"vn":{"kmap":"","mirror":"","name":"Vietnam","zone":"Asia/Ho_Chi_Minh"},"vu":{"kmap":"","mirror":"","name":"Vanuatu","zone":"Pacific/Efate"},"wf":{"kmap":"","mirror":"","name":"Wallis and Futuna","zone":"Pacific/Wallis"},"ws":{"kmap":"","mirror":"","name":"Samoa","zone":"Pacific/Apia"},"ye":{"kmap":"","mirror":"","name":"Yemen","zone":"Asia/Aden"},"yt":{"kmap":"","mirror":"","name":"Mayotte","zone":"Indian/Mayotte"},"za":{"kmap":"","mirror":"","name":"South Africa","zone":"Africa/Johannesburg"},"zm":{"kmap":"","mirror":"","name":"Zambia","zone":"Africa/Lusaka"},"zw":{"kmap":"","mirror":"","name":"Zimbabwe","zone":"Africa/Harare"}},"countryhash":{"afghanistan":"af","albania":"al","algeria":"dz","american samoa":"as","andorra":"ad","angola":"ao","anguilla":"ai","antarctica":"aq","antigua and barbuda":"ag","argentina":"ar","armenia":"am","aruba":"aw","australia":"au","austria":"at","azerbaijan":"az","bahamas":"bs","bahrain":"bh","bangladesh":"bd","barbados":"bb","belarus":"by","belgium":"be","belize":"bz","benin":"bj","bermuda":"bm","bhutan":"bt","bolivia":"bo","bonaire, sint eustatius and saba":"bq","bosnia and herzegovina":"ba","botswana":"bw","bouvet island":"bv","brazil":"br","british indian ocean territory":"io","brunei darussalam":"bn","bulgaria":"bg","burkina faso":"bf","burundi":"bi","cabo verde":"cv","cambodia":"kh","cameroon":"cm","canada":"ca","cayman islands":"ky","central african republic":"cf","chad":"td","chile":"cl","china":"cn","christmas island":"cx","cocos (keeling) islands":"cc","colombia":"co","comoros":"km","congo":"cg","congo, the democratic republic of the":"cd","cook islands":"ck","costa rica":"cr","croatia":"hr","cuba":"cu","curaçao":"cw","cyprus":"cy","czechia":"cz","côte d'ivoire":"ci","denmark":"dk","djibouti":"dj","dominica":"dm","dominican republic":"do","ecuador":"ec","egypt":"eg","el salvador":"sv","equatorial guinea":"gq","eritrea":"er","estonia":"ee","eswatini":"sz","ethiopia":"et","falkland islands (malvinas)":"fk","faroe islands":"fo","fiji":"fj","finland":"fi","france":"fr","french guiana":"gf","french polynesia":"pf","french southern territories":"tf","gabon":"ga","gambia":"gm","georgia":"ge","germany":"de","ghana":"gh","gibraltar":"gi","greece":"gr","greenland":"gl","grenada":"gd","guadeloupe":"gp","guam":"gu","guatemala":"gt","guernsey":"gg","guinea":"gn","guinea-bissau":"gw","guyana":"gy","haiti":"ht","heard island and mcdonald islands":"hm","holy see (vatican city state)":"va","honduras":"hn","hong kong":"hk","hungary":"hu","iceland":"is","india":"in","indonesia":"id","iran":"ir","iraq":"iq","ireland":"ie","isle of man":"im","israel":"il","italy":"it","jamaica":"jm","japan":"jp","jersey":"je","jordan":"jo","kazakhstan":"kz","kenya":"ke","kiribati":"ki","kuwait":"kw","kyrgyzstan":"kg","laos":"la","latvia":"lv","lebanon":"lb","lesotho":"ls","liberia":"lr","libya":"ly","liechtenstein":"li","lithuania":"lt","luxembourg":"lu","macao":"mo","madagascar":"mg","malawi":"mw","malaysia":"my","maldives":"mv","mali":"ml","malta":"mt","marshall islands":"mh","martinique":"mq","mauritania":"mr","mauritius":"mu","mayotte":"yt","mexico":"mx","micronesia, federated states of":"fm","moldova":"md","monaco":"mc","mongolia":"mn","montenegro":"me","montserrat":"ms","morocco":"ma","mozambique":"mz","myanmar":"mm","namibia":"na","nauru":"nr","nepal":"np","netherlands":"nl","new caledonia":"nc","new zealand":"nz","nicaragua":"ni","niger":"ne","nigeria":"ng","niue":"nu","norfolk island":"nf","north korea":"kp","north macedonia":"mk","northern mariana islands":"mp","norway":"no","oman":"om","pakistan":"pk","palau":"pw","palestine, state of":"ps","panama":"pa","papua new guinea":"pg","paraguay":"py","peru":"pe","philippines":"ph","pitcairn":"pn","poland":"pl","portugal":"pt","puerto rico":"pr","qatar":"qa","romania":"ro","russian federation":"ru","rwanda":"rw","réunion":"re","saint barthélemy":"bl","saint helena, ascension and tristan da cunha":"sh","saint kitts and nevis":"kn","saint lucia":"lc","saint martin (french part)":"mf","saint pierre and miquelon":"pm","saint vincent and the grenadines":"vc","samoa":"ws","san marino":"sm","sao tome and principe":"st","saudi arabia":"sa","senegal":"sn","serbia":"rs","seychelles":"sc","sierra leone":"sl","singapore":"sg","sint maarten (dutch part)":"sx","slovakia":"sk","slovenia":"si","solomon islands":"sb","somalia":"so","south africa":"za","south georgia and the south sandwich islands":"gs","south korea":"kr","south sudan":"ss","spain":"es","sri lanka":"lk","sudan":"sd","suriname":"sr","svalbard and jan mayen":"sj","sweden":"se","switzerland":"ch","syria":"sy","taiwan":"tw","tajikistan":"tj","tanzania":"tz","thailand":"th","timor-leste":"tl","togo":"tg","tokelau":"tk","tonga":"to","trinidad and tobago":"tt","tunisia":"tn","turkmenistan":"tm","turks and caicos islands":"tc","tuvalu":"tv","türkiye":"tr","uganda":"ug","ukraine":"ua","united arab emirates":"ae","united kingdom":"gb","united states":"us","united states minor outlying islands":"um","uruguay":"uy","uzbekistan":"uz","vanuatu":"vu","venezuela":"ve","vietnam":"vn","virgin islands, british":"vg","virgin islands, u.s.":"vi","wallis and futuna":"wf","western sahara":"eh","yemen":"ye","zambia":"zm","zimbabwe":"zw","åland islands":"ax"},"kmap":{"de":{"console":"qwertz/de-latin1-nodeadkeys.kmap.gz","kvm":"de","name":"German","x11":"de","x11var":"nodeadkeys"},"de-ch":{"console":"qwertz/sg-latin1.kmap.gz","kvm":"de-ch","name":"Swiss-German","x11":"ch","x11var":"de_nodeadkeys"},"dk":{"console":"qwerty/dk-latin1.kmap.gz","kvm":"da","name":"Danish","x11":"dk","x11var":"nodeadkeys"},"en-gb":{"console":"qwerty/uk.kmap.gz","kvm":"en-gb","name":"United Kingdom","x11":"gb","x11var":""},"en-us":{"console":"qwerty/us-latin1.kmap.gz","kvm":"en-us","name":"U.S. English","x11":"us","x11var":""},"es":{"console":"qwerty/es.kmap.gz","kvm":"es","name":"Spanish","x11":"es","x11var":"nodeadkeys"},"fi":{"console":"qwerty/fi-latin1.kmap.gz","kvm":"fi","name":"Finnish","x11":"fi","x11var":"nodeadkeys"},"fr":{"console":"azerty/fr-latin1.kmap.gz","kvm":"fr","name":"French","x11":"fr","x11var":"nodeadkeys"},"fr-be":{"console":"azerty/be2-latin1.kmap.gz","kvm":"fr-be","name":"Belgium-French","x11":"be","x11var":"nodeadkeys"},"fr-ca":{"console":"qwerty/cf.kmap.gz","kvm":"fr-ca","name":"Canada-French","x11":"ca","x11var":"fr-legacy"},"fr-ch":{"console":"qwertz/fr_CH-latin1.kmap.gz","kvm":"fr-ch","name":"Swiss-French","x11":"ch","x11var":"fr_nodeadkeys"},"hu":{"console":"qwertz/hu.kmap.gz","kvm":"hu","name":"Hungarian","x11":"hu","x11var":""},"is":{"console":"qwerty/is-latin1.kmap.gz","kvm":"is","name":"Icelandic","x11":"is","x11var":"nodeadkeys"},"it":{"console":"qwerty/it2.kmap.gz","kvm":"it","name":"Italian","x11":"it","x11var":"nodeadkeys"},"jp":{"console":"qwerty/jp106.kmap.gz","kvm":"ja","name":"Japanese","x11":"jp","x11var":""},"lt":{"console":"qwerty/lt.kmap.gz","kvm":"lt","name":"Lithuanian","x11":"lt","x11var":"std"},"mk":{"console":"qwerty/mk.kmap.gz","kvm":"mk","name":"Macedonian","x11":"mk","x11var":"nodeadkeys"},"nl":{"console":"qwerty/nl.kmap.gz","kvm":"nl","name":"Dutch","x11":"nl","x11var":""},"no":{"console":"qwerty/no-latin1.kmap.gz","kvm":"no","name":"Norwegian","x11":"no","x11var":"nodeadkeys"},"pl":{"console":"qwerty/pl.kmap.gz","kvm":"pl","name":"Polish","x11":"pl","x11var":""},"pt":{"console":"qwerty/pt-latin1.kmap.gz","kvm":"pt","name":"Portuguese","x11":"pt","x11var":"nodeadkeys"},"pt-br":{"console":"qwerty/br-latin1.kmap.gz","kvm":"pt-br","name":"Brazil-Portuguese","x11":"br","x11var":"nodeadkeys"},"se":{"console":"qwerty/se-latin1.kmap.gz","kvm":"sv","name":"Swedish","x11":"se","x11var":"nodeadkeys"},"si":{"console":"qwertz/slovene.kmap.gz","kvm":"sl","name":"Slovenian","x11":"si","x11var":""},"tr":{"console":"qwerty/trq.kmap.gz","kvm":"tr","name":"Turkish","x11":"tr","x11var":""}},"kmaphash":{"Belgium-French":"fr-be","Brazil-Portuguese":"pt-br","Canada-French":"fr-ca","Danish":"dk","Dutch":"nl","Finnish":"fi","French":"fr","German":"de","Hungarian":"hu","Icelandic":"is","Italian":"it","Japanese":"jp","Lithuanian":"lt","Macedonian":"mk","Norwegian":"no","Polish":"pl","Portuguese":"pt","Slovenian":"si","Spanish":"es","Swedish":"se","Swiss-French":"fr-ch","Swiss-German":"de-ch","Turkish":"tr","U.S. English":"en-us","United Kingdom":"en-gb"},"zones":{"Africa/Abidjan":1,"Africa/Accra":1,"Africa/Addis_Ababa":1,"Africa/Algiers":1,"Africa/Asmara":1,"Africa/Bamako":1,"Africa/Bangui":1,"Africa/Banjul":1,"Africa/Bissau":1,"Africa/Blantyre":1,"Africa/Brazzaville":1,"Africa/Bujumbura":1,"Africa/Cairo":1,"Africa/Casablanca":1,"Africa/Ceuta":1,"Africa/Conakry":1,"Africa/Dakar":1,"Africa/Dar_es_Salaam":1,"Africa/Djibouti":1,"Africa/Douala":1,"Africa/El_Aaiun":1,"Africa/Freetown":1,"Africa/Gaborone":1,"Africa/Harare":1,"Africa/Johannesburg":1,"Africa/Juba":1,"Africa/Kampala":1,"Africa/Khartoum":1,"Africa/Kigali":1,"Africa/Kinshasa":1,"Africa/Lagos":1,"Africa/Libreville":1,"Africa/Lome":1,"Africa/Luanda":1,"Africa/Lubumbashi":1,"Africa/Lusaka":1,"Africa/Malabo":1,"Africa/Maputo":1,"Africa/Maseru":1,"Africa/Mbabane":1,"Africa/Mogadishu":1,"Africa/Monrovia":1,"Africa/Nairobi":1,"Africa/Ndjamena":1,"Africa/Niamey":1,"Africa/Nouakchott":1,"Africa/Ouagadougou":1,"Africa/Porto-Novo":1,"Africa/Sao_Tome":1,"Africa/Tripoli":1,"Africa/Tunis":1,"Africa/Windhoek":1,"America/Adak":1,"America/Anchorage":1,"America/Anguilla":1,"America/Antigua":1,"America/Araguaina":1,"America/Argentina/Buenos_Aires":1,"America/Argentina/Catamarca":1,"America/Argentina/Cordoba":1,"America/Argentina/Jujuy":1,"America/Argentina/La_Rioja":1,"America/Argentina/Mendoza":1,"America/Argentina/Rio_Gallegos":1,"America/Argentina/Salta":1,"America/Argentina/San_Juan":1,"America/Argentina/San_Luis":1,"America/Argentina/Tucuman":1,"America/Argentina/Ushuaia":1,"America/Aruba":1,"America/Asuncion":1,"America/Atikokan":1,"America/Bahia":1,"America/Bahia_Banderas":1,"America/Barbados":1,"America/Belem":1,"America/Belize":1,"America/Blanc-Sablon":1,"America/Boa_Vista":1,"America/Bogota":1,"America/Boise":1,"America/Cambridge_Bay":1,"America/Campo_Grande":1,"America/Cancun":1,"America/Caracas":1,"America/Cayenne":1,"America/Cayman":1,"America/Chicago":1,"America/Chihuahua":1,"America/Ciudad_Juarez":1,"America/Costa_Rica":1,"America/Creston":1,"America/Cuiaba":1,"America/Curacao":1,"America/Danmarkshavn":1,"America/Dawson":1,"America/Dawson_Creek":1,"America/Denver":1,"America/Detroit":1,"America/Dominica":1,"America/Edmonton":1,"America/Eirunepe":1,"America/El_Salvador":1,"America/Fort_Nelson":1,"America/Fortaleza":1,"America/Glace_Bay":1,"America/Goose_Bay":1,"America/Grand_Turk":1,"America/Grenada":1,"America/Guadeloupe":1,"America/Guatemala":1,"America/Guayaquil":1,"America/Guyana":1,"America/Halifax":1,"America/Havana":1,"America/Hermosillo":1,"America/Indiana/Indianapolis":1,"America/Indiana/Knox":1,"America/Indiana/Marengo":1,"America/Indiana/Petersburg":1,"America/Indiana/Tell_City":1,"America/Indiana/Vevay":1,"America/Indiana/Vincennes":1,"America/Indiana/Winamac":1,"America/Inuvik":1,"America/Iqaluit":1,"America/Jamaica":1,"America/Juneau":1,"America/Kentucky/Louisville":1,"America/Kentucky/Monticello":1,"America/Kralendijk":1,"America/La_Paz":1,"America/Lima":1,"America/Los_Angeles":1,"America/Lower_Princes":1,"America/Maceio":1,"America/Managua":1,"America/Manaus":1,"America/Marigot":1,"America/Martinique":1,"America/Matamoros":1,"America/Mazatlan":1,"America/Menominee":1,"America/Merida":1,"America/Metlakatla":1,"America/Mexico_City":1,"America/Miquelon":1,"America/Moncton":1,"America/Monterrey":1,"America/Montevideo":1,"America/Montserrat":1,"America/Nassau":1,"America/New_York":1,"America/Nome":1,"America/Noronha":1,"America/North_Dakota/Beulah":1,"America/North_Dakota/Center":1,"America/North_Dakota/New_Salem":1,"America/Nuuk":1,"America/Ojinaga":1,"America/Panama":1,"America/Paramaribo":1,"America/Phoenix":1,"America/Port-au-Prince":1,"America/Port_of_Spain":1,"America/Porto_Velho":1,"America/Puerto_Rico":1,"America/Punta_Arenas":1,"America/Rankin_Inlet":1,"America/Recife":1,"America/Regina":1,"America/Resolute":1,"America/Rio_Branco":1,"America/Santarem":1,"America/Santiago":1,"America/Santo_Domingo":1,"America/Sao_Paulo":1,"America/Scoresbysund":1,"America/Sitka":1,"America/St_Barthelemy":1,"America/St_Johns":1,"America/St_Kitts":1,"America/St_Lucia":1,"America/St_Thomas":1,"America/St_Vincent":1,"America/Swift_Current":1,"America/Tegucigalpa":1,"America/Thule":1,"America/Tijuana":1,"America/Toronto":1,"America/Tortola":1,"America/Vancouver":1,"America/Whitehorse":1,"America/Winnipeg":1,"America/Yakutat":1,"Antarctica/Casey":1,"Antarctica/Davis":1,"Antarctica/DumontDUrville":1,"Antarctica/Macquarie":1,"Antarctica/Mawson":1,"Antarctica/McMurdo":1,"Antarctica/Palmer":1,"Antarctica/Rothera":1,"Antarctica/Syowa":1,"Antarctica/Troll":1,"Antarctica/Vostok":1,"Arctic/Longyearbyen":1,"Asia/Aden":1,"Asia/Almaty":1,"Asia/Amman":1,"Asia/Anadyr":1,"Asia/Aqtau":1,"Asia/Aqtobe":1,"Asia/Ashgabat":1,"Asia/Atyrau":1,"Asia/Baghdad":1,"Asia/Bahrain":1,"Asia/Baku":1,"Asia/Bangkok":1,"Asia/Barnaul":1,"Asia/Beirut":1,"Asia/Bishkek":1,"Asia/Brunei":1,"Asia/Chita":1,"Asia/Choibalsan":1,"Asia/Colombo":1,"Asia/Damascus":1,"Asia/Dhaka":1,"Asia/Dili":1,"Asia/Dubai":1,"Asia/Dushanbe":1,"Asia/Famagusta":1,"Asia/Gaza":1,"Asia/Hebron":1,"Asia/Ho_Chi_Minh":1,"Asia/Hong_Kong":1,"Asia/Hovd":1,"Asia/Irkutsk":1,"Asia/Jakarta":1,"Asia/Jayapura":1,"Asia/Jerusalem":1,"Asia/Kabul":1,"Asia/Kamchatka":1,"Asia/Karachi":1,"Asia/Kathmandu":1,"Asia/Khandyga":1,"Asia/Kolkata":1,"Asia/Krasnoyarsk":1,"Asia/Kuala_Lumpur":1,"Asia/Kuching":1,"Asia/Kuwait":1,"Asia/Macau":1,"Asia/Magadan":1,"Asia/Makassar":1,"Asia/Manila":1,"Asia/Muscat":1,"Asia/Nicosia":1,"Asia/Novokuznetsk":1,"Asia/Novosibirsk":1,"Asia/Omsk":1,"Asia/Oral":1,"Asia/Phnom_Penh":1,"Asia/Pontianak":1,"Asia/Pyongyang":1,"Asia/Qatar":1,"Asia/Qostanay":1,"Asia/Qyzylorda":1,"Asia/Riyadh":1,"Asia/Sakhalin":1,"Asia/Samarkand":1,"Asia/Seoul":1,"Asia/Shanghai":1,"Asia/Singapore":1,"Asia/Srednekolymsk":1,"Asia/Taipei":1,"Asia/Tashkent":1,"Asia/Tbilisi":1,"Asia/Tehran":1,"Asia/Thimphu":1,"Asia/Tokyo":1,"Asia/Tomsk":1,"Asia/Ulaanbaatar":1,"Asia/Urumqi":1,"Asia/Ust-Nera":1,"Asia/Vientiane":1,"Asia/Vladivostok":1,"Asia/Yakutsk":1,"Asia/Yangon":1,"Asia/Yekaterinburg":1,"Asia/Yerevan":1,"Atlantic/Azores":1,"Atlantic/Bermuda":1,"Atlantic/Canary":1,"Atlantic/Cape_Verde":1,"Atlantic/Faroe":1,"Atlantic/Madeira":1,"Atlantic/Reykjavik":1,"Atlantic/South_Georgia":1,"Atlantic/St_Helena":1,"Atlantic/Stanley":1,"Australia/Adelaide":1,"Australia/Brisbane":1,"Australia/Broken_Hill":1,"Australia/Darwin":1,"Australia/Eucla":1,"Australia/Hobart":1,"Australia/Lindeman":1,"Australia/Lord_Howe":1,"Australia/Melbourne":1,"Australia/Perth":1,"Australia/Sydney":1,"Europe/Amsterdam":1,"Europe/Andorra":1,"Europe/Astrakhan":1,"Europe/Athens":1,"Europe/Belgrade":1,"Europe/Berlin":1,"Europe/Bratislava":1,"Europe/Brussels":1,"Europe/Bucharest":1,"Europe/Budapest":1,"Europe/Busingen":1,"Europe/Chisinau":1,"Europe/Copenhagen":1,"Europe/Dublin":1,"Europe/Gibraltar":1,"Europe/Guernsey":1,"Europe/Helsinki":1,"Europe/Isle_of_Man":1,"Europe/Istanbul":1,"Europe/Jersey":1,"Europe/Kaliningrad":1,"Europe/Kirov":1,"Europe/Kyiv":1,"Europe/Lisbon":1,"Europe/Ljubljana":1,"Europe/London":1,"Europe/Luxembourg":1,"Europe/Madrid":1,"Europe/Malta":1,"Europe/Mariehamn":1,"Europe/Minsk":1,"Europe/Monaco":1,"Europe/Moscow":1,"Europe/Oslo":1,"Europe/Paris":1,"Europe/Podgorica":1,"Europe/Prague":1,"Europe/Riga":1,"Europe/Rome":1,"Europe/Samara":1,"Europe/San_Marino":1,"Europe/Sarajevo":1,"Europe/Saratov":1,"Europe/Simferopol":1,"Europe/Skopje":1,"Europe/Sofia":1,"Europe/Stockholm":1,"Europe/Tallinn":1,"Europe/Tirane":1,"Europe/Ulyanovsk":1,"Europe/Vaduz":1,"Europe/Vatican":1,"Europe/Vienna":1,"Europe/Vilnius":1,"Europe/Volgograd":1,"Europe/Warsaw":1,"Europe/Zagreb":1,"Europe/Zurich":1,"Indian/Antananarivo":1,"Indian/Chagos":1,"Indian/Christmas":1,"Indian/Cocos":1,"Indian/Comoro":1,"Indian/Kerguelen":1,"Indian/Mahe":1,"Indian/Maldives":1,"Indian/Mauritius":1,"Indian/Mayotte":1,"Indian/Reunion":1,"Pacific/Apia":1,"Pacific/Auckland":1,"Pacific/Bougainville":1,"Pacific/Chatham":1,"Pacific/Chuuk":1,"Pacific/Easter":1,"Pacific/Efate":1,"Pacific/Fakaofo":1,"Pacific/Fiji":1,"Pacific/Funafuti":1,"Pacific/Galapagos":1,"Pacific/Gambier":1,"Pacific/Guadalcanal":1,"Pacific/Guam":1,"Pacific/Honolulu":1,"Pacific/Kanton":1,"Pacific/Kiritimati":1,"Pacific/Kosrae":1,"Pacific/Kwajalein":1,"Pacific/Majuro":1,"Pacific/Marquesas":1,"Pacific/Midway":1,"Pacific/Nauru":1,"Pacific/Niue":1,"Pacific/Norfolk":1,"Pacific/Noumea":1,"Pacific/Pago_Pago":1,"Pacific/Palau":1,"Pacific/Pitcairn":1,"Pacific/Pohnpei":1,"Pacific/Port_Moresby":1,"Pacific/Rarotonga":1,"Pacific/Saipan":1,"Pacific/Tahiti":1,"Pacific/Tarawa":1,"Pacific/Tongatapu":1,"Pacific/Wake":1,"Pacific/Wallis":1}}
diff --git a/proxmox-auto-installer/resources/test/parse_answer/disk_match.json b/proxmox-auto-installer/resources/test/parse_answer/disk_match.json
new file mode 100644
index 0000000..0966065
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/disk_match.json
@@ -0,0 +1,28 @@
+{
+ "autoreboot": 1,
+ "cidr": "192.168.1.114/24",
+ "country": "at",
+ "dns": "192.168.1.254",
+ "domain": "testinstall",
+ "disk_selection": {
+ "6": "6",
+ "7": "7",
+ "8": "8",
+ "9": "9"
+ },
+ "filesys": "zfs (RAID10)",
+ "gateway": "192.168.1.1",
+ "hdsize": 223.57088470458984,
+ "hostname": "pveauto",
+ "keymap": "de",
+ "mailto": "mail@no.invalid",
+ "mngmt_nic": "eno1",
+ "password": "123456",
+ "timezone": "Europe/Vienna",
+ "zfs_opts": {
+ "ashift": 12,
+ "checksum": "on",
+ "compress": "on",
+ "copies": 1
+ }
+}
diff --git a/proxmox-auto-installer/resources/test/parse_answer/disk_match.toml b/proxmox-auto-installer/resources/test/parse_answer/disk_match.toml
new file mode 100644
index 0000000..796ccc6
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/disk_match.toml
@@ -0,0 +1,14 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+password = "123456"
+
+[network]
+use_dhcp = true
+
+[disks]
+filesystem = "zfs-raid10"
+filter.ID_SERIAL = "*MZ7KM240HAGR*"
diff --git a/proxmox-auto-installer/resources/test/parse_answer/disk_match_all.json b/proxmox-auto-installer/resources/test/parse_answer/disk_match_all.json
new file mode 100644
index 0000000..02328a9
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/disk_match_all.json
@@ -0,0 +1,25 @@
+{
+ "autoreboot": 1,
+ "cidr": "192.168.1.114/24",
+ "country": "at",
+ "dns": "192.168.1.254",
+ "domain": "testinstall",
+ "disk_selection": {
+ "9": "9"
+ },
+ "filesys": "zfs (RAID0)",
+ "gateway": "192.168.1.1",
+ "hdsize": 223.57088470458984,
+ "hostname": "pveauto",
+ "keymap": "de",
+ "mailto": "mail@no.invalid",
+ "mngmt_nic": "eno1",
+ "password": "123456",
+ "timezone": "Europe/Vienna",
+ "zfs_opts": {
+ "ashift": 12,
+ "checksum": "on",
+ "compress": "on",
+ "copies": 1
+ }
+}
diff --git a/proxmox-auto-installer/resources/test/parse_answer/disk_match_all.toml b/proxmox-auto-installer/resources/test/parse_answer/disk_match_all.toml
new file mode 100644
index 0000000..5171153
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/disk_match_all.toml
@@ -0,0 +1,16 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+password = "123456"
+
+[network]
+use_dhcp = true
+
+[disks]
+filesystem = "zfs-raid0"
+filter_match = "all"
+filter.ID_SERIAL = "*MZ7KM240HAGR*"
+filter.ID_SERIAL_SHORT = "S2HRNX0J403419"
diff --git a/proxmox-auto-installer/resources/test/parse_answer/disk_match_any.json b/proxmox-auto-installer/resources/test/parse_answer/disk_match_any.json
new file mode 100644
index 0000000..048376c
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/disk_match_any.json
@@ -0,0 +1,32 @@
+{
+ "autoreboot": 1,
+ "cidr": "192.168.1.114/24",
+ "country": "at",
+ "dns": "192.168.1.254",
+ "domain": "testinstall",
+ "disk_selection": {
+ "0": "0",
+ "1": "1",
+ "2": "2",
+ "3": "3",
+ "6": "6",
+ "7": "7",
+ "8": "8",
+ "9": "9"
+ },
+ "filesys": "zfs (RAID10)",
+ "gateway": "192.168.1.1",
+ "hdsize": 23846.56512451172,
+ "hostname": "pveauto",
+ "keymap": "de",
+ "mailto": "mail@no.invalid",
+ "mngmt_nic": "eno1",
+ "password": "123456",
+ "timezone": "Europe/Vienna",
+ "zfs_opts": {
+ "ashift": 12,
+ "checksum": "on",
+ "compress": "on",
+ "copies": 1
+ }
+}
diff --git a/proxmox-auto-installer/resources/test/parse_answer/disk_match_any.toml b/proxmox-auto-installer/resources/test/parse_answer/disk_match_any.toml
new file mode 100644
index 0000000..df6c88c
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/disk_match_any.toml
@@ -0,0 +1,16 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+password = "123456"
+
+[network]
+use_dhcp = true
+
+[disks]
+filesystem = "zfs-raid10"
+filter_match = "any"
+filter.ID_SERIAL = "*MZ7KM240HAGR*"
+filter.ID_MODEL = "Micron_9300*"
diff --git a/proxmox-auto-installer/resources/test/parse_answer/minimal.json b/proxmox-auto-installer/resources/test/parse_answer/minimal.json
new file mode 100644
index 0000000..9021377
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/minimal.json
@@ -0,0 +1,17 @@
+{
+ "autoreboot": 1,
+ "cidr": "192.168.1.114/24",
+ "country": "at",
+ "dns": "192.168.1.254",
+ "domain": "testinstall",
+ "filesys": "ext4",
+ "gateway": "192.168.1.1",
+ "hdsize": 223.57088470458984,
+ "hostname": "pveauto",
+ "keymap": "de",
+ "mailto": "mail@no.invalid",
+ "mngmt_nic": "eno1",
+ "password": "123456",
+ "target_hd": "/dev/sda",
+ "timezone": "Europe/Vienna"
+}
diff --git a/proxmox-auto-installer/resources/test/parse_answer/minimal.toml b/proxmox-auto-installer/resources/test/parse_answer/minimal.toml
new file mode 100644
index 0000000..a417c00
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/minimal.toml
@@ -0,0 +1,14 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+password = "123456"
+
+[network]
+use_dhcp = true
+
+[disks]
+filesystem = "ext4"
+disk_selection = ["sda"]
diff --git a/proxmox-auto-installer/resources/test/parse_answer/nic_matching.json b/proxmox-auto-installer/resources/test/parse_answer/nic_matching.json
new file mode 100644
index 0000000..6f31079
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/nic_matching.json
@@ -0,0 +1,17 @@
+{
+ "autoreboot": 1,
+ "cidr": "10.10.10.10/24",
+ "country": "at",
+ "dns": "10.10.10.1",
+ "domain": "testinstall",
+ "filesys": "ext4",
+ "gateway": "10.10.10.1",
+ "hdsize": 223.57088470458984,
+ "hostname": "pveauto",
+ "keymap": "de",
+ "mailto": "mail@no.invalid",
+ "mngmt_nic": "enp65s0f0",
+ "password": "123456",
+ "target_hd": "/dev/sda",
+ "timezone": "Europe/Vienna"
+}
diff --git a/proxmox-auto-installer/resources/test/parse_answer/nic_matching.toml b/proxmox-auto-installer/resources/test/parse_answer/nic_matching.toml
new file mode 100644
index 0000000..10e21d2
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/nic_matching.toml
@@ -0,0 +1,19 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+password = "123456"
+
+[network]
+use_dhcp = false
+cidr = "10.10.10.10/24"
+dns = "10.10.10.1"
+gateway = "10.10.10.1"
+filter.ID_NET_NAME_MAC = "*a0369f0ab382"
+
+
+[disks]
+filesystem = "ext4"
+disk_selection = ["sda"]
diff --git a/proxmox-auto-installer/resources/test/parse_answer/readme b/proxmox-auto-installer/resources/test/parse_answer/readme
new file mode 100644
index 0000000..6faefe4
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/readme
@@ -0,0 +1,4 @@
+the size parameter from /sys/block/{disk}/size is the number of blocks.
+
+to calculate the size as the low level installer needs it:
+size * block_size / 1024 / 1024 / 1024 with 14 digits after the comma
diff --git a/proxmox-auto-installer/resources/test/parse_answer/specific_nic.json b/proxmox-auto-installer/resources/test/parse_answer/specific_nic.json
new file mode 100644
index 0000000..515cc89
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/specific_nic.json
@@ -0,0 +1,17 @@
+{
+ "autoreboot": 1,
+ "cidr": "10.10.10.10/24",
+ "country": "at",
+ "dns": "10.10.10.1",
+ "domain": "testinstall",
+ "filesys": "ext4",
+ "gateway": "10.10.10.1",
+ "hdsize": 223.57088470458984,
+ "hostname": "pveauto",
+ "keymap": "de",
+ "mailto": "mail@no.invalid",
+ "mngmt_nic": "enp129s0f1np1",
+ "password": "123456",
+ "target_hd": "/dev/sda",
+ "timezone": "Europe/Vienna"
+}
diff --git a/proxmox-auto-installer/resources/test/parse_answer/specific_nic.toml b/proxmox-auto-installer/resources/test/parse_answer/specific_nic.toml
new file mode 100644
index 0000000..60c08b4
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/specific_nic.toml
@@ -0,0 +1,19 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+password = "123456"
+
+[network]
+use_dhcp = false
+cidr = "10.10.10.10/24"
+dns = "10.10.10.1"
+gateway = "10.10.10.1"
+filter.ID_NET_NAME = "enp129s0f1np1"
+
+
+[disks]
+filesystem = "ext4"
+disk_selection = ["sda"]
diff --git a/proxmox-auto-installer/resources/test/parse_answer/zfs.json b/proxmox-auto-installer/resources/test/parse_answer/zfs.json
new file mode 100644
index 0000000..4746682
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/zfs.json
@@ -0,0 +1,26 @@
+{
+ "autoreboot": 1,
+ "cidr": "192.168.1.114/24",
+ "country": "at",
+ "dns": "192.168.1.254",
+ "domain": "testinstall",
+ "disk_selection": {
+ "6": "6",
+ "7": "7"
+ },
+ "filesys": "zfs (RAID1)",
+ "gateway": "192.168.1.1",
+ "hdsize": 80.0,
+ "hostname": "pveauto",
+ "keymap": "de",
+ "mailto": "mail@no.invalid",
+ "mngmt_nic": "eno1",
+ "password": "123456",
+ "timezone": "Europe/Vienna",
+ "zfs_opts": {
+ "ashift": 12,
+ "checksum": "on",
+ "compress": "lz4",
+ "copies": 2
+ }
+}
diff --git a/proxmox-auto-installer/resources/test/parse_answer/zfs.toml b/proxmox-auto-installer/resources/test/parse_answer/zfs.toml
new file mode 100644
index 0000000..2032c0a
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/parse_answer/zfs.toml
@@ -0,0 +1,19 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+password = "123456"
+
+[network]
+use_dhcp = true
+
+[disks]
+filesystem = "zfs-raid1"
+disk_selection = ["sda", "sdb"]
+zfs.ashift = 12
+zfs.checksum = "on"
+zfs.compress = "lz4"
+zfs.copies = 2
+zfs.hdsize = 80
diff --git a/proxmox-auto-installer/resources/test/run-env-info.json b/proxmox-auto-installer/resources/test/run-env-info.json
new file mode 100644
index 0000000..51a287f
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/run-env-info.json
@@ -0,0 +1 @@
+{"boot_type":"efi","country":"at","disks":[[0,"/dev/nvme0n1",6251233968,"Micron_9300_MTFDHAL3T2TDR",4096,"/sys/block/nvme0n1"],[1,"/dev/nvme1n1",6251233968,"Micron_9300_MTFDHAL3T2TDR",4096,"/sys/block/nvme1n1"],[2,"/dev/nvme2n1",6251233968,"Micron_9300_MTFDHAL3T2TDR",4096,"/sys/block/nvme2n1"],[3,"/dev/nvme3n1",6251233968,"Micron_9300_MTFDHAL3T2TDR",4096,"/sys/block/nvme3n1"],[4,"/dev/nvme4n1",732585168,"INTEL SSDPED1K375GA",512,"/sys/block/nvme4n1"],[5,"/dev/nvme5n1",976773168,"Samsung SSD 970 EVO Plus 500GB",512,"/sys/block/nvme5n1"],[6,"/dev/sda",468862128,"SAMSUNG MZ7KM240",512,"/sys/block/sda"],[7,"/dev/sdb",468862128,"SAMSUNG MZ7KM240",512,"/sys/block/sdb"],[8,"/dev/sdc",468862128,"SAMSUNG MZ7KM240",512,"/sys/block/sdc"],[9,"/dev/sdd",468862128,"SAMSUNG MZ7KM240",512,"/sys/block/sdd"]],"hvm_supported":1,"ipconf":{"default":"5","dnsserver":"192.168.1.254","domain":"proxmox.com","gateway":"192.168.1.1","ifaces":{"10":{"driver":"mlx5_core","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"24:8a:07:1e:05:bd","name":"enp193s0f1np1","state":"DOWN"},"2":{"driver":"cdc_ether","flags":"BROADCAST,MULTICAST,UP,LOWER_UP","mac":"aa:0c:30:4b:63:62","name":"enxaa0c304b6362","state":"UNKNOWN"},"3":{"driver":"igb","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"a0:36:9f:0a:b3:82","name":"enp65s0f0","state":"DOWN"},"4":{"driver":"igb","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"a0:36:9f:0a:b3:83","name":"enp65s0f1","state":"DOWN"},"5":{"driver":"igb","flags":"BROADCAST,MULTICAST,UP,LOWER_UP","inet":{"addr":"192.168.1.114","mask":"255.255.255.0","prefix":24},"mac":"b4:2e:99:ac:ad:b4","name":"eno1","state":"UP"},"6":{"driver":"igb","flags":"BROADCAST,MULTICAST,UP,LOWER_UP","inet":{"addr":"192.168.1.70","mask":"255.255.255.0","prefix":24},"mac":"b4:2e:99:ac:ad:b5","name":"eno2","state":"UP"},"7":{"driver":"mlx5_core","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"1c:34:da:5c:5e:24","name":"enp129s0f0np0","state":"DOWN"},"8":{"driver":"mlx5_core","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"1c:34:da:5c:5e:25","name":"enp129s0f1np1","state":"DOWN"},"9":{"driver":"mlx5_core","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"24:8a:07:1e:05:bc","name":"enp193s0f0np0","state":"DOWN"}}},"kernel_cmdline":"BOOT_IMAGE=/boot/linux26 ro ramdisk_size=16777216 rw splash=verbose proxdebug proxtui","network":{"dns":{"dns":["192.168.1.254","192.168.1.1"],"domain":"proxmox.com"},"interfaces":{"eno1":{"addresses":[{"address":"192.168.1.114","family":"inet","prefix":24}],"index":5,"mac":"b4:2e:99:ac:ad:b4","name":"eno1"},"eno2":{"addresses":[{"address":"192.168.1.70","family":"inet","prefix":24}],"index":6,"mac":"b4:2e:99:ac:ad:b5","name":"eno2"},"enp129s0f0np0":{"index":7,"mac":"1c:34:da:5c:5e:24","name":"enp129s0f0np0"},"enp129s0f1np1":{"index":8,"mac":"1c:34:da:5c:5e:25","name":"enp129s0f1np1"},"enp193s0f0np0":{"index":9,"mac":"24:8a:07:1e:05:bc","name":"enp193s0f0np0"},"enp193s0f1np1":{"index":10,"mac":"24:8a:07:1e:05:bd","name":"enp193s0f1np1"},"enp65s0f0":{"index":3,"mac":"a0:36:9f:0a:b3:82","name":"enp65s0f0"},"enp65s0f1":{"index":4,"mac":"a0:36:9f:0a:b3:83","name":"enp65s0f1"},"enxaa0c304b6362":{"index":2,"mac":"aa:0c:30:4b:63:62","name":"enxaa0c304b6362"}},"routes":{"gateway4":{"dev":"eno1","gateway":"192.168.1.1"}}},"total_memory":257598}
diff --git a/proxmox-auto-installer/resources/test/run-env-udev.json b/proxmox-auto-installer/resources/test/run-env-udev.json
new file mode 100644
index 0000000..4fe1f30
--- /dev/null
+++ b/proxmox-auto-installer/resources/test/run-env-udev.json
@@ -0,0 +1 @@
+{"disks":{"0":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-path/pci-0000:01:00.0-nvme-1 /dev/disk/by-id/nvme-Micron_9300_MTFDHAL3T2TDR_19502596FC74 /dev/disk/by-id/lvm-pv-uuid-hl5Cyv-dghE-CcX8-lDCV-6BSj-EbFU-cT4dIP /dev/disk/by-diskseq/16 /dev/disk/by-id/nvme-eui.000000000000001500a075012596fc74","DEVNAME":"/dev/nvme0n1","DEVPATH":"/devices/pci0000:00/0000:00:01.1/0000:01:00.0/nvme/nvme0/nvme0n1","DEVTYPE":"disk","DISKSEQ":"16","ID_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"hl5Cyv-dghE-CcX8-lDCV-6BSj-EbFU-cT4dIP","ID_FS_UUID_ENC":"hl5Cyv-dghE-CcX8-lDCV-6BSj-EbFU-cT4dIP","ID_FS_VERSION":"LVM2 001","ID_MODEL":"Micron_9300_MTFDHAL3T2TDR","ID_PATH":"pci-0000:01:00.0-nvme-1","ID_PATH_TAG":"pci-0000_01_00_0-nvme-1","ID_REVISION":"11300DN0","ID_SERIAL":"Micron_9300_MTFDHAL3T2TDR_19502596FC74","ID_SERIAL_SHORT":"19502596FC74","ID_WWN":"eui.000000000000001500a075012596fc74","LVM_VG_NAME_COMPLETE":"ceph-67f6a633-8bac-4ba6-a54c-40f0d24a9701","MAJOR":"259","MINOR":"6","SUBSYSTEM":"block","SYSTEMD_READY":"1","TAGS":":systemd:","USEC_INITIALIZED":"45215609"},"1":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-path/pci-0000:02:00.0-nvme-1 /dev/disk/by-id/nvme-eui.000000000000001400a0750125de7a16 /dev/disk/by-diskseq/15 /dev/disk/by-id/nvme-Micron_9300_MTFDHAL3T2TDR_195225DE7A16","DEVNAME":"/dev/nvme1n1","DEVPATH":"/devices/pci0000:00/0000:00:01.2/0000:02:00.0/nvme/nvme1/nvme1n1","DEVTYPE":"disk","DISKSEQ":"15","ID_MODEL":"Micron_9300_MTFDHAL3T2TDR","ID_PATH":"pci-0000:02:00.0-nvme-1","ID_PATH_TAG":"pci-0000_02_00_0-nvme-1","ID_REVISION":"11300DN0","ID_SERIAL":"Micron_9300_MTFDHAL3T2TDR_195225DE7A16","ID_SERIAL_SHORT":"195225DE7A16","ID_WWN":"eui.000000000000001400a0750125de7a16","MAJOR":"259","MINOR":"5","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIALIZED":"43271971"},"2":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-path/pci-0000:03:00.0-nvme-1 /dev/disk/by-diskseq/17 /dev/disk/by-id/lvm-pv-uuid-b92FQw-lExM-2EYR-5UyV-T6cl-yzsM-qRjCOU /dev/disk/by-id/nvme-Micron_9300_MTFDHAL3T2TDR_1945250F206E /dev/disk/by-id/nvme-eui.000000000000001400a07501250f206e","DEVNAME":"/dev/nvme2n1","DEVPATH":"/devices/pci0000:00/0000:00:01.3/0000:03:00.0/nvme/nvme2/nvme2n1","DEVTYPE":"disk","DISKSEQ":"17","ID_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"b92FQw-lExM-2EYR-5UyV-T6cl-yzsM-qRjCOU","ID_FS_UUID_ENC":"b92FQw-lExM-2EYR-5UyV-T6cl-yzsM-qRjCOU","ID_FS_VERSION":"LVM2 001","ID_MODEL":"Micron_9300_MTFDHAL3T2TDR","ID_PATH":"pci-0000:03:00.0-nvme-1","ID_PATH_TAG":"pci-0000_03_00_0-nvme-1","ID_REVISION":"11300DN0","ID_SERIAL":"Micron_9300_MTFDHAL3T2TDR_1945250F206E","ID_SERIAL_SHORT":"1945250F206E","ID_WWN":"eui.000000000000001400a07501250f206e","LVM_VG_NAME_COMPLETE":"ceph-ee820014-6121-458b-a661-889f0901bff6","MAJOR":"259","MINOR":"7","SUBSYSTEM":"block","SYSTEMD_READY":"1","TAGS":":systemd:","USEC_INITIALIZED":"45218640"},"3":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-path/pci-0000:04:00.0-nvme-1 /dev/disk/by-id/lvm-pv-uuid-f56spY-IptZ-fH5e-AqQv-K1cI-3nnt-2UlO17 /dev/disk/by-id/nvme-Micron_9300_MTFDHAL3T2TDR_1945250F20AC /dev/disk/by-diskseq/18 /dev/disk/by-id/nvme-eui.000000000000001400a07501250f20ac","DEVNAME":"/dev/nvme3n1","DEVPATH":"/devices/pci0000:00/0000:00:01.4/0000:04:00.0/nvme/nvme3/nvme3n1","DEVTYPE":"disk","DISKSEQ":"18","ID_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"f56spY-IptZ-fH5e-AqQv-K1cI-3nnt-2UlO17","ID_FS_UUID_ENC":"f56spY-IptZ-fH5e-AqQv-K1cI-3nnt-2UlO17","ID_FS_VERSION":"LVM2 001","ID_MODEL":"Micron_9300_MTFDHAL3T2TDR","ID_PATH":"pci-0000:04:00.0-nvme-1","ID_PATH_TAG":"pci-0000_04_00_0-nvme-1","ID_REVISION":"11300DN0","ID_SERIAL":"Micron_9300_MTFDHAL3T2TDR_1945250F20AC","ID_SERIAL_SHORT":"1945250F20AC","ID_WWN":"eui.000000000000001400a07501250f20ac","LVM_VG_NAME_COMPLETE":"ceph-2928aceb-9300-4175-8640-e227d897d45e","MAJOR":"259","MINOR":"8","SUBSYSTEM":"block","SYSTEMD_READY":"1","TAGS":":systemd:","USEC_INITIALIZED":"45215244"},"4":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-diskseq/13 /dev/disk/by-path/pci-0000:82:00.0-nvme-1 /dev/disk/by-id/lvm-pv-uuid-jFM6eE-KUmT-fTBO-9SWe-4VJG-W4rW-DUQPRd /dev/disk/by-id/nvme-INTEL_SSDPED1K375GA_PHKS746500DK375AGN /dev/disk/by-id/nvme-nvme.8086-50484b53373436353030444b33373541474e-494e54454c20535344504544314b3337354741-00000001","DEVNAME":"/dev/nvme4n1","DEVPATH":"/devices/pci0000:80/0000:80:03.1/0000:82:00.0/nvme/nvme4/nvme4n1","DEVTYPE":"disk","DISKSEQ":"13","ID_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"jFM6eE-KUmT-fTBO-9SWe-4VJG-W4rW-DUQPRd","ID_FS_UUID_ENC":"jFM6eE-KUmT-fTBO-9SWe-4VJG-W4rW-DUQPRd","ID_FS_VERSION":"LVM2 001","ID_MODEL":"INTEL SSDPED1K375GA","ID_PATH":"pci-0000:82:00.0-nvme-1","ID_PATH_TAG":"pci-0000_82_00_0-nvme-1","ID_REVISION":"E2010435","ID_SERIAL":"INTEL_SSDPED1K375GA_PHKS746500DK375AGN","ID_SERIAL_SHORT":"PHKS746500DK375AGN","ID_WWN":"nvme.8086-50484b53373436353030444b33373541474e-494e54454c20535344504544314b3337354741-00000001","LVM_VG_NAME_COMPLETE":"ceph-b4af8112-88e7-4cd4-9cf9-0f4163ca77bd","MAJOR":"259","MINOR":"0","SUBSYSTEM":"block","SYSTEMD_READY":"1","TAGS":":systemd:","USEC_INITIALIZED":"45219471"},"5":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-id/nvme-eui.0025385791b04175 /dev/disk/by-id/nvme-Samsung_SSD_970_EVO_Plus_500GB_S4EVNF0M703256N /dev/disk/by-path/pci-0000:06:00.0-nvme-1 /dev/disk/by-diskseq/14","DEVNAME":"/dev/nvme5n1","DEVPATH":"/devices/pci0000:00/0000:00:03.3/0000:06:00.0/nvme/nvme5/nvme5n1","DEVTYPE":"disk","DISKSEQ":"14","ID_MODEL":"Samsung SSD 970 EVO Plus 500GB","ID_PART_TABLE_TYPE":"gpt","ID_PART_TABLE_UUID":"1c40cb4b-72d8-49ec-804b-e5933e09423d","ID_PATH":"pci-0000:06:00.0-nvme-1","ID_PATH_TAG":"pci-0000_06_00_0-nvme-1","ID_REVISION":"2B2QEXM7","ID_SERIAL":"Samsung_SSD_970_EVO_Plus_500GB_S4EVNF0M703256N","ID_SERIAL_SHORT":"S4EVNF0M703256N","ID_WWN":"eui.0025385791b04175","MAJOR":"259","MINOR":"1","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIALIZED":"43271933"},"6":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-id/lvm-pv-uuid-tMMNAX-noqI-P0oS-9OEJ-7IR5-WoRL-N5K5Cv /dev/disk/by-id/ata-SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403550 /dev/disk/by-path/pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-phy0-lun-0 /dev/disk/by-diskseq/9 /dev/disk/by-id/wwn-0x5002538c405dbf10","DEVNAME":"/dev/sda","DEVPATH":"/devices/pci0000:00/0000:00:03.1/0000:05:00.0/host8/port-8:0/expander-8:0/port-8:0:0/end_device-8:0:0/target8:0:0/8:0:0:0/block/sda","DEVTYPE":"disk","DISKSEQ":"9","ID_ATA":"1","ID_ATA_DOWNLOAD_MICROCODE":"1","ID_ATA_FEATURE_SET_HPA":"1","ID_ATA_FEATURE_SET_HPA_ENABLED":"1","ID_ATA_FEATURE_SET_PM":"1","ID_ATA_FEATURE_SET_PM_ENABLED":"1","ID_ATA_FEATURE_SET_SECURITY":"1","ID_ATA_FEATURE_SET_SECURITY_ENABLED":"0","ID_ATA_FEATURE_SET_SECURITY_ENHANCED_ERASE_UNIT_MIN":"32","ID_ATA_FEATURE_SET_SECURITY_ERASE_UNIT_MIN":"32","ID_ATA_FEATURE_SET_SMART":"1","ID_ATA_FEATURE_SET_SMART_ENABLED":"1","ID_ATA_ROTATION_RATE_RPM":"0","ID_ATA_SATA":"1","ID_ATA_WRITE_CACHE":"1","ID_ATA_WRITE_CACHE_ENABLED":"1","ID_BUS":"ata","ID_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"tMMNAX-noqI-P0oS-9OEJ-7IR5-WoRL-N5K5Cv","ID_FS_UUID_ENC":"tMMNAX-noqI-P0oS-9OEJ-7IR5-WoRL-N5K5Cv","ID_FS_VERSION":"LVM2 001","ID_MODEL":"SAMSUNG_MZ7KM240HAGR-00005","ID_MODEL_ENC":"SAMSUNG\\x20MZ7KM240HAGR-00005\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20","ID_PATH":"pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-phy0-lun-0","ID_PATH_TAG":"pci-0000_05_00_0-sas-exp0x500304801f3f7f7f-phy0-lun-0","ID_REVISION":"GXM1103Q","ID_SERIAL":"SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403550","ID_SERIAL_SHORT":"S2HRNX0J403550","ID_TYPE":"disk","ID_WWN":"0x5002538c405dbf10","ID_WWN_WITH_EXTENSION":"0x5002538c405dbf10","MAJOR":"8","MINOR":"0","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIALIZED":"45234812"},"7":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-id/wwn-0x5002538c405dbce5 /dev/disk/by-path/pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-phy1-lun-0 /dev/disk/by-id/lvm-pv-uuid-oPUG7c-CMh3-oHQy-YRZP-8cNJ-uMIv-ceVPZu /dev/disk/by-diskseq/10 /dev/disk/by-id/ata-SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403335","DEVNAME":"/dev/sdb","DEVPATH":"/devices/pci0000:00/0000:00:03.1/0000:05:00.0/host8/port-8:0/expander-8:0/port-8:0:1/end_device-8:0:1/target8:0:1/8:0:1:0/block/sdb","DEVTYPE":"disk","DISKSEQ":"10","ID_ATA":"1","ID_ATA_DOWNLOAD_MICROCODE":"1","ID_ATA_FEATURE_SET_HPA":"1","ID_ATA_FEATURE_SET_HPA_ENABLED":"1","ID_ATA_FEATURE_SET_PM":"1","ID_ATA_FEATURE_SET_PM_ENABLED":"1","ID_ATA_FEATURE_SET_SECURITY":"1","ID_ATA_FEATURE_SET_SECURITY_ENABLED":"0","ID_ATA_FEATURE_SET_SECURITY_ENHANCED_ERASE_UNIT_MIN":"32","ID_ATA_FEATURE_SET_SECURITY_ERASE_UNIT_MIN":"32","ID_ATA_FEATURE_SET_SMART":"1","ID_ATA_FEATURE_SET_SMART_ENABLED":"1","ID_ATA_ROTATION_RATE_RPM":"0","ID_ATA_SATA":"1","ID_ATA_WRITE_CACHE":"1","ID_ATA_WRITE_CACHE_ENABLED":"1","ID_BUS":"ata","ID_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"oPUG7c-CMh3-oHQy-YRZP-8cNJ-uMIv-ceVPZu","ID_FS_UUID_ENC":"oPUG7c-CMh3-oHQy-YRZP-8cNJ-uMIv-ceVPZu","ID_FS_VERSION":"LVM2 001","ID_MODEL":"SAMSUNG_MZ7KM240HAGR-00005","ID_MODEL_ENC":"SAMSUNG\\x20MZ7KM240HAGR-00005\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20","ID_PATH":"pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-phy1-lun-0","ID_PATH_TAG":"pci-0000_05_00_0-sas-exp0x500304801f3f7f7f-phy1-lun-0","ID_REVISION":"GXM1103Q","ID_SERIAL":"SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403335","ID_SERIAL_SHORT":"S2HRNX0J403335","ID_TYPE":"disk","ID_WWN":"0x5002538c405dbce5","ID_WWN_WITH_EXTENSION":"0x5002538c405dbce5","MAJOR":"8","MINOR":"16","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIALIZED":"45215406"},"8":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-id/wwn-0x5002538c405dbcd9 /dev/disk/by-diskseq/11 /dev/disk/by-id/ata-SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403333 /dev/disk/by-path/pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-phy2-lun-0 /dev/disk/by-id/lvm-pv-uuid-tbguYd-sqom-3Okm-aJ0F-0F8N-2ALl-lo7ONW","DEVNAME":"/dev/sdc","DEVPATH":"/devices/pci0000:00/0000:00:03.1/0000:05:00.0/host8/port-8:0/expander-8:0/port-8:0:2/end_device-8:0:2/target8:0:2/8:0:2:0/block/sdc","DEVTYPE":"disk","DISKSEQ":"11","ID_ATA":"1","ID_ATA_DOWNLOAD_MICROCODE":"1","ID_ATA_FEATURE_SET_HPA":"1","ID_ATA_FEATURE_SET_HPA_ENABLED":"1","ID_ATA_FEATURE_SET_PM":"1","ID_ATA_FEATURE_SET_PM_ENABLED":"1","ID_ATA_FEATURE_SET_SECURITY":"1","ID_ATA_FEATURE_SET_SECURITY_ENABLED":"0","ID_ATA_FEATURE_SET_SECURITY_ENHANCED_ERASE_UNIT_MIN":"32","ID_ATA_FEATURE_SET_SECURITY_ERASE_UNIT_MIN":"32","ID_ATA_FEATURE_SET_SMART":"1","ID_ATA_FEATURE_SET_SMART_ENABLED":"1","ID_ATA_ROTATION_RATE_RPM":"0","ID_ATA_SATA":"1","ID_ATA_WRITE_CACHE":"1","ID_ATA_WRITE_CACHE_ENABLED":"1","ID_BUS":"ata","ID_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"tbguYd-sqom-3Okm-aJ0F-0F8N-2ALl-lo7ONW","ID_FS_UUID_ENC":"tbguYd-sqom-3Okm-aJ0F-0F8N-2ALl-lo7ONW","ID_FS_VERSION":"LVM2 001","ID_MODEL":"SAMSUNG_MZ7KM240HAGR-00005","ID_MODEL_ENC":"SAMSUNG\\x20MZ7KM240HAGR-00005\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20","ID_PATH":"pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-phy2-lun-0","ID_PATH_TAG":"pci-0000_05_00_0-sas-exp0x500304801f3f7f7f-phy2-lun-0","ID_REVISION":"GXM1103Q","ID_SERIAL":"SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403333","ID_SERIAL_SHORT":"S2HRNX0J403333","ID_TYPE":"disk","ID_WWN":"0x5002538c405dbcd9","ID_WWN_WITH_EXTENSION":"0x5002538c405dbcd9","MAJOR":"8","MINOR":"32","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIALIZED":"45198824"},"9":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-diskseq/12 /dev/disk/by-id/wwn-0x5002538c405dbdc5 /dev/disk/by-id/lvm-pv-uuid-Lpxa0X-i8MT-EYWV-J7yQ-r5x7-S99u-jLf8bz /dev/disk/by-path/pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-phy5-lun-0 /dev/disk/by-id/ata-SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403419","DEVNAME":"/dev/sdd","DEVPATH":"/devices/pci0000:00/0000:00:03.1/0000:05:00.0/host8/port-8:0/expander-8:0/port-8:0:3/end_device-8:0:3/target8:0:3/8:0:3:0/block/sdd","DEVTYPE":"disk","DISKSEQ":"12","ID_ATA":"1","ID_ATA_DOWNLOAD_MICROCODE":"1","ID_ATA_FEATURE_SET_HPA":"1","ID_ATA_FEATURE_SET_HPA_ENABLED":"1","ID_ATA_FEATURE_SET_PM":"1","ID_ATA_FEATURE_SET_PM_ENABLED":"1","ID_ATA_FEATURE_SET_SECURITY":"1","ID_ATA_FEATURE_SET_SECURITY_ENABLED":"0","ID_ATA_FEATURE_SET_SECURITY_ENHANCED_ERASE_UNIT_MIN":"32","ID_ATA_FEATURE_SET_SECURITY_ERASE_UNIT_MIN":"32","ID_ATA_FEATURE_SET_SMART":"1","ID_ATA_FEATURE_SET_SMART_ENABLED":"1","ID_ATA_ROTATION_RATE_RPM":"0","ID_ATA_SATA":"1","ID_ATA_WRITE_CACHE":"1","ID_ATA_WRITE_CACHE_ENABLED":"1","ID_BUS":"ata","ID_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"Lpxa0X-i8MT-EYWV-J7yQ-r5x7-S99u-jLf8bz","ID_FS_UUID_ENC":"Lpxa0X-i8MT-EYWV-J7yQ-r5x7-S99u-jLf8bz","ID_FS_VERSION":"LVM2 001","ID_MODEL":"SAMSUNG_MZ7KM240HAGR-00005","ID_MODEL_ENC":"SAMSUNG\\x20MZ7KM240HAGR-00005\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20","ID_PATH":"pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-phy5-lun-0","ID_PATH_TAG":"pci-0000_05_00_0-sas-exp0x500304801f3f7f7f-phy5-lun-0","ID_REVISION":"GXM1103Q","ID_SERIAL":"SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403419","ID_SERIAL_SHORT":"S2HRNX0J403419","ID_TYPE":"disk","ID_WWN":"0x5002538c405dbdc5","ID_WWN_WITH_EXTENSION":"0x5002538c405dbdc5","MAJOR":"8","MINOR":"48","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIALIZED":"45215283"}},"nics":{"eno1":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:c0/0000:c0:03.5/0000:c2:00.0/net/eno1","ID_BUS":"pci","ID_MODEL_FROM_DATABASE":"I350 Gigabit Network Connection","ID_MODEL_ID":"0x1521","ID_NET_DRIVER":"igb","ID_NET_LABEL_ONBOARD":"Onboard LAN1","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-default.link","ID_NET_NAME":"eno1","ID_NET_NAME_MAC":"enxb42e99acadb4","ID_NET_NAME_ONBOARD":"eno1","ID_NET_NAME_PATH":"enp194s0f0","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"GIGA-BYTE TECHNOLOGY CO.,LTD.","ID_PATH":"pci-0000:c2:00.0","ID_PATH_TAG":"pci-0000_c2_00_0","ID_PCI_CLASS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FROM_DATABASE":"Intel Corporation","ID_VENDOR_ID":"0x8086","IFINDEX":"5","INTERFACE":"eno1","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/eno1","TAGS":":systemd:","USEC_INITIALIZED":"45212091"},"eno2":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:c0/0000:c0:03.5/0000:c2:00.1/net/eno2","ID_BUS":"pci","ID_MODEL_FROM_DATABASE":"I350 Gigabit Network Connection","ID_MODEL_ID":"0x1521","ID_NET_DRIVER":"igb","ID_NET_LABEL_ONBOARD":"Onboard LAN2","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-default.link","ID_NET_NAME":"eno2","ID_NET_NAME_MAC":"enxb42e99acadb5","ID_NET_NAME_ONBOARD":"eno2","ID_NET_NAME_PATH":"enp194s0f1","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"GIGA-BYTE TECHNOLOGY CO.,LTD.","ID_PATH":"pci-0000:c2:00.1","ID_PATH_TAG":"pci-0000_c2_00_1","ID_PCI_CLASS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FROM_DATABASE":"Intel Corporation","ID_VENDOR_ID":"0x8086","IFINDEX":"6","INTERFACE":"eno2","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/eno2","TAGS":":systemd:","USEC_INITIALIZED":"45128159"},"enp129s0f0np0":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:80/0000:80:01.1/0000:81:00.0/net/enp129s0f0np0","ID_BUS":"pci","ID_MODEL_FROM_DATABASE":"MT27710 Family [ConnectX-4 Lx] (MCX4421A-ACQN ConnectX-4 Lx EN OCP,2x25G)","ID_MODEL_ID":"0x1015","ID_NET_DRIVER":"mlx5_core","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-default.link","ID_NET_NAME":"enp129s0f0np0","ID_NET_NAME_MAC":"enx1c34da5c5e24","ID_NET_NAME_PATH":"enp129s0f0np0","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"Mellanox Technologies, Inc.","ID_PATH":"pci-0000:81:00.0","ID_PATH_TAG":"pci-0000_81_00_0","ID_PCI_CLASS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FROM_DATABASE":"Mellanox Technologies","ID_VENDOR_ID":"0x15b3","IFINDEX":"7","INTERFACE":"enp129s0f0np0","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/enp129s0f0np0","TAGS":":systemd:","USEC_INITIALIZED":"47752091"},"enp129s0f1np1":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:80/0000:80:01.1/0000:81:00.1/net/enp129s0f1np1","ID_BUS":"pci","ID_MODEL_FROM_DATABASE":"MT27710 Family [ConnectX-4 Lx] (MCX4421A-ACQN ConnectX-4 Lx EN OCP,2x25G)","ID_MODEL_ID":"0x1015","ID_NET_DRIVER":"mlx5_core","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-default.link","ID_NET_NAME":"enp129s0f1np1","ID_NET_NAME_MAC":"enx1c34da5c5e25","ID_NET_NAME_PATH":"enp129s0f1np1","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"Mellanox Technologies, Inc.","ID_PATH":"pci-0000:81:00.1","ID_PATH_TAG":"pci-0000_81_00_1","ID_PCI_CLASS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FROM_DATABASE":"Mellanox Technologies","ID_VENDOR_ID":"0x15b3","IFINDEX":"8","INTERFACE":"enp129s0f1np1","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/enp129s0f1np1","TAGS":":systemd:","USEC_INITIALIZED":"47716100"},"enp193s0f0np0":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:c0/0000:c0:01.1/0000:c1:00.0/net/enp193s0f0np0","ID_BUS":"pci","ID_MODEL_FROM_DATABASE":"MT27700 Family [ConnectX-4]","ID_MODEL_ID":"0x1013","ID_NET_DRIVER":"mlx5_core","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-default.link","ID_NET_NAME":"enp193s0f0np0","ID_NET_NAME_MAC":"enx248a071e05bc","ID_NET_NAME_PATH":"enp193s0f0np0","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"Mellanox Technologies, Inc.","ID_PATH":"pci-0000:c1:00.0","ID_PATH_TAG":"pci-0000_c1_00_0","ID_PCI_CLASS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FROM_DATABASE":"Mellanox Technologies","ID_VENDOR_ID":"0x15b3","IFINDEX":"9","INTERFACE":"enp193s0f0np0","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/enp193s0f0np0","TAGS":":systemd:","USEC_INITIALIZED":"47784094"},"enp193s0f1np1":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:c0/0000:c0:01.1/0000:c1:00.1/net/enp193s0f1np1","ID_BUS":"pci","ID_MODEL_FROM_DATABASE":"MT27700 Family [ConnectX-4]","ID_MODEL_ID":"0x1013","ID_NET_DRIVER":"mlx5_core","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-default.link","ID_NET_NAME":"enp193s0f1np1","ID_NET_NAME_MAC":"enx248a071e05bd","ID_NET_NAME_PATH":"enp193s0f1np1","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"Mellanox Technologies, Inc.","ID_PATH":"pci-0000:c1:00.1","ID_PATH_TAG":"pci-0000_c1_00_1","ID_PCI_CLASS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FROM_DATABASE":"Mellanox Technologies","ID_VENDOR_ID":"0x15b3","IFINDEX":"10","INTERFACE":"enp193s0f1np1","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/enp193s0f1np1","TAGS":":systemd:","USEC_INITIALIZED":"47820155"},"enp65s0f0":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:40/0000:40:03.1/0000:41:00.0/net/enp65s0f0","ID_BUS":"pci","ID_MODEL_FROM_DATABASE":"I350 Gigabit Network Connection (Ethernet Server Adapter I350-T2)","ID_MODEL_ID":"0x1521","ID_NET_DRIVER":"igb","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-default.link","ID_NET_NAME":"enp65s0f0","ID_NET_NAME_MAC":"enxa0369f0ab382","ID_NET_NAME_PATH":"enp65s0f0","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"Intel Corporate","ID_PATH":"pci-0000:41:00.0","ID_PATH_TAG":"pci-0000_41_00_0","ID_PCI_CLASS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FROM_DATABASE":"Intel Corporation","ID_VENDOR_ID":"0x8086","IFINDEX":"3","INTERFACE":"enp65s0f0","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/enp65s0f0","TAGS":":systemd:","USEC_INITIALIZED":"45176103"},"enp65s0f1":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:40/0000:40:03.1/0000:41:00.1/net/enp65s0f1","ID_BUS":"pci","ID_MODEL_FROM_DATABASE":"I350 Gigabit Network Connection (Ethernet Server Adapter I350-T2)","ID_MODEL_ID":"0x1521","ID_NET_DRIVER":"igb","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-default.link","ID_NET_NAME":"enp65s0f1","ID_NET_NAME_MAC":"enxa0369f0ab383","ID_NET_NAME_PATH":"enp65s0f1","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"Intel Corporate","ID_PATH":"pci-0000:41:00.1","ID_PATH_TAG":"pci-0000_41_00_1","ID_PCI_CLASS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FROM_DATABASE":"Intel Corporation","ID_VENDOR_ID":"0x8086","IFINDEX":"4","INTERFACE":"enp65s0f1","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/enp65s0f1","TAGS":":systemd:","USEC_INITIALIZED":"45260218"},"enxaa0c304b6362":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:40/0000:40:08.1/0000:43:00.3/usb3/3-2/3-2.4/3-2.4.3/3-2.4.3:2.0/net/enxaa0c304b6362","ID_BUS":"usb","ID_MODEL":"Virtual_Ethernet","ID_MODEL_ENC":"Virtual\\x20Ethernet","ID_MODEL_ID":"ffb0","ID_NET_DRIVER":"cdc_ether","ID_NET_LINK_FILE":"/usr/lib/systemd/network/73-usb-net-by-mac.link","ID_NET_NAME":"enxaa0c304b6362","ID_NET_NAME_MAC":"enxaa0c304b6362","ID_NET_NAME_PATH":"enp67s0f3u2u4u3c2","ID_NET_NAMING_SCHEME":"v252","ID_PATH":"pci-0000:43:00.3-usb-0:2.4.3:2.0","ID_PATH_TAG":"pci-0000_43_00_3-usb-0_2_4_3_2_0","ID_REVISION":"0100","ID_SERIAL":"American_Megatrends_Inc._Virtual_Ethernet_1234567890","ID_SERIAL_SHORT":"1234567890","ID_TYPE":"generic","ID_USB_CLASS_FROM_DATABASE":"Communications","ID_USB_DRIVER":"cdc_ether","ID_USB_INTERFACES":":0202ff:0a0000:020600:","ID_USB_INTERFACE_NUM":"00","ID_USB_MODEL":"Virtual_Ethernet","ID_USB_MODEL_ENC":"Virtual\\x20Ethernet","ID_USB_MODEL_ID":"ffb0","ID_USB_REVISION":"0100","ID_USB_SERIAL":"American_Megatrends_Inc._Virtual_Ethernet_1234567890","ID_USB_SERIAL_SHORT":"1234567890","ID_USB_TYPE":"generic","ID_USB_VENDOR":"American_Megatrends_Inc.","ID_USB_VENDOR_ENC":"American\\x20Megatrends\\x20Inc.","ID_USB_VENDOR_ID":"046b","ID_VENDOR":"American_Megatrends_Inc.","ID_VENDOR_ENC":"American\\x20Megatrends\\x20Inc.","ID_VENDOR_FROM_DATABASE":"American Megatrends, Inc.","ID_VENDOR_ID":"046b","IFINDEX":"2","INTERFACE":"enxaa0c304b6362","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/enxaa0c304b6362","TAGS":":systemd:","USEC_INITIALIZED":"44748106"}}}
diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
new file mode 100644
index 0000000..566030c
--- /dev/null
+++ b/proxmox-auto-installer/src/answer.rs
@@ -0,0 +1,144 @@
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+#[derive(Clone, Deserialize, Debug)]
+#[serde(rename_all = "lowercase")]
+pub struct Answer {
+ pub global: Global,
+ pub network: Network,
+ pub disks: Disks,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct Global {
+ pub country: String,
+ pub fqdn: String,
+ pub keyboard: String,
+ pub mailto: String,
+ pub timezone: String,
+ pub password: String,
+ pub pre_command: Option<Vec<String>>,
+ pub post_command: Option<Vec<String>>,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct Network {
+ pub use_dhcp: Option<bool>,
+ pub cidr: Option<String>,
+ pub dns: Option<String>,
+ pub gateway: Option<String>,
+ // use BTreeMap to have keys sorted
+ pub filter: Option<BTreeMap<String, String>>,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct Disks {
+ pub filesystem: Option<Filesystem>,
+ pub disk_selection: Option<Vec<String>>,
+ pub filter_match: Option<FilterMatch>,
+ // use BTreeMap to have keys sorted
+ pub filter: Option<BTreeMap<String, String>>,
+ pub zfs: Option<ZfsOptions>,
+ pub lvm: Option<LvmOptions>,
+ pub btrfs: Option<BtrfsOptions>,
+}
+
+#[derive(Clone, Deserialize, Debug, PartialEq)]
+#[serde(rename_all = "lowercase")]
+pub enum FilterMatch {
+ Any,
+ All,
+}
+
+#[derive(Clone, Deserialize, Serialize, Debug)]
+#[serde(rename_all = "kebab-case")]
+pub enum Filesystem {
+ Ext4,
+ Xfs,
+ ZfsRaid0,
+ ZfsRaid1,
+ ZfsRaid10,
+ ZfsRaidZ1,
+ ZfsRaidZ2,
+ ZfsRaidZ3,
+ BtrfsRaid0,
+ BtrfsRaid1,
+ BtrfsRaid10,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct ZfsOptions {
+ pub ashift: Option<usize>,
+ pub checksum: Option<ZfsChecksumOption>,
+ pub compress: Option<ZfsCompressOption>,
+ pub copies: Option<usize>,
+ pub hdsize: Option<f64>,
+}
+
+impl ZfsOptions {
+ pub fn new() -> ZfsOptions {
+ ZfsOptions {
+ ashift: None,
+ checksum: None,
+ compress: None,
+ copies: None,
+ hdsize: None,
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
+#[serde(rename_all(deserialize = "lowercase"))]
+pub enum ZfsCompressOption {
+ #[default]
+ On,
+ Off,
+ Lzjb,
+ Lz4,
+ Zle,
+ Gzip,
+ Zstd,
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum ZfsChecksumOption {
+ #[default]
+ On,
+ Off,
+ Fletcher2,
+ Fletcher4,
+ Sha256,
+}
+
+#[derive(Clone, Deserialize, Serialize, Debug)]
+pub struct LvmOptions {
+ pub hdsize: Option<f64>,
+ pub swapsize: Option<f64>,
+ pub maxroot: Option<f64>,
+ pub maxvz: Option<f64>,
+ pub minfree: Option<f64>,
+}
+
+impl LvmOptions {
+ pub fn new() -> LvmOptions {
+ LvmOptions {
+ hdsize: None,
+ swapsize: None,
+ maxroot: None,
+ maxvz: None,
+ minfree: None,
+ }
+ }
+}
+
+#[derive(Clone, Deserialize, Serialize, Debug)]
+pub struct BtrfsOptions {
+ pub hdsize: Option<f64>,
+}
+
+impl BtrfsOptions {
+ pub fn new() -> BtrfsOptions {
+ BtrfsOptions { hdsize: None }
+ }
+}
diff --git a/proxmox-auto-installer/src/main.rs b/proxmox-auto-installer/src/main.rs
new file mode 100644
index 0000000..d647567
--- /dev/null
+++ b/proxmox-auto-installer/src/main.rs
@@ -0,0 +1,412 @@
+use std::{
+ collections::BTreeMap,
+ env,
+ io::{BufRead, BufReader, Write},
+ path::PathBuf,
+ process::ExitCode,
+};
+mod tui;
+use tui::{
+ options::{BtrfsRaidLevel, FsType, ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel},
+ setup::{InstallConfig, InstallZfsOption, LocaleInfo, RuntimeInfo, SetupInfo},
+};
+
+mod answer;
+mod udevinfo;
+mod utils;
+use answer::Answer;
+use udevinfo::UdevInfo;
+
+/// ISO information is available globally.
+static mut SETUP_INFO: Option<SetupInfo> = None;
+
+pub fn setup_info() -> &'static SetupInfo {
+ unsafe { SETUP_INFO.as_ref().unwrap() }
+}
+
+fn init_setup_info(info: SetupInfo) {
+ unsafe {
+ SETUP_INFO = Some(info);
+ }
+}
+
+#[inline]
+pub fn current_product() -> tui::setup::ProxmoxProduct {
+ setup_info().config.product
+}
+
+fn installer_setup(
+ in_test_mode: bool,
+) -> Result<(Answer, LocaleInfo, RuntimeInfo, UdevInfo), String> {
+ let base_path = if in_test_mode { "./testdir" } else { "/" };
+ let mut path = PathBuf::from(base_path);
+
+ path.push("run");
+ path.push("proxmox-installer");
+
+ let installer_info = {
+ let mut path = path.clone();
+ path.push("iso-info.json");
+
+ tui::setup::read_json(&path)
+ .map_err(|err| format!("Failed to retrieve setup info: {err}"))?
+ };
+ init_setup_info(installer_info);
+
+ let locale_info = {
+ let mut path = path.clone();
+ path.push("locales.json");
+
+ tui::setup::read_json(&path)
+ .map_err(|err| format!("Failed to retrieve locale info: {err}"))?
+ };
+
+ let mut runtime_info: RuntimeInfo = {
+ let mut path = path.clone();
+ path.push("run-env-info.json");
+
+ tui::setup::read_json(&path)
+ .map_err(|err| format!("Failed to retrieve runtime environment info: {err}"))?
+ };
+
+ let udev_info: UdevInfo = {
+ let mut path = path.clone();
+ path.push("run-env-udev.json");
+
+ tui::setup::read_json(&path)
+ .map_err(|err| format!("Failed to retrieve udev info details: {err}"))?
+ };
+
+ let mut buffer = String::new();
+ let lines = std::io::stdin().lock().lines();
+ for line in lines {
+ buffer.push_str(&line.unwrap());
+ buffer.push('\n');
+ }
+
+ let answer: answer::Answer =
+ toml::from_str(&buffer).map_err(|err| format!("Failed parsing answer file: {err}"))?;
+
+ runtime_info.disks.sort();
+ if runtime_info.disks.is_empty() {
+ Err("The installer could not find any supported hard disks.".to_owned())
+ } else {
+ Ok((answer, locale_info, runtime_info, udev_info))
+ }
+}
+
+fn main() -> ExitCode {
+ let in_test_mode = match env::args().nth(1).as_deref() {
+ Some("-t") => true,
+ // Always force the test directory in debug builds
+ _ => cfg!(debug_assertions),
+ };
+ println!("Starting auto installer");
+
+ let (answer, locales, runtime_info, udevadm_info) = match installer_setup(in_test_mode) {
+ Ok(result) => result,
+ Err(err) => {
+ eprintln!("Installer setup error: {err}");
+ return ExitCode::FAILURE;
+ }
+ };
+
+ match utils::run_cmds("Pre", &answer.global.pre_command) {
+ Ok(_) => (),
+ Err(err) => {
+ eprintln!("Error when running Pre-Commands: {}", err);
+ return ExitCode::FAILURE;
+ }
+ };
+ match run_installation(
+ &answer,
+ &locales,
+ &runtime_info,
+ &udevadm_info,
+ ) {
+ Ok(_) => println!("Installation done."),
+ Err(err) => {
+ eprintln!("Installation failed: {err}");
+ return ExitCode::FAILURE;
+ }
+ }
+ match utils::run_cmds("Post", &answer.global.post_command) {
+ Ok(_) => (),
+ Err(err) => {
+ eprintln!("Error when running Post-Commands: {}", err);
+ return ExitCode::FAILURE;
+ }
+ };
+ ExitCode::SUCCESS
+}
+
+fn run_installation(
+ answer: &Answer,
+ locales: &LocaleInfo,
+ runtime_info: &RuntimeInfo,
+ udevadm_info: &UdevInfo,
+) -> Result<(), String> {
+ let config = parse_answer(answer, udevadm_info, runtime_info, locales)?;
+ #[cfg(debug_assertions)]
+ println!(
+ "FINAL JSON:\n{}",
+ serde_json::to_string_pretty(&config).expect("serialization failed")
+ );
+
+ let child = {
+ use std::process::{Command, Stdio};
+
+ #[cfg(not(debug_assertions))]
+ let (path, args, envs): (&str, [&str; 1], [(&str, &str); 0]) =
+ ("proxmox-low-level-installer", ["start-session"], []);
+
+ #[cfg(debug_assertions)]
+ let (path, args, envs) = (
+ PathBuf::from("./proxmox-low-level-installer"),
+ ["-t", "start-session-test"],
+ [("PERL5LIB", ".")],
+ );
+
+ Command::new(path)
+ .args(args)
+ .envs(envs)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .spawn()
+ };
+
+ let mut child = match child {
+ Ok(child) => child,
+ Err(err) => {
+ return Err(format!("Low level installer could not be started: {err}"));
+ }
+ };
+
+ let mut inner = || {
+ let reader = child.stdout.take().map(BufReader::new)?;
+ let mut writer = child.stdin.take()?;
+
+ serde_json::to_writer(&mut writer, &config).unwrap();
+ writeln!(writer).unwrap();
+
+ for line in reader.lines() {
+ match line {
+ Ok(line) => print!("{line}"),
+ Err(_) => break,
+ };
+ }
+ Some(())
+ };
+ match inner() {
+ Some(_) => Ok(()),
+ None => Err("low level installer returned early".to_string()),
+ }
+}
+
+fn parse_answer(
+ answer: &Answer,
+ udev_info: &UdevInfo,
+ runtime_info: &RuntimeInfo,
+ locales: &LocaleInfo,
+) -> Result<InstallConfig, String> {
+ let filesystem = match &answer.disks.filesystem {
+ Some(answer::Filesystem::Ext4) => FsType::Ext4,
+ Some(answer::Filesystem::Xfs) => FsType::Xfs,
+ Some(answer::Filesystem::ZfsRaid0) => FsType::Zfs(ZfsRaidLevel::Raid0),
+ Some(answer::Filesystem::ZfsRaid1) => FsType::Zfs(ZfsRaidLevel::Raid1),
+ Some(answer::Filesystem::ZfsRaid10) => FsType::Zfs(ZfsRaidLevel::Raid10),
+ Some(answer::Filesystem::ZfsRaidZ1) => FsType::Zfs(ZfsRaidLevel::RaidZ),
+ Some(answer::Filesystem::ZfsRaidZ2) => FsType::Zfs(ZfsRaidLevel::RaidZ2),
+ Some(answer::Filesystem::ZfsRaidZ3) => FsType::Zfs(ZfsRaidLevel::RaidZ3),
+ Some(answer::Filesystem::BtrfsRaid0) => FsType::Btrfs(BtrfsRaidLevel::Raid0),
+ Some(answer::Filesystem::BtrfsRaid1) => FsType::Btrfs(BtrfsRaidLevel::Raid1),
+ Some(answer::Filesystem::BtrfsRaid10) => FsType::Btrfs(BtrfsRaidLevel::Raid10),
+ None => FsType::Ext4,
+ };
+
+ let network_settings = utils::get_network_settings(answer, udev_info, runtime_info)?;
+
+ utils::verify_locale_settings(answer, locales)?;
+
+ let mut config = InstallConfig {
+ autoreboot: 1_usize,
+ filesys: filesystem,
+ hdsize: 0.,
+ swapsize: None,
+ maxroot: None,
+ minfree: None,
+ maxvz: None,
+ zfs_opts: None,
+ target_hd: None,
+ disk_selection: BTreeMap::new(),
+
+ country: answer.global.country.clone(),
+ timezone: answer.global.timezone.clone(),
+ keymap: answer.global.keyboard.clone(),
+
+ password: answer.global.password.clone(),
+ mailto: answer.global.mailto.clone(),
+
+ mngmt_nic: network_settings.ifname,
+
+ hostname: network_settings.fqdn.host().unwrap().to_string(),
+ domain: network_settings.fqdn.domain(),
+ cidr: network_settings.address,
+ gateway: network_settings.gateway,
+ dns: network_settings.dns_server,
+ };
+
+ utils::set_disks(answer, udev_info, runtime_info, &mut config)?;
+ match &config.filesys {
+ FsType::Xfs | FsType::Ext4 => {
+ let lvm = match &answer.disks.lvm {
+ Some(lvm) => lvm.clone(),
+ None => answer::LvmOptions::new(),
+ };
+ config.hdsize = lvm.hdsize.unwrap_or(config.target_hd.clone().unwrap().size);
+ config.swapsize = lvm.swapsize;
+ config.maxroot = lvm.maxroot;
+ config.maxvz = lvm.maxvz;
+ config.minfree = lvm.minfree;
+ }
+ FsType::Zfs(_) => {
+ let zfs = match &answer.disks.zfs {
+ Some(zfs) => zfs.clone(),
+ None => answer::ZfsOptions::new(),
+ };
+ let first_selected_disk = utils::get_first_selected_disk(&config);
+
+ config.hdsize = zfs
+ .hdsize
+ .unwrap_or(runtime_info.disks[first_selected_disk].size);
+ config.zfs_opts = Some(InstallZfsOption {
+ ashift: zfs.ashift.unwrap_or(12),
+ compress: match zfs.compress {
+ Some(answer::ZfsCompressOption::On) => ZfsCompressOption::On,
+ Some(answer::ZfsCompressOption::Off) => ZfsCompressOption::Off,
+ Some(answer::ZfsCompressOption::Lzjb) => ZfsCompressOption::Lzjb,
+ Some(answer::ZfsCompressOption::Lz4) => ZfsCompressOption::Lz4,
+ Some(answer::ZfsCompressOption::Zle) => ZfsCompressOption::Zle,
+ Some(answer::ZfsCompressOption::Gzip) => ZfsCompressOption::Gzip,
+ Some(answer::ZfsCompressOption::Zstd) => ZfsCompressOption::Zstd,
+ None => ZfsCompressOption::On,
+ },
+ checksum: match zfs.checksum {
+ Some(answer::ZfsChecksumOption::On) => ZfsChecksumOption::On,
+ Some(answer::ZfsChecksumOption::Off) => ZfsChecksumOption::Off,
+ Some(answer::ZfsChecksumOption::Fletcher2) => ZfsChecksumOption::Fletcher2,
+ Some(answer::ZfsChecksumOption::Fletcher4) => ZfsChecksumOption::Fletcher4,
+ Some(answer::ZfsChecksumOption::Sha256) => ZfsChecksumOption::Sha256,
+ None => ZfsChecksumOption::On,
+ },
+ copies: zfs.copies.unwrap_or(1),
+ });
+ }
+ FsType::Btrfs(_) => {
+ let btrfs = match &answer.disks.btrfs {
+ Some(btrfs) => btrfs.clone(),
+ None => answer::BtrfsOptions::new(),
+ };
+ let first_selected_disk = utils::get_first_selected_disk(&config);
+
+ config.hdsize = btrfs
+ .hdsize
+ .unwrap_or(runtime_info.disks[first_selected_disk].size);
+ }
+ }
+ Ok(config)
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::Value;
+ use std::fs;
+
+ use super::*;
+ fn get_test_resource_path() -> Result<PathBuf, String> {
+ Ok(std::env::current_dir()
+ .expect("current dir failed")
+ .join("resources/test"))
+ }
+ fn get_answer(path: PathBuf) -> Result<Answer, String> {
+ let answer_raw = std::fs::read_to_string(&path).unwrap();
+ let answer: answer::Answer = toml::from_str(&answer_raw)
+ .map_err(|err| format!("error parsing answer.toml: {err}"))
+ .unwrap();
+
+ Ok(answer)
+ }
+
+ fn setup_test_basic(path: &PathBuf) -> (RuntimeInfo, UdevInfo, LocaleInfo) {
+ let installer_info: SetupInfo = {
+ let mut path = path.clone();
+ path.push("iso-info.json");
+
+ tui::setup::read_json(&path)
+ .map_err(|err| format!("Failed to retrieve setup info: {err}"))
+ .unwrap()
+ };
+ init_setup_info(installer_info.clone());
+ let udev_info: UdevInfo = {
+ let mut path = path.clone();
+ path.push("run-env-udev.json");
+
+ tui::setup::read_json(&path)
+ .map_err(|err| format!("Failed to retrieve udev info details: {err}"))
+ .unwrap()
+ };
+ let runtime_info: RuntimeInfo = {
+ let mut path = path.clone();
+ path.push("run-env-info.json");
+
+ tui::setup::read_json(&path)
+ .map_err(|err| format!("Failed to retrieve udev info details: {err}"))
+ .unwrap()
+ };
+ let locales: LocaleInfo = {
+ let mut path = path.clone();
+ path.push("locales.json");
+
+ tui::setup::read_json(&path)
+ .map_err(|err| format!("Failed to retrieve locales: {err}"))
+ .unwrap()
+ };
+ (runtime_info, udev_info, locales)
+ }
+
+ #[test]
+ fn test_parse_answers() {
+ let path = get_test_resource_path().unwrap();
+ let (runtime_info, udev_info, locales) = setup_test_basic(&path);
+ let mut tests_path = path.clone();
+ tests_path.push("parse_answer");
+ let test_dir = fs::read_dir(tests_path.clone()).unwrap();
+ for file_entry in test_dir {
+ let file = file_entry.unwrap();
+ if !file.file_type().unwrap().is_file() || file.file_name() == "readme" {
+ continue;
+ }
+ let p = file.path();
+ let name = p.file_stem().unwrap().to_str().unwrap();
+ let extension = p.extension().unwrap().to_str().unwrap();
+ if extension == "toml" {
+ println!("Test: {name}");
+ let answer = get_answer(p.clone()).unwrap();
+ let config = &parse_answer(&answer, &udev_info, &runtime_info, &locales).unwrap();
+ println!("Selected disks: {:#?}", &config.disk_selection);
+ let config_json = serde_json::to_string(config);
+ let config: Value = serde_json::from_str(config_json.unwrap().as_str()).unwrap();
+ let mut path = tests_path.clone();
+ path.push(format!("{name}.json"));
+ let compare_raw = std::fs::read_to_string(&path).unwrap();
+ let compare: Value = serde_json::from_str(&compare_raw).unwrap();
+ if config != compare {
+ panic!(
+ "Test {} failed:\nleft: {:?}\nright: {:?}",
+ name, config, compare
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/proxmox-auto-installer/src/tui/mod.rs b/proxmox-auto-installer/src/tui/mod.rs
new file mode 100644
index 0000000..0b7fc39
--- /dev/null
+++ b/proxmox-auto-installer/src/tui/mod.rs
@@ -0,0 +1,3 @@
+pub mod options;
+pub mod setup;
+pub mod utils;
diff --git a/proxmox-auto-installer/src/tui/options.rs b/proxmox-auto-installer/src/tui/options.rs
new file mode 100644
index 0000000..f87584c
--- /dev/null
+++ b/proxmox-auto-installer/src/tui/options.rs
@@ -0,0 +1,302 @@
+use std::net::{IpAddr, Ipv4Addr};
+use std::{cmp, fmt};
+
+use serde::Deserialize;
+
+use crate::tui::setup::NetworkInfo;
+use crate::tui::utils::{CidrAddress, Fqdn};
+
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum BtrfsRaidLevel {
+ Raid0,
+ Raid1,
+ Raid10,
+}
+
+impl fmt::Display for BtrfsRaidLevel {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ use BtrfsRaidLevel::*;
+ match self {
+ Raid0 => write!(f, "RAID0"),
+ Raid1 => write!(f, "RAID1"),
+ Raid10 => write!(f, "RAID10"),
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum ZfsRaidLevel {
+ Raid0,
+ Raid1,
+ Raid10,
+ RaidZ,
+ RaidZ2,
+ RaidZ3,
+}
+
+impl fmt::Display for ZfsRaidLevel {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ use ZfsRaidLevel::*;
+ match self {
+ Raid0 => write!(f, "RAID0"),
+ Raid1 => write!(f, "RAID1"),
+ Raid10 => write!(f, "RAID10"),
+ RaidZ => write!(f, "RAIDZ-1"),
+ RaidZ2 => write!(f, "RAIDZ-2"),
+ RaidZ3 => write!(f, "RAIDZ-3"),
+ }
+ }
+}
+
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum FsType {
+ Ext4,
+ Xfs,
+ Zfs(ZfsRaidLevel),
+ Btrfs(BtrfsRaidLevel),
+}
+
+impl fmt::Display for FsType {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ use FsType::*;
+ match self {
+ Ext4 => write!(f, "ext4"),
+ Xfs => write!(f, "XFS"),
+ Zfs(level) => write!(f, "ZFS ({level})"),
+ Btrfs(level) => write!(f, "Btrfs ({level})"),
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct LvmBootdiskOptions {
+ pub total_size: f64,
+ pub swap_size: Option<f64>,
+ pub max_root_size: Option<f64>,
+ pub max_data_size: Option<f64>,
+ pub min_lvm_free: Option<f64>,
+}
+
+#[derive(Clone, Debug)]
+pub struct BtrfsBootdiskOptions {
+ pub disk_size: f64,
+ pub selected_disks: Vec<usize>,
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
+pub enum ZfsCompressOption {
+ #[default]
+ On,
+ Off,
+ Lzjb,
+ Lz4,
+ Zle,
+ Gzip,
+ Zstd,
+}
+
+impl fmt::Display for ZfsCompressOption {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "{}", format!("{self:?}").to_lowercase())
+ }
+}
+
+impl From<&ZfsCompressOption> for String {
+ fn from(value: &ZfsCompressOption) -> Self {
+ value.to_string()
+ }
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
+pub enum ZfsChecksumOption {
+ #[default]
+ On,
+ Off,
+ Fletcher2,
+ Fletcher4,
+ Sha256,
+}
+
+impl fmt::Display for ZfsChecksumOption {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "{}", format!("{self:?}").to_lowercase())
+ }
+}
+
+impl From<&ZfsChecksumOption> for String {
+ fn from(value: &ZfsChecksumOption) -> Self {
+ value.to_string()
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct ZfsBootdiskOptions {
+ pub ashift: usize,
+ pub compress: ZfsCompressOption,
+ pub checksum: ZfsChecksumOption,
+ pub copies: usize,
+ pub disk_size: f64,
+ pub selected_disks: Vec<usize>,
+}
+
+#[derive(Clone, Debug)]
+pub enum AdvancedBootdiskOptions {
+ Lvm(LvmBootdiskOptions),
+ Zfs(ZfsBootdiskOptions),
+ Btrfs(BtrfsBootdiskOptions),
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct Disk {
+ pub index: String,
+ pub path: String,
+ pub model: Option<String>,
+ pub size: f64,
+ pub block_size: usize,
+}
+
+impl fmt::Display for Disk {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ // TODO: Format sizes properly with `proxmox-human-byte` once merged
+ // https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006125.html
+ f.write_str(&self.path)?;
+ if let Some(model) = &self.model {
+ // FIXME: ellipsize too-long names?
+ write!(f, " ({model})")?;
+ }
+ write!(f, " ({:.2} GiB)", self.size)
+ }
+}
+
+impl From<&Disk> for String {
+ fn from(value: &Disk) -> Self {
+ value.to_string()
+ }
+}
+
+impl cmp::Eq for Disk {}
+
+impl cmp::PartialOrd for Disk {
+ fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
+ self.index.partial_cmp(&other.index)
+ }
+}
+
+impl cmp::Ord for Disk {
+ fn cmp(&self, other: &Self) -> cmp::Ordering {
+ self.index.cmp(&other.index)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct BootdiskOptions {
+ pub disks: Vec<Disk>,
+ pub fstype: FsType,
+ pub advanced: AdvancedBootdiskOptions,
+}
+
+#[derive(Clone, Debug)]
+pub struct TimezoneOptions {
+ pub country: String,
+ pub timezone: String,
+ pub kb_layout: String,
+}
+
+#[derive(Clone, Debug)]
+pub struct PasswordOptions {
+ pub email: String,
+ pub root_password: String,
+}
+
+impl Default for PasswordOptions {
+ fn default() -> Self {
+ Self {
+ email: "mail@example.invalid".to_string(),
+ root_password: String::new(),
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct NetworkOptions {
+ pub ifname: String,
+ pub fqdn: Fqdn,
+ pub address: CidrAddress,
+ pub gateway: IpAddr,
+ pub dns_server: IpAddr,
+}
+
+impl Default for NetworkOptions {
+ fn default() -> Self {
+ let fqdn = format!(
+ "{}.example.invalid",
+ crate::current_product().default_hostname()
+ );
+ // TODO: Retrieve automatically
+ Self {
+ ifname: String::new(),
+ fqdn: fqdn.parse().unwrap(),
+ // Safety: The provided mask will always be valid.
+ address: CidrAddress::new(Ipv4Addr::UNSPECIFIED, 0).unwrap(),
+ gateway: Ipv4Addr::UNSPECIFIED.into(),
+ dns_server: Ipv4Addr::UNSPECIFIED.into(),
+ }
+ }
+}
+
+impl From<&NetworkInfo> for NetworkOptions {
+ fn from(info: &NetworkInfo) -> Self {
+ let mut this = Self::default();
+
+ if let Some(ip) = info.dns.dns.first() {
+ this.dns_server = *ip;
+ }
+
+ if let Some(domain) = &info.dns.domain {
+ let hostname = crate::current_product().default_hostname();
+ if let Ok(fqdn) = Fqdn::from(&format!("{hostname}.{domain}")) {
+ this.fqdn = fqdn;
+ }
+ }
+
+ if let Some(routes) = &info.routes {
+ let mut filled = false;
+ if let Some(gw) = &routes.gateway4 {
+ if let Some(iface) = info.interfaces.get(&gw.dev) {
+ this.ifname = iface.name.clone();
+ if let Some(addresses) = &iface.addresses {
+ if let Some(addr) = addresses.iter().find(|addr| addr.is_ipv4()) {
+ this.gateway = gw.gateway;
+ this.address = addr.clone();
+ filled = true;
+ }
+ }
+ }
+ }
+ if !filled {
+ if let Some(gw) = &routes.gateway6 {
+ if let Some(iface) = info.interfaces.get(&gw.dev) {
+ if let Some(addresses) = &iface.addresses {
+ if let Some(addr) = addresses.iter().find(|addr| addr.is_ipv6()) {
+ this.ifname = iface.name.clone();
+ this.gateway = gw.gateway;
+ this.address = addr.clone();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ this
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct InstallerOptions {
+ pub bootdisk: BootdiskOptions,
+ pub timezone: TimezoneOptions,
+ pub password: PasswordOptions,
+ pub network: NetworkOptions,
+ pub autoreboot: bool,
+}
diff --git a/proxmox-auto-installer/src/tui/setup.rs b/proxmox-auto-installer/src/tui/setup.rs
new file mode 100644
index 0000000..c4523f2
--- /dev/null
+++ b/proxmox-auto-installer/src/tui/setup.rs
@@ -0,0 +1,447 @@
+use std::{
+ cmp,
+ collections::{BTreeMap, HashMap},
+ fmt,
+ fs::File,
+ io::BufReader,
+ net::IpAddr,
+ path::{Path, PathBuf},
+};
+
+use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
+
+use crate::tui::{
+ options::{
+ AdvancedBootdiskOptions, BtrfsRaidLevel, Disk, FsType, InstallerOptions,
+ ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel,
+ },
+ utils::CidrAddress,
+};
+
+#[allow(clippy::upper_case_acronyms)]
+#[derive(Clone, Copy, Deserialize, PartialEq, Debug)]
+#[serde(rename_all = "lowercase")]
+pub enum ProxmoxProduct {
+ PVE,
+ PBS,
+ PMG,
+}
+
+impl ProxmoxProduct {
+ pub fn default_hostname(self) -> &'static str {
+ match self {
+ Self::PVE => "pve",
+ Self::PMG => "pmg",
+ Self::PBS => "pbs",
+ }
+ }
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct ProductConfig {
+ pub fullname: String,
+ pub product: ProxmoxProduct,
+ #[serde(deserialize_with = "deserialize_bool_from_int")]
+ pub enable_btrfs: bool,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct IsoInfo {
+ pub release: String,
+ pub isorelease: String,
+}
+
+/// Paths in the ISO environment containing installer data.
+#[derive(Clone, Deserialize, Debug)]
+pub struct IsoLocations {
+ pub iso: PathBuf,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct SetupInfo {
+ #[serde(rename = "product-cfg")]
+ pub config: ProductConfig,
+ #[serde(rename = "iso-info")]
+ pub iso_info: IsoInfo,
+ pub locations: IsoLocations,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct CountryInfo {
+ pub name: String,
+ #[serde(default)]
+ pub zone: String,
+ pub kmap: String,
+}
+
+#[derive(Clone, Deserialize, Eq, PartialEq, Debug)]
+pub struct KeyboardMapping {
+ pub name: String,
+ #[serde(rename = "kvm")]
+ pub id: String,
+ #[serde(rename = "x11")]
+ pub xkb_layout: String,
+ #[serde(rename = "x11var")]
+ pub xkb_variant: String,
+}
+
+impl cmp::PartialOrd for KeyboardMapping {
+ fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
+ self.name.partial_cmp(&other.name)
+ }
+}
+
+impl cmp::Ord for KeyboardMapping {
+ fn cmp(&self, other: &Self) -> cmp::Ordering {
+ self.name.cmp(&other.name)
+ }
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct LocaleInfo {
+ #[serde(deserialize_with = "deserialize_cczones_map")]
+ pub cczones: HashMap<String, Vec<String>>,
+ #[serde(rename = "country")]
+ pub countries: HashMap<String, CountryInfo>,
+ pub kmap: HashMap<String, KeyboardMapping>,
+}
+
+#[derive(Serialize)]
+pub struct InstallZfsOption {
+ pub ashift: usize,
+ #[serde(serialize_with = "serialize_as_display")]
+ pub compress: ZfsCompressOption,
+ #[serde(serialize_with = "serialize_as_display")]
+ pub checksum: ZfsChecksumOption,
+ pub copies: usize,
+}
+
+impl From<ZfsBootdiskOptions> for InstallZfsOption {
+ fn from(opts: ZfsBootdiskOptions) -> Self {
+ InstallZfsOption {
+ ashift: opts.ashift,
+ compress: opts.compress,
+ checksum: opts.checksum,
+ copies: opts.copies,
+ }
+ }
+}
+
+/// See Proxmox::Install::Config
+#[derive(Serialize)]
+pub struct InstallConfig {
+ pub autoreboot: usize,
+
+ #[serde(serialize_with = "serialize_fstype")]
+ pub filesys: FsType,
+ pub hdsize: f64,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub swapsize: Option<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub maxroot: Option<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub minfree: Option<f64>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub maxvz: Option<f64>,
+
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub zfs_opts: Option<InstallZfsOption>,
+
+ #[serde(
+ serialize_with = "serialize_disk_opt",
+ skip_serializing_if = "Option::is_none"
+ )]
+ pub target_hd: Option<Disk>,
+ #[serde(skip_serializing_if = "BTreeMap::is_empty")]
+ pub disk_selection: BTreeMap<String, String>,
+
+ pub country: String,
+ pub timezone: String,
+ pub keymap: String,
+
+ pub password: String,
+ pub mailto: String,
+
+ pub mngmt_nic: String,
+
+ pub hostname: String,
+ pub domain: String,
+ #[serde(serialize_with = "serialize_as_display")]
+ pub cidr: CidrAddress,
+ pub gateway: IpAddr,
+ pub dns: IpAddr,
+}
+
+impl From<InstallerOptions> for InstallConfig {
+ fn from(options: InstallerOptions) -> Self {
+ let mut config = Self {
+ autoreboot: options.autoreboot as usize,
+
+ filesys: options.bootdisk.fstype,
+ hdsize: 0.,
+ swapsize: None,
+ maxroot: None,
+ minfree: None,
+ maxvz: None,
+ zfs_opts: None,
+ target_hd: None,
+ disk_selection: BTreeMap::new(),
+
+ country: options.timezone.country,
+ timezone: options.timezone.timezone,
+ keymap: options.timezone.kb_layout,
+
+ password: options.password.root_password,
+ mailto: options.password.email,
+
+ mngmt_nic: options.network.ifname,
+
+ hostname: options
+ .network
+ .fqdn
+ .host()
+ .unwrap_or_else(|| crate::current_product().default_hostname())
+ .to_owned(),
+ domain: options.network.fqdn.domain(),
+ cidr: options.network.address,
+ gateway: options.network.gateway,
+ dns: options.network.dns_server,
+ };
+
+ match &options.bootdisk.advanced {
+ AdvancedBootdiskOptions::Lvm(lvm) => {
+ config.hdsize = lvm.total_size;
+ config.target_hd = Some(options.bootdisk.disks[0].clone());
+ config.swapsize = lvm.swap_size;
+ config.maxroot = lvm.max_root_size;
+ config.minfree = lvm.min_lvm_free;
+ config.maxvz = lvm.max_data_size;
+ }
+ AdvancedBootdiskOptions::Zfs(zfs) => {
+ config.hdsize = zfs.disk_size;
+ config.zfs_opts = Some(zfs.clone().into());
+
+ for (i, disk) in options.bootdisk.disks.iter().enumerate() {
+ config
+ .disk_selection
+ .insert(i.to_string(), disk.index.clone());
+ }
+ }
+ AdvancedBootdiskOptions::Btrfs(btrfs) => {
+ config.hdsize = btrfs.disk_size;
+
+ for (i, disk) in options.bootdisk.disks.iter().enumerate() {
+ config
+ .disk_selection
+ .insert(i.to_string(), disk.index.clone());
+ }
+ }
+ }
+
+ config
+ }
+}
+
+pub fn read_json<T: for<'de> Deserialize<'de>, P: AsRef<Path>>(path: P) -> Result<T, String> {
+ let file = File::open(path).map_err(|err| err.to_string())?;
+ let reader = BufReader::new(file);
+
+ serde_json::from_reader(reader).map_err(|err| format!("failed to parse JSON: {err}"))
+}
+
+fn deserialize_bool_from_int<'de, D>(deserializer: D) -> Result<bool, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let val: u32 = Deserialize::deserialize(deserializer)?;
+ Ok(val != 0)
+}
+
+fn deserialize_cczones_map<'de, D>(
+ deserializer: D,
+) -> Result<HashMap<String, Vec<String>>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let map: HashMap<String, HashMap<String, u32>> = Deserialize::deserialize(deserializer)?;
+
+ let mut result = HashMap::new();
+ for (cc, list) in map.into_iter() {
+ result.insert(cc, list.into_keys().collect());
+ }
+
+ Ok(result)
+}
+
+fn deserialize_disks_map<'de, D>(deserializer: D) -> Result<Vec<Disk>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ let disks = <Vec<(usize, String, f64, String, usize, String)>>::deserialize(deserializer)?;
+ Ok(disks
+ .into_iter()
+ .map(
+ |(index, device, size_mb, model, logical_bsize, _syspath)| Disk {
+ index: index.to_string(),
+ size: (size_mb * logical_bsize as f64) / 1024. / 1024. / 1024.,
+ block_size: logical_bsize,
+ path: device,
+ model: (!model.is_empty()).then_some(model),
+ },
+ )
+ .collect())
+}
+
+fn deserialize_cidr_list<'de, D>(deserializer: D) -> Result<Option<Vec<CidrAddress>>, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ #[derive(Deserialize)]
+ struct CidrDescriptor {
+ address: String,
+ prefix: usize,
+ // family is implied anyway by parsing the address
+ }
+
+ let list: Vec<CidrDescriptor> = Deserialize::deserialize(deserializer)?;
+
+ let mut result = Vec::with_capacity(list.len());
+ for desc in list {
+ let ip_addr = desc
+ .address
+ .parse::<IpAddr>()
+ .map_err(|err| de::Error::custom(format!("{:?}", err)))?;
+
+ result.push(
+ CidrAddress::new(ip_addr, desc.prefix)
+ .map_err(|err| de::Error::custom(format!("{:?}", err)))?,
+ );
+ }
+
+ Ok(Some(result))
+}
+
+fn serialize_disk_opt<S>(value: &Option<Disk>, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ if let Some(disk) = value {
+ serializer.serialize_str(&disk.path)
+ } else {
+ serializer.serialize_none()
+ }
+}
+
+fn serialize_fstype<S>(value: &FsType, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+{
+ use FsType::*;
+ let value = match value {
+ // proxinstall::$fssetup
+ Ext4 => "ext4",
+ Xfs => "xfs",
+ // proxinstall::get_zfs_raid_setup()
+ Zfs(ZfsRaidLevel::Raid0) => "zfs (RAID0)",
+ Zfs(ZfsRaidLevel::Raid1) => "zfs (RAID1)",
+ Zfs(ZfsRaidLevel::Raid10) => "zfs (RAID10)",
+ Zfs(ZfsRaidLevel::RaidZ) => "zfs (RAIDZ-1)",
+ Zfs(ZfsRaidLevel::RaidZ2) => "zfs (RAIDZ-2)",
+ Zfs(ZfsRaidLevel::RaidZ3) => "zfs (RAIDZ-3)",
+ // proxinstall::get_btrfs_raid_setup()
+ Btrfs(BtrfsRaidLevel::Raid0) => "btrfs (RAID0)",
+ Btrfs(BtrfsRaidLevel::Raid1) => "btrfs (RAID1)",
+ Btrfs(BtrfsRaidLevel::Raid10) => "btrfs (RAID10)",
+ };
+
+ serializer.collect_str(value)
+}
+
+fn serialize_as_display<S, T>(value: &T, serializer: S) -> Result<S::Ok, S::Error>
+where
+ S: Serializer,
+ T: fmt::Display,
+{
+ serializer.collect_str(value)
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct RuntimeInfo {
+ /// Whether is system was booted in (legacy) BIOS or UEFI mode.
+ pub boot_type: BootType,
+
+ /// Detected country if available.
+ pub country: Option<String>,
+
+ /// Maps devices to their information.
+ #[serde(deserialize_with = "deserialize_disks_map")]
+ pub disks: Vec<Disk>,
+
+ /// Network addresses, gateways and DNS info.
+ pub network: NetworkInfo,
+
+ /// Total memory of the system in MiB.
+ pub total_memory: usize,
+
+ /// Whether the CPU supports hardware-accelerated virtualization
+ #[serde(deserialize_with = "deserialize_bool_from_int")]
+ pub hvm_supported: bool,
+}
+
+#[derive(Clone, Eq, Deserialize, PartialEq, Debug)]
+#[serde(rename_all = "lowercase")]
+pub enum BootType {
+ Bios,
+ Efi,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct NetworkInfo {
+ pub dns: Dns,
+ pub routes: Option<Routes>,
+
+ /// Maps devices to their configuration, if it has a usable configuration.
+ /// (Contains no entries for devices with only link-local addresses.)
+ #[serde(default)]
+ pub interfaces: BTreeMap<String, Interface>,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct Dns {
+ pub domain: Option<String>,
+
+ /// List of stringified IP addresses.
+ #[serde(default)]
+ pub dns: Vec<IpAddr>,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct Routes {
+ /// Ipv4 gateway.
+ pub gateway4: Option<Gateway>,
+
+ /// Ipv6 gateway.
+ pub gateway6: Option<Gateway>,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct Gateway {
+ /// Outgoing network device.
+ pub dev: String,
+
+ /// Stringified gateway IP address.
+ pub gateway: IpAddr,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct Interface {
+ pub name: String,
+
+ pub index: usize,
+
+ pub mac: String,
+
+ #[serde(default)]
+ #[serde(deserialize_with = "deserialize_cidr_list")]
+ pub addresses: Option<Vec<CidrAddress>>,
+}
diff --git a/proxmox-auto-installer/src/tui/utils.rs b/proxmox-auto-installer/src/tui/utils.rs
new file mode 100644
index 0000000..516f9c6
--- /dev/null
+++ b/proxmox-auto-installer/src/tui/utils.rs
@@ -0,0 +1,268 @@
+use std::{
+ fmt,
+ net::{AddrParseError, IpAddr},
+ num::ParseIntError,
+ str::FromStr,
+};
+
+use serde::Deserialize;
+
+/// Possible errors that might occur when parsing CIDR addresses.
+#[derive(Debug)]
+pub enum CidrAddressParseError {
+ /// No delimiter for separating address and mask was found.
+ NoDelimiter,
+ /// The IP address part could not be parsed.
+ InvalidAddr(AddrParseError),
+ /// The mask could not be parsed.
+ InvalidMask(Option<ParseIntError>),
+}
+
+/// An IP address (IPv4 or IPv6), including network mask.
+///
+/// See the [`IpAddr`] type for more information how IP addresses are handled.
+/// The mask is appropriately enforced to be `0 <= mask <= 32` for IPv4 or
+/// `0 <= mask <= 128` for IPv6 addresses.
+///
+/// # Examples
+/// ```
+/// use std::net::{Ipv4Addr, Ipv6Addr};
+/// let ipv4 = CidrAddress::new(Ipv4Addr::new(192, 168, 0, 1), 24).unwrap();
+/// let ipv6 = CidrAddress::new(Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0xc0a8, 1), 32).unwrap();
+///
+/// assert_eq!(ipv4.to_string(), "192.168.0.1/24");
+/// assert_eq!(ipv6.to_string(), "2001:db8::c0a8:1/32");
+/// ```
+#[derive(Clone, Debug)]
+pub struct CidrAddress {
+ addr: IpAddr,
+ mask: usize,
+}
+
+impl CidrAddress {
+ /// Constructs a new CIDR address.
+ ///
+ /// It fails if the mask is invalid for the given IP address.
+ pub fn new<T: Into<IpAddr>>(addr: T, mask: usize) -> Result<Self, CidrAddressParseError> {
+ let addr = addr.into();
+
+ if mask > mask_limit(&addr) {
+ Err(CidrAddressParseError::InvalidMask(None))
+ } else {
+ Ok(Self { addr, mask })
+ }
+ }
+
+ /// Returns only the IP address part of the address.
+ pub fn addr(&self) -> IpAddr {
+ self.addr
+ }
+
+ /// Returns `true` if this address is an IPv4 address, `false` otherwise.
+ pub fn is_ipv4(&self) -> bool {
+ self.addr.is_ipv4()
+ }
+
+ /// Returns `true` if this address is an IPv6 address, `false` otherwise.
+ pub fn is_ipv6(&self) -> bool {
+ self.addr.is_ipv6()
+ }
+
+ /// Returns only the mask part of the address.
+ pub fn mask(&self) -> usize {
+ self.mask
+ }
+}
+
+impl FromStr for CidrAddress {
+ type Err = CidrAddressParseError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ let (addr, mask) = s
+ .split_once('/')
+ .ok_or(CidrAddressParseError::NoDelimiter)?;
+
+ let addr = addr.parse().map_err(CidrAddressParseError::InvalidAddr)?;
+
+ let mask = mask
+ .parse()
+ .map_err(|err| CidrAddressParseError::InvalidMask(Some(err)))?;
+
+ if mask > mask_limit(&addr) {
+ Err(CidrAddressParseError::InvalidMask(None))
+ } else {
+ Ok(Self { addr, mask })
+ }
+ }
+}
+
+impl fmt::Display for CidrAddress {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}/{}", self.addr, self.mask)
+ }
+}
+
+fn mask_limit(addr: &IpAddr) -> usize {
+ if addr.is_ipv4() {
+ 32
+ } else {
+ 128
+ }
+}
+
+/// Possible errors that might occur when parsing FQDNs.
+#[derive(Debug, Eq, PartialEq)]
+pub enum FqdnParseError {
+ MissingHostname,
+ NumericHostname,
+ InvalidPart(String),
+}
+
+impl fmt::Display for FqdnParseError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ use FqdnParseError::*;
+ match self {
+ MissingHostname => write!(f, "missing hostname part"),
+ NumericHostname => write!(f, "hostname cannot be purely numeric"),
+ InvalidPart(part) => write!(
+ f,
+ "FQDN must only consist of alphanumeric characters and dashes. Invalid part: '{part}'",
+ ),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Fqdn {
+ parts: Vec<String>,
+}
+
+impl Fqdn {
+ pub fn from(fqdn: &str) -> Result<Self, FqdnParseError> {
+ let parts = fqdn
+ .split('.')
+ .map(ToOwned::to_owned)
+ .collect::<Vec<String>>();
+
+ for part in &parts {
+ if !Self::validate_single(part) {
+ return Err(FqdnParseError::InvalidPart(part.clone()));
+ }
+ }
+
+ if parts.len() < 2 {
+ Err(FqdnParseError::MissingHostname)
+ } else if parts[0].chars().all(|c| c.is_ascii_digit()) {
+ // Not allowed/supported on Debian systems.
+ Err(FqdnParseError::NumericHostname)
+ } else {
+ Ok(Self { parts })
+ }
+ }
+
+ pub fn host(&self) -> Option<&str> {
+ self.has_host().then_some(&self.parts[0])
+ }
+
+ pub fn domain(&self) -> String {
+ let parts = if self.has_host() {
+ &self.parts[1..]
+ } else {
+ &self.parts
+ };
+
+ parts.join(".")
+ }
+
+ /// Checks whether the FQDN has a hostname associated with it, i.e. is has more than 1 part.
+ fn has_host(&self) -> bool {
+ self.parts.len() > 1
+ }
+
+ fn validate_single(s: &String) -> bool {
+ !s.is_empty()
+ // First character must be alphanumeric
+ && s.chars()
+ .next()
+ .map(|c| c.is_ascii_alphanumeric())
+ .unwrap_or_default()
+ // .. last character as well,
+ && s.chars()
+ .last()
+ .map(|c| c.is_ascii_alphanumeric())
+ .unwrap_or_default()
+ // and anything between must be alphanumeric or -
+ && s.chars()
+ .skip(1)
+ .take(s.len().saturating_sub(2))
+ .all(|c| c.is_ascii_alphanumeric() || c == '-')
+ }
+}
+
+impl FromStr for Fqdn {
+ type Err = FqdnParseError;
+
+ fn from_str(value: &str) -> Result<Self, Self::Err> {
+ Self::from(value)
+ }
+}
+
+impl fmt::Display for Fqdn {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ write!(f, "{}", self.parts.join("."))
+ }
+}
+
+impl<'de> Deserialize<'de> for Fqdn {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ let s: String = Deserialize::deserialize(deserializer)?;
+ s.parse()
+ .map_err(|_| serde::de::Error::custom("invalid FQDN"))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn fqdn_construct() {
+ use FqdnParseError::*;
+ assert!(Fqdn::from("foo.example.com").is_ok());
+ assert!(Fqdn::from("foo-bar.com").is_ok());
+ assert!(Fqdn::from("a-b.com").is_ok());
+
+ assert_eq!(Fqdn::from("foo"), Err(MissingHostname));
+
+ assert_eq!(Fqdn::from("-foo.com"), Err(InvalidPart("-foo".to_owned())));
+ assert_eq!(Fqdn::from("foo-.com"), Err(InvalidPart("foo-".to_owned())));
+ assert_eq!(Fqdn::from("foo.com-"), Err(InvalidPart("com-".to_owned())));
+ assert_eq!(Fqdn::from("-o-.com"), Err(InvalidPart("-o-".to_owned())));
+
+ assert_eq!(Fqdn::from("123.com"), Err(NumericHostname));
+ assert!(Fqdn::from("foo123.com").is_ok());
+ assert!(Fqdn::from("123foo.com").is_ok());
+ }
+
+ #[test]
+ fn fqdn_parts() {
+ let fqdn = Fqdn::from("pve.example.com").unwrap();
+ assert_eq!(fqdn.host().unwrap(), "pve");
+ assert_eq!(fqdn.domain(), "example.com");
+ assert_eq!(
+ fqdn.parts,
+ &["pve".to_owned(), "example".to_owned(), "com".to_owned()]
+ );
+ }
+
+ #[test]
+ fn fqdn_display() {
+ assert_eq!(
+ Fqdn::from("foo.example.com").unwrap().to_string(),
+ "foo.example.com"
+ );
+ }
+}
diff --git a/proxmox-auto-installer/src/udevinfo.rs b/proxmox-auto-installer/src/udevinfo.rs
new file mode 100644
index 0000000..a6b61b5
--- /dev/null
+++ b/proxmox-auto-installer/src/udevinfo.rs
@@ -0,0 +1,9 @@
+use serde::Deserialize;
+use std::collections::BTreeMap;
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct UdevInfo {
+ // use BTreeMap to have keys sorted
+ pub disks: BTreeMap<String, BTreeMap<String, String>>,
+ pub nics: BTreeMap<String, BTreeMap<String, String>>,
+}
diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
new file mode 100644
index 0000000..6ff78ac
--- /dev/null
+++ b/proxmox-auto-installer/src/utils.rs
@@ -0,0 +1,325 @@
+use std::collections::BTreeMap;
+use std::net::IpAddr;
+use std::str::FromStr;
+use std::process::Command;
+
+use crate::answer::{Answer, FilterMatch};
+use crate::tui::options::{FsType, NetworkOptions};
+use crate::tui::setup::{InstallConfig, LocaleInfo, RuntimeInfo};
+use crate::tui::utils::{CidrAddress, Fqdn};
+use crate::udevinfo::UdevInfo;
+
+/// Supports the globbing character '*' at the beginning, end or both of the pattern.
+/// Globbing within the pattern is not supported
+fn find_with_glob(pattern: &str, value: &str) -> bool {
+ let globbing_symbol = '*';
+ let mut start_glob = false;
+ let mut end_glob = false;
+ let mut pattern = pattern;
+
+ if pattern.starts_with(globbing_symbol) {
+ start_glob = true;
+ pattern = &pattern[1..];
+ }
+
+ if pattern.ends_with(globbing_symbol) {
+ end_glob = true;
+ pattern = &pattern[..pattern.len() - 1]
+ }
+
+ match (start_glob, end_glob) {
+ (true, true) => value.contains(pattern),
+ (true, false) => value.ends_with(pattern),
+ (false, true) => value.starts_with(pattern),
+ _ => value == pattern,
+ }
+}
+
+pub fn get_network_settings(
+ answer: &Answer,
+ udev_info: &UdevInfo,
+ runtime_info: &RuntimeInfo,
+) -> Result<NetworkOptions, String> {
+ let mut network_options = NetworkOptions::from(&runtime_info.network);
+
+ // Always use the FQDN from the answer file
+ network_options.fqdn = Fqdn::from(answer.global.fqdn.as_str()).expect("Error parsing FQDN");
+
+ if answer.network.use_dhcp.is_none() || !answer.network.use_dhcp.unwrap() {
+ network_options.address = CidrAddress::from_str(
+ answer
+ .network
+ .cidr
+ .clone()
+ .expect("No CIDR defined")
+ .as_str(),
+ )
+ .expect("Error parsing CIDR");
+ network_options.dns_server = IpAddr::from_str(
+ answer
+ .network
+ .dns
+ .clone()
+ .expect("No DNS server defined")
+ .as_str(),
+ )
+ .expect("Error parsing DNS server");
+ network_options.gateway = IpAddr::from_str(
+ answer
+ .network
+ .gateway
+ .clone()
+ .expect("No gateway defined")
+ .as_str(),
+ )
+ .expect("Error parsing gateway");
+ network_options.ifname =
+ get_single_udev_index(answer.network.filter.clone().unwrap(), &udev_info.nics)?
+ }
+
+ Ok(network_options)
+}
+
+fn get_single_udev_index(
+ filter: BTreeMap<String, String>,
+ udev_list: &BTreeMap<String, BTreeMap<String, String>>,
+) -> Result<String, String> {
+ if filter.is_empty() {
+ return Err(String::from("no filter defined"));
+ }
+ let mut dev_index: Option<String> = None;
+ 'outer: for (dev, dev_values) in udev_list {
+ for (filter_key, filter_value) in &filter {
+ for (udev_key, udev_value) in dev_values {
+ if udev_key == filter_key && find_with_glob(filter_value, udev_value) {
+ dev_index = Some(dev.clone());
+ break 'outer; // take first match
+ }
+ }
+ }
+ }
+ if dev_index.is_none() {
+ return Err(String::from("filter did not match any device"));
+ }
+
+ Ok(dev_index.unwrap())
+}
+
+fn get_matched_udev_indexes(
+ filter: BTreeMap<String, String>,
+ udev_list: &BTreeMap<String, BTreeMap<String, String>>,
+ match_all: bool,
+) -> Result<Vec<String>, String> {
+ let mut matches = vec![];
+ for (dev, dev_values) in udev_list {
+ let mut did_match_once = false;
+ let mut did_match_all = true;
+ for (filter_key, filter_value) in &filter {
+ for (udev_key, udev_value) in dev_values {
+ if udev_key == filter_key && find_with_glob(filter_value, udev_value) {
+ did_match_once = true;
+ } else if udev_key == filter_key {
+ did_match_all = false;
+ }
+ }
+ }
+ if (match_all && did_match_all) || (!match_all && did_match_once) {
+ matches.push(dev.clone());
+ }
+ }
+ if matches.is_empty() {
+ return Err(String::from("filter did not match any devices"));
+ }
+ matches.sort();
+ Ok(matches)
+}
+
+pub fn set_disks(
+ answer: &Answer,
+ udev_info: &UdevInfo,
+ runtime_info: &RuntimeInfo,
+ config: &mut InstallConfig,
+) -> Result<(), String> {
+ match config.filesys {
+ FsType::Ext4 | FsType::Xfs => set_single_disk(answer, udev_info, runtime_info, config),
+ FsType::Zfs(_) | FsType::Btrfs(_) => {
+ set_selected_disks(answer, udev_info, runtime_info, config)
+ }
+ }
+}
+
+fn set_single_disk(
+ answer: &Answer,
+ udev_info: &UdevInfo,
+ runtime_info: &RuntimeInfo,
+ config: &mut InstallConfig,
+) -> Result<(), String> {
+ match &answer.disks.disk_selection {
+ Some(selection) => {
+ let disk_name = selection[0].clone();
+ let disk = runtime_info
+ .disks
+ .iter()
+ .find(|item| item.path.ends_with(disk_name.as_str()));
+ match disk {
+ Some(disk) => config.target_hd = Some(disk.clone()),
+ None => return Err("disk in 'disk_selection' not found".to_string()),
+ }
+ }
+ None => {
+ let disk_index =
+ get_single_udev_index(answer.disks.filter.clone().unwrap(), &udev_info.disks)?;
+ let disk = runtime_info
+ .disks
+ .iter()
+ .find(|item| item.index == disk_index);
+ config.target_hd = disk.cloned();
+ }
+ }
+ Ok(())
+}
+
+fn set_selected_disks(
+ answer: &Answer,
+ udev_info: &UdevInfo,
+ runtime_info: &RuntimeInfo,
+ config: &mut InstallConfig,
+) -> Result<(), String> {
+ match &answer.disks.disk_selection {
+ Some(selection) => {
+ for disk_name in selection {
+ let disk = runtime_info
+ .disks
+ .iter()
+ .find(|item| item.path.ends_with(disk_name.as_str()));
+ if let Some(disk) = disk {
+ config
+ .disk_selection
+ .insert(disk.index.clone(), disk.index.clone());
+ }
+ }
+ }
+ None => {
+ let filter_match = answer
+ .disks
+ .filter_match
+ .clone()
+ .unwrap_or(FilterMatch::Any);
+ let selected_disk_indexes = get_matched_udev_indexes(
+ answer.disks.filter.clone().unwrap(),
+ &udev_info.disks,
+ filter_match == FilterMatch::All,
+ )?;
+
+ for i in selected_disk_indexes.into_iter() {
+ let disk = runtime_info
+ .disks
+ .iter()
+ .find(|item| item.index == i)
+ .unwrap();
+ config
+ .disk_selection
+ .insert(disk.index.clone(), disk.index.clone());
+ }
+ }
+ }
+ if config.disk_selection.is_empty() {
+ return Err("No disks found matching selection.".to_string());
+ }
+ Ok(())
+}
+
+pub fn get_first_selected_disk(config: &InstallConfig) -> usize {
+ config
+ .disk_selection
+ .iter()
+ .next()
+ .expect("no disks found")
+ .0
+ .parse::<usize>()
+ .expect("could not parse key to usize")
+}
+
+pub fn verify_locale_settings(answer: &Answer, locales: &LocaleInfo) -> Result<(), String> {
+ if !locales
+ .countries
+ .keys()
+ .any(|i| i == &answer.global.country)
+ {
+ return Err(format!(
+ "country code '{}' is not valid",
+ &answer.global.country
+ ));
+ }
+ if !locales.kmap.keys().any(|i| i == &answer.global.keyboard) {
+ return Err(format!(
+ "keyboard layout '{}' is not valid",
+ &answer.global.keyboard
+ ));
+ }
+ if !locales
+ .cczones
+ .iter()
+ .any(|(_, zones)| zones.contains(&answer.global.timezone))
+ {
+ return Err(format!(
+ "timezone '{}' is not valid",
+ &answer.global.timezone
+ ));
+ }
+ Ok(())
+}
+
+pub fn run_cmds(step: &str, cmd_vec: &Option<Vec<String>>) -> Result<(), String> {
+ if let Some(cmds) = cmd_vec {
+ if !cmds.is_empty() {
+ println!("Running {step}-Commands:");
+ run_cmd(cmds)?;
+ println!("{step}-Commands finished");
+ }
+ }
+ Ok(())
+}
+
+fn run_cmd(cmds: &Vec<String>) -> Result<(), String> {
+ for cmd in cmds {
+ #[cfg(debug_assertions)]
+ println!("Would run command '{}'", cmd);
+
+// #[cfg(not(debug_assertions))]
+ {
+ println!("Command '{}':", cmd);
+ let output = Command::new("/bin/bash")
+ .arg("-c")
+ .arg(cmd.clone())
+ .output()
+ .map_err(|err| format!("error running command {}: {err}", cmd))?;
+ print!(
+ "{}",
+ String::from_utf8(output.stdout).map_err(|err| format!("{err}"))?
+ );
+ print!(
+ "{}",
+ String::from_utf8(output.stderr).map_err(|err| format!("{err}"))?
+ );
+ if !output.status.success() {
+ return Err("command failed".to_string());
+ }
+ }
+ }
+
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn test_glob_patterns() {
+ let test_value = "foobar";
+ assert_eq!(find_with_glob("*bar", test_value), true);
+ assert_eq!(find_with_glob("foo*", test_value), true);
+ assert_eq!(find_with_glob("foobar", test_value), true);
+ assert_eq!(find_with_glob("oobar", test_value), false);
+ }
+}
--
2.39.2
next prev parent reply other threads:[~2023-09-05 13:29 UTC|newest]
Thread overview: 11+ messages / expand[flat|nested] mbox.gz Atom feed top
2023-09-05 13:28 [pve-devel] [RFC installer 0/6] add automated installation Aaron Lauterer
2023-09-05 13:28 ` [pve-devel] [RFC installer 1/6] low level: sys: fetch udev properties Aaron Lauterer
2023-09-05 13:28 ` Aaron Lauterer [this message]
2023-09-21 11:16 ` [pve-devel] [RFC installer 2/6] add proxmox-auto-installer Christoph Heiss
2023-09-21 11:30 ` Thomas Lamprecht
2023-09-21 11:39 ` Christoph Heiss
2023-09-05 13:28 ` [pve-devel] [RFC installer 3/6] add answer file fetch script Aaron Lauterer
2023-09-20 9:52 ` Christoph Heiss
2023-09-05 13:28 ` [pve-devel] [PATCH installer 4/6] makefile: fix handling of multiple usr_bin files Aaron Lauterer
2023-09-05 13:28 ` [pve-devel] [RFC installer 5/6] makefile: add auto installer Aaron Lauterer
2023-09-05 13:28 ` [pve-devel] [RFC docs 6/6] installation: add unattended documentation Aaron Lauterer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20230905132832.3179097-3-a.lauterer@proxmox.com \
--to=a.lauterer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox