public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [RFC installer 0/6] add automated installation
@ 2023-09-05 13:28 Aaron Lauterer
  2023-09-05 13:28 ` [pve-devel] [RFC installer 1/6] low level: sys: fetch udev properties Aaron Lauterer
                   ` (5 more replies)
  0 siblings, 6 replies; 11+ messages in thread
From: Aaron Lauterer @ 2023-09-05 13:28 UTC (permalink / raw)
  To: pve-devel

This is the first iteration of making it possible to automatically run
the installer.

The main idea is to provide an answer file (TOML as of now) that
provides the properties usually queried by the TUI or GUI installer.
Additionally we want to be able to do some more fuzzy matching for the
NIC and disks used by the installer.

Therefore we have some basic globbing/wildcard support at the start and
end of the search string. For now the UDEV device properties are used to
for this matching. The format for the filters are "UDEV KEY -> search
string".

The answer file and auto installer have the additional option to run
commands pre- and post-installation. More details can be found in the
patch itself.

The big question is how we actually get the answer file without creating
a custom installer image per answer file.
The most basic variant is to scan local storage for a partition/volume
with an expected label, mount it and look for the expected file.
For this I added a small shell script that does exactly this and then
starts the auto installer.

Another idea is to get an URL and query it
* could come from a default subdomain that is queried within the DHCP
  provided search domain
* We could get it from a DHCP option. How we extract that is something I
  don't know at this time.
* From the kernel cmdline, if the installer is booted via PXE with
  customized kernel parameters
* ...

When running the http request, we could add identifying properties as
parameters. Properties like MAC addresses and serial numbers, for
example. This would make it possible to have a script querying an
internal database and create the answer file on demand.

This version definitely has some rough edges and probably a lot of
things that could be done nicer, more idiomatic. There are quite a few
nested for loops that could probably be done better/nicer as well.

A lot of code has been reused from the TUI installer. The plan is to
factor out common code into a new library crate.

For now, the auto installer just prints everything to stdout. We could
implement a simple GUI that shows a progress bar.


pve-install: Aaron Lauterer (5):
  low level: sys: fetch udev properties
  add proxmox-auto-installer
  add answer file fetch script
  makefile: fix handling of multiple usr_bin files
  makefile: add auto installer

 Cargo.toml                                    |   1 +
 Makefile                                      |   8 +-
 Proxmox/Makefile                              |   1 +
 Proxmox/Sys/Udev.pm                           |  54 +++
 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 +++++++++++++
 proxmox-low-level-installer                   |  14 +
 start_autoinstall.sh                          |  50 ++
 35 files changed, 2372 insertions(+), 2 deletions(-)
 create mode 100644 Proxmox/Sys/Udev.pm
 create mode 100644 proxmox-auto-installer/Cargo.toml
 create mode 100644 proxmox-auto-installer/answer.toml
 create mode 100644 proxmox-auto-installer/resources/test/iso-info.json
 create mode 100644 proxmox-auto-installer/resources/test/locales.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_all.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_all.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_any.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_any.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/minimal.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/minimal.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/nic_matching.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/nic_matching.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/readme
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/specific_nic.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/specific_nic.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/zfs.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/zfs.toml
 create mode 100644 proxmox-auto-installer/resources/test/run-env-info.json
 create mode 100644 proxmox-auto-installer/resources/test/run-env-udev.json
 create mode 100644 proxmox-auto-installer/src/answer.rs
 create mode 100644 proxmox-auto-installer/src/main.rs
 create mode 100644 proxmox-auto-installer/src/tui/mod.rs
 create mode 100644 proxmox-auto-installer/src/tui/options.rs
 create mode 100644 proxmox-auto-installer/src/tui/setup.rs
 create mode 100644 proxmox-auto-installer/src/tui/utils.rs
 create mode 100644 proxmox-auto-installer/src/udevinfo.rs
 create mode 100644 proxmox-auto-installer/src/utils.rs
 create mode 100755 start_autoinstall.sh


pve-docs: Aaron Lauterer (1):
  installation: add unattended documentation

 pve-installation.adoc | 245 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 245 insertions(+)

-- 
2.39.2





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [pve-devel] [RFC installer 1/6] low level: sys: fetch udev properties
  2023-09-05 13:28 [pve-devel] [RFC installer 0/6] add automated installation Aaron Lauterer
@ 2023-09-05 13:28 ` Aaron Lauterer
  2023-09-05 13:28 ` [pve-devel] [RFC installer 2/6] add proxmox-auto-installer Aaron Lauterer
                   ` (4 subsequent siblings)
  5 siblings, 0 replies; 11+ messages in thread
From: Aaron Lauterer @ 2023-09-05 13:28 UTC (permalink / raw)
  To: pve-devel

Fetch UDEV device properties (prepended with E:) for NICs and disks and
store them in their own JSON file so that we can use them for filtering.

Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
---
 Proxmox/Makefile            |  1 +
 Proxmox/Sys/Udev.pm         | 54 +++++++++++++++++++++++++++++++++++++
 proxmox-low-level-installer | 14 ++++++++++
 3 files changed, 69 insertions(+)
 create mode 100644 Proxmox/Sys/Udev.pm

diff --git a/Proxmox/Makefile b/Proxmox/Makefile
index d49da80..9561d9b 100644
--- a/Proxmox/Makefile
+++ b/Proxmox/Makefile
@@ -16,6 +16,7 @@ PERL_MODULES=\
     Sys/Command.pm \
     Sys/File.pm \
     Sys/Net.pm \
+    Sys/Udev.pm \
     UI.pm \
     UI/Base.pm \
     UI/Gtk3.pm \
diff --git a/Proxmox/Sys/Udev.pm b/Proxmox/Sys/Udev.pm
new file mode 100644
index 0000000..69d674f
--- /dev/null
+++ b/Proxmox/Sys/Udev.pm
@@ -0,0 +1,54 @@
+package Proxmox::Sys::Udev;
+
+use strict;
+use warnings;
+
+use base qw(Exporter);
+our @EXPORT_OK = qw(disk_details);
+
+my $udev_regex = '^E: ([A-Z_]*)=(.*)$';
+
+my sub fetch_udevadm_info {
+    my ($path) = @_;
+
+    my $info = `udevadm info --path $path --query all`;
+    if (!$info) {
+	warn "no details found for device '${path}'\n";
+	next;
+    }
+    my $details = {};
+    for my $line (split('\n', $info)) {
+	if ($line =~ m/$udev_regex/) {
+	    $details->{$1} = $2;
+	}
+    }
+    return $details;
+}
+
+# return hash of E: properties returned by udevadm
+sub disk_details {
+    my $result = {};
+    for my $data (@{Proxmox::Sys::Block::get_cached_disks()}) {
+	my $index = @$data[0];
+	my $bd = @$data[5];
+	$result->{$index} = fetch_udevadm_info($bd);
+    }
+    return $result;
+}
+
+
+sub nic_details {
+    my $nic_path = "/sys/class/net";
+    my $result = {};
+
+    my $nics = Proxmox::Sys::Net::get_ip_config()->{ifaces};
+
+    for my $index (keys %$nics) {
+	my $name = $nics->{$index}->{name};
+	my $nic = "${nic_path}/${name}";
+	$result->{$name} = fetch_udevadm_info($nic);
+    }
+    return $result;
+}
+
+1;
diff --git a/proxmox-low-level-installer b/proxmox-low-level-installer
index 814961e..99d5b9a 100755
--- a/proxmox-low-level-installer
+++ b/proxmox-low-level-installer
@@ -23,14 +23,17 @@ use Proxmox::Install::ISOEnv;
 use Proxmox::Install::RunEnv;
 
 use Proxmox::Sys::File qw(file_write_all);
+use Proxmox::Sys::Udev;
 
 use Proxmox::Log;
 use Proxmox::Install;
 use Proxmox::Install::Config;
 use Proxmox::UI;
 
+
 my $commands = {
     'dump-env' => 'Dump the current ISO and Hardware environment to base the installer UI on.',
+    'dump-udev' => 'Dump disk and network device info. Used for the auto installation.',
     'start-session' => 'Start an installation session, with command and result transmitted via stdin/out',
     'start-session-test' => 'Start an installation TEST session, with command and result transmitted via stdin/out',
     'help' => 'Output this usage help.',
@@ -85,6 +88,17 @@ if ($cmd eq 'dump-env') {
     my $run_env = Proxmox::Install::RunEnv::query_installation_environment();
     my $run_env_serialized = to_json($run_env, {canonical => 1, utf8 => 1}) ."\n";
     file_write_all($run_env_file, $run_env_serialized);
+} elsif ($cmd eq 'dump-udev') {
+    my $out_dir = $env->{locations}->{run};
+    make_path($out_dir);
+    die "failed to create output directory '$out_dir'\n" if !-d $out_dir;
+
+    my $output = {};
+    $output->{disks} = Proxmox::Sys::Udev::disk_details();
+    $output->{nics} = Proxmox::Sys::Udev::nic_details();
+
+    my $output_serialized = to_json($output, {canonical => 1, utf8 => 1}) ."\n";
+    file_write_all("$out_dir/run-env-udev.json", $output_serialized);
 } elsif ($cmd eq 'start-session') {
     Proxmox::UI::init_stdio({}, $env);
 
-- 
2.39.2





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [pve-devel] [RFC installer 2/6] add proxmox-auto-installer
  2023-09-05 13:28 [pve-devel] [RFC installer 0/6] add automated installation Aaron Lauterer
  2023-09-05 13:28 ` [pve-devel] [RFC installer 1/6] low level: sys: fetch udev properties Aaron Lauterer
@ 2023-09-05 13:28 ` Aaron Lauterer
  2023-09-21 11:16   ` Christoph Heiss
  2023-09-05 13:28 ` [pve-devel] [RFC installer 3/6] add answer file fetch script Aaron Lauterer
                   ` (3 subsequent siblings)
  5 siblings, 1 reply; 11+ messages in thread
From: Aaron Lauterer @ 2023-09-05 13:28 UTC (permalink / raw)
  To: pve-devel

The auto installer, in this first iteration expects an answer file on
stdin. It needs to be in TOML format.

It then translates the information from the answer file into the JSON
needed for the low level installer. Basically all options that can be
chosen during a normal installation (GUI/TUI) can be configured in the
answer file.

Additionally it is possible to select the NIC and disks by matching them
against UDEV device properties. For example, if one wants to use a NIC
with a specific MAC address. Or the disks for the OS can be matched
against a vendor or model number.

It supports basic globbing/wildcard is supported at the beginning and
end of the search string. The matching is implemented by us as it isn't
that difficult and we can avoid additional crates.

The answer file has options to configure commands to be run pre- and
post-installation. The idea is that one could for example clean up the
disks or send a status update to some dashboard, or modify the
installation further before rebooting.

Technically it is reusing a lot of the TUI installer. All the source
files needed are in the 'tui' subdirectory. The idea is, that we can
factor out common code into a dedicated library crate. To make it
easier, unused parts are removed.
Some changes were made as well, for example changing HashMaps to
BTreeMaps to avoid random ordering. Some structs got their properties
made public, but with a refactor, we can probably rework that and
implement additional From methods.

For the tests, I used the information from one of our benchmark servers
to have a realistic starting point.

Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
---

I am not too happy that the json files created by the low level
installer are currently all stored as is in the test directory. E.g.
locales, iso-info and so forth. Those could probably be created on
demand. While the others, run-env-{info,udev}, need to be stable for the
tests to work as expected.

 Cargo.toml                                    |   1 +
 proxmox-auto-installer/Cargo.toml             |  13 +
 proxmox-auto-installer/answer.toml            |  36 ++
 .../resources/test/iso-info.json              |   1 +
 .../resources/test/locales.json               |   1 +
 .../test/parse_answer/disk_match.json         |  28 ++
 .../test/parse_answer/disk_match.toml         |  14 +
 .../test/parse_answer/disk_match_all.json     |  25 +
 .../test/parse_answer/disk_match_all.toml     |  16 +
 .../test/parse_answer/disk_match_any.json     |  32 ++
 .../test/parse_answer/disk_match_any.toml     |  16 +
 .../resources/test/parse_answer/minimal.json  |  17 +
 .../resources/test/parse_answer/minimal.toml  |  14 +
 .../test/parse_answer/nic_matching.json       |  17 +
 .../test/parse_answer/nic_matching.toml       |  19 +
 .../resources/test/parse_answer/readme        |   4 +
 .../test/parse_answer/specific_nic.json       |  17 +
 .../test/parse_answer/specific_nic.toml       |  19 +
 .../resources/test/parse_answer/zfs.json      |  26 +
 .../resources/test/parse_answer/zfs.toml      |  19 +
 .../resources/test/run-env-info.json          |   1 +
 .../resources/test/run-env-udev.json          |   1 +
 proxmox-auto-installer/src/answer.rs          | 144 ++++++
 proxmox-auto-installer/src/main.rs            | 412 ++++++++++++++++
 proxmox-auto-installer/src/tui/mod.rs         |   3 +
 proxmox-auto-installer/src/tui/options.rs     | 302 ++++++++++++
 proxmox-auto-installer/src/tui/setup.rs       | 447 ++++++++++++++++++
 proxmox-auto-installer/src/tui/utils.rs       | 268 +++++++++++
 proxmox-auto-installer/src/udevinfo.rs        |   9 +
 proxmox-auto-installer/src/utils.rs           | 325 +++++++++++++
 30 files changed, 2247 insertions(+)
 create mode 100644 proxmox-auto-installer/Cargo.toml
 create mode 100644 proxmox-auto-installer/answer.toml
 create mode 100644 proxmox-auto-installer/resources/test/iso-info.json
 create mode 100644 proxmox-auto-installer/resources/test/locales.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_all.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_all.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_any.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/disk_match_any.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/minimal.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/minimal.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/nic_matching.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/nic_matching.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/readme
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/specific_nic.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/specific_nic.toml
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/zfs.json
 create mode 100644 proxmox-auto-installer/resources/test/parse_answer/zfs.toml
 create mode 100644 proxmox-auto-installer/resources/test/run-env-info.json
 create mode 100644 proxmox-auto-installer/resources/test/run-env-udev.json
 create mode 100644 proxmox-auto-installer/src/answer.rs
 create mode 100644 proxmox-auto-installer/src/main.rs
 create mode 100644 proxmox-auto-installer/src/tui/mod.rs
 create mode 100644 proxmox-auto-installer/src/tui/options.rs
 create mode 100644 proxmox-auto-installer/src/tui/setup.rs
 create mode 100644 proxmox-auto-installer/src/tui/utils.rs
 create mode 100644 proxmox-auto-installer/src/udevinfo.rs
 create mode 100644 proxmox-auto-installer/src/utils.rs

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





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [pve-devel] [RFC installer 3/6] add answer file fetch script
  2023-09-05 13:28 [pve-devel] [RFC installer 0/6] add automated installation Aaron Lauterer
  2023-09-05 13:28 ` [pve-devel] [RFC installer 1/6] low level: sys: fetch udev properties Aaron Lauterer
  2023-09-05 13:28 ` [pve-devel] [RFC installer 2/6] add proxmox-auto-installer Aaron Lauterer
@ 2023-09-05 13:28 ` Aaron Lauterer
  2023-09-20  9:52   ` Christoph Heiss
  2023-09-05 13:28 ` [pve-devel] [PATCH installer 4/6] makefile: fix handling of multiple usr_bin files Aaron Lauterer
                   ` (2 subsequent siblings)
  5 siblings, 1 reply; 11+ messages in thread
From: Aaron Lauterer @ 2023-09-05 13:28 UTC (permalink / raw)
  To: pve-devel

With the auto installer present, the crucial question is how we get the
answer file. This script implements the way of a local disk/partition
present, labelled 'proxmoxinst', lower or upper case, with the
'answer.toml' file in the root directory.

We either want to use it directly and call it from 'unconfigured.sh' or
see it as a first approach to showcase how it could be done.

Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
---
 start_autoinstall.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 50 insertions(+)
 create mode 100755 start_autoinstall.sh

diff --git a/start_autoinstall.sh b/start_autoinstall.sh
new file mode 100755
index 0000000..081b865
--- /dev/null
+++ b/start_autoinstall.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+
+answer_file=answer.toml;
+answer_mp=/tmp/answer;
+answer_location="";
+mount_source="";
+label="proxmoxinst";
+
+mount_answer() {
+    echo "mounting answer filesystem"
+    mkdir -p $answer_mp
+    mount "$mount_source" "$answer_mp"
+}
+
+find_fs() {
+    search_path="/dev/disk/by-label/";
+    if [[ -e ${search_path}/${label,,} ]]; then
+	mount_source="${search_path}/${label,,}";
+    elif [[ -e ${search_path}/${label^^} ]]; then
+	mount_source="${search_path}/${label^^}";
+    else
+	echo "No partition for answer file found!";
+	return 1;
+    fi
+    mount_answer;
+}
+
+find_answer_file() {
+    if [ -e $answer_mp/$answer_file ]; then
+	cp $answer_mp/$answer_file /run/proxmox-installer/answer.toml
+	answer_location=/run/proxmox-installer/answer.toml
+	umount $answer_mp;
+    else
+	return 1;
+    fi
+}
+
+start_installation() {
+    echo "calling 'proxmox-auto-installer'";
+    proxmox-auto-installer < $answer_location;
+}
+
+
+if find_fs && find_answer_file; then
+    echo "found answer file on local device";
+    start_installation;
+else
+    echo "Could not retrieve answer file. Aborting installation!"
+    exit 1;
+fi
-- 
2.39.2





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [pve-devel] [PATCH installer 4/6] makefile: fix handling of multiple usr_bin files
  2023-09-05 13:28 [pve-devel] [RFC installer 0/6] add automated installation Aaron Lauterer
                   ` (2 preceding siblings ...)
  2023-09-05 13:28 ` [pve-devel] [RFC installer 3/6] add answer file fetch script Aaron Lauterer
@ 2023-09-05 13:28 ` Aaron Lauterer
  2023-09-05 13:28 ` [pve-devel] [RFC installer 5/6] makefile: add auto installer Aaron Lauterer
  2023-09-05 13:28 ` [pve-devel] [RFC docs 6/6] installation: add unattended documentation Aaron Lauterer
  5 siblings, 0 replies; 11+ messages in thread
From: Aaron Lauterer @ 2023-09-05 13:28 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
---
this fix should apply in any case and is unrelated to the series, but I
discovered it while adding the proxmox-auto-installer binary.

 Makefile | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index dc180b2..514845a 100644
--- a/Makefile
+++ b/Makefile
@@ -93,7 +93,7 @@ install: $(INSTALLER_SOURCES) $(CARGO_COMPILEDIR)/proxmox-tui-installer
 	install -D -m 755 unconfigured.sh $(DESTDIR)/sbin/unconfigured.sh
 	install -D -m 755 proxinstall $(DESTDIR)/usr/bin/proxinstall
 	install -D -m 755 proxmox-low-level-installer $(DESTDIR)/$(BINDIR)/proxmox-low-level-installer
-	$(foreach i,$(USR_BIN), install -m755 $(CARGO_COMPILEDIR)/$(i) $(DESTDIR)$(BINDIR)/)
+	$(foreach i,$(USR_BIN), install -m755 $(CARGO_COMPILEDIR)/$(i) $(DESTDIR)$(BINDIR)/ ;)
 	install -D -m 755 checktime $(DESTDIR)/usr/bin/checktime
 	install -D -m 644 xinitrc $(DESTDIR)/.xinitrc
 	install -D -m 755 spice-vdagent.sh $(DESTDIR)/.spice-vdagent.sh
-- 
2.39.2





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [pve-devel] [RFC installer 5/6] makefile: add auto installer
  2023-09-05 13:28 [pve-devel] [RFC installer 0/6] add automated installation Aaron Lauterer
                   ` (3 preceding siblings ...)
  2023-09-05 13:28 ` [pve-devel] [PATCH installer 4/6] makefile: fix handling of multiple usr_bin files Aaron Lauterer
@ 2023-09-05 13:28 ` Aaron Lauterer
  2023-09-05 13:28 ` [pve-devel] [RFC docs 6/6] installation: add unattended documentation Aaron Lauterer
  5 siblings, 0 replies; 11+ messages in thread
From: Aaron Lauterer @ 2023-09-05 13:28 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
---
 Makefile | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index 514845a..15cdc14 100644
--- a/Makefile
+++ b/Makefile
@@ -18,7 +18,9 @@ INSTALLER_SOURCES=$(shell git ls-files) country.dat
 
 PREFIX = /usr
 BINDIR = $(PREFIX)/bin
-USR_BIN := proxmox-tui-installer
+USR_BIN :=\
+	  proxmox-tui-installer \
+	  proxmox-auto-installer
 
 COMPILED_BINS := \
 	$(addprefix $(CARGO_COMPILEDIR)/,$(USR_BIN))
@@ -43,6 +45,7 @@ $(BUILDDIR):
 	  proxinstall \
 	  proxmox-low-level-installer \
 	  proxmox-tui-installer/ \
+	  proxmox-auto-installer/ \
 	  spice-vdagent.sh \
 	  unconfigured.sh \
 	  xinitrc \
@@ -103,6 +106,7 @@ $(COMPILED_BINS): cargo-build
 .PHONY: cargo-build
 cargo-build:
 	$(CARGO) build --package proxmox-tui-installer --bin proxmox-tui-installer $(CARGO_BUILD_ARGS)
+	$(CARGO) build --package proxmox-auto-installer --bin proxmox-auto-installer $(CARGO_BUILD_ARGS)
 
 %-banner.png: %-banner.svg
 	rsvg-convert -o $@ $<
-- 
2.39.2





^ permalink raw reply	[flat|nested] 11+ messages in thread

* [pve-devel] [RFC docs 6/6] installation: add unattended documentation
  2023-09-05 13:28 [pve-devel] [RFC installer 0/6] add automated installation Aaron Lauterer
                   ` (4 preceding siblings ...)
  2023-09-05 13:28 ` [pve-devel] [RFC installer 5/6] makefile: add auto installer Aaron Lauterer
@ 2023-09-05 13:28 ` Aaron Lauterer
  5 siblings, 0 replies; 11+ messages in thread
From: Aaron Lauterer @ 2023-09-05 13:28 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
---
 pve-installation.adoc | 245 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 245 insertions(+)

diff --git a/pve-installation.adoc b/pve-installation.adoc
index aa4e4c9..9011d09 100644
--- a/pve-installation.adoc
+++ b/pve-installation.adoc
@@ -298,6 +298,251 @@ following command:
 # zpool add <pool-name> log </dev/path_to_fast_ssd>
 ----
 
+[[installation_auto]]
+Unattended Installation
+-----------------------
+
+// TODO: rework once it is clearer how the process actually works
+
+The unattended installation can help to automate the installation process from
+the very beginning. It needs the dedicated ISO image for unattended
+installations.
+
+The options that the regular installer would ask for, need to be provided in an
+answer file. The answer file can be placed on a USB flash drive. The volume
+needs to be labeled 'PROXMOXINST' and needs to contain the answer file named
+'answer.toml'.
+
+The answer file allows for fuzzy matching to select the network card and disks
+used for the installation.
+
+[[installation_auto_answer_file]]
+Answer file
+~~~~~~~~~~~
+
+The answer file is expected in `TOML` format. The following example shows an
+answer file that uses the DHCP provided network settings. It will use a ZFS
+Raid 10 with an 'ashift' of '12' and will use all Micron disks it can find.
+
+----
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pve-1.example.com"
+mailto = "mail@example.com"
+timezone = "Europe/Vienna"
+password = "123456"
+
+[network]
+use_dhcp = true
+
+[disks]
+filesystem = "zfs-raid10"
+zfs.ashift = 12
+filter.ID_SERIAL = "Micron_*"
+----
+
+Global Section
+^^^^^^^^^^^^^^
+
+This section contains the following keys:
+
+`keyboard`:: The keyboard layout. The following options are possible:
+*   `de`
+*   `de-ch`
+*   `dk`
+*   `en-gb`
+*   `en-us`
+*   `es`
+*   `fi`
+*   `fr`
+*   `fr-be`
+*   `fr-ca`
+*   `fr-ch`
+*   `hu`
+*   `is`
+*   `it`
+*   `jp`
+*   `lt`
+*   `mk`
+*   `nl`
+*   `no`
+*   `pl`
+*   `pt`
+*   `pt-br`
+*   `se`
+*   `si`
+*   `tr`
+
+`country`:: The country code in the two letter variant. For example `at`, `us`,
+    or `fr`.
+
+`fqdn`:: The fully qualified domain of the host. The domain part will be used
+as the search domain.
+
+`mailto`:: The default email address. Used for notifications.
+
+`timezone`:: The timezone in `tzdata` format. For example `Europe/Vienna` or
+`America/New_York`.
+
+`password`:: The password for the `root` user.
+
+`pre_command`:: A list of commands to run prior to the installation.
+
+`post_command`:: A list of commands run after the installation.
+
+TODO: explain commands and list of available useful CLI tools in the iso
+
+Network Section
+^^^^^^^^^^^^^^^
+
+`use_dhcp`:: Set to `true` if the IP configuration received by DHCP should be
+used.
+
+`cidr`:: IP address in CIDR notation. For example `192.168.1.10/24`.
+
+`dns`:: IP address of the DNS server.
+
+`gateway`:: IP address of the default gateway.
+
+`filter`:: Filter against `UDEV` properties to select the network card. See
+xref:installation_auto_filter[Filters].
+
+
+Disks Section
+^^^^^^^^^^^^^
+
+`filesystem`:: The file system used for the installation. The options are:
+*    `ext4`
+*    `xfs`
+*    `zfs-raid0`
+*    `zfs-raid1`
+*    `zfs-raid10`
+*    `zfs-raidz1`
+*    `zfs-raidz2`
+*    `zfs-raidz3`
+*    `btrfs-raid0`
+*    `btrfs-raid1`
+*    `btrfs-raid10`
+
+`disk_selection`:: List of disks to use. Useful if you are sure about the disk
+names. For example:
+----
+disk_selection = ["sda", "sdb"]
+----
+
+`filter_match`:: Can be `any` or `all`. Decides if a match of any filter is
+enough or if all filters need to match for a disk to be selected. Default is `any`.
+
+`filter`:: Filter against `UDEV` properties to select disks to install to. See
+xref:installation_auto_filter[Filters]. Filters won't be used if
+`disk_selection` is configured.
+
+`zfs`:: ZFS specific properties. See xref:advanced_zfs_options[Advanced ZFS Configuration Options]
+for more details. The properties are:
+    * `ashift`
+    * `checksum`
+    * `compress`
+    * `copies`
+    * `hdsize`
+
+`lvm`:: Advanced properties that can be used when `ext4` or `xfs` is used as `filesystem`.
+See xref:advanced_lvm_options[Advanced LVM Configuration Options] for more details. The properties are:
+    * `hdsize`
+    * `swapsize`
+    * `maxroot`
+    * `maxvz`
+    * `minfree`
+
+`btrfs`:: BTRFS specific settings. Currently there is only `hdsize`.
+
+[[installation_auto_filter]]
+Filters
+~~~~~~~
+
+Filters allow you to match against device properties exposed by `udevadm`. You
+can see them if you run the following commands. The first is for a disk, the
+second for a network card.
+----
+udevadm info /sys/block/{disk name}
+udevadm info /sys/class/net/{NIC name}
+----
+
+For example:
+
+----
+# udevadm info -p /sys/class/net/enp129s0f0np0 | grep "E:"
+E: DEVPATH=/devices/pci0000:80/0000:80:01.1/0000:81:00.0/net/enp129s0f0np0
+E: SUBSYSTEM=net
+E: INTERFACE=enp129s0f0np0
+E: IFINDEX=6
+E: USEC_INITIALIZED=4808080
+E: ID_NET_NAMING_SCHEME=v252
+E: ID_NET_NAME_MAC=enx1c34da5c5e24
+E: ID_OUI_FROM_DATABASE=Mellanox Technologies, Inc.
+E: ID_NET_NAME_PATH=enp129s0f0np0
+E: ID_BUS=pci
+E: ID_VENDOR_ID=0x15b3
+E: ID_MODEL_ID=0x1015
+E: ID_PCI_CLASS_FROM_DATABASE=Network controller
+E: ID_PCI_SUBCLASS_FROM_DATABASE=Ethernet controller
+E: ID_VENDOR_FROM_DATABASE=Mellanox Technologies
+E: ID_MODEL_FROM_DATABASE=MT27710 Family [ConnectX-4 Lx] (MCX4421A-ACQN ConnectX-4 Lx EN OCP,2x25G)
+E: ID_PATH=pci-0000:81:00.0
+E: ID_PATH_TAG=pci-0000_81_00_0
+E: ID_NET_DRIVER=mlx5_core
+E: ID_NET_LINK_FILE=/usr/lib/systemd/network/99-default.link
+E: ID_NET_NAME=enp129s0f0np0
+E: SYSTEMD_ALIAS=/sys/subsystem/net/devices/enp129s0f0np0
+E: TAGS=:systemd:
+E: CURRENT_TAGS=:systemd:
+----
+
+The key of the filter decides on which property it should be applied to. For
+example, to match against the name of the network card, the filter could look
+like this:
+
+----
+filter.ID_NET_NAME = "enp129s0fn0np0"
+----
+
+Filter support globbing (`*`) at the beginning and end of the search
+string. For example, if we want to match against the vendor part of the MAC
+address in the property `ID_NET_NAME_MAC`, we can use the following filter:
+
+----
+filter.ID_NET_NAME_MAC = "*1c34da*"
+----
+
+In case we would want to match against the full MAC address, we only need to
+use the glob character at the beginning:
+
+----
+filter.ID_NET_NAME_MAC = "*1c34da5c5e24"
+----
+
+Useful Properties
+^^^^^^^^^^^^^^^^^
+
+For network cards, the following properties can be useful:
+
+* `ID_NET_NAME`
+* `ID_NET_NAME_MAC`
+* `ID_VENDOR_FROM_DATABASE`
+* `ID_MODEL_FROM_DATABASE`
+
+For disks, these properties can be useful:
+
+* `DEVNAME`
+* `ID_SERIAL_SHORT`
+* `ID_WWN`
+* `ID_MODEL`
+* `ID_SERIAL`
+
+
+// TODO: showcase a more complicated answer file
+
+
 ifndef::wiki[]
 
 Install {pve} on Debian
-- 
2.39.2





^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [pve-devel] [RFC installer 3/6] add answer file fetch script
  2023-09-05 13:28 ` [pve-devel] [RFC installer 3/6] add answer file fetch script Aaron Lauterer
@ 2023-09-20  9:52   ` Christoph Heiss
  0 siblings, 0 replies; 11+ messages in thread
From: Christoph Heiss @ 2023-09-20  9:52 UTC (permalink / raw)
  To: Aaron Lauterer; +Cc: Proxmox VE development discussion


I think this can be part of the auto-installer itself, instead of
introducing another shell script somewhere. While yes, "do one thing and
do it well", its a rather small part and not unreasonable to move it
directly into the auto-installer.
Or have a separate Rust-written executable for this, both have their own
merits of course - just not shell :^)

If and when we add other methods of retrieving the answer file (as
illustrated in the cover letter), it makes sense to write this in a
sensible language and have a modular architecture, rather than arcane
shell scripting. (Apart from the fact that the latter might even be
nearly impossible for some things.)

On Tue, Sep 05, 2023 at 03:28:29PM +0200, Aaron Lauterer wrote:
>
> With the auto installer present, the crucial question is how we get the
> answer file. This script implements the way of a local disk/partition
> present, labelled 'proxmoxinst', lower or upper case, with the
> 'answer.toml' file in the root directory.
>
> We either want to use it directly and call it from 'unconfigured.sh' or
> see it as a first approach to showcase how it could be done.
>
> Signed-off-by: Aaron Lauterer <a.lauterer@proxmox.com>
> ---
>  start_autoinstall.sh | 50 ++++++++++++++++++++++++++++++++++++++++++++
>  1 file changed, 50 insertions(+)
>  create mode 100755 start_autoinstall.sh
>
> [..]




^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [pve-devel] [RFC installer 2/6] add proxmox-auto-installer
  2023-09-05 13:28 ` [pve-devel] [RFC installer 2/6] add proxmox-auto-installer Aaron Lauterer
@ 2023-09-21 11:16   ` Christoph Heiss
  2023-09-21 11:30     ` Thomas Lamprecht
  0 siblings, 1 reply; 11+ messages in thread
From: Christoph Heiss @ 2023-09-21 11:16 UTC (permalink / raw)
  To: Aaron Lauterer; +Cc: Proxmox VE development discussion


Some general notes:

- The overall approach seems fine too me. Did some cursory testing too,
  worked fine - although I did not really test out the
  filtering/matching much.

- Probably just due to it being still a bit early, but all these
  `.unwrap()/.expect()` should be replaced with proper error handling in
  the end.

- Continuing on the thought of error handling:
  We should come up with some way to properly report and (persistently)
  log errors, such that an user can see them. It's really not helpful if
  the machine is just boot-looping without any indication what went
  wrong. Any `.expect()`/`.unwrap()` will just panic the auto-installer,
  which will ultimately result in a reboot.

  Especially in this case, where everything runs headless, it's IMO
  important to have /some/ way to know what went wrong.

  Sending simple JSON-formatted logs to an HTTP endpoint or even using
  the rsyslog protocol come to mind and would be a good solution for
  this, I think.
  Or, if the answer file is read from an (USB drive) partition, write
  the log there?

  At the very least, I would log everything to a tmpfile, such that an
  administrator can e.g. upload that file somewhere using a post-install
  command.

  Maybe even /disable/ auto-reboot in case of an error, so that
  administrators can investigate the machine directly?

- For the most part I did a rather in-depth review, just as things
  caught my eye. Still trimmed down things as much as possible.

On Tue, Sep 05, 2023 at 03:28:28PM +0200, Aaron Lauterer wrote:
> [..]
>
> 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.
If we ever want more extensive matching in the future, we could use the
`glob` crate, which provides a `Pattern` struct for using it directly.
It's actively maintained and does not have any non-dev dependencies, so
from my side it would be fine.

> 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.
If you want I can prepare some patches too, moving all the things used
in this series into a separate, shared crate. Just ping me in that case.

>
> [..]
> diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Cargo.toml
> new file mode 100644
> index 0000000..fd38d28
> --- /dev/null
> +++ b/proxmox-auto-installer/Cargo.toml
> @@ -0,0 +1,13 @@
> +[package]
> +name = "proxmox-auto-installer"
> +version = "0.1.0"
> +edition = "2021"
> +authors = [ "Aaron Lauerer <a.lauterer@proxmox.com" ]
> +license = "AGPL-3"
> +exclude = [ "build", "debian" ]
> +homepage = "https://www.proxmox.com"
> +
> +[dependencies]
> +serde = { version = "1.0", features = ["derive"] }
> +serde_json = "1.0"
Should be workspace dependencies, as they are shared between both crates
anyway (and possible a third, shared crate).

> +toml = "0.5.11"
> [..]
> diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
> new file mode 100644
> index 0000000..566030c
> --- /dev/null
> +++ b/proxmox-auto-installer/src/answer.rs
> @@ -0,0 +1,144 @@
> +use serde::{Deserialize, Serialize};
> +use std::collections::BTreeMap;
> +
> +#[derive(Clone, Deserialize, Debug)]
> +#[serde(rename_all = "lowercase")]
> +pub struct Answer {
> +    pub global: Global,
> +    pub network: Network,
> +    pub disks: Disks,
> +}
> +
> +#[derive(Clone, Deserialize, Debug)]
> +pub struct Global {
> +    pub country: String,
> +    pub fqdn: String,
`proxmox_tui_installer::utils::Fqdn` implements `Deserialize`, so can be
used here directly. As this is a user-provided value and *must* be
valid anyway, it's IMHO okay to let it fail if it doesn't parse.

> +    pub keyboard: String,
> +    pub mailto: String,
> +    pub timezone: String,
> +    pub password: String,
> +    pub pre_command: Option<Vec<String>>,
> +    pub post_command: Option<Vec<String>>,
The additional `Option<..>` can be avoided for both:

  #[serde(default)]
  pub pre_command: Vec<String>,

Further, a way to run commands in the finished installation chroot could
be pretty useful too, e.g. to create some files in /etc comes to mind.

> +}
> +
> +#[derive(Clone, Deserialize, Debug)]
> +pub struct Network {
> +    pub use_dhcp: Option<bool>,
> +    pub cidr: Option<String>,
`proxmox_tui_installer::utils::CidrAddress` implements `Deserialize` too

> +    pub dns: Option<String>,
> +    pub gateway: Option<String>,
.. and `std::net::IpAddr`

> +    // use BTreeMap to have keys sorted
> +    pub filter: Option<BTreeMap<String, String>>,
The `Option<..>` can be again be removed, as with `Global::pre_command`
and `Global::post_command`. Same for other instances below.

> +}
> +
> +#[derive(Clone, Deserialize, Debug)]
> +pub struct Disks {
> +    pub filesystem: Option<Filesystem>,
> +    pub disk_selection: Option<Vec<String>>,
> +    pub filter_match: Option<FilterMatch>,
As `FilterMatch::Any` is the default anyway, it can be declared the
default variant below, such that `#[serde(default)]` can be used there
too. Samo for `filesystem` above.

> +    // use BTreeMap to have keys sorted
> +    pub filter: Option<BTreeMap<String, String>>,
> +    pub zfs: Option<ZfsOptions>,
> +    pub lvm: Option<LvmOptions>,
> +    pub btrfs: Option<BtrfsOptions>,
These last three should probably be in a enum, much like
`proxmox_tui_installer::options::AdvancedBootdiskOptions`. Only one of
them will ever be used at the same time, using an enum this invariant
can easily be enforced.

OTOH, probably needs a custom deserializer, so a simple check somewhere
that only one of them is ever set would be fine too.

> +}
> +
> +#[derive(Clone, Deserialize, Debug, PartialEq)]
> +#[serde(rename_all = "lowercase")]
> +pub enum FilterMatch {
> +    Any,
> +    All,
> +}
> +
> +#[derive(Clone, Deserialize, Serialize, Debug)]
> +#[serde(rename_all = "kebab-case")]
> +pub enum Filesystem {
> +    Ext4,
> +    Xfs,
> +    ZfsRaid0,
> +    ZfsRaid1,
> +    ZfsRaid10,
> +    ZfsRaidZ1,
> +    ZfsRaidZ2,
> +    ZfsRaidZ3,
> +    BtrfsRaid0,
> +    BtrfsRaid1,
> +    BtrfsRaid10,
> +}
This should probably reuse `proxmox_tui_installer::options::FsType`.
Having /even more/ definitions of possible filesystems (e.g. the ZFS
stuff is already mostly duplicated in pbs-api-types). Also saves us some
converting between these two types, like in parse_answer().

In this case it requires a custom deserializer, but boils down to a
rather simple `match`.

> +
> +#[derive(Clone, Deserialize, Debug)]
> +pub struct ZfsOptions {
> +    pub ashift: Option<usize>,
> +    pub checksum: Option<ZfsChecksumOption>,
> +    pub compress: Option<ZfsCompressOption>,
#[serde(default)] and avoid the `Option<..>`s again :^)

> +    pub copies: Option<usize>,
> +    pub hdsize: Option<f64>,
> +}
^ Should reuse `proxmox_tui_installer::options::ZfsBootdiskOptions`

> + [..]
> +
> +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
> +#[serde(rename_all(deserialize = "lowercase"))]
> +pub enum ZfsCompressOption {
> +    #[default]
> +    On,
> +    Off,
> +    Lzjb,
> +    Lz4,
> +    Zle,
> +    Gzip,
> +    Zstd,
> +}
^ Same

> +
> +#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Deserialize)]
> +#[serde(rename_all = "kebab-case")]
> +pub enum ZfsChecksumOption {
> +    #[default]
> +    On,
> +    Off,
> +    Fletcher2,
> +    Fletcher4,
> +    Sha256,
> +}
^ Same

> +
> +#[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>,
> +}
^ Same

> +
> +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>,
> +}
^ Same

> +
> +impl BtrfsOptions {
> +    pub fn new() -> BtrfsOptions {
> +        BtrfsOptions { hdsize: None }
> +    }
> +}
> diff --git a/proxmox-auto-installer/src/main.rs b/proxmox-auto-installer/src/main.rs
> new file mode 100644
> index 0000000..d647567
> --- /dev/null
> +++ b/proxmox-auto-installer/src/main.rs
> @@ -0,0 +1,412 @@
> [..]
> +
> +fn installer_setup(
> +    in_test_mode: bool,
> +) -> Result<(Answer, LocaleInfo, RuntimeInfo, UdevInfo), String> {
> +    let base_path = if in_test_mode { "./testdir" } else { "/" };
> +    let mut path = PathBuf::from(base_path);
> +
> [..]
> +
> +    let mut buffer = String::new();
> +    let lines = std::io::stdin().lock().lines();
> +    for line in lines {
> +        buffer.push_str(&line.unwrap());
> +        buffer.push('\n');
> +    }
Instead of reading line-by-line:

  use std::io::Read;
  let mut buffer = String::new();
  std::io::stdin().lock().read_to_string(&mut buffer);

> +
> +    let answer: answer::Answer =
> +        toml::from_str(&buffer).map_err(|err| format!("Failed parsing answer file: {err}"))?;
> +
> +    runtime_info.disks.sort();
> +    if runtime_info.disks.is_empty() {
> +        Err("The installer could not find any supported hard disks.".to_owned())
> +    } else {
> +        Ok((answer, locale_info, runtime_info, udev_info))
> +    }
> +}
> +
> [..]
> +
> +fn run_installation(
> +    answer: &Answer,
> +    locales: &LocaleInfo,
> +    runtime_info: &RuntimeInfo,
> +    udevadm_info: &UdevInfo,
> +) -> Result<(), String> {
> +    let config = parse_answer(answer, udevadm_info, runtime_info, locales)?;
> [..]
> +    let mut inner = || {
> +        let reader = child.stdout.take().map(BufReader::new)?;
> +        let mut writer = child.stdin.take()?;
> +
> +        serde_json::to_writer(&mut writer, &config).unwrap();
> +        writeln!(writer).unwrap();
> +
> +        for line in reader.lines() {
> +            match line {
> +                Ok(line) => print!("{line}"),
Needs a `println!()`, otherwise everything is just printed on one, very
long line.

> +                Err(_) => break,
> +            };
> +        }
> +        Some(())
> +    };
> +    match inner() {
> +        Some(_) => Ok(()),
> +        None => Err("low level installer returned early".to_string()),
> +    }
> +}
> +
> +fn parse_answer(
> +    answer: &Answer,
> +    udev_info: &UdevInfo,
> +    runtime_info: &RuntimeInfo,
> +    locales: &LocaleInfo,
> +) -> Result<InstallConfig, String> {
> +    let filesystem = match &answer.disks.filesystem {
> +        Some(answer::Filesystem::Ext4) => FsType::Ext4,
> +        Some(answer::Filesystem::Xfs) => FsType::Xfs,
> +        Some(answer::Filesystem::ZfsRaid0) => FsType::Zfs(ZfsRaidLevel::Raid0),
> +        Some(answer::Filesystem::ZfsRaid1) => FsType::Zfs(ZfsRaidLevel::Raid1),
> +        Some(answer::Filesystem::ZfsRaid10) => FsType::Zfs(ZfsRaidLevel::Raid10),
> +        Some(answer::Filesystem::ZfsRaidZ1) => FsType::Zfs(ZfsRaidLevel::RaidZ),
> +        Some(answer::Filesystem::ZfsRaidZ2) => FsType::Zfs(ZfsRaidLevel::RaidZ2),
> +        Some(answer::Filesystem::ZfsRaidZ3) => FsType::Zfs(ZfsRaidLevel::RaidZ3),
> +        Some(answer::Filesystem::BtrfsRaid0) => FsType::Btrfs(BtrfsRaidLevel::Raid0),
> +        Some(answer::Filesystem::BtrfsRaid1) => FsType::Btrfs(BtrfsRaidLevel::Raid1),
> +        Some(answer::Filesystem::BtrfsRaid10) => FsType::Btrfs(BtrfsRaidLevel::Raid10),
> +        None => FsType::Ext4,
Definitely would put this in a `From<..>` impl, or better re-use the
definitions from the TUI (as mentioned above already).

> +    };
> +
> [..]
> +    utils::set_disks(answer, udev_info, runtime_info, &mut config)?;
> +    match &config.filesys {
> [..]
> +        FsType::Zfs(_) => {
> +            let zfs = match &answer.disks.zfs {
> +                Some(zfs) => zfs.clone(),
> +                None => answer::ZfsOptions::new(),
> +            };
> +            let first_selected_disk = utils::get_first_selected_disk(&config);
> +
> +            config.hdsize = zfs
> +                .hdsize
> +                .unwrap_or(runtime_info.disks[first_selected_disk].size);
> +            config.zfs_opts = Some(InstallZfsOption {
> +                ashift: zfs.ashift.unwrap_or(12),
> +                compress: match zfs.compress {
> +                    Some(answer::ZfsCompressOption::On) => ZfsCompressOption::On,
> +                    Some(answer::ZfsCompressOption::Off) => ZfsCompressOption::Off,
> +                    Some(answer::ZfsCompressOption::Lzjb) => ZfsCompressOption::Lzjb,
> +                    Some(answer::ZfsCompressOption::Lz4) => ZfsCompressOption::Lz4,
> +                    Some(answer::ZfsCompressOption::Zle) => ZfsCompressOption::Zle,
> +                    Some(answer::ZfsCompressOption::Gzip) => ZfsCompressOption::Gzip,
> +                    Some(answer::ZfsCompressOption::Zstd) => ZfsCompressOption::Zstd,
> +                    None => ZfsCompressOption::On,
^ Same

> +                },
> +                checksum: match zfs.checksum {
> +                    Some(answer::ZfsChecksumOption::On) => ZfsChecksumOption::On,
> +                    Some(answer::ZfsChecksumOption::Off) => ZfsChecksumOption::Off,
> +                    Some(answer::ZfsChecksumOption::Fletcher2) => ZfsChecksumOption::Fletcher2,
> +                    Some(answer::ZfsChecksumOption::Fletcher4) => ZfsChecksumOption::Fletcher4,
> +                    Some(answer::ZfsChecksumOption::Sha256) => ZfsChecksumOption::Sha256,
> +                    None => ZfsChecksumOption::On,
^ Same

> +                },
> +                copies: zfs.copies.unwrap_or(1),
> +            });
> +        }
> [..]
> diff --git a/proxmox-auto-installer/src/utils.rs b/proxmox-auto-installer/src/utils.rs
> new file mode 100644
> index 0000000..6ff78ac
> --- /dev/null
> +++ b/proxmox-auto-installer/src/utils.rs
> @@ -0,0 +1,325 @@
> [..]
> +
> +pub fn get_network_settings(
> +    answer: &Answer,
> +    udev_info: &UdevInfo,
> +    runtime_info: &RuntimeInfo,
> +) -> Result<NetworkOptions, String> {
> +    let mut network_options = NetworkOptions::from(&runtime_info.network);
> +
> +    // Always use the FQDN from the answer file
> +    network_options.fqdn = Fqdn::from(answer.global.fqdn.as_str()).expect("Error parsing FQDN");
> +
> +    if answer.network.use_dhcp.is_none() || !answer.network.use_dhcp.unwrap() {
if !answer.network.use_dhcp.unwrap_or_default() {

> +        network_options.address = CidrAddress::from_str(
> +            answer
> +                .network
> +                .cidr
> +                .clone()
> +                .expect("No CIDR defined")
Should rather return an `Err(..)` than panic'ing here

> +                .as_str(),
> +        )
> +        .expect("Error parsing CIDR");
> +        network_options.dns_server = IpAddr::from_str(
> +            answer
> +                .network
> +                .dns
> +                .clone()
> +                .expect("No DNS server defined")
^ Same
> +                .as_str(),
> +        )
> +        .expect("Error parsing DNS server");
> +        network_options.gateway = IpAddr::from_str(
> +            answer
> +                .network
> +                .gateway
> +                .clone()
> +                .expect("No gateway defined")
^ Same
> +                .as_str(),
> +        )
> +        .expect("Error parsing gateway");
^ Same
> +        network_options.ifname =
> +            get_single_udev_index(answer.network.filter.clone().unwrap(), &udev_info.nics)?
> +    }
> +
> +    Ok(network_options)
> +}
> +
> +fn get_single_udev_index(
> +    filter: BTreeMap<String, String>,
> +    udev_list: &BTreeMap<String, BTreeMap<String, String>>,
> +) -> Result<String, String> {
> +    if filter.is_empty() {
> +        return Err(String::from("no filter defined"));
> +    }
> +    let mut dev_index: Option<String> = None;
> +    'outer: for (dev, dev_values) in udev_list {
> +        for (filter_key, filter_value) in &filter {
> +            for (udev_key, udev_value) in dev_values {
> +                if udev_key == filter_key && find_with_glob(filter_value, udev_value) {
> +                    dev_index = Some(dev.clone());
> +                    break 'outer; // take first match
Just return `Ok(dev.clone())` here directly
> +                }
> +            }
> +        }
> +    }
> +    if dev_index.is_none() {
> +        return Err(String::from("filter did not match any device"));
> +    }
> +
> +    Ok(dev_index.unwrap())
> +}
> +
> [..]
> +
> +fn set_selected_disks(
> +    answer: &Answer,
> +    udev_info: &UdevInfo,
> +    runtime_info: &RuntimeInfo,
> +    config: &mut InstallConfig,
> +) -> Result<(), String> {
> +    match &answer.disks.disk_selection {
> +        Some(selection) => {
> +            for disk_name in selection {
> +                let disk = runtime_info
> +                    .disks
> +                    .iter()
> +                    .find(|item| item.path.ends_with(disk_name.as_str()));
> +                if let Some(disk) = disk {
> +                    config
> +                        .disk_selection
> +                        .insert(disk.index.clone(), disk.index.clone());
> +                }
> +            }
> +        }
> +        None => {
> +            let filter_match = answer
> +                .disks
> +                .filter_match
> +                .clone()
> +                .unwrap_or(FilterMatch::Any);
> +            let selected_disk_indexes = get_matched_udev_indexes(
> +                answer.disks.filter.clone().unwrap(),
> +                &udev_info.disks,
> +                filter_match == FilterMatch::All,
> +            )?;
> +
> +            for i in selected_disk_indexes.into_iter() {
> +                let disk = runtime_info
> +                    .disks
> +                    .iter()
> +                    .find(|item| item.index == i)
> +                    .unwrap();
> +                config
> +                    .disk_selection
> +                    .insert(disk.index.clone(), disk.index.clone());
Isn't this just the same as `config.disk_selection.insert(i, i)`?

> +            }
> +        }
> +    }
> +    if config.disk_selection.is_empty() {
> +        return Err("No disks found matching selection.".to_string());
> +    }
> +    Ok(())
> +}
> +
> [..]
> +
> +pub fn verify_locale_settings(answer: &Answer, locales: &LocaleInfo) -> Result<(), String> {
> +    if !locales
> +        .countries
> +        .keys()
> +        .any(|i| i == &answer.global.country)
Can be replaced with
`locales.countries.contains_key(&answer.global.country)` - should be
more efficiently too, since it avoids iterating all the keys.

> +    {
> +        return Err(format!(
> +            "country code '{}' is not valid",
> +            &answer.global.country
> +        ));
> +    }
> +    if !locales.kmap.keys().any(|i| i == &answer.global.keyboard) {
^ Same

> +        return Err(format!(
> +            "keyboard layout '{}' is not valid",
> +            &answer.global.keyboard
> +        ));
> +    }
> +    if !locales
> +        .cczones
> +        .iter()
> +        .any(|(_, zones)| zones.contains(&answer.global.timezone))
In `locales.json`, there is also the `zones` key (which is not
deserialized by the TUI as it is not needed). This is basically just a
list of all available timezones (minus UTC), thus I would add that to
`LocaleInfo` and use that list here to check if it is a valid timezone.

> +    {
> +        return Err(format!(
> +            "timezone '{}' is not valid",
> +            &answer.global.timezone
> +        ));
> +    }
> +    Ok(())
> +}
> +
> [..]
> +
> +fn run_cmd(cmds: &Vec<String>) -> Result<(), String> {
Ideally, this would be called run_cmds(), as it processes multiple
commands, that should be reflected in the name.

Maybe the above could be renamed to e.g. run_cmds_step()?

> +    for cmd in cmds {
> +        #[cfg(debug_assertions)]
> +        println!("Would run command '{}'", cmd);
> +
> +//        #[cfg(not(debug_assertions))]
> +        {
> +            println!("Command '{}':", cmd);
> +            let output = Command::new("/bin/bash")
> +                .arg("-c")
> +                .arg(cmd.clone())
> +                .output()
> +                .map_err(|err| format!("error running command {}: {err}", cmd))?;
As this is also used to run user-provided commands, it should them in
some well-known working directory. (Possibly a temporary directory, or
just /).

> +            print!(
> +                "{}",
> +                String::from_utf8(output.stdout).map_err(|err| format!("{err}"))?
> +            );
> +            print!(
> +                "{}",
> +                String::from_utf8(output.stderr).map_err(|err| format!("{err}"))?
> +            );
> +            if !output.status.success() {
> +                return Err("command failed".to_string());
> +            }
> +        }
> +    }
> +
> +    Ok(())
> +}
> +




^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [pve-devel] [RFC installer 2/6] add proxmox-auto-installer
  2023-09-21 11:16   ` Christoph Heiss
@ 2023-09-21 11:30     ` Thomas Lamprecht
  2023-09-21 11:39       ` Christoph Heiss
  0 siblings, 1 reply; 11+ messages in thread
From: Thomas Lamprecht @ 2023-09-21 11:30 UTC (permalink / raw)
  To: Proxmox VE development discussion, Christoph Heiss, Aaron Lauterer

Am 21/09/2023 um 13:16 schrieb Christoph Heiss:
> 
> Some general notes:
> 
> - The overall approach seems fine too me. Did some cursory testing too,
>   worked fine - although I did not really test out the
>   filtering/matching much.
> 
> - Probably just due to it being still a bit early, but all these
>   `.unwrap()/.expect()` should be replaced with proper error handling in
>   the end.
> 
> - Continuing on the thought of error handling:
>   We should come up with some way to properly report and (persistently)
>   log errors, such that an user can see them. It's really not helpful if
>   the machine is just boot-looping without any indication what went
>   wrong. Any `.expect()`/`.unwrap()` will just panic the auto-installer,
>   which will ultimately result in a reboot.
> 
>   Especially in this case, where everything runs headless, it's IMO
>   important to have /some/ way to know what went wrong.
> 
>   Sending simple JSON-formatted logs to an HTTP endpoint or even using
>   the rsyslog protocol come to mind and would be a good solution for
>   this, I think.
>   Or, if the answer file is read from an (USB drive) partition, write
>   the log there?

I think this should be added to the config as option and default to
"stop-and-wait-for-human-intervention", i.e., show error in a prompt
and wait.

We can then add other reporting mechanisms in the future, possibly
dependent on where the config is pulled from.

> 
>   At the very least, I would log everything to a tmpfile, such that an
>   administrator can e.g. upload that file somewhere using a post-install
>   command.
> 
>   Maybe even /disable/ auto-reboot in case of an error, so that
>   administrators can investigate the machine directly?
> 
> - For the most part I did a rather in-depth review, just as things
>   caught my eye. Still trimmed down things as much as possible.
> 
> On Tue, Sep 05, 2023 at 03:28:28PM +0200, Aaron Lauterer wrote:
>> [..]
>>
>> 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.
> If we ever want more extensive matching in the future, we could use the
> `glob` crate, which provides a `Pattern` struct for using it directly.
> It's actively maintained and does not have any non-dev dependencies, so
> from my side it would be fine.

yes, IIRC, we mentioned that crate too when we discussed the general
strategy and matching in-person some months ago; would be fine for me
too.


> [..]
>
> Further, a way to run commands in the finished installation chroot could
> be pretty useful too, e.g. to create some files in /etc comes to mind.

I would like to avoid having such things from the start, let's keep
it simple for now and collect feedback when this is then released.
As then, we can decide which mechanism make sense as native feature
and if we really want such a general hook that can allow users to
break lots off stuff and assumptions and still say "well I used the
official installer", i.e., cause support overhead.




^ permalink raw reply	[flat|nested] 11+ messages in thread

* Re: [pve-devel] [RFC installer 2/6] add proxmox-auto-installer
  2023-09-21 11:30     ` Thomas Lamprecht
@ 2023-09-21 11:39       ` Christoph Heiss
  0 siblings, 0 replies; 11+ messages in thread
From: Christoph Heiss @ 2023-09-21 11:39 UTC (permalink / raw)
  To: Thomas Lamprecht; +Cc: Proxmox VE development discussion, Aaron Lauterer


On Thu, Sep 21, 2023 at 01:30:33PM +0200, Thomas Lamprecht wrote:
>
> Am 21/09/2023 um 13:16 schrieb Christoph Heiss:
[..]
> >   Sending simple JSON-formatted logs to an HTTP endpoint or even using
> >   the rsyslog protocol come to mind and would be a good solution for
> >   this, I think.
> >   Or, if the answer file is read from an (USB drive) partition, write
> >   the log there?
>
> I think this should be added to the config as option and default to
> "stop-and-wait-for-human-intervention", i.e., show error in a prompt
> and wait.
Yeah, seems even better to make it a conscious choice for
administrators with a sensible default.

>
> We can then add other reporting mechanisms in the future, possibly
> dependent on where the config is pulled from.
>

[..]
> > Further, a way to run commands in the finished installation chroot could
> > be pretty useful too, e.g. to create some files in /etc comes to mind.
>
> I would like to avoid having such things from the start, let's keep
> it simple for now and collect feedback when this is then released.
> As then, we can decide which mechanism make sense as native feature
> and if we really want such a general hook that can allow users to
> break lots off stuff and assumptions and still say "well I used the
> official installer", i.e., cause support overhead.
No objections here either - I was just spilling out some of my thoughts
while messing around with it for testing.
Esp. the latter is quite a good point, how to handle that "correctly"
without causing us pain.





^ permalink raw reply	[flat|nested] 11+ messages in thread

end of thread, other threads:[~2023-09-21 11:39 UTC | newest]

Thread overview: 11+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2023-09-05 13:28 [pve-devel] [RFC installer 0/6] add automated installation Aaron Lauterer
2023-09-05 13:28 ` [pve-devel] [RFC installer 1/6] low level: sys: fetch udev properties Aaron Lauterer
2023-09-05 13:28 ` [pve-devel] [RFC installer 2/6] add proxmox-auto-installer Aaron Lauterer
2023-09-21 11:16   ` Christoph Heiss
2023-09-21 11:30     ` Thomas Lamprecht
2023-09-21 11:39       ` Christoph Heiss
2023-09-05 13:28 ` [pve-devel] [RFC installer 3/6] add answer file fetch script Aaron Lauterer
2023-09-20  9:52   ` Christoph Heiss
2023-09-05 13:28 ` [pve-devel] [PATCH installer 4/6] makefile: fix handling of multiple usr_bin files Aaron Lauterer
2023-09-05 13:28 ` [pve-devel] [RFC installer 5/6] makefile: add auto installer Aaron Lauterer
2023-09-05 13:28 ` [pve-devel] [RFC docs 6/6] installation: add unattended documentation Aaron Lauterer

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal