From mboxrd@z Thu Jan  1 00:00:00 1970
Return-Path: <a.lauterer@proxmox.com>
Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by lists.proxmox.com (Postfix) with ESMTPS id 34F2B9ACE
 for <pve-devel@lists.proxmox.com>; Tue,  5 Sep 2023 15:29:12 +0200 (CEST)
Received: from firstgate.proxmox.com (localhost [127.0.0.1])
 by firstgate.proxmox.com (Proxmox) with ESMTP id 15EDB19F38
 for <pve-devel@lists.proxmox.com>; Tue,  5 Sep 2023 15:28:42 +0200 (CEST)
Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com
 [94.136.29.106])
 (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits)
 key-exchange X25519 server-signature RSA-PSS (2048 bits))
 (No client certificate requested)
 by firstgate.proxmox.com (Proxmox) with ESMTPS
 for <pve-devel@lists.proxmox.com>; Tue,  5 Sep 2023 15:28:38 +0200 (CEST)
Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1])
 by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 3C3914226F
 for <pve-devel@lists.proxmox.com>; Tue,  5 Sep 2023 15:28:38 +0200 (CEST)
From: Aaron Lauterer <a.lauterer@proxmox.com>
To: pve-devel@lists.proxmox.com
Date: Tue,  5 Sep 2023 15:28:28 +0200
Message-Id: <20230905132832.3179097-3-a.lauterer@proxmox.com>
X-Mailer: git-send-email 2.39.2
In-Reply-To: <20230905132832.3179097-1-a.lauterer@proxmox.com>
References: <20230905132832.3179097-1-a.lauterer@proxmox.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: quoted-printable
X-SPAM-LEVEL: Spam detection results:  0
 AWL -0.079 Adjusted score from AWL reputation of From: address
 BAYES_00                 -1.9 Bayes spam probability is 0 to 1%
 DMARC_MISSING             0.1 Missing DMARC policy
 KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment
 SPF_HELO_NONE           0.001 SPF: HELO does not publish an SPF Record
 SPF_PASS               -0.001 SPF: sender matches SPF record
 WEIRD_QUOTING           0.001 Weird repeated double-quotation marks
X-Mailman-Approved-At: Wed, 06 Sep 2023 09:29:54 +0200
Subject: [pve-devel] [RFC installer 2/6] add proxmox-auto-installer
X-BeenThere: pve-devel@lists.proxmox.com
X-Mailman-Version: 2.1.29
Precedence: list
List-Id: Proxmox VE development discussion <pve-devel.lists.proxmox.com>
List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=unsubscribe>
List-Archive: <http://lists.proxmox.com/pipermail/pve-devel/>
List-Post: <mailto:pve-devel@lists.proxmox.com>
List-Help: <mailto:pve-devel-request@lists.proxmox.com?subject=help>
List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel>, 
 <mailto:pve-devel-request@lists.proxmox.com?subject=subscribe>
X-List-Received-Date: Tue, 05 Sep 2023 13:29:12 -0000

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/mini=
mal.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/mini=
mal.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/read=
me
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/spec=
ific_nic.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/spec=
ific_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 =3D [
+    "proxmox-auto-installer",
     "proxmox-tui-installer",
 ]
=20
diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Car=
go.toml
new file mode 100644
index 0000000..fd38d28
--- /dev/null
+++ b/proxmox-auto-installer/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name =3D "proxmox-auto-installer"
+version =3D "0.1.0"
+edition =3D "2021"
+authors =3D [ "Aaron Lauerer <a.lauterer@proxmox.com" ]
+license =3D "AGPL-3"
+exclude =3D [ "build", "debian" ]
+homepage =3D "https://www.proxmox.com"
+
+[dependencies]
+serde =3D { version =3D "1.0", features =3D ["derive"] }
+serde_json =3D "1.0"
+toml =3D "0.5.11"
diff --git a/proxmox-auto-installer/answer.toml b/proxmox-auto-installer/an=
swer.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 =3D "de"
+country =3D "at"
+fqdn =3D "pve.intern"
+mailto =3D "mail@example.invalid"
+timezone =3D "Europe/Vienna"
+password =3D "123456"
+pre_command =3D [ "sgdisk -Z /dev/sd*" ] # maybe run commands before calli=
ng installer to clean up some stuff?
+post_command =3D [ "wget http://setup/done" ] # maybe give the option to d=
o some things afterwards? Like sending an HTTP request to indicate that the=
 setup is done?
+
+[network]
+use_dhcp =3D true # optional makes everything else obsolete in this section
+cidr =3D "10.9.9.240/24"
+dns =3D "10.9.9.2"
+gateway =3D "10.9.9.1"
+nic =3D "enp6s0" # takes precedence over filter
+filter.ID_NET_NAME =3D "enp6s18"
+#filter.ID_VENDOR_FROM_DATABASE =3D "Realtek*"
+
+[disks]
+filesystem =3D "zfs-raid1"
+disk_selection =3D ["sda", "sdb"]
+filter_match =3D "any" # "all" as other option, default is "any"
+filter.ID_SERIAL =3D "*_SN850X_*"
+zfs.ashift =3D 12
+zfs.checksum =3D "on"
+zfs.compress =3D "lz4"
+zfs.copies =3D 2
+#lvm.hdsize =3D 80.0
+#lvm.swapsize =3D 6
+#lvm.maxroot =3D 20
+#lvm.maxvz =3D 50
+#lvm.minfree =3D 4
+#btrfs.hdsize =3D 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","prod=
uctlong":"Proxmox VE","release":"8.0"},"locations":{"iso":"/cdrom","lib":"/=
var/lib/proxmox-installer","pkg":"/cdrom/proxmox/packages/","run":"/run/pro=
xmox-installer"},"product":"pve","product-cfg":{"bridged_network":1,"enable=
_btrfs":1,"fullname":"Proxmox VE","port":"8006","product":"pve"},"run-env-c=
ache-file":"/run/proxmox-installer/run-env-info.json"}
diff --git a/proxmox-auto-installer/resources/test/locales.json b/proxmox-a=
uto-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/Ka=
bul":1},"ag":{"America/Antigua":1},"ai":{"America/Anguilla":1},"al":{"Europ=
e/Tirane":1},"am":{"Asia/Yerevan":1},"ao":{"Africa/Luanda":1},"aq":{"Antarc=
tica/Casey":1,"Antarctica/Davis":1,"Antarctica/DumontDUrville":1,"Antarctic=
a/Mawson":1,"Antarctica/McMurdo":1,"Antarctica/Palmer":1,"Antarctica/Rother=
a":1,"Antarctica/Syowa":1,"Antarctica/Troll":1,"Antarctica/Vostok":1},"ar":=
{"America/Argentina/Buenos_Aires":1,"America/Argentina/Catamarca":1,"Americ=
a/Argentina/Cordoba":1,"America/Argentina/Jujuy":1,"America/Argentina/La_Ri=
oja":1,"America/Argentina/Mendoza":1,"America/Argentina/Rio_Gallegos":1,"Am=
erica/Argentina/Salta":1,"America/Argentina/San_Juan":1,"America/Argentina/=
San_Luis":1,"America/Argentina/Tucuman":1,"America/Argentina/Ushuaia":1},"a=
s":{"Pacific/Pago_Pago":1},"at":{"Europe/Vienna":1},"au":{"Antarctica/Macqu=
arie":1,"Australia/Adelaide":1,"Australia/Brisbane":1,"Australia/Broken_Hil=
l":1,"Australia/Darwin":1,"Australia/Eucla":1,"Australia/Hobart":1,"Austral=
ia/Lindeman":1,"Australia/Lord_Howe":1,"Australia/Melbourne":1,"Australia/P=
erth":1,"Australia/Sydney":1},"aw":{"America/Aruba":1},"ax":{"Europe/Marieh=
amn":1},"az":{"Asia/Baku":1},"ba":{"Europe/Sarajevo":1},"bb":{"America/Barb=
ados":1},"bd":{"Asia/Dhaka":1},"be":{"Europe/Brussels":1},"bf":{"Africa/Oua=
gadougou":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,"Ameri=
ca/Cuiaba":1,"America/Eirunepe":1,"America/Fortaleza":1,"America/Maceio":1,=
"America/Manaus":1,"America/Noronha":1,"America/Porto_Velho":1,"America/Rec=
ife":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/Fo=
rt_Nelson":1,"America/Glace_Bay":1,"America/Goose_Bay":1,"America/Halifax":=
1,"America/Inuvik":1,"America/Iqaluit":1,"America/Moncton":1,"America/Ranki=
n_Inlet":1,"America/Regina":1,"America/Resolute":1,"America/St_Johns":1,"Am=
erica/Swift_Current":1,"America/Toronto":1,"America/Vancouver":1,"America/W=
hitehorse":1,"America/Winnipeg":1},"cc":{"Indian/Cocos":1},"cd":{"Africa/Ki=
nshasa":1,"Africa/Lubumbashi":1},"cf":{"Africa/Bangui":1},"cg":{"Africa/Bra=
zzaville":1},"ch":{"Europe/Zurich":1},"ci":{"Africa/Abidjan":1},"ck":{"Paci=
fic/Rarotonga":1},"cl":{"America/Punta_Arenas":1,"America/Santiago":1,"Paci=
fic/Easter":1},"cm":{"Africa/Douala":1},"cn":{"Asia/Shanghai":1,"Asia/Urumq=
i":1},"co":{"America/Bogota":1},"cr":{"America/Costa_Rica":1},"cu":{"Americ=
a/Havana":1},"cv":{"Atlantic/Cape_Verde":1},"cw":{"America/Curacao":1},"cx"=
:{"Indian/Christmas":1},"cy":{"Asia/Famagusta":1,"Asia/Nicosia":1},"cz":{"E=
urope/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/Guaya=
quil":1,"Pacific/Galapagos":1},"ee":{"Europe/Tallinn":1},"eg":{"Africa/Cair=
o":1},"eh":{"Africa/El_Aaiun":1},"er":{"Africa/Asmara":1},"es":{"Africa/Ceu=
ta":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},"g=
f":{"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":{"Amer=
ica/Guyana":1},"hk":{"Asia/Hong_Kong":1},"hn":{"America/Tegucigalpa":1},"hr=
":{"Europe/Zagreb":1},"ht":{"America/Port-au-Prince":1},"hu":{"Europe/Budap=
est":1},"id":{"Asia/Jakarta":1,"Asia/Jayapura":1,"Asia/Makassar":1,"Asia/Po=
ntianak":1},"ie":{"Europe/Dublin":1},"il":{"Asia/Jerusalem":1},"im":{"Europ=
e/Isle_of_Man":1},"in":{"Asia/Kolkata":1},"io":{"Indian/Chagos":1},"iq":{"A=
sia/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/K=
iritimati":1,"Pacific/Tarawa":1},"km":{"Indian/Comoro":1},"kn":{"America/St=
_Kitts":1},"kp":{"Asia/Pyongyang":1},"kr":{"Asia/Seoul":1},"kw":{"Asia/Kuwa=
it":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_Luci=
a":1},"li":{"Europe/Vaduz":1},"lk":{"Asia/Colombo":1},"lr":{"Africa/Monrovi=
a":1},"ls":{"Africa/Maseru":1},"lt":{"Europe/Vilnius":1},"lu":{"Europe/Luxe=
mbourg":1},"lv":{"Europe/Riga":1},"ly":{"Africa/Tripoli":1},"ma":{"Africa/C=
asablanca":1},"mc":{"Europe/Monaco":1},"md":{"Europe/Chisinau":1},"me":{"Eu=
rope/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/Sai=
pan":1},"mq":{"America/Martinique":1},"mr":{"Africa/Nouakchott":1},"ms":{"A=
merica/Montserrat":1},"mt":{"Europe/Malta":1},"mu":{"Indian/Mauritius":1},"=
mv":{"Indian/Maldives":1},"mw":{"Africa/Blantyre":1},"mx":{"America/Bahia_B=
anderas":1,"America/Cancun":1,"America/Chihuahua":1,"America/Ciudad_Juarez"=
:1,"America/Hermosillo":1,"America/Matamoros":1,"America/Mazatlan":1,"Ameri=
ca/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,"P=
acific/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":{"A=
sia/Anadyr":1,"Asia/Barnaul":1,"Asia/Chita":1,"Asia/Irkutsk":1,"Asia/Kamcha=
tka":1,"Asia/Khandyga":1,"Asia/Krasnoyarsk":1,"Asia/Magadan":1,"Asia/Novoku=
znetsk":1,"Asia/Novosibirsk":1,"Asia/Omsk":1,"Asia/Sakhalin":1,"Asia/Sredne=
kolymsk":1,"Asia/Tomsk":1,"Asia/Ust-Nera":1,"Asia/Vladivostok":1,"Asia/Yaku=
tsk":1,"Asia/Yekaterinburg":1,"Europe/Astrakhan":1,"Europe/Kaliningrad":1,"=
Europe/Kirov":1,"Europe/Moscow":1,"Europe/Samara":1,"Europe/Saratov":1,"Eur=
ope/Ulyanovsk":1,"Europe/Volgograd":1},"rw":{"Africa/Kigali":1},"sa":{"Asia=
/Riyadh":1},"sb":{"Pacific/Guadalcanal":1},"sc":{"Indian/Mahe":1},"sd":{"Af=
rica/Khartoum":1},"se":{"Europe/Stockholm":1},"sg":{"Asia/Singapore":1},"sh=
":{"Atlantic/St_Helena":1},"si":{"Europe/Ljubljana":1},"sj":{"Arctic/Longye=
arbyen":1},"sk":{"Europe/Bratislava":1},"sl":{"Africa/Freetown":1},"sm":{"E=
urope/San_Marino":1},"sn":{"Africa/Dakar":1},"so":{"Africa/Mogadishu":1},"s=
r":{"America/Paramaribo":1},"ss":{"Africa/Juba":1},"st":{"Africa/Sao_Tome":=
1},"sv":{"America/El_Salvador":1},"sx":{"America/Lower_Princes":1},"sy":{"A=
sia/Damascus":1},"sz":{"Africa/Mbabane":1},"tc":{"America/Grand_Turk":1},"t=
d":{"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":{"Afri=
ca/Kampala":1},"um":{"Pacific/Midway":1,"Pacific/Wake":1},"us":{"America/Ad=
ak":1,"America/Anchorage":1,"America/Boise":1,"America/Chicago":1,"America/=
Denver":1,"America/Detroit":1,"America/Indiana/Indianapolis":1,"America/Ind=
iana/Knox":1,"America/Indiana/Marengo":1,"America/Indiana/Petersburg":1,"Am=
erica/Indiana/Tell_City":1,"America/Indiana/Vevay":1,"America/Indiana/Vince=
nnes":1,"America/Indiana/Winamac":1,"America/Juneau":1,"America/Kentucky/Lo=
uisville":1,"America/Kentucky/Monticello":1,"America/Los_Angeles":1,"Americ=
a/Menominee":1,"America/Metlakatla":1,"America/New_York":1,"America/Nome":1=
,"America/North_Dakota/Beulah":1,"America/North_Dakota/Center":1,"America/N=
orth_Dakota/New_Salem":1,"America/Phoenix":1,"America/Sitka":1,"America/Yak=
utat":1,"Pacific/Honolulu":1},"uy":{"America/Montevideo":1},"uz":{"Asia/Sam=
arkand":1,"Asia/Tashkent":1},"va":{"Europe/Vatican":1},"vc":{"America/St_Vi=
ncent":1},"ve":{"America/Caracas":1},"vg":{"America/Tortola":1},"vi":{"Amer=
ica/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":{"I=
ndian/Mayotte":1},"za":{"Africa/Johannesburg":1},"zm":{"Africa/Lusaka":1},"=
zw":{"Africa/Harare":1}},"country":{"ad":{"kmap":"","mirror":"","name":"And=
orra","zone":"Europe/Andorra"},"ae":{"kmap":"","mirror":"","name":"United A=
rab Emirates","zone":"Asia/Dubai"},"af":{"kmap":"","mirror":"","name":"Afgh=
anistan","zone":"Asia/Kabul"},"ag":{"kmap":"","mirror":"","name":"Antigua a=
nd Barbuda","zone":"America/Antigua"},"ai":{"kmap":"","mirror":"","name":"A=
nguilla","zone":"America/Anguilla"},"al":{"kmap":"","mirror":"","name":"Alb=
ania","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":"Ant=
arctica/McMurdo"},"ar":{"kmap":"","mirror":"","name":"Argentina","zone":"Am=
erica/Argentina/Buenos_Aires"},"as":{"kmap":"","mirror":"","name":"American=
 Samoa","zone":"Pacific/Pago_Pago"},"at":{"kmap":"de","mirror":"ftp.at.debi=
an.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":"=C3=85land 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/Brus=
sels"},"bf":{"kmap":"","mirror":"","name":"Burkina Faso","zone":"Africa/Oua=
gadougou"},"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=C3=A9lemy","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 Eustati=
us and Saba","zone":"America/Kralendijk"},"br":{"kmap":"pt-br","mirror":"ft=
p.br.debian.org","name":"Brazil","zone":"America/Noronha"},"bs":{"kmap":"",=
"mirror":"","name":"Bahamas","zone":"America/Nassau"},"bt":{"kmap":"","mirr=
or":"","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":"Eu=
rope/Minsk"},"bz":{"kmap":"","mirror":"","name":"Belize","zone":"America/Be=
lize"},"ca":{"kmap":"en-us","mirror":"ftp.ca.debian.org","name":"Canada","z=
one":"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":"Eur=
ope/Zurich"},"ci":{"kmap":"","mirror":"","name":"C=C3=B4te 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":"Camer=
oon","zone":"Africa/Douala"},"cn":{"kmap":"","mirror":"","name":"China","zo=
ne":"Asia/Shanghai"},"co":{"kmap":"","mirror":"","name":"Colombia","zone":"=
America/Bogota"},"cr":{"kmap":"","mirror":"","name":"Costa Rica","zone":"Am=
erica/Costa_Rica"},"cu":{"kmap":"","mirror":"","name":"Cuba","zone":"Americ=
a/Havana"},"cv":{"kmap":"","mirror":"","name":"Cabo Verde","zone":"Atlantic=
/Cape_Verde"},"cw":{"kmap":"","mirror":"","name":"Cura=C3=A7ao","zone":"Ame=
rica/Curacao"},"cx":{"kmap":"","mirror":"","name":"Christmas Island","zone"=
:"Indian/Christmas"},"cy":{"kmap":"","mirror":"","name":"Cyprus","zone":"As=
ia/Nicosia"},"cz":{"kmap":"","mirror":"ftp.cz.debian.org","name":"Czechia",=
"zone":"Europe/Prague"},"de":{"kmap":"de","mirror":"ftp.de.debian.org","nam=
e":"Germany","zone":"Europe/Berlin"},"dj":{"kmap":"","mirror":"","name":"Dj=
ibouti","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":"","m=
irror":"","name":"Ecuador","zone":"America/Guayaquil"},"ee":{"kmap":"","mir=
ror":"ftp.ee.debian.org","name":"Estonia","zone":"Europe/Tallinn"},"eg":{"k=
map":"","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","m=
irror":"ftp.es.debian.org","name":"Spain","zone":"Europe/Madrid"},"et":{"km=
ap":"","mirror":"","name":"Ethiopia","zone":"Africa/Addis_Ababa"},"fi":{"km=
ap":"fi","mirror":"ftp.fi.debian.org","name":"Finland","zone":"Europe/Helsi=
nki"},"fj":{"kmap":"","mirror":"","name":"Fiji","zone":"Pacific/Fiji"},"fk"=
:{"kmap":"","mirror":"","name":"Falkland Islands (Malvinas)","zone":"Atlant=
ic/Stanley"},"fm":{"kmap":"","mirror":"","name":"Micronesia, Federated Stat=
es of","zone":"Pacific/Chuuk"},"fo":{"kmap":"","mirror":"","name":"Faroe Is=
lands","zone":"Atlantic/Faroe"},"fr":{"kmap":"fr","mirror":"ftp.fr.debian.o=
rg","name":"France","zone":"Europe/Paris"},"ga":{"kmap":"","mirror":"","nam=
e":"Gabon","zone":"Africa/Libreville"},"gb":{"kmap":"en-gb","mirror":"ftp.u=
k.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":"","mirro=
r":"","name":"Guernsey","zone":"Europe/Guernsey"},"gh":{"kmap":"","mirror":=
"","name":"Ghana","zone":"Africa/Accra"},"gi":{"kmap":"es","mirror":"","nam=
e":"Gibraltar","zone":"Europe/Gibraltar"},"gl":{"kmap":"","mirror":"","name=
":"Greenland","zone":"America/Nuuk"},"gm":{"kmap":"","mirror":"","name":"Ga=
mbia","zone":"Africa/Banjul"},"gn":{"kmap":"","mirror":"","name":"Guinea","=
zone":"Africa/Conakry"},"gp":{"kmap":"","mirror":"","name":"Guadeloupe","zo=
ne":"America/Guadeloupe"},"gq":{"kmap":"","mirror":"","name":"Equatorial Gu=
inea","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_Georg=
ia"},"gt":{"kmap":"","mirror":"","name":"Guatemala","zone":"America/Guatema=
la"},"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":"Euro=
pe/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":"Israe=
l","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 Oc=
ean Territory","zone":"Indian/Chagos"},"iq":{"kmap":"","mirror":"","name":"=
Iraq","zone":"Asia/Baghdad"},"ir":{"kmap":"","mirror":"","name":"Iran","zon=
e":"Asia/Tehran"},"is":{"kmap":"is","mirror":"ftp.is.debian.org","name":"Ic=
eland","zone":"Atlantic/Reykjavik"},"it":{"kmap":"it","mirror":"ftp.it.debi=
an.org","name":"Italy","zone":"Europe/Rome"},"je":{"kmap":"","mirror":"","n=
ame":"Jersey","zone":"Europe/Jersey"},"jm":{"kmap":"","mirror":"","name":"J=
amaica","zone":"America/Jamaica"},"jo":{"kmap":"","mirror":"","name":"Jorda=
n","zone":"Asia/Amman"},"jp":{"kmap":"jp","mirror":"ftp.jp.debian.org","nam=
e":"Japan","zone":"Asia/Tokyo"},"ke":{"kmap":"","mirror":"","name":"Kenya",=
"zone":"Africa/Nairobi"},"kg":{"kmap":"","mirror":"","name":"Kyrgyzstan","z=
one":"Asia/Bishkek"},"kh":{"kmap":"","mirror":"","name":"Cambodia","zone":"=
Asia/Phnom_Penh"},"ki":{"kmap":"","mirror":"","name":"Kiribati","zone":"Pac=
ific/Tarawa"},"km":{"kmap":"","mirror":"","name":"Comoros","zone":"Indian/C=
omoro"},"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":"Sout=
h Korea","zone":"Asia/Seoul"},"kw":{"kmap":"","mirror":"","name":"Kuwait","=
zone":"Asia/Kuwait"},"ky":{"kmap":"","mirror":"","name":"Cayman Islands","z=
one":"America/Cayman"},"kz":{"kmap":"","mirror":"","name":"Kazakhstan","zon=
e":"Asia/Almaty"},"la":{"kmap":"","mirror":"","name":"Laos","zone":"Asia/Vi=
entiane"},"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/V=
aduz"},"lk":{"kmap":"","mirror":"","name":"Sri Lanka","zone":"Asia/Colombo"=
},"lr":{"kmap":"","mirror":"","name":"Liberia","zone":"Africa/Monrovia"},"l=
s":{"kmap":"","mirror":"","name":"Lesotho","zone":"Africa/Maseru"},"lt":{"k=
map":"lt","mirror":"","name":"Lithuania","zone":"Europe/Vilnius"},"lu":{"km=
ap":"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":"","mi=
rror":"","name":"Morocco","zone":"Africa/Casablanca"},"mc":{"kmap":"","mirr=
or":"","name":"Monaco","zone":"Europe/Monaco"},"md":{"kmap":"","mirror":"",=
"name":"Moldova","zone":"Europe/Chisinau"},"me":{"kmap":"","mirror":"","nam=
e":"Montenegro","zone":"Europe/Podgorica"},"mf":{"kmap":"","mirror":"","nam=
e":"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":{"kma=
p":"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":"","mirro=
r":"","name":"Mauritania","zone":"Africa/Nouakchott"},"ms":{"kmap":"","mirr=
or":"","name":"Montserrat","zone":"America/Montserrat"},"mt":{"kmap":"","mi=
rror":"","name":"Malta","zone":"Europe/Malta"},"mu":{"kmap":"","mirror":"",=
"name":"Mauritius","zone":"Indian/Mauritius"},"mv":{"kmap":"","mirror":"","=
name":"Maldives","zone":"Indian/Maldives"},"mw":{"kmap":"","mirror":"","nam=
e":"Malawi","zone":"Africa/Blantyre"},"mx":{"kmap":"","mirror":"ftp.mx.debi=
an.org","name":"Mexico","zone":"America/Mexico_City"},"my":{"kmap":"","mirr=
or":"","name":"Malaysia","zone":"Asia/Kuala_Lumpur"},"mz":{"kmap":"","mirro=
r":"","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":"Nica=
ragua","zone":"America/Managua"},"nl":{"kmap":"en-us","mirror":"ftp.nl.debi=
an.org","name":"Netherlands","zone":"Europe/Amsterdam"},"no":{"kmap":"no","=
mirror":"ftp.no.debian.org","name":"Norway","zone":"Europe/Oslo"},"np":{"km=
ap":"","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.d=
ebian.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":"Pap=
ua 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.debi=
an.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":{"kma=
p":"","mirror":"","name":"Palestine, State of","zone":"Asia/Gaza"},"pt":{"k=
map":"pt","mirror":"ftp.pt.debian.org","name":"Portugal","zone":"Europe/Lis=
bon"},"pw":{"kmap":"","mirror":"","name":"Palau","zone":"Pacific/Palau"},"p=
y":{"kmap":"","mirror":"","name":"Paraguay","zone":"America/Asuncion"},"qa"=
:{"kmap":"","mirror":"","name":"Qatar","zone":"Asia/Qatar"},"re":{"kmap":""=
,"mirror":"","name":"R=C3=A9union","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":"Af=
rica/Kigali"},"sa":{"kmap":"","mirror":"","name":"Saudi Arabia","zone":"Asi=
a/Riyadh"},"sb":{"kmap":"","mirror":"","name":"Solomon Islands","zone":"Pac=
ific/Guadalcanal"},"sc":{"kmap":"","mirror":"","name":"Seychelles","zone":"=
Indian/Mahe"},"sd":{"kmap":"","mirror":"","name":"Sudan","zone":"Africa/Kha=
rtoum"},"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, Ascensi=
on and Tristan da Cunha","zone":"Atlantic/St_Helena"},"si":{"kmap":"si","mi=
rror":"ftp.si.debian.org","name":"Slovenia","zone":"Europe/Ljubljana"},"sj"=
:{"kmap":"","mirror":"","name":"Svalbard and Jan Mayen","zone":"Arctic/Long=
yearbyen"},"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":"Am=
erica/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 Maart=
en (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":"Tu=
rks and Caicos Islands","zone":"America/Grand_Turk"},"td":{"kmap":"","mirro=
r":"","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":"","nam=
e":"Tokelau","zone":"Pacific/Fakaofo"},"tl":{"kmap":"","mirror":"","name":"=
Timor-Leste","zone":"Asia/Dili"},"tm":{"kmap":"","mirror":"","name":"Turkme=
nistan","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=
=C3=BCrkiye","zone":"Europe/Istanbul"},"tt":{"kmap":"","mirror":"","name":"=
Trinidad and Tobago","zone":"America/Port_of_Spain"},"tv":{"kmap":"","mirro=
r":"","name":"Tuvalu","zone":"Pacific/Funafuti"},"tw":{"kmap":"","mirror":"=
ftp.tw.debian.org","name":"Taiwan","zone":"Asia/Taipei"},"tz":{"kmap":"","m=
irror":"","name":"Tanzania","zone":"Africa/Dar_es_Salaam"},"ua":{"kmap":"",=
"mirror":"","name":"Ukraine","zone":"Europe/Simferopol"},"ug":{"kmap":"","m=
irror":"","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","z=
one":"America/New_York"},"uy":{"kmap":"","mirror":"","name":"Uruguay","zone=
":"America/Montevideo"},"uz":{"kmap":"","mirror":"","name":"Uzbekistan","zo=
ne":"Asia/Samarkand"},"va":{"kmap":"it","mirror":"","name":"Holy See (Vatic=
an City State)","zone":"Europe/Vatican"},"vc":{"kmap":"","mirror":"","name"=
:"Saint Vincent and the Grenadines","zone":"America/St_Vincent"},"ve":{"kma=
p":"","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/S=
t_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/Wa=
llis"},"ws":{"kmap":"","mirror":"","name":"Samoa","zone":"Pacific/Apia"},"y=
e":{"kmap":"","mirror":"","name":"Yemen","zone":"Asia/Aden"},"yt":{"kmap":"=
","mirror":"","name":"Mayotte","zone":"Indian/Mayotte"},"za":{"kmap":"","mi=
rror":"","name":"South Africa","zone":"Africa/Johannesburg"},"zm":{"kmap":"=
","mirror":"","name":"Zambia","zone":"Africa/Lusaka"},"zw":{"kmap":"","mirr=
or":"","name":"Zimbabwe","zone":"Africa/Harare"}},"countryhash":{"afghanist=
an":"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":"a=
t","azerbaijan":"az","bahamas":"bs","bahrain":"bh","bangladesh":"bd","barba=
dos":"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","bulg=
aria":"bg","burkina faso":"bf","burundi":"bi","cabo verde":"cv","cambodia":=
"kh","cameroon":"cm","canada":"ca","cayman islands":"ky","central african r=
epublic":"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 ri=
ca":"cr","croatia":"hr","cuba":"cu","cura=C3=A7ao":"cw","cyprus":"cy","czec=
hia":"cz","c=C3=B4te d'ivoire":"ci","denmark":"dk","djibouti":"dj","dominic=
a":"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 polyne=
sia":"pf","french southern territories":"tf","gabon":"ga","gambia":"gm","ge=
orgia":"ge","germany":"de","ghana":"gh","gibraltar":"gi","greece":"gr","gre=
enland":"gl","grenada":"gd","guadeloupe":"gp","guam":"gu","guatemala":"gt",=
"guernsey":"gg","guinea":"gn","guinea-bissau":"gw","guyana":"gy","haiti":"h=
t","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","luxembou=
rg":"lu","macao":"mo","madagascar":"mg","malawi":"mw","malaysia":"my","mald=
ives":"mv","mali":"ml","malta":"mt","marshall islands":"mh","martinique":"m=
q","mauritania":"mr","mauritius":"mu","mayotte":"yt","mexico":"mx","microne=
sia, federated states of":"fm","moldova":"md","monaco":"mc","mongolia":"mn"=
,"montenegro":"me","montserrat":"ms","morocco":"ma","mozambique":"mz","myan=
mar":"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 guine=
a":"pg","paraguay":"py","peru":"pe","philippines":"ph","pitcairn":"pn","pol=
and":"pl","portugal":"pt","puerto rico":"pr","qatar":"qa","romania":"ro","r=
ussian federation":"ru","rwanda":"rw","r=C3=A9union":"re","saint barth=C3=
=A9lemy":"bl","saint helena, ascension and tristan da cunha":"sh","saint ki=
tts and nevis":"kn","saint lucia":"lc","saint martin (french part)":"mf","s=
aint pierre and miquelon":"pm","saint vincent and the grenadines":"vc","sam=
oa":"ws","san marino":"sm","sao tome and principe":"st","saudi arabia":"sa"=
,"senegal":"sn","serbia":"rs","seychelles":"sc","sierra leone":"sl","singap=
ore":"sg","sint maarten (dutch part)":"sx","slovakia":"sk","slovenia":"si",=
"solomon islands":"sb","somalia":"so","south africa":"za","south georgia an=
d the south sandwich islands":"gs","south korea":"kr","south sudan":"ss","s=
pain":"es","sri lanka":"lk","sudan":"sd","suriname":"sr","svalbard and jan =
mayen":"sj","sweden":"se","switzerland":"ch","syria":"sy","taiwan":"tw","ta=
jikistan":"tj","tanzania":"tz","thailand":"th","timor-leste":"tl","togo":"t=
g","tokelau":"tk","tonga":"to","trinidad and tobago":"tt","tunisia":"tn","t=
urkmenistan":"tm","turks and caicos islands":"tc","tuvalu":"tv","t=C3=BCrki=
ye":"tr","uganda":"ug","ukraine":"ua","united arab emirates":"ae","united k=
ingdom":"gb","united states":"us","united states minor outlying islands":"u=
m","uruguay":"uy","uzbekistan":"uz","vanuatu":"vu","venezuela":"ve","vietna=
m":"vn","virgin islands, british":"vg","virgin islands, u.s.":"vi","wallis =
and futuna":"wf","western sahara":"eh","yemen":"ye","zambia":"zm","zimbabwe=
":"zw","=C3=A5land islands":"ax"},"kmap":{"de":{"console":"qwertz/de-latin1=
-nodeadkeys.kmap.gz","kvm":"de","name":"German","x11":"de","x11var":"nodead=
keys"},"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":"nodeadke=
ys"},"en-gb":{"console":"qwerty/uk.kmap.gz","kvm":"en-gb","name":"United Ki=
ngdom","x11":"gb","x11var":""},"en-us":{"console":"qwerty/us-latin1.kmap.gz=
","kvm":"en-us","name":"U.S. English","x11":"us","x11var":""},"es":{"consol=
e":"qwerty/es.kmap.gz","kvm":"es","name":"Spanish","x11":"es","x11var":"nod=
eadkeys"},"fi":{"console":"qwerty/fi-latin1.kmap.gz","kvm":"fi","name":"Fin=
nish","x11":"fi","x11var":"nodeadkeys"},"fr":{"console":"azerty/fr-latin1.k=
map.gz","kvm":"fr","name":"French","x11":"fr","x11var":"nodeadkeys"},"fr-be=
":{"console":"azerty/be2-latin1.kmap.gz","kvm":"fr-be","name":"Belgium-Fren=
ch","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-F=
rench","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":"Ita=
lian","x11":"it","x11var":"nodeadkeys"},"jp":{"console":"qwerty/jp106.kmap.=
gz","kvm":"ja","name":"Japanese","x11":"jp","x11var":""},"lt":{"console":"q=
werty/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.kma=
p.gz","kvm":"no","name":"Norwegian","x11":"no","x11var":"nodeadkeys"},"pl":=
{"console":"qwerty/pl.kmap.gz","kvm":"pl","name":"Polish","x11":"pl","x11va=
r":""},"pt":{"console":"qwerty/pt-latin1.kmap.gz","kvm":"pt","name":"Portug=
uese","x11":"pt","x11var":"nodeadkeys"},"pt-br":{"console":"qwerty/br-latin=
1.kmap.gz","kvm":"pt-br","name":"Brazil-Portuguese","x11":"br","x11var":"no=
deadkeys"},"se":{"console":"qwerty/se-latin1.kmap.gz","kvm":"sv","name":"Sw=
edish","x11":"se","x11var":"nodeadkeys"},"si":{"console":"qwertz/slovene.km=
ap.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-F=
rench":"fr-ca","Danish":"dk","Dutch":"nl","Finnish":"fi","French":"fr","Ger=
man":"de","Hungarian":"hu","Icelandic":"is","Italian":"it","Japanese":"jp",=
"Lithuanian":"lt","Macedonian":"mk","Norwegian":"no","Polish":"pl","Portugu=
ese":"pt","Slovenian":"si","Spanish":"es","Swedish":"se","Swiss-French":"fr=
-ch","Swiss-German":"de-ch","Turkish":"tr","U.S. English":"en-us","United K=
ingdom":"en-gb"},"zones":{"Africa/Abidjan":1,"Africa/Accra":1,"Africa/Addis=
_Ababa":1,"Africa/Algiers":1,"Africa/Asmara":1,"Africa/Bamako":1,"Africa/Ba=
ngui":1,"Africa/Banjul":1,"Africa/Bissau":1,"Africa/Blantyre":1,"Africa/Bra=
zzaville":1,"Africa/Bujumbura":1,"Africa/Cairo":1,"Africa/Casablanca":1,"Af=
rica/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/Ju=
ba":1,"Africa/Kampala":1,"Africa/Khartoum":1,"Africa/Kigali":1,"Africa/Kins=
hasa":1,"Africa/Lagos":1,"Africa/Libreville":1,"Africa/Lome":1,"Africa/Luan=
da":1,"Africa/Lubumbashi":1,"Africa/Lusaka":1,"Africa/Malabo":1,"Africa/Map=
uto":1,"Africa/Maseru":1,"Africa/Mbabane":1,"Africa/Mogadishu":1,"Africa/Mo=
nrovia":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/A=
raguaina":1,"America/Argentina/Buenos_Aires":1,"America/Argentina/Catamarca=
":1,"America/Argentina/Cordoba":1,"America/Argentina/Jujuy":1,"America/Arge=
ntina/La_Rioja":1,"America/Argentina/Mendoza":1,"America/Argentina/Rio_Gall=
egos":1,"America/Argentina/Salta":1,"America/Argentina/San_Juan":1,"America=
/Argentina/San_Luis":1,"America/Argentina/Tucuman":1,"America/Argentina/Ush=
uaia":1,"America/Aruba":1,"America/Asuncion":1,"America/Atikokan":1,"Americ=
a/Bahia":1,"America/Bahia_Banderas":1,"America/Barbados":1,"America/Belem":=
1,"America/Belize":1,"America/Blanc-Sablon":1,"America/Boa_Vista":1,"Americ=
a/Bogota":1,"America/Boise":1,"America/Cambridge_Bay":1,"America/Campo_Gran=
de":1,"America/Cancun":1,"America/Caracas":1,"America/Cayenne":1,"America/C=
ayman":1,"America/Chicago":1,"America/Chihuahua":1,"America/Ciudad_Juarez":=
1,"America/Costa_Rica":1,"America/Creston":1,"America/Cuiaba":1,"America/Cu=
racao":1,"America/Danmarkshavn":1,"America/Dawson":1,"America/Dawson_Creek"=
:1,"America/Denver":1,"America/Detroit":1,"America/Dominica":1,"America/Edm=
onton":1,"America/Eirunepe":1,"America/El_Salvador":1,"America/Fort_Nelson"=
:1,"America/Fortaleza":1,"America/Glace_Bay":1,"America/Goose_Bay":1,"Ameri=
ca/Grand_Turk":1,"America/Grenada":1,"America/Guadeloupe":1,"America/Guatem=
ala":1,"America/Guayaquil":1,"America/Guyana":1,"America/Halifax":1,"Americ=
a/Havana":1,"America/Hermosillo":1,"America/Indiana/Indianapolis":1,"Americ=
a/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/Iqalui=
t":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,"Amer=
ica/Martinique":1,"America/Matamoros":1,"America/Mazatlan":1,"America/Menom=
inee":1,"America/Merida":1,"America/Metlakatla":1,"America/Mexico_City":1,"=
America/Miquelon":1,"America/Moncton":1,"America/Monterrey":1,"America/Mont=
evideo":1,"America/Montserrat":1,"America/Nassau":1,"America/New_York":1,"A=
merica/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/Pho=
enix":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,"Americ=
a/Rio_Branco":1,"America/Santarem":1,"America/Santiago":1,"America/Santo_Do=
mingo":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_Cu=
rrent":1,"America/Tegucigalpa":1,"America/Thule":1,"America/Tijuana":1,"Ame=
rica/Toronto":1,"America/Tortola":1,"America/Vancouver":1,"America/Whitehor=
se":1,"America/Winnipeg":1,"America/Yakutat":1,"Antarctica/Casey":1,"Antarc=
tica/Davis":1,"Antarctica/DumontDUrville":1,"Antarctica/Macquarie":1,"Antar=
ctica/Mawson":1,"Antarctica/McMurdo":1,"Antarctica/Palmer":1,"Antarctica/Ro=
thera":1,"Antarctica/Syowa":1,"Antarctica/Troll":1,"Antarctica/Vostok":1,"A=
rctic/Longyearbyen":1,"Asia/Aden":1,"Asia/Almaty":1,"Asia/Amman":1,"Asia/An=
adyr":1,"Asia/Aqtau":1,"Asia/Aqtobe":1,"Asia/Ashgabat":1,"Asia/Atyrau":1,"A=
sia/Baghdad":1,"Asia/Bahrain":1,"Asia/Baku":1,"Asia/Bangkok":1,"Asia/Barnau=
l":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/Irkut=
sk":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,"A=
sia/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/Man=
ila":1,"Asia/Muscat":1,"Asia/Nicosia":1,"Asia/Novokuznetsk":1,"Asia/Novosib=
irsk":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,"Asi=
a/Riyadh":1,"Asia/Sakhalin":1,"Asia/Samarkand":1,"Asia/Seoul":1,"Asia/Shang=
hai":1,"Asia/Singapore":1,"Asia/Srednekolymsk":1,"Asia/Taipei":1,"Asia/Tash=
kent":1,"Asia/Tbilisi":1,"Asia/Tehran":1,"Asia/Thimphu":1,"Asia/Tokyo":1,"A=
sia/Tomsk":1,"Asia/Ulaanbaatar":1,"Asia/Urumqi":1,"Asia/Ust-Nera":1,"Asia/V=
ientiane":1,"Asia/Vladivostok":1,"Asia/Yakutsk":1,"Asia/Yangon":1,"Asia/Yek=
aterinburg":1,"Asia/Yerevan":1,"Atlantic/Azores":1,"Atlantic/Bermuda":1,"At=
lantic/Canary":1,"Atlantic/Cape_Verde":1,"Atlantic/Faroe":1,"Atlantic/Madei=
ra":1,"Atlantic/Reykjavik":1,"Atlantic/South_Georgia":1,"Atlantic/St_Helena=
":1,"Atlantic/Stanley":1,"Australia/Adelaide":1,"Australia/Brisbane":1,"Aus=
tralia/Broken_Hill":1,"Australia/Darwin":1,"Australia/Eucla":1,"Australia/H=
obart":1,"Australia/Lindeman":1,"Australia/Lord_Howe":1,"Australia/Melbourn=
e":1,"Australia/Perth":1,"Australia/Sydney":1,"Europe/Amsterdam":1,"Europe/=
Andorra":1,"Europe/Astrakhan":1,"Europe/Athens":1,"Europe/Belgrade":1,"Euro=
pe/Berlin":1,"Europe/Bratislava":1,"Europe/Brussels":1,"Europe/Bucharest":1=
,"Europe/Budapest":1,"Europe/Busingen":1,"Europe/Chisinau":1,"Europe/Copenh=
agen":1,"Europe/Dublin":1,"Europe/Gibraltar":1,"Europe/Guernsey":1,"Europe/=
Helsinki":1,"Europe/Isle_of_Man":1,"Europe/Istanbul":1,"Europe/Jersey":1,"E=
urope/Kaliningrad":1,"Europe/Kirov":1,"Europe/Kyiv":1,"Europe/Lisbon":1,"Eu=
rope/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,"Eur=
ope/Prague":1,"Europe/Riga":1,"Europe/Rome":1,"Europe/Samara":1,"Europe/San=
_Marino":1,"Europe/Sarajevo":1,"Europe/Saratov":1,"Europe/Simferopol":1,"Eu=
rope/Skopje":1,"Europe/Sofia":1,"Europe/Stockholm":1,"Europe/Tallinn":1,"Eu=
rope/Tirane":1,"Europe/Ulyanovsk":1,"Europe/Vaduz":1,"Europe/Vatican":1,"Eu=
rope/Vienna":1,"Europe/Vilnius":1,"Europe/Volgograd":1,"Europe/Warsaw":1,"E=
urope/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/Bouga=
inville":1,"Pacific/Chatham":1,"Pacific/Chuuk":1,"Pacific/Easter":1,"Pacifi=
c/Efate":1,"Pacific/Fakaofo":1,"Pacific/Fiji":1,"Pacific/Funafuti":1,"Pacif=
ic/Galapagos":1,"Pacific/Gambier":1,"Pacific/Guadalcanal":1,"Pacific/Guam":=
1,"Pacific/Honolulu":1,"Pacific/Kanton":1,"Pacific/Kiritimati":1,"Pacific/K=
osrae":1,"Pacific/Kwajalein":1,"Pacific/Majuro":1,"Pacific/Marquesas":1,"Pa=
cific/Midway":1,"Pacific/Nauru":1,"Pacific/Niue":1,"Pacific/Norfolk":1,"Pac=
ific/Noumea":1,"Pacific/Pago_Pago":1,"Pacific/Palau":1,"Pacific/Pitcairn":1=
,"Pacific/Pohnpei":1,"Pacific/Port_Moresby":1,"Pacific/Rarotonga":1,"Pacifi=
c/Saipan":1,"Pacific/Tahiti":1,"Pacific/Tarawa":1,"Pacific/Tongatapu":1,"Pa=
cific/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 =3D "de"
+country =3D "at"
+fqdn =3D "pveauto.testinstall"
+mailto =3D "mail@no.invalid"
+timezone =3D "Europe/Vienna"
+password =3D "123456"
+
+[network]
+use_dhcp =3D true
+
+[disks]
+filesystem =3D "zfs-raid10"
+filter.ID_SERIAL =3D "*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_al=
l.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_al=
l.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 =3D "de"
+country =3D "at"
+fqdn =3D "pveauto.testinstall"
+mailto =3D "mail@no.invalid"
+timezone =3D "Europe/Vienna"
+password =3D "123456"
+
+[network]
+use_dhcp =3D true
+
+[disks]
+filesystem =3D "zfs-raid0"
+filter_match =3D "all"
+filter.ID_SERIAL =3D "*MZ7KM240HAGR*"
+filter.ID_SERIAL_SHORT =3D "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_an=
y.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_an=
y.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 =3D "de"
+country =3D "at"
+fqdn =3D "pveauto.testinstall"
+mailto =3D "mail@no.invalid"
+timezone =3D "Europe/Vienna"
+password =3D "123456"
+
+[network]
+use_dhcp =3D true
+
+[disks]
+filesystem =3D "zfs-raid10"
+filter_match =3D "any"
+filter.ID_SERIAL =3D "*MZ7KM240HAGR*"
+filter.ID_MODEL =3D "Micron_9300*"
diff --git a/proxmox-auto-installer/resources/test/parse_answer/minimal.jso=
n 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.tom=
l 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 =3D "de"
+country =3D "at"
+fqdn =3D "pveauto.testinstall"
+mailto =3D "mail@no.invalid"
+timezone =3D "Europe/Vienna"
+password =3D "123456"
+
+[network]
+use_dhcp =3D true
+
+[disks]
+filesystem =3D "ext4"
+disk_selection =3D ["sda"]
diff --git a/proxmox-auto-installer/resources/test/parse_answer/nic_matchin=
g.json b/proxmox-auto-installer/resources/test/parse_answer/nic_matching.js=
on
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_matchin=
g.toml b/proxmox-auto-installer/resources/test/parse_answer/nic_matching.to=
ml
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 =3D "de"
+country =3D "at"
+fqdn =3D "pveauto.testinstall"
+mailto =3D "mail@no.invalid"
+timezone =3D "Europe/Vienna"
+password =3D "123456"
+
+[network]
+use_dhcp =3D false
+cidr =3D "10.10.10.10/24"
+dns =3D "10.10.10.1"
+gateway =3D "10.10.10.1"
+filter.ID_NET_NAME_MAC =3D "*a0369f0ab382"
+
+
+[disks]
+filesystem =3D "ext4"
+disk_selection =3D ["sda"]
diff --git a/proxmox-auto-installer/resources/test/parse_answer/readme b/pr=
oxmox-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_ni=
c.json b/proxmox-auto-installer/resources/test/parse_answer/specific_nic.js=
on
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_ni=
c.toml b/proxmox-auto-installer/resources/test/parse_answer/specific_nic.to=
ml
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 =3D "de"
+country =3D "at"
+fqdn =3D "pveauto.testinstall"
+mailto =3D "mail@no.invalid"
+timezone =3D "Europe/Vienna"
+password =3D "123456"
+
+[network]
+use_dhcp =3D false
+cidr =3D "10.10.10.10/24"
+dns =3D "10.10.10.1"
+gateway =3D "10.10.10.1"
+filter.ID_NET_NAME =3D "enp129s0f1np1"
+
+
+[disks]
+filesystem =3D "ext4"
+disk_selection =3D ["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 =3D "de"
+country =3D "at"
+fqdn =3D "pveauto.testinstall"
+mailto =3D "mail@no.invalid"
+timezone =3D "Europe/Vienna"
+password =3D "123456"
+
+[network]
+use_dhcp =3D true
+
+[disks]
+filesystem =3D "zfs-raid1"
+disk_selection =3D ["sda", "sdb"]
+zfs.ashift =3D 12
+zfs.checksum =3D "on"
+zfs.compress =3D "lz4"
+zfs.copies =3D 2
+zfs.hdsize =3D 80
diff --git a/proxmox-auto-installer/resources/test/run-env-info.json b/prox=
mox-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,"M=
icron_9300_MTFDHAL3T2TDR",4096,"/sys/block/nvme0n1"],[1,"/dev/nvme1n1",6251=
233968,"Micron_9300_MTFDHAL3T2TDR",4096,"/sys/block/nvme1n1"],[2,"/dev/nvme=
2n1",6251233968,"Micron_9300_MTFDHAL3T2TDR",4096,"/sys/block/nvme2n1"],[3,"=
/dev/nvme3n1",6251233968,"Micron_9300_MTFDHAL3T2TDR",4096,"/sys/block/nvme3=
n1"],[4,"/dev/nvme4n1",732585168,"INTEL SSDPED1K375GA",512,"/sys/block/nvme=
4n1"],[5,"/dev/nvme5n1",976773168,"Samsung SSD 970 EVO Plus 500GB",512,"/sy=
s/block/nvme5n1"],[6,"/dev/sda",468862128,"SAMSUNG MZ7KM240",512,"/sys/bloc=
k/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,"ip=
conf":{"default":"5","dnsserver":"192.168.1.254","domain":"proxmox.com","ga=
teway":"192.168.1.1","ifaces":{"10":{"driver":"mlx5_core","flags":"NO-CARRI=
ER,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":"UNKNO=
WN"},"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,MULTIC=
AST,UP,LOWER_UP","inet":{"addr":"192.168.1.114","mask":"255.255.255.0","pre=
fix":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":"DO=
WN"},"8":{"driver":"mlx5_core","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP",=
"mac":"1c:34:da:5c:5e:25","name":"enp129s0f1np1","state":"DOWN"},"9":{"driv=
er":"mlx5_core","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"24:8a:07=
:1e:05:bc","name":"enp193s0f0np0","state":"DOWN"}}},"kernel_cmdline":"BOOT_=
IMAGE=3D/boot/linux26 ro ramdisk_size=3D16777216 rw splash=3Dverbose proxde=
bug proxtui","network":{"dns":{"dns":["192.168.1.254","192.168.1.1"],"domai=
n":"proxmox.com"},"interfaces":{"eno1":{"addresses":[{"address":"192.168.1.=
114","family":"inet","prefix":24}],"index":5,"mac":"b4:2e:99:ac:ad:b4","nam=
e":"eno1"},"eno2":{"addresses":[{"address":"192.168.1.70","family":"inet","=
prefix":24}],"index":6,"mac":"b4:2e:99:ac:ad:b5","name":"eno2"},"enp129s0f0=
np0":{"index":7,"mac":"1c:34:da:5c:5e:24","name":"enp129s0f0np0"},"enp129s0=
f1np1":{"index":8,"mac":"1c:34:da:5c:5e:25","name":"enp129s0f1np1"},"enp193=
s0f0np0":{"index":9,"mac":"24:8a:07:1e:05:bc","name":"enp193s0f0np0"},"enp1=
93s0f1np1":{"index":10,"mac":"24:8a:07:1e:05:bd","name":"enp193s0f1np1"},"e=
np65s0f0":{"index":3,"mac":"a0:36:9f:0a:b3:82","name":"enp65s0f0"},"enp65s0=
f1":{"index":4,"mac":"a0:36:9f:0a:b3:83","name":"enp65s0f1"},"enxaa0c304b63=
62":{"index":2,"mac":"aa:0c:30:4b:63:62","name":"enxaa0c304b6362"}},"routes=
":{"gateway4":{"dev":"eno1","gateway":"192.168.1.1"}}},"total_memory":25759=
8}
diff --git a/proxmox-auto-installer/resources/test/run-env-udev.json b/prox=
mox-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/pc=
i-0000:01:00.0-nvme-1 /dev/disk/by-id/nvme-Micron_9300_MTFDHAL3T2TDR_195025=
96FC74 /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.000000000000001500a07501259=
6fc74","DEVNAME":"/dev/nvme0n1","DEVPATH":"/devices/pci0000:00/0000:00:01.1=
/0000:01:00.0/nvme/nvme0/nvme0n1","DEVTYPE":"disk","DISKSEQ":"16","ID_FS_TY=
PE":"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_REV=
ISION":"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":":system=
d:","USEC_INITIALIZED":"45215609"},"1":{"CURRENT_TAGS":":systemd:","DEVLINK=
S":"/dev/disk/by-path/pci-0000:02:00.0-nvme-1 /dev/disk/by-id/nvme-eui.0000=
00000000001400a0750125de7a16 /dev/disk/by-diskseq/15 /dev/disk/by-id/nvme-M=
icron_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":"pc=
i-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_SERIA=
L_SHORT":"195225DE7A16","ID_WWN":"eui.000000000000001400a0750125de7a16","MA=
JOR":"259","MINOR":"5","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIAL=
IZED":"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_93=
00_MTFDHAL3T2TDR_1945250F206E /dev/disk/by-id/nvme-eui.000000000000001400a0=
7501250f206e","DEVNAME":"/dev/nvme2n1","DEVPATH":"/devices/pci0000:00/0000:=
00:01.3/0000:03:00.0/nvme/nvme2/nvme2n1","DEVTYPE":"disk","DISKSEQ":"17","I=
D_FS_TYPE":"LVM2_member","ID_FS_USAGE":"raid","ID_FS_UUID":"b92FQw-lExM-2EY=
R-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_1945250F206=
E","ID_SERIAL_SHORT":"1945250F206E","ID_WWN":"eui.000000000000001400a075012=
50f206e","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_93=
00_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_F=
S_UUID":"f56spY-IptZ-fH5e-AqQv-K1cI-3nnt-2UlO17","ID_FS_UUID_ENC":"f56spY-I=
ptZ-fH5e-AqQv-K1cI-3nnt-2UlO17","ID_FS_VERSION":"LVM2 001","ID_MODEL":"Micr=
on_9300_MTFDHAL3T2TDR","ID_PATH":"pci-0000:04:00.0-nvme-1","ID_PATH_TAG":"p=
ci-0000_04_00_0-nvme-1","ID_REVISION":"11300DN0","ID_SERIAL":"Micron_9300_M=
TFDHAL3T2TDR_1945250F20AC","ID_SERIAL_SHORT":"1945250F20AC","ID_WWN":"eui.0=
00000000000001400a07501250f20ac","LVM_VG_NAME_COMPLETE":"ceph-2928aceb-9300=
-4175-8640-e227d897d45e","MAJOR":"259","MINOR":"8","SUBSYSTEM":"block","SYS=
TEMD_READY":"1","TAGS":":systemd:","USEC_INITIALIZED":"45215244"},"4":{"CUR=
RENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-diskseq/13 /dev/disk/by-pat=
h/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_PHKS746500DK375A=
GN /dev/disk/by-id/nvme-nvme.8086-50484b53373436353030444b33373541474e-494e=
54454c20535344504544314b3337354741-00000001","DEVNAME":"/dev/nvme4n1","DEVP=
ATH":"/devices/pci0000:80/0000:80:03.1/0000:82:00.0/nvme/nvme4/nvme4n1","DE=
VTYPE":"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":"j=
FM6eE-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_SSDPED=
1K375GA_PHKS746500DK375AGN","ID_SERIAL_SHORT":"PHKS746500DK375AGN","ID_WWN"=
:"nvme.8086-50484b53373436353030444b33373541474e-494e54454c2053534450454431=
4b3337354741-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/dis=
k/by-id/nvme-Samsung_SSD_970_EVO_Plus_500GB_S4EVNF0M703256N /dev/disk/by-pa=
th/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/nvme5=
n1","DEVTYPE":"disk","DISKSEQ":"14","ID_MODEL":"Samsung SSD 970 EVO Plus 50=
0GB","ID_PART_TABLE_TYPE":"gpt","ID_PART_TABLE_UUID":"1c40cb4b-72d8-49ec-80=
4b-e5933e09423d","ID_PATH":"pci-0000:06:00.0-nvme-1","ID_PATH_TAG":"pci-000=
0_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-N5K=
5Cv /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","DE=
VPATH":"/devices/pci0000:00/0000:00:03.1/0000:05:00.0/host8/port-8:0/expand=
er-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_S=
ET_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_SECURIT=
Y_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\\x=
20\\x20\\x20\\x20","ID_PATH":"pci-0000:05:00.0-sas-exp0x500304801f3f7f7f-ph=
y0-lun-0","ID_PATH_TAG":"pci-0000_05_00_0-sas-exp0x500304801f3f7f7f-phy0-lu=
n-0","ID_REVISION":"GXM1103Q","ID_SERIAL":"SAMSUNG_MZ7KM240HAGR-00005_S2HRN=
X0J403550","ID_SERIAL_SHORT":"S2HRNX0J403550","ID_TYPE":"disk","ID_WWN":"0x=
5002538c405dbf10","ID_WWN_WITH_EXTENSION":"0x5002538c405dbf10","MAJOR":"8",=
"MINOR":"0","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIALIZED":"4523=
4812"},"7":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-id/wwn-0x50=
02538c405dbce5 /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-ceV=
PZu /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_FEATU=
RE_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","I=
D_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-oHQ=
y-YRZP-8cNJ-uMIv-ceVPZu","ID_FS_VERSION":"LVM2 001","ID_MODEL":"SAMSUNG_MZ7=
KM240HAGR-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":"S2HR=
NX0J403335","ID_TYPE":"disk","ID_WWN":"0x5002538c405dbce5","ID_WWN_WITH_EXT=
ENSION":"0x5002538c405dbce5","MAJOR":"8","MINOR":"16","SUBSYSTEM":"block","=
TAGS":":systemd:","USEC_INITIALIZED":"45215406"},"8":{"CURRENT_TAGS":":syst=
emd:","DEVLINKS":"/dev/disk/by-id/wwn-0x5002538c405dbcd9 /dev/disk/by-disks=
eq/11 /dev/disk/by-id/ata-SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403333 /dev/di=
sk/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/s=
dc","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","D=
EVTYPE":"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_F=
EATURE_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_ENA=
BLED":"1","ID_ATA_ROTATION_RATE_RPM":"0","ID_ATA_SATA":"1","ID_ATA_WRITE_CA=
CHE":"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_V=
ERSION":"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-exp0x500304801f3=
f7f7f-phy2-lun-0","ID_PATH_TAG":"pci-0000_05_00_0-sas-exp0x500304801f3f7f7f=
-phy2-lun-0","ID_REVISION":"GXM1103Q","ID_SERIAL":"SAMSUNG_MZ7KM240HAGR-000=
05_S2HRNX0J403333","ID_SERIAL_SHORT":"S2HRNX0J403333","ID_TYPE":"disk","ID_=
WWN":"0x5002538c405dbcd9","ID_WWN_WITH_EXTENSION":"0x5002538c405dbcd9","MAJ=
OR":"8","MINOR":"32","SUBSYSTEM":"block","TAGS":":systemd:","USEC_INITIALIZ=
ED":"45198824"},"9":{"CURRENT_TAGS":":systemd:","DEVLINKS":"/dev/disk/by-di=
skseq/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_MZ7KM240HA=
GR-00005_S2HRNX0J403419","DEVNAME":"/dev/sdd","DEVPATH":"/devices/pci0000:0=
0/0000:00:03.1/0000:05:00.0/host8/port-8:0/expander-8:0/port-8:0:3/end_devi=
ce-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_FEATUR=
E_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_SE=
T_SMART":"1","ID_ATA_FEATURE_SET_SMART_ENABLED":"1","ID_ATA_ROTATION_RATE_R=
PM":"0","ID_ATA_SATA":"1","ID_ATA_WRITE_CACHE":"1","ID_ATA_WRITE_CACHE_ENAB=
LED":"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":"SA=
MSUNG_MZ7KM240HAGR-00005","ID_MODEL_ENC":"SAMSUNG\\x20MZ7KM240HAGR-00005\\x=
20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20\\x20","ID_PA=
TH":"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":"GXM11=
03Q","ID_SERIAL":"SAMSUNG_MZ7KM240HAGR-00005_S2HRNX0J403419","ID_SERIAL_SHO=
RT":"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 Ne=
twork Connection","ID_MODEL_ID":"0x1521","ID_NET_DRIVER":"igb","ID_NET_LABE=
L_ONBOARD":"Onboard LAN1","ID_NET_LINK_FILE":"/usr/lib/systemd/network/99-d=
efault.link","ID_NET_NAME":"eno1","ID_NET_NAME_MAC":"enxb42e99acadb4","ID_N=
ET_NAME_ONBOARD":"eno1","ID_NET_NAME_PATH":"enp194s0f0","ID_NET_NAMING_SCHE=
ME":"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_DAT=
ABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet contr=
oller","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:c=
0: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/n=
etwork/99-default.link","ID_NET_NAME":"eno2","ID_NET_NAME_MAC":"enxb42e99ac=
adb5","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_CLA=
SS_FROM_DATABASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Eth=
ernet controller","ID_VENDOR_FROM_DATABASE":"Intel Corporation","ID_VENDOR_=
ID":"0x8086","IFINDEX":"6","INTERFACE":"eno2","SUBSYSTEM":"net","SYSTEMD_AL=
IAS":"/sys/subsystem/net/devices/eno2","TAGS":":systemd:","USEC_INITIALIZED=
":"45128159"},"enp129s0f0np0":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devic=
es/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 Conn=
ectX-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":"e=
np129s0f0np0","ID_NET_NAMING_SCHEME":"v252","ID_OUI_FROM_DATABASE":"Mellano=
x 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 T=
echnologies","ID_VENDOR_ID":"0x15b3","IFINDEX":"7","INTERFACE":"enp129s0f0n=
p0","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/devices/enp129s0f=
0np0","TAGS":":systemd:","USEC_INITIALIZED":"47752091"},"enp129s0f1np1":{"C=
URRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:80/0000:80:01.1/0000:8=
1: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_MODE=
L_ID":"0x1015","ID_NET_DRIVER":"mlx5_core","ID_NET_LINK_FILE":"/usr/lib/sys=
temd/network/99-default.link","ID_NET_NAME":"enp129s0f1np1","ID_NET_NAME_MA=
C":"enx1c34da5c5e25","ID_NET_NAME_PATH":"enp129s0f1np1","ID_NET_NAMING_SCHE=
ME":"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_DATAB=
ASE":"Network controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet control=
ler","ID_VENDOR_FROM_DATABASE":"Mellanox Technologies","ID_VENDOR_ID":"0x15=
b3","IFINDEX":"8","INTERFACE":"enp129s0f1np1","SUBSYSTEM":"net","SYSTEMD_AL=
IAS":"/sys/subsystem/net/devices/enp129s0f1np1","TAGS":":systemd:","USEC_IN=
ITIALIZED":"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_INITIA=
LIZED":"47784094"},"enp193s0f1np1":{"CURRENT_TAGS":":systemd:","DEVPATH":"/=
devices/pci0000:c0/0000:c0:01.1/0000:c1:00.1/net/enp193s0f1np1","ID_BUS":"p=
ci","ID_MODEL_FROM_DATABASE":"MT27700 Family [ConnectX-4]","ID_MODEL_ID":"0=
x1013","ID_NET_DRIVER":"mlx5_core","ID_NET_LINK_FILE":"/usr/lib/systemd/net=
work/99-default.link","ID_NET_NAME":"enp193s0f1np1","ID_NET_NAME_MAC":"enx2=
48a071e05bd","ID_NET_NAME_PATH":"enp193s0f1np1","ID_NET_NAMING_SCHEME":"v25=
2","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":"Ne=
twork controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID=
_VENDOR_FROM_DATABASE":"Mellanox Technologies","ID_VENDOR_ID":"0x15b3","IFI=
NDEX":"10","INTERFACE":"enp193s0f1np1","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/=
sys/subsystem/net/devices/enp193s0f1np1","TAGS":":systemd:","USEC_INITIALIZ=
ED":"47820155"},"enp65s0f0":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices=
/pci0000:40/0000:40:03.1/0000:41:00.0/net/enp65s0f0","ID_BUS":"pci","ID_MOD=
EL_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_NE=
T_NAME_MAC":"enxa0369f0ab382","ID_NET_NAME_PATH":"enp65s0f0","ID_NET_NAMING=
_SCHEME":"v252","ID_OUI_FROM_DATABASE":"Intel Corporate","ID_PATH":"pci-000=
0:41:00.0","ID_PATH_TAG":"pci-0000_41_00_0","ID_PCI_CLASS_FROM_DATABASE":"N=
etwork controller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","I=
D_VENDOR_FROM_DATABASE":"Intel Corporation","ID_VENDOR_ID":"0x8086","IFINDE=
X":"3","INTERFACE":"enp65s0f0","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subs=
ystem/net/devices/enp65s0f0","TAGS":":systemd:","USEC_INITIALIZED":"4517610=
3"},"enp65s0f1":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:40/=
0000:40:03.1/0000:41:00.1/net/enp65s0f1","ID_BUS":"pci","ID_MODEL_FROM_DATA=
BASE":"I350 Gigabit Network Connection (Ethernet Server Adapter I350-T2)","=
ID_MODEL_ID":"0x1521","ID_NET_DRIVER":"igb","ID_NET_LINK_FILE":"/usr/lib/sy=
stemd/network/99-default.link","ID_NET_NAME":"enp65s0f1","ID_NET_NAME_MAC":=
"enxa0369f0ab383","ID_NET_NAME_PATH":"enp65s0f1","ID_NET_NAMING_SCHEME":"v2=
52","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 contr=
oller","ID_PCI_SUBCLASS_FROM_DATABASE":"Ethernet controller","ID_VENDOR_FRO=
M_DATABASE":"Intel Corporation","ID_VENDOR_ID":"0x8086","IFINDEX":"4","INTE=
RFACE":"enp65s0f1","SUBSYSTEM":"net","SYSTEMD_ALIAS":"/sys/subsystem/net/de=
vices/enp65s0f1","TAGS":":systemd:","USEC_INITIALIZED":"45260218"},"enxaa0c=
304b6362":{"CURRENT_TAGS":":systemd:","DEVPATH":"/devices/pci0000:40/0000:4=
0: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\\x20E=
thernet","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":"enxaa0c3=
04b6362","ID_NET_NAME_MAC":"enxaa0c304b6362","ID_NET_NAME_PATH":"enp67s0f3u=
2u4u3c2","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":"01=
00","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_Etherne=
t","ID_USB_MODEL_ENC":"Virtual\\x20Ethernet","ID_USB_MODEL_ID":"ffb0","ID_U=
SB_REVISION":"0100","ID_USB_SERIAL":"American_Megatrends_Inc._Virtual_Ether=
net_1234567890","ID_USB_SERIAL_SHORT":"1234567890","ID_USB_TYPE":"generic",=
"ID_USB_VENDOR":"American_Megatrends_Inc.","ID_USB_VENDOR_ENC":"American\\x=
20Megatrends\\x20Inc.","ID_USB_VENDOR_ID":"046b","ID_VENDOR":"American_Mega=
trends_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/s=
ubsystem/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 =3D "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 =3D "lowercase")]
+pub enum FilterMatch {
+    Any,
+    All,
+}
+
+#[derive(Clone, Deserialize, Serialize, Debug)]
+#[serde(rename_all =3D "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 =3D "lowercase"))]
+pub enum ZfsCompressOption {
+    #[default]
+    On,
+    Off,
+    Lzjb,
+    Lz4,
+    Zle,
+    Gzip,
+    Zstd,
+}
+
+#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
+#[serde(rename_all =3D "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/sr=
c/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, Setu=
pInfo},
+};
+
+mod answer;
+mod udevinfo;
+mod utils;
+use answer::Answer;
+use udevinfo::UdevInfo;
+
+/// ISO information is available globally.
+static mut SETUP_INFO: Option<SetupInfo> =3D None;
+
+pub fn setup_info() -> &'static SetupInfo {
+    unsafe { SETUP_INFO.as_ref().unwrap() }
+}
+
+fn init_setup_info(info: SetupInfo) {
+    unsafe {
+        SETUP_INFO =3D 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 =3D if in_test_mode { "./testdir" } else { "/" };
+    let mut path =3D PathBuf::from(base_path);
+
+    path.push("run");
+    path.push("proxmox-installer");
+
+    let installer_info =3D {
+        let mut path =3D 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 =3D {
+        let mut path =3D 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 =3D {
+        let mut path =3D 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 =3D {
+        let mut path =3D 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 =3D String::new();
+    let lines =3D std::io::stdin().lock().lines();
+    for line in lines {
+        buffer.push_str(&line.unwrap());
+        buffer.push('\n');
+    }
+
+    let answer: answer::Answer =3D
+        toml::from_str(&buffer).map_err(|err| format!("Failed parsing answ=
er file: {err}"))?;
+
+    runtime_info.disks.sort();
+    if runtime_info.disks.is_empty() {
+        Err("The installer could not find any supported hard disks.".to_ow=
ned())
+    } else {
+        Ok((answer, locale_info, runtime_info, udev_info))
+    }
+}
+
+fn main() -> ExitCode {
+    let in_test_mode =3D match env::args().nth(1).as_deref() {
+        Some("-t") =3D> true,
+        // Always force the test directory in debug builds
+        _ =3D> cfg!(debug_assertions),
+    };
+    println!("Starting auto installer");
+
+    let (answer, locales, runtime_info, udevadm_info) =3D match installer_=
setup(in_test_mode) {
+        Ok(result) =3D> result,
+        Err(err) =3D> {
+            eprintln!("Installer setup error: {err}");
+            return ExitCode::FAILURE;
+        }
+    };
+
+    match utils::run_cmds("Pre", &answer.global.pre_command) {
+        Ok(_) =3D> (),
+        Err(err) =3D> {
+            eprintln!("Error when running Pre-Commands: {}", err);
+            return ExitCode::FAILURE;
+        }
+    };
+    match run_installation(
+        &answer,
+        &locales,
+        &runtime_info,
+        &udevadm_info,
+    ) {
+        Ok(_) =3D> println!("Installation done."),
+        Err(err) =3D> {
+            eprintln!("Installation failed: {err}");
+            return ExitCode::FAILURE;
+        }
+    }
+    match utils::run_cmds("Post", &answer.global.post_command) {
+        Ok(_) =3D> (),
+        Err(err) =3D> {
+            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 =3D parse_answer(answer, udevadm_info, runtime_info, locale=
s)?;
+    #[cfg(debug_assertions)]
+    println!(
+        "FINAL JSON:\n{}",
+        serde_json::to_string_pretty(&config).expect("serialization failed=
")
+    );
+
+    let child =3D {
+        use std::process::{Command, Stdio};
+
+        #[cfg(not(debug_assertions))]
+        let (path, args, envs): (&str, [&str; 1], [(&str, &str); 0]) =3D
+            ("proxmox-low-level-installer", ["start-session"], []);
+
+        #[cfg(debug_assertions)]
+        let (path, args, envs) =3D (
+            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 =3D match child {
+        Ok(child) =3D> child,
+        Err(err) =3D> {
+            return Err(format!("Low level installer could not be started: =
{err}"));
+        }
+    };
+
+    let mut inner =3D || {
+        let reader =3D child.stdout.take().map(BufReader::new)?;
+        let mut writer =3D child.stdin.take()?;
+
+        serde_json::to_writer(&mut writer, &config).unwrap();
+        writeln!(writer).unwrap();
+
+        for line in reader.lines() {
+            match line {
+                Ok(line) =3D> print!("{line}"),
+                Err(_) =3D> break,
+            };
+        }
+        Some(())
+    };
+    match inner() {
+        Some(_) =3D> Ok(()),
+        None =3D> 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 =3D match &answer.disks.filesystem {
+        Some(answer::Filesystem::Ext4) =3D> FsType::Ext4,
+        Some(answer::Filesystem::Xfs) =3D> FsType::Xfs,
+        Some(answer::Filesystem::ZfsRaid0) =3D> FsType::Zfs(ZfsRaidLevel::=
Raid0),
+        Some(answer::Filesystem::ZfsRaid1) =3D> FsType::Zfs(ZfsRaidLevel::=
Raid1),
+        Some(answer::Filesystem::ZfsRaid10) =3D> FsType::Zfs(ZfsRaidLevel:=
:Raid10),
+        Some(answer::Filesystem::ZfsRaidZ1) =3D> FsType::Zfs(ZfsRaidLevel:=
:RaidZ),
+        Some(answer::Filesystem::ZfsRaidZ2) =3D> FsType::Zfs(ZfsRaidLevel:=
:RaidZ2),
+        Some(answer::Filesystem::ZfsRaidZ3) =3D> FsType::Zfs(ZfsRaidLevel:=
:RaidZ3),
+        Some(answer::Filesystem::BtrfsRaid0) =3D> FsType::Btrfs(BtrfsRaidL=
evel::Raid0),
+        Some(answer::Filesystem::BtrfsRaid1) =3D> FsType::Btrfs(BtrfsRaidL=
evel::Raid1),
+        Some(answer::Filesystem::BtrfsRaid10) =3D> FsType::Btrfs(BtrfsRaid=
Level::Raid10),
+        None =3D> FsType::Ext4,
+    };
+
+    let network_settings =3D utils::get_network_settings(answer, udev_info=
, runtime_info)?;
+
+    utils::verify_locale_settings(answer, locales)?;
+
+    let mut config =3D 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 =3D> {
+            let lvm =3D match &answer.disks.lvm {
+                Some(lvm) =3D> lvm.clone(),
+                None =3D> answer::LvmOptions::new(),
+            };
+            config.hdsize =3D lvm.hdsize.unwrap_or(config.target_hd.clone(=
).unwrap().size);
+            config.swapsize =3D lvm.swapsize;
+            config.maxroot =3D lvm.maxroot;
+            config.maxvz =3D lvm.maxvz;
+            config.minfree =3D lvm.minfree;
+        }
+        FsType::Zfs(_) =3D> {
+            let zfs =3D match &answer.disks.zfs {
+                Some(zfs) =3D> zfs.clone(),
+                None =3D> answer::ZfsOptions::new(),
+            };
+            let first_selected_disk =3D utils::get_first_selected_disk(&co=
nfig);
+
+            config.hdsize =3D zfs
+                .hdsize
+                .unwrap_or(runtime_info.disks[first_selected_disk].size);
+            config.zfs_opts =3D Some(InstallZfsOption {
+                ashift: zfs.ashift.unwrap_or(12),
+                compress: match zfs.compress {
+                    Some(answer::ZfsCompressOption::On) =3D> ZfsCompressOp=
tion::On,
+                    Some(answer::ZfsCompressOption::Off) =3D> ZfsCompressO=
ption::Off,
+                    Some(answer::ZfsCompressOption::Lzjb) =3D> ZfsCompress=
Option::Lzjb,
+                    Some(answer::ZfsCompressOption::Lz4) =3D> ZfsCompressO=
ption::Lz4,
+                    Some(answer::ZfsCompressOption::Zle) =3D> ZfsCompressO=
ption::Zle,
+                    Some(answer::ZfsCompressOption::Gzip) =3D> ZfsCompress=
Option::Gzip,
+                    Some(answer::ZfsCompressOption::Zstd) =3D> ZfsCompress=
Option::Zstd,
+                    None =3D> ZfsCompressOption::On,
+                },
+                checksum: match zfs.checksum {
+                    Some(answer::ZfsChecksumOption::On) =3D> ZfsChecksumOp=
tion::On,
+                    Some(answer::ZfsChecksumOption::Off) =3D> ZfsChecksumO=
ption::Off,
+                    Some(answer::ZfsChecksumOption::Fletcher2) =3D> ZfsChe=
cksumOption::Fletcher2,
+                    Some(answer::ZfsChecksumOption::Fletcher4) =3D> ZfsChe=
cksumOption::Fletcher4,
+                    Some(answer::ZfsChecksumOption::Sha256) =3D> ZfsChecks=
umOption::Sha256,
+                    None =3D> ZfsChecksumOption::On,
+                },
+                copies: zfs.copies.unwrap_or(1),
+            });
+        }
+        FsType::Btrfs(_) =3D> {
+            let btrfs =3D match &answer.disks.btrfs {
+                Some(btrfs) =3D> btrfs.clone(),
+                None =3D> answer::BtrfsOptions::new(),
+            };
+            let first_selected_disk =3D utils::get_first_selected_disk(&co=
nfig);
+
+            config.hdsize =3D 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 =3D std::fs::read_to_string(&path).unwrap();
+        let answer: answer::Answer =3D 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, LocaleI=
nfo) {
+        let installer_info: SetupInfo =3D {
+            let mut path =3D path.clone();
+            path.push("iso-info.json");
+
+            tui::setup::read_json(&path)
+                .map_err(|err| format!("Failed to retrieve setup info: {er=
r}"))
+                .unwrap()
+        };
+        init_setup_info(installer_info.clone());
+        let udev_info: UdevInfo =3D {
+            let mut path =3D path.clone();
+            path.push("run-env-udev.json");
+
+            tui::setup::read_json(&path)
+                .map_err(|err| format!("Failed to retrieve udev info detai=
ls: {err}"))
+                .unwrap()
+        };
+        let runtime_info: RuntimeInfo =3D {
+            let mut path =3D path.clone();
+            path.push("run-env-info.json");
+
+            tui::setup::read_json(&path)
+                .map_err(|err| format!("Failed to retrieve udev info detai=
ls: {err}"))
+                .unwrap()
+        };
+        let locales: LocaleInfo =3D {
+            let mut path =3D 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 =3D get_test_resource_path().unwrap();
+        let (runtime_info, udev_info, locales) =3D setup_test_basic(&path);
+        let mut tests_path =3D path.clone();
+        tests_path.push("parse_answer");
+        let test_dir =3D fs::read_dir(tests_path.clone()).unwrap();
+        for file_entry in test_dir {
+            let file =3D file_entry.unwrap();
+            if !file.file_type().unwrap().is_file() || file.file_name() =
=3D=3D "readme" {
+                continue;
+            }
+            let p =3D file.path();
+            let name =3D p.file_stem().unwrap().to_str().unwrap();
+            let extension =3D p.extension().unwrap().to_str().unwrap();
+            if extension =3D=3D "toml" {
+                println!("Test: {name}");
+                let answer =3D get_answer(p.clone()).unwrap();
+                let config =3D &parse_answer(&answer, &udev_info, &runtime=
_info, &locales).unwrap();
+                println!("Selected disks: {:#?}", &config.disk_selection);
+                let config_json =3D serde_json::to_string(config);
+                let config: Value =3D serde_json::from_str(config_json.unw=
rap().as_str()).unwrap();
+                let mut path =3D tests_path.clone();
+                path.push(format!("{name}.json"));
+                let compare_raw =3D std::fs::read_to_string(&path).unwrap(=
);
+                let compare: Value =3D serde_json::from_str(&compare_raw).=
unwrap();
+                if config !=3D 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-insta=
ller/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 =3D> write!(f, "RAID0"),
+            Raid1 =3D> write!(f, "RAID1"),
+            Raid10 =3D> 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 =3D> write!(f, "RAID0"),
+            Raid1 =3D> write!(f, "RAID1"),
+            Raid10 =3D> write!(f, "RAID10"),
+            RaidZ =3D> write!(f, "RAIDZ-1"),
+            RaidZ2 =3D> write!(f, "RAIDZ-2"),
+            RaidZ3 =3D> 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 =3D> write!(f, "ext4"),
+            Xfs =3D> write!(f, "XFS"),
+            Zfs(level) =3D> write!(f, "ZFS ({level})"),
+            Btrfs(level) =3D> 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 merg=
ed
+        // https://lists.proxmox.com/pipermail/pbs-devel/2023-May/006125.h=
tml
+        f.write_str(&self.path)?;
+        if let Some(model) =3D &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 =3D 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 =3D Self::default();
+
+        if let Some(ip) =3D info.dns.dns.first() {
+            this.dns_server =3D *ip;
+        }
+
+        if let Some(domain) =3D &info.dns.domain {
+            let hostname =3D crate::current_product().default_hostname();
+            if let Ok(fqdn) =3D Fqdn::from(&format!("{hostname}.{domain}")=
) {
+                this.fqdn =3D fqdn;
+            }
+        }
+
+        if let Some(routes) =3D &info.routes {
+            let mut filled =3D false;
+            if let Some(gw) =3D &routes.gateway4 {
+                if let Some(iface) =3D info.interfaces.get(&gw.dev) {
+                    this.ifname =3D iface.name.clone();
+                    if let Some(addresses) =3D &iface.addresses {
+                        if let Some(addr) =3D addresses.iter().find(|addr|=
 addr.is_ipv4()) {
+                            this.gateway =3D gw.gateway;
+                            this.address =3D addr.clone();
+                            filled =3D true;
+                        }
+                    }
+                }
+            }
+            if !filled {
+                if let Some(gw) =3D &routes.gateway6 {
+                    if let Some(iface) =3D info.interfaces.get(&gw.dev) {
+                        if let Some(addresses) =3D &iface.addresses {
+                            if let Some(addr) =3D addresses.iter().find(|a=
ddr| addr.is_ipv6()) {
+                                this.ifname =3D iface.name.clone();
+                                this.gateway =3D gw.gateway;
+                                this.address =3D 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-install=
er/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, InstallerOp=
tions,
+        ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption, ZfsRaidL=
evel,
+    },
+    utils::CidrAddress,
+};
+
+#[allow(clippy::upper_case_acronyms)]
+#[derive(Clone, Copy, Deserialize, PartialEq, Debug)]
+#[serde(rename_all =3D "lowercase")]
+pub enum ProxmoxProduct {
+    PVE,
+    PBS,
+    PMG,
+}
+
+impl ProxmoxProduct {
+    pub fn default_hostname(self) -> &'static str {
+        match self {
+            Self::PVE =3D> "pve",
+            Self::PMG =3D> "pmg",
+            Self::PBS =3D> "pbs",
+        }
+    }
+}
+
+#[derive(Clone, Deserialize, Debug)]
+pub struct ProductConfig {
+    pub fullname: String,
+    pub product: ProxmoxProduct,
+    #[serde(deserialize_with =3D "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 =3D "product-cfg")]
+    pub config: ProductConfig,
+    #[serde(rename =3D "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 =3D "kvm")]
+    pub id: String,
+    #[serde(rename =3D "x11")]
+    pub xkb_layout: String,
+    #[serde(rename =3D "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 =3D "deserialize_cczones_map")]
+    pub cczones: HashMap<String, Vec<String>>,
+    #[serde(rename =3D "country")]
+    pub countries: HashMap<String, CountryInfo>,
+    pub kmap: HashMap<String, KeyboardMapping>,
+}
+
+#[derive(Serialize)]
+pub struct InstallZfsOption {
+    pub ashift: usize,
+    #[serde(serialize_with =3D "serialize_as_display")]
+    pub compress: ZfsCompressOption,
+    #[serde(serialize_with =3D "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 =3D "serialize_fstype")]
+    pub filesys: FsType,
+    pub hdsize: f64,
+    #[serde(skip_serializing_if =3D "Option::is_none")]
+    pub swapsize: Option<f64>,
+    #[serde(skip_serializing_if =3D "Option::is_none")]
+    pub maxroot: Option<f64>,
+    #[serde(skip_serializing_if =3D "Option::is_none")]
+    pub minfree: Option<f64>,
+    #[serde(skip_serializing_if =3D "Option::is_none")]
+    pub maxvz: Option<f64>,
+
+    #[serde(skip_serializing_if =3D "Option::is_none")]
+    pub zfs_opts: Option<InstallZfsOption>,
+
+    #[serde(
+        serialize_with =3D "serialize_disk_opt",
+        skip_serializing_if =3D "Option::is_none"
+    )]
+    pub target_hd: Option<Disk>,
+    #[serde(skip_serializing_if =3D "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 =3D "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 =3D 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_hostna=
me())
+                .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) =3D> {
+                config.hdsize =3D lvm.total_size;
+                config.target_hd =3D Some(options.bootdisk.disks[0].clone(=
));
+                config.swapsize =3D lvm.swap_size;
+                config.maxroot =3D lvm.max_root_size;
+                config.minfree =3D lvm.min_lvm_free;
+                config.maxvz =3D lvm.max_data_size;
+            }
+            AdvancedBootdiskOptions::Zfs(zfs) =3D> {
+                config.hdsize =3D zfs.disk_size;
+                config.zfs_opts =3D 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) =3D> {
+                config.hdsize =3D 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 =3D File::open(path).map_err(|err| err.to_string())?;
+    let reader =3D 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::E=
rror>
+where
+    D: Deserializer<'de>,
+{
+    let val: u32 =3D Deserialize::deserialize(deserializer)?;
+    Ok(val !=3D 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>> =3D Deserialize::deseri=
alize(deserializer)?;
+
+    let mut result =3D 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 =3D <Vec<(usize, String, f64, String, usize, String)>>::dese=
rialize(deserializer)?;
+    Ok(disks
+        .into_iter()
+        .map(
+            |(index, device, size_mb, model, logical_bsize, _syspath)| Dis=
k {
+                index: index.to_string(),
+                size: (size_mb * logical_bsize as f64) / 1024. / 1024. / 1=
024.,
+                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<Cid=
rAddress>>, 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> =3D Deserialize::deserialize(deserialize=
r)?;
+
+    let mut result =3D Vec::with_capacity(list.len());
+    for desc in list {
+        let ip_addr =3D 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) =3D 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 =3D match value {
+        // proxinstall::$fssetup
+        Ext4 =3D> "ext4",
+        Xfs =3D> "xfs",
+        // proxinstall::get_zfs_raid_setup()
+        Zfs(ZfsRaidLevel::Raid0) =3D> "zfs (RAID0)",
+        Zfs(ZfsRaidLevel::Raid1) =3D> "zfs (RAID1)",
+        Zfs(ZfsRaidLevel::Raid10) =3D> "zfs (RAID10)",
+        Zfs(ZfsRaidLevel::RaidZ) =3D> "zfs (RAIDZ-1)",
+        Zfs(ZfsRaidLevel::RaidZ2) =3D> "zfs (RAIDZ-2)",
+        Zfs(ZfsRaidLevel::RaidZ3) =3D> "zfs (RAIDZ-3)",
+        // proxinstall::get_btrfs_raid_setup()
+        Btrfs(BtrfsRaidLevel::Raid0) =3D> "btrfs (RAID0)",
+        Btrfs(BtrfsRaidLevel::Raid1) =3D> "btrfs (RAID1)",
+        Btrfs(BtrfsRaidLevel::Raid10) =3D> "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 =3D "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 =3D "deserialize_bool_from_int")]
+    pub hvm_supported: bool,
+}
+
+#[derive(Clone, Eq, Deserialize, PartialEq, Debug)]
+#[serde(rename_all =3D "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 configurat=
ion.
+    /// (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 =3D "deserialize_cidr_list")]
+    pub addresses: Option<Vec<CidrAddress>>,
+}
diff --git a/proxmox-auto-installer/src/tui/utils.rs b/proxmox-auto-install=
er/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 hand=
led.
+/// The mask is appropriately enforced to be `0 <=3D mask <=3D 32` for IPv=
4 or
+/// `0 <=3D mask <=3D 128` for IPv6 addresses.
+///
+/// # Examples
+/// ```
+/// use std::net::{Ipv4Addr, Ipv6Addr};
+/// let ipv4 =3D CidrAddress::new(Ipv4Addr::new(192, 168, 0, 1), 24).unwra=
p();
+/// let ipv6 =3D 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, Cidr=
AddressParseError> {
+        let addr =3D 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` otherwi=
se.
+    pub fn is_ipv4(&self) -> bool {
+        self.addr.is_ipv4()
+    }
+
+    /// Returns `true` if this address is an IPv6 address, `false` otherwi=
se.
+    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 =3D CidrAddressParseError;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let (addr, mask) =3D s
+            .split_once('/')
+            .ok_or(CidrAddressParseError::NoDelimiter)?;
+
+        let addr =3D addr.parse().map_err(CidrAddressParseError::InvalidAd=
dr)?;
+
+        let mask =3D 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 =3D> write!(f, "missing hostname part"),
+            NumericHostname =3D> write!(f, "hostname cannot be purely nume=
ric"),
+            InvalidPart(part) =3D> write!(
+                f,
+                "FQDN must only consist of alphanumeric characters and das=
hes. 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 =3D 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 =3D 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 =3D=3D '-')
+    }
+}
+
+impl FromStr for Fqdn {
+    type Err =3D 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 =3D 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 =3D 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-installe=
r/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/s=
rc/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 t=
he pattern.
+/// Globbing within the pattern is not supported
+fn find_with_glob(pattern: &str, value: &str) -> bool {
+    let globbing_symbol =3D '*';
+    let mut start_glob =3D false;
+    let mut end_glob =3D false;
+    let mut pattern =3D pattern;
+
+    if pattern.starts_with(globbing_symbol) {
+        start_glob =3D true;
+        pattern =3D &pattern[1..];
+    }
+
+    if pattern.ends_with(globbing_symbol) {
+        end_glob =3D true;
+        pattern =3D &pattern[..pattern.len() - 1]
+    }
+
+    match (start_glob, end_glob) {
+        (true, true) =3D> value.contains(pattern),
+        (true, false) =3D> value.ends_with(pattern),
+        (false, true) =3D> value.starts_with(pattern),
+        _ =3D> value =3D=3D pattern,
+    }
+}
+
+pub fn get_network_settings(
+    answer: &Answer,
+    udev_info: &UdevInfo,
+    runtime_info: &RuntimeInfo,
+) -> Result<NetworkOptions, String> {
+    let mut network_options =3D NetworkOptions::from(&runtime_info.network=
);
+
+    // Always use the FQDN from the answer file
+    network_options.fqdn =3D Fqdn::from(answer.global.fqdn.as_str()).expec=
t("Error parsing FQDN");
+
+    if answer.network.use_dhcp.is_none() || !answer.network.use_dhcp.unwra=
p() {
+        network_options.address =3D CidrAddress::from_str(
+            answer
+                .network
+                .cidr
+                .clone()
+                .expect("No CIDR defined")
+                .as_str(),
+        )
+        .expect("Error parsing CIDR");
+        network_options.dns_server =3D IpAddr::from_str(
+            answer
+                .network
+                .dns
+                .clone()
+                .expect("No DNS server defined")
+                .as_str(),
+        )
+        .expect("Error parsing DNS server");
+        network_options.gateway =3D IpAddr::from_str(
+            answer
+                .network
+                .gateway
+                .clone()
+                .expect("No gateway defined")
+                .as_str(),
+        )
+        .expect("Error parsing gateway");
+        network_options.ifname =3D
+            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> =3D 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 =3D=3D filter_key && find_with_glob(filter_val=
ue, udev_value) {
+                    dev_index =3D 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 =3D vec![];
+    for (dev, dev_values) in udev_list {
+        let mut did_match_once =3D false;
+        let mut did_match_all =3D true;
+        for (filter_key, filter_value) in &filter {
+            for (udev_key, udev_value) in dev_values {
+                if udev_key =3D=3D filter_key && find_with_glob(filter_val=
ue, udev_value) {
+                    did_match_once =3D true;
+                } else if udev_key =3D=3D filter_key {
+                    did_match_all =3D 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 =3D> set_single_disk(answer, udev_info,=
 runtime_info, config),
+        FsType::Zfs(_) | FsType::Btrfs(_) =3D> {
+            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) =3D> {
+            let disk_name =3D selection[0].clone();
+            let disk =3D runtime_info
+                .disks
+                .iter()
+                .find(|item| item.path.ends_with(disk_name.as_str()));
+            match disk {
+                Some(disk) =3D> config.target_hd =3D Some(disk.clone()),
+                None =3D> return Err("disk in 'disk_selection' not found".=
to_string()),
+            }
+        }
+        None =3D> {
+            let disk_index =3D
+                get_single_udev_index(answer.disks.filter.clone().unwrap()=
, &udev_info.disks)?;
+            let disk =3D runtime_info
+                .disks
+                .iter()
+                .find(|item| item.index =3D=3D disk_index);
+            config.target_hd =3D 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) =3D> {
+            for disk_name in selection {
+                let disk =3D runtime_info
+                    .disks
+                    .iter()
+                    .find(|item| item.path.ends_with(disk_name.as_str()));
+                if let Some(disk) =3D disk {
+                    config
+                        .disk_selection
+                        .insert(disk.index.clone(), disk.index.clone());
+                }
+            }
+        }
+        None =3D> {
+            let filter_match =3D answer
+                .disks
+                .filter_match
+                .clone()
+                .unwrap_or(FilterMatch::Any);
+            let selected_disk_indexes =3D get_matched_udev_indexes(
+                answer.disks.filter.clone().unwrap(),
+                &udev_info.disks,
+                filter_match =3D=3D FilterMatch::All,
+            )?;
+
+            for i in selected_disk_indexes.into_iter() {
+                let disk =3D runtime_info
+                    .disks
+                    .iter()
+                    .find(|item| item.index =3D=3D 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) -> Re=
sult<(), String> {
+    if !locales
+        .countries
+        .keys()
+        .any(|i| i =3D=3D &answer.global.country)
+    {
+        return Err(format!(
+            "country code '{}' is not valid",
+            &answer.global.country
+        ));
+    }
+    if !locales.kmap.keys().any(|i| i =3D=3D &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<(), S=
tring> {
+    if let Some(cmds) =3D 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 =3D 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!("{e=
rr}"))?
+            );
+            print!(
+                "{}",
+                String::from_utf8(output.stderr).map_err(|err| format!("{e=
rr}"))?
+            );
+            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 =3D "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);
+    }
+}
--=20
2.39.2