]> git.proxmox.com Git - pve-installer.git/commitdiff
install module: getters: correctly use plural in error messages master
authorAlexander Zeidler <a.zeidler@proxmox.com>
Thu, 25 Apr 2024 08:40:21 +0000 (10:40 +0200)
committerThomas Lamprecht <t.lamprecht@proxmox.com>
Thu, 25 Apr 2024 15:52:58 +0000 (17:52 +0200)
Signed-off-by: Alexander Zeidler <a.zeidler@proxmox.com>
69 files changed:
.cargo/config [deleted file]
.cargo/config.toml [new file with mode: 0644]
.gitignore
Cargo.toml
Makefile
Proxmox/Install.pm
Proxmox/Install/Config.pm
Proxmox/Install/RunEnv.pm
Proxmox/Makefile
Proxmox/Sys/Block.pm
Proxmox/Sys/Net.pm
Proxmox/Sys/Udev.pm [new file with mode: 0644]
debian/changelog
debian/control
debian/install
debian/pbs-installer.install [deleted file]
debian/pmg-installer.install [deleted file]
debian/proxmox-auto-install-assistant.install [new file with mode: 0644]
debian/pve-installer.install [deleted file]
debian/source/format [new file with mode: 0644]
proxmox-auto-install-assistant/Cargo.toml [new file with mode: 0644]
proxmox-auto-install-assistant/src/main.rs [new file with mode: 0644]
proxmox-auto-installer/Cargo.toml [new file with mode: 0644]
proxmox-auto-installer/src/answer.rs [new file with mode: 0644]
proxmox-auto-installer/src/bin/proxmox-auto-installer.rs [new file with mode: 0644]
proxmox-auto-installer/src/lib.rs [new file with mode: 0644]
proxmox-auto-installer/src/log.rs [new file with mode: 0644]
proxmox-auto-installer/src/sysinfo.rs [new file with mode: 0644]
proxmox-auto-installer/src/udevinfo.rs [new file with mode: 0644]
proxmox-auto-installer/src/utils.rs [new file with mode: 0644]
proxmox-auto-installer/tests/parse-answer.rs [new file with mode: 0644]
proxmox-auto-installer/tests/resources/iso-info.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/locales.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/disk_match.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/disk_match.toml [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.toml [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.toml [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/minimal.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/minimal.toml [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/nic_matching.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/nic_matching.toml [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/readme [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/specific_nic.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/specific_nic.toml [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/zfs.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/parse_answer/zfs.toml [new file with mode: 0644]
proxmox-auto-installer/tests/resources/run-env-info.json [new file with mode: 0644]
proxmox-auto-installer/tests/resources/run-env-udev.json [new file with mode: 0644]
proxmox-chroot/Cargo.toml [new file with mode: 0644]
proxmox-chroot/src/main.rs [new file with mode: 0644]
proxmox-fetch-answer/Cargo.toml [new file with mode: 0644]
proxmox-fetch-answer/src/fetch_plugins/http.rs [new file with mode: 0644]
proxmox-fetch-answer/src/fetch_plugins/mod.rs [new file with mode: 0644]
proxmox-fetch-answer/src/fetch_plugins/partition.rs [new file with mode: 0644]
proxmox-fetch-answer/src/main.rs [new file with mode: 0644]
proxmox-installer-common/Cargo.toml
proxmox-installer-common/src/disk_checks.rs
proxmox-installer-common/src/options.rs
proxmox-installer-common/src/setup.rs
proxmox-installer-common/src/utils.rs
proxmox-low-level-installer
proxmox-tui-installer/src/main.rs
proxmox-tui-installer/src/options.rs
proxmox-tui-installer/src/setup.rs
proxmox-tui-installer/src/views/bootdisk.rs
proxmox-tui-installer/src/views/install_progress.rs
unconfigured.sh

diff --git a/.cargo/config b/.cargo/config
deleted file mode 100644 (file)
index 3b5b6e4..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-[source]
-[source.debian-packages]
-directory = "/usr/share/cargo/registry"
-[source.crates-io]
-replace-with = "debian-packages"
diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644 (file)
index 0000000..3b5b6e4
--- /dev/null
@@ -0,0 +1,5 @@
+[source]
+[source.debian-packages]
+directory = "/usr/share/cargo/registry"
+[source.crates-io]
+replace-with = "debian-packages"
index 4212b7d946371040971567e1537e8a72eaeb0a17..d50d191a8553d10c957252d0e17c5c401ed4558b 100644 (file)
@@ -1,10 +1,11 @@
-/*.deb
-/*.changes
 /*.buildinfo
+/*.changes
+/*.deb
 /build/
-/testdir/
+/cd-info.test
+/proxmox-installer-[0-9]*/
 /target/
 /test*.img
-country.dat
+/testdir/
 Cargo.lock
-/cd-info.test
+country.dat
index c1bd5783ead5f6ae29269f51f507ca6decc2565f..1e730ce036b860a791c12bcf0dcff36e2d26f1ba 100644 (file)
@@ -1,5 +1,10 @@
 [workspace]
+resolver = "2"
 members = [
+    "proxmox-auto-installer",
+    "proxmox-auto-install-assistant",
+    "proxmox-chroot",
+    "proxmox-fetch-answer",
     "proxmox-installer-common",
     "proxmox-tui-installer",
 ]
index af33e909494567a08f75394186bd050b4f51bedf..e96a0f2166e46405d38033c7ce3086892c337155 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -4,6 +4,7 @@ PACKAGE = proxmox-installer
 BUILDDIR ?= $(PACKAGE)-$(DEB_VERSION_UPSTREAM)
 
 DEB=$(PACKAGE)_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
+ASSISTANT_DEB=proxmox-auto-install-assistant_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb
 DSC=$(PACKAGE)_$(DEB_VERSION).dsc
 
 CARGO ?= cargo
@@ -18,7 +19,12 @@ INSTALLER_SOURCES=$(shell git ls-files) country.dat
 
 PREFIX = /usr
 BINDIR = $(PREFIX)/bin
-USR_BIN := proxmox-tui-installer
+USR_BIN := \
+          proxmox-chroot\
+          proxmox-tui-installer\
+          proxmox-fetch-answer\
+          proxmox-auto-install-assistant \
+          proxmox-auto-installer
 
 COMPILED_BINS := \
        $(addprefix $(CARGO_COMPILEDIR)/,$(USR_BIN))
@@ -47,6 +53,10 @@ $(BUILDDIR):
          interfaces \
          proxinstall \
          proxmox-low-level-installer \
+         proxmox-auto-installer/ \
+         proxmox-auto-install-assistant/ \
+         proxmox-fetch-answer/ \
+         proxmox-chroot \
          proxmox-tui-installer/ \
          proxmox-installer-common/ \
          test/ \
@@ -60,6 +70,7 @@ country.dat: country.pl
        mv country.dat.tmp country.dat
 
 deb: $(DEB)
+$(ASSISTANT_DEB): $(DEB)
 $(DEB): $(BUILDDIR)
        cd $(BUILDDIR); dpkg-buildpackage -b -us -uc
        lintian $(DEB)
@@ -98,7 +109,7 @@ VARLIBDIR=$(DESTDIR)/var/lib/proxmox-installer
 HTMLDIR=$(VARLIBDIR)/html/common
 
 .PHONY: install
-install: $(INSTALLER_SOURCES) $(CARGO_COMPILEDIR)/proxmox-tui-installer
+install: $(INSTALLER_SOURCES) $(COMPILED_BINS)
        $(MAKE) -C banner install
        $(MAKE) -C Proxmox install
        install -D -m 644 interfaces $(DESTDIR)/etc/network/interfaces
@@ -117,15 +128,19 @@ install: $(INSTALLER_SOURCES) $(CARGO_COMPILEDIR)/proxmox-tui-installer
 $(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-tui-installer --bin proxmox-tui-installer \
+               --package proxmox-auto-installer --bin proxmox-auto-installer \
+               --package proxmox-fetch-answer --bin proxmox-fetch-answer \
+               --package proxmox-auto-install-assistant --bin proxmox-auto-install-assistant \
+               --package proxmox-chroot --bin proxmox-chroot $(CARGO_BUILD_ARGS)
 
 %-banner.png: %-banner.svg
        rsvg-convert -o $@ $<
 
 .PHONY: upload
 upload: UPLOAD_DIST ?= $(DEB_DISTRIBUTION)
-upload: $(DEB)
-       tar cf - $(DEB) | ssh -X repoman@repo.proxmox.com -- upload --product pve,pmg,pbs --dist $(UPLOAD_DIST)
+upload: $(DEB) $(ASSISTANT_DEB)
+       tar cf - $(DEB) $(ASSISTANT_DEB) | ssh -X repoman@repo.proxmox.com -- upload --product pve,pmg,pbs --dist $(UPLOAD_DIST)
 
 %.img:
        truncate -s 2G $@
index 2161ebf1dd05ccc91067937929df3fa000db6021..c0f89559edcf9695bbea208821066d8f99c7e5e4 100644 (file)
@@ -15,7 +15,7 @@ use Proxmox::Install::StorageConfig;
 
 use Proxmox::Sys::Block qw(get_cached_disks wipe_disk partition_bootable_disk);
 use Proxmox::Sys::Command qw(run_command syscmd);
-use Proxmox::Sys::File qw(file_read_all file_read_firstline file_write_all);
+use Proxmox::Sys::File qw(file_read_firstline file_write_all);
 use Proxmox::UI;
 
 # TODO: move somewhere better?
@@ -255,7 +255,7 @@ sub get_zfs_raid_setup {
            $cmd .= " @$hd[1]";
        }
     } elsif ($filesys eq 'zfs (RAID1)') {
-       die "zfs (RAID1) needs at least 2 device\n" if $diskcount < 2;
+       die "zfs (RAID1) needs at least 2 devices\n" if $diskcount < 2;
        $cmd .= ' mirror ';
        my $hd = @$devlist[0];
        my $expected_size = @$hd[2]; # all disks need approximately same size
@@ -265,7 +265,7 @@ sub get_zfs_raid_setup {
            $cmd .= " @$hd[1]";
        }
     } elsif ($filesys eq 'zfs (RAID10)') {
-       die "zfs (RAID10) needs at least 4 device\n" if $diskcount < 4;
+       die "zfs (RAID10) needs at least 4 devices\n" if $diskcount < 4;
        die "zfs (RAID10) needs an even number of devices\n" if $diskcount & 1;
 
        for (my $i = 0; $i < $diskcount; $i+=2) {
@@ -329,10 +329,10 @@ sub get_btrfs_raid_setup {
        if ($filesys eq 'btrfs (RAID0)') {
            $mode = 'raid0';
        } elsif ($filesys eq 'btrfs (RAID1)') {
-           die "btrfs (RAID1) needs at least 2 device\n" if $diskcount < 2;
+           die "btrfs (RAID1) needs at least 2 devices\n" if $diskcount < 2;
            $mode = 'raid1';
        } elsif ($filesys eq 'btrfs (RAID10)') {
-           die "btrfs (RAID10) needs at least 4 device\n" if $diskcount < 4;
+           die "btrfs (RAID10) needs at least 4 devices\n" if $diskcount < 4;
            $mode = 'raid10';
        } else {
            die "unknown btrfs mode '$filesys'\n";
@@ -374,8 +374,6 @@ sub ask_existing_vg_rename_or_abort {
     my $duplicate_vgs = get_pv_list_from_vgname($vgname);
     return if !$duplicate_vgs;
 
-    my $message = "Detected existing '$vgname' Volume Group(s)! Do you want to:\n";
-
     for my $vg_uuid (keys %$duplicate_vgs) {
        my $vg = $duplicate_vgs->{$vg_uuid};
 
@@ -384,12 +382,20 @@ sub ask_existing_vg_rename_or_abort {
        # we have a disk with both a "$vgname" and "$vgname-old"...
        my $short_uid = sprintf "%08X", rand(0xffffffff);
        $vg->{new_vgname} = "$vgname-OLD-$short_uid";
-
-       $message .= "rename VG backed by PV '$vg->{pvs}' to '$vg->{new_vgname}'\n";
     }
-    $message .= "or cancel the installation?";
 
-    my $response_ok = Proxmox::UI::prompt($message);
+    my $response_ok = Proxmox::Install::Config::get_lvm_auto_rename();
+    if (!$response_ok) {
+       my $message = "Detected existing '$vgname' Volume Group(s)! Do you want to:\n";
+
+       for my $vg_uuid (keys %$duplicate_vgs) {
+           my $vg = $duplicate_vgs->{$vg_uuid};
+           $message .= "rename VG backed by PV '$vg->{pvs}' to '$vg->{new_vgname}'\n";
+       }
+       $message .= "or cancel the installation?";
+
+       $response_ok = Proxmox::UI::prompt($message);
+    }
 
     if ($response_ok) {
        for my $vg_uuid (keys %$duplicate_vgs) {
@@ -570,20 +576,12 @@ my sub chroot_chmod {
 }
 
 sub prepare_proxmox_boot_esp {
-    my ($espdev, $targetdir) = @_;
+    my ($espdev, $targetdir, $secureboot) = @_;
 
     my $mode = '';
 
-    # detect secure boot being enabled and switch to grub-on-ESP if it is
-    if (-d "/sys/firmware/efi") {
-       my $content = eval { file_read_all("/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c") };
-       if ($@) {
-           warn "Failed to read secure boot state: $@\n";
-       } else {
-           my @secureboot = unpack("CCCCC", $content);
-           $mode = 'grub' if $secureboot[4] == 1;
-       }
-    }
+    # if secure boot is enabled switch to grub-on-ESP
+    $mode = 'grub' if $secureboot;
 
     syscmd("chroot $targetdir proxmox-boot-tool init $espdev $mode") == 0 ||
        die "unable to init ESP and install proxmox-boot loader on '$espdev'\n";
@@ -826,8 +824,8 @@ sub extract_data {
                die "unable to set zfs properties\n";
        }
 
-       update_progress(0.04, 0, $maxper, "create swap space");
        if ($swapfile) {
+           update_progress(0.04, 0, $maxper, "create swap space");
            syscmd("mkswap -f $swapfile") == 0 ||
                die "unable to create swap space\n";
        }
@@ -850,11 +848,12 @@ sub extract_data {
            create_filesystem($rootdev, 'root', $filesys, 0.05, $maxper, 0, 1);
        }
 
-       update_progress(1, 0.05, $maxper, "mounting target $rootdev");
 
        if ($use_zfs) {
            # do nothing
        } else {
+           update_progress(1, 0.05, $maxper, "mounting target $rootdev");
+
            my $mount_opts = 'noatime';
            $mount_opts .= ',nobarrier'
                if $use_btrfs || $filesys =~ /^ext\d$/;
@@ -1100,9 +1099,11 @@ _EOD
            # upon upgrade - and conflict with each other - install the fitting one only
            next if ($deb =~ /grub-pc_/ && $run_env->{boot_type} ne 'bios');
            next if ($deb =~ /grub-efi-amd64_/ && $run_env->{boot_type} ne 'efi');
+           next if ($deb =~ /^proxmox-grub/ && $run_env->{boot_type} ne 'efi');
+           next if ($deb =~ /^proxmox-secure-boot-support_/ && !$run_env->{secure_boot});
 
            update_progress($count/$pkg_count, 0.5, 0.75, "extracting $deb");
-           print STDERR "extracting: $deb\n";
+
            syscmd("chroot $targetdir dpkg $dpkg_opts --force-depends --no-triggers --unpack /tmp/pkg/$deb") == 0
                || die "installation of package $deb failed\n";
            update_progress((++$count)/$pkg_count, 0.5, 0.75);
@@ -1231,7 +1232,7 @@ _EOD
                foreach my $di (@$bootdevinfo) {
                    my $dev = $di->{devname};
                    if ($use_zfs) {
-                       prepare_proxmox_boot_esp($di->{esp}, $targetdir);
+                       prepare_proxmox_boot_esp($di->{esp}, $targetdir, $run_env->{secure_boot});
                    } else {
                        if (!$native_4k_disk_bootable) {
                            eval {
@@ -1272,6 +1273,13 @@ _EOD
        my $octets = encode("utf-8", Proxmox::Install::Config::get_password());
        run_command("chroot $targetdir /usr/sbin/chpasswd", undef, "root:$octets\n");
 
+       # set root ssh keys
+       my $ssh_keys = Proxmox::Install::Config::get_root_ssh_keys();
+       if (scalar(@$ssh_keys) > 0) {
+           mkdir "$targetdir/root/.ssh";
+           file_write_all("$targetdir/root/.ssh/authorized_keys", join("\n", @$ssh_keys));
+       }
+
        my $mailto = Proxmox::Install::Config::get_mailto();
        if ($iso_env->{product} eq 'pmg') {
            # save admin email
index b1acebc57a5585a2462d62cb014af913621c9bf4..ecd8a74182d73dea3804fefa9aee72ee2210f16d 100644 (file)
@@ -82,6 +82,7 @@ my sub init_cfg {
        # TODO: single disk selection config
        target_hd => undef,
        disk_selection => {},
+       lvm_auto_rename => 0,
 
        # locale
        country => $country,
@@ -91,6 +92,7 @@ my sub init_cfg {
        # root credentials & details
        password => undef,
        mailto => 'mail@example.invalid',
+       root_ssh_keys => [],
 
        # network related
        mngmt_nic => undef,
@@ -200,6 +202,9 @@ sub get_password { return get('password'); }
 sub set_mailto { set_key('mailto', $_[0]); }
 sub get_mailto { return get('mailto'); }
 
+sub set_root_ssh_keys { set_key('root_ssh_keys', $_[0]); }
+sub get_root_ssh_keys { return get('root_ssh_keys'); }
+
 sub set_mngmt_nic { set_key('mngmt_nic', $_[0]); }
 sub get_mngmt_nic { return get('mngmt_nic'); }
 
@@ -239,5 +244,7 @@ sub get_dns { return get('dns'); }
 sub set_target_cmdline { set_key('target_cmdline', $_[0]); }
 sub get_target_cmdline { return get('target_cmdline'); }
 
+sub set_lvm_auto_rename { set_key('lvm_auto_rename', $_[0]); }
+sub get_lvm_auto_rename { return get('lvm_auto_rename'); }
 
 1;
index 25b6bb3db25a648328ac69f321e84c93ca895fd8..7eaf96a08af9475d54acdb4632d4a26c27dcc792 100644 (file)
@@ -8,7 +8,7 @@ use JSON qw(from_json to_json);
 
 use Proxmox::Log;
 use Proxmox::Sys::Command qw(run_command CMD_FINISHED);
-use Proxmox::Sys::File qw(file_read_firstline);
+use Proxmox::Sys::File qw(file_read_all file_read_firstline);
 use Proxmox::Sys::Block;
 use Proxmox::Sys::Net;
 
@@ -285,6 +285,16 @@ sub query_installation_environment : prototype() {
     $output->{hvm_supported} = query_cpu_hvm_support();
     $output->{boot_type} = -d '/sys/firmware/efi' ? 'efi' : 'bios';
 
+    if ($output->{boot_type} eq 'efi') {
+       my $content = eval { file_read_all("/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c") };
+       if ($@) {
+           log_warn("Failed to read secure boot state: $@\n");
+       } else {
+           my @secureboot = unpack("CCCCC", $content);
+           $output->{secure_boot} = $secureboot[4] == 1;
+       }
+    }
+
     my $err;
     my $country;
     if ($routes->{gateway4}) {
@@ -302,7 +312,7 @@ sub query_installation_environment : prototype() {
     if (defined($country)) {
        $output->{country} = $country;
     } else {
-       warn ($err // "unable to detect country\n");
+       warn ($err || "unable to detect country\n");
     }
 
     return $output;
index d49da8040ed9c8b00c2186514171616c8aa807aa..9561d9be4ea9760c3253eb07962ec114852eb0e7 100644 (file)
@@ -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 \
index 759349573526e5e64a057c0e2ae0fd8f236e550a..e337474a74a6799909c0811e25ae96f9497a754b 100644 (file)
@@ -10,6 +10,7 @@ use List::Util qw(first);
 use Proxmox::Install::ISOEnv;
 use Proxmox::Sys::Command qw(syscmd);
 use Proxmox::Sys::File qw(file_read_firstline);
+use Proxmox::Sys::Udev;
 use Proxmox::UI;
 
 use base qw(Exporter);
@@ -73,7 +74,8 @@ my sub hd_list {
        next if $bd =~ m|^/sys/block/fd\d+$|;
        next if $bd =~ m|^/sys/block/sr\d+$|;
 
-       my $info = `udevadm info --path $bd --query all`;
+       # TODO: switch to get_udev_properties and switch from regex to checking the parsed properties
+       my $info = Proxmox::Sys::Udev::query_udevadm_info($bd);
        next if !$info;
        next if $info !~ m/^E: DEVTYPE=disk$/m;
        next if $info =~ m/^E: ID_CDROM/m;
@@ -184,6 +186,16 @@ sub udevadm_trigger_block {
     syscmd("udevadm settle --timeout 10");
 };
 
+sub udevadm_disk_details {
+    my $disks = get_cached_disks();
+    my $result = {};
+    for my $disk_info ($disks->@*) {
+       my ($dev_index, $sys_path) = ($disk_info->[0], $disk_info->[5]);
+       $result->{$dev_index} = Proxmox::Sys::Udev::get_udev_properties($sys_path);
+    }
+    return $result;
+}
+
 sub wipe_disk {
     my ($disk) = @_;
 
index c2f3e9c049b609ede7c26fe4cb066279ec283de2..81cb15f0042b195461324fffeca53d732133629e 100644 (file)
@@ -3,6 +3,8 @@ package Proxmox::Sys::Net;
 use strict;
 use warnings;
 
+use Proxmox::Sys::Udev;
+
 use base qw(Exporter);
 our @EXPORT_OK = qw(parse_ip_address parse_ip_mask parse_fqdn);
 
@@ -189,6 +191,17 @@ sub get_ip_config {
     }
 }
 
+sub udevadm_netdev_details {
+    my $ip_config = get_ip_config();
+
+    my $result = {};
+    for my $dev (values $ip_config->{ifaces}->%*) {
+       my $name = $dev->{name};
+       $result->{$name} = Proxmox::Sys::Udev::get_udev_properties("/sys/class/net/$name");
+    }
+    return $result;
+}
+
 # Tries to detect the FQDN hostname for this system via DHCP, if available.
 #
 # DHCP server can set option 12 to inform the client about it's hostname [0]. dhclient dumps all
@@ -228,7 +241,7 @@ sub parse_fqdn : prototype($) {
        if $text =~ /^[0-9]+(?:\.|$)/;
 
     die "FQDN must only consist of alphanumeric characters and dashes\n"
-       if $text !~ m/^${Proxmox::Sys::Net::FQDN_RE}$/;
+       if $text !~ m/^${FQDN_RE}$/;
 
     if ($text =~ m/^([^\.]+)\.(\S+)$/) {
        return ($1, $2);
diff --git a/Proxmox/Sys/Udev.pm b/Proxmox/Sys/Udev.pm
new file mode 100644 (file)
index 0000000..89ada32
--- /dev/null
@@ -0,0 +1,35 @@
+package Proxmox::Sys::Udev;
+
+use strict;
+use warnings;
+
+my $UDEV_REGEX = qr/^E: ([^=]+)=(.*)$/;
+
+sub query_udevadm_info {
+    my ($sys_path) = @_;
+
+    my $info = `udevadm info --path $sys_path --query all`;
+    warn "no details found for device '${sys_path}'\n" if !$info;
+    return $info;
+}
+
+# return hash of E: properties returned by udevadm
+sub parse_udevadm_info {
+    my ($udev_raw) = @_;
+
+    my $details = {};
+    for my $line (split('\n', $udev_raw)) {
+       if ($line =~ $UDEV_REGEX) {
+           $details->{$1} = $2;
+       }
+    }
+    return $details;
+}
+
+sub get_udev_properties {
+    my ($sys_path) = @_;
+    my $info = query_udevadm_info($sys_path) or return;
+    return parse_udevadm_info($info);
+}
+
+1;
index db44d10dc8afe9f08767c1213ed7ac144e16c825..ca33a75498ea308cda6667b684dd83aaf79d039a 100644 (file)
@@ -1,3 +1,103 @@
+proxmox-installer (8.2.5) bookworm; urgency=medium
+
+  * fetch answer: really try lower-case variant of label too
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 24 Apr 2024 13:11:42 +0200
+
+proxmox-installer (8.2.4) bookworm; urgency=medium
+
+  * auto-installer: shorten partition label to "PROXMOX-AIS" so that it
+    fits in the 11 characters limit for labels when using the FAT file-
+    system
+
+  * assistant: fix newline before ':' in help usage text
+
+  * assistant: updated remaining error messages to reworked CLI
+
+  * answer: perform basic input validation for keyboard
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 24 Apr 2024 10:55:05 +0200
+
+proxmox-installer (8.2.3) bookworm; urgency=medium
+
+  * also skip installing the proxmox-grub meta package if not booted in EFI
+    mode
+
+ -- Proxmox Support Team <support@proxmox.com>  Wed, 24 Apr 2024 00:16:36 +0200
+
+proxmox-installer (8.2.2) bookworm; urgency=medium
+
+  * auto-installer:
+    - rename `global.password` option to `global.root_password`
+    - move `system.root_ssh_keys` option to `global` section
+    - support UTC as timezone
+    - drop fetch-from auto mode to avoid complexity and querying (potentially
+      untrusted) networks by default
+    - rework the default filename suffix that gets added to the default
+      output filename of the prepared iso
+    - move ssh keys setup to low-level installer, avoiding command injection
+    - rework command line options:
+      - rename 'install-mode' option to 'fetch-from'
+      - rename 'included' fetch-from variant to 'iso'
+      - rename 'source' to 'input' and 'target' to 'output'
+      - drop all short options for now
+      - update help text
+    - report every progress update and include text
+
+  * low level installer:
+    - drop printing about extracting deb packages to stderr
+    - avoid undef warning in progress log when using ZFS
+    - only log about creating SWAP if there's any configured
+
+  * fetch answer tool: allow one to override the fetch-from mode through CLI
+    arguments, which can be useful for debugging or to try the automatic
+    installer without preparing the ISO first
+
+  * installer init: start debug shell if auto-installation is enabled without
+    config
+
+  * assistant: error out on if there's a static network config set together
+    with from-dhcp as source
+
+  * tui: update screen during installation only when necessary
+
+  * less strict regex for matching udev env variables
+
+  * skip new proxmox-secure-boot-support package if secureboot is not enabled
+
+ -- Proxmox Support Team <support@proxmox.com>  Tue, 23 Apr 2024 21:30:02 +0200
+
+proxmox-installer (8.2.1) bookworm; urgency=medium
+
+  * low-level install: add option to automatically rename LVM volumes
+
+  * auto-installer: always set new `lvm_auto_rename` option to avoid prompt
+    that breaks installation flow
+
+  * assistant: prepare iso: avoid an useless intermediate copy of the answer
+    file
+
+  * d/control: recommend xorriso as dependency for the assistant package
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 22 Apr 2024 17:50:41 +0200
+
+proxmox-installer (8.2.0) bookworm; urgency=medium
+
+  * unconfigured: move terminal size setting before starting debug shell
+
+  * html: pbs: fix missing <br> in template after feature list
+
+  * run env: use default error message if country detection failed with empty
+    string
+
+  * add tech-preview for unattended automatic installation through a premade,
+    but flexible answer file
+
+  * add new proxmox-auto-install-assistant to provide a tool to help with
+    preparing an ISO and answer files for automated installation
+
+ -- Proxmox Support Team <support@proxmox.com>  Thu, 18 Apr 2024 22:37:04 +0200
+
 proxmox-installer (8.1.12) bookworm; urgency=medium
 
   * installation: handle if the clamav-clamonacc.service is already disabled
index 3ca208b296beca5f5bc2d4562b7d98501c5ce6cb..eb4d3be1dc5adc837d706c301ac3e16184ce246d 100644 (file)
@@ -4,14 +4,27 @@ Priority: optional
 Maintainer: Proxmox Support Team <support@proxmox.com>
 Build-Depends: cargo:native,
                debhelper-compat (= 12),
+               iproute2,
                iso-codes,
                libgtk3-perl,
                libpve-common-perl,
                librsvg2-bin,
+               librust-anyhow-1-dev,
+               librust-clap-4+derive-dev,
                librust-cursive+termion-backend-dev (>= 0.20.0),
+               librust-glob-0.3-dev,
+               librust-hex-0.4-dev,
+               librust-native-tls-dev,
+               librust-nix-0.26+default-dev,
                librust-regex-1+default-dev (>= 1.7~~),
+               librust-rustls-0.20+dangerous-configuration-dev,
+               librust-rustls-native-certs-dev,
                librust-serde-1+default-dev,
                librust-serde-json-1+default-dev,
+               librust-serde-plain-1+default-dev,
+               librust-sha2-0.10-dev,
+               librust-toml-0.7-dev,
+               librust-ureq-2.6-dev,
                libtest-mockmodule-perl,
                perl,
                rustc:native,
@@ -23,6 +36,7 @@ Package: proxmox-installer
 Architecture: any
 Depends: chrony,
          geoip-bin,
+         iproute2,
          libgtk3-perl,
          libgtk3-webkit2-perl,
          libjson-perl,
@@ -38,3 +52,12 @@ Description: Installer for Proxmox Projects
   * Proxmox VE.
   * Proxmox Mail Gateway.
   * Proxmox Backup Server
+
+Package: proxmox-auto-install-assistant
+Architecture: any
+Depends: ${misc:Depends}, ${shlibs:Depends},
+Recommends: xorriso,
+Description: Assistant to help with automated installations
+ Provides a helper that can assist with creating an answer file for a automated
+ installation of a Proxmox project, and preparing a official ISO image to use
+ this answer file.
index b0e29528a50225f1a2a70cc7837f7197acc06c86..bb91da763ba4cef7ad86efe698333ffe992d0aaf 100644 (file)
@@ -1,4 +1,18 @@
+.Xdefaults
+.spice-vdagent.sh
+.xinitrc
 banner/pbs-banner.png /var/lib/proxmox-installer
 banner/pmg-banner.png /var/lib/proxmox-installer
 banner/pve-banner.png /var/lib/proxmox-installer
+etc
 html /var/lib/proxmox-installer/
+sbin
+usr/share
+usr/bin/checktime
+usr/bin/proxinstall
+usr/bin/proxmox-auto-installer
+usr/bin/proxmox-chroot
+usr/bin/proxmox-fetch-answer
+usr/bin/proxmox-low-level-installer
+usr/bin/proxmox-tui-installer
+var
diff --git a/debian/pbs-installer.install b/debian/pbs-installer.install
deleted file mode 100644 (file)
index 0d6f779..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-html-pbs/extract1-license.htm /var/lib/pve-installer/html
-html-pbs/extract2-rulesystem.htm /var/lib/pve-installer/html
-html-pbs/extract3-spam.htm /var/lib/pve-installer/html
-html-pbs/extract4-virus.htm /var/lib/pve-installer/html
-html-pbs/page1.htm /var/lib/pve-installer/html
-html-pbs/passwd.htm /var/lib/pve-installer/html
-pbs-banner.png /var/lib/pve-installer
diff --git a/debian/pmg-installer.install b/debian/pmg-installer.install
deleted file mode 100644 (file)
index 84e46de..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-html-pmg/extract1-license.htm /var/lib/pve-installer/html
-html-pmg/extract2-rulesystem.htm /var/lib/pve-installer/html
-html-pmg/extract3-spam.htm /var/lib/pve-installer/html
-html-pmg/extract4-virus.htm /var/lib/pve-installer/html
-html-pmg/page1.htm /var/lib/pve-installer/html
-html-pmg/passwd.htm /var/lib/pve-installer/html
-pmg-banner.png /var/lib/pve-installer
diff --git a/debian/proxmox-auto-install-assistant.install b/debian/proxmox-auto-install-assistant.install
new file mode 100644 (file)
index 0000000..74aa2a9
--- /dev/null
@@ -0,0 +1 @@
+usr/bin/proxmox-auto-install-assistant
diff --git a/debian/pve-installer.install b/debian/pve-installer.install
deleted file mode 100644 (file)
index 9c6a27b..0000000
+++ /dev/null
@@ -1,7 +0,0 @@
-html-pve/extract1-license.htm /var/lib/pve-installer/html
-html-pve/extract2-rulesystem.htm /var/lib/pve-installer/html
-html-pve/extract3-spam.htm /var/lib/pve-installer/html
-html-pve/extract4-virus.htm /var/lib/pve-installer/html
-html-pve/page1.htm /var/lib/pve-installer/html
-html-pve/passwd.htm /var/lib/pve-installer/html
-pve-banner.png /var/lib/pve-installer
diff --git a/debian/source/format b/debian/source/format
new file mode 100644 (file)
index 0000000..89ae9db
--- /dev/null
@@ -0,0 +1 @@
+3.0 (native)
diff --git a/proxmox-auto-install-assistant/Cargo.toml b/proxmox-auto-install-assistant/Cargo.toml
new file mode 100644 (file)
index 0000000..eaca7f8
--- /dev/null
@@ -0,0 +1,22 @@
+[package]
+name = "proxmox-auto-install-assistant"
+version = "0.1.0"
+edition = "2021"
+authors = [
+    "Aaron Lauterer <a.lauterer@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+license = "AGPL-3"
+exclude = [ "build", "debian" ]
+homepage = "https://www.proxmox.com"
+
+[dependencies]
+anyhow = "1.0"
+clap = { version = "4.0", features = ["derive"] }
+glob = "0.3"
+log = "0.4.20"
+proxmox-auto-installer = { path = "../proxmox-auto-installer" }
+regex = "1.7"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+toml = "0.7"
diff --git a/proxmox-auto-install-assistant/src/main.rs b/proxmox-auto-install-assistant/src/main.rs
new file mode 100644 (file)
index 0000000..0debd29
--- /dev/null
@@ -0,0 +1,555 @@
+use anyhow::{bail, Result};
+use clap::{Args, Parser, Subcommand, ValueEnum};
+use glob::Pattern;
+use regex::Regex;
+use serde::Serialize;
+use std::{
+    collections::BTreeMap,
+    fs,
+    io::{self, Read},
+    path::{Path, PathBuf},
+    process::{Command, Stdio},
+};
+
+use proxmox_auto_installer::{
+    answer::Answer,
+    answer::FilterMatch,
+    sysinfo::SysInfo,
+    utils::{
+        get_matched_udev_indexes, get_nic_list, get_single_udev_index, AutoInstSettings,
+        FetchAnswerFrom, HttpOptions,
+    },
+};
+
+static PROXMOX_ISO_FLAG: &str = "/auto-installer-capable";
+
+/// This tool can be used to prepare a Proxmox installation ISO for automated installations.
+/// Additional uses are to validate the format of an answer file or to test match filters and
+/// print information on the properties to match against for the current hardware.
+#[derive(Parser, Debug)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+    PrepareIso(CommandPrepareISO),
+    ValidateAnswer(CommandValidateAnswer),
+    DeviceMatch(CommandDeviceMatch),
+    DeviceInfo(CommandDeviceInfo),
+    SystemInfo(CommandSystemInfo),
+}
+
+/// Show device information that can be used for filters
+#[derive(Args, Debug)]
+struct CommandDeviceInfo {
+    /// For which device type information should be shown
+    #[arg(name="type", short, long, value_enum, default_value_t=AllDeviceTypes::All)]
+    device: AllDeviceTypes,
+}
+
+/// Test which devices the given filter matches against
+///
+/// Filters support the following syntax:
+/// ?          Match a single character
+/// *          Match any number of characters
+/// [a], [0-9] Specifc character or range of characters
+/// [!a]       Negate a specific character of range
+///
+/// To avoid globbing characters being interpreted by the shell, use single quotes.
+/// Multiple filters can be defined.
+///
+/// Examples:
+/// Match disks against the serial number and device name, both must match:
+///
+/// proxmox-auto-install-assistant match --filter-match all disk 'ID_SERIAL_SHORT=*2222*' 'DEVNAME=*nvme*'
+#[derive(Args, Debug)]
+#[command(verbatim_doc_comment)]
+struct CommandDeviceMatch {
+    /// Device type to match the filter against
+    r#type: Devicetype,
+
+    /// Filter in the format KEY=VALUE where the key is the UDEV key and VALUE the filter string.
+    /// Multiple filters are possible, separated by a space.
+    filter: Vec<String>,
+
+    /// Defines if any filter or all filters must match.
+    #[arg(long, value_enum, default_value_t=FilterMatch::Any)]
+    filter_match: FilterMatch,
+}
+
+/// Validate if an answer file is formatted correctly.
+#[derive(Args, Debug)]
+struct CommandValidateAnswer {
+    /// Path to the answer file
+    path: PathBuf,
+    #[arg(short, long, default_value_t = false)]
+    debug: bool,
+}
+
+/// Prepare an ISO for automated installation.
+///
+/// The behavior of how to fetch an answer file must be set with the '--fetch-from' parameter. The
+/// answer file can be:{n}
+/// * integrated into the ISO itself ('iso'){n}
+/// * present on a partition / file-system with the label 'PROXMOX-AIS' (Proxmox
+/// Automated Installer Source) ('partition'){n}
+/// * requested via an HTTP Post request ('http').
+///
+/// The URL for the HTTP mode can be defined for the ISO with the '--url' argument. If not present,
+/// it will try to get a URL from a DHCP option (250, TXT) or by querying a DNS TXT record for the
+/// domain 'proxmox-auto-installer.{search domain}'.
+///
+/// The TLS certificate fingerprint can either be defined via the '--cert-fingerprint' argument or
+/// alternatively via the custom DHCP option (251, TXT) or in a DNS TXT record located at
+/// 'proxmox-auto-installer-cert-fingerprint.{search domain}'.
+///
+/// The latter options to provide the TLS fingerprint will only be used if the same method was used
+/// to retrieve the URL. For example, the DNS TXT record for the fingerprint will only be used, if
+/// no one was configured with the '--cert-fingerprint' parameter and if the URL was retrieved via
+/// the DNS TXT record.
+#[derive(Args, Debug)]
+struct CommandPrepareISO {
+    /// Path to the source ISO to prepare
+    input: PathBuf,
+
+    /// Path to store the final ISO to, defaults to an auto-generated file name depending on mode
+    /// and the same directory as the source file is located in.
+    #[arg(long)]
+    output: Option<PathBuf>,
+
+    /// Where the automatic installer should fetch the answer file from.
+    #[arg(long, value_enum)]
+    fetch_from: FetchAnswerFrom,
+
+    /// Include the specified answer file in the ISO. Requires the '--fetch-from'  parameter
+    /// to be set to 'iso'.
+    #[arg(long)]
+    answer_file: Option<PathBuf>,
+
+    /// Specify URL for fetching the answer file via HTTP
+    #[arg(long)]
+    url: Option<String>,
+
+    /// Pin the ISO to the specified SHA256 TLS certificate fingerprint.
+    #[arg(long)]
+    cert_fingerprint: Option<String>,
+
+    /// Staging directory to use for preparing the new ISO file. Defaults to the directory of the
+    /// input ISO file.
+    #[arg(long)]
+    tmp: Option<String>,
+}
+
+/// Show the system information that can be used to identify a host.
+///
+/// The shown information is sent as POST HTTP request when fetching the answer file for the
+/// automatic installation through HTTP, You can, for example, use this to return a dynamically
+/// assembled answer file.
+#[derive(Args, Debug)]
+struct CommandSystemInfo {}
+
+#[derive(Args, Debug)]
+struct GlobalOpts {
+    /// Output format
+    #[arg(long, short, value_enum)]
+    format: OutputFormat,
+}
+
+#[derive(Clone, Debug, ValueEnum, PartialEq)]
+enum AllDeviceTypes {
+    All,
+    Network,
+    Disk,
+}
+
+#[derive(Clone, Debug, ValueEnum)]
+enum Devicetype {
+    Network,
+    Disk,
+}
+
+#[derive(Clone, Debug, ValueEnum)]
+enum OutputFormat {
+    Pretty,
+    Json,
+}
+
+#[derive(Serialize)]
+struct Devs {
+    disks: Option<BTreeMap<String, BTreeMap<String, String>>>,
+    nics: Option<BTreeMap<String, BTreeMap<String, String>>>,
+}
+
+fn main() {
+    let args = Cli::parse();
+    let res = match &args.command {
+        Commands::PrepareIso(args) => prepare_iso(args),
+        Commands::ValidateAnswer(args) => validate_answer(args),
+        Commands::DeviceInfo(args) => info(args),
+        Commands::DeviceMatch(args) => match_filter(args),
+        Commands::SystemInfo(args) => show_system_info(args),
+    };
+    if let Err(err) = res {
+        eprintln!("{err}");
+        std::process::exit(1);
+    }
+}
+
+fn info(args: &CommandDeviceInfo) -> Result<()> {
+    let mut devs = Devs {
+        disks: None,
+        nics: None,
+    };
+
+    if args.device == AllDeviceTypes::Network || args.device == AllDeviceTypes::All {
+        match get_nics() {
+            Ok(res) => devs.nics = Some(res),
+            Err(err) => bail!("Error getting NIC data: {err}"),
+        }
+    }
+    if args.device == AllDeviceTypes::Disk || args.device == AllDeviceTypes::All {
+        match get_disks() {
+            Ok(res) => devs.disks = Some(res),
+            Err(err) => bail!("Error getting disk data: {err}"),
+        }
+    }
+    println!("{}", serde_json::to_string_pretty(&devs).unwrap());
+    Ok(())
+}
+
+fn match_filter(args: &CommandDeviceMatch) -> Result<()> {
+    let devs: BTreeMap<String, BTreeMap<String, String>> = match args.r#type {
+        Devicetype::Disk => get_disks().unwrap(),
+        Devicetype::Network => get_nics().unwrap(),
+    };
+    // parse filters
+
+    let mut filters: BTreeMap<String, String> = BTreeMap::new();
+
+    for f in &args.filter {
+        match f.split_once('=') {
+            Some((key, value)) => {
+                if key.is_empty() || value.is_empty() {
+                    bail!("Filter key or value is empty in filter: '{f}'");
+                }
+                filters.insert(String::from(key), String::from(value));
+            }
+            None => {
+                bail!("Could not find separator '=' in filter: '{f}'");
+            }
+        }
+    }
+
+    // align return values
+    let result = match args.r#type {
+        Devicetype::Disk => {
+            get_matched_udev_indexes(&filters, &devs, args.filter_match == FilterMatch::All)
+        }
+        Devicetype::Network => get_single_udev_index(&filters, &devs).map(|r| vec![r]),
+    };
+
+    match result {
+        Ok(result) => println!("{}", serde_json::to_string_pretty(&result).unwrap()),
+        Err(err) => bail!("Error matching filters: {err}"),
+    }
+    Ok(())
+}
+
+fn validate_answer(args: &CommandValidateAnswer) -> Result<()> {
+    let answer = parse_answer(&args.path)?;
+    if args.debug {
+        println!("Parsed data from answer file:\n{:#?}", answer);
+    }
+    Ok(())
+}
+
+fn show_system_info(_args: &CommandSystemInfo) -> Result<()> {
+    match SysInfo::as_json_pretty() {
+        Ok(res) => println!("{res}"),
+        Err(err) => eprintln!("Error fetching system info: {err}"),
+    }
+    Ok(())
+}
+
+fn prepare_iso(args: &CommandPrepareISO) -> Result<()> {
+    check_prepare_requirements(args)?;
+
+    if args.fetch_from == FetchAnswerFrom::Iso && args.answer_file.is_none() {
+        bail!("Missing path to the answer file required for the fetch-from 'iso' mode.");
+    }
+    if args.url.is_some() && args.fetch_from != FetchAnswerFrom::Http {
+        bail!(
+            "Setting a URL is incompatible with the fetch-from '{:?}' mode, only works with the 'http' mode",
+            args.fetch_from,
+        );
+    }
+    if args.cert_fingerprint.is_some() && args.fetch_from != FetchAnswerFrom::Http {
+        bail!(
+            "Setting a certificate fingerprint incompatible is fetch-from '{:?}' mode, only works for 'http' mode.",
+            args.fetch_from,
+        );
+    }
+    if args.answer_file.is_some() && args.fetch_from != FetchAnswerFrom::Iso {
+        bail!("You must set '--fetch-from' to 'iso' to place the answer file directly in the ISO.");
+    }
+
+    if let Some(file) = &args.answer_file {
+        println!("Checking provided answer file...");
+        parse_answer(file)?;
+    }
+
+    let iso_target = final_iso_location(args);
+    let iso_target_file_name = match iso_target.file_name() {
+        None => bail!("no base filename in target ISO path found"),
+        Some(source_file_name) => source_file_name.to_string_lossy(),
+    };
+
+    let mut tmp_base = PathBuf::new();
+    match args.tmp.as_ref() {
+        Some(tmp_dir) => tmp_base.push(tmp_dir),
+        None => tmp_base.push(iso_target.parent().unwrap()),
+    }
+
+    let mut tmp_iso = tmp_base.clone();
+    tmp_iso.push(format!("{iso_target_file_name}.tmp",));
+
+    println!("Copying source ISO to temporary location...");
+    fs::copy(&args.input, &tmp_iso)?;
+
+    println!("Preparing ISO...");
+    let config = AutoInstSettings {
+        mode: args.fetch_from.clone(),
+        http: HttpOptions {
+            url: args.url.clone(),
+            cert_fingerprint: args.cert_fingerprint.clone(),
+        },
+    };
+    let mut instmode_file_tmp = tmp_base.clone();
+    instmode_file_tmp.push("auto-installer-mode.toml");
+    fs::write(&instmode_file_tmp, toml::to_string_pretty(&config)?)?;
+
+    inject_file_to_iso(&tmp_iso, &instmode_file_tmp, "/auto-installer-mode.toml")?;
+
+    if let Some(answer_file) = &args.answer_file {
+        inject_file_to_iso(&tmp_iso, answer_file, "/answer.toml")?;
+    }
+
+    println!("Moving prepared ISO to target location...");
+    fs::rename(&tmp_iso, &iso_target)?;
+    println!("Final ISO is available at {iso_target:?}.");
+
+    Ok(())
+}
+
+fn final_iso_location(args: &CommandPrepareISO) -> PathBuf {
+    if let Some(specified) = args.output.clone() {
+        return specified;
+    }
+    let mut suffix: String = match args.fetch_from {
+        FetchAnswerFrom::Http => "auto-from-http",
+        FetchAnswerFrom::Iso => "auto-from-iso",
+        FetchAnswerFrom::Partition => "auto-from-partition",
+    }
+    .into();
+
+    if args.url.is_some() {
+        suffix.push_str("-url");
+    }
+    if args.cert_fingerprint.is_some() {
+        suffix.push_str("-fp");
+    }
+
+    let base = args.input.parent().unwrap();
+    let iso = args.input.file_stem().unwrap();
+
+    let mut target = base.to_path_buf();
+    target.push(format!("{}-{}.iso", iso.to_str().unwrap(), suffix));
+
+    target.to_path_buf()
+}
+
+fn inject_file_to_iso(iso: &PathBuf, file: &PathBuf, location: &str) -> Result<()> {
+    let result = Command::new("xorriso")
+        .arg("--boot_image")
+        .arg("any")
+        .arg("keep")
+        .arg("-dev")
+        .arg(iso)
+        .arg("-map")
+        .arg(file)
+        .arg(location)
+        .output()?;
+    if !result.status.success() {
+        bail!(
+            "Error injecting {file:?} into {iso:?}: {}",
+            String::from_utf8_lossy(&result.stderr)
+        );
+    }
+    Ok(())
+}
+
+fn get_disks() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
+    let unwantend_block_devs = vec![
+        "ram[0-9]*",
+        "loop[0-9]*",
+        "md[0-9]*",
+        "dm-*",
+        "fd[0-9]*",
+        "sr[0-9]*",
+    ];
+
+    // compile Regex here once and not inside the loop
+    let re_disk = Regex::new(r"(?m)^E: DEVTYPE=disk")?;
+    let re_cdrom = Regex::new(r"(?m)^E: ID_CDROM")?;
+    let re_iso9660 = Regex::new(r"(?m)^E: ID_FS_TYPE=iso9660")?;
+
+    let re_name = Regex::new(r"(?m)^N: (.*)$")?;
+    let re_props = Regex::new(r"(?m)^E: ([^=]+)=(.*)$")?;
+
+    let mut disks: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
+
+    'outer: for entry in fs::read_dir("/sys/block")? {
+        let entry = entry.unwrap();
+        let filename = entry.file_name().into_string().unwrap();
+
+        for p in &unwantend_block_devs {
+            if Pattern::new(p)?.matches(&filename) {
+                continue 'outer;
+            }
+        }
+
+        let output = match get_udev_properties(&entry.path()) {
+            Ok(output) => output,
+            Err(err) => {
+                eprint!("{err}");
+                continue 'outer;
+            }
+        };
+
+        if !re_disk.is_match(&output) {
+            continue 'outer;
+        };
+        if re_cdrom.is_match(&output) {
+            continue 'outer;
+        };
+        if re_iso9660.is_match(&output) {
+            continue 'outer;
+        };
+
+        let mut name = filename;
+        if let Some(cap) = re_name.captures(&output) {
+            if let Some(res) = cap.get(1) {
+                name = String::from(res.as_str());
+            }
+        }
+
+        let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
+
+        for line in output.lines() {
+            if let Some(caps) = re_props.captures(line) {
+                let key = String::from(caps.get(1).unwrap().as_str());
+                let value = String::from(caps.get(2).unwrap().as_str());
+                udev_props.insert(key, value);
+            }
+        }
+
+        disks.insert(name, udev_props);
+    }
+    Ok(disks)
+}
+
+fn get_nics() -> Result<BTreeMap<String, BTreeMap<String, String>>> {
+    let re_props = Regex::new(r"(?m)^E: (.*)=(.*)$")?;
+    let mut nics: BTreeMap<String, BTreeMap<String, String>> = BTreeMap::new();
+
+    let links = get_nic_list()?;
+    for link in links {
+        let path = format!("/sys/class/net/{link}");
+
+        let output = match get_udev_properties(&PathBuf::from(path)) {
+            Ok(output) => output,
+            Err(err) => {
+                eprint!("{err}");
+                continue;
+            }
+        };
+
+        let mut udev_props: BTreeMap<String, String> = BTreeMap::new();
+
+        for line in output.lines() {
+            if let Some(caps) = re_props.captures(line) {
+                let key = String::from(caps.get(1).unwrap().as_str());
+                let value = String::from(caps.get(2).unwrap().as_str());
+                udev_props.insert(key, value);
+            }
+        }
+
+        nics.insert(link, udev_props);
+    }
+    Ok(nics)
+}
+
+fn get_udev_properties(path: &PathBuf) -> Result<String> {
+    let udev_output = Command::new("udevadm")
+        .arg("info")
+        .arg("--path")
+        .arg(path)
+        .arg("--query")
+        .arg("all")
+        .output()?;
+    if !udev_output.status.success() {
+        bail!("could not run udevadm successfully for {path:?}");
+    }
+    Ok(String::from_utf8(udev_output.stdout)?)
+}
+
+fn parse_answer(path: &PathBuf) -> Result<Answer> {
+    let mut file = match fs::File::open(path) {
+        Ok(file) => file,
+        Err(err) => bail!("Opening answer file {path:?} failed: {err}"),
+    };
+    let mut contents = String::new();
+    if let Err(err) = file.read_to_string(&mut contents) {
+        bail!("Reading from file {path:?} failed: {err}");
+    }
+    match toml::from_str(&contents) {
+        Ok(answer) => {
+            println!("The file was parsed successfully, no syntax errors found!");
+            Ok(answer)
+        }
+        Err(err) => bail!("Error parsing answer file: {err}"),
+    }
+}
+
+fn check_prepare_requirements(args: &CommandPrepareISO) -> Result<()> {
+    match Path::try_exists(&args.input) {
+        Ok(true) => (),
+        Ok(false) => bail!("Source file does not exist."),
+        Err(_) => bail!("Source file does not exist."),
+    }
+
+    match Command::new("xorriso")
+        .arg("-dev")
+        .arg(&args.input)
+        .arg("-find")
+        .arg(PROXMOX_ISO_FLAG)
+        .stderr(Stdio::null())
+        .stdout(Stdio::null())
+        .status()
+    {
+        Ok(v) => {
+            if !v.success() {
+                bail!("The source ISO file is not able to be installed automatically. Please try a more current one.");
+            }
+        }
+        Err(err) if err.kind() == io::ErrorKind::NotFound => {
+            bail!("Could not find the 'xorriso' binary. Please install it.")
+        }
+        Err(err) => bail!("unexpected error when trying to execute 'xorriso' - {err}"),
+    };
+
+    Ok(())
+}
diff --git a/proxmox-auto-installer/Cargo.toml b/proxmox-auto-installer/Cargo.toml
new file mode 100644 (file)
index 0000000..4f54439
--- /dev/null
@@ -0,0 +1,23 @@
+[package]
+name = "proxmox-auto-installer"
+version = "0.1.0"
+edition = "2021"
+authors = [
+    "Aaron Lauterer <a.lauterer@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+license = "AGPL-3"
+exclude = [ "build", "debian" ]
+homepage = "https://www.proxmox.com"
+
+[dependencies]
+anyhow = "1.0"
+clap = { version = "4.0", features = ["derive"] }
+glob = "0.3"
+log = "0.4.20"
+proxmox-installer-common = { path = "../proxmox-installer-common" }
+regex = "1.7"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+serde_plain = "1.0"
+toml = "0.7"
diff --git a/proxmox-auto-installer/src/answer.rs b/proxmox-auto-installer/src/answer.rs
new file mode 100644 (file)
index 0000000..aab7198
--- /dev/null
@@ -0,0 +1,304 @@
+use clap::ValueEnum;
+use proxmox_installer_common::{
+    options::{BtrfsRaidLevel, FsType, ZfsChecksumOption, ZfsCompressOption, ZfsRaidLevel},
+    utils::{CidrAddress, Fqdn},
+};
+use serde::{Deserialize, Serialize};
+use std::{collections::BTreeMap, net::IpAddr};
+
+// BTreeMap is used to store filters as the order of the filters will be stable, compared to
+// storing them in a HashMap
+
+#[derive(Clone, Deserialize, Debug)]
+#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+pub struct Answer {
+    pub global: Global,
+    pub network: Network,
+    #[serde(rename = "disk-setup")]
+    pub disks: Disks,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub struct Global {
+    pub country: String,
+    pub fqdn: Fqdn,
+    pub keyboard: KeyboardLayout,
+    pub mailto: String,
+    pub timezone: String,
+    pub root_password: String,
+    #[serde(default)]
+    pub reboot_on_error: bool,
+    #[serde(default)]
+    pub root_ssh_keys: Vec<String>,
+}
+
+#[derive(Clone, Deserialize, Debug, Default, PartialEq)]
+#[serde(deny_unknown_fields)]
+enum NetworkConfigMode {
+    #[default]
+    #[serde(rename = "from-dhcp")]
+    FromDhcp,
+    #[serde(rename = "from-answer")]
+    FromAnswer,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+#[serde(deny_unknown_fields)]
+struct NetworkInAnswer {
+    #[serde(default)]
+    pub source: NetworkConfigMode,
+    pub cidr: Option<CidrAddress>,
+    pub dns: Option<IpAddr>,
+    pub gateway: Option<IpAddr>,
+    pub filter: Option<BTreeMap<String, String>>,
+}
+
+#[derive(Clone, Deserialize, Debug)]
+#[serde(try_from = "NetworkInAnswer", deny_unknown_fields)]
+pub struct Network {
+    pub network_settings: NetworkSettings,
+}
+
+impl TryFrom<NetworkInAnswer> for Network {
+    type Error = &'static str;
+
+    fn try_from(network: NetworkInAnswer) -> Result<Self, Self::Error> {
+        if network.source == NetworkConfigMode::FromAnswer {
+            if network.cidr.is_none() {
+                return Err("Field 'cidr' must be set.");
+            }
+            if network.dns.is_none() {
+                return Err("Field 'dns' must be set.");
+            }
+            if network.gateway.is_none() {
+                return Err("Field 'gateway' must be set.");
+            }
+            if network.filter.is_none() {
+                return Err("Field 'filter' must be set.");
+            }
+
+            Ok(Network {
+                network_settings: NetworkSettings::Manual(NetworkManual {
+                    cidr: network.cidr.unwrap(),
+                    dns: network.dns.unwrap(),
+                    gateway: network.gateway.unwrap(),
+                    filter: network.filter.unwrap(),
+                }),
+            })
+        } else {
+            if network.cidr.is_some() {
+                return Err("Field 'cidr' not supported for 'from-dhcp' config.");
+            }
+            if network.dns.is_some() {
+                return Err("Field 'dns' not supported for 'from-dhcp' config.");
+            }
+            if network.gateway.is_some() {
+                return Err("Field 'gateway' not supported for 'from-dhcp' config.");
+            }
+            if network.filter.is_some() {
+                return Err("Field 'filter' not supported for 'from-dhcp' config.");
+            }
+
+            Ok(Network {
+                network_settings: NetworkSettings::FromDhcp,
+            })
+        }
+    }
+}
+
+#[derive(Clone, Debug)]
+pub enum NetworkSettings {
+    FromDhcp,
+    Manual(NetworkManual),
+}
+
+#[derive(Clone, Debug)]
+pub struct NetworkManual {
+    pub cidr: CidrAddress,
+    pub dns: IpAddr,
+    pub gateway: IpAddr,
+    pub filter: BTreeMap<String, String>,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct DiskSetup {
+    pub filesystem: Filesystem,
+    #[serde(default)]
+    pub disk_list: Vec<String>,
+    pub filter: Option<BTreeMap<String, String>>,
+    pub filter_match: Option<FilterMatch>,
+    pub zfs: Option<ZfsOptions>,
+    pub lvm: Option<LvmOptions>,
+    pub btrfs: Option<BtrfsOptions>,
+}
+
+#[derive(Clone, Debug, Deserialize)]
+#[serde(try_from = "DiskSetup", deny_unknown_fields)]
+pub struct Disks {
+    pub fs_type: FsType,
+    pub disk_selection: DiskSelection,
+    pub filter_match: Option<FilterMatch>,
+    pub fs_options: FsOptions,
+}
+
+impl TryFrom<DiskSetup> for Disks {
+    type Error = &'static str;
+
+    fn try_from(source: DiskSetup) -> Result<Self, Self::Error> {
+        if source.disk_list.is_empty() && source.filter.is_none() {
+            return Err("Need either 'disk_list' or 'filter' set");
+        }
+        if !source.disk_list.is_empty() && source.filter.is_some() {
+            return Err("Cannot use both, 'disk_list' and 'filter'");
+        }
+
+        let disk_selection = if !source.disk_list.is_empty() {
+            DiskSelection::Selection(source.disk_list.clone())
+        } else {
+            DiskSelection::Filter(source.filter.clone().unwrap())
+        };
+
+        let lvm_checks = |source: &DiskSetup| -> Result<(), Self::Error> {
+            if source.zfs.is_some() || source.btrfs.is_some() {
+                return Err("make sure only 'lvm' options are set");
+            }
+            if source.disk_list.len() > 1 {
+                return Err("make sure to define only one disk for ext4 and xfs");
+            }
+            Ok(())
+        };
+        // TODO: improve checks for foreign FS options. E.g. less verbose and handling new FS types
+        // automatically
+        let (fs, fs_options) = match source.filesystem {
+            Filesystem::Xfs => {
+                lvm_checks(&source)?;
+                (FsType::Xfs, FsOptions::LVM(source.lvm.unwrap_or_default()))
+            }
+            Filesystem::Ext4 => {
+                lvm_checks(&source)?;
+                (FsType::Ext4, FsOptions::LVM(source.lvm.unwrap_or_default()))
+            }
+            Filesystem::Zfs => {
+                if source.lvm.is_some() || source.btrfs.is_some() {
+                    return Err("make sure only 'zfs' options are set");
+                }
+                match source.zfs {
+                    None | Some(ZfsOptions { raid: None, .. }) => {
+                        return Err("ZFS raid level 'zfs.raid' must be set")
+                    }
+                    Some(opts) => (FsType::Zfs(opts.raid.unwrap()), FsOptions::ZFS(opts)),
+                }
+            }
+            Filesystem::Btrfs => {
+                if source.zfs.is_some() || source.lvm.is_some() {
+                    return Err("make sure only 'btrfs' options are set");
+                }
+                match source.btrfs {
+                    None | Some(BtrfsOptions { raid: None, .. }) => {
+                        return Err("BTRFS raid level 'btrfs.raid' must be set")
+                    }
+                    Some(opts) => (FsType::Btrfs(opts.raid.unwrap()), FsOptions::BTRFS(opts)),
+                }
+            }
+        };
+
+        let res = Disks {
+            fs_type: fs,
+            disk_selection,
+            filter_match: source.filter_match,
+            fs_options,
+        };
+        Ok(res)
+    }
+}
+
+#[derive(Clone, Debug)]
+pub enum FsOptions {
+    LVM(LvmOptions),
+    ZFS(ZfsOptions),
+    BTRFS(BtrfsOptions),
+}
+
+#[derive(Clone, Debug)]
+pub enum DiskSelection {
+    Selection(Vec<String>),
+    Filter(BTreeMap<String, String>),
+}
+#[derive(Clone, Deserialize, Debug, PartialEq, ValueEnum)]
+#[serde(rename_all = "lowercase", deny_unknown_fields)]
+pub enum FilterMatch {
+    Any,
+    All,
+}
+
+#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)]
+#[serde(rename_all = "lowercase", deny_unknown_fields)]
+pub enum Filesystem {
+    Ext4,
+    Xfs,
+    Zfs,
+    Btrfs,
+}
+
+#[derive(Clone, Copy, Default, Deserialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub struct ZfsOptions {
+    pub raid: Option<ZfsRaidLevel>,
+    pub ashift: Option<usize>,
+    pub arc_max: Option<usize>,
+    pub checksum: Option<ZfsChecksumOption>,
+    pub compress: Option<ZfsCompressOption>,
+    pub copies: Option<usize>,
+    pub hdsize: Option<f64>,
+}
+
+#[derive(Clone, Copy, Default, Deserialize, Serialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub struct LvmOptions {
+    pub hdsize: Option<f64>,
+    pub swapsize: Option<f64>,
+    pub maxroot: Option<f64>,
+    pub maxvz: Option<f64>,
+    pub minfree: Option<f64>,
+}
+
+#[derive(Clone, Copy, Default, Deserialize, Debug)]
+#[serde(deny_unknown_fields)]
+pub struct BtrfsOptions {
+    pub hdsize: Option<f64>,
+    pub raid: Option<BtrfsRaidLevel>,
+}
+
+#[derive(Clone, Deserialize, Serialize, Debug, PartialEq)]
+#[serde(rename_all = "kebab-case", deny_unknown_fields)]
+pub enum KeyboardLayout {
+    De,
+    DeCh,
+    Dk,
+    EnGb,
+    EnUs,
+    Es,
+    Fi,
+    Fr,
+    FrBe,
+    FrCa,
+    FrCh,
+    Hu,
+    Is,
+    It,
+    Jp,
+    Lt,
+    Mk,
+    Nl,
+    No,
+    Pl,
+    Pt,
+    PtBr,
+    Se,
+    Si,
+    Tr,
+}
+
+serde_plain::derive_display_from_serialize!(KeyboardLayout);
diff --git a/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs b/proxmox-auto-installer/src/bin/proxmox-auto-installer.rs
new file mode 100644 (file)
index 0000000..9fcec1e
--- /dev/null
@@ -0,0 +1,175 @@
+use anyhow::{bail, format_err, Result};
+use log::{error, info, LevelFilter};
+use std::{
+    env,
+    io::{BufRead, BufReader, Write},
+    path::PathBuf,
+    process::ExitCode,
+};
+
+use proxmox_installer_common::setup::{
+    installer_setup, read_json, spawn_low_level_installer, LocaleInfo, RuntimeInfo, SetupInfo,
+};
+
+use proxmox_auto_installer::{
+    answer::Answer,
+    log::AutoInstLogger,
+    udevinfo::UdevInfo,
+    utils::{parse_answer, LowLevelMessage},
+};
+
+static LOGGER: AutoInstLogger = AutoInstLogger;
+
+pub fn init_log() -> Result<()> {
+    AutoInstLogger::init("/tmp/auto_installer.log")?;
+    log::set_logger(&LOGGER)
+        .map(|()| log::set_max_level(LevelFilter::Info))
+        .map_err(|err| format_err!(err))
+}
+
+fn auto_installer_setup(in_test_mode: bool) -> Result<(Answer, UdevInfo)> {
+    let base_path = if in_test_mode { "./testdir" } else { "/" };
+    let mut path = PathBuf::from(base_path);
+
+    path.push("run");
+    path.push("proxmox-installer");
+
+    let udev_info: UdevInfo = {
+        let mut path = path.clone();
+        path.push("run-env-udev.json");
+
+        read_json(&path)
+            .map_err(|err| format_err!("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 =
+        toml::from_str(&buffer).map_err(|err| format_err!("Failed parsing answer file: {err}"))?;
+
+    Ok((answer, udev_info))
+}
+
+fn main() -> ExitCode {
+    if let Err(err) = init_log() {
+        panic!("could not initilize logging: {}", err);
+    }
+
+    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),
+    };
+    info!("Starting auto installer");
+
+    let (setup_info, locales, runtime_info) = match installer_setup(in_test_mode) {
+        Ok(result) => result,
+        Err(err) => {
+            error!("Installer setup error: {err}");
+            return ExitCode::FAILURE;
+        }
+    };
+
+    let (answer, udevadm_info) = match auto_installer_setup(in_test_mode) {
+        Ok(result) => result,
+        Err(err) => {
+            error!("Autoinstaller setup error: {err}");
+            return ExitCode::FAILURE;
+        }
+    };
+
+    match run_installation(&answer, &locales, &runtime_info, &udevadm_info, &setup_info) {
+        Ok(_) => info!("Installation done."),
+        Err(err) => {
+            error!("Installation failed: {err}");
+            return exit_failure(answer.global.reboot_on_error);
+        }
+    }
+
+    // TODO: (optionally) do a HTTP post with basic system info, like host SSH public key(s) here
+
+    ExitCode::SUCCESS
+}
+
+/// When we exit with a failure, the installer will not automatically reboot.
+/// Default value for reboot_on_error is false
+fn exit_failure(reboot_on_error: bool) -> ExitCode {
+    if reboot_on_error {
+        ExitCode::SUCCESS
+    } else {
+        ExitCode::FAILURE
+    }
+}
+
+fn run_installation(
+    answer: &Answer,
+    locales: &LocaleInfo,
+    runtime_info: &RuntimeInfo,
+    udevadm_info: &UdevInfo,
+    setup_info: &SetupInfo,
+) -> Result<()> {
+    let config = parse_answer(answer, udevadm_info, runtime_info, locales, setup_info)?;
+    info!("Calling low-level installer");
+
+    let mut child = match spawn_low_level_installer(false) {
+        Ok(child) => child,
+        Err(err) => {
+            bail!("Low level installer could not be started: {}", err);
+        }
+    };
+
+    let mut inner = || -> Result<()> {
+        let reader = child
+            .stdout
+            .take()
+            .map(BufReader::new)
+            .ok_or(format_err!("failed to get stdout reader"))?;
+        let mut writer = child
+            .stdin
+            .take()
+            .ok_or(format_err!("failed to get stdin writer"))?;
+
+        serde_json::to_writer(&mut writer, &config)
+            .map_err(|err| format_err!("failed to serialize install config: {err}"))?;
+        writeln!(writer).map_err(|err| format_err!("failed to write install config: {err}"))?;
+
+        for line in reader.lines() {
+            let line = match line {
+                Ok(line) => line,
+                Err(_) => break,
+            };
+            let msg = match serde_json::from_str::<LowLevelMessage>(&line) {
+                Ok(msg) => msg,
+                Err(_) => {
+                    // Not a fatal error, so don't abort the installation by returning
+                    continue;
+                }
+            };
+
+            match msg.clone() {
+                LowLevelMessage::Info { message } => info!("{message}"),
+                LowLevelMessage::Error { message } => error!("{message}"),
+                LowLevelMessage::Prompt { query } => {
+                    bail!("Got interactive prompt I cannot answer: {query}")
+                }
+                LowLevelMessage::Progress { ratio, text } => {
+                    let percentage = ratio * 100.;
+                    info!("progress {percentage:>5.1} % - {text}");
+                }
+                LowLevelMessage::Finished { state, message } => {
+                    if state == "err" {
+                        bail!("{message}");
+                    }
+                    info!("Finished: '{state}' {message}");
+                }
+            };
+        }
+        Ok(())
+    };
+    inner().map_err(|err| format_err!("low level installer returned early: {err}"))
+}
diff --git a/proxmox-auto-installer/src/lib.rs b/proxmox-auto-installer/src/lib.rs
new file mode 100644 (file)
index 0000000..3bdf0b5
--- /dev/null
@@ -0,0 +1,5 @@
+pub mod answer;
+pub mod log;
+pub mod sysinfo;
+pub mod udevinfo;
+pub mod utils;
diff --git a/proxmox-auto-installer/src/log.rs b/proxmox-auto-installer/src/log.rs
new file mode 100644 (file)
index 0000000..51b6661
--- /dev/null
@@ -0,0 +1,46 @@
+use anyhow::{bail, Result};
+use log::{Level, Metadata, Record};
+use std::{fs::File, io::Write, sync::Mutex, sync::OnceLock};
+
+pub struct AutoInstLogger;
+static LOGFILE: OnceLock<Mutex<File>> = OnceLock::new();
+
+impl AutoInstLogger {
+    pub fn init(path: &str) -> Result<()> {
+        let f = File::create(path)?;
+        if LOGFILE.set(Mutex::new(f)).is_err() {
+            bail!("Cannot set LOGFILE")
+        }
+        Ok(())
+    }
+}
+
+impl log::Log for AutoInstLogger {
+    fn enabled(&self, metadata: &Metadata) -> bool {
+        metadata.level() <= Level::Info
+    }
+
+    /// Logs to both, stderr and into a log file
+    fn log(&self, record: &Record) {
+        if self.enabled(record.metadata()) {
+            eprintln!("{}: {}", record.level(), record.args());
+            let mut file = LOGFILE
+                .get()
+                .expect("could not get LOGFILE")
+                .lock()
+                .expect("could not get mutex for LOGFILE");
+            writeln!(file, "{}: {}", record.level(), record.args())
+                .expect("could not write to LOGFILE");
+        }
+    }
+
+    fn flush(&self) {
+        LOGFILE
+            .get()
+            .expect("could not get LOGFILE")
+            .lock()
+            .expect("could not get mutex for LOGFILE")
+            .flush()
+            .expect("could not flush LOGFILE");
+    }
+}
diff --git a/proxmox-auto-installer/src/sysinfo.rs b/proxmox-auto-installer/src/sysinfo.rs
new file mode 100644 (file)
index 0000000..05f7f50
--- /dev/null
@@ -0,0 +1,114 @@
+use anyhow::{bail, Result};
+use proxmox_installer_common::setup::{IsoInfo, ProductConfig, SetupInfo};
+use serde::Serialize;
+use std::{collections::HashMap, fs, io};
+
+use crate::utils::get_nic_list;
+
+const DMI_PATH: &str = "/sys/devices/virtual/dmi/id";
+
+#[derive(Debug, Serialize)]
+pub struct SysInfo {
+    product: ProductConfig,
+    iso: IsoInfo,
+    dmi: SystemDMI,
+    network_interfaces: Vec<NetdevWithMac>,
+}
+
+impl SysInfo {
+    pub fn get() -> Result<Self> {
+        let setup_info: SetupInfo = match fs::File::open("/run/proxmox-installer/iso-info.json") {
+            Ok(iso_info_file) => {
+                let reader = io::BufReader::new(iso_info_file);
+                serde_json::from_reader(reader)?
+            }
+            Err(err) if err.kind() == io::ErrorKind::NotFound => SetupInfo::mocked(),
+            Err(err) => bail!("failed to open iso-info.json - {err}"),
+        };
+
+        Ok(Self {
+            product: setup_info.config,
+            iso: setup_info.iso_info,
+            network_interfaces: NetdevWithMac::get_all()?,
+            dmi: SystemDMI::get()?,
+        })
+    }
+
+    pub fn as_json_pretty() -> Result<String> {
+        let info = Self::get()?;
+        Ok(serde_json::to_string_pretty(&info)?)
+    }
+
+    pub fn as_json() -> Result<String> {
+        let info = Self::get()?;
+        Ok(serde_json::to_string(&info)?)
+    }
+}
+
+#[derive(Debug, Serialize)]
+struct NetdevWithMac {
+    /// The network link name
+    pub link: String,
+    /// The MAC address of the network device
+    pub mac: String,
+}
+
+impl NetdevWithMac {
+    fn get_all() -> Result<Vec<Self>> {
+        let mut result: Vec<Self> = Vec::new();
+
+        let links = get_nic_list()?;
+        for link in links {
+            let mac = fs::read_to_string(format!("/sys/class/net/{link}/address"))?;
+            let mac = String::from(mac.trim());
+            result.push(Self { link, mac });
+        }
+        Ok(result)
+    }
+}
+
+#[derive(Debug, Serialize)]
+struct SystemDMI {
+    system: HashMap<String, String>,
+    baseboard: HashMap<String, String>,
+    chassis: HashMap<String, String>,
+}
+
+impl SystemDMI {
+    pub(crate) fn get() -> Result<Self> {
+        let system_files = [
+            "product_serial",
+            "product_sku",
+            "product_uuid",
+            "product_name",
+        ];
+        let baseboard_files = ["board_asset_tag", "board_serial", "board_name"];
+        let chassis_files = ["chassis_serial", "chassis_sku", "chassis_asset_tag"];
+
+        Ok(Self {
+            system: Self::get_dmi_infos(&system_files)?,
+            baseboard: Self::get_dmi_infos(&baseboard_files)?,
+            chassis: Self::get_dmi_infos(&chassis_files)?,
+        })
+    }
+
+    fn get_dmi_infos(files: &[&str]) -> Result<HashMap<String, String>> {
+        let mut res: HashMap<String, String> = HashMap::new();
+
+        for file in files {
+            let path = format!("{DMI_PATH}/{file}");
+            let content = match fs::read_to_string(&path) {
+                Err(ref err) if err.kind() == std::io::ErrorKind::NotFound => continue,
+                Err(ref err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
+                    bail!("Could not read data. Are you running as root or with sudo?")
+                }
+                Err(err) => bail!("Error: '{err}' on '{path}'"),
+                Ok(content) => content.trim().into(),
+            };
+            let key = file.splitn(2, '_').last().unwrap();
+            res.insert(key.into(), content);
+        }
+
+        Ok(res)
+    }
+}
diff --git a/proxmox-auto-installer/src/udevinfo.rs b/proxmox-auto-installer/src/udevinfo.rs
new file mode 100644 (file)
index 0000000..a6b61b5
--- /dev/null
@@ -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 (file)
index 0000000..202ad41
--- /dev/null
@@ -0,0 +1,405 @@
+use anyhow::{bail, Context as _, Result};
+use clap::ValueEnum;
+use glob::Pattern;
+use log::info;
+use std::{collections::BTreeMap, process::Command};
+
+use crate::{
+    answer::{self, Answer},
+    udevinfo::UdevInfo,
+};
+use proxmox_installer_common::{
+    options::{FsType, NetworkOptions, ZfsChecksumOption, ZfsCompressOption},
+    setup::{InstallConfig, InstallZfsOption, LocaleInfo, RuntimeInfo, SetupInfo},
+};
+use serde::{Deserialize, Serialize};
+
+pub fn get_network_settings(
+    answer: &Answer,
+    udev_info: &UdevInfo,
+    runtime_info: &RuntimeInfo,
+    setup_info: &SetupInfo,
+) -> Result<NetworkOptions> {
+    let mut network_options = NetworkOptions::defaults_from(setup_info, &runtime_info.network);
+
+    info!("Setting network configuration");
+
+    // Always use the FQDN from the answer file
+    network_options.fqdn = answer.global.fqdn.clone();
+
+    if let answer::NetworkSettings::Manual(settings) = &answer.network.network_settings {
+        network_options.address = settings.cidr.clone();
+        network_options.dns_server = settings.dns;
+        network_options.gateway = settings.gateway;
+        network_options.ifname = get_single_udev_index(&settings.filter, &udev_info.nics)?;
+    }
+    info!("Network interface used is '{}'", &network_options.ifname);
+    Ok(network_options)
+}
+
+pub fn get_single_udev_index(
+    filter: &BTreeMap<String, String>,
+    udev_list: &BTreeMap<String, BTreeMap<String, String>>,
+) -> Result<String> {
+    if filter.is_empty() {
+        bail!("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 {
+            let filter_pattern =
+                Pattern::new(filter_value).context("invalid glob in disk selection")?;
+            for (udev_key, udev_value) in dev_values {
+                if udev_key == filter_key && filter_pattern.matches(udev_value) {
+                    dev_index = Some(dev.clone());
+                    break 'outer; // take first match
+                }
+            }
+        }
+    }
+    if dev_index.is_none() {
+        bail!("filter did not match any device");
+    }
+
+    Ok(dev_index.unwrap())
+}
+
+#[derive(Deserialize, Serialize, Debug, Clone, ValueEnum, PartialEq)]
+#[serde(rename_all = "lowercase", deny_unknown_fields)]
+pub enum FetchAnswerFrom {
+    Iso,
+    Http,
+    Partition,
+}
+
+#[derive(Deserialize, Serialize, Clone, Default, PartialEq, Debug)]
+pub struct HttpOptions {
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub url: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub cert_fingerprint: Option<String>,
+}
+
+#[derive(Deserialize, Serialize, Debug)]
+#[serde(rename_all = "lowercase", deny_unknown_fields)]
+pub struct AutoInstSettings {
+    pub mode: FetchAnswerFrom,
+    #[serde(default)]
+    pub http: HttpOptions,
+}
+
+#[derive(Deserialize, Debug)]
+struct IpLinksUdevInfo {
+    ifname: String,
+}
+
+/// Returns vec of usable NICs
+pub fn get_nic_list() -> Result<Vec<String>> {
+    let ip_output = Command::new("/usr/sbin/ip")
+        .arg("-j")
+        .arg("link")
+        .output()?;
+    let parsed_links: Vec<IpLinksUdevInfo> = serde_json::from_slice(&ip_output.stdout)?;
+    let mut links: Vec<String> = Vec::new();
+
+    for link in parsed_links {
+        if link.ifname == *"lo" {
+            continue;
+        }
+        links.push(link.ifname);
+    }
+
+    Ok(links)
+}
+
+pub fn get_matched_udev_indexes(
+    filter: &BTreeMap<String, String>,
+    udev_list: &BTreeMap<String, BTreeMap<String, String>>,
+    match_all: bool,
+) -> Result<Vec<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 {
+            let filter_pattern =
+                Pattern::new(filter_value).context("invalid glob in disk selection")?;
+            for (udev_key, udev_value) in dev_values {
+                if udev_key == filter_key && filter_pattern.matches(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() {
+        bail!("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<()> {
+    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<()> {
+    match &answer.disks.disk_selection {
+        answer::DiskSelection::Selection(disk_list) => {
+            let disk_name = disk_list[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 => bail!("disk in 'disk_selection' not found"),
+            }
+        }
+        answer::DiskSelection::Filter(filter) => {
+            let disk_index = get_single_udev_index(filter, &udev_info.disks)?;
+            let disk = runtime_info
+                .disks
+                .iter()
+                .find(|item| item.index == disk_index);
+            config.target_hd = disk.cloned();
+        }
+    }
+    info!("Selected disk: {}", config.target_hd.clone().unwrap().path);
+    Ok(())
+}
+
+fn set_selected_disks(
+    answer: &Answer,
+    udev_info: &UdevInfo,
+    runtime_info: &RuntimeInfo,
+    config: &mut InstallConfig,
+) -> Result<()> {
+    match &answer.disks.disk_selection {
+        answer::DiskSelection::Selection(disk_list) => {
+            info!("Disk selection found");
+            for disk_name in disk_list.clone() {
+                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());
+                }
+            }
+        }
+        answer::DiskSelection::Filter(filter) => {
+            info!("No disk list found, looking for disk filters");
+            let filter_match = answer
+                .disks
+                .filter_match
+                .clone()
+                .unwrap_or(answer::FilterMatch::Any);
+            let selected_disk_indexes = get_matched_udev_indexes(
+                filter,
+                &udev_info.disks,
+                filter_match == answer::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() {
+        bail!("No disks found matching selection.");
+    }
+
+    let mut selected_disks: Vec<String> = Vec::new();
+    for i in config.disk_selection.keys() {
+        selected_disks.push(
+            runtime_info
+                .disks
+                .iter()
+                .find(|item| item.index.as_str() == i)
+                .unwrap()
+                .clone()
+                .path,
+        );
+    }
+    info!(
+        "Selected disks: {}",
+        selected_disks
+            .iter()
+            .map(|x| x.to_string() + " ")
+            .collect::<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<()> {
+    info!("Verifying locale settings");
+    if !locales
+        .countries
+        .keys()
+        .any(|i| i == &answer.global.country)
+    {
+        bail!("country code '{}' is not valid", &answer.global.country);
+    }
+    if !locales
+        .kmap
+        .keys()
+        .any(|i| i == &answer.global.keyboard.to_string())
+    {
+        bail!("keyboard layout '{}' is not valid", &answer.global.keyboard);
+    }
+
+    if !locales
+        .cczones
+        .iter()
+        .any(|(_, zones)| zones.contains(&answer.global.timezone))
+        && answer.global.timezone != "UTC"
+    {
+        bail!("timezone '{}' is not valid", &answer.global.timezone);
+    }
+
+    Ok(())
+}
+
+pub fn parse_answer(
+    answer: &Answer,
+    udev_info: &UdevInfo,
+    runtime_info: &RuntimeInfo,
+    locales: &LocaleInfo,
+    setup_info: &SetupInfo,
+) -> Result<InstallConfig> {
+    info!("Parsing answer file");
+    info!("Setting File system");
+    let filesystem = answer.disks.fs_type;
+    info!("File system selected: {}", filesystem);
+
+    let network_settings = get_network_settings(answer, udev_info, runtime_info, setup_info)?;
+
+    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(),
+        lvm_auto_rename: 1,
+
+        country: answer.global.country.clone(),
+        timezone: answer.global.timezone.clone(),
+        keymap: answer.global.keyboard.to_string(),
+
+        password: answer.global.root_password.clone(),
+        mailto: answer.global.mailto.clone(),
+        root_ssh_keys: answer.global.root_ssh_keys.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,
+    };
+
+    set_disks(answer, udev_info, runtime_info, &mut config)?;
+    match &answer.disks.fs_options {
+        answer::FsOptions::LVM(lvm) => {
+            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;
+        }
+        answer::FsOptions::ZFS(zfs) => {
+            let first_selected_disk = 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),
+                arc_max: zfs.arc_max.unwrap_or(2048),
+                compress: zfs.compress.unwrap_or(ZfsCompressOption::On),
+                checksum: zfs.checksum.unwrap_or(ZfsChecksumOption::On),
+                copies: zfs.copies.unwrap_or(1),
+            });
+        }
+        answer::FsOptions::BTRFS(btrfs) => {
+            let first_selected_disk = get_first_selected_disk(&config);
+
+            config.hdsize = btrfs
+                .hdsize
+                .unwrap_or(runtime_info.disks[first_selected_disk].size);
+        }
+    }
+    Ok(config)
+}
+
+#[derive(Clone, Debug, Deserialize, PartialEq)]
+#[serde(tag = "type", rename_all = "lowercase")]
+pub enum LowLevelMessage {
+    #[serde(rename = "message")]
+    Info {
+        message: String,
+    },
+    Error {
+        message: String,
+    },
+    Prompt {
+        query: String,
+    },
+    Finished {
+        state: String,
+        message: String,
+    },
+    Progress {
+        ratio: f32,
+        text: String,
+    },
+}
diff --git a/proxmox-auto-installer/tests/parse-answer.rs b/proxmox-auto-installer/tests/parse-answer.rs
new file mode 100644 (file)
index 0000000..4014b6d
--- /dev/null
@@ -0,0 +1,106 @@
+use std::path::PathBuf;
+
+use serde_json::Value;
+use std::fs;
+
+use proxmox_auto_installer::answer;
+use proxmox_auto_installer::answer::Answer;
+use proxmox_auto_installer::udevinfo::UdevInfo;
+use proxmox_auto_installer::utils::parse_answer;
+
+use proxmox_installer_common::setup::{read_json, LocaleInfo, RuntimeInfo, SetupInfo};
+
+fn get_test_resource_path() -> Result<PathBuf, String> {
+    Ok(std::env::current_dir()
+        .expect("current dir failed")
+        .join("tests/resources"))
+}
+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) -> (SetupInfo, LocaleInfo, RuntimeInfo, UdevInfo) {
+    let installer_info: SetupInfo = {
+        let mut path = path.clone();
+        path.push("iso-info.json");
+
+        read_json(&path)
+            .map_err(|err| format!("Failed to retrieve setup info: {err}"))
+            .unwrap()
+    };
+
+    let locale_info = {
+        let mut path = path.clone();
+        path.push("locales.json");
+
+        read_json(&path)
+            .map_err(|err| format!("Failed to retrieve locale info: {err}"))
+            .unwrap()
+    };
+
+    let mut runtime_info: RuntimeInfo = {
+        let mut path = path.clone();
+        path.push("run-env-info.json");
+
+        read_json(&path)
+            .map_err(|err| format!("Failed to retrieve runtime environment info: {err}"))
+            .unwrap()
+    };
+
+    let udev_info: UdevInfo = {
+        let mut path = path.clone();
+        path.push("run-env-udev.json");
+
+        read_json(&path)
+            .map_err(|err| format!("Failed to retrieve udev info details: {err}"))
+            .unwrap()
+    };
+    runtime_info.disks.sort();
+    if runtime_info.disks.is_empty() {
+        panic!("disk list is empty!");
+    }
+    (installer_info, locale_info, runtime_info, udev_info)
+}
+
+#[test]
+fn test_parse_answers() {
+    let path = get_test_resource_path().unwrap();
+    let (setup_info, locales, runtime_info, udev_info) = 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, &setup_info).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: {:#?}\n",
+                    name, config, compare
+                );
+            }
+        }
+    }
+}
diff --git a/proxmox-auto-installer/tests/resources/iso-info.json b/proxmox-auto-installer/tests/resources/iso-info.json
new file mode 100644 (file)
index 0000000..33cb79b
--- /dev/null
@@ -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/tests/resources/locales.json b/proxmox-auto-installer/tests/resources/locales.json
new file mode 100644 (file)
index 0000000..220a18c
--- /dev/null
@@ -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/tests/resources/parse_answer/disk_match.json b/proxmox-auto-installer/tests/resources/parse_answer/disk_match.json
new file mode 100644 (file)
index 0000000..3a117b6
--- /dev/null
@@ -0,0 +1,30 @@
+{
+  "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"
+  },
+  "lvm_auto_rename": 1,
+  "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": {
+      "arc_max": 2048,
+      "ashift": 12,
+      "checksum": "on",
+      "compress": "on",
+      "copies": 1
+  }
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/disk_match.toml b/proxmox-auto-installer/tests/resources/parse_answer/disk_match.toml
new file mode 100644 (file)
index 0000000..68676ac
--- /dev/null
@@ -0,0 +1,16 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root_password = "123456"
+
+[network]
+source = "from-dhcp"
+
+[disk-setup]
+filesystem = "zfs"
+zfs.raid = "raid10"
+#disk_list = ['sda']
+filter.ID_SERIAL = "*MZ7KM240HAGR*"
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.json b/proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.json
new file mode 100644 (file)
index 0000000..5325fc3
--- /dev/null
@@ -0,0 +1,27 @@
+{
+  "autoreboot": 1,
+  "cidr": "192.168.1.114/24",
+  "country": "at",
+  "dns": "192.168.1.254",
+  "domain": "testinstall",
+  "disk_selection": {
+       "9": "9"
+  },
+  "lvm_auto_rename": 1,
+  "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": {
+      "arc_max": 2048,
+      "ashift": 12,
+      "checksum": "on",
+      "compress": "on",
+      "copies": 1
+  }
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.toml b/proxmox-auto-installer/tests/resources/parse_answer/disk_match_all.toml
new file mode 100644 (file)
index 0000000..f20a4fe
--- /dev/null
@@ -0,0 +1,17 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root_password = "123456"
+
+[network]
+source = "from-dhcp"
+
+[disk-setup]
+filesystem = "zfs"
+zfs.raid = "raid0"
+filter_match = "all"
+filter.ID_SERIAL = "*MZ7KM240HAGR*"
+filter.ID_SERIAL_SHORT = "S2HRNX0J403419"
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.json b/proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.json
new file mode 100644 (file)
index 0000000..18e22d1
--- /dev/null
@@ -0,0 +1,34 @@
+{
+  "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"
+  },
+  "lvm_auto_rename": 1,
+  "filesys": "zfs (RAID10)",
+  "gateway": "192.168.1.1",
+  "hdsize": 2980.820640563965,
+  "hostname": "pveauto",
+  "keymap": "de",
+  "mailto": "mail@no.invalid",
+  "mngmt_nic": "eno1",
+  "password": "123456",
+  "timezone": "Europe/Vienna",
+  "zfs_opts": {
+      "arc_max": 2048,
+      "ashift": 12,
+      "checksum": "on",
+      "compress": "on",
+      "copies": 1
+  }
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.toml b/proxmox-auto-installer/tests/resources/parse_answer/disk_match_any.toml
new file mode 100644 (file)
index 0000000..e1f33c9
--- /dev/null
@@ -0,0 +1,17 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root_password = "123456"
+
+[network]
+source = "from-dhcp"
+
+[disk-setup]
+filesystem = "zfs"
+zfs.raid = "raid10"
+filter_match = "any"
+filter.ID_SERIAL = "*MZ7KM240HAGR*"
+filter.ID_MODEL = "Micron_9300*"
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/minimal.json b/proxmox-auto-installer/tests/resources/parse_answer/minimal.json
new file mode 100644 (file)
index 0000000..bb72713
--- /dev/null
@@ -0,0 +1,18 @@
+{
+  "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,
+  "lvm_auto_rename": 1,
+  "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/tests/resources/parse_answer/minimal.toml b/proxmox-auto-installer/tests/resources/parse_answer/minimal.toml
new file mode 100644 (file)
index 0000000..db8fec4
--- /dev/null
@@ -0,0 +1,14 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root_password = "123456"
+
+[network]
+source = "from-dhcp"
+
+[disk-setup]
+filesystem = "ext4"
+disk_list = ["sda"]
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/nic_matching.json b/proxmox-auto-installer/tests/resources/parse_answer/nic_matching.json
new file mode 100644 (file)
index 0000000..de94165
--- /dev/null
@@ -0,0 +1,18 @@
+{
+  "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,
+  "lvm_auto_rename": 1,
+  "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/tests/resources/parse_answer/nic_matching.toml b/proxmox-auto-installer/tests/resources/parse_answer/nic_matching.toml
new file mode 100644 (file)
index 0000000..087c37f
--- /dev/null
@@ -0,0 +1,19 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root_password = "123456"
+
+[network]
+source = "from-answer"
+cidr = "10.10.10.10/24"
+dns = "10.10.10.1"
+gateway = "10.10.10.1"
+filter.ID_NET_NAME_MAC = "*a0369f0ab382"
+
+
+[disk-setup]
+filesystem = "ext4"
+disk_list = ["sda"]
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/readme b/proxmox-auto-installer/tests/resources/parse_answer/readme
new file mode 100644 (file)
index 0000000..6ce77ae
--- /dev/null
@@ -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 * 512 / 1024 / 1024 / 1024 with 14 digits after the comma
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/specific_nic.json b/proxmox-auto-installer/tests/resources/parse_answer/specific_nic.json
new file mode 100644 (file)
index 0000000..5b4fcfc
--- /dev/null
@@ -0,0 +1,18 @@
+{
+  "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,
+  "lvm_auto_rename": 1,
+  "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/tests/resources/parse_answer/specific_nic.toml b/proxmox-auto-installer/tests/resources/parse_answer/specific_nic.toml
new file mode 100644 (file)
index 0000000..60f7f14
--- /dev/null
@@ -0,0 +1,19 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root_password = "123456"
+
+[network]
+source = "from-answer"
+cidr = "10.10.10.10/24"
+dns = "10.10.10.1"
+gateway = "10.10.10.1"
+filter.ID_NET_NAME = "enp129s0f1np1"
+
+
+[disk-setup]
+filesystem = "ext4"
+disk_list = ["sda"]
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/zfs.json b/proxmox-auto-installer/tests/resources/parse_answer/zfs.json
new file mode 100644 (file)
index 0000000..65724a8
--- /dev/null
@@ -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"
+  },
+  "lvm_auto_rename": 1,
+  "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": {
+      "arc_max": 2048,
+      "ashift": 12,
+      "checksum": "on",
+      "compress": "lz4",
+      "copies": 2
+  }
+}
diff --git a/proxmox-auto-installer/tests/resources/parse_answer/zfs.toml b/proxmox-auto-installer/tests/resources/parse_answer/zfs.toml
new file mode 100644 (file)
index 0000000..4d48998
--- /dev/null
@@ -0,0 +1,20 @@
+[global]
+keyboard = "de"
+country = "at"
+fqdn = "pveauto.testinstall"
+mailto = "mail@no.invalid"
+timezone = "Europe/Vienna"
+root_password = "123456"
+
+[network]
+source = "from-dhcp"
+
+[disk-setup]
+filesystem = "zfs"
+zfs.raid = "raid1"
+zfs.ashift = 12
+zfs.checksum = "on"
+zfs.compress = "lz4"
+zfs.copies = 2
+zfs.hdsize = 80
+disk_list = ["sda", "sdb"]
diff --git a/proxmox-auto-installer/tests/resources/run-env-info.json b/proxmox-auto-installer/tests/resources/run-env-info.json
new file mode 100644 (file)
index 0000000..6762470
--- /dev/null
@@ -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",976773168,"Samsung SSD 970 EVO Plus 500GB",512,"/sys/block/nvme4n1"],[5,"/dev/nvme5n1",732585168,"INTEL SSDPED1K375GA",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":"4","dnsserver":"192.168.1.254","domain":null,"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":"igb","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"a0:36:9f:0a:b3:82","name":"enp65s0f0","state":"DOWN"},"3":{"driver":"igb","flags":"NO-CARRIER,BROADCAST,MULTICAST,UP","mac":"a0:36:9f:0a:b3:83","name":"enp65s0f1","state":"DOWN"},"4":{"driver":"igb","flags":"BROADCAST,MULTICAST,UP,LOWER_UP","inet":{"addr":"192.168.1.114","mask":"255.255.240.0","prefix":20},"mac":"b4:2e:99:ac:ad:b4","name":"eno1","state":"UP"},"5":{"driver":"cdc_ether","flags":"BROADCAST,MULTICAST,UP,LOWER_UP","mac":"5a:47:32:dd:c7:47","name":"enx5a4732ddc747","state":"UNKNOWN"},"6":{"driver":"igb","flags":"BROADCAST,MULTICAST,UP,LOWER_UP","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":"BROADCAST,MULTICAST,UP,LOWER_UP","mac":"24:8a:07:1e:05:bc","name":"enp193s0f0np0","state":"UP"}}},"kernel_cmdline":"BOOT_IMAGE=/boot/linux26 ro ramdisk_size=16777216 rw splash=verbose proxdebug vga=788","network":{"dns":{"dns":["192.168.1.254"],"domain":null},"interfaces":{"eno1":{"addresses":[{"address":"192.168.1.114","family":"inet","prefix":24}],"index":4,"mac":"b4:2e:99:ac:ad:b4","name":"eno1","state":"UP"},"eno2":{"index":6,"mac":"b4:2e:99:ac:ad:b5","name":"eno2","state":"UP"},"enp129s0f0np0":{"index":7,"mac":"1c:34:da:5c:5e:24","name":"enp129s0f0np0","state":"DOWN"},"enp129s0f1np1":{"index":8,"mac":"1c:34:da:5c:5e:25","name":"enp129s0f1np1","state":"DOWN"},"enp193s0f0np0":{"index":9,"mac":"24:8a:07:1e:05:bc","name":"enp193s0f0np0","state":"UP"},"enp193s0f1np1":{"index":10,"mac":"24:8a:07:1e:05:bd","name":"enp193s0f1np1","state":"DOWN"},"enp65s0f0":{"index":2,"mac":"a0:36:9f:0a:b3:82","name":"enp65s0f0","state":"DOWN"},"enp65s0f1":{"index":3,"mac":"a0:36:9f:0a:b3:83","name":"enp65s0f1","state":"DOWN"},"enx5a4732ddc747":{"index":5,"mac":"5a:47:32:dd:c7:47","name":"enx5a4732ddc747","state":"UNKNOWN"}},"routes":{"gateway4":{"dev":"eno1","gateway":"192.168.1.1"}}},"total_memory":257597}
diff --git a/proxmox-auto-installer/tests/resources/run-env-udev.json b/proxmox-auto-installer/tests/resources/run-env-udev.json
new file mode 100644 (file)
index 0000000..4fe1f30
--- /dev/null
@@ -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-chroot/Cargo.toml b/proxmox-chroot/Cargo.toml
new file mode 100644 (file)
index 0000000..43b96ff
--- /dev/null
@@ -0,0 +1,16 @@
+[package]
+name = "proxmox-chroot"
+version = "0.1.0"
+edition = "2021"
+authors = [ "Aaron Lauterer <a.lauterer@proxmox.com>" ]
+license = "AGPL-3"
+exclude = [ "build", "debian" ]
+homepage = "https://www.proxmox.com"
+
+[dependencies]
+anyhow = "1.0"
+clap = { version = "4.0", features = ["derive"] }
+nix = "0.26.1"
+proxmox-installer-common = { path = "../proxmox-installer-common" }
+regex = "1.7"
+serde_json = "1.0"
diff --git a/proxmox-chroot/src/main.rs b/proxmox-chroot/src/main.rs
new file mode 100644 (file)
index 0000000..ca6f3a9
--- /dev/null
@@ -0,0 +1,352 @@
+use std::{fs, io, path, process::Command};
+
+use anyhow::{bail, Result};
+use clap::{Args, Parser, Subcommand, ValueEnum};
+use nix::mount::{mount, umount, MsFlags};
+use proxmox_installer_common::{
+    options::FsType,
+    setup::{InstallConfig, SetupInfo},
+};
+use regex::Regex;
+
+const ANSWER_MP: &str = "answer";
+static BINDMOUNTS: [&str; 4] = ["dev", "proc", "run", "sys"];
+const TARGET_DIR: &str = "/target";
+const ZPOOL_NAME: &str = "rpool";
+
+/// Helper tool to prepare eveything to `chroot` into an installation
+#[derive(Parser, Debug)]
+#[command(author, version, about, long_about = None)]
+struct Cli {
+    #[command(subcommand)]
+    command: Commands,
+}
+
+#[derive(Subcommand, Debug)]
+enum Commands {
+    Prepare(CommandPrepare),
+    Cleanup(CommandCleanup),
+}
+
+/// Mount the root file system and bind mounts in preparation to chroot into the installation
+#[derive(Args, Debug)]
+struct CommandPrepare {
+    /// Filesystem used for the installation. Will try to automatically detect it after a
+    /// successful installation.
+    #[arg(short, long, value_enum)]
+    filesystem: Option<Filesystems>,
+
+    /// Numerical ID of `rpool` ZFS pool to import. Needed if multiple pools of name `rpool` are present.
+    #[arg(long)]
+    rpool_id: Option<u64>,
+
+    /// UUID of the BTRFS file system to mount. Needed if multiple BTRFS file systems are present.
+    #[arg(long)]
+    btrfs_uuid: Option<String>,
+}
+
+/// Unmount everything. Use once done with chroot.
+#[derive(Args, Debug)]
+struct CommandCleanup {
+    /// Filesystem used for the installation. Will try to automatically detect it by default.
+    #[arg(short, long, value_enum)]
+    filesystem: Option<Filesystems>,
+}
+
+#[derive(Copy, Clone, Debug, ValueEnum)]
+enum Filesystems {
+    Zfs,
+    Ext4,
+    Xfs,
+    Btrfs,
+}
+
+impl From<FsType> for Filesystems {
+    fn from(fs: FsType) -> Self {
+        match fs {
+            FsType::Xfs => Self::Xfs,
+            FsType::Ext4 => Self::Ext4,
+            FsType::Zfs(_) => Self::Zfs,
+            FsType::Btrfs(_) => Self::Btrfs,
+        }
+    }
+}
+
+fn main() {
+    let args = Cli::parse();
+    let res = match &args.command {
+        Commands::Prepare(args) => prepare(args),
+        Commands::Cleanup(args) => cleanup(args),
+    };
+    if let Err(err) = res {
+        eprintln!("{err}");
+        std::process::exit(1);
+    }
+}
+
+fn prepare(args: &CommandPrepare) -> Result<()> {
+    let fs = get_fs(args.filesystem)?;
+
+    fs::create_dir_all(TARGET_DIR)?;
+
+    match fs {
+        Filesystems::Zfs => mount_zpool(args.rpool_id)?,
+        Filesystems::Xfs => mount_fs()?,
+        Filesystems::Ext4 => mount_fs()?,
+        Filesystems::Btrfs => mount_btrfs(args.btrfs_uuid.clone())?,
+    }
+
+    if let Err(e) = bindmount() {
+        eprintln!("{e}")
+    }
+
+    println!("Done. You can now use 'chroot /target /bin/bash'!");
+    Ok(())
+}
+
+fn cleanup(args: &CommandCleanup) -> Result<()> {
+    let fs = get_fs(args.filesystem)?;
+
+    if let Err(e) = bind_umount() {
+        eprintln!("{e}")
+    }
+
+    match fs {
+        Filesystems::Zfs => umount_zpool(),
+        Filesystems::Xfs => umount_fs()?,
+        Filesystems::Ext4 => umount_fs()?,
+        _ => (),
+    }
+
+    println!("Chroot cleanup done. You can now reboot or leave the shell.");
+    Ok(())
+}
+
+fn get_fs(filesystem: Option<Filesystems>) -> Result<Filesystems> {
+    let fs = match filesystem {
+        None => {
+            let low_level_config = match get_low_level_config() {
+                Ok(c) => c,
+                Err(_) => bail!("Could not fetch config from previous installation. Please specify file system with -f."),
+            };
+            Filesystems::from(low_level_config.filesys)
+        }
+        Some(fs) => fs,
+    };
+
+    Ok(fs)
+}
+
+fn get_low_level_config() -> Result<InstallConfig> {
+    let file = fs::File::open("/tmp/low-level-config.json")?;
+    let reader = io::BufReader::new(file);
+    let config: InstallConfig = serde_json::from_reader(reader)?;
+    Ok(config)
+}
+
+fn get_iso_info() -> Result<SetupInfo> {
+    let file = fs::File::open("/run/proxmox-installer/iso-info.json")?;
+    let reader = io::BufReader::new(file);
+    let setup_info: SetupInfo = serde_json::from_reader(reader)?;
+    Ok(setup_info)
+}
+
+fn mount_zpool(pool_id: Option<u64>) -> Result<()> {
+    println!("importing ZFS pool to {TARGET_DIR}");
+    let mut import = Command::new("zpool");
+    import.arg("import").args(["-R", TARGET_DIR]);
+    match pool_id {
+        None => {
+            import.arg(ZPOOL_NAME);
+        }
+        Some(id) => {
+            import.arg(id.to_string());
+        }
+    }
+    match import.status() {
+        Ok(s) if !s.success() => bail!("Could not import ZFS pool. Abort!"),
+        _ => (),
+    }
+    println!("successfully imported ZFS pool to {TARGET_DIR}");
+    Ok(())
+}
+
+fn umount_zpool() {
+    match Command::new("zpool").arg("export").arg(ZPOOL_NAME).status() {
+        Ok(s) if !s.success() => println!("failure on exporting {ZPOOL_NAME}"),
+        _ => (),
+    }
+}
+
+fn mount_fs() -> Result<()> {
+    let iso_info = get_iso_info()?;
+    let product = iso_info.config.product;
+
+    println!("Activating VG '{product}'");
+    let res = Command::new("vgchange")
+        .arg("-ay")
+        .arg(product.to_string())
+        .output();
+    match res {
+        Err(e) => bail!("{e}"),
+        Ok(output) => {
+            if output.status.success() {
+                println!(
+                    "successfully activated VG '{product}': {}",
+                    String::from_utf8(output.stdout)?
+                );
+            } else {
+                bail!(
+                    "activation of VG '{product}' failed: {}",
+                    String::from_utf8(output.stderr)?
+                )
+            }
+        }
+    }
+
+    match Command::new("mount")
+        .arg(format!("/dev/mapper/{product}-root"))
+        .arg("/target")
+        .output()
+    {
+        Err(e) => bail!("{e}"),
+        Ok(output) => {
+            if output.status.success() {
+                println!("mounted root file system successfully");
+            } else {
+                bail!(
+                    "mounting of root file system failed: {}",
+                    String::from_utf8(output.stderr)?
+                )
+            }
+        }
+    }
+
+    Ok(())
+}
+
+fn umount_fs() -> Result<()> {
+    umount(TARGET_DIR)?;
+    Ok(())
+}
+
+fn mount_btrfs(btrfs_uuid: Option<String>) -> Result<()> {
+    let uuid = match btrfs_uuid {
+        Some(uuid) => uuid,
+        None => get_btrfs_uuid()?,
+    };
+
+    match Command::new("mount")
+        .arg("--uuid")
+        .arg(uuid)
+        .arg("/target")
+        .output()
+    {
+        Err(e) => bail!("{e}"),
+        Ok(output) => {
+            if output.status.success() {
+                println!("mounted BTRFS root file system successfully");
+            } else {
+                bail!(
+                    "mounting of BTRFS root file system failed: {}",
+                    String::from_utf8(output.stderr)?
+                )
+            }
+        }
+    }
+
+    Ok(())
+}
+
+fn get_btrfs_uuid() -> Result<String> {
+    let output = Command::new("btrfs")
+        .arg("filesystem")
+        .arg("show")
+        .output()?;
+    if !output.status.success() {
+        bail!(
+            "Error checking for BTRFS file systems: {}",
+            String::from_utf8(output.stderr)?
+        );
+    }
+    let out = String::from_utf8(output.stdout)?;
+    let mut uuids = Vec::new();
+
+    let re_uuid =
+        Regex::new(r"uuid: ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$")?;
+    for line in out.lines() {
+        if let Some(cap) = re_uuid.captures(line) {
+            if let Some(uuid) = cap.get(1) {
+                uuids.push(uuid.as_str());
+            }
+        }
+    }
+    match uuids.len() {
+        0 => bail!("Could not find any BTRFS UUID"),
+        i if i > 1 => {
+            let uuid_list = uuids
+                .iter()
+                .fold(String::new(), |acc, &arg| format!("{acc}\n{arg}"));
+            bail!("Found {i} UUIDs:{uuid_list}\nPlease specify the UUID to use with the --btrfs-uuid parameter")
+        }
+        _ => (),
+    }
+    Ok(uuids[0].into())
+}
+
+fn bindmount() -> Result<()> {
+    println!("Bind mounting");
+    // https://github.com/nix-rust/nix/blob/7badbee1e388618457ed0d725c1091359f253012/test/test_mount.rs#L19
+    // https://github.com/nix-rust/nix/blob/7badbee1e388618457ed0d725c1091359f253012/test/test_mount.rs#L146
+    const NONE: Option<&'static [u8]> = None;
+
+    let flags = MsFlags::MS_BIND;
+    for item in BINDMOUNTS {
+        let source = path::Path::new("/").join(item);
+        let target = path::Path::new(TARGET_DIR).join(item);
+
+        println!("Bindmount {source:?} to {target:?}");
+        mount(Some(source.as_path()), target.as_path(), NONE, flags, NONE)?;
+    }
+
+    let answer_path = path::Path::new("/mnt").join(ANSWER_MP);
+    if answer_path.exists() {
+        let target = path::Path::new(TARGET_DIR).join("mnt").join(ANSWER_MP);
+
+        println!("Create dir {target:?}");
+        fs::create_dir_all(&target)?;
+
+        println!("Bindmount {answer_path:?} to {target:?}");
+        mount(
+            Some(answer_path.as_path()),
+            target.as_path(),
+            NONE,
+            flags,
+            NONE,
+        )?;
+    }
+    Ok(())
+}
+
+fn bind_umount() -> Result<()> {
+    for item in BINDMOUNTS {
+        let target = path::Path::new(TARGET_DIR).join(item);
+        println!("Unmounting {target:?}");
+        if let Err(e) = umount(target.as_path()) {
+            eprintln!("{e}");
+        }
+    }
+
+    let answer_target = path::Path::new(TARGET_DIR).join("mnt").join(ANSWER_MP);
+    if answer_target.exists() {
+        println!("Unmounting and removing answer mountpoint");
+        if let Err(e) = umount(answer_target.as_os_str()) {
+            eprintln!("{e}");
+        }
+        if let Err(e) = fs::remove_dir(answer_target) {
+            eprintln!("{e}");
+        }
+    }
+
+    Ok(())
+}
diff --git a/proxmox-fetch-answer/Cargo.toml b/proxmox-fetch-answer/Cargo.toml
new file mode 100644 (file)
index 0000000..964682a
--- /dev/null
@@ -0,0 +1,25 @@
+[package]
+name = "proxmox-fetch-answer"
+version = "0.1.0"
+edition = "2021"
+authors = [
+    "Aaron Lauterer <a.lauterer@proxmox.com>",
+    "Proxmox Support Team <support@proxmox.com>",
+]
+license = "AGPL-3"
+exclude = [ "build", "debian" ]
+homepage = "https://www.proxmox.com"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+anyhow = "1.0"
+hex = "0.4"
+log = "0.4.20"
+native-tls = "0.2"
+proxmox-auto-installer = { path = "../proxmox-auto-installer" }
+rustls = { version = "0.20", features = [ "dangerous_configuration" ] }
+rustls-native-certs = "0.6"
+sha2 = "0.10"
+toml = "0.7"
+ureq = { version = "2.6", features = [ "native-certs", "native-tls" ] }
diff --git a/proxmox-fetch-answer/src/fetch_plugins/http.rs b/proxmox-fetch-answer/src/fetch_plugins/http.rs
new file mode 100644 (file)
index 0000000..1c5e9ea
--- /dev/null
@@ -0,0 +1,278 @@
+use anyhow::{bail, Result};
+use log::info;
+use std::{
+    fs::{self, read_to_string},
+    process::Command,
+};
+
+use proxmox_auto_installer::{sysinfo::SysInfo, utils::HttpOptions};
+
+static ANSWER_URL_SUBDOMAIN: &str = "proxmox-auto-installer";
+static ANSWER_CERT_FP_SUBDOMAIN: &str = "proxmox-auto-installer-cert-fingerprint";
+
+// It is possible to set custom DHPC options. Option numbers 224 to 254 [0].
+// To use them with dhclient, we need to configure it to request them and what they should be
+// called.
+//
+// e.g. /etc/dhcp/dhclient.conf:
+// ```
+// option proxmox-auto-installer-manifest-url code 250 = text;
+// option proxmox-auto-installer-cert-fingerprint code 251 = text;
+// also request proxmox-auto-installer-manifest-url, proxmox-auto-installer-cert-fingerprint;
+// ```
+//
+// The results will end up in the /var/lib/dhcp/dhclient.leases file from where we can fetch them
+//
+// [0] https://www.iana.org/assignments/bootp-dhcp-parameters/bootp-dhcp-parameters.xhtml
+static DHCP_URL_OPTION: &str = "proxmox-auto-installer-manifest-url";
+static DHCP_CERT_FP_OPTION: &str = "proxmox-auto-installer-cert-fingerprint";
+static DHCP_LEASE_FILE: &str = "/var/lib/dhcp/dhclient.leases";
+
+pub struct FetchFromHTTP;
+
+impl FetchFromHTTP {
+    /// Will try to fetch the answer.toml by sending a HTTP POST request. The URL can be configured
+    /// either via DHCP or DNS or preconfigured in the ISO.
+    /// If the URL is not defined in the ISO, it will first check DHCP options. The SSL certificate
+    /// needs to be either trusted by the root certs or a SHA256 fingerprint needs to be provided.
+    /// The SHA256 SSL fingerprint can either be defined in the ISO, as DHCP option, or as DNS TXT
+    /// record. If provided, the fingerprint provided in the ISO has preference.
+    pub fn get_answer(settings: &HttpOptions) -> Result<String> {
+        let mut fingerprint: Option<String> = match settings.cert_fingerprint.clone() {
+            Some(fp) => {
+                info!("SSL fingerprint provided through ISO.");
+                Some(fp)
+            }
+            None => None,
+        };
+
+        let answer_url: String;
+        if let Some(url) = settings.url.clone() {
+            info!("URL specified in ISO");
+            answer_url = url;
+        } else {
+            (answer_url, fingerprint) = match Self::fetch_dhcp(fingerprint.clone()) {
+                Ok((url, fp)) => (url, fp),
+                Err(err) => {
+                    info!("{err}");
+                    Self::fetch_dns(fingerprint.clone())?
+                }
+            };
+        }
+
+        if let Some(fingerprint) = &fingerprint {
+            let _ = fs::write("/tmp/cert_fingerprint", fingerprint);
+        }
+
+        info!("Gathering system information.");
+        let payload = SysInfo::as_json()?;
+        info!("Sending POST request to '{answer_url}'.");
+        let answer = http_post::call(answer_url, fingerprint.as_deref(), payload)?;
+        Ok(answer)
+    }
+
+    /// Fetches search domain from resolv.conf file
+    fn get_search_domain() -> Result<String> {
+        info!("Retrieving default search domain.");
+        for line in read_to_string("/etc/resolv.conf")?.lines() {
+            if let Some((key, value)) = line.split_once(' ') {
+                if key == "search" {
+                    return Ok(value.trim().into());
+                }
+            }
+        }
+        bail!("Could not find search domain in resolv.conf.");
+    }
+
+    /// Runs a TXT DNS query on the domain provided
+    fn query_txt_record(query: String) -> Result<String> {
+        info!("Querying TXT record for '{query}'");
+        let url: String;
+        match Command::new("dig")
+            .args(["txt", "+short"])
+            .arg(&query)
+            .output()
+        {
+            Ok(output) => {
+                if output.status.success() {
+                    url = String::from_utf8(output.stdout)?
+                        .replace('"', "")
+                        .trim()
+                        .into();
+                    if url.is_empty() {
+                        bail!("Got empty response.");
+                    }
+                } else {
+                    bail!(
+                        "Error querying DNS record '{query}' : {}",
+                        String::from_utf8(output.stderr)?
+                    );
+                }
+            }
+            Err(err) => bail!("Error querying DNS record '{query}': {err}"),
+        }
+        info!("Found: '{url}'");
+        Ok(url)
+    }
+
+    /// Tries to fetch answer URL and SSL fingerprint info from DNS
+    fn fetch_dns(mut fingerprint: Option<String>) -> Result<(String, Option<String>)> {
+        let search_domain = Self::get_search_domain()?;
+
+        let answer_url =
+            match Self::query_txt_record(format!("{ANSWER_URL_SUBDOMAIN}.{search_domain}")) {
+                Ok(url) => url,
+                Err(err) => bail!("{err}"),
+            };
+
+        if fingerprint.is_none() {
+            fingerprint =
+                match Self::query_txt_record(format!("{ANSWER_CERT_FP_SUBDOMAIN}.{search_domain}"))
+                {
+                    Ok(fp) => Some(fp),
+                    Err(err) => {
+                        info!("{err}");
+                        None
+                    }
+                };
+        }
+        Ok((answer_url, fingerprint))
+    }
+
+    /// Tries to fetch answer URL and SSL fingerprint info from DHCP options
+    fn fetch_dhcp(mut fingerprint: Option<String>) -> Result<(String, Option<String>)> {
+        info!("Checking DHCP options.");
+        let leases = fs::read_to_string(DHCP_LEASE_FILE)?;
+
+        let mut answer_url: Option<String> = None;
+
+        let url_match = format!("option {DHCP_URL_OPTION}");
+        let fp_match = format!("option {DHCP_CERT_FP_OPTION}");
+
+        for line in leases.lines() {
+            if answer_url.is_none() && line.trim().starts_with(url_match.as_str()) {
+                answer_url = Self::strip_dhcp_option(line.split(' ').nth_back(0));
+            }
+            if fingerprint.is_none() && line.trim().starts_with(fp_match.as_str()) {
+                fingerprint = Self::strip_dhcp_option(line.split(' ').nth_back(0));
+            }
+        }
+
+        let answer_url = match answer_url {
+            None => bail!("No DHCP option found for fetch URL."),
+            Some(url) => {
+                info!("Found URL for answer in DHCP option: '{url}'");
+                url
+            }
+        };
+
+        if let Some(fp) = fingerprint.clone() {
+            info!("Found SSL Fingerprint via DHCP: '{fp}'");
+        }
+
+        Ok((answer_url, fingerprint))
+    }
+
+    /// Clean DHCP option string
+    fn strip_dhcp_option(value: Option<&str>) -> Option<String> {
+        // value is expected to be in format: "value";
+        value.map(|value| String::from(&value[1..value.len() - 2]))
+    }
+}
+
+mod http_post {
+    use anyhow::Result;
+    use rustls::ClientConfig;
+    use sha2::{Digest, Sha256};
+    use std::sync::Arc;
+    use ureq::{Agent, AgentBuilder};
+
+    /// Issues a POST request with the payload (JSON). Optionally a SHA256 fingerprint can be used to
+    /// check the cert against it, instead of the regular cert validation.
+    /// To gather the sha256 fingerprint you can use the following command:
+    /// ```no_compile
+    /// openssl s_client -connect <host>:443 < /dev/null 2>/dev/null | openssl x509 -fingerprint -sha256  -noout -in /dev/stdin
+    /// ```
+    ///
+    /// # Arguemnts
+    /// * `url` - URL to call
+    /// * `fingerprint` - SHA256 cert fingerprint if certificate pinning should be used. Optional.
+    /// * `payload` - The payload to send to the server. Expected to be a JSON formatted string.
+    pub fn call(url: String, fingerprint: Option<&str>, payload: String) -> Result<String> {
+        let answer;
+
+        if let Some(fingerprint) = fingerprint {
+            let tls_config = ClientConfig::builder()
+                .with_safe_defaults()
+                .with_custom_certificate_verifier(VerifyCertFingerprint::new(fingerprint)?)
+                .with_no_client_auth();
+
+            let agent: Agent = AgentBuilder::new().tls_config(Arc::new(tls_config)).build();
+
+            answer = agent
+                .post(&url)
+                .set("Content-type", "application/json; charset=utf-")
+                .send_string(&payload)?
+                .into_string()?;
+        } else {
+            let mut roots = rustls::RootCertStore::empty();
+            for cert in rustls_native_certs::load_native_certs()? {
+                roots.add(&rustls::Certificate(cert.0)).unwrap();
+            }
+
+            let tls_config = rustls::ClientConfig::builder()
+                .with_safe_defaults()
+                .with_root_certificates(roots)
+                .with_no_client_auth();
+
+            let agent = AgentBuilder::new()
+                .tls_connector(Arc::new(native_tls::TlsConnector::new()?))
+                .tls_config(Arc::new(tls_config))
+                .build();
+            answer = agent
+                .post(&url)
+                .set("Content-type", "application/json; charset=utf-")
+                .timeout(std::time::Duration::from_secs(60))
+                .send_string(&payload)?
+                .into_string()?;
+        }
+        Ok(answer)
+    }
+
+    struct VerifyCertFingerprint {
+        cert_fingerprint: Vec<u8>,
+    }
+
+    impl VerifyCertFingerprint {
+        fn new<S: AsRef<str>>(cert_fingerprint: S) -> Result<std::sync::Arc<Self>> {
+            let cert_fingerprint = cert_fingerprint.as_ref();
+            let sanitized = cert_fingerprint.replace(':', "");
+            let decoded = hex::decode(sanitized)?;
+            Ok(std::sync::Arc::new(Self {
+                cert_fingerprint: decoded,
+            }))
+        }
+    }
+
+    impl rustls::client::ServerCertVerifier for VerifyCertFingerprint {
+        fn verify_server_cert(
+            &self,
+            end_entity: &rustls::Certificate,
+            _intermediates: &[rustls::Certificate],
+            _server_name: &rustls::ServerName,
+            _scts: &mut dyn Iterator<Item = &[u8]>,
+            _ocsp_response: &[u8],
+            _now: std::time::SystemTime,
+        ) -> Result<rustls::client::ServerCertVerified, rustls::Error> {
+            let mut hasher = Sha256::new();
+            hasher.update(end_entity);
+            let result = hasher.finalize();
+
+            if result.as_slice() == self.cert_fingerprint {
+                Ok(rustls::client::ServerCertVerified::assertion())
+            } else {
+                Err(rustls::Error::General("Fingerprint did not match!".into()))
+            }
+        }
+    }
+}
diff --git a/proxmox-fetch-answer/src/fetch_plugins/mod.rs b/proxmox-fetch-answer/src/fetch_plugins/mod.rs
new file mode 100644 (file)
index 0000000..1b433e4
--- /dev/null
@@ -0,0 +1,2 @@
+pub(crate) mod http;
+pub(crate) mod partition;
diff --git a/proxmox-fetch-answer/src/fetch_plugins/partition.rs b/proxmox-fetch-answer/src/fetch_plugins/partition.rs
new file mode 100644 (file)
index 0000000..0479c8f
--- /dev/null
@@ -0,0 +1,106 @@
+use anyhow::{bail, format_err, Result};
+use log::{info, warn};
+use std::{
+    fs::{self, create_dir_all},
+    path::{Path, PathBuf},
+    process::Command,
+};
+
+static ANSWER_FILE: &str = "answer.toml";
+static ANSWER_MP: &str = "/mnt/answer";
+// FAT can only handle 11 characters, so shorten Automated Installer Source to AIS
+static PARTLABEL: &str = "proxmox-ais";
+static DISK_BY_ID_PATH: &str = "/dev/disk/by-label";
+
+pub struct FetchFromPartition;
+
+impl FetchFromPartition {
+    /// Returns the contents of the answer file
+    pub fn get_answer() -> Result<String> {
+        info!("Checking for answer file on partition.");
+
+        let mut mount_path = PathBuf::from(mount_proxmoxinst_part()?);
+        mount_path.push(ANSWER_FILE);
+        let answer = fs::read_to_string(mount_path)
+            .map_err(|err| format_err!("failed to read answer file - {err}"))?;
+
+        info!("Found answer file on partition.");
+
+        Ok(answer)
+    }
+}
+
+fn path_exists_logged(file_name: &str, search_path: &str) -> Option<PathBuf> {
+    let path = Path::new(search_path).join(&file_name);
+    info!("Testing partition search path {path:?}");
+    match path.try_exists() {
+        Ok(true) => Some(path),
+        Ok(false) => None,
+        Err(err) => {
+            info!("Encountered issue, accessing '{path:?}': {err}");
+            None
+        }
+    }
+}
+
+/// Searches for upper and lower case existence of the partlabel in the search_path
+///
+/// # Arguemnts
+/// * `partlabel_source` - Partition Label, used as upper and lower case
+/// * `search_path` - Path where to search for the partiiton label
+fn scan_partlabels(partlabel: &str, search_path: &str) -> Result<PathBuf> {
+    let partlabel_upper_case = partlabel.to_uppercase();
+    if let Some(path) = path_exists_logged(&partlabel_upper_case, search_path) {
+            info!("Found partition with label '{partlabel_upper_case}'");
+            return Ok(path);
+    }
+
+    let partlabel_lower_case = partlabel.to_lowercase();
+    if let Some(path) = path_exists_logged(&partlabel_lower_case, search_path) {
+            info!("Found partition with label '{partlabel_lower_case}'");
+            return Ok(path);
+    }
+
+    bail!("Could not detect upper or lower case labels for '{partlabel}'");
+}
+
+/// Will search and mount a partition/FS labeled PARTLABEL (proxmox-ais) in lower or uppercase
+/// to ANSWER_MP
+fn mount_proxmoxinst_part() -> Result<String> {
+    if let Ok(true) = check_if_mounted(ANSWER_MP) {
+        info!("Skipping: '{ANSWER_MP}' is already mounted.");
+        return Ok(ANSWER_MP.into());
+    }
+    let part_path = scan_partlabels(PARTLABEL, DISK_BY_ID_PATH)?;
+    info!("Mounting partition at {ANSWER_MP}");
+    // create dir for mountpoint
+    create_dir_all(ANSWER_MP)?;
+    match Command::new("mount")
+        .args(["-o", "ro"])
+        .arg(part_path)
+        .arg(ANSWER_MP)
+        .output()
+    {
+        Ok(output) => {
+            if output.status.success() {
+                Ok(ANSWER_MP.into())
+            } else {
+                warn!("Error mounting: {}", String::from_utf8(output.stderr)?);
+                Ok(ANSWER_MP.into())
+            }
+        }
+        Err(err) => bail!("Error mounting: {err}"),
+    }
+}
+
+fn check_if_mounted(target_path: &str) -> Result<bool> {
+    let mounts = fs::read_to_string("/proc/mounts")?;
+    for line in mounts.lines() {
+        if let Some(mp) = line.split(' ').nth(1) {
+            if mp == target_path {
+                return Ok(true);
+            }
+        }
+    }
+    Ok(false)
+}
diff --git a/proxmox-fetch-answer/src/main.rs b/proxmox-fetch-answer/src/main.rs
new file mode 100644 (file)
index 0000000..660dc51
--- /dev/null
@@ -0,0 +1,108 @@
+use std::process::ExitCode;
+use std::{fs, path::PathBuf};
+
+use anyhow::{bail, format_err, Result};
+use log::{error, info, LevelFilter};
+
+use proxmox_auto_installer::{
+    log::AutoInstLogger,
+    utils::{AutoInstSettings, FetchAnswerFrom, HttpOptions},
+};
+
+use fetch_plugins::{http::FetchFromHTTP, partition::FetchFromPartition};
+
+mod fetch_plugins;
+
+static LOGGER: AutoInstLogger = AutoInstLogger;
+static AUTOINST_MODE_FILE: &str = "/cdrom/auto-installer-mode.toml";
+
+pub fn init_log() -> Result<()> {
+    AutoInstLogger::init("/tmp/fetch_answer.log")?;
+    log::set_logger(&LOGGER)
+        .map(|()| log::set_max_level(LevelFilter::Info))
+        .map_err(|err| format_err!(err))
+}
+
+fn fetch_answer(install_settings: &AutoInstSettings) -> Result<String> {
+    info!("Fetching answer file in mode {:?}:", &install_settings.mode);
+    match install_settings.mode {
+        FetchAnswerFrom::Iso => {
+            let answer_path = PathBuf::from("/cdrom/answer.toml");
+            match fs::read_to_string(answer_path) {
+                Ok(answer) => return Ok(answer),
+                Err(err) => info!("Fetching answer file from ISO failed: {err}"),
+            }
+        }
+        FetchAnswerFrom::Partition => match FetchFromPartition::get_answer() {
+            Ok(answer) => return Ok(answer),
+            Err(err) => info!("Fetching answer file from partition failed: {err}"),
+        },
+        FetchAnswerFrom::Http => match FetchFromHTTP::get_answer(&install_settings.http) {
+            Ok(answer) => return Ok(answer),
+            Err(err) => info!("Fetching answer file via HTTP failed: {err}"),
+        },
+    }
+    bail!("Could not find any answer file!");
+}
+
+fn settings_from_cli_args(args: &[String]) -> Result<AutoInstSettings> {
+    // TODO: this was done in a bit of a hurry, needs tidying up
+    let mode = match args[1].to_lowercase().as_str() {
+        "iso" => FetchAnswerFrom::Iso,
+        "http" => FetchAnswerFrom::Http,
+        "partition" => FetchAnswerFrom::Partition,
+        "-h" | "--help" => bail!(
+            "usage: {} <http|iso|partition> [<http-url>] [<tls-cert-fingerprint>]",
+            args[0]
+        ),
+        _ => bail!("failed to parse fetch-from argument, not one of 'http', 'iso', or 'partition'"),
+    };
+    if args.len() > 4 {
+    } else if args.len() > 2 && mode != FetchAnswerFrom::Http {
+        bail!("only 'http' fetch-from mode supports additional url and cert-fingerprint mode");
+    }
+    Ok(AutoInstSettings {
+        mode,
+        http: HttpOptions {
+            url: args.get(2).cloned(),
+            cert_fingerprint: args.get(3).cloned(),
+        },
+    })
+}
+
+fn do_main() -> Result<()> {
+    if let Err(err) = init_log() {
+        bail!("could not initialize logging: {err}");
+    }
+
+    let args: Vec<String> = std::env::args().collect();
+
+    let install_settings: AutoInstSettings = if args.len() > 1 {
+        settings_from_cli_args(&args)?
+    } else {
+        let raw_install_settings = fs::read_to_string(AUTOINST_MODE_FILE).map_err(|err| {
+            format_err!(
+                "Could not find needed file '{AUTOINST_MODE_FILE}' in live environment: {err}"
+            )
+        })?;
+        toml::from_str(raw_install_settings.as_str())
+            .map_err(|err| format_err!("Failed to parse '{AUTOINST_MODE_FILE}': {err}"))?
+    };
+
+    let answer = fetch_answer(&install_settings).map_err(|err| format_err!("Aborting: {err}"))?;
+    info!("queried answer file for automatic installation successfully");
+
+    println!("{answer}");
+
+    Ok(())
+}
+
+fn main() -> ExitCode {
+    match do_main() {
+        Ok(()) => ExitCode::SUCCESS,
+        Err(err) => {
+            error!("{err}");
+            ExitCode::FAILURE
+        }
+    }
+}
index bde5457d0cd4c510351f2ba760b99c3de1ffebe8..70f828acde96bfc3b64173f6b2e89a34d3b242c8 100644 (file)
@@ -8,5 +8,6 @@ exclude = [ "build", "debian" ]
 homepage = "https://www.proxmox.com"
 
 [dependencies]
+regex = "1.7"
 serde = { version = "1.0", features = ["derive"] }
 serde_json = "1.0"
index 7cbdf1933846125e26c838def3665a234f1d6846..89b300cb055d1bbd94df40ca9fd3505334da17e9 100644 (file)
@@ -42,9 +42,7 @@ pub fn check_raid_min_disks(disks: &[Disk], min: usize) -> Result<(), String> {
 /// * `runinfo` - `RuntimeInfo` instance of currently running system
 /// * `disks` - List of disks designated as bootdisk targets.
 pub fn check_disks_4kn_legacy_boot(boot_type: BootType, disks: &[Disk]) -> Result<(), &str> {
-    let is_blocksize_4096 = |disk: &Disk| disk.block_size.map(|s| s == 4096).unwrap_or(false);
-
-    if boot_type == BootType::Bios && disks.iter().any(is_blocksize_4096) {
+    if boot_type == BootType::Bios && disks.iter().any(|disk| disk.block_size == Some(4096)) {
         return Err("Booting from 4Kn drive in legacy BIOS mode is not supported.");
     }
 
index 1aa8f657319291086f2b0bcf40cb69aebdc3e01f..e77914b8693036350480304d0e3057230c1b32fc 100644 (file)
@@ -1,3 +1,4 @@
+use serde::Deserialize;
 use std::net::{IpAddr, Ipv4Addr};
 use std::{cmp, fmt};
 
@@ -6,7 +7,8 @@ use crate::setup::{
 };
 use crate::utils::{CidrAddress, Fqdn};
 
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "lowercase")]
 pub enum BtrfsRaidLevel {
     Raid0,
     Raid1,
@@ -24,13 +26,17 @@ impl fmt::Display for BtrfsRaidLevel {
     }
 }
 
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "lowercase")]
 pub enum ZfsRaidLevel {
     Raid0,
     Raid1,
     Raid10,
+    #[serde(rename = "raidz-1")]
     RaidZ,
+    #[serde(rename = "raidz-2")]
     RaidZ2,
+    #[serde(rename = "raidz-3")]
     RaidZ3,
 }
 
@@ -48,7 +54,8 @@ impl fmt::Display for ZfsRaidLevel {
     }
 }
 
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "lowercase")]
 pub enum FsType {
     Ext4,
     Xfs,
@@ -112,7 +119,8 @@ impl BtrfsBootdiskOptions {
     }
 }
 
-#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq)]
+#[serde(rename_all(deserialize = "lowercase"))]
 pub enum ZfsCompressOption {
     #[default]
     On,
@@ -141,7 +149,8 @@ pub const ZFS_COMPRESS_OPTIONS: &[ZfsCompressOption] = {
     &[On, Off, Lzjb, Lz4, Zle, Gzip, Zstd]
 };
 
-#[derive(Copy, Clone, Debug, Default, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, Default, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "kebab-case")]
 pub enum ZfsChecksumOption {
     #[default]
     On,
@@ -221,7 +230,7 @@ pub enum AdvancedBootdiskOptions {
     Btrfs(BtrfsBootdiskOptions),
 }
 
-#[derive(Clone, Debug, PartialEq)]
+#[derive(Clone, Debug, Deserialize, PartialEq)]
 pub struct Disk {
     pub index: String,
     pub path: String,
@@ -253,7 +262,7 @@ 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)
+        Some(self.cmp(other))
     }
 }
 
@@ -294,7 +303,7 @@ impl TimezoneOptions {
         let timezone = locales
             .cczones
             .get(&country)
-            .and_then(|zones| zones.get(0))
+            .and_then(|zones| zones.first())
             .cloned()
             .unwrap_or_else(|| "UTC".to_owned());
 
@@ -343,7 +352,7 @@ pub struct NetworkOptions {
 }
 
 impl NetworkOptions {
-    const DEFAULT_DOMAIN: &str = "example.invalid";
+    const DEFAULT_DOMAIN: &'static str = "example.invalid";
 
     pub fn defaults_from(setup: &SetupInfo, network: &NetworkInfo) -> Self {
         let mut this = Self {
@@ -363,7 +372,7 @@ impl NetworkOptions {
             let mut filled = false;
             if let Some(gw) = &routes.gateway4 {
                 if let Some(iface) = network.interfaces.get(&gw.dev) {
-                    this.ifname = iface.name.clone();
+                    this.ifname.clone_from(&iface.name);
                     if let Some(addresses) = &iface.addresses {
                         if let Some(addr) = addresses.iter().find(|addr| addr.is_ipv4()) {
                             this.gateway = gw.gateway;
@@ -378,7 +387,7 @@ impl NetworkOptions {
                     if let Some(iface) = network.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.ifname.clone_from(&iface.name);
                                 this.gateway = gw.gateway;
                                 this.address = addr.clone();
                             }
index 472e1f201bb3241a19727285f3e7b2fb8e89ce75..64d05afc737e66cf608e53c7e582cddaafa17994 100644 (file)
@@ -1,6 +1,6 @@
 use std::{
     cmp,
-    collections::HashMap,
+    collections::{BTreeMap, HashMap},
     fmt,
     fs::File,
     io::{self, BufReader},
@@ -12,12 +12,15 @@ use std::{
 use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
 
 use crate::{
-    options::{Disk, ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption},
+    options::{
+        BtrfsRaidLevel, Disk, FsType, ZfsBootdiskOptions, ZfsChecksumOption, ZfsCompressOption,
+        ZfsRaidLevel,
+    },
     utils::CidrAddress,
 };
 
 #[allow(clippy::upper_case_acronyms)]
-#[derive(Clone, Copy, Deserialize, PartialEq)]
+#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Serialize)]
 #[serde(rename_all = "lowercase")]
 pub enum ProxmoxProduct {
     PVE,
@@ -35,7 +38,17 @@ impl ProxmoxProduct {
     }
 }
 
-#[derive(Clone, Deserialize)]
+impl fmt::Display for ProxmoxProduct {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        f.write_str(match self {
+            Self::PVE => "pve",
+            Self::PMG => "pmg",
+            Self::PBS => "pbs",
+        })
+    }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
 pub struct ProductConfig {
     pub fullname: String,
     pub product: ProxmoxProduct,
@@ -43,18 +56,48 @@ pub struct ProductConfig {
     pub enable_btrfs: bool,
 }
 
-#[derive(Clone, Deserialize)]
+impl ProductConfig {
+    /// A mocked ProductConfig simulating a Proxmox VE environment.
+    pub fn mocked() -> Self {
+        Self {
+            fullname: String::from("Proxmox VE (mocked)"),
+            product: ProxmoxProduct::PVE,
+            enable_btrfs: true,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
 pub struct IsoInfo {
     pub release: String,
     pub isorelease: String,
 }
 
+impl IsoInfo {
+    /// A mocked IsoInfo with some edge case to convey that this is not necessarily purely numeric.
+    pub fn mocked() -> Self {
+        Self {
+            release: String::from("42.1"),
+            isorelease: String::from("mocked-1"),
+        }
+    }
+}
+
 /// Paths in the ISO environment containing installer data.
 #[derive(Clone, Deserialize)]
 pub struct IsoLocations {
     pub iso: PathBuf,
 }
 
+impl IsoLocations {
+    /// A mocked location, uses the current working directory by default
+    pub fn mocked() -> Self {
+        Self {
+            iso: std::env::current_dir().unwrap_or("/dev/null".into()),
+        }
+    }
+}
+
 #[derive(Clone, Deserialize)]
 pub struct SetupInfo {
     #[serde(rename = "product-cfg")]
@@ -64,6 +107,18 @@ pub struct SetupInfo {
     pub locations: IsoLocations,
 }
 
+impl SetupInfo {
+    /// Return a mocked SetupInfo that is very similar to how our actual ones look like and should
+    /// be good enough for testing.
+    pub fn mocked() -> Self {
+        Self {
+            config: ProductConfig::mocked(),
+            iso_info: IsoInfo::mocked(),
+            locations: IsoLocations::mocked(),
+        }
+    }
+}
+
 #[derive(Clone, Deserialize)]
 pub struct CountryInfo {
     pub name: String,
@@ -85,7 +140,7 @@ pub struct KeyboardMapping {
 
 impl cmp::PartialOrd for KeyboardMapping {
     fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
-        self.name.partial_cmp(&other.name)
+        Some(self.cmp(other))
     }
 }
 
@@ -142,15 +197,15 @@ pub fn installer_setup(in_test_mode: bool) -> Result<(SetupInfo, LocaleInfo, Run
     }
 }
 
-#[derive(Serialize)]
+#[derive(Debug, Deserialize, Serialize)]
 pub struct InstallZfsOption {
-    ashift: usize,
+    pub ashift: usize,
     #[serde(serialize_with = "serialize_as_display")]
-    compress: ZfsCompressOption,
+    pub compress: ZfsCompressOption,
     #[serde(serialize_with = "serialize_as_display")]
-    checksum: ZfsChecksumOption,
-    copies: usize,
-    arc_max: usize,
+    pub checksum: ZfsChecksumOption,
+    pub copies: usize,
+    pub arc_max: usize,
 }
 
 impl From<ZfsBootdiskOptions> for InstallZfsOption {
@@ -294,7 +349,7 @@ pub struct NetworkInfo {
     /// 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: HashMap<String, Interface>,
+    pub interfaces: BTreeMap<String, Interface>,
 
     /// The hostname of this machine, if set by the DHCP server.
     pub hostname: Option<String>,
@@ -387,3 +442,119 @@ pub fn spawn_low_level_installer(test_mode: bool) -> io::Result<process::Child>
         .stdout(Stdio::piped())
         .spawn()
 }
+
+/// See Proxmox::Install::Config
+#[derive(Debug, Deserialize, Serialize)]
+pub struct InstallConfig {
+    pub autoreboot: usize,
+
+    #[serde(
+        serialize_with = "serialize_fstype",
+        deserialize_with = "deserialize_fs_type"
+    )]
+    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",
+        // only the 'path' property is serialized -> deserialization is problematic
+        // The information would be present in the 'run-env-info-json', but for now there is no
+        // need for it in any code that deserializes the low-level config. Therefore we are
+        // currently skipping it on deserialization
+        skip_deserializing
+    )]
+    pub target_hd: Option<Disk>,
+    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
+    pub disk_selection: BTreeMap<String, String>,
+
+    pub lvm_auto_rename: usize,
+
+    pub country: String,
+    pub timezone: String,
+    pub keymap: String,
+
+    pub password: String,
+    pub mailto: String,
+    #[serde(skip_serializing_if = "Vec::is_empty")]
+    pub root_ssh_keys: Vec<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,
+}
+
+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)
+}
+
+pub fn deserialize_fs_type<'de, D>(deserializer: D) -> Result<FsType, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    use FsType::*;
+    let de_fs: String = Deserialize::deserialize(deserializer)?;
+
+    match de_fs.as_str() {
+        "ext4" => Ok(Ext4),
+        "xfs" => Ok(Xfs),
+        "zfs (RAID0)" => Ok(Zfs(ZfsRaidLevel::Raid0)),
+        "zfs (RAID1)" => Ok(Zfs(ZfsRaidLevel::Raid1)),
+        "zfs (RAID10)" => Ok(Zfs(ZfsRaidLevel::Raid10)),
+        "zfs (RAIDZ-1)" => Ok(Zfs(ZfsRaidLevel::RaidZ)),
+        "zfs (RAIDZ-2)" => Ok(Zfs(ZfsRaidLevel::RaidZ2)),
+        "zfs (RAIDZ-3)" => Ok(Zfs(ZfsRaidLevel::RaidZ3)),
+        "btrfs (RAID0)" => Ok(Btrfs(BtrfsRaidLevel::Raid0)),
+        "btrfs (RAID1)" => Ok(Btrfs(BtrfsRaidLevel::Raid1)),
+        "btrfs (RAID10)" => Ok(Btrfs(BtrfsRaidLevel::Raid10)),
+        _ => Err(de::Error::custom("could not find file system: {de_fs}")),
+    }
+}
index 36b1d538f3d4888cb224bbfe4b9c5473b06ff6fc..57b175321c8cba89db7c6054f73898ff68c3adb6 100644 (file)
@@ -103,6 +103,17 @@ impl fmt::Display for CidrAddress {
     }
 }
 
+impl<'de> Deserialize<'de> for CidrAddress {
+    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 CIDR"))
+    }
+}
+
 fn mask_limit(addr: &IpAddr) -> usize {
     if addr.is_ipv4() {
         32
@@ -211,7 +222,7 @@ impl Fqdn {
         self.parts.len() > 1
     }
 
-    fn validate_single(s: &String) -> bool {
+    fn validate_single(s: &str) -> bool {
         !s.is_empty()
             && s.len() <= Self::MAX_LABEL_LENGTH
             // First character must be alphanumeric
@@ -336,7 +347,10 @@ mod tests {
         assert_eq!(Fqdn::from("example.com"), Fqdn::from("example.com"));
         assert_eq!(Fqdn::from("example.com"), Fqdn::from("ExAmPle.Com"));
         assert_eq!(Fqdn::from("ExAmPle.Com"), Fqdn::from("example.com"));
-        assert_ne!(Fqdn::from("subdomain.ExAmPle.Com"), Fqdn::from("example.com"));
+        assert_ne!(
+            Fqdn::from("subdomain.ExAmPle.Com"),
+            Fqdn::from("example.com")
+        );
         assert_ne!(Fqdn::from("foo.com"), Fqdn::from("bar.com"));
         assert_ne!(Fqdn::from("example.com"), Fqdn::from("example.net"));
     }
index 28482959ae0f96c63b54f96365774110e7f50598..e5c2908c3a2b06b9d3d59d3bd4a252a1d0195c63 100755 (executable)
@@ -21,6 +21,7 @@ use Time::HiRes qw(usleep);
 
 use Proxmox::Install::ISOEnv;
 use Proxmox::Install::RunEnv;
+use Proxmox::Sys::Udev;
 
 use Proxmox::Sys::File qw(file_write_all);
 
@@ -31,6 +32,7 @@ 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.',
@@ -67,6 +69,7 @@ sub read_and_merge_config {
 
     Proxmox::Install::Config::merge($config);
     log_info("got installation config: ". to_json(Proxmox::Install::Config::get(), { utf8 => 1, canonical => 1 }) ."\n");
+    file_write_all("/tmp/low-level-config.json", to_json(Proxmox::Install::Config::get()));
 }
 
 sub send_reboot_ui_message {
@@ -115,6 +118,18 @@ 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 = {
+       disks => Proxmox::Sys::Block::udevadm_disk_details(),
+       nics => Proxmox::Sys::Net::udevadm_netdev_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);
     read_and_merge_config();
index 2462a58f56783fab51159e7c009c1196c3c080c8..4fb7afd8efdac67bb96f36fdf1a55934559c562b 100644 (file)
@@ -664,9 +664,6 @@ fn summary_dialog(siv: &mut Cursive) -> InstallerView {
 }
 
 fn install_progress_dialog(siv: &mut Cursive) -> InstallerView {
-    // Ensure the screen is updated independently of keyboard events and such
-    siv.set_autorefresh(true);
-
     let state = siv.user_data::<InstallerState>().cloned().unwrap();
     InstallerView::with_raw(&state, InstallProgressView::new(siv))
 }
index 094a430085f4d4e6c63319db526691f49683f8c6..73fbf2ab1f50557338cfcd443a8b314ce64adb0e 100644 (file)
@@ -76,7 +76,7 @@ mod tests {
         utils::{CidrAddress, Fqdn},
     };
     use std::net::{IpAddr, Ipv4Addr};
-    use std::{collections::HashMap, path::PathBuf};
+    use std::{collections::BTreeMap, path::PathBuf};
 
     fn dummy_setup_info() -> SetupInfo {
         SetupInfo {
@@ -99,7 +99,7 @@ mod tests {
     fn network_options_from_setup_network_info() {
         let setup = dummy_setup_info();
 
-        let mut interfaces = HashMap::new();
+        let mut interfaces = BTreeMap::new();
         interfaces.insert(
             "eth0".to_owned(),
             Interface {
index 79421d78158a5f10ae45caf62914ecd90b4db912..8c01e4216329549c316164f81f5953cdb2f4f040 100644 (file)
@@ -1,58 +1,7 @@
-use std::{collections::HashMap, fmt, net::IpAddr};
-
-use serde::{Serialize, Serializer};
+use std::collections::BTreeMap;
 
 use crate::options::InstallerOptions;
-use proxmox_installer_common::{
-    options::{AdvancedBootdiskOptions, BtrfsRaidLevel, Disk, FsType, ZfsRaidLevel},
-    setup::InstallZfsOption,
-    utils::CidrAddress,
-};
-
-/// See Proxmox::Install::Config
-#[derive(Serialize)]
-pub struct InstallConfig {
-    autoreboot: usize,
-
-    #[serde(serialize_with = "serialize_fstype")]
-    filesys: FsType,
-    hdsize: f64,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    swapsize: Option<f64>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    maxroot: Option<f64>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    minfree: Option<f64>,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    maxvz: Option<f64>,
-
-    #[serde(skip_serializing_if = "Option::is_none")]
-    zfs_opts: Option<InstallZfsOption>,
-
-    #[serde(
-        serialize_with = "serialize_disk_opt",
-        skip_serializing_if = "Option::is_none"
-    )]
-    target_hd: Option<Disk>,
-    #[serde(skip_serializing_if = "HashMap::is_empty")]
-    disk_selection: HashMap<String, String>,
-
-    country: String,
-    timezone: String,
-    keymap: String,
-
-    password: String,
-    mailto: String,
-
-    mngmt_nic: String,
-
-    hostname: String,
-    domain: String,
-    #[serde(serialize_with = "serialize_as_display")]
-    cidr: CidrAddress,
-    gateway: IpAddr,
-    dns: IpAddr,
-}
+use proxmox_installer_common::{options::AdvancedBootdiskOptions, setup::InstallConfig};
 
 impl From<InstallerOptions> for InstallConfig {
     fn from(options: InstallerOptions) -> Self {
@@ -67,7 +16,8 @@ impl From<InstallerOptions> for InstallConfig {
             maxvz: None,
             zfs_opts: None,
             target_hd: None,
-            disk_selection: HashMap::new(),
+            disk_selection: BTreeMap::new(),
+            lvm_auto_rename: 0,
 
             country: options.timezone.country,
             timezone: options.timezone.timezone,
@@ -75,6 +25,7 @@ impl From<InstallerOptions> for InstallConfig {
 
             password: options.password.root_password,
             mailto: options.password.email,
+            root_ssh_keys: vec![],
 
             mngmt_nic: options.network.ifname,
 
@@ -121,47 +72,3 @@ impl From<InstallerOptions> for InstallConfig {
         config
     }
 }
-
-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)
-}
index 7e13e91f7adf88d2065b85d17e3d1972e2ac9478..f6fdb31a7ad8be5a55bf000fb0b2194e103768fb 100644 (file)
@@ -592,7 +592,10 @@ impl ZfsBootdiskOptionsView {
                             .unwrap_or_default(),
                     ),
             )
-            .child("copies", IntegerEditView::new().content(options.copies).max_value(3))
+            .child(
+                "copies",
+                IntegerEditView::new().content(options.copies).max_value(3),
+            )
             .child_conditional(
                 is_pve,
                 "ARC max size",
index c2c9ddf926a32e933b37290f5c1c31f7a401727a..6453426d3297a3ffe757c2040e9c52efc28792ee 100644 (file)
@@ -1,7 +1,7 @@
 use cursive::{
     utils::Counter,
     view::{Nameable, Resizable, ViewWrapper},
-    views::{Dialog, DummyView, LinearLayout, PaddedView, ProgressBar, TextContent, TextView},
+    views::{Dialog, DummyView, LinearLayout, PaddedView, ProgressBar, TextView},
     CbSink, Cursive,
 };
 use serde::Deserialize;
@@ -13,23 +13,23 @@ use std::{
     time::Duration,
 };
 
-use crate::{abort_install_button, prompt_dialog, setup::InstallConfig, InstallerState};
-use proxmox_installer_common::setup::spawn_low_level_installer;
+use crate::{abort_install_button, prompt_dialog, InstallerState};
+use proxmox_installer_common::setup::{spawn_low_level_installer, InstallConfig};
 
 pub struct InstallProgressView {
     view: PaddedView<LinearLayout>,
 }
 
 impl InstallProgressView {
+    const PROGRESS_TEXT_VIEW_ID: &str = "progress-text";
+
     pub fn new(siv: &mut Cursive) -> Self {
         let cb_sink = siv.cb_sink().clone();
         let state = siv.user_data::<InstallerState>().unwrap();
-        let progress_text = TextContent::new("starting the installation ..");
 
         let progress_task = {
-            let progress_text = progress_text.clone();
             let state = state.clone();
-            move |counter: Counter| Self::progress_task(counter, cb_sink, state, progress_text)
+            move |counter: Counter| Self::progress_task(counter, cb_sink, state)
         };
 
         let progress_bar = ProgressBar::new().with_task(progress_task).full_width();
@@ -41,7 +41,11 @@ impl InstallProgressView {
             LinearLayout::vertical()
                 .child(PaddedView::lrtb(1, 1, 0, 0, progress_bar))
                 .child(DummyView)
-                .child(TextView::new_with_content(progress_text).center())
+                .child(
+                    TextView::new("starting the installation ..")
+                        .center()
+                        .with_name(Self::PROGRESS_TEXT_VIEW_ID),
+                )
                 .child(PaddedView::lrtb(
                     1,
                     1,
@@ -54,12 +58,7 @@ impl InstallProgressView {
         Self { view }
     }
 
-    fn progress_task(
-        counter: Counter,
-        cb_sink: CbSink,
-        state: InstallerState,
-        progress_text: TextContent,
-    ) {
+    fn progress_task(counter: Counter, cb_sink: CbSink, state: InstallerState) {
         let mut child = match spawn_low_level_installer(state.in_test_mode) {
             Ok(child) => child,
             Err(err) => {
@@ -129,13 +128,18 @@ impl InstallProgressView {
                     }),
                     UiMessage::Progress { ratio, text } => {
                         counter.set((ratio * 100.).floor() as usize);
-                        progress_text.set_content(text);
-                        Ok(())
+                        cb_sink.send(Box::new(move |siv| {
+                            siv.call_on_name(Self::PROGRESS_TEXT_VIEW_ID, |v: &mut TextView| {
+                                v.set_content(text);
+                            });
+                        }))
                     }
                     UiMessage::Finished { state, message } => {
                         counter.set(100);
-                        progress_text.set_content(message.to_owned());
                         cb_sink.send(Box::new(move |siv| {
+                            siv.call_on_name(Self::PROGRESS_TEXT_VIEW_ID, |v: &mut TextView| {
+                                v.set_content(&message);
+                            });
                             Self::prepare_for_reboot(siv, state == "ok", &message);
                         }))
                     }
index 2b371f089fe3594a6acf2e3ea413e05fc324a4df..a39e6ad774baf8a4949c4aa8e47531dd8fd8c01a 100755 (executable)
@@ -5,18 +5,22 @@ trap "err_reboot" ERR
 # NOTE: we nowadays get exec'd by the initrd's PID 1, so we're the new PID 1
 
 parse_cmdline() {
+    start_auto_installer=0
     proxdebug=0
     proxtui=0
     serial=0
     # shellcheck disable=SC2013 # per word splitting is wanted here
     for par in $(cat /proc/cmdline); do
         case $par in
-            proxdebug)
+            proxdebug|proxmox-debug)
                 proxdebug=1
             ;;
-            proxtui)
+            proxtui|proxmox-tui-mode)
                 proxtui=1
             ;;
+            proxauto|proxmox-start-auto-installer)
+                start_auto_installer=1
+            ;;
             console=ttyS*)
                 serial=1
             ;;
@@ -208,6 +212,16 @@ if [ $proxdebug -ne 0 ]; then
     debugsh || true
 fi
 
+# add custom DHCP options for auto installer
+if [ $start_auto_installer -ne 0 ]; then
+    echo "Preparing DHCP as potential source to get location of automatic-installation answer file"
+    cat >> /etc/dhcp/dhclient.conf <<EOF
+option proxmox-auto-installer-manifest-url code 250 = text;
+option proxmox-auto-installer-cert-fingerprint code 251 = text;
+also request proxmox-auto-installer-manifest-url, proxmox-auto-installer-cert-fingerprint;
+EOF
+fi
+
 # try to get ip config with dhcp
 echo -n "Attempting to get DHCP leases... "
 dhclient -v
@@ -224,6 +238,22 @@ setsid /sbin/agetty -a root --noclear tty3 &
 if [ $proxtui -ne 0 ]; then
     echo "Starting the TUI installer"
     /usr/bin/proxmox-tui-installer 2>/dev/tty2
+elif [ $start_auto_installer -ne 0 ]; then
+    echo "Caching device info from udev"
+    /usr/bin/proxmox-low-level-installer dump-udev
+
+    if [ -f /cdrom/auto-installer-mode.toml ]; then
+        echo "Fetching answers for automatic installation"
+        /usr/bin/proxmox-fetch-answer >/run/automatic-installer-answers
+    else
+        printf "\nAutomatic installation selected but no config for fetching the answer file found!\n"
+        echo "Starting debug shell, to fetch the answer file manually use:"
+        echo "  proxmox-fetch-answer MODE >/run/automatic-installer-answers"
+        echo "and enter 'exit' or press 'CTRL' + 'D' when finished."
+        debugsh || true
+    fi
+    echo "Starting automatic installation"
+    /usr/bin/proxmox-auto-installer </run/automatic-installer-answers
 else
     echo "Starting the installer GUI - see tty2 (CTRL+ALT+F2) for any errors..."
     xinit -- -dpi "$DPI" -s 0 >/dev/tty2 2>&1