From 67089f146e1d46783928322b2cde6abaca2fba25 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 29 May 2023 16:15:57 +0200 Subject: [PATCH 001/398] update/extend lintian-overrides for debhelper 13+ Signed-off-by: Thomas Lamprecht --- debian/lintian-overrides | 10 +++++----- debian/source/lintian-overrides | 4 ++++ 2 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 debian/source/lintian-overrides diff --git a/debian/lintian-overrides b/debian/lintian-overrides index e30e70547..fb9cc49de 100644 --- a/debian/lintian-overrides +++ b/debian/lintian-overrides @@ -1,7 +1,7 @@ pve-manager: mail-transport-agent-dependency-does-not-specify-default-mta * -pve-manager: no-manual-page usr/bin/pvebanner -pve-manager: no-manual-page usr/bin/pveupdate -pve-manager: non-standard-dir-perm var/log/pveproxy/ 0700 != 0755 -pve-manager: package-installs-apt-sources etc/apt/sources.list.d/pve-enterprise.list +pve-manager: no-manual-page [usr/bin/pvebanner] +pve-manager: no-manual-page [usr/bin/pveupdate] +pve-manager: non-standard-dir-perm 0700 != 0755 [var/log/pveproxy/] +pve-manager: systemd-service-file-refers-to-unusual-wantedby-target getty.target [lib/systemd/system/pvebanner.service] +pve-manager: package-installs-apt-sources [etc/apt/sources.list.d/pve-enterprise.list] pve-manager: privacy-breach-generic usr/share/pve-manager/touch/sencha-touch-all-debug.js * -pve-manager: systemd-service-file-refers-to-unusual-wantedby-target lib/systemd/system/pvebanner.service getty.target diff --git a/debian/source/lintian-overrides b/debian/source/lintian-overrides new file mode 100644 index 000000000..e4dccda09 --- /dev/null +++ b/debian/source/lintian-overrides @@ -0,0 +1,4 @@ +# for now we manage those ourselves, with modern dh_system it might be possible to avoid that though +pve-manager source: maintainer-script-lacks-debhelper-token [debian/postinst] +pve-manager source: maintainer-script-lacks-debhelper-token [debian/prerm] + From 70d32783636a63aa42d967067c01d97a1384e260 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 29 May 2023 17:40:03 +0200 Subject: [PATCH 002/398] d/control: define compat level via build-depends and raise to 13 Signed-off-by: Thomas Lamprecht --- debian/compat | 1 - debian/control | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 debian/compat diff --git a/debian/compat b/debian/compat deleted file mode 100644 index 48082f72f..000000000 --- a/debian/compat +++ /dev/null @@ -1 +0,0 @@ -12 diff --git a/debian/control b/debian/control index 21dfb29f3..a34ad80ce 100644 --- a/debian/control +++ b/debian/control @@ -1,7 +1,7 @@ Source: pve-manager Section: admin Priority: optional -Build-Depends: debhelper (>= 12~), +Build-Depends: debhelper-compat (= 13), libapt-pkg-perl, libfile-readbackwards-perl, libhttp-daemon-perl, From 39075150e1f36b058811cded3572ec6d0ca0bbe6 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 29 May 2023 17:04:59 +0200 Subject: [PATCH 003/398] bump version to 8.0.0~1 Signed-off-by: Thomas Lamprecht --- debian/changelog | 8 ++++++++ debian/source/format | 1 + 2 files changed, 9 insertions(+) create mode 100644 debian/source/format diff --git a/debian/changelog b/debian/changelog index ccb01abc2..4ff86fcb3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +pve-manager (8.0.0~1) bookworm; urgency=medium + + * switch to native versioning scheme + + * build for Proxmox VE 8 release series, based on Debian 12 Bookworm + + -- Proxmox Support Team Mon, 29 May 2023 15:57:33 +0200 + pve-manager (7.4-4) bullseye; urgency=medium * pvereport: add `date -R` to general system info section diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 000000000..89ae9db8f --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) From 03e1cd62c39e30bd7172a16b3015897acb709f26 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 30 May 2023 15:15:06 +0200 Subject: [PATCH 004/398] pve sources: update dist to bookworm Signed-off-by: Thomas Lamprecht --- configs/pve-sources.list | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/pve-sources.list b/configs/pve-sources.list index 9a763cbcd..010385a97 100644 --- a/configs/pve-sources.list +++ b/configs/pve-sources.list @@ -1 +1 @@ -deb https://enterprise.proxmox.com/debian/pve bullseye pve-enterprise +deb https://enterprise.proxmox.com/debian/pve bookworm pve-enterprise From 74ea1fb2575dfae2977f564e5b37bba4f1a8673f Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 30 May 2023 15:15:30 +0200 Subject: [PATCH 005/398] pveceph: update Ceph releases and repo dist for Bookworm We'll only support Quincy and newer in Bookworm, and Reef isn't yet released. Signed-off-by: Thomas Lamprecht --- PVE/CLI/pveceph.pm | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/PVE/CLI/pveceph.pm b/PVE/CLI/pveceph.pm index 52c916298..e24f5fcaf 100755 --- a/PVE/CLI/pveceph.pm +++ b/PVE/CLI/pveceph.pm @@ -106,8 +106,8 @@ __PACKAGE__->register_method ({ return undef; }}); -my $supported_ceph_versions = ['octopus', 'pacific', 'quincy']; -my $default_ceph_version = 'pacific'; +my $supported_ceph_versions = ['quincy']; +my $default_ceph_version = 'quincy'; __PACKAGE__->register_method ({ name => 'install', @@ -147,13 +147,8 @@ __PACKAGE__->register_method ({ my $repo = $param->{'test-repository'} ? 'test' : 'main'; my $repolist; - if ($cephver eq 'octopus') { - warn "Ceph Octopus will go EOL after 2022-07\n"; - $repolist = "deb http://download.proxmox.com/debian/ceph-octopus bullseye $repo\n"; - } elsif ($cephver eq 'pacific') { - $repolist = "deb http://download.proxmox.com/debian/ceph-pacific bullseye $repo\n"; - } elsif ($cephver eq 'quincy') { - $repolist = "deb http://download.proxmox.com/debian/ceph-quincy bullseye $repo\n"; + if ($cephver eq 'quincy') { + $repolist = "deb http://download.proxmox.com/debian/ceph-quincy bookworm $repo\n"; } else { die "unsupported ceph version: $cephver"; } From ddd8927990b2da79c52f03527fdbc29101a340fc Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 30 May 2023 15:17:05 +0200 Subject: [PATCH 006/398] pveceph: drop special for octopus and pacific, unsupported in 8.x sort the package list while at it Signed-off-by: Thomas Lamprecht --- PVE/CLI/pveceph.pm | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/PVE/CLI/pveceph.pm b/PVE/CLI/pveceph.pm index e24f5fcaf..3eeabf8ab 100755 --- a/PVE/CLI/pveceph.pm +++ b/PVE/CLI/pveceph.pm @@ -171,18 +171,13 @@ __PACKAGE__->register_method ({ my @ceph_packages = qw( ceph ceph-common - ceph-mds ceph-fuse + ceph-mds + ceph-volume gdisk nvme-cli ); - # got split out with quincy and is required by PVE tooling, conditionally exclude it for older - # FIXME: remove condition with PVE 8.0, i.e., once we only support quincy+ new installations - if ($cephver ne 'octopus' and $cephver ne 'pacific') { - push @ceph_packages, 'ceph-volume'; - } - print "start installation\n"; # this flag helps to determine when apt is actually done installing (vs. partial extracing) From 40d819c7bfbdebdd16380ea93f50335e34d27d9b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 30 May 2023 15:19:29 +0200 Subject: [PATCH 007/398] bump version to 8.0.0~2 Signed-off-by: Thomas Lamprecht --- debian/changelog | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/debian/changelog b/debian/changelog index 4ff86fcb3..f793d0c87 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,12 @@ +pve-manager (8.0.0~2) bookworm; urgency=medium + + * update shipped apt sources list for Debian Bookworm + + * drop Ceph Octopus and Pacific from available dists, Proxmox VE 8 will only + support Ceph Quincy, and newer + + -- Proxmox Support Team Tue, 30 May 2023 15:19:23 +0200 + pve-manager (8.0.0~1) bookworm; urgency=medium * switch to native versioning scheme From fa3dfb1c2cc9eae13f4601ed7cdba3ca98f3ce5b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 30 May 2023 15:31:46 +0200 Subject: [PATCH 008/398] ui: ceph install wizard: drop releases not supported for Proxmox VE 8 Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index 47efe1829..5e6f7bc60 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -49,8 +49,6 @@ Ext.define('PVE.ceph.CephVersionSelector', { }, }, data: [ - { release: "octopus", version: "15.2" }, - { release: "pacific", version: "16.2" }, { release: "quincy", version: "17.2" }, ], }, @@ -109,6 +107,7 @@ Ext.define('PVE.ceph.CephHighestVersionDisplay', { 15: 'octopus', 16: 'pacific', 17: 'quincy', + 18: 'reef', }; let release = major2release[maxversion[0]] || 'unknown'; let newestVersionTxt = `${Ext.String.capitalize(release)} (${maxversiontext})`; From c07ef5362477cbd49126eacd1db2e1143f88d33c Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 30 May 2023 15:32:35 +0200 Subject: [PATCH 009/398] bump version to 8.0.0~3 Signed-off-by: Thomas Lamprecht --- debian/changelog | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index f793d0c87..51005abcc 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,11 +1,14 @@ -pve-manager (8.0.0~2) bookworm; urgency=medium +pve-manager (8.0.0~3) bookworm; urgency=medium * update shipped apt sources list for Debian Bookworm * drop Ceph Octopus and Pacific from available dists, Proxmox VE 8 will only support Ceph Quincy, and newer - -- Proxmox Support Team Tue, 30 May 2023 15:19:23 +0200 + * ui: ceph install wizard: drop Octopus and Pacific releases, not supported + inProxmox VE 8 + + -- Proxmox Support Team Tue, 30 May 2023 15:32:31 +0200 pve-manager (8.0.0~1) bookworm; urgency=medium From 37c01c901d9807e6e857cb20e271b2b13997fb18 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 31 May 2023 09:16:55 +0200 Subject: [PATCH 010/398] buildsys: split list of CLI sources over multiple lines Signed-off-by: Thomas Lamprecht --- PVE/CLI/Makefile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/PVE/CLI/Makefile b/PVE/CLI/Makefile index fc53f475e..11d6dacab 100644 --- a/PVE/CLI/Makefile +++ b/PVE/CLI/Makefile @@ -1,6 +1,14 @@ include ../../defines.mk -SOURCES=vzdump.pm pvesubscription.pm pveceph.pm pveam.pm pvesr.pm pvenode.pm pvesh.pm pve6to7.pm +SOURCES = \ + vzdump.pm \ + pvesubscription.pm \ + pveceph.pm \ + pveam.pm \ + pvesr.pm \ + pvenode.pm \ + pvesh.pm \ + pve6to7.pm \ all: From a98193c295f3fa325780f103a76923f406c21941 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 31 May 2023 10:16:15 +0200 Subject: [PATCH 011/398] update source of pve7to8 upgrade checker script squashed from the respective commits of the stable-7 branch, which is the canonical source for this specific script. Signed-off-by: Thomas Lamprecht --- PVE/CLI/Makefile | 1 + PVE/CLI/pve6to7.pm | 7 +- PVE/CLI/pve7to8.pm | 1304 ++++++++++++++++++++++++++++++++++++++++++++ bin/Makefile | 30 +- bin/pve7to8 | 8 + 5 files changed, 1345 insertions(+), 5 deletions(-) create mode 100644 PVE/CLI/pve7to8.pm create mode 100755 bin/pve7to8 diff --git a/PVE/CLI/Makefile b/PVE/CLI/Makefile index 11d6dacab..22006cd38 100644 --- a/PVE/CLI/Makefile +++ b/PVE/CLI/Makefile @@ -9,6 +9,7 @@ SOURCES = \ pvenode.pm \ pvesh.pm \ pve6to7.pm \ + pve7to8.pm \ all: diff --git a/PVE/CLI/pve6to7.pm b/PVE/CLI/pve6to7.pm index d3a6ebd46..4b3a5f732 100644 --- a/PVE/CLI/pve6to7.pm +++ b/PVE/CLI/pve6to7.pm @@ -172,7 +172,12 @@ sub check_pve_packages { my $upgraded = 0; if ($maj > $min_pve_major) { - log_pass("already upgraded to Proxmox VE " . ($min_pve_major + 1)); + my $pve_now = "". ($min_pve_major + 1); + my $pve_next = "". ($min_pve_major + 2); + log_pass("already upgraded to Proxmox VE ${pve_now}"); + log_warn("Proxmox VE ${pve_now} got superseeded by Proxmox VE ${pve_next}.\n" + ." Did you mean to use the pve${pve_now}to${pve_next} checker script?" + ); $upgraded = 1; } elsif ($maj >= $min_pve_major && $min >= $min_pve_minor && $pkgrel >= $min_pve_pkgrel) { log_pass("proxmox-ve package has version >= $min_pve_ver"); diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm new file mode 100644 index 000000000..66cd542c1 --- /dev/null +++ b/PVE/CLI/pve7to8.pm @@ -0,0 +1,1304 @@ +package PVE::CLI::pve7to8; + +use strict; +use warnings; + +use PVE::API2::APT; +use PVE::API2::Ceph; +use PVE::API2::LXC; +use PVE::API2::Qemu; +use PVE::API2::Certificates; +use PVE::API2::Cluster::Ceph; + +use PVE::AccessControl; +use PVE::Ceph::Tools; +use PVE::Cluster; +use PVE::Corosync; +use PVE::INotify; +use PVE::JSONSchema; +use PVE::NodeConfig; +use PVE::RPCEnvironment; +use PVE::Storage; +use PVE::Storage::Plugin; +use PVE::Tools qw(run_command split_list); +use PVE::QemuConfig; +use PVE::QemuServer; +use PVE::VZDump::Common; +use PVE::LXC; +use PVE::LXC::Config; +use PVE::LXC::Setup; + +use Term::ANSIColor; + +use PVE::CLIHandler; + +use base qw(PVE::CLIHandler); + +my $nodename = PVE::INotify::nodename(); + +sub setup_environment { + PVE::RPCEnvironment->setup_default_cli_env(); +} + +my ($min_pve_major, $min_pve_minor, $min_pve_pkgrel) = (7, 4, 1); + +my $ceph_release2code = { + '12' => 'Luminous', + '13' => 'Mimic', + '14' => 'Nautilus', + '15' => 'Octopus', + '16' => 'Pacific', + '17' => 'Quincy', + '18' => 'Reef', +}; +my $ceph_supported_release = 17; # the version we support for upgrading (i.e., available on both) +my $ceph_supported_code_name = $ceph_release2code->{"$ceph_supported_release"} + or die "inconsistent source code, could not map expected ceph version to code name!"; + +my $forced_legacy_cgroup = 0; + +my $counters = { + pass => 0, + skip => 0, + warn => 0, + fail => 0, +}; + +my $log_line = sub { + my ($level, $line) = @_; + + $counters->{$level}++ if defined($level) && defined($counters->{$level}); + + print uc($level), ': ' if defined($level); + print "$line\n"; +}; + +sub log_pass { + print color('green'); + $log_line->('pass', @_); + print color('reset'); +} + +sub log_info { + $log_line->('info', @_); +} +sub log_skip { + $log_line->('skip', @_); +} +sub log_warn { + print color('yellow'); + $log_line->('warn', @_); + print color('reset'); +} +sub log_fail { + print color('bold red'); + $log_line->('fail', @_); + print color('reset'); +} + +my $print_header_first = 1; +sub print_header { + my ($h) = @_; + print "\n" if !$print_header_first; + print "= $h =\n\n"; + $print_header_first = 0; +} + +my $get_systemd_unit_state = sub { + my ($unit, $surpress_stderr) = @_; + + my $state; + my $filter_output = sub { + $state = shift; + chomp $state; + }; + + my %extra = (outfunc => $filter_output, noerr => 1); + $extra{errfunc} = sub { } if $surpress_stderr; + + eval { + run_command(['systemctl', 'is-enabled', "$unit"], %extra); + return if !defined($state); + run_command(['systemctl', 'is-active', "$unit"], %extra); + }; + + return $state // 'unknown'; +}; +my $log_systemd_unit_state = sub { + my ($unit, $no_fail_on_inactive) = @_; + + my $log_method = \&log_warn; + + my $state = $get_systemd_unit_state->($unit); + if ($state eq 'active') { + $log_method = \&log_pass; + } elsif ($state eq 'inactive') { + $log_method = $no_fail_on_inactive ? \&log_warn : \&log_fail; + } elsif ($state eq 'failed') { + $log_method = \&log_fail; + } + + $log_method->("systemd unit '$unit' is in state '$state'"); +}; + +my $versions; +my $get_pkg = sub { + my ($pkg) = @_; + + $versions = eval { PVE::API2::APT->versions({ node => $nodename }) } if !defined($versions); + + if (!defined($versions)) { + my $msg = "unable to retrieve package version information"; + $msg .= "- $@" if $@; + log_fail("$msg"); + return undef; + } + + my $pkgs = [ grep { $_->{Package} eq $pkg } @$versions ]; + if (!defined $pkgs || $pkgs == 0) { + log_fail("unable to determine installed $pkg version."); + return undef; + } else { + return $pkgs->[0]; + } +}; + +sub check_pve_packages { + print_header("CHECKING VERSION INFORMATION FOR PVE PACKAGES"); + + print "Checking for package updates..\n"; + my $updates = eval { PVE::API2::APT->list_updates({ node => $nodename }); }; + if (!defined($updates)) { + log_warn("$@") if $@; + log_fail("unable to retrieve list of package updates!"); + } elsif (@$updates > 0) { + my $pkgs = join(', ', map { $_->{Package} } @$updates); + log_warn("updates for the following packages are available:\n $pkgs"); + } else { + log_pass("all packages uptodate"); + } + + print "\nChecking proxmox-ve package version..\n"; + if (defined(my $proxmox_ve = $get_pkg->('proxmox-ve'))) { + my $min_pve_ver = "$min_pve_major.$min_pve_minor-$min_pve_pkgrel"; + + my ($maj, $min, $pkgrel) = $proxmox_ve->{OldVersion} =~ m/^(\d+)\.(\d+)-(\d+)/; + + my $upgraded = 0; + + if ($maj > $min_pve_major) { + log_pass("already upgraded to Proxmox VE " . ($min_pve_major + 1)); + $upgraded = 1; + } elsif ($maj >= $min_pve_major && $min >= $min_pve_minor && $pkgrel >= $min_pve_pkgrel) { + log_pass("proxmox-ve package has version >= $min_pve_ver"); + } else { + log_fail("proxmox-ve package is too old, please upgrade to >= $min_pve_ver!"); + } + + my ($krunning, $kinstalled) = (qr/6\.(?:2|5)/, 'pve-kernel-6.2'); + if (!$upgraded) { + # we got a few that avoided 5.15 in cluster with mixed CPUs, so allow older too + ($krunning, $kinstalled) = (qr/(?:5\.(?:13|15)|6\.2)/, 'pve-kernel-5.15'); + } + + print "\nChecking running kernel version..\n"; + my $kernel_ver = $proxmox_ve->{RunningKernel}; + if (!defined($kernel_ver)) { + log_fail("unable to determine running kernel version."); + } elsif ($kernel_ver =~ /^$krunning/) { + if ($upgraded) { + log_pass("running new kernel '$kernel_ver' after upgrade."); + } else { + log_pass("running kernel '$kernel_ver' is considered suitable for upgrade."); + } + } elsif ($get_pkg->($kinstalled)) { + # with 6.2 kernel being available in both we might want to fine-tune the check? + log_warn("a suitable kernel ($kinstalled) is intalled, but an unsuitable ($kernel_ver) is booted, missing reboot?!"); + } else { + log_warn("unexpected running and installed kernel '$kernel_ver'."); + } + + if ($upgraded && $kernel_ver =~ /^$krunning/) { + my $outdated_kernel_meta_pkgs = []; + for my $kernel_meta_version ('5.4', '5.11', '5.13', '5.15') { + my $pkg = "pve-kernel-${kernel_meta_version}"; + if ($get_pkg->($pkg)) { + push @$outdated_kernel_meta_pkgs, $pkg; + } + } + if (scalar(@$outdated_kernel_meta_pkgs) > 0) { + log_info( + "Found outdated kernel meta-packages, taking up extra space on boot partitions.\n" + ." After a successful upgrade, you can remove them using this command:\n" + ." apt remove " . join(' ', $outdated_kernel_meta_pkgs->@*) + ); + } + } + } else { + log_fail("proxmox-ve package not found!"); + } +} + + +sub check_storage_health { + print_header("CHECKING CONFIGURED STORAGES"); + my $cfg = PVE::Storage::config(); + + my $ctime = time(); + + my $info = PVE::Storage::storage_info($cfg); + + foreach my $storeid (sort keys %$info) { + my $d = $info->{$storeid}; + if ($d->{enabled}) { + if ($d->{active}) { + log_pass("storage '$storeid' enabled and active."); + } else { + log_warn("storage '$storeid' enabled but not active!"); + } + } else { + log_skip("storage '$storeid' disabled."); + } + } + + check_storage_content(); +} + +sub check_cluster_corosync { + print_header("CHECKING CLUSTER HEALTH/SETTINGS"); + + if (!PVE::Corosync::check_conf_exists(1)) { + log_skip("standalone node."); + return; + } + + $log_systemd_unit_state->('pve-cluster.service'); + $log_systemd_unit_state->('corosync.service'); + + if (PVE::Cluster::check_cfs_quorum(1)) { + log_pass("Cluster Filesystem is quorate."); + } else { + log_fail("Cluster Filesystem readonly, lost quorum?!"); + } + + my $conf = PVE::Cluster::cfs_read_file('corosync.conf'); + my $conf_nodelist = PVE::Corosync::nodelist($conf); + my $node_votes = 0; + + print "\nAnalzying quorum settings and state..\n"; + if (!defined($conf_nodelist)) { + log_fail("unable to retrieve nodelist from corosync.conf"); + } else { + if (grep { $conf_nodelist->{$_}->{quorum_votes} != 1 } keys %$conf_nodelist) { + log_warn("non-default quorum_votes distribution detected!"); + } + map { $node_votes += $conf_nodelist->{$_}->{quorum_votes} // 0 } keys %$conf_nodelist; + } + + my ($expected_votes, $total_votes); + my $filter_output = sub { + my $line = shift; + ($expected_votes) = $line =~ /^Expected votes:\s*(\d+)\s*$/ + if !defined($expected_votes); + ($total_votes) = $line =~ /^Total votes:\s*(\d+)\s*$/ + if !defined($total_votes); + }; + eval { + run_command(['corosync-quorumtool', '-s'], outfunc => $filter_output, noerr => 1); + }; + + if (!defined($expected_votes)) { + log_fail("unable to get expected number of votes, assuming 0."); + $expected_votes = 0; + } + if (!defined($total_votes)) { + log_fail("unable to get expected number of votes, assuming 0."); + $total_votes = 0; + } + + my $cfs_nodelist = PVE::Cluster::get_clinfo()->{nodelist}; + my $offline_nodes = grep { $cfs_nodelist->{$_}->{online} != 1 } keys %$cfs_nodelist; + if ($offline_nodes > 0) { + log_fail("$offline_nodes nodes are offline!"); + } + + my $qdevice_votes = 0; + if (my $qdevice_setup = $conf->{main}->{quorum}->{device}) { + $qdevice_votes = $qdevice_setup->{votes} // 1; + } + + log_info("configured votes - nodes: $node_votes"); + log_info("configured votes - qdevice: $qdevice_votes"); + log_info("current expected votes: $expected_votes"); + log_info("current total votes: $total_votes"); + + log_warn("expected votes set to non-standard value '$expected_votes'.") + if $expected_votes != $node_votes + $qdevice_votes; + log_warn("total votes < expected votes: $total_votes/$expected_votes!") + if $total_votes < $expected_votes; + + my $conf_nodelist_count = scalar(keys %$conf_nodelist); + my $cfs_nodelist_count = scalar(keys %$cfs_nodelist); + log_warn("cluster consists of less than three quorum-providing nodes!") + if $conf_nodelist_count < 3 && $conf_nodelist_count + $qdevice_votes < 3; + + log_fail("corosync.conf ($conf_nodelist_count) and pmxcfs ($cfs_nodelist_count) don't agree about size of nodelist.") + if $conf_nodelist_count != $cfs_nodelist_count; + + print "\nChecking nodelist entries..\n"; + my $nodelist_pass = 1; + for my $cs_node (sort keys %$conf_nodelist) { + my $entry = $conf_nodelist->{$cs_node}; + if (!defined($entry->{name})) { + $nodelist_pass = 0; + log_fail("$cs_node: no name entry in corosync.conf."); + } + if (!defined($entry->{nodeid})) { + $nodelist_pass = 0; + log_fail("$cs_node: no nodeid configured in corosync.conf."); + } + my $gotLinks = 0; + for my $link (0..7) { + $gotLinks++ if defined($entry->{"ring${link}_addr"}); + } + if ($gotLinks <= 0) { + $nodelist_pass = 0; + log_fail("$cs_node: no ringX_addr (0 <= X <= 7) link defined in corosync.conf."); + } + + my $verify_ring_ip = sub { + my $key = shift; + if (defined(my $ring = $entry->{$key})) { + my ($resolved_ip, undef) = PVE::Corosync::resolve_hostname_like_corosync($ring, $conf); + if (defined($resolved_ip)) { + if ($resolved_ip ne $ring) { + $nodelist_pass = 0; + log_warn( + "$cs_node: $key '$ring' resolves to '$resolved_ip'.\n" + ." Consider replacing it with the currently resolved IP address." + ); + } + } else { + $nodelist_pass = 0; + log_fail( + "$cs_node: unable to resolve $key '$ring' to an IP address according to Corosync's" + ." resolve strategy - cluster will potentially fail with Corosync 3.x/kronosnet!" + ); + } + } + }; + for my $link (0..7) { + $verify_ring_ip->("ring${link}_addr"); + } + } + log_pass("nodelist settings OK") if $nodelist_pass; + + print "\nChecking totem settings..\n"; + my $totem = $conf->{main}->{totem}; + my $totem_pass = 1; + + my $transport = $totem->{transport}; + if (defined($transport)) { + if ($transport ne 'knet') { + $totem_pass = 0; + log_fail("Corosync transport explicitly set to '$transport' instead of implicit default!"); + } + } + + # TODO: are those values still up-to-date? + if ((!defined($totem->{secauth}) || $totem->{secauth} ne 'on') && (!defined($totem->{crypto_cipher}) || $totem->{crypto_cipher} eq 'none')) { + $totem_pass = 0; + log_fail("Corosync authentication/encryption is not explicitly enabled (secauth / crypto_cipher / crypto_hash)!"); + } elsif (defined($totem->{crypto_cipher}) && $totem->{crypto_cipher} eq '3des') { + $totem_pass = 0; + log_fail("Corosync encryption cipher set to '3des', no longer supported in Corosync 3.x!"); # FIXME: can be removed? + } + + log_pass("totem settings OK") if $totem_pass; + print "\n"; + log_info("run 'pvecm status' to get detailed cluster status.."); + + if (defined(my $corosync = $get_pkg->('corosync'))) { + if ($corosync->{OldVersion} =~ m/^2\./) { + log_fail("\ncorosync 2.x installed, cluster-wide upgrade to 3.x needed!"); + } elsif ($corosync->{OldVersion} !~ m/^3\./) { + log_fail("\nunexpected corosync version installed: $corosync->{OldVersion}!"); + } + } +} + +sub check_ceph { + print_header("CHECKING HYPER-CONVERGED CEPH STATUS"); + + if (PVE::Ceph::Tools::check_ceph_inited(1)) { + log_info("hyper-converged ceph setup detected!"); + } else { + log_skip("no hyper-converged ceph setup detected!"); + return; + } + + log_info("getting Ceph status/health information.."); + my $ceph_status = eval { PVE::API2::Ceph->status({ node => $nodename }); }; + my $noout = eval { PVE::API2::Cluster::Ceph->get_flag({ flag => "noout" }); }; + if ($@) { + log_fail("failed to get 'noout' flag status - $@"); + } + + my $noout_wanted = 1; + + if (!$ceph_status || !$ceph_status->{health}) { + log_fail("unable to determine Ceph status!"); + } else { + my $ceph_health = $ceph_status->{health}->{status}; + if (!$ceph_health) { + log_fail("unable to determine Ceph health!"); + } elsif ($ceph_health eq 'HEALTH_OK') { + log_pass("Ceph health reported as 'HEALTH_OK'."); + } elsif ($ceph_health eq 'HEALTH_WARN' && $noout && (keys %{$ceph_status->{health}->{checks}} == 1)) { + log_pass("Ceph health reported as 'HEALTH_WARN' with a single failing check and 'noout' flag set."); + } else { + log_warn( + "Ceph health reported as '$ceph_health'.\n Use the PVE dashboard or 'ceph -s'" + ." to determine the specific issues and try to resolve them." + ); + } + } + + # TODO: check OSD min-required version, if to low it breaks stuff! + + log_info("cehcking local Ceph version.."); + if (my $release = eval { PVE::Ceph::Tools::get_local_version(1) }) { + my $code_name = $ceph_release2code->{"$release"} || 'unknown'; + if ($release == $ceph_supported_release) { + log_pass("found expected Ceph $ceph_supported_release $ceph_supported_code_name release.") + } elsif ($release > $ceph_supported_release) { + log_warn( + "found newer Ceph release $release $code_name as the expected $ceph_supported_release" + ." $ceph_supported_code_name, installed third party repos?!" + ) + } else { + log_fail( + "Hyper-converged Ceph $release $code_name is to old for upgrade!\n" + ." Upgrade Ceph first to $ceph_supported_code_name following our how-to:\n" + ." " + ); + } + } else { + log_fail("unable to determine local Ceph version!"); + } + + log_info("getting Ceph daemon versions.."); + my $ceph_versions = eval { PVE::Ceph::Tools::get_cluster_versions(undef, 1); }; + if (!$ceph_versions) { + log_fail("unable to determine Ceph daemon versions!"); + } else { + my $services = [ + { 'key' => 'mon', 'name' => 'monitor' }, + { 'key' => 'mgr', 'name' => 'manager' }, + { 'key' => 'mds', 'name' => 'MDS' }, + { 'key' => 'osd', 'name' => 'OSD' }, + ]; + + foreach my $service (@$services) { + my ($name, $key) = $service->@{'name', 'key'}; + if (my $service_versions = $ceph_versions->{$key}) { + if (keys %$service_versions == 0) { + log_skip("no running instances detected for daemon type $name."); + } elsif (keys %$service_versions == 1) { + log_pass("single running version detected for daemon type $name."); + } else { + log_warn("multiple running versions detected for daemon type $name!"); + } + } else { + log_skip("unable to determine versions of running Ceph $name instances."); + } + } + + my $overall_versions = $ceph_versions->{overall}; + if (!$overall_versions) { + log_warn("unable to determine overall Ceph daemon versions!"); + } elsif (keys %$overall_versions == 1) { + log_pass("single running overall version detected for all Ceph daemon types."); + $noout_wanted = 0; # off post-upgrade, on pre-upgrade + } else { + log_warn("overall version mismatch detected, check 'ceph versions' output for details!"); + } + } + + if ($noout) { + if ($noout_wanted) { + log_pass("'noout' flag set to prevent rebalancing during cluster-wide upgrades."); + } else { + log_warn("'noout' flag set, Ceph cluster upgrade seems finished."); + } + } elsif ($noout_wanted) { + log_warn("'noout' flag not set - recommended to prevent rebalancing during upgrades."); + } + + log_info("checking Ceph config.."); + my $conf = PVE::Cluster::cfs_read_file('ceph.conf'); + if (%$conf) { + my $global = $conf->{global}; + + my $global_monhost = $global->{mon_host} // $global->{"mon host"} // $global->{"mon-host"}; + if (!defined($global_monhost)) { + log_warn( + "No 'mon_host' entry found in ceph config.\n It's recommended to add mon_host with" + ." all monitor addresses (without ports) to the global section." + ); + } + + my $ipv6 = $global->{ms_bind_ipv6} // $global->{"ms bind ipv6"} // $global->{"ms-bind-ipv6"}; + if ($ipv6) { + my $ipv4 = $global->{ms_bind_ipv4} // $global->{"ms bind ipv4"} // $global->{"ms-bind-ipv4"}; + if ($ipv6 eq 'true' && (!defined($ipv4) || $ipv4 ne 'false')) { + log_warn( + "'ms_bind_ipv6' is enabled but 'ms_bind_ipv4' is not disabled.\n Make sure to" + ." disable 'ms_bind_ipv4' for ipv6 only clusters, or add an ipv4 network to public/cluster network." + ); + } + } + + if (defined($global->{keyring})) { + log_warn( + "[global] config section contains 'keyring' option, which will prevent services from" + ." starting with Nautilus.\n Move 'keyring' option to [client] section instead." + ); + } + + } else { + log_warn("Empty ceph config found"); + } + + my $local_ceph_ver = PVE::Ceph::Tools::get_local_version(1); + if (defined($local_ceph_ver)) { + if ($local_ceph_ver <= 14) { + log_fail("local Ceph version too low, at least Octopus required.."); + } + } else { + log_fail("unable to determine local Ceph version."); + } +} + +sub check_backup_retention_settings { + log_info("Checking backup retention settings.."); + + my $pass = 1; + + my $node_has_retention; + + my $maxfiles_msg = "parameter 'maxfiles' is deprecated with PVE 7.x and will be removed in a " . + "future version, use 'prune-backups' instead."; + + eval { + my $confdesc = PVE::VZDump::Common::get_confdesc(); + + my $fn = "/etc/vzdump.conf"; + my $raw = PVE::Tools::file_get_contents($fn); + + my $conf_schema = { type => 'object', properties => $confdesc, }; + my $param = PVE::JSONSchema::parse_config($conf_schema, $fn, $raw); + + if (defined($param->{maxfiles})) { + $pass = 0; + log_warn("$fn - $maxfiles_msg"); + } + + $node_has_retention = defined($param->{maxfiles}) || defined($param->{'prune-backups'}); + }; + if (my $err = $@) { + $pass = 0; + log_warn("unable to parse node's VZDump configuration - $err"); + } + + my $storage_cfg = PVE::Storage::config(); + + for my $storeid (keys $storage_cfg->{ids}->%*) { + my $scfg = $storage_cfg->{ids}->{$storeid}; + + if (defined($scfg->{maxfiles})) { + $pass = 0; + log_warn("storage '$storeid' - $maxfiles_msg"); + } + + next if !$scfg->{content}->{backup}; + next if defined($scfg->{maxfiles}) || defined($scfg->{'prune-backups'}); + next if $node_has_retention; + + log_info( + "storage '$storeid' - no backup retention settings defined - by default, since PVE 7.0" + ." it will no longer keep only the last backup, but all backups" + ); + } + + eval { + my $vzdump_cron = PVE::Cluster::cfs_read_file('vzdump.cron'); + + # only warn once, there might be many jobs... + if (scalar(grep { defined($_->{maxfiles}) } $vzdump_cron->{jobs}->@*)) { + $pass = 0; + log_warn("/etc/pve/vzdump.cron - $maxfiles_msg"); + } + }; + if (my $err = $@) { + $pass = 0; + log_warn("unable to parse node's VZDump configuration - $err"); + } + + log_pass("no problems found.") if $pass; +} + +sub check_cifs_credential_location { + log_info("checking CIFS credential location.."); + + my $regex = qr/^(.*)\.cred$/; + + my $found; + + PVE::Tools::dir_glob_foreach('/etc/pve/priv/', $regex, sub { + my ($filename) = @_; + + my ($basename) = $filename =~ $regex; + + log_warn( + "CIFS credentials '/etc/pve/priv/$filename' will be moved to" + ." '/etc/pve/priv/storage/$basename.pw' during the update" + ); + + $found = 1; + }); + + log_pass("no CIFS credentials at outdated location found.") if !$found; +} + +sub check_custom_pool_roles { + log_info("Checking custom roles for pool permissions.."); + + if (! -f "/etc/pve/user.cfg") { + log_skip("user.cfg does not exist"); + return; + } + + my $raw = eval { PVE::Tools::file_get_contents('/etc/pve/user.cfg'); }; + if ($@) { + log_fail("Failed to read '/etc/pve/user.cfg' - $@"); + return; + } + + my $roles = {}; + while ($raw =~ /^\s*(.+?)\s*$/gm) { + my $line = $1; + my @data; + + foreach my $d (split (/:/, $line)) { + $d =~ s/^\s+//; + $d =~ s/\s+$//; + push @data, $d + } + + my $et = shift @data; + next if $et ne 'role'; + + my ($role, $privlist) = @data; + if (!PVE::AccessControl::verify_rolename($role, 1)) { + warn "user config - ignore role '$role' - invalid characters in role name\n"; + next; + } + + $roles->{$role} = {} if !$roles->{$role}; + foreach my $priv (split_list($privlist)) { + $roles->{$role}->{$priv} = 1; + } + } + + foreach my $role (sort keys %{$roles}) { + next if PVE::AccessControl::role_is_special($role); + + # TODO: any role updates? + } +} + +my sub check_max_length { + my ($raw, $max_length, $warning) = @_; + log_warn($warning) if defined($raw) && length($raw) > $max_length; +} + +sub check_node_and_guest_configurations { + log_info("Checking node and guest description/note legnth.."); + + my @affected_nodes = grep { + my $desc = PVE::NodeConfig::load_config($_)->{desc}; + defined($desc) && length($desc) > 64 * 1024 + } PVE::Cluster::get_nodelist(); + + if (scalar(@affected_nodes) > 0) { + log_warn("Node config description of the following nodes too long for new limit of 64 KiB:\n " + . join(', ', @affected_nodes)); + } else { + log_pass("All node config descriptions fit in the new limit of 64 KiB"); + } + + my $affected_guests_long_desc = []; + my $affected_cts_cgroup_keys = []; + + my $cts = PVE::LXC::config_list(); + for my $vmid (sort { $a <=> $b } keys %$cts) { + my $conf = PVE::LXC::Config->load_config($vmid); + + my $desc = $conf->{description}; + push @$affected_guests_long_desc, "CT $vmid" if defined($desc) && length($desc) > 8 * 1024; + + my $lxc_raw_conf = $conf->{lxc}; + push @$affected_cts_cgroup_keys, "CT $vmid" if (grep (@$_[0] =~ /^lxc\.cgroup\./, @$lxc_raw_conf)); + } + my $vms = PVE::QemuServer::config_list(); + for my $vmid (sort { $a <=> $b } keys %$vms) { + my $desc = PVE::QemuConfig->load_config($vmid)->{description}; + push @$affected_guests_long_desc, "VM $vmid" if defined($desc) && length($desc) > 8 * 1024; + } + if (scalar($affected_guests_long_desc->@*) > 0) { + log_warn("Guest config description of the following virtual-guests too long for new limit of 64 KiB:\n" + ." " . join(", ", $affected_guests_long_desc->@*)); + } else { + log_pass("All guest config descriptions fit in the new limit of 8 KiB"); + } + + log_info("Checking container configs for deprecated lxc.cgroup entries"); + + if (scalar($affected_cts_cgroup_keys->@*) > 0) { + if ($forced_legacy_cgroup) { + log_pass("Found legacy 'lxc.cgroup' keys, but system explicitly configured for legacy hybrid cgroup hierarchy."); + } else { + log_warn("The following CTs have 'lxc.cgroup' keys configured, which will be ignored in the new default unified cgroupv2:\n" + ." " . join(", ", $affected_cts_cgroup_keys->@*) ."\n" + ." Often it can be enough to change to the new 'lxc.cgroup2' prefix after the upgrade to Proxmox VE 7.x"); + } + } else { + log_pass("No legacy 'lxc.cgroup' keys found."); + } +} + +sub check_storage_content { + log_info("Checking storage content type configuration.."); + + my $found; + my $pass = 1; + + my $storage_cfg = PVE::Storage::config(); + + for my $storeid (sort keys $storage_cfg->{ids}->%*) { + my $scfg = $storage_cfg->{ids}->{$storeid}; + + next if $scfg->{shared}; + next if !PVE::Storage::storage_check_enabled($storage_cfg, $storeid, undef, 1); + + my $valid_content = PVE::Storage::Plugin::valid_content_types($scfg->{type}); + + if (scalar(keys $scfg->{content}->%*) == 0 && !$valid_content->{none}) { + $pass = 0; + log_fail("storage '$storeid' does not support configured content type 'none'"); + delete $scfg->{content}->{none}; # scan for guest images below + } + + next if $scfg->{content}->{images}; + next if $scfg->{content}->{rootdir}; + + # Skip 'iscsi(direct)' (and foreign plugins with potentially similiar behavior) with 'none', + # because that means "use LUNs directly" and vdisk_list() in PVE 6.x still lists those. + # It's enough to *not* skip 'dir', because it is the only other storage that supports 'none' + # and 'images' or 'rootdir', hence being potentially misconfigured. + next if $scfg->{type} ne 'dir' && $scfg->{content}->{none}; + + eval { PVE::Storage::activate_storage($storage_cfg, $storeid) }; + if (my $err = $@) { + log_warn("activating '$storeid' failed - $err"); + next; + } + + my $res = eval { PVE::Storage::vdisk_list($storage_cfg, $storeid); }; + if (my $err = $@) { + log_warn("listing images on '$storeid' failed - $err"); + next; + } + my @volids = map { $_->{volid} } $res->{$storeid}->@*; + + my $number = scalar(@volids); + if ($number > 0) { + log_info( + "storage '$storeid' - neither content type 'images' nor 'rootdir' configured, but" + ."found $number guest volume(s)" + ); + } + } + + my $check_volid = sub { + my ($volid, $vmid, $vmtype, $reference) = @_; + + my $guesttext = $vmtype eq 'qemu' ? 'VM' : 'CT'; + my $prefix = "$guesttext $vmid - volume '$volid' ($reference)"; + + my ($storeid) = PVE::Storage::parse_volume_id($volid, 1); + return if !defined($storeid); + + my $scfg = $storage_cfg->{ids}->{$storeid}; + if (!$scfg) { + $pass = 0; + log_warn("$prefix - storage does not exist!"); + return; + } + + # cannot use parse_volname for containers, as it can return 'images' + # but containers cannot have ISO images attached, so assume 'rootdir' + my $vtype = 'rootdir'; + if ($vmtype eq 'qemu') { + ($vtype) = eval { PVE::Storage::parse_volname($storage_cfg, $volid); }; + return if $@; + } + + if (!$scfg->{content}->{$vtype}) { + $found = 1; + $pass = 0; + log_warn("$prefix - storage does not have content type '$vtype' configured."); + } + }; + + my $cts = PVE::LXC::config_list(); + for my $vmid (sort { $a <=> $b } keys %$cts) { + my $conf = PVE::LXC::Config->load_config($vmid); + + my $volhash = {}; + + my $check = sub { + my ($ms, $mountpoint, $reference) = @_; + + my $volid = $mountpoint->{volume}; + return if !$volid || $mountpoint->{type} ne 'volume'; + + return if $volhash->{$volid}; # volume might be referenced multiple times + + $volhash->{$volid} = 1; + + $check_volid->($volid, $vmid, 'lxc', $reference); + }; + + my $opts = { include_unused => 1 }; + PVE::LXC::Config->foreach_volume_full($conf, $opts, $check, 'in config'); + for my $snapname (keys $conf->{snapshots}->%*) { + my $snap = $conf->{snapshots}->{$snapname}; + PVE::LXC::Config->foreach_volume_full($snap, $opts, $check, "in snapshot '$snapname'"); + } + } + + my $vms = PVE::QemuServer::config_list(); + for my $vmid (sort { $a <=> $b } keys %$vms) { + my $conf = PVE::QemuConfig->load_config($vmid); + + my $volhash = {}; + + my $check = sub { + my ($key, $drive, $reference) = @_; + + my $volid = $drive->{file}; + return if $volid =~ m|^/|; + return if $volhash->{$volid}; # volume might be referenced multiple times + + $volhash->{$volid} = 1; + $check_volid->($volid, $vmid, 'qemu', $reference); + }; + + my $opts = { + extra_keys => ['vmstate'], + include_unused => 1, + }; + # startup from a suspended state works even without 'images' content type on the + # state storage, so do not check 'vmstate' for $conf + PVE::QemuConfig->foreach_volume_full($conf, { include_unused => 1 }, $check, 'in config'); + for my $snapname (keys $conf->{snapshots}->%*) { + my $snap = $conf->{snapshots}->{$snapname}; + PVE::QemuConfig->foreach_volume_full($snap, $opts, $check, "in snapshot '$snapname'"); + } + } + + if ($found) { + log_warn("Proxmox VE enforces stricter content type checks since 7.0. The guests above " . + "might not work until the storage configuration is fixed."); + } + + if ($pass) { + log_pass("no problems found"); + } +} + +sub check_containers_cgroup_compat { + if ($forced_legacy_cgroup) { + log_warn("System explicitly configured for legacy hybrid cgroup hierarchy.\n" + ." NOTE: support for the hybrid cgroup hierachy will be removed in future Proxmox VE 9 (~ 2025)." + ); + } + + my $supports_cgroupv2 = sub { + my ($conf, $rootdir, $ctid) = @_; + + my $get_systemd_version = sub { + my ($self) = @_; + + my $sd_lib_dir = -d "/lib/systemd" ? "/lib/systemd" : "/usr/lib/systemd"; + my $libsd = PVE::Tools::dir_glob_regex($sd_lib_dir, "libsystemd-shared-.+\.so"); + if (defined($libsd) && $libsd =~ /libsystemd-shared-(\d+)\.so/) { + return $1; + } + + return undef; + }; + + my $unified_cgroupv2_support = sub { + my ($self) = @_; + + # https://www.freedesktop.org/software/systemd/man/systemd.html + # systemd is installed as symlink to /sbin/init + my $systemd = CORE::readlink('/sbin/init'); + + # assume non-systemd init will run with unified cgroupv2 + if (!defined($systemd) || $systemd !~ m@/systemd$@) { + return 1; + } + + # systemd version 232 (e.g. debian stretch) supports the unified hierarchy + my $sdver = $get_systemd_version->(); + if (!defined($sdver) || $sdver < 232) { + return 0; + } + + return 1; + }; + + my $ostype = $conf->{ostype}; + if (!defined($ostype)) { + log_warn("Found CT ($ctid) without 'ostype' set!"); + } elsif ($ostype eq 'devuan' || $ostype eq 'alpine') { + return 1; # no systemd, no cgroup problems + } + + my $lxc_setup = PVE::LXC::Setup->new($conf, $rootdir); + return $lxc_setup->protected_call($unified_cgroupv2_support); + }; + + my $log_problem = sub { + my ($ctid) = @_; + my $extra = $forced_legacy_cgroup ? '' : " or set systemd.unified_cgroup_hierarchy=0 in the Proxmox VE hosts' kernel cmdline"; + log_warn( + "Found at least one CT ($ctid) which does not support running in a unified cgroup v2 layout\n" + ." Consider upgrading the Containers distro${extra}! Skipping further CT compat checks." + ); + }; + + my $cts = eval { PVE::API2::LXC->vmlist({ node => $nodename }) }; + if ($@) { + log_warn("Failed to retrieve information about this node's CTs - $@"); + return; + } + + if (!defined($cts) || !scalar(@$cts)) { + log_skip("No containers on node detected."); + return; + } + + my @running_cts = sort { $a <=> $b } grep { $_->{status} eq 'running' } @$cts; + my @offline_cts = sort { $a <=> $b } grep { $_->{status} ne 'running' } @$cts; + + for my $ct (@running_cts) { + my $ctid = $ct->{vmid}; + my $pid = eval { PVE::LXC::find_lxc_pid($ctid) }; + if (my $err = $@) { + log_warn("Failed to get PID for running CT $ctid - $err"); + next; + } + my $rootdir = "/proc/$pid/root"; + my $conf = PVE::LXC::Config->load_config($ctid); + + my $ret = eval { $supports_cgroupv2->($conf, $rootdir, $ctid) }; + if (my $err = $@) { + log_warn("Failed to get cgroup support status for CT $ctid - $err"); + next; + } + if (!$ret) { + $log_problem->($ctid); + return; + } + } + + my $storage_cfg = PVE::Storage::config(); + for my $ct (@offline_cts) { + my $ctid = $ct->{vmid}; + my ($conf, $rootdir, $ret); + eval { + $conf = PVE::LXC::Config->load_config($ctid); + $rootdir = PVE::LXC::mount_all($ctid, $storage_cfg, $conf); + $ret = $supports_cgroupv2->($conf, $rootdir, $ctid); + }; + if (my $err = $@) { + log_warn("Failed to load config and mount CT $ctid - $err"); + eval { PVE::LXC::umount_all($ctid, $storage_cfg, $conf) }; + next; + } + if (!$ret) { + $log_problem->($ctid); + eval { PVE::LXC::umount_all($ctid, $storage_cfg, $conf) }; + last; + } + + eval { PVE::LXC::umount_all($ctid, $storage_cfg, $conf) }; + } +}; + +sub check_apt_repos { + log_info("Checking if the suite for the Debian security repository is correct.."); + + my $found = 0; + + my $dir = '/etc/apt/sources.list.d'; + my $in_dir = 0; + + # TODO: check that (original) debian and Proxmox VE mirrors are present. + + my $check_file = sub { + my ($file) = @_; + + $file = "${dir}/${file}" if $in_dir; + + my $raw = eval { PVE::Tools::file_get_contents($file) }; + return if !defined($raw); + my @lines = split(/\n/, $raw); + + my $number = 0; + for my $line (@lines) { + $number++; + + next if length($line) == 0; # split would result in undef then... + + ($line) = split(/#/, $line); + + next if $line !~ m/^deb[[:space:]]/; # is case sensitive + + my $suite; + + # catch any of + # https://deb.debian.org/debian-security + # http://security.debian.org/debian-security + # http://security.debian.org/ + if ($line =~ m|https?://deb\.debian\.org/debian-security/?\s+(\S*)|i) { + $suite = $1; + } elsif ($line =~ m|https?://security\.debian\.org(?:.*?)\s+(\S*)|i) { + $suite = $1; + } else { + next; + } + + $found = 1; + + my $where = "in ${file}:${number}"; + # TODO: is this useful (for some other checks)? + } + }; + + $check_file->("/etc/apt/sources.list"); + + $in_dir = 1; + + PVE::Tools::dir_glob_foreach($dir, '^.*\.list$', $check_file); + + if (!$found) { + # only warn, it might be defined in a .sources file or in a way not catched above + log_warn("No Debian security repository detected in /etc/apt/sources.list and " . + "/etc/apt/sources.list.d/*.list"); + } +} + +sub check_time_sync { + my $unit_active = sub { return $get_systemd_unit_state->($_[0], 1) eq 'active' ? $_[0] : undef }; + + log_info("Checking for supported & active NTP service.."); + if ($unit_active->('systemd-timesyncd.service')) { + log_warn( + "systemd-timesyncd is not the best choice for time-keeping on servers, due to only applying" + ." updates on boot.\n While not necesarry for the upgrade it's recommended to use one of:\n" + ." * chrony (Default in new Proxmox VE installations)\n * ntpsec\n * openntpd\n" + ); + } elsif ($unit_active->('ntp.service')) { + log_info("Debian deprecated and removed the ntp package for Bookworm, but the system" + ." will automatically migrate to the 'ntpsec' replacement package on upgrade."); + } elsif (my $active_ntp = ($unit_active->('chrony.service') || $unit_active->('openntpd.service') || $unit_active->('ntpsec.service'))) { + log_pass("Detected active time synchronisation unit '$active_ntp'"); + } else { + log_warn( + "No (active) time synchronisation daemon (NTP) detected, but synchronized systems are important," + ." especially for cluster and/or ceph!" + ); + } +} + +sub check_misc { + print_header("MISCELLANEOUS CHECKS"); + my $ssh_config = eval { PVE::Tools::file_get_contents('/root/.ssh/config') }; + if (defined($ssh_config)) { + log_fail("Unsupported SSH Cipher configured for root in /root/.ssh/config: $1") + if $ssh_config =~ /^Ciphers .*(blowfish|arcfour|3des).*$/m; + } else { + log_skip("No SSH config file found."); + } + + log_info("Checking common daemon services.."); + $log_systemd_unit_state->('pveproxy.service'); + $log_systemd_unit_state->('pvedaemon.service'); + $log_systemd_unit_state->('pvescheduler.service'); + $log_systemd_unit_state->('pvestatd.service'); + + check_time_sync(); + + my $root_free = PVE::Tools::df('/', 10); + log_warn("Less than 5 GB free space on root file system.") + if defined($root_free) && $root_free->{avail} < 5 * 1000*1000*1000; + + log_info("Checking for running guests.."); + my $running_guests = 0; + + my $vms = eval { PVE::API2::Qemu->vmlist({ node => $nodename }) }; + log_warn("Failed to retrieve information about this node's VMs - $@") if $@; + $running_guests += grep { $_->{status} eq 'running' } @$vms if defined($vms); + + my $cts = eval { PVE::API2::LXC->vmlist({ node => $nodename }) }; + log_warn("Failed to retrieve information about this node's CTs - $@") if $@; + $running_guests += grep { $_->{status} eq 'running' } @$cts if defined($cts); + + if ($running_guests > 0) { + log_warn("$running_guests running guest(s) detected - consider migrating or stopping them.") + } else { + log_pass("no running guest detected.") + } + + log_info("Checking if the local node's hostname '$nodename' is resolvable.."); + my $local_ip = eval { PVE::Network::get_ip_from_hostname($nodename) }; + if ($@) { + log_warn("Failed to resolve hostname '$nodename' to IP - $@"); + } else { + log_info("Checking if resolved IP is configured on local node.."); + my $cidr = Net::IP::ip_is_ipv6($local_ip) ? "$local_ip/128" : "$local_ip/32"; + my $configured_ips = PVE::Network::get_local_ip_from_cidr($cidr); + my $ip_count = scalar(@$configured_ips); + + if ($ip_count <= 0) { + log_fail("Resolved node IP '$local_ip' not configured or active for '$nodename'"); + } elsif ($ip_count > 1) { + log_warn("Resolved node IP '$local_ip' active on multiple ($ip_count) interfaces!"); + } else { + log_pass("Resolved node IP '$local_ip' configured and active on single interface."); + } + } + + log_info("Check node certificate's RSA key size"); + my $certs = PVE::API2::Certificates->info({ node => $nodename }); + my $certs_check = { + 'rsaEncryption' => { + minsize => 2048, + name => 'RSA', + }, + 'id-ecPublicKey' => { + minsize => 224, + name => 'ECC', + }, + }; + + my $certs_check_failed = 0; + foreach my $cert (@$certs) { + my ($type, $size, $fn) = $cert->@{qw(public-key-type public-key-bits filename)}; + + if (!defined($type) || !defined($size)) { + log_warn("'$fn': cannot check certificate, failed to get it's type or size!"); + } + + my $check = $certs_check->{$type}; + if (!defined($check)) { + log_warn("'$fn': certificate's public key type '$type' unknown!"); + next; + } + + if ($size < $check->{minsize}) { + log_fail("'$fn', certificate's $check->{name} public key size is less than 2048 bit"); + $certs_check_failed = 1; + } else { + log_pass("Certificate '$fn' passed Debian Busters (and newer) security level for TLS connections ($size >= 2048)"); + } + } + + check_backup_retention_settings(); + check_cifs_credential_location(); + check_custom_pool_roles(); + check_node_and_guest_configurations(); + check_apt_repos(); +} + +my sub colored_if { + my ($str, $color, $condition) = @_; + return "". ($condition ? colored($str, $color) : $str); +} + +__PACKAGE__->register_method ({ + name => 'checklist', + path => 'checklist', + method => 'GET', + description => 'Check (pre-/post-)upgrade conditions.', + parameters => { + additionalProperties => 0, + properties => { + full => { + description => 'perform additional, expensive checks.', + type => 'boolean', + optional => 1, + default => 0, + }, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $kernel_cli = PVE::Tools::file_get_contents('/proc/cmdline'); + if ($kernel_cli =~ /systemd.unified_cgroup_hierarchy=0/){ + $forced_legacy_cgroup = 1; + } + + check_pve_packages(); + check_cluster_corosync(); + check_ceph(); + check_storage_health(); + check_misc(); + + if ($param->{full}) { + check_containers_cgroup_compat(); + } else { + log_skip("NOTE: Expensive checks, like CT cgroupv2 compat, not performed without '--full' parameter"); + } + + print_header("SUMMARY"); + + my $total = 0; + $total += $_ for values %$counters; + + print "TOTAL: $total\n"; + print colored("PASSED: $counters->{pass}\n", 'green'); + print "SKIPPED: $counters->{skip}\n"; + print colored_if("WARNINGS: $counters->{warn}\n", 'yellow', $counters->{warn} > 0); + print colored_if("FAILURES: $counters->{fail}\n", 'bold red', $counters->{fail} > 0); + + if ($counters->{warn} > 0 || $counters->{fail} > 0) { + my $color = $counters->{fail} > 0 ? 'bold red' : 'yellow'; + print colored("\nATTENTION: Please check the output for detailed information!\n", $color); + print colored("Try to solve the problems one at a time and then run this checklist tool again.\n", $color) if $counters->{fail} > 0; + } + + return undef; + }}); + +our $cmddef = [ __PACKAGE__, 'checklist', [], {}]; + +1; diff --git a/bin/Makefile b/bin/Makefile index 248d1b8b9..bd70ad717 100644 --- a/bin/Makefile +++ b/bin/Makefile @@ -4,7 +4,17 @@ PERL_DOC_INC_DIRS=.. -include /usr/share/pve-doc-generator/pve-doc-generator.mk SERVICES = pvestatd pveproxy pvedaemon spiceproxy pvescheduler -CLITOOLS = vzdump pvesubscription pveceph pveam pvesr pvenode pvesh pve6to7 +CLITOOLS = \ + vzdump \ + pvesubscription \ + pveceph \ + pveam \ + pvesr \ + pvenode \ + pvesh \ + pve6to7 \ + pve7to8 \ + SCRIPTS = \ $(SERVICES) \ @@ -45,8 +55,20 @@ all: $(SERVICE_MANS) $(CLI_MANS) mv $@.tmp $@ pve6to7.1: - printf ".TH PVE6TO7 1\n.SH NAME\npve6to7 \- Proxmox VE upgrade checker script for 6.4 to 7.x\n" > $@ - printf ".SH SYNOPSIS\npve6to7 [--full]\n" >> $@ + printf ".TH PVE6TO7 1\n.SH NAME\npve6to7 \- Proxmox VE upgrade checker script for 6.4 to 7.x\n" > $@.tmp + printf ".SH NOTE\npve6to7 is for the previous upgrade, from Proxmox VE 6 to 7, but there's a\ + new Proxmox VE 8 available, see the 'pve7to8' tool.\n" >> $@.tmp + printf ".SH SYNOPSIS\npve6to7 [--full]\n" >> $@.tmp + mv $@.tmp $@ + +pve7to8.1: + printf ".TH PVE7TO8 1\n.SH NAME\npve7to8 \- Proxmox VE upgrade checker script for 7.4+ to current 8.x\n" > $@.tmp + printf ".SH DESCRIPTION\nThis tool will help you to detect common pitfalls and misconfguration\ + before, and during the upgrade of a Proxmox VE system\n" >> $@.tmp + printf "Any failure must be addressed before the upgrade, and any waring must be addressed, \ + or at least carefully evaluated, if a false-positive is suspected\n" >> $@.tmp + printf ".SH SYNOPSIS\npve7to8 [--full]\n" >> $@.tmp + mv $@.tmp $@ pveversion.1.pod: pveversion pveupgrade.1.pod: pveupgrade @@ -73,7 +95,7 @@ install: $(SCRIPTS) $(CLI_MANS) $(SERVICE_MANS) $(BASH_COMPLETIONS) $(ZSH_COMPLE .PHONY: clean clean: - rm -f *.xml.tmp *.1 *.5 *.8 *{synopsis,opts}.adoc docinfo.xml + rm -f *.xml.tmp *.1 *.5 *.8 *{synopsis,opts}.adoc docinfo.xml *.tmp rm -f *~ *.tmp $(CLI_MANS) $(SERVICE_MANS) *.1.pod *.8.pod rm -f *.bash-completion *.service-bash-completion *.zsh-completion *.service-zsh-completion rm -f *.api-verified *.service-api-verified diff --git a/bin/pve7to8 b/bin/pve7to8 new file mode 100755 index 000000000..00b750248 --- /dev/null +++ b/bin/pve7to8 @@ -0,0 +1,8 @@ +#!/usr/bin/perl + +use strict; +use warnings; + +use PVE::CLI::pve7to8; + +PVE::CLI::pve7to8->run_cli_handler(); From de52feb88df6633bace09789e653226be09009f6 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 31 May 2023 18:02:39 +0200 Subject: [PATCH 012/398] drop outdated pve6to7 upgrade checker script for bookworm Signed-off-by: Thomas Lamprecht --- PVE/CLI/Makefile | 1 - PVE/CLI/pve6to7.pm | 1220 -------------------------------------------- bin/Makefile | 1 - bin/pve6to7 | 8 - 4 files changed, 1230 deletions(-) delete mode 100644 PVE/CLI/pve6to7.pm delete mode 100755 bin/pve6to7 diff --git a/PVE/CLI/Makefile b/PVE/CLI/Makefile index 22006cd38..04b604930 100644 --- a/PVE/CLI/Makefile +++ b/PVE/CLI/Makefile @@ -8,7 +8,6 @@ SOURCES = \ pvesr.pm \ pvenode.pm \ pvesh.pm \ - pve6to7.pm \ pve7to8.pm \ all: diff --git a/PVE/CLI/pve6to7.pm b/PVE/CLI/pve6to7.pm deleted file mode 100644 index 4b3a5f732..000000000 --- a/PVE/CLI/pve6to7.pm +++ /dev/null @@ -1,1220 +0,0 @@ -package PVE::CLI::pve6to7; - -use strict; -use warnings; - -use PVE::API2::APT; -use PVE::API2::Ceph; -use PVE::API2::LXC; -use PVE::API2::Qemu; -use PVE::API2::Certificates; -use PVE::API2::Cluster::Ceph; - -use PVE::AccessControl; -use PVE::Ceph::Tools; -use PVE::Cluster; -use PVE::Corosync; -use PVE::INotify; -use PVE::JSONSchema; -use PVE::NodeConfig; -use PVE::RPCEnvironment; -use PVE::Storage; -use PVE::Storage::Plugin; -use PVE::Tools qw(run_command split_list); -use PVE::QemuConfig; -use PVE::QemuServer; -use PVE::VZDump::Common; -use PVE::LXC; -use PVE::LXC::Config; -use PVE::LXC::Setup; - -use Term::ANSIColor; - -use PVE::CLIHandler; - -use base qw(PVE::CLIHandler); - -my $nodename = PVE::INotify::nodename(); - -sub setup_environment { - PVE::RPCEnvironment->setup_default_cli_env(); -} - -my $min_pve_major = 6; -my $min_pve_minor = 4; -my $min_pve_pkgrel = 1; - -my $forced_legacy_cgroup = 0; - -my $counters = { - pass => 0, - skip => 0, - warn => 0, - fail => 0, -}; - -my $log_line = sub { - my ($level, $line) = @_; - - $counters->{$level}++ if defined($level) && defined($counters->{$level}); - - print uc($level), ': ' if defined($level); - print "$line\n"; -}; - -sub log_pass { - print color('green'); - $log_line->('pass', @_); - print color('reset'); -} - -sub log_info { - $log_line->('info', @_); -} -sub log_skip { - $log_line->('skip', @_); -} -sub log_warn { - print color('yellow'); - $log_line->('warn', @_); - print color('reset'); -} -sub log_fail { - print color('red'); - $log_line->('fail', @_); - print color('reset'); -} - -my $print_header_first = 1; -sub print_header { - my ($h) = @_; - print "\n" if !$print_header_first; - print "= $h =\n\n"; - $print_header_first = 0; -} - -my $get_systemd_unit_state = sub { - my ($unit) = @_; - - my $state; - my $filter_output = sub { - $state = shift; - chomp $state; - }; - eval { - run_command(['systemctl', 'is-enabled', "$unit"], outfunc => $filter_output, noerr => 1); - return if !defined($state); - run_command(['systemctl', 'is-active', "$unit"], outfunc => $filter_output, noerr => 1); - }; - - return $state // 'unknown'; -}; -my $log_systemd_unit_state = sub { - my ($unit, $no_fail_on_inactive) = @_; - - my $log_method = \&log_warn; - - my $state = $get_systemd_unit_state->($unit); - if ($state eq 'active') { - $log_method = \&log_pass; - } elsif ($state eq 'inactive') { - $log_method = $no_fail_on_inactive ? \&log_warn : \&log_fail; - } elsif ($state eq 'failed') { - $log_method = \&log_fail; - } - - $log_method->("systemd unit '$unit' is in state '$state'"); -}; - -my $versions; -my $get_pkg = sub { - my ($pkg) = @_; - - $versions = eval { PVE::API2::APT->versions({ node => $nodename }) } if !defined($versions); - - if (!defined($versions)) { - my $msg = "unable to retrieve package version information"; - $msg .= "- $@" if $@; - log_fail("$msg"); - return undef; - } - - my $pkgs = [ grep { $_->{Package} eq $pkg } @$versions ]; - if (!defined $pkgs || $pkgs == 0) { - log_fail("unable to determine installed $pkg version."); - return undef; - } else { - return $pkgs->[0]; - } -}; - -sub check_pve_packages { - print_header("CHECKING VERSION INFORMATION FOR PVE PACKAGES"); - - print "Checking for package updates..\n"; - my $updates = eval { PVE::API2::APT->list_updates({ node => $nodename }); }; - if (!defined($updates)) { - log_warn("$@") if $@; - log_fail("unable to retrieve list of package updates!"); - } elsif (@$updates > 0) { - my $pkgs = join(', ', map { $_->{Package} } @$updates); - log_warn("updates for the following packages are available:\n $pkgs"); - } else { - log_pass("all packages uptodate"); - } - - print "\nChecking proxmox-ve package version..\n"; - if (defined(my $proxmox_ve = $get_pkg->('proxmox-ve'))) { - my $min_pve_ver = "$min_pve_major.$min_pve_minor-$min_pve_pkgrel"; - - my ($maj, $min, $pkgrel) = $proxmox_ve->{OldVersion} =~ m/^(\d+)\.(\d+)-(\d+)/; - - my $upgraded = 0; - - if ($maj > $min_pve_major) { - my $pve_now = "". ($min_pve_major + 1); - my $pve_next = "". ($min_pve_major + 2); - log_pass("already upgraded to Proxmox VE ${pve_now}"); - log_warn("Proxmox VE ${pve_now} got superseeded by Proxmox VE ${pve_next}.\n" - ." Did you mean to use the pve${pve_now}to${pve_next} checker script?" - ); - $upgraded = 1; - } elsif ($maj >= $min_pve_major && $min >= $min_pve_minor && $pkgrel >= $min_pve_pkgrel) { - log_pass("proxmox-ve package has version >= $min_pve_ver"); - } else { - log_fail("proxmox-ve package is too old, please upgrade to >= $min_pve_ver!"); - } - - my ($krunning, $kinstalled) = (qr/5\.(?:13|15)/, 'pve-kernel-5.11'); - if (!$upgraded) { - ($krunning, $kinstalled) = (qr/5\.(?:4|11)/, 'pve-kernel-4.15'); - } - - print "\nChecking running kernel version..\n"; - my $kernel_ver = $proxmox_ve->{RunningKernel}; - if (!defined($kernel_ver)) { - log_fail("unable to determine running kernel version."); - } elsif ($kernel_ver =~ /^$krunning/) { - log_pass("expected running kernel '$kernel_ver'."); - } elsif ($get_pkg->($kinstalled)) { - log_warn("expected kernel '$kinstalled' intalled but not yet rebooted!"); - } else { - log_warn("unexpected running and installed kernel '$kernel_ver'."); - } - } else { - log_fail("proxmox-ve package not found!"); - } -} - - -sub check_storage_health { - print_header("CHECKING CONFIGURED STORAGES"); - my $cfg = PVE::Storage::config(); - - my $ctime = time(); - - my $info = PVE::Storage::storage_info($cfg); - - foreach my $storeid (sort keys %$info) { - my $d = $info->{$storeid}; - if ($d->{enabled}) { - if ($d->{type} eq 'sheepdog') { - log_fail("storage '$storeid' of type 'sheepdog' is enabled - experimental sheepdog support dropped in PVE 6") - } elsif ($d->{active}) { - log_pass("storage '$storeid' enabled and active."); - } else { - log_warn("storage '$storeid' enabled but not active!"); - } - } else { - log_skip("storage '$storeid' disabled."); - } - } -} - -sub check_cluster_corosync { - print_header("CHECKING CLUSTER HEALTH/SETTINGS"); - - if (!PVE::Corosync::check_conf_exists(1)) { - log_skip("standalone node."); - return; - } - - $log_systemd_unit_state->('pve-cluster.service'); - $log_systemd_unit_state->('corosync.service'); - - if (PVE::Cluster::check_cfs_quorum(1)) { - log_pass("Cluster Filesystem is quorate."); - } else { - log_fail("Cluster Filesystem readonly, lost quorum?!"); - } - - my $conf = PVE::Cluster::cfs_read_file('corosync.conf'); - my $conf_nodelist = PVE::Corosync::nodelist($conf); - my $node_votes = 0; - - print "\nAnalzying quorum settings and state..\n"; - if (!defined($conf_nodelist)) { - log_fail("unable to retrieve nodelist from corosync.conf"); - } else { - if (grep { $conf_nodelist->{$_}->{quorum_votes} != 1 } keys %$conf_nodelist) { - log_warn("non-default quorum_votes distribution detected!"); - } - map { $node_votes += $conf_nodelist->{$_}->{quorum_votes} // 0 } keys %$conf_nodelist; - } - - my ($expected_votes, $total_votes); - my $filter_output = sub { - my $line = shift; - ($expected_votes) = $line =~ /^Expected votes:\s*(\d+)\s*$/ - if !defined($expected_votes); - ($total_votes) = $line =~ /^Total votes:\s*(\d+)\s*$/ - if !defined($total_votes); - }; - eval { - run_command(['corosync-quorumtool', '-s'], outfunc => $filter_output, noerr => 1); - }; - - if (!defined($expected_votes)) { - log_fail("unable to get expected number of votes, setting to 0."); - $expected_votes = 0; - } - if (!defined($total_votes)) { - log_fail("unable to get expected number of votes, setting to 0."); - $total_votes = 0; - } - - my $cfs_nodelist = PVE::Cluster::get_clinfo()->{nodelist}; - my $offline_nodes = grep { $cfs_nodelist->{$_}->{online} != 1 } keys %$cfs_nodelist; - if ($offline_nodes > 0) { - log_fail("$offline_nodes nodes are offline!"); - } - - my $qdevice_votes = 0; - if (my $qdevice_setup = $conf->{main}->{quorum}->{device}) { - $qdevice_votes = $qdevice_setup->{votes} // 1; - } - - log_info("configured votes - nodes: $node_votes"); - log_info("configured votes - qdevice: $qdevice_votes"); - log_info("current expected votes: $expected_votes"); - log_info("current total votes: $total_votes"); - - log_warn("expected votes set to non-standard value '$expected_votes'.") - if $expected_votes != $node_votes + $qdevice_votes; - log_warn("total votes < expected votes: $total_votes/$expected_votes!") - if $total_votes < $expected_votes; - - my $conf_nodelist_count = scalar(keys %$conf_nodelist); - my $cfs_nodelist_count = scalar(keys %$cfs_nodelist); - log_warn("cluster consists of less than three quorum-providing nodes!") - if $conf_nodelist_count < 3 && $conf_nodelist_count + $qdevice_votes < 3; - - log_fail("corosync.conf ($conf_nodelist_count) and pmxcfs ($cfs_nodelist_count) don't agree about size of nodelist.") - if $conf_nodelist_count != $cfs_nodelist_count; - - print "\nChecking nodelist entries..\n"; - my $nodelist_pass = 1; - for my $cs_node (sort keys %$conf_nodelist) { - my $entry = $conf_nodelist->{$cs_node}; - if (!defined($entry->{name})) { - $nodelist_pass = 0; - log_fail("$cs_node: no name entry in corosync.conf."); - } - if (!defined($entry->{nodeid})) { - $nodelist_pass = 0; - log_fail("$cs_node: no nodeid configured in corosync.conf."); - } - my $gotLinks = 0; - for my $link (0..7) { - $gotLinks++ if defined($entry->{"ring${link}_addr"}); - } - if ($gotLinks <= 0) { - $nodelist_pass = 0; - log_fail("$cs_node: no ringX_addr (0 <= X <= 7) link defined in corosync.conf."); - } - - my $verify_ring_ip = sub { - my $key = shift; - if (defined(my $ring = $entry->{$key})) { - my ($resolved_ip, undef) = PVE::Corosync::resolve_hostname_like_corosync($ring, $conf); - if (defined($resolved_ip)) { - if ($resolved_ip ne $ring) { - $nodelist_pass = 0; - log_warn("$cs_node: $key '$ring' resolves to '$resolved_ip'.\n Consider replacing it with the currently resolved IP address."); - } - } else { - $nodelist_pass = 0; - log_fail("$cs_node: unable to resolve $key '$ring' to an IP address according to Corosync's resolve strategy - cluster will potentially fail with Corosync 3.x/kronosnet!"); - } - } - }; - for my $link (0..7) { - $verify_ring_ip->("ring${link}_addr"); - } - } - log_pass("nodelist settings OK") if $nodelist_pass; - - print "\nChecking totem settings..\n"; - my $totem = $conf->{main}->{totem}; - my $totem_pass = 1; - - my $transport = $totem->{transport}; - if (defined($transport)) { - if ($transport ne 'knet') { - $totem_pass = 0; - log_fail("Corosync transport explicitly set to '$transport' instead of implicit default!"); - } - } - - # TODO: are those values still up-to-date? - if ((!defined($totem->{secauth}) || $totem->{secauth} ne 'on') && (!defined($totem->{crypto_cipher}) || $totem->{crypto_cipher} eq 'none')) { - $totem_pass = 0; - log_fail("Corosync authentication/encryption is not explicitly enabled (secauth / crypto_cipher / crypto_hash)!"); - } elsif (defined($totem->{crypto_cipher}) && $totem->{crypto_cipher} eq '3des') { - $totem_pass = 0; - log_fail("Corosync encryption cipher set to '3des', no longer supported in Corosync 3.x!"); # FIXME: can be removed? - } - - log_pass("totem settings OK") if $totem_pass; - print "\n"; - log_info("run 'pvecm status' to get detailed cluster status.."); - - if (defined(my $corosync = $get_pkg->('corosync'))) { - if ($corosync->{OldVersion} =~ m/^2\./) { - log_fail("\ncorosync 2.x installed, cluster-wide upgrade to 3.x needed!"); - } elsif ($corosync->{OldVersion} !~ m/^3\./) { - log_fail("\nunexpected corosync version installed: $corosync->{OldVersion}!"); - } - } -} - -sub check_ceph { - print_header("CHECKING HYPER-CONVERGED CEPH STATUS"); - - if (PVE::Ceph::Tools::check_ceph_inited(1)) { - log_info("hyper-converged ceph setup detected!"); - } else { - log_skip("no hyper-converged ceph setup detected!"); - return; - } - - log_info("getting Ceph status/health information.."); - my $ceph_status = eval { PVE::API2::Ceph->status({ node => $nodename }); }; - my $noout = eval { PVE::API2::Cluster::Ceph->get_flag({ flag => "noout" }); }; - if ($@) { - log_fail("failed to get 'noout' flag status - $@"); - } - - my $noout_wanted = 1; - - if (!$ceph_status || !$ceph_status->{health}) { - log_fail("unable to determine Ceph status!"); - } else { - my $ceph_health = $ceph_status->{health}->{status}; - if (!$ceph_health) { - log_fail("unable to determine Ceph health!"); - } elsif ($ceph_health eq 'HEALTH_OK') { - log_pass("Ceph health reported as 'HEALTH_OK'."); - } elsif ($ceph_health eq 'HEALTH_WARN' && $noout && (keys %{$ceph_status->{health}->{checks}} == 1)) { - log_pass("Ceph health reported as 'HEALTH_WARN' with a single failing check and 'noout' flag set."); - } else { - log_warn("Ceph health reported as '$ceph_health'.\n Use the PVE ". - "dashboard or 'ceph -s' to determine the specific issues and try to resolve them."); - } - } - - # TODO: check OSD min-required version, if to low it breaks stuff! - - log_info("getting Ceph daemon versions.."); - my $ceph_versions = eval { PVE::Ceph::Tools::get_cluster_versions(undef, 1); }; - if (!$ceph_versions) { - log_fail("unable to determine Ceph daemon versions!"); - } else { - my $services = [ - { 'key' => 'mon', 'name' => 'monitor' }, - { 'key' => 'mgr', 'name' => 'manager' }, - { 'key' => 'mds', 'name' => 'MDS' }, - { 'key' => 'osd', 'name' => 'OSD' }, - ]; - - foreach my $service (@$services) { - my $name = $service->{name}; - if (my $service_versions = $ceph_versions->{$service->{key}}) { - if (keys %$service_versions == 0) { - log_skip("no running instances detected for daemon type $name."); - } elsif (keys %$service_versions == 1) { - log_pass("single running version detected for daemon type $name."); - } else { - log_warn("multiple running versions detected for daemon type $name!"); - } - } else { - log_skip("unable to determine versions of running Ceph $name instances."); - } - } - - my $overall_versions = $ceph_versions->{overall}; - if (!$overall_versions) { - log_warn("unable to determine overall Ceph daemon versions!"); - } elsif (keys %$overall_versions == 1) { - log_pass("single running overall version detected for all Ceph daemon types."); - $noout_wanted = 0; # off post-upgrade, on pre-upgrade - } else { - log_warn("overall version mismatch detected, check 'ceph versions' output for details!"); - } - } - - if ($noout) { - if ($noout_wanted) { - log_pass("'noout' flag set to prevent rebalancing during cluster-wide upgrades."); - } else { - log_warn("'noout' flag set, Ceph cluster upgrade seems finished."); - } - } elsif ($noout_wanted) { - log_warn("'noout' flag not set - recommended to prevent rebalancing during upgrades."); - } - - log_info("checking Ceph config.."); - my $conf = PVE::Cluster::cfs_read_file('ceph.conf'); - if (%$conf) { - my $global = $conf->{global}; - - my $global_monhost = $global->{mon_host} // $global->{"mon host"} // $global->{"mon-host"}; - if (!defined($global_monhost)) { - log_warn("No 'mon_host' entry found in ceph config.\n It's recommended to add mon_host with all monitor addresses (without ports) to the global section."); - } - - my $ipv6 = $global->{ms_bind_ipv6} // $global->{"ms bind ipv6"} // $global->{"ms-bind-ipv6"}; - if ($ipv6) { - my $ipv4 = $global->{ms_bind_ipv4} // $global->{"ms bind ipv4"} // $global->{"ms-bind-ipv4"}; - if ($ipv6 eq 'true' && (!defined($ipv4) || $ipv4 ne 'false')) { - log_warn("'ms_bind_ipv6' is enabled but 'ms_bind_ipv4' is not disabled.\n Make sure to disable 'ms_bind_ipv4' for ipv6 only clusters, or add an ipv4 network to public/cluster network."); - } - } - - if (defined($global->{keyring})) { - log_warn("[global] config section contains 'keyring' option, which will prevent services from starting with Nautilus.\n Move 'keyring' option to [client] section instead."); - } - - } else { - log_warn("Empty ceph config found"); - } - - my $local_ceph_ver = PVE::Ceph::Tools::get_local_version(1); - if (defined($local_ceph_ver)) { - if ($local_ceph_ver <= 14) { - log_fail("local Ceph version too low, at least Octopus required.."); - } - } else { - log_fail("unable to determine local Ceph version."); - } -} - -sub check_backup_retention_settings { - log_info("Checking backup retention settings.."); - - my $pass = 1; - - my $node_has_retention; - - my $maxfiles_msg = "parameter 'maxfiles' is deprecated with PVE 7.x and will be removed in a " . - "future version, use 'prune-backups' instead."; - - eval { - my $confdesc = PVE::VZDump::Common::get_confdesc(); - - my $fn = "/etc/vzdump.conf"; - my $raw = PVE::Tools::file_get_contents($fn); - - my $conf_schema = { type => 'object', properties => $confdesc, }; - my $param = PVE::JSONSchema::parse_config($conf_schema, $fn, $raw); - - if (defined($param->{maxfiles})) { - $pass = 0; - log_warn("$fn - $maxfiles_msg"); - } - - $node_has_retention = defined($param->{maxfiles}) || defined($param->{'prune-backups'}); - }; - if (my $err = $@) { - $pass = 0; - log_warn("unable to parse node's VZDump configuration - $err"); - } - - my $storage_cfg = PVE::Storage::config(); - - for my $storeid (keys $storage_cfg->{ids}->%*) { - my $scfg = $storage_cfg->{ids}->{$storeid}; - - if (defined($scfg->{maxfiles})) { - $pass = 0; - log_warn("storage '$storeid' - $maxfiles_msg"); - } - - next if !$scfg->{content}->{backup}; - next if defined($scfg->{maxfiles}) || defined($scfg->{'prune-backups'}); - next if $node_has_retention; - - log_info("storage '$storeid' - no backup retention settings defined - by default, PVE " . - "7.x will no longer keep only the last backup, but all backups"); - } - - eval { - my $vzdump_cron = PVE::Cluster::cfs_read_file('vzdump.cron'); - - # only warn once, there might be many jobs... - if (scalar(grep { defined($_->{maxfiles}) } $vzdump_cron->{jobs}->@*)) { - $pass = 0; - log_warn("/etc/pve/vzdump.cron - $maxfiles_msg"); - } - }; - if (my $err = $@) { - $pass = 0; - log_warn("unable to parse node's VZDump configuration - $err"); - } - - log_pass("no problems found.") if $pass; -} - -sub check_cifs_credential_location { - log_info("checking CIFS credential location.."); - - my $regex = qr/^(.*)\.cred$/; - - my $found; - - PVE::Tools::dir_glob_foreach('/etc/pve/priv/', $regex, sub { - my ($filename) = @_; - - my ($basename) = $filename =~ $regex; - - log_warn("CIFS credentials '/etc/pve/priv/$filename' will be moved to " . - "'/etc/pve/priv/storage/$basename.pw' during the update"); - - $found = 1; - }); - - log_pass("no CIFS credentials at outdated location found.") if !$found; -} - -sub check_custom_pool_roles { - log_info("Checking custom roles for pool permissions.."); - - if (! -f "/etc/pve/user.cfg") { - log_skip("user.cfg does not exist"); - return; - } - - my $raw = eval { PVE::Tools::file_get_contents('/etc/pve/user.cfg'); }; - if ($@) { - log_fail("Failed to read '/etc/pve/user.cfg' - $@"); - return; - } - - my $roles = {}; - while ($raw =~ /^\s*(.+?)\s*$/gm) { - my $line = $1; - my @data; - - foreach my $d (split (/:/, $line)) { - $d =~ s/^\s+//; - $d =~ s/\s+$//; - push @data, $d - } - - my $et = shift @data; - next if $et ne 'role'; - - my ($role, $privlist) = @data; - if (!PVE::AccessControl::verify_rolename($role, 1)) { - warn "user config - ignore role '$role' - invalid characters in role name\n"; - next; - } - - $roles->{$role} = {} if !$roles->{$role}; - foreach my $priv (split_list($privlist)) { - $roles->{$role}->{$priv} = 1; - } - } - - foreach my $role (sort keys %{$roles}) { - if (PVE::AccessControl::role_is_special($role)) { - next; - } - - if ($role eq "PVEPoolUser") { - # the user created a custom role named PVEPoolUser - log_fail("Custom role '$role' has a restricted name - a built-in role 'PVEPoolUser' will be available with the upgrade"); - } else { - log_pass("Custom role '$role' has no restricted name"); - } - - my $perms = $roles->{$role}; - if ($perms->{'Pool.Allocate'} && $perms->{'Pool.Audit'}) { - log_pass("Custom role '$role' contains updated pool permissions"); - } elsif ($perms->{'Pool.Allocate'}) { - log_warn("Custom role '$role' contains permission 'Pool.Allocate' - to ensure same behavior add 'Pool.Audit' to this role"); - } else { - log_pass("Custom role '$role' contains no permissions that need to be updated"); - } - } -} - -my sub check_max_length { - my ($raw, $max_length, $warning) = @_; - log_warn($warning) if defined($raw) && length($raw) > $max_length; -} - -sub check_node_and_guest_configurations { - log_info("Checking node and guest description/note legnth.."); - - my @affected_nodes = grep { - my $desc = PVE::NodeConfig::load_config($_)->{desc}; - defined($desc) && length($desc) > 64 * 1024 - } PVE::Cluster::get_nodelist(); - - if (scalar(@affected_nodes) > 0) { - log_warn("Node config description of the following nodes too long for new limit of 64 KiB:\n " - . join(', ', @affected_nodes)); - } else { - log_pass("All node config descriptions fit in the new limit of 64 KiB"); - } - - my $affected_guests_long_desc = []; - my $affected_cts_cgroup_keys = []; - - my $cts = PVE::LXC::config_list(); - for my $vmid (sort { $a <=> $b } keys %$cts) { - my $conf = PVE::LXC::Config->load_config($vmid); - - my $desc = $conf->{description}; - push @$affected_guests_long_desc, "CT $vmid" if defined($desc) && length($desc) > 8 * 1024; - - my $lxc_raw_conf = $conf->{lxc}; - push @$affected_cts_cgroup_keys, "CT $vmid" if (grep (@$_[0] =~ /^lxc\.cgroup\./, @$lxc_raw_conf)); - } - my $vms = PVE::QemuServer::config_list(); - for my $vmid (sort { $a <=> $b } keys %$vms) { - my $desc = PVE::QemuConfig->load_config($vmid)->{description}; - push @$affected_guests_long_desc, "VM $vmid" if defined($desc) && length($desc) > 8 * 1024; - } - if (scalar($affected_guests_long_desc->@*) > 0) { - log_warn("Guest config description of the following virtual-guests too long for new limit of 64 KiB:\n" - ." " . join(", ", $affected_guests_long_desc->@*)); - } else { - log_pass("All guest config descriptions fit in the new limit of 8 KiB"); - } - - log_info("Checking container configs for deprecated lxc.cgroup entries"); - - if (scalar($affected_cts_cgroup_keys->@*) > 0) { - if ($forced_legacy_cgroup) { - log_pass("Found legacy 'lxc.cgroup' keys, but system explicitly configured for legacy hybrid cgroup hierarchy."); - } else { - log_warn("The following CTs have 'lxc.cgroup' keys configured, which will be ignored in the new default unified cgroupv2:\n" - ." " . join(", ", $affected_cts_cgroup_keys->@*) ."\n" - ." Often it can be enough to change to the new 'lxc.cgroup2' prefix after the upgrade to Proxmox VE 7.x"); - } - } else { - log_pass("No legacy 'lxc.cgroup' keys found."); - } -} - -sub check_storage_content { - log_info("Checking storage content type configuration.."); - - my $found; - my $pass = 1; - - my $storage_cfg = PVE::Storage::config(); - - for my $storeid (sort keys $storage_cfg->{ids}->%*) { - my $scfg = $storage_cfg->{ids}->{$storeid}; - - next if $scfg->{shared}; - next if !PVE::Storage::storage_check_enabled($storage_cfg, $storeid, undef, 1); - - my $valid_content = PVE::Storage::Plugin::valid_content_types($scfg->{type}); - - if (scalar(keys $scfg->{content}->%*) == 0 && !$valid_content->{none}) { - $pass = 0; - log_fail("storage '$storeid' does not support configured content type 'none'"); - delete $scfg->{content}->{none}; # scan for guest images below - } - - next if $scfg->{content}->{images}; - next if $scfg->{content}->{rootdir}; - - # Skip 'iscsi(direct)' (and foreign plugins with potentially similiar behavior) with 'none', - # because that means "use LUNs directly" and vdisk_list() in PVE 6.x still lists those. - # It's enough to *not* skip 'dir', because it is the only other storage that supports 'none' - # and 'images' or 'rootdir', hence being potentially misconfigured. - next if $scfg->{type} ne 'dir' && $scfg->{content}->{none}; - - eval { PVE::Storage::activate_storage($storage_cfg, $storeid) }; - if (my $err = $@) { - log_warn("activating '$storeid' failed - $err"); - next; - } - - my $res = eval { PVE::Storage::vdisk_list($storage_cfg, $storeid); }; - if (my $err = $@) { - log_warn("listing images on '$storeid' failed - $err"); - next; - } - my @volids = map { $_->{volid} } $res->{$storeid}->@*; - - my $number = scalar(@volids); - if ($number > 0) { - log_info("storage '$storeid' - neither content type 'images' nor 'rootdir' configured" - .", but found $number guest volume(s)"); - } - } - - my $check_volid = sub { - my ($volid, $vmid, $vmtype, $reference) = @_; - - my $guesttext = $vmtype eq 'qemu' ? 'VM' : 'CT'; - my $prefix = "$guesttext $vmid - volume '$volid' ($reference)"; - - my ($storeid) = PVE::Storage::parse_volume_id($volid, 1); - return if !defined($storeid); - - my $scfg = $storage_cfg->{ids}->{$storeid}; - if (!$scfg) { - $pass = 0; - log_warn("$prefix - storage does not exist!"); - return; - } - - # cannot use parse_volname for containers, as it can return 'images' - # but containers cannot have ISO images attached, so assume 'rootdir' - my $vtype = 'rootdir'; - if ($vmtype eq 'qemu') { - ($vtype) = eval { PVE::Storage::parse_volname($storage_cfg, $volid); }; - return if $@; - } - - if (!$scfg->{content}->{$vtype}) { - $found = 1; - $pass = 0; - log_warn("$prefix - storage does not have content type '$vtype' configured."); - } - }; - - my $cts = PVE::LXC::config_list(); - for my $vmid (sort { $a <=> $b } keys %$cts) { - my $conf = PVE::LXC::Config->load_config($vmid); - - my $volhash = {}; - - my $check = sub { - my ($ms, $mountpoint, $reference) = @_; - - my $volid = $mountpoint->{volume}; - return if !$volid || $mountpoint->{type} ne 'volume'; - - return if $volhash->{$volid}; # volume might be referenced multiple times - - $volhash->{$volid} = 1; - - $check_volid->($volid, $vmid, 'lxc', $reference); - }; - - my $opts = { include_unused => 1 }; - PVE::LXC::Config->foreach_volume_full($conf, $opts, $check, 'in config'); - for my $snapname (keys $conf->{snapshots}->%*) { - my $snap = $conf->{snapshots}->{$snapname}; - PVE::LXC::Config->foreach_volume_full($snap, $opts, $check, "in snapshot '$snapname'"); - } - } - - my $vms = PVE::QemuServer::config_list(); - for my $vmid (sort { $a <=> $b } keys %$vms) { - my $conf = PVE::QemuConfig->load_config($vmid); - - my $volhash = {}; - - my $check = sub { - my ($key, $drive, $reference) = @_; - - my $volid = $drive->{file}; - return if $volid =~ m|^/|; - - return if $volhash->{$volid}; # volume might be referenced multiple times - - $volhash->{$volid} = 1; - - $check_volid->($volid, $vmid, 'qemu', $reference); - }; - - my $opts = { - extra_keys => ['vmstate'], - include_unused => 1, - }; - # startup from a suspended state works even without 'images' content type on the - # state storage, so do not check 'vmstate' for $conf - PVE::QemuConfig->foreach_volume_full($conf, { include_unused => 1 }, $check, 'in config'); - for my $snapname (keys $conf->{snapshots}->%*) { - my $snap = $conf->{snapshots}->{$snapname}; - PVE::QemuConfig->foreach_volume_full($snap, $opts, $check, "in snapshot '$snapname'"); - } - } - - if ($found) { - log_warn("Proxmox VE 7.0 enforces stricter content type checks. The guests above " . - "might not work until the storage configuration is fixed."); - } - - if ($pass) { - log_pass("no problems found"); - } -} - -sub check_containers_cgroup_compat { - if ($forced_legacy_cgroup) { - log_skip("System explicitly configured for legacy hybrid cgroup hierarchy."); - return; - } - - my $supports_cgroupv2 = sub { - my ($conf, $rootdir, $ctid) = @_; - - my $get_systemd_version = sub { - my ($self) = @_; - - my $sd_lib_dir = -d "/lib/systemd" ? "/lib/systemd" : "/usr/lib/systemd"; - my $libsd = PVE::Tools::dir_glob_regex($sd_lib_dir, "libsystemd-shared-.+\.so"); - if (defined($libsd) && $libsd =~ /libsystemd-shared-(\d+)\.so/) { - return $1; - } - - return undef; - }; - - my $unified_cgroupv2_support = sub { - my ($self) = @_; - - # https://www.freedesktop.org/software/systemd/man/systemd.html - # systemd is installed as symlink to /sbin/init - my $systemd = CORE::readlink('/sbin/init'); - - # assume non-systemd init will run with unified cgroupv2 - if (!defined($systemd) || $systemd !~ m@/systemd$@) { - return 1; - } - - # systemd version 232 (e.g. debian stretch) supports the unified hierarchy - my $sdver = $get_systemd_version->(); - if (!defined($sdver) || $sdver < 232) { - return 0; - } - - return 1; - }; - - my $ostype = $conf->{ostype}; - if (!defined($ostype)) { - log_warn("Found CT ($ctid) without 'ostype' set!"); - } elsif ($ostype eq 'devuan' || $ostype eq 'alpine') { - return 1; # no systemd, no cgroup problems - } - - my $lxc_setup = PVE::LXC::Setup->new($conf, $rootdir); - return $lxc_setup->protected_call($unified_cgroupv2_support); - }; - - my $log_problem = sub { - my ($ctid) = @_; - log_warn("Found at least one CT ($ctid) which does not support running in a unified cgroup v2" . - " layout.\n Either upgrade the Container distro or set systemd.unified_cgroup_hierarchy=0 " . - "in the Proxmox VE hosts' kernel cmdline! Skipping further CT compat checks." - ); - }; - - my $cts = eval { PVE::API2::LXC->vmlist({ node => $nodename }) }; - if ($@) { - log_warn("Failed to retrieve information about this node's CTs - $@"); - return; - } - - if (!defined($cts) || !scalar(@$cts)) { - log_skip("No containers on node detected."); - return; - } - - my @running_cts = sort { $a <=> $b } grep { $_->{status} eq 'running' } @$cts; - my @offline_cts = sort { $a <=> $b } grep { $_->{status} ne 'running' } @$cts; - - for my $ct (@running_cts) { - my $ctid = $ct->{vmid}; - my $pid = eval { PVE::LXC::find_lxc_pid($ctid) }; - if (my $err = $@) { - log_warn("Failed to get PID for running CT $ctid - $err"); - next; - } - my $rootdir = "/proc/$pid/root"; - my $conf = PVE::LXC::Config->load_config($ctid); - - my $ret = eval { $supports_cgroupv2->($conf, $rootdir, $ctid) }; - if (my $err = $@) { - log_warn("Failed to get cgroup support status for CT $ctid - $err"); - next; - } - if (!$ret) { - $log_problem->($ctid); - return; - } - } - - my $storage_cfg = PVE::Storage::config(); - for my $ct (@offline_cts) { - my $ctid = $ct->{vmid}; - my ($conf, $rootdir, $ret); - eval { - $conf = PVE::LXC::Config->load_config($ctid); - $rootdir = PVE::LXC::mount_all($ctid, $storage_cfg, $conf); - $ret = $supports_cgroupv2->($conf, $rootdir, $ctid); - }; - if (my $err = $@) { - log_warn("Failed to load config and mount CT $ctid - $err"); - eval { PVE::LXC::umount_all($ctid, $storage_cfg, $conf) }; - next; - } - if (!$ret) { - $log_problem->($ctid); - eval { PVE::LXC::umount_all($ctid, $storage_cfg, $conf) }; - last; - } - - eval { PVE::LXC::umount_all($ctid, $storage_cfg, $conf) }; - } -}; - -sub check_security_repo { - log_info("Checking if the suite for the Debian security repository is correct.."); - - my $found = 0; - - my $dir = '/etc/apt/sources.list.d'; - my $in_dir = 0; - - my $check_file = sub { - my ($file) = @_; - - $file = "${dir}/${file}" if $in_dir; - - my $raw = eval { PVE::Tools::file_get_contents($file) }; - return if !defined($raw); - my @lines = split(/\n/, $raw); - - my $number = 0; - for my $line (@lines) { - $number++; - - next if length($line) == 0; # split would result in undef then... - - ($line) = split(/#/, $line); - - next if $line !~ m/^deb[[:space:]]/; # is case sensitive - - my $suite; - - # catch any of - # https://deb.debian.org/debian-security - # http://security.debian.org/debian-security - # http://security.debian.org/ - if ($line =~ m|https?://deb\.debian\.org/debian-security/?\s+(\S*)|i) { - $suite = $1; - } elsif ($line =~ m|https?://security\.debian\.org(?:.*?)\s+(\S*)|i) { - $suite = $1; - } else { - next; - } - - $found = 1; - - my $where = "in ${file}:${number}"; - - if ($suite eq 'buster/updates') { - log_info("Make sure to change the suite of the Debian security repository " . - "from 'buster/updates' to 'bullseye-security' - $where"); - } elsif ($suite eq 'bullseye-security') { - log_pass("already using 'bullseye-security'"); - } else { - log_fail("The new suite of the Debian security repository should be " . - "'bullseye-security' - $where"); - } - } - }; - - $check_file->("/etc/apt/sources.list"); - - $in_dir = 1; - - PVE::Tools::dir_glob_foreach($dir, '^.*\.list$', $check_file); - - if (!$found) { - # only warn, it might be defined in a .sources file or in a way not catched above - log_warn("No Debian security repository detected in /etc/apt/sources.list and " . - "/etc/apt/sources.list.d/*.list"); - } -} - -sub check_misc { - print_header("MISCELLANEOUS CHECKS"); - my $ssh_config = eval { PVE::Tools::file_get_contents('/root/.ssh/config') }; - if (defined($ssh_config)) { - log_fail("Unsupported SSH Cipher configured for root in /root/.ssh/config: $1") - if $ssh_config =~ /^Ciphers .*(blowfish|arcfour|3des).*$/m; - } else { - log_skip("No SSH config file found."); - } - - log_info("Checking common daemon services.."); - $log_systemd_unit_state->('pveproxy.service'); - $log_systemd_unit_state->('pvedaemon.service'); - $log_systemd_unit_state->('pvestatd.service'); - - my $root_free = PVE::Tools::df('/', 10); - log_warn("Less than 4 GiB free space on root file system.") - if defined($root_free) && $root_free->{avail} < 4*1024*1024*1024; - - log_info("Checking for running guests.."); - my $running_guests = 0; - - my $vms = eval { PVE::API2::Qemu->vmlist({ node => $nodename }) }; - log_warn("Failed to retrieve information about this node's VMs - $@") if $@; - $running_guests += grep { $_->{status} eq 'running' } @$vms if defined($vms); - - my $cts = eval { PVE::API2::LXC->vmlist({ node => $nodename }) }; - log_warn("Failed to retrieve information about this node's CTs - $@") if $@; - $running_guests += grep { $_->{status} eq 'running' } @$cts if defined($cts); - - if ($running_guests > 0) { - log_warn("$running_guests running guest(s) detected - consider migrating or stopping them.") - } else { - log_pass("no running guest detected.") - } - - log_info("Checking if the local node's hostname '$nodename' is resolvable.."); - my $local_ip = eval { PVE::Network::get_ip_from_hostname($nodename) }; - if ($@) { - log_warn("Failed to resolve hostname '$nodename' to IP - $@"); - } else { - log_info("Checking if resolved IP is configured on local node.."); - my $cidr = Net::IP::ip_is_ipv6($local_ip) ? "$local_ip/128" : "$local_ip/32"; - my $configured_ips = PVE::Network::get_local_ip_from_cidr($cidr); - my $ip_count = scalar(@$configured_ips); - - if ($ip_count <= 0) { - log_fail("Resolved node IP '$local_ip' not configured or active for '$nodename'"); - } elsif ($ip_count > 1) { - log_warn("Resolved node IP '$local_ip' active on multiple ($ip_count) interfaces!"); - } else { - log_pass("Resolved node IP '$local_ip' configured and active on single interface."); - } - } - - log_info("Check node certificate's RSA key size"); - my $certs = PVE::API2::Certificates->info({ node => $nodename }); - my $certs_check = { - 'rsaEncryption' => { - minsize => 2048, - name => 'RSA', - }, - 'id-ecPublicKey' => { - minsize => 224, - name => 'ECC', - }, - }; - - my $certs_check_failed = 0; - foreach my $cert (@$certs) { - my ($type, $size, $fn) = $cert->@{qw(public-key-type public-key-bits filename)}; - - if (!defined($type) || !defined($size)) { - log_warn("'$fn': cannot check certificate, failed to get it's type or size!"); - } - - my $check = $certs_check->{$type}; - if (!defined($check)) { - log_warn("'$fn': certificate's public key type '$type' unknown, check Debian Busters release notes"); - next; - } - - if ($size < $check->{minsize}) { - log_fail("'$fn', certificate's $check->{name} public key size is less than 2048 bit"); - $certs_check_failed = 1; - } else { - log_pass("Certificate '$fn' passed Debian Busters security level for TLS connections ($size >= 2048)"); - } - } - - check_backup_retention_settings(); - check_cifs_credential_location(); - check_custom_pool_roles(); - check_node_and_guest_configurations(); - check_storage_content(); - check_security_repo(); -} - -__PACKAGE__->register_method ({ - name => 'checklist', - path => 'checklist', - method => 'GET', - description => 'Check (pre-/post-)upgrade conditions.', - parameters => { - additionalProperties => 0, - properties => { - full => { - description => 'perform additional, expensive checks.', - type => 'boolean', - optional => 1, - default => 0, - }, - }, - }, - returns => { type => 'null' }, - code => sub { - my ($param) = @_; - - my $kernel_cli = PVE::Tools::file_get_contents('/proc/cmdline'); - if ($kernel_cli =~ /systemd.unified_cgroup_hierarchy=0/){ - $forced_legacy_cgroup = 1; - } - - check_pve_packages(); - check_cluster_corosync(); - check_ceph(); - check_storage_health(); - check_misc(); - - if ($param->{full}) { - check_containers_cgroup_compat(); - } else { - log_skip("NOTE: Expensive checks, like CT cgroupv2 compat, not performed without '--full' parameter"); - } - - print_header("SUMMARY"); - - my $total = 0; - $total += $_ for values %$counters; - - print "TOTAL: $total\n"; - print colored("PASSED: $counters->{pass}\n", 'green'); - print "SKIPPED: $counters->{skip}\n"; - print colored("WARNINGS: $counters->{warn}\n", 'yellow'); - print colored("FAILURES: $counters->{fail}\n", 'red'); - - if ($counters->{warn} > 0 || $counters->{fail} > 0) { - my $color = $counters->{fail} > 0 ? 'red' : 'yellow'; - print colored("\nATTENTION: Please check the output for detailed information!\n", $color); - print colored("Try to solve the problems one at a time and then run this checklist tool again.\n", $color) if $counters->{fail} > 0; - } - - return undef; - }}); - -our $cmddef = [ __PACKAGE__, 'checklist', [], {}]; - -1; diff --git a/bin/Makefile b/bin/Makefile index bd70ad717..06d8148ee 100644 --- a/bin/Makefile +++ b/bin/Makefile @@ -12,7 +12,6 @@ CLITOOLS = \ pvesr \ pvenode \ pvesh \ - pve6to7 \ pve7to8 \ diff --git a/bin/pve6to7 b/bin/pve6to7 deleted file mode 100755 index edebd6d61..000000000 --- a/bin/pve6to7 +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/perl - -use strict; -use warnings; - -use PVE::CLI::pve6to7; - -PVE::CLI::pve6to7->run_cli_handler(); From ff166f9c586a57b46a2733c04ae8e896ecb1060e Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 31 May 2023 18:10:20 +0200 Subject: [PATCH 013/398] bump version to 8.0.0~4 Signed-off-by: Thomas Lamprecht --- debian/changelog | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/debian/changelog b/debian/changelog index 51005abcc..7ba9c9438 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +pve-manager (8.0.0~4) bookworm; urgency=medium + + * update source of pve7to8 upgrade helper script for post-upgrade checks + + * drop outdated pve6to7 upgrade checker script for bookworm + + -- Proxmox Support Team Wed, 31 May 2023 18:10:16 +0200 + pve-manager (8.0.0~3) bookworm; urgency=medium * update shipped apt sources list for Debian Bookworm From 45d1707c7508a88ecde18e826ce1bae65b3c445f Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 31 May 2023 18:19:21 +0200 Subject: [PATCH 014/398] pve7to8: cope with native version of proxmox-ve package Signed-off-by: Thomas Lamprecht --- PVE/CLI/pve7to8.pm | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 66cd542c1..06f4e2bb2 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -180,9 +180,10 @@ sub check_pve_packages { print "\nChecking proxmox-ve package version..\n"; if (defined(my $proxmox_ve = $get_pkg->('proxmox-ve'))) { + # TODO: update to native version for pve8to9 my $min_pve_ver = "$min_pve_major.$min_pve_minor-$min_pve_pkgrel"; - my ($maj, $min, $pkgrel) = $proxmox_ve->{OldVersion} =~ m/^(\d+)\.(\d+)-(\d+)/; + my ($maj, $min, $pkgrel) = $proxmox_ve->{OldVersion} =~ m/^(\d+)\.(\d+)[.-](\d+)/; my $upgraded = 0; From 0b1eb12908c6b7263c0f952e8eac390d95d4d6da Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 31 May 2023 18:19:51 +0200 Subject: [PATCH 015/398] bump version to 8.0.0~5 Signed-off-by: Thomas Lamprecht --- debian/changelog | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index 7ba9c9438..4d56f8d61 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,10 +1,12 @@ -pve-manager (8.0.0~4) bookworm; urgency=medium +pve-manager (8.0.0~5) bookworm; urgency=medium * update source of pve7to8 upgrade helper script for post-upgrade checks * drop outdated pve6to7 upgrade checker script for bookworm - -- Proxmox Support Team Wed, 31 May 2023 18:10:16 +0200 + * pve7to8: cope with native version of proxmox-ve package + + -- Proxmox Support Team Wed, 31 May 2023 18:19:47 +0200 pve-manager (8.0.0~3) bookworm; urgency=medium From d6d3e55b1d9f960d702a86aa0516d601a787f4de Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 12 May 2023 14:23:51 +0200 Subject: [PATCH 016/398] vzdump: prepare 'exclude-path' for array format we want to move the 'exclude-path' to an array format (from 'string-alist') prepare the code that it can be either a string or a list Signed-off-by: Dominik Csapak --- PVE/VZDump.pm | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index a04837e7e..dde347562 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -279,7 +279,15 @@ sub read_vzdump_defaults { my $conf_schema = { type => 'object', properties => $confdesc_for_defaults }; my $res = PVE::JSONSchema::parse_config($conf_schema, $fn, $raw); if (my $excludes = $res->{'exclude-path'}) { - $res->{'exclude-path'} = PVE::Tools::split_args($excludes); + if (ref($excludes) eq 'ARRAY') { + my $list = []; + for my $path ($excludes->@*) { + push $list->@*, PVE::Tools::split_args($path)->@*; + } + $res->{'exclude-path'} = $list; + } else { + $res->{'exclude-path'} = PVE::Tools::split_args($excludes); + } } if (defined($res->{mailto})) { my @mailto = split_list($res->{mailto}); @@ -1339,10 +1347,15 @@ sub option_exists { sub parse_mailto_exclude_path { my ($param) = @_; - # exclude-path list need to be 0 separated + # exclude-path list need to be 0 separated or be an array if (defined($param->{'exclude-path'})) { - my @expaths = split(/\0/, $param->{'exclude-path'} || ''); - $param->{'exclude-path'} = [ @expaths ]; + my $expaths; + if (ref($param->{'exclude-path'}) eq 'ARRAY') { + $expaths = $param->{'exclude-path'}; + } else { + $expaths = [split(/\0/, $param->{'exclude-path'} || '')]; + } + $param->{'exclude-path'} = $expaths; } if (defined($param->{mailto})) { From 6a75aa33968bd89d9f83a5476f890269bb59ed23 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Mon, 5 Jun 2023 10:02:58 +0200 Subject: [PATCH 017/398] vzdump: add comment about why we still split_args for arrays Signed-off-by: Wolfgang Bumiller --- PVE/VZDump.pm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index dde347562..df6fd64b3 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -282,6 +282,8 @@ sub read_vzdump_defaults { if (ref($excludes) eq 'ARRAY') { my $list = []; for my $path ($excludes->@*) { + # We still use `split_args` here to be compatible with old configs where one line + # still has multiple space separated entries. push $list->@*, PVE::Tools::split_args($path)->@*; } $res->{'exclude-path'} = $list; From a4c38b8f08a3ad853150968894da106da852f2a7 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Mon, 5 Jun 2023 10:05:38 +0200 Subject: [PATCH 018/398] bump version to 8.0.0~6 Signed-off-by: Wolfgang Bumiller --- debian/changelog | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/debian/changelog b/debian/changelog index 4d56f8d61..6385c88b7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,10 @@ +pve-manager (8.0.0~6) bookworm; urgency=medium + + * vzdump: prepare to support 'exclude-path' as arry (multiple lines in the + config) + + -- Proxmox Support Team Mon, 05 Jun 2023 10:05:00 +0200 + pve-manager (8.0.0~5) bookworm; urgency=medium * update source of pve7to8 upgrade helper script for post-upgrade checks From 3211e833d9140b8e3241993029c7693756bb9950 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 12:15:09 +0200 Subject: [PATCH 019/398] ui: USB selector: fix typo Signed-off-by: Thomas Lamprecht --- www/manager6/form/USBSelector.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/form/USBSelector.js b/www/manager6/form/USBSelector.js index 0d5116992..a67c87654 100644 --- a/www/manager6/form/USBSelector.js +++ b/www/manager6/form/USBSelector.js @@ -125,7 +125,7 @@ Ext.define('PVE.form.USBSelector', { name: 'product_and_id', type: 'string', convert: (v, rec) => { - let res = rec.data.product || gettext('Unkown'); + let res = rec.data.product || gettext('Unknown'); res += " (" + rec.data.usbid + ")"; return res; }, From 76a165a1f2cdc9bbdfe5449f2acd47b303552187 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 13:25:17 +0200 Subject: [PATCH 020/398] api: subscription: style cleanups Signed-off-by: Thomas Lamprecht --- PVE/API2/Subscription.pm | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/PVE/API2/Subscription.pm b/PVE/API2/Subscription.pm index 5f599e158..dd3ac4505 100644 --- a/PVE/API2/Subscription.pm +++ b/PVE/API2/Subscription.pm @@ -82,7 +82,8 @@ sub write_etc_subscription { my $server_id = PVE::API2Tools::get_hwaddress(); mkdir "/etc/apt/auth.conf.d"; - Proxmox::RS::Subscription::write_subscription($filename, "/etc/apt/auth.conf.d/pve.conf", "enterprise.proxmox.com/debian/pve", $info); + Proxmox::RS::Subscription::write_subscription( + $filename, "/etc/apt/auth.conf.d/pve.conf", "enterprise.proxmox.com/debian/pve", $info); } __PACKAGE__->register_method ({ @@ -152,7 +153,7 @@ __PACKAGE__->register_method ({ properties => { node => get_standard_option('pve-node'), force => { - description => "Always connect to server, even if we have up to date info inside local cache.", + description => "Always connect to server, even if local cache is still valid.", type => 'boolean', optional => 1, default => 0 @@ -219,7 +220,7 @@ __PACKAGE__->register_method ({ my $key = PVE::Tools::trim($param->{key}); - my $info = { + my $new_info = { status => 'New', key => $key, checktime => time(), @@ -230,14 +231,15 @@ __PACKAGE__->register_method ({ check_key($key, $req_sockets); - write_etc_subscription($info); + write_etc_subscription($new_info); my $dccfg = PVE::Cluster::cfs_read_file('datacenter.cfg'); my $proxy = $dccfg->{http_proxy}; - $info = Proxmox::RS::Subscription::check_subscription($key, $server_id, "", "Proxmox VE", $proxy); + my $checked_info = Proxmox::RS::Subscription::check_subscription( + $key, $server_id, "", "Proxmox VE", $proxy); - write_etc_subscription($info); + write_etc_subscription($checked_info); return undef; }}); From fc3e061bd009e171ea9bef68845f2458e78ef3b6 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 13:25:42 +0200 Subject: [PATCH 021/398] api: subscription: factor out check for cache being valid multi-line post-if's are against our style guide. Signed-off-by: Thomas Lamprecht --- PVE/API2/Subscription.pm | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/PVE/API2/Subscription.pm b/PVE/API2/Subscription.pm index dd3ac4505..255cc2143 100644 --- a/PVE/API2/Subscription.pm +++ b/PVE/API2/Subscription.pm @@ -77,6 +77,15 @@ sub read_etc_subscription { return $info; } +my sub cache_is_valid { + my ($info) = @_; + + return if !$info || $info->{status} ne 'active'; + + my $checked_info = Proxmox::RS::Subscription::check_age($info, 1); + return $checked_info->{status} eq 'active' +} + sub write_etc_subscription { my ($info) = @_; @@ -173,11 +182,7 @@ __PACKAGE__->register_method ({ die "Updating offline key not possible - please remove and re-add subscription key to switch to online key.\n" if $info->{signature}; - # key has been recently checked - return undef - if !$param->{force} - && $info->{status} eq 'active' - && Proxmox::RS::Subscription::check_age($info, 1)->{status} eq 'active'; + return undef if !$param->{force} && cache_is_valid($info); # key has been recently checked my $req_sockets = get_sockets(); check_key($key, $req_sockets); From 75d1eb55aeb4044edf524eea3b99a91b9408a56c Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 13:27:01 +0200 Subject: [PATCH 022/398] cli: subscription: simplify printing output in get command Signed-off-by: Thomas Lamprecht --- PVE/CLI/pvesubscription.pm | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/PVE/CLI/pvesubscription.pm b/PVE/CLI/pvesubscription.pm index a62d2be17..2eb26cb44 100755 --- a/PVE/CLI/pvesubscription.pm +++ b/PVE/CLI/pvesubscription.pm @@ -51,13 +51,10 @@ __PACKAGE__->register_method({ our $cmddef = { update => [ 'PVE::API2::Subscription', 'update', undef, { node => $nodename } ], - get => [ 'PVE::API2::Subscription', 'get', undef, { node => $nodename }, - sub { - my $info = shift; - foreach my $k (sort keys %$info) { - print "$k: $info->{$k}\n"; - } - }], + get => [ 'PVE::API2::Subscription', 'get', undef, { node => $nodename }, sub { + my $info = shift; + print "$_: $info->{$_}\n" for sort keys %$info; + }], set => [ 'PVE::API2::Subscription', 'set', ['key'], { node => $nodename } ], "set-offline-key" => [ __PACKAGE__, 'set_offline_key', ['data'] ], delete => [ 'PVE::API2::Subscription', 'delete', undef, { node => $nodename } ], From 93542d7748eb08764d765a3a5a75042a5a4df621 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 13:07:48 +0200 Subject: [PATCH 023/398] pveceph: support new enterprise repository With Proxmox VE 8, we'll have support for a enterprise ceph repo, accessed through Proxmox VE subscriptions, to provide more broadly tested ceph updates for production setups. Replace the test-repository parameter with an actual enum of selectable repo types for: - test (same as previously selected through setting test-repository) - no-subscription (the previous default, then named "main") - enterprise (new and the default now, recommended for production) Note that writing the auth-part is a bit hacky and might/should be improved. Signed-off-by: Thomas Lamprecht --- PVE/API2/Subscription.pm | 9 +++++++++ PVE/CLI/pveceph.pm | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/PVE/API2/Subscription.pm b/PVE/API2/Subscription.pm index 255cc2143..c7b81ee93 100644 --- a/PVE/API2/Subscription.pm +++ b/PVE/API2/Subscription.pm @@ -93,6 +93,15 @@ sub write_etc_subscription { mkdir "/etc/apt/auth.conf.d"; Proxmox::RS::Subscription::write_subscription( $filename, "/etc/apt/auth.conf.d/pve.conf", "enterprise.proxmox.com/debian/pve", $info); + + # FIXME: improve this, especially the selection of valid ceph-releases + # NOTE: currently we should add future ceph releases as early as possible, to ensure that + my $ceph_auth = ''; + for my $ceph_release ('quincy', 'reef') { + $ceph_auth .= "machine enterprise.proxmox.com/debian/ceph-${ceph_release}" + ." login $info->{key} password $info->{serverid}\n" + } + PVE::Tools::file_set_contents("/etc/apt/auth.conf.d/ceph.conf", $ceph_auth); } __PACKAGE__->register_method ({ diff --git a/PVE/CLI/pveceph.pm b/PVE/CLI/pveceph.pm index 3eeabf8ab..c13aaa49d 100755 --- a/PVE/CLI/pveceph.pm +++ b/PVE/CLI/pveceph.pm @@ -124,18 +124,19 @@ __PACKAGE__->register_method ({ description => "Ceph version to install.", optional => 1, }, + repository => { + type => 'string', + enum => ['enterprise', 'no-subscription', 'test'], + default => 'enterprise', + description => "Ceph repository to use.", + optional => 1, + }, 'allow-experimental' => { type => 'boolean', default => 0, optional => 1, description => "Allow experimental versions. Use with care!", }, - 'test-repository' => { - type => 'boolean', - default => 0, - optional => 1, - description => "Use the test, not the main repository. Use with care!", - }, }, }, returns => { type => 'null' }, @@ -144,11 +145,13 @@ __PACKAGE__->register_method ({ my $cephver = $param->{version} || $default_ceph_version; - my $repo = $param->{'test-repository'} ? 'test' : 'main'; + my $repo = $param->{'repository'} // 'enterprise'; + my $enterprise_repo = $repo eq 'enterprise'; + my $cdn = $enterprise_repo ? 'https://enterprise.proxmox.com' : 'http://download.proxmox.com'; my $repolist; if ($cephver eq 'quincy') { - $repolist = "deb http://download.proxmox.com/debian/ceph-quincy bookworm $repo\n"; + $repolist = "deb ${cdn}/debian/ceph-quincy bookworm $repo\n"; } else { die "unsupported ceph version: $cephver"; } From bbfbf0efe9940100733bdd2db28d147d572e82a1 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 13:23:38 +0200 Subject: [PATCH 024/398] ceph install: output some hints depending repo choice and subscription Signed-off-by: Thomas Lamprecht --- PVE/CLI/pveceph.pm | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/PVE/CLI/pveceph.pm b/PVE/CLI/pveceph.pm index c13aaa49d..eda87f609 100755 --- a/PVE/CLI/pveceph.pm +++ b/PVE/CLI/pveceph.pm @@ -10,6 +10,8 @@ use JSON; use Data::Dumper; use LWP::UserAgent; +use Proxmox::RS::Subscription; + use PVE::SafeSyslog; use PVE::Cluster; use PVE::INotify; @@ -106,6 +108,12 @@ __PACKAGE__->register_method ({ return undef; }}); +my sub has_valid_subscription { + my $info = eval { Proxmox::RS::Subscription::read_subscription('/etc/subscription') } // {}; + warn "couldn't check subscription info - $@" if $@; + return $info->{status} && $info->{status} eq 'active'; # age check? +} + my $supported_ceph_versions = ['quincy']; my $default_ceph_version = 'quincy'; @@ -149,6 +157,19 @@ __PACKAGE__->register_method ({ my $enterprise_repo = $repo eq 'enterprise'; my $cdn = $enterprise_repo ? 'https://enterprise.proxmox.com' : 'http://download.proxmox.com'; + if (has_valid_subscription()) { + warn "\nNOTE: The node has an active subscription but a non-production Ceph repository selected.\n\n" + if !$enterprise_repo; + } elsif ($enterprise_repo) { + warn "\nWARN: Enterprise repository selected, but no active subscription!\n\n"; + } elsif ($repo eq 'no-subscription') { + warn "\nHINT: The no-subscription repository is not the best choice for production setups.\n" + ."Proxmox recommends using the enterprise repository with a valid subscription.\n"; + } else { + warn "\nWARN: The test repository should only be used for test setups or after consulting" + ." the official Proxmox support!\n\n" + } + my $repolist; if ($cephver eq 'quincy') { $repolist = "deb ${cdn}/debian/ceph-quincy bookworm $repo\n"; From cd56fd5557e39a4d427bde5d34ab4a23fa6bb5b0 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 13:42:47 +0200 Subject: [PATCH 025/398] ui: ceph: fix code indentation in onShow of wizard Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index 5e6f7bc60..3dec60109 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -170,12 +170,12 @@ Ext.define('PVE.ceph.CephInstallWizard', { tp.setActiveTab(initialTab); }, onShow: function() { - this.callParent(arguments); - var isInstalled = this.getViewModel().get('isInstalled'); - if (isInstalled) { - this.getViewModel().set('configuration', false); - this.setInitialTab(2); - } + this.callParent(arguments); + var isInstalled = this.getViewModel().get('isInstalled'); + if (isInstalled) { + this.getViewModel().set('configuration', false); + this.setInitialTab(2); + } }, items: [ { From 35ff09a46ef3a337dfdada236b93ead629346858 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 18:12:02 +0200 Subject: [PATCH 026/398] ui: ceph install: increase dimension of window with 4:3 ratio especially the console window is rather on the small side otherwise.. Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index 3dec60109..cbe9cca01 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -139,6 +139,9 @@ Ext.define('PVE.ceph.CephInstallWizard', { resizable: false, nodename: undefined, + width: 760, // 4:3 + height: 570, + viewModel: { data: { nodename: '', From b686fd3774d87ca1e47fb841544a33cc3dad1ba4 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 18:02:41 +0200 Subject: [PATCH 027/398] ui: ceph install wizard: allow selecting repository provide a second combo box that allows one to select which specific repository out of enterprise, no-subscription or test one would like to use. Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 54 +++++++++++++++++++------- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index cbe9cca01..2a7bb381a 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -146,6 +146,7 @@ Ext.define('PVE.ceph.CephInstallWizard', { data: { nodename: '', cephRelease: 'quincy', + cephRepo: 'enterprise', configuration: true, isInstalled: false, }, @@ -205,7 +206,7 @@ Ext.define('PVE.ceph.CephInstallWizard', { }, { xtype: 'pveCephHighestVersionDisplay', - labelWidth: 180, + labelWidth: 150, cbind: { nodename: '{nodename}', }, @@ -218,20 +219,46 @@ Ext.define('PVE.ceph.CephInstallWizard', { }, }, { - xtype: 'pveCephVersionSelector', - labelWidth: 180, - submitValue: false, - bind: { - value: '{cephRelease}', + xtype: 'container', + layout: 'hbox', + defaults: { + border: false, + layout: 'anchor', + flex: 1, }, - listeners: { - change: function(field, release) { - let wizard = this.up('pveCephInstallWizard'); - wizard.down('#next').setText( - Ext.String.format(gettext('Start {0} installation'), release), - ); + items: [{ + xtype: 'pveCephVersionSelector', + labelWidth: 150, + padding: '0 10 0 0', + submitValue: false, + bind: { + value: '{cephRelease}', + }, + listeners: { + change: function(field, release) { + let wizard = this.up('pveCephInstallWizard'); + wizard.down('#next').setText( + Ext.String.format(gettext('Start {0} installation'), release), + ); + }, }, }, + { + xtype: 'proxmoxKVComboBox', + fieldLabel: gettext('Repository'), + padding: '0 0 0 10', + comboItems: [ + ['enterprise', gettext('Enterprise (recommended)')], + ['no-subscription', gettext('No-Subscription')], + ['test', gettext('Test')], + ], + labelWidth: 150, + submitValue: false, + value: 'enterprise', + bind: { + value: '{cephRepo}', + }, + }], }, ], listeners: { @@ -323,7 +350,8 @@ Ext.define('PVE.ceph.CephInstallWizard', { let me = this; let wizard = me.up('pveCephInstallWizard'); let release = wizard.getViewModel().get('cephRelease'); - me.cmdOpts = `--version\0${release}`; + let repo = wizard.getViewModel().get('cephRepo'); + me.cmdOpts = `--version\0${release}\0--repository\0${repo}`; }, cmd: 'ceph_install', }, From e6a1160f95f3c27f7c1d3277defd8ca982be21e5 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 18:03:46 +0200 Subject: [PATCH 028/398] ui: ceph install: add hints depending on selected repo and subscriptions None hint required if all nodes have subscriptions and enterprise repo is selected, but otherwise give some hints for better UX and to (hopefully) reduce the chance for mishaps. We might want to highlight the label to improve visibility tough. Signed-off-by: Thomas Lamprecht --- www/manager6/Utils.js | 8 +++++ www/manager6/ceph/CephInstallWizard.js | 45 +++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index d5dd29990..cc7e8ce17 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -37,6 +37,14 @@ Ext.define('PVE.Utils', { +'' +'www.proxmox.com to get a list of available options.', + getClusterSubscriptionLevel: async function() { + let { result } = await Proxmox.Async.api2({ url: '/cluster/status' }); + let levelMap = Object.fromEntries( + result.data.filter(v => v.type === 'node').map(v => [v.name, v.level]), + ); + return levelMap; + }, + kvm_ostypes: { 'Linux': [ { desc: '6.x - 2.6 Kernel', val: 'l26' }, diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index 2a7bb381a..009161d04 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -149,6 +149,31 @@ Ext.define('PVE.ceph.CephInstallWizard', { cephRepo: 'enterprise', configuration: true, isInstalled: false, + nodeHasSubscription: true, // avoid warning hint until fully loaded + allHaveSubscription: true, // avoid warning hint until fully loaded + }, + formulas: { + repoHintHidden: get => get('allHaveSubscription') && get('cephRepo') === 'enterprise', + repoHint: function(get) { + let repo = get('cephRepo'); + let nodeSub = get('nodeHasSubscription'), allSub = get('allHaveSubscription'); + + if (repo === 'enterprise') { + if (!nodeSub) { + return gettext('The enterprise repository is enabled, but there is no active subscription!'); + } else if (!allSub) { + //return gettext('Not all nodes in the cluster have an active subscription, so not all have access to the enterprise repository and therefore may receive upgrades sooner!'); + return gettext('Not all nodes have an active subscription, which is required for cluster-wide enterprise repo access'); + } + return ''; // should be hidden + } else if (repo === 'no-subscription') { + return allSub + ? gettext("Cluster has active subscriptions and would be elligible for using the enterprise repository.") + : gettext("The no-subscription repository is not the best choice for production setups."); + } else { + return gettext('The test repository should only be used for test setups or after consulting the official Proxmox support!'); + } + }, }, }, cbindData: { @@ -175,11 +200,19 @@ Ext.define('PVE.ceph.CephInstallWizard', { }, onShow: function() { this.callParent(arguments); + let viewModel = this.getViewModel(); var isInstalled = this.getViewModel().get('isInstalled'); if (isInstalled) { - this.getViewModel().set('configuration', false); + viewModel.set('configuration', false); this.setInitialTab(2); } + + PVE.Utils.getClusterSubscriptionLevel().then(subcriptionMap => { + viewModel.set('nodeHasSubscription', !!subcriptionMap[this.nodename]); + + let allHaveSubscription = Object.values(subcriptionMap).every(level => !!level); + viewModel.set('allHaveSubscription', allHaveSubscription); + }); }, items: [ { @@ -204,6 +237,16 @@ Ext.define('PVE.ceph.CephInstallWizard', { { flex: 1, }, + { + xtype: 'displayfield', + fieldLabel: gettext('Hint'), + submitValue: false, + labelWidth: 50, + bind: { + value: '{repoHint}', + hidden: '{repoHintHidden}', + }, + }, { xtype: 'pveCephHighestVersionDisplay', labelWidth: 150, From 4bbd64a2f8442929fc615b7229c16c4055e47d61 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 18:12:22 +0200 Subject: [PATCH 029/398] ui: ceph install: add pmx-hint class to hint field-label looks a bit odd as the background it produces goes over the text, but is the least invasive method to apply something like this, and highlighting the whole thing is too flashy here. Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index 009161d04..46272fc9b 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -240,6 +240,7 @@ Ext.define('PVE.ceph.CephInstallWizard', { { xtype: 'displayfield', fieldLabel: gettext('Hint'), + labelClsExtra: 'pmx-hint', submitValue: false, labelWidth: 50, bind: { From 13831d8d3df29908fca2da1dd4760c320f79bb93 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 18:14:00 +0200 Subject: [PATCH 030/398] ui: dc summary: code style fix Signed-off-by: Thomas Lamprecht --- www/manager6/dc/Summary.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/dc/Summary.js b/www/manager6/dc/Summary.js index 8ea602679..371c8980f 100644 --- a/www/manager6/dc/Summary.js +++ b/www/manager6/dc/Summary.js @@ -57,8 +57,8 @@ Ext.define('PVE.dc.Summary', { height: 220, items: [ { - itemId: 'subscriptions', xtype: 'pveHealthWidget', + itemId: 'subscriptions', userCls: 'pointer', listeners: { element: 'el', From 06c3062a7e9eeb48ab7222b6ffdda000eba9dbd2 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 5 Jun 2023 18:54:12 +0200 Subject: [PATCH 031/398] bump version to 8.0.0~7 Signed-off-by: Thomas Lamprecht --- debian/changelog | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/debian/changelog b/debian/changelog index 6385c88b7..b2b7983ed 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,23 @@ +pve-manager (8.0.0~7) bookworm; urgency=medium + + * pveceph: support new enterprise repository, new in Proxmox VE 8, which can + be accessed through Proxmox VE subscriptions, to provide more broadly + tested ceph updates for production setups. + + * ceph install: output some hints depending repo choice and subscription + + * ui: ceph install: increase dimension of window with 4:3 ratio + + * ui: ceph install wizard: allow selecting repository + + * ui: ceph install: add hints depending on selected repo and subscriptions + + * ui: USB selector: fix typo + + * cli: subscription: simplify printing output in get command + + -- Proxmox Support Team Mon, 05 Jun 2023 18:54:08 +0200 + pve-manager (8.0.0~6) bookworm; urgency=medium * vzdump: prepare to support 'exclude-path' as arry (multiple lines in the From 44f9ab364d977aed0cc28322dd53678338cd893f Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 6 Jun 2023 09:49:23 +0200 Subject: [PATCH 032/398] ui: add beta text with link to bugtracker Signed-off-by: Thomas Lamprecht --- www/manager6/Workspace.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js index 18d574b75..3e678dc0f 100644 --- a/www/manager6/Workspace.js +++ b/www/manager6/Workspace.js @@ -335,6 +335,10 @@ Ext.define('PVE.StdWorkspace', { 'line-height': '18px', }, }, + { + padding: 5, + html: 'BETA', + }, { xtype: 'pveGlobalSearchField', tree: rtree, From 8b3c353ed798730350b4859609518fbcae367af1 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 29 Mar 2023 13:36:42 +0200 Subject: [PATCH 033/398] api: nodes: add 'migrateall' to index Signed-off-by: Fiona Ebner --- PVE/API2/Nodes.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index bfe5c40a1..e642ab4f4 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -272,6 +272,7 @@ __PACKAGE__->register_method ({ { name => 'hosts' }, { name => 'journal' }, { name => 'lxc' }, + { name => 'migrateall' }, { name => 'netstat' }, { name => 'network' }, { name => 'qemu' }, From 711658f42c920bcc0131f3e36e6f6eafae5d07e7 Mon Sep 17 00:00:00 2001 From: Leo Nunner Date: Wed, 8 Feb 2023 10:05:26 +0100 Subject: [PATCH 034/398] fix #2641: ui: storage: expose CIFS subdir parameter on add makes it possible to optionally set the 'subdir' parameter when adding a new CIFS storage. Signed-off-by: Leo Nunner [ T: reword/flow commit message slightly ] Signed-off-by: Thomas Lamprecht --- www/manager6/storage/CIFSEdit.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/www/manager6/storage/CIFSEdit.js b/www/manager6/storage/CIFSEdit.js index 71415401f..8040bc858 100644 --- a/www/manager6/storage/CIFSEdit.js +++ b/www/manager6/storage/CIFSEdit.js @@ -129,6 +129,9 @@ Ext.define('PVE.storage.CIFSInputPanel', { if (values.username?.length === 0) { delete values.username; } + if (values.subdir?.length === 0) { + delete values.subdir; + } return me.callParent([values]); }, @@ -216,6 +219,14 @@ Ext.define('PVE.storage.CIFSInputPanel', { }, }, }, + { + xtype: 'pmxDisplayEditField', + editable: me.isCreate, + name: 'subdir', + fieldLabel: gettext('Subdirectory'), + allowBlank: true, + emptyText: gettext('/some/path'), + }, ]; me.callParent(); From 90d69e2dbc77b0c2967bd1bf2dee3fe5f6bb72e9 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 21 Mar 2023 14:27:11 +0100 Subject: [PATCH 035/398] ui: fix duplicate references when using multiple disk storage selectors by removing the references and change the one place where we used one of the references. Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/form/DiskStorageSelector.js | 4 ---- www/manager6/window/Clone.js | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/www/manager6/form/DiskStorageSelector.js b/www/manager6/form/DiskStorageSelector.js index abd46debc..860a3b3ca 100644 --- a/www/manager6/form/DiskStorageSelector.js +++ b/www/manager6/form/DiskStorageSelector.js @@ -118,7 +118,6 @@ Ext.define('PVE.form.DiskStorageSelector', { xtype: 'pveStorageSelector', itemId: 'hdstorage', name: 'hdstorage', - reference: 'hdstorage', fieldLabel: me.storageLabel, nodename: me.nodename, storageContent: me.storageContent, @@ -136,7 +135,6 @@ Ext.define('PVE.form.DiskStorageSelector', { { xtype: 'pveFileSelector', name: 'hdimage', - reference: 'hdimage', itemId: 'hdimage', fieldLabel: gettext('Disk image'), nodename: me.nodename, @@ -146,7 +144,6 @@ Ext.define('PVE.form.DiskStorageSelector', { { xtype: 'numberfield', itemId: 'disksize', - reference: 'disksize', name: 'disksize', fieldLabel: gettext('Disk size') + ' (GiB)', hidden: me.hideSize, @@ -160,7 +157,6 @@ Ext.define('PVE.form.DiskStorageSelector', { { xtype: 'pveDiskFormatSelector', itemId: 'diskformat', - reference: 'diskformat', name: 'diskformat', fieldLabel: gettext('Format'), nodename: me.nodename, diff --git a/www/manager6/window/Clone.js b/www/manager6/window/Clone.js index e4ea17adf..2d3f26781 100644 --- a/www/manager6/window/Clone.js +++ b/www/manager6/window/Clone.js @@ -170,7 +170,7 @@ Ext.define('PVE.window.Clone', { onlineValidator: true, listeners: { change: function(f, value) { - me.lookupReference('hdstorage').setTargetNode(value); + me.lookup('diskselector').getComponent('hdstorage').setTargetNode(value); }, }, }); From b48ca5a7c0235b42fe2fa18a7b912114443ac01f Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Mon, 19 Dec 2022 15:46:34 +0100 Subject: [PATCH 036/398] pveceph: add osd details command To provide similar output on the CLI as is possible in the GUI/API regaring OSD details. By default (output-format=text) a more concise output is shown. Using json or yaml as output format will print all the available data. The 'verbose' flag causes json-pretty output to be used. The functionality is split between the actual function and the output formatter as not all options/parameters are available in each. Signed-off-by: Aaron Lauterer --- PVE/CLI/pveceph.pm | 66 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/PVE/CLI/pveceph.pm b/PVE/CLI/pveceph.pm index eda87f609..77749696c 100755 --- a/PVE/CLI/pveceph.pm +++ b/PVE/CLI/pveceph.pm @@ -366,6 +366,71 @@ __PACKAGE__->register_method ({ return $rpcenv->fork_worker('cephdestroyfs', $fs_name, $user, $worker); }}); +__PACKAGE__->register_method ({ + name => 'osddetails', + path => 'osddetails', + method => 'GET', + description => "Get OSD details.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + osdid => { + description => "ID of the OSD", + type => 'string', + }, + verbose => { + description => "Print verbose information, same as json-pretty output format.", + type => 'boolean', + default => 0, + optional => 1, + }, + }, + }, + returns => { type => 'object' }, + code => sub { + my ($param) = @_; + PVE::Ceph::Tools::check_ceph_inited(); + my $res = PVE::API2::Ceph::OSD->osddetails({ + osdid => $param->{osdid}, + node => $param->{node}, + }); + + for my $dev (@{ $res->{devices} }) { + $dev->{"lv-info"} = PVE::API2::Ceph::OSD->osdvolume({ + osdid => $param->{osdid}, + node => $param->{node}, + type => $dev->{device}, + }); + } + $res->{verbose} = 1 if $param->{verbose}; + return $res; + }}); + +my $format_osddetails = sub { + my ($data, $schema, $options) = @_; + $options->{"output-format"} //= "text"; + + if ($data->{verbose}) { + $options->{"output-format"} = "json-pretty"; + delete $data->{verbose}; + } + + if ($options->{"output-format"} eq "text") { + for my $dev (@{ $data->{devices} }) { + my $str = "Disk: $dev->{physical_device}," + ." Type: $dev->{type}," + ." LV Size: $dev->{'lv-info'}->{lv_size}," + ." LV Creation Time: $dev->{'lv-info'}->{creation_time}"; + + $data->{osd}->{$dev->{device}} = $str; + } + PVE::CLIFormatter::print_api_result($data->{osd}, $schema, undef, $options); + } else { + PVE::CLIFormatter::print_api_result($data, $schema, undef, $options); + } +}; + our $cmddef = { init => [ 'PVE::API2::Ceph', 'init', [], { node => $nodename } ], pool => { @@ -406,6 +471,7 @@ our $cmddef = { osd => { create => [ 'PVE::API2::Ceph::OSD', 'createosd', ['dev'], { node => $nodename }, $upid_exit], destroy => [ 'PVE::API2::Ceph::OSD', 'destroyosd', ['osdid'], { node => $nodename }, $upid_exit], + details => [ __PACKAGE__, 'osddetails', ['osdid'], { node => $nodename }, $format_osddetails, $PVE::RESTHandler::standard_output_options], }, createosd => { alias => 'osd create' }, destroyosd => { alias => 'osd destroy' }, From cf14758f5fa9fdbc95887e2f9d796d4d3b1e4d77 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 6 Jun 2023 18:21:38 +0200 Subject: [PATCH 037/398] ceph CLI: osd details: code/style cleanups Signed-off-by: Thomas Lamprecht --- PVE/CLI/pveceph.pm | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/PVE/CLI/pveceph.pm b/PVE/CLI/pveceph.pm index 77749696c..b47f8cc19 100755 --- a/PVE/CLI/pveceph.pm +++ b/PVE/CLI/pveceph.pm @@ -367,8 +367,8 @@ __PACKAGE__->register_method ({ }}); __PACKAGE__->register_method ({ - name => 'osddetails', - path => 'osddetails', + name => 'osd-details', + path => 'osd-details', method => 'GET', description => "Get OSD details.", parameters => { @@ -390,18 +390,20 @@ __PACKAGE__->register_method ({ returns => { type => 'object' }, code => sub { my ($param) = @_; + PVE::Ceph::Tools::check_ceph_inited(); + my $res = PVE::API2::Ceph::OSD->osddetails({ + osdid => $param->{osdid}, + node => $param->{node}, + }); + + for my $dev ($res->{devices}->@*) { + $dev->{"lv-info"} = PVE::API2::Ceph::OSD->osdvolume({ osdid => $param->{osdid}, node => $param->{node}, + type => $dev->{device}, }); - - for my $dev (@{ $res->{devices} }) { - $dev->{"lv-info"} = PVE::API2::Ceph::OSD->osdvolume({ - osdid => $param->{osdid}, - node => $param->{node}, - type => $dev->{device}, - }); } $res->{verbose} = 1 if $param->{verbose}; return $res; @@ -409,6 +411,7 @@ __PACKAGE__->register_method ({ my $format_osddetails = sub { my ($data, $schema, $options) = @_; + $options->{"output-format"} //= "text"; if ($data->{verbose}) { @@ -417,13 +420,11 @@ my $format_osddetails = sub { } if ($options->{"output-format"} eq "text") { - for my $dev (@{ $data->{devices} }) { - my $str = "Disk: $dev->{physical_device}," - ." Type: $dev->{type}," - ." LV Size: $dev->{'lv-info'}->{lv_size}," - ." LV Creation Time: $dev->{'lv-info'}->{creation_time}"; + for my $dev ($data->{devices}->@*) { + my ($disk, $type, $device) = $dev->@{'physical_device', 'type', 'device'}; + my ($lv_size, $lv_ctime) = $dev->{'lv-info'}->@{'lv_size', 'creation_time'}; - $data->{osd}->{$dev->{device}} = $str; + $data->{osd}->{$device} = "Disk: $disk, Type: $type, LV Size: $lv_size, LV Creation Time: $lv_ctime"; } PVE::CLIFormatter::print_api_result($data->{osd}, $schema, undef, $options); } else { @@ -471,7 +472,10 @@ our $cmddef = { osd => { create => [ 'PVE::API2::Ceph::OSD', 'createosd', ['dev'], { node => $nodename }, $upid_exit], destroy => [ 'PVE::API2::Ceph::OSD', 'destroyosd', ['osdid'], { node => $nodename }, $upid_exit], - details => [ __PACKAGE__, 'osddetails', ['osdid'], { node => $nodename }, $format_osddetails, $PVE::RESTHandler::standard_output_options], + details => [ + __PACKAGE__, 'osd-details', ['osdid'], { node => $nodename }, $format_osddetails, + $PVE::RESTHandler::standard_output_options, + ], }, createosd => { alias => 'osd create' }, destroyosd => { alias => 'osd destroy' }, From 9a023d5540b909a39af2743c6e1ffadb73ae98d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Mon, 17 Apr 2023 09:04:18 +0200 Subject: [PATCH 038/398] fix #4605: drop rsyncable from zstd invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts 7420d7ff ("zstd: add --rsyncable flag") That flag causes severe slow downs on fast disks, and we still have other rsyncable compressors available. It was originally added based on wrong documentation that made the performance impact look a lot smaller than it actually is. Signed-off-by: Fabian Grünbichler --- PVE/VZDump.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index df6fd64b3..64a5fd4fc 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -767,7 +767,7 @@ sub compressor_info { my $cpuinfo = PVE::ProcFSTools::read_cpuinfo(); $zstd_threads = int(($cpuinfo->{cpus} + 1)/2); } - return ("zstd --rsyncable --threads=${zstd_threads}", 'zst'); + return ("zstd --threads=${zstd_threads}", 'zst'); } else { die "internal error - unknown compression option '$opt_compress'"; } From 73ea065ac23b6d0119238b6ee6765991f28eed5f Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 25 Apr 2023 09:21:27 +0200 Subject: [PATCH 039/398] ui: storage: backup: refactor extraColumns assignment makes it easier to add columns, and uses less indentation Signed-off-by: Dominik Csapak --- www/manager6/storage/BackupView.js | 40 +++++++++++++++--------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/www/manager6/storage/BackupView.js b/www/manager6/storage/BackupView.js index fbdf573d3..e0921f4f5 100644 --- a/www/manager6/storage/BackupView.js +++ b/www/manager6/storage/BackupView.js @@ -195,28 +195,28 @@ Ext.define('PVE.storage.BackupView', { pruneButton, ); + me.extraColumns = {}; + if (isPBS) { - me.extraColumns = { - encrypted: { - header: gettext('Encrypted'), - dataIndex: 'encrypted', - renderer: PVE.Utils.render_backup_encryption, - sorter: { - property: 'encrypted', - transform: encrypted => encrypted ? 1 : 0, - }, + me.extraColumns.encrypted = { + header: gettext('Encrypted'), + dataIndex: 'encrypted', + renderer: PVE.Utils.render_backup_encryption, + sorter: { + property: 'encrypted', + transform: encrypted => encrypted ? 1 : 0, }, - verification: { - header: gettext('Verify State'), - dataIndex: 'verification', - renderer: PVE.Utils.render_backup_verification, - sorter: { - property: 'verification', - transform: value => { - let state = value?.state ?? 'none'; - let order = PVE.Utils.verificationStateOrder; - return order[state] ?? order.__default__; - }, + }; + me.extraColumns.verification = { + header: gettext('Verify State'), + dataIndex: 'verification', + renderer: PVE.Utils.render_backup_verification, + sorter: { + property: 'verification', + transform: value => { + let state = value?.state ?? 'none'; + let order = PVE.Utils.verificationStateOrder; + return order[state] ?? order.__default__; }, }, }; From 63d74bb632833f31208eb966c2701b6eec7ac319 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 25 Apr 2023 09:21:28 +0200 Subject: [PATCH 040/398] fix #4678: ui: don't sort storage backup content by vmid by default instead, add the vmid as extra column, so that the user can still sort by vmid if they wish to Signed-off-by: Dominik Csapak --- www/manager6/storage/BackupView.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/www/manager6/storage/BackupView.js b/www/manager6/storage/BackupView.js index e0921f4f5..bdaf85c8d 100644 --- a/www/manager6/storage/BackupView.js +++ b/www/manager6/storage/BackupView.js @@ -222,14 +222,17 @@ Ext.define('PVE.storage.BackupView', { }; } + me.extraColumns.vmid = { + header: gettext('VMID'), + dataIndex: 'vmid', + hidden: true, + sorter: (a, b) => (a.data.vmid ?? 0) - (b.data.vmid ?? 0), + }; + me.callParent(); me.store.getSorters().clear(); me.store.setSorters([ - { - property: 'vmid', - direction: 'ASC', - }, { property: 'vdate', direction: 'DESC', From a1c51a74ca5326541e8bdfd154ca1e137689f37c Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 17 Jan 2023 12:46:56 +0100 Subject: [PATCH 041/398] Jobs: include existing types in state file regex for deletion otherwise, we cannot correctly match types that contain a hyphen, since the id itself can also contain those. creating a regex where the first part is the concrete allowed types followed by a hyphen + id can also match those. Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- PVE/Jobs.pm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/PVE/Jobs.pm b/PVE/Jobs.pm index 86ce9f693..70cb48212 100644 --- a/PVE/Jobs.pm +++ b/PVE/Jobs.pm @@ -324,7 +324,10 @@ sub synchronize_job_states_with_config { } } - PVE::Tools::dir_glob_foreach($state_dir, '(.*?)-(.*).json', sub { + my $valid_types = PVE::Job::Registry->lookup_types(); + my $type_regex = join("|", $valid_types->@*); + + PVE::Tools::dir_glob_foreach($state_dir, "(${type_regex})-(.*).json", sub { my ($path, $type, $id) = @_; if (!defined($data->{ids}->{$id})) { From 23d641254d4be95765521ecd661c0e92a0b86680 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 17 Jan 2023 12:46:57 +0100 Subject: [PATCH 042/398] Jobs: add RealmSync Plugin and register it so that realmsync jobs get executed Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- PVE/Jobs.pm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PVE/Jobs.pm b/PVE/Jobs.pm index 70cb48212..bd3233323 100644 --- a/PVE/Jobs.pm +++ b/PVE/Jobs.pm @@ -7,9 +7,11 @@ use JSON; use PVE::Cluster qw(cfs_lock_file cfs_read_file cfs_register_file); use PVE::Job::Registry; use PVE::Jobs::VZDump; +use PVE::Jobs::RealmSync; use PVE::Tools; PVE::Jobs::VZDump->register(); +PVE::Jobs::RealmSync->register(); PVE::Job::Registry->init(); cfs_register_file( From 98022975176ea1611b6da38f12d0762714e4bfd3 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 17 Jan 2023 12:46:58 +0100 Subject: [PATCH 043/398] api: add realm-sync crud api to /cluster/jobs Signed-off-by: Dominik Csapak [ T: fix-up realm sync module namespace, moved to PVE::API2::Jobs ] Signed-off-by: Thomas Lamprecht --- PVE/API2/Cluster/Jobs.pm | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/PVE/API2/Cluster/Jobs.pm b/PVE/API2/Cluster/Jobs.pm index 8166333dc..56b40fa25 100644 --- a/PVE/API2/Cluster/Jobs.pm +++ b/PVE/API2/Cluster/Jobs.pm @@ -6,8 +6,15 @@ use warnings; use PVE::RESTHandler; use PVE::CalendarEvent; +use PVE::API2::Jobs::RealmSync; + use base qw(PVE::RESTHandler); +__PACKAGE__->register_method ({ + subclass => "PVE::API2::Jobs::RealmSync", + path => 'realm-sync', +}); + __PACKAGE__->register_method({ name => 'index', path => '', @@ -35,6 +42,7 @@ __PACKAGE__->register_method({ code => sub { return [ { subdir => 'schedule-analyze' }, + { subdir => 'realm-sync' }, ]; }}); From f44ce5955e54794afecf76036103e789085f2e10 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 17 Jan 2023 12:46:59 +0100 Subject: [PATCH 044/398] ui: add Realm Sync panel a typical CRUD panel for adding/editing/removing realm sync jobs Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 1 + www/manager6/dc/Config.js | 7 + www/manager6/dc/RealmSyncJob.js | 364 ++++++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 www/manager6/dc/RealmSyncJob.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 336355abf..71ab928ff 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -167,6 +167,7 @@ JSSRC= \ dc/MetricServerView.js \ dc/UserTagAccessEdit.js \ dc/RegisteredTagsEdit.js \ + dc/RealmSyncJob.js \ lxc/CmdMenu.js \ lxc/Config.js \ lxc/CreateWizard.js \ diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 13ded12e8..72a9bec13 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -140,6 +140,13 @@ Ext.define('PVE.dc.Config', { iconCls: 'fa fa-address-book-o', itemId: 'domains', }, + { + xtype: 'pveRealmSyncJobView', + title: gettext('Realm Sync'), + groups: ['permissions'], + iconCls: 'fa fa-refresh', + itemId: 'realmsyncjobs', + }, { xtype: 'pveHAStatus', title: 'HA', diff --git a/www/manager6/dc/RealmSyncJob.js b/www/manager6/dc/RealmSyncJob.js new file mode 100644 index 000000000..5a903ef7d --- /dev/null +++ b/www/manager6/dc/RealmSyncJob.js @@ -0,0 +1,364 @@ +Ext.define('PVE.dc.RealmSyncJobView', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveRealmSyncJobView', + + stateful: true, + stateId: 'grid-realmsyncjobs', + + controller: { + xclass: 'Ext.app.ViewController', + + addRealmSyncJob: function(button) { + let me = this; + Ext.create(`PVE.dc.RealmSyncJobEdit`, { + autoShow: true, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + + editRealmSyncJob: function() { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (!selection || selection.length < 1) { + return; + } + + Ext.create(`PVE.dc.RealmSyncJobEdit`, { + jobid: selection[0].data.id, + autoShow: true, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + + reload: function() { + this.getView().getStore().load(); + }, + }, + + store: { + autoLoad: true, + id: 'realm-syncs', + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/jobs/realm-sync', + }, + }, + + columns: [ + { + header: gettext('Enabled'), + width: 80, + dataIndex: 'enabled', + xtype: 'checkcolumn', + sortable: true, + disabled: true, + disabledCls: 'x-item-enabled', + stopSelection: false, + }, + { + text: gettext('Name'), + flex: 1, + dataIndex: 'id', + hidden: true, + }, + { + text: gettext('Realm'), + width: 200, + dataIndex: 'realm', + }, + { + header: gettext('Schedule'), + width: 150, + dataIndex: 'schedule', + }, + { + text: gettext('Next Run'), + dataIndex: 'next-run', + width: 150, + renderer: PVE.Utils.render_next_event, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + renderer: Ext.htmlEncode, + sorter: (a, b) => (a.data.comment || '').localeCompare(b.data.comment || ''), + flex: 1, + }, + ], + + tbar: [ + { + text: gettext('Add'), + handler: 'addRealmSyncJob', + }, + { + text: gettext('Edit'), + xtype: 'proxmoxButton', + handler: 'editRealmSyncJob', + disabled: true, + }, + { + xtype: 'proxmoxStdRemoveButton', + baseurl: `/api2/extjs/cluster/jobs/realm-sync`, + callback: 'reload', + }, + ], + + listeners: { + itemdblclick: 'editRealmSyncJob', + }, + + initComponent: function() { + var me = this; + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore()); + }, +}); + +Ext.define('PVE.dc.RealmSyncJobEdit', { + extend: 'Proxmox.window.Edit', + mixins: ['Proxmox.Mixin.CBind'], + + subject: gettext('Realm Sync Job'), + onlineHelp: 'pveum_ldap_sync', + + // don't focus the schedule field on edit + defaultFocus: 'field[name=id]', + + cbindData: function() { + let me = this; + me.isCreate = !me.jobid; + me.jobid = me.jobid || ""; + let url = '/api2/extjs/cluster/jobs/realm-sync'; + me.url = me.jobid ? `${url}/${me.jobid}` : url; + me.method = me.isCreate ? 'POST' : 'PUT'; + if (!me.isCreate) { + me.subject = `${me.subject}: ${me.jobid}`; + } + return {}; + }, + + submitUrl: function(url, values) { + return this.isCreate ? `${url}/${values.id}` : url; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + updateDefaults: function(_field, newValue) { + let me = this; + // only update on create + if (!me.getView().isCreate) { + return; + } + Proxmox.Utils.API2Request({ + url: `/access/domains/${newValue}`, + success: function(response) { + // first reset the fields to their default + ['acl', 'entry', 'properties'].forEach(opt => { + me.lookup(`remove-vanished-${opt}`)?.setValue(false); + }); + me.lookup('enable-new')?.setValue('1'); + me.lookup('scope')?.setValue(undefined); + + let options = response?.result?.data?.['sync-defaults-options']; + if (options) { + let parsed = PVE.Parser.parsePropertyString(options); + if (parsed['remove-vanished']) { + let opts = parsed['remove-vanished'].split(';'); + for (const opt of opts) { + me.lookup(`remove-vanished-${opt}`)?.setValue(true); + } + delete parsed['remove-vanished']; + } + for (const [name, value] of Object.entries(parsed)) { + me.lookup(name)?.setValue(value); + } + } + }, + }); + }, + }, + + items: [ + { + xtype: 'inputpanel', + + cbind: { + isCreate: '{isCreate}', + }, + + onGetValues: function(values) { + let me = this; + + let vanished_opts = []; + ['acl', 'entry', 'properties'].forEach((prop) => { + if (values[`remove-vanished-${prop}`]) { + vanished_opts.push(prop); + } + delete values[`remove-vanished-${prop}`]; + }); + + if (!values.id && me.isCreate) { + values.id = 'realmsync-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); + } + + if (vanished_opts.length > 0) { + values['remove-vanished'] = vanished_opts.join(';'); + } else { + values['remove-vanished'] = 'none'; + } + + PVE.Utils.delete_if_default(values, 'node', ''); + + if (me.isCreate) { + delete values.delete; // on create we cannot delete values + } + + return values; + }, + + column1: [ + { + xtype: 'pmxDisplayEditField', + editConfig: { + xtype: 'pmxRealmComboBox', + storeFilter: rec => rec.data.type === 'ldap' || rec.data.type === 'ad', + }, + cbind: { + editable: '{isCreate}', + }, + listeners: { + change: 'updateDefaults', + }, + fieldLabel: gettext('Realm'), + name: 'realm', + reference: 'realm', + }, + { + xtype: 'pveCalendarEvent', + fieldLabel: gettext('Schedule'), + allowBlank: false, + name: 'schedule', + reference: 'schedule', + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Enable'), + name: 'enabled', + reference: 'enabled', + uncheckedValue: 0, + defaultValue: 1, + checked: true, + }, + ], + + column2: [ + { + xtype: 'proxmoxKVComboBox', + name: 'scope', + reference: 'scope', + fieldLabel: gettext('Scope'), + value: '', + emptyText: gettext('No default available'), + deleteEmpty: false, + allowBlank: false, + comboItems: [ + ['users', gettext('Users')], + ['groups', gettext('Groups')], + ['both', gettext('Users and Groups')], + ], + }, + { + xtype: 'proxmoxKVComboBox', + value: '1', + deleteEmpty: false, + allowBlank: false, + comboItems: [ + ['1', Proxmox.Utils.yesText], + ['0', Proxmox.Utils.noText], + ], + name: 'enable-new', + reference: 'enable-new', + fieldLabel: gettext('Enable new'), + }, + ], + + columnB: [ + { + xtype: 'fieldset', + title: gettext('Remove Vanished Options'), + items: [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('ACL'), + name: 'remove-vanished-acl', + reference: 'remove-vanished-acl', + boxLabel: gettext('Remove ACLs of vanished users and groups.'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Entry'), + name: 'remove-vanished-entry', + reference: 'remove-vanished-entry', + boxLabel: gettext('Remove vanished user and group entries.'), + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Properties'), + name: 'remove-vanished-properties', + reference: 'remove-vanished-properties', + boxLabel: gettext('Remove vanished properties from synced users.'), + }, + ], + }, + { + xtype: 'proxmoxtextfield', + name: 'comment', + fieldLabel: gettext('Job Comment'), + cbind: { + deleteEmpty: '{!isCreate}', + }, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Description of the job'), + }, + }, + { + xtype: 'displayfield', + reference: 'defaulthint', + value: gettext('Default sync options can be set by editing the realm.'), + userCls: 'pmx-hint', + hidden: true, + }, + ], + }, + ], + + initComponent: function() { + let me = this; + me.callParent(); + if (me.jobid) { + me.load({ + success: function(response, options) { + let values = response.result.data; + + if (values['remove-vanished']) { + let opts = values['remove-vanished'].split(';'); + for (const opt of opts) { + values[`remove-vanished-${opt}`] = 1; + } + } + me.down('inputpanel').setValues(values); + }, + }); + } + }, +}); From d7f0fd55816ed26f1cedd86e798ec2bccd0bd7c8 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 7 Jun 2023 11:36:19 +0200 Subject: [PATCH 045/398] d/control: bump versioned dependency for libpve-access-control-perl To ensure that the new realm-sync modules are available. Signed-off-by: Thomas Lamprecht --- debian/control | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index a34ad80ce..473df92cd 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Build-Depends: debhelper-compat (= 13), libpod-parser-perl, libproxmox-acme-perl, libproxmox-rs-perl (>= 0.2.0), - libpve-access-control (>= 7.0-2), + libpve-access-control (>= 8.0.0~2), libpve-cluster-api-perl, libpve-cluster-perl (>= 6.1-6), libpve-common-perl (>= 7.2-6), @@ -55,7 +55,7 @@ Depends: apt (>= 1.5~), libproxmox-acme-perl, libproxmox-acme-plugins, libproxmox-rs-perl (>= 0.2.0), - libpve-access-control (>= 7.0-7), + libpve-access-control (>= 8.0.0~2), libpve-cluster-api-perl (>= 7.0-5), libpve-cluster-perl (>= 7.2-3), libpve-common-perl (>= 7.2-7), From 63c8b37122a7d9b83c26fc3c4c31371e7a9c0bcf Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 7 Jun 2023 10:59:12 +0200 Subject: [PATCH 046/398] appliances: switch over to Proxmox VE 8 index Signed-off-by: Thomas Lamprecht --- PVE/APLInfo.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/APLInfo.pm b/PVE/APLInfo.pm index 919b5b95e..f98ecfe70 100644 --- a/PVE/APLInfo.pm +++ b/PVE/APLInfo.pm @@ -198,7 +198,7 @@ sub get_apl_sources { { host => "download.proxmox.com", url => "http://download.proxmox.com/images", - file => 'aplinfo-pve-7.dat', + file => 'aplinfo-pve-8.dat', }, { host => "releases.turnkeylinux.org", From 085cf362bda652379d68368bcac6de487bd657d1 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 7 Jun 2023 11:49:33 +0200 Subject: [PATCH 047/398] aplinfo: ensure keyring is in binary format GnuPG chokes on it otherwise... Fixes: 00ea2e4b ("aplinfo: use sequioa for key ring generation") Signed-off-by: Thomas Lamprecht --- aplinfo/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aplinfo/Makefile b/aplinfo/Makefile index 23ab4ac0e..ec82550f6 100644 --- a/aplinfo/Makefile +++ b/aplinfo/Makefile @@ -21,7 +21,7 @@ update: mv aplinfo.dat.tmp aplinfo.dat trustedkeys.gpg: $(TRUSTED_KEYS) - sq keyring join -o $@.tmp *.pubkey proxmox-release-bookworm.gpg + sq keyring join --binary -o $@.tmp *.pubkey proxmox-release-bookworm.gpg mv $@.tmp $@ .PHONY: clean From 5fcda825ea104134bc66cc89efce9489315610bf Mon Sep 17 00:00:00 2001 From: Christoph Heiss Date: Wed, 3 May 2023 11:50:40 +0200 Subject: [PATCH 048/398] ui: clean up remnants of in-tree font-awesome files Commit e97c2601 ("change to debian font-awesome") removed the usage of the in-tree font-awesome files, replacing them with the Debian package. Thus clear out these leftovers out, as they are completely usused. Signed-off-by: Christoph Heiss Signed-off-by: Thomas Lamprecht --- www/css/font-awesome.css | 2086 ----------------------- www/css/fonts/FontAwesome.otf | Bin 109688 -> 0 bytes www/css/fonts/Makefile | 12 - www/css/fonts/README | 11 - www/css/fonts/fontawesome-webfont.eot | Bin 70807 -> 0 bytes www/css/fonts/fontawesome-webfont.svg | 655 ------- www/css/fonts/fontawesome-webfont.ttf | Bin 142072 -> 0 bytes www/css/fonts/fontawesome-webfont.woff | Bin 83588 -> 0 bytes www/css/fonts/fontawesome-webfont.woff2 | Bin 66624 -> 0 bytes 9 files changed, 2764 deletions(-) delete mode 100644 www/css/font-awesome.css delete mode 100644 www/css/fonts/FontAwesome.otf delete mode 100644 www/css/fonts/Makefile delete mode 100644 www/css/fonts/README delete mode 100644 www/css/fonts/fontawesome-webfont.eot delete mode 100644 www/css/fonts/fontawesome-webfont.svg delete mode 100644 www/css/fonts/fontawesome-webfont.ttf delete mode 100644 www/css/fonts/fontawesome-webfont.woff delete mode 100644 www/css/fonts/fontawesome-webfont.woff2 diff --git a/www/css/font-awesome.css b/www/css/font-awesome.css deleted file mode 100644 index ec52e7f8a..000000000 --- a/www/css/font-awesome.css +++ /dev/null @@ -1,2086 +0,0 @@ -/*! - * Font Awesome 4.5.0 by @davegandy - http://fontawesome.io - @fontawesome - * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) - */ -/* FONT PATH - * -------------------------- */ -@font-face { - font-family: 'FontAwesome'; - src: url('./fonts/fontawesome-webfont.eot?v=4.5.0'); - src: url('./fonts/fontawesome-webfont.eot?#iefix&v=4.5.0') format('embedded-opentype'), url('./fonts/fontawesome-webfont.woff2?v=4.5.0') format('woff2'), url('./fonts/fontawesome-webfont.woff?v=4.5.0') format('woff'), url('./fonts/fontawesome-webfont.ttf?v=4.5.0') format('truetype'), url('./fonts/fontawesome-webfont.svg?v=4.5.0#fontawesomeregular') format('svg'); - font-weight: normal; - font-style: normal; -} -.fa { - display: inline-block; - font: normal normal normal 14px/1 FontAwesome; - font-size: inherit; - text-rendering: auto; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} -/* makes the font 33% larger relative to the icon container */ -.fa-lg { - font-size: 1.33333333em; - line-height: 0.75em; - vertical-align: -15%; -} -.fa-2x { - font-size: 2em; -} -.fa-3x { - font-size: 3em; -} -.fa-4x { - font-size: 4em; -} -.fa-5x { - font-size: 5em; -} -.fa-fw { - width: 1.28571429em; - text-align: center; -} -.fa-ul { - padding-left: 0; - margin-left: 2.14285714em; - list-style-type: none; -} -.fa-ul > li { - position: relative; -} -.fa-li { - position: absolute; - left: -2.14285714em; - width: 2.14285714em; - top: 0.14285714em; - text-align: center; -} -.fa-li.fa-lg { - left: -1.85714286em; -} -.fa-border { - padding: .2em .25em .15em; - border: solid 0.08em #eeeeee; - border-radius: .1em; -} -.fa-pull-left { - float: left; -} -.fa-pull-right { - float: right; -} -.fa.fa-pull-left { - margin-right: .3em; -} -.fa.fa-pull-right { - margin-left: .3em; -} -/* Deprecated as of 4.4.0 */ -.pull-right { - float: right; -} -.pull-left { - float: left; -} -.fa.pull-left { - margin-right: .3em; -} -.fa.pull-right { - margin-left: .3em; -} -.fa-spin { - -webkit-animation: fa-spin 2s infinite linear; - animation: fa-spin 2s infinite linear; -} -.fa-pulse { - -webkit-animation: fa-spin 1s infinite steps(8); - animation: fa-spin 1s infinite steps(8); -} -@-webkit-keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} -@keyframes fa-spin { - 0% { - -webkit-transform: rotate(0deg); - transform: rotate(0deg); - } - 100% { - -webkit-transform: rotate(359deg); - transform: rotate(359deg); - } -} -.fa-rotate-90 { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=1); - -webkit-transform: rotate(90deg); - -ms-transform: rotate(90deg); - transform: rotate(90deg); -} -.fa-rotate-180 { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2); - -webkit-transform: rotate(180deg); - -ms-transform: rotate(180deg); - transform: rotate(180deg); -} -.fa-rotate-270 { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=3); - -webkit-transform: rotate(270deg); - -ms-transform: rotate(270deg); - transform: rotate(270deg); -} -.fa-flip-horizontal { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=0, mirror=1); - -webkit-transform: scale(-1, 1); - -ms-transform: scale(-1, 1); - transform: scale(-1, 1); -} -.fa-flip-vertical { - filter: progid:DXImageTransform.Microsoft.BasicImage(rotation=2, mirror=1); - -webkit-transform: scale(1, -1); - -ms-transform: scale(1, -1); - transform: scale(1, -1); -} -:root .fa-rotate-90, -:root .fa-rotate-180, -:root .fa-rotate-270, -:root .fa-flip-horizontal, -:root .fa-flip-vertical { - filter: none; -} -.fa-stack { - position: relative; - display: inline-block; - width: 2em; - height: 2em; - line-height: 2em; - vertical-align: middle; -} -.fa-stack-1x, -.fa-stack-2x { - position: absolute; - left: 0; - width: 100%; - text-align: center; -} -.fa-stack-1x { - line-height: inherit; -} -.fa-stack-2x { - font-size: 2em; -} -.fa-inverse { - color: #ffffff; -} -/* Font Awesome uses the Unicode Private Use Area (PUA) to ensure screen - readers do not read off random characters that represent icons */ -.fa-glass:before { - content: "\f000"; -} -.fa-music:before { - content: "\f001"; -} -.fa-search:before { - content: "\f002"; -} -.fa-envelope-o:before { - content: "\f003"; -} -.fa-heart:before { - content: "\f004"; -} -.fa-star:before { - content: "\f005"; -} -.fa-star-o:before { - content: "\f006"; -} -.fa-user:before { - content: "\f007"; -} -.fa-film:before { - content: "\f008"; -} -.fa-th-large:before { - content: "\f009"; -} -.fa-th:before { - content: "\f00a"; -} -.fa-th-list:before { - content: "\f00b"; -} -.fa-check:before { - content: "\f00c"; -} -.fa-remove:before, -.fa-close:before, -.fa-times:before { - content: "\f00d"; -} -.fa-search-plus:before { - content: "\f00e"; -} -.fa-search-minus:before { - content: "\f010"; -} -.fa-power-off:before { - content: "\f011"; -} -.fa-signal:before { - content: "\f012"; -} -.fa-gear:before, -.fa-cog:before { - content: "\f013"; -} -.fa-trash-o:before { - content: "\f014"; -} -.fa-home:before { - content: "\f015"; -} -.fa-file-o:before { - content: "\f016"; -} -.fa-clock-o:before { - content: "\f017"; -} -.fa-road:before { - content: "\f018"; -} -.fa-download:before { - content: "\f019"; -} -.fa-arrow-circle-o-down:before { - content: "\f01a"; -} -.fa-arrow-circle-o-up:before { - content: "\f01b"; -} -.fa-inbox:before { - content: "\f01c"; -} -.fa-play-circle-o:before { - content: "\f01d"; -} -.fa-rotate-right:before, -.fa-repeat:before { - content: "\f01e"; -} -.fa-refresh:before { - content: "\f021"; -} -.fa-list-alt:before { - content: "\f022"; -} -.fa-lock:before { - content: "\f023"; -} -.fa-flag:before { - content: "\f024"; -} -.fa-headphones:before { - content: "\f025"; -} -.fa-volume-off:before { - content: "\f026"; -} -.fa-volume-down:before { - content: "\f027"; -} -.fa-volume-up:before { - content: "\f028"; -} -.fa-qrcode:before { - content: "\f029"; -} -.fa-barcode:before { - content: "\f02a"; -} -.fa-tag:before { - content: "\f02b"; -} -.fa-tags:before { - content: "\f02c"; -} -.fa-book:before { - content: "\f02d"; -} -.fa-bookmark:before { - content: "\f02e"; -} -.fa-print:before { - content: "\f02f"; -} -.fa-camera:before { - content: "\f030"; -} -.fa-font:before { - content: "\f031"; -} -.fa-bold:before { - content: "\f032"; -} -.fa-italic:before { - content: "\f033"; -} -.fa-text-height:before { - content: "\f034"; -} -.fa-text-width:before { - content: "\f035"; -} -.fa-align-left:before { - content: "\f036"; -} -.fa-align-center:before { - content: "\f037"; -} -.fa-align-right:before { - content: "\f038"; -} -.fa-align-justify:before { - content: "\f039"; -} -.fa-list:before { - content: "\f03a"; -} -.fa-dedent:before, -.fa-outdent:before { - content: "\f03b"; -} -.fa-indent:before { - content: "\f03c"; -} -.fa-video-camera:before { - content: "\f03d"; -} -.fa-photo:before, -.fa-image:before, -.fa-picture-o:before { - content: "\f03e"; -} -.fa-pencil:before { - content: "\f040"; -} -.fa-map-marker:before { - content: "\f041"; -} -.fa-adjust:before { - content: "\f042"; -} -.fa-tint:before { - content: "\f043"; -} -.fa-edit:before, -.fa-pencil-square-o:before { - content: "\f044"; -} -.fa-share-square-o:before { - content: "\f045"; -} -.fa-check-square-o:before { - content: "\f046"; -} -.fa-arrows:before { - content: "\f047"; -} -.fa-step-backward:before { - content: "\f048"; -} -.fa-fast-backward:before { - content: "\f049"; -} -.fa-backward:before { - content: "\f04a"; -} -.fa-play:before { - content: "\f04b"; -} -.fa-pause:before { - content: "\f04c"; -} -.fa-stop:before { - content: "\f04d"; -} -.fa-forward:before { - content: "\f04e"; -} -.fa-fast-forward:before { - content: "\f050"; -} -.fa-step-forward:before { - content: "\f051"; -} -.fa-eject:before { - content: "\f052"; -} -.fa-chevron-left:before { - content: "\f053"; -} -.fa-chevron-right:before { - content: "\f054"; -} -.fa-plus-circle:before { - content: "\f055"; -} -.fa-minus-circle:before { - content: "\f056"; -} -.fa-times-circle:before { - content: "\f057"; -} -.fa-check-circle:before { - content: "\f058"; -} -.fa-question-circle:before { - content: "\f059"; -} -.fa-info-circle:before { - content: "\f05a"; -} -.fa-crosshairs:before { - content: "\f05b"; -} -.fa-times-circle-o:before { - content: "\f05c"; -} -.fa-check-circle-o:before { - content: "\f05d"; -} -.fa-ban:before { - content: "\f05e"; -} -.fa-arrow-left:before { - content: "\f060"; -} -.fa-arrow-right:before { - content: "\f061"; -} -.fa-arrow-up:before { - content: "\f062"; -} -.fa-arrow-down:before { - content: "\f063"; -} -.fa-mail-forward:before, -.fa-share:before { - content: "\f064"; -} -.fa-expand:before { - content: "\f065"; -} -.fa-compress:before { - content: "\f066"; -} -.fa-plus:before { - content: "\f067"; -} -.fa-minus:before { - content: "\f068"; -} -.fa-asterisk:before { - content: "\f069"; -} -.fa-exclamation-circle:before { - content: "\f06a"; -} -.fa-gift:before { - content: "\f06b"; -} -.fa-leaf:before { - content: "\f06c"; -} -.fa-fire:before { - content: "\f06d"; -} -.fa-eye:before { - content: "\f06e"; -} -.fa-eye-slash:before { - content: "\f070"; -} -.fa-warning:before, -.fa-exclamation-triangle:before { - content: "\f071"; -} -.fa-plane:before { - content: "\f072"; -} -.fa-calendar:before { - content: "\f073"; -} -.fa-random:before { - content: "\f074"; -} -.fa-comment:before { - content: "\f075"; -} -.fa-magnet:before { - content: "\f076"; -} -.fa-chevron-up:before { - content: "\f077"; -} -.fa-chevron-down:before { - content: "\f078"; -} -.fa-retweet:before { - content: "\f079"; -} -.fa-shopping-cart:before { - content: "\f07a"; -} -.fa-folder:before { - content: "\f07b"; -} -.fa-folder-open:before { - content: "\f07c"; -} -.fa-arrows-v:before { - content: "\f07d"; -} -.fa-arrows-h:before { - content: "\f07e"; -} -.fa-bar-chart-o:before, -.fa-bar-chart:before { - content: "\f080"; -} -.fa-twitter-square:before { - content: "\f081"; -} -.fa-facebook-square:before { - content: "\f082"; -} -.fa-camera-retro:before { - content: "\f083"; -} -.fa-key:before { - content: "\f084"; -} -.fa-gears:before, -.fa-cogs:before { - content: "\f085"; -} -.fa-comments:before { - content: "\f086"; -} -.fa-thumbs-o-up:before { - content: "\f087"; -} -.fa-thumbs-o-down:before { - content: "\f088"; -} -.fa-star-half:before { - content: "\f089"; -} -.fa-heart-o:before { - content: "\f08a"; -} -.fa-sign-out:before { - content: "\f08b"; -} -.fa-linkedin-square:before { - content: "\f08c"; -} -.fa-thumb-tack:before { - content: "\f08d"; -} -.fa-external-link:before { - content: "\f08e"; -} -.fa-sign-in:before { - content: "\f090"; -} -.fa-trophy:before { - content: "\f091"; -} -.fa-github-square:before { - content: "\f092"; -} -.fa-upload:before { - content: "\f093"; -} -.fa-lemon-o:before { - content: "\f094"; -} -.fa-phone:before { - content: "\f095"; -} -.fa-square-o:before { - content: "\f096"; -} -.fa-bookmark-o:before { - content: "\f097"; -} -.fa-phone-square:before { - content: "\f098"; -} -.fa-twitter:before { - content: "\f099"; -} -.fa-facebook-f:before, -.fa-facebook:before { - content: "\f09a"; -} -.fa-github:before { - content: "\f09b"; -} -.fa-unlock:before { - content: "\f09c"; -} -.fa-credit-card:before { - content: "\f09d"; -} -.fa-feed:before, -.fa-rss:before { - content: "\f09e"; -} -.fa-hdd-o:before { - content: "\f0a0"; -} -.fa-bullhorn:before { - content: "\f0a1"; -} -.fa-bell:before { - content: "\f0f3"; -} -.fa-certificate:before { - content: "\f0a3"; -} -.fa-hand-o-right:before { - content: "\f0a4"; -} -.fa-hand-o-left:before { - content: "\f0a5"; -} -.fa-hand-o-up:before { - content: "\f0a6"; -} -.fa-hand-o-down:before { - content: "\f0a7"; -} -.fa-arrow-circle-left:before { - content: "\f0a8"; -} -.fa-arrow-circle-right:before { - content: "\f0a9"; -} -.fa-arrow-circle-up:before { - content: "\f0aa"; -} -.fa-arrow-circle-down:before { - content: "\f0ab"; -} -.fa-globe:before { - content: "\f0ac"; -} -.fa-wrench:before { - content: "\f0ad"; -} -.fa-tasks:before { - content: "\f0ae"; -} -.fa-filter:before { - content: "\f0b0"; -} -.fa-briefcase:before { - content: "\f0b1"; -} -.fa-arrows-alt:before { - content: "\f0b2"; -} -.fa-group:before, -.fa-users:before { - content: "\f0c0"; -} -.fa-chain:before, -.fa-link:before { - content: "\f0c1"; -} -.fa-cloud:before { - content: "\f0c2"; -} -.fa-flask:before { - content: "\f0c3"; -} -.fa-cut:before, -.fa-scissors:before { - content: "\f0c4"; -} -.fa-copy:before, -.fa-files-o:before { - content: "\f0c5"; -} -.fa-paperclip:before { - content: "\f0c6"; -} -.fa-save:before, -.fa-floppy-o:before { - content: "\f0c7"; -} -.fa-square:before { - content: "\f0c8"; -} -.fa-navicon:before, -.fa-reorder:before, -.fa-bars:before { - content: "\f0c9"; -} -.fa-list-ul:before { - content: "\f0ca"; -} -.fa-list-ol:before { - content: "\f0cb"; -} -.fa-strikethrough:before { - content: "\f0cc"; -} -.fa-underline:before { - content: "\f0cd"; -} -.fa-table:before { - content: "\f0ce"; -} -.fa-magic:before { - content: "\f0d0"; -} -.fa-truck:before { - content: "\f0d1"; -} -.fa-pinterest:before { - content: "\f0d2"; -} -.fa-pinterest-square:before { - content: "\f0d3"; -} -.fa-google-plus-square:before { - content: "\f0d4"; -} -.fa-google-plus:before { - content: "\f0d5"; -} -.fa-money:before { - content: "\f0d6"; -} -.fa-caret-down:before { - content: "\f0d7"; -} -.fa-caret-up:before { - content: "\f0d8"; -} -.fa-caret-left:before { - content: "\f0d9"; -} -.fa-caret-right:before { - content: "\f0da"; -} -.fa-columns:before { - content: "\f0db"; -} -.fa-unsorted:before, -.fa-sort:before { - content: "\f0dc"; -} -.fa-sort-down:before, -.fa-sort-desc:before { - content: "\f0dd"; -} -.fa-sort-up:before, -.fa-sort-asc:before { - content: "\f0de"; -} -.fa-envelope:before { - content: "\f0e0"; -} -.fa-linkedin:before { - content: "\f0e1"; -} -.fa-rotate-left:before, -.fa-undo:before { - content: "\f0e2"; -} -.fa-legal:before, -.fa-gavel:before { - content: "\f0e3"; -} -.fa-dashboard:before, -.fa-tachometer:before { - content: "\f0e4"; -} -.fa-comment-o:before { - content: "\f0e5"; -} -.fa-comments-o:before { - content: "\f0e6"; -} -.fa-flash:before, -.fa-bolt:before { - content: "\f0e7"; -} -.fa-sitemap:before { - content: "\f0e8"; -} -.fa-umbrella:before { - content: "\f0e9"; -} -.fa-paste:before, -.fa-clipboard:before { - content: "\f0ea"; -} -.fa-lightbulb-o:before { - content: "\f0eb"; -} -.fa-exchange:before { - content: "\f0ec"; -} -.fa-cloud-download:before { - content: "\f0ed"; -} -.fa-cloud-upload:before { - content: "\f0ee"; -} -.fa-user-md:before { - content: "\f0f0"; -} -.fa-stethoscope:before { - content: "\f0f1"; -} -.fa-suitcase:before { - content: "\f0f2"; -} -.fa-bell-o:before { - content: "\f0a2"; -} -.fa-coffee:before { - content: "\f0f4"; -} -.fa-cutlery:before { - content: "\f0f5"; -} -.fa-file-text-o:before { - content: "\f0f6"; -} -.fa-building-o:before { - content: "\f0f7"; -} -.fa-hospital-o:before { - content: "\f0f8"; -} -.fa-ambulance:before { - content: "\f0f9"; -} -.fa-medkit:before { - content: "\f0fa"; -} -.fa-fighter-jet:before { - content: "\f0fb"; -} -.fa-beer:before { - content: "\f0fc"; -} -.fa-h-square:before { - content: "\f0fd"; -} -.fa-plus-square:before { - content: "\f0fe"; -} -.fa-angle-double-left:before { - content: "\f100"; -} -.fa-angle-double-right:before { - content: "\f101"; -} -.fa-angle-double-up:before { - content: "\f102"; -} -.fa-angle-double-down:before { - content: "\f103"; -} -.fa-angle-left:before { - content: "\f104"; -} -.fa-angle-right:before { - content: "\f105"; -} -.fa-angle-up:before { - content: "\f106"; -} -.fa-angle-down:before { - content: "\f107"; -} -.fa-desktop:before { - content: "\f108"; -} -.fa-laptop:before { - content: "\f109"; -} -.fa-tablet:before { - content: "\f10a"; -} -.fa-mobile-phone:before, -.fa-mobile:before { - content: "\f10b"; -} -.fa-circle-o:before { - content: "\f10c"; -} -.fa-quote-left:before { - content: "\f10d"; -} -.fa-quote-right:before { - content: "\f10e"; -} -.fa-spinner:before { - content: "\f110"; -} -.fa-circle:before { - content: "\f111"; -} -.fa-mail-reply:before, -.fa-reply:before { - content: "\f112"; -} -.fa-github-alt:before { - content: "\f113"; -} -.fa-folder-o:before { - content: "\f114"; -} -.fa-folder-open-o:before { - content: "\f115"; -} -.fa-smile-o:before { - content: "\f118"; -} -.fa-frown-o:before { - content: "\f119"; -} -.fa-meh-o:before { - content: "\f11a"; -} -.fa-gamepad:before { - content: "\f11b"; -} -.fa-keyboard-o:before { - content: "\f11c"; -} -.fa-flag-o:before { - content: "\f11d"; -} -.fa-flag-checkered:before { - content: "\f11e"; -} -.fa-terminal:before { - content: "\f120"; -} -.fa-code:before { - content: "\f121"; -} -.fa-mail-reply-all:before, -.fa-reply-all:before { - content: "\f122"; -} -.fa-star-half-empty:before, -.fa-star-half-full:before, -.fa-star-half-o:before { - content: "\f123"; -} -.fa-location-arrow:before { - content: "\f124"; -} -.fa-crop:before { - content: "\f125"; -} -.fa-code-fork:before { - content: "\f126"; -} -.fa-unlink:before, -.fa-chain-broken:before { - content: "\f127"; -} -.fa-question:before { - content: "\f128"; -} -.fa-info:before { - content: "\f129"; -} -.fa-exclamation:before { - content: "\f12a"; -} -.fa-superscript:before { - content: "\f12b"; -} -.fa-subscript:before { - content: "\f12c"; -} -.fa-eraser:before { - content: "\f12d"; -} -.fa-puzzle-piece:before { - content: "\f12e"; -} -.fa-microphone:before { - content: "\f130"; -} -.fa-microphone-slash:before { - content: "\f131"; -} -.fa-shield:before { - content: "\f132"; -} -.fa-calendar-o:before { - content: "\f133"; -} -.fa-fire-extinguisher:before { - content: "\f134"; -} -.fa-rocket:before { - content: "\f135"; -} -.fa-maxcdn:before { - content: "\f136"; -} -.fa-chevron-circle-left:before { - content: "\f137"; -} -.fa-chevron-circle-right:before { - content: "\f138"; -} -.fa-chevron-circle-up:before { - content: "\f139"; -} -.fa-chevron-circle-down:before { - content: "\f13a"; -} -.fa-html5:before { - content: "\f13b"; -} -.fa-css3:before { - content: "\f13c"; -} -.fa-anchor:before { - content: "\f13d"; -} -.fa-unlock-alt:before { - content: "\f13e"; -} -.fa-bullseye:before { - content: "\f140"; -} -.fa-ellipsis-h:before { - content: "\f141"; -} -.fa-ellipsis-v:before { - content: "\f142"; -} -.fa-rss-square:before { - content: "\f143"; -} -.fa-play-circle:before { - content: "\f144"; -} -.fa-ticket:before { - content: "\f145"; -} -.fa-minus-square:before { - content: "\f146"; -} -.fa-minus-square-o:before { - content: "\f147"; -} -.fa-level-up:before { - content: "\f148"; -} -.fa-level-down:before { - content: "\f149"; -} -.fa-check-square:before { - content: "\f14a"; -} -.fa-pencil-square:before { - content: "\f14b"; -} -.fa-external-link-square:before { - content: "\f14c"; -} -.fa-share-square:before { - content: "\f14d"; -} -.fa-compass:before { - content: "\f14e"; -} -.fa-toggle-down:before, -.fa-caret-square-o-down:before { - content: "\f150"; -} -.fa-toggle-up:before, -.fa-caret-square-o-up:before { - content: "\f151"; -} -.fa-toggle-right:before, -.fa-caret-square-o-right:before { - content: "\f152"; -} -.fa-euro:before, -.fa-eur:before { - content: "\f153"; -} -.fa-gbp:before { - content: "\f154"; -} -.fa-dollar:before, -.fa-usd:before { - content: "\f155"; -} -.fa-rupee:before, -.fa-inr:before { - content: "\f156"; -} -.fa-cny:before, -.fa-rmb:before, -.fa-yen:before, -.fa-jpy:before { - content: "\f157"; -} -.fa-ruble:before, -.fa-rouble:before, -.fa-rub:before { - content: "\f158"; -} -.fa-won:before, -.fa-krw:before { - content: "\f159"; -} -.fa-bitcoin:before, -.fa-btc:before { - content: "\f15a"; -} -.fa-file:before { - content: "\f15b"; -} -.fa-file-text:before { - content: "\f15c"; -} -.fa-sort-alpha-asc:before { - content: "\f15d"; -} -.fa-sort-alpha-desc:before { - content: "\f15e"; -} -.fa-sort-amount-asc:before { - content: "\f160"; -} -.fa-sort-amount-desc:before { - content: "\f161"; -} -.fa-sort-numeric-asc:before { - content: "\f162"; -} -.fa-sort-numeric-desc:before { - content: "\f163"; -} -.fa-thumbs-up:before { - content: "\f164"; -} -.fa-thumbs-down:before { - content: "\f165"; -} -.fa-youtube-square:before { - content: "\f166"; -} -.fa-youtube:before { - content: "\f167"; -} -.fa-xing:before { - content: "\f168"; -} -.fa-xing-square:before { - content: "\f169"; -} -.fa-youtube-play:before { - content: "\f16a"; -} -.fa-dropbox:before { - content: "\f16b"; -} -.fa-stack-overflow:before { - content: "\f16c"; -} -.fa-instagram:before { - content: "\f16d"; -} -.fa-flickr:before { - content: "\f16e"; -} -.fa-adn:before { - content: "\f170"; -} -.fa-bitbucket:before { - content: "\f171"; -} -.fa-bitbucket-square:before { - content: "\f172"; -} -.fa-tumblr:before { - content: "\f173"; -} -.fa-tumblr-square:before { - content: "\f174"; -} -.fa-long-arrow-down:before { - content: "\f175"; -} -.fa-long-arrow-up:before { - content: "\f176"; -} -.fa-long-arrow-left:before { - content: "\f177"; -} -.fa-long-arrow-right:before { - content: "\f178"; -} -.fa-apple:before { - content: "\f179"; -} -.fa-windows:before { - content: "\f17a"; -} -.fa-android:before { - content: "\f17b"; -} -.fa-linux:before { - content: "\f17c"; -} -.fa-dribbble:before { - content: "\f17d"; -} -.fa-skype:before { - content: "\f17e"; -} -.fa-foursquare:before { - content: "\f180"; -} -.fa-trello:before { - content: "\f181"; -} -.fa-female:before { - content: "\f182"; -} -.fa-male:before { - content: "\f183"; -} -.fa-gittip:before, -.fa-gratipay:before { - content: "\f184"; -} -.fa-sun-o:before { - content: "\f185"; -} -.fa-moon-o:before { - content: "\f186"; -} -.fa-archive:before { - content: "\f187"; -} -.fa-bug:before { - content: "\f188"; -} -.fa-vk:before { - content: "\f189"; -} -.fa-weibo:before { - content: "\f18a"; -} -.fa-renren:before { - content: "\f18b"; -} -.fa-pagelines:before { - content: "\f18c"; -} -.fa-stack-exchange:before { - content: "\f18d"; -} -.fa-arrow-circle-o-right:before { - content: "\f18e"; -} -.fa-arrow-circle-o-left:before { - content: "\f190"; -} -.fa-toggle-left:before, -.fa-caret-square-o-left:before { - content: "\f191"; -} -.fa-dot-circle-o:before { - content: "\f192"; -} -.fa-wheelchair:before { - content: "\f193"; -} -.fa-vimeo-square:before { - content: "\f194"; -} -.fa-turkish-lira:before, -.fa-try:before { - content: "\f195"; -} -.fa-plus-square-o:before { - content: "\f196"; -} -.fa-space-shuttle:before { - content: "\f197"; -} -.fa-slack:before { - content: "\f198"; -} -.fa-envelope-square:before { - content: "\f199"; -} -.fa-wordpress:before { - content: "\f19a"; -} -.fa-openid:before { - content: "\f19b"; -} -.fa-institution:before, -.fa-bank:before, -.fa-university:before { - content: "\f19c"; -} -.fa-mortar-board:before, -.fa-graduation-cap:before { - content: "\f19d"; -} -.fa-yahoo:before { - content: "\f19e"; -} -.fa-google:before { - content: "\f1a0"; -} -.fa-reddit:before { - content: "\f1a1"; -} -.fa-reddit-square:before { - content: "\f1a2"; -} -.fa-stumbleupon-circle:before { - content: "\f1a3"; -} -.fa-stumbleupon:before { - content: "\f1a4"; -} -.fa-delicious:before { - content: "\f1a5"; -} -.fa-digg:before { - content: "\f1a6"; -} -.fa-pied-piper:before { - content: "\f1a7"; -} -.fa-pied-piper-alt:before { - content: "\f1a8"; -} -.fa-drupal:before { - content: "\f1a9"; -} -.fa-joomla:before { - content: "\f1aa"; -} -.fa-language:before { - content: "\f1ab"; -} -.fa-fax:before { - content: "\f1ac"; -} -.fa-building:before { - content: "\f1ad"; -} -.fa-child:before { - content: "\f1ae"; -} -.fa-paw:before { - content: "\f1b0"; -} -.fa-spoon:before { - content: "\f1b1"; -} -.fa-cube:before { - content: "\f1b2"; -} -.fa-cubes:before { - content: "\f1b3"; -} -.fa-behance:before { - content: "\f1b4"; -} -.fa-behance-square:before { - content: "\f1b5"; -} -.fa-steam:before { - content: "\f1b6"; -} -.fa-steam-square:before { - content: "\f1b7"; -} -.fa-recycle:before { - content: "\f1b8"; -} -.fa-automobile:before, -.fa-car:before { - content: "\f1b9"; -} -.fa-cab:before, -.fa-taxi:before { - content: "\f1ba"; -} -.fa-tree:before { - content: "\f1bb"; -} -.fa-spotify:before { - content: "\f1bc"; -} -.fa-deviantart:before { - content: "\f1bd"; -} -.fa-soundcloud:before { - content: "\f1be"; -} -.fa-database:before { - content: "\f1c0"; -} -.fa-file-pdf-o:before { - content: "\f1c1"; -} -.fa-file-word-o:before { - content: "\f1c2"; -} -.fa-file-excel-o:before { - content: "\f1c3"; -} -.fa-file-powerpoint-o:before { - content: "\f1c4"; -} -.fa-file-photo-o:before, -.fa-file-picture-o:before, -.fa-file-image-o:before { - content: "\f1c5"; -} -.fa-file-zip-o:before, -.fa-file-archive-o:before { - content: "\f1c6"; -} -.fa-file-sound-o:before, -.fa-file-audio-o:before { - content: "\f1c7"; -} -.fa-file-movie-o:before, -.fa-file-video-o:before { - content: "\f1c8"; -} -.fa-file-code-o:before { - content: "\f1c9"; -} -.fa-vine:before { - content: "\f1ca"; -} -.fa-codepen:before { - content: "\f1cb"; -} -.fa-jsfiddle:before { - content: "\f1cc"; -} -.fa-life-bouy:before, -.fa-life-buoy:before, -.fa-life-saver:before, -.fa-support:before, -.fa-life-ring:before { - content: "\f1cd"; -} -.fa-circle-o-notch:before { - content: "\f1ce"; -} -.fa-ra:before, -.fa-rebel:before { - content: "\f1d0"; -} -.fa-ge:before, -.fa-empire:before { - content: "\f1d1"; -} -.fa-git-square:before { - content: "\f1d2"; -} -.fa-git:before { - content: "\f1d3"; -} -.fa-y-combinator-square:before, -.fa-yc-square:before, -.fa-hacker-news:before { - content: "\f1d4"; -} -.fa-tencent-weibo:before { - content: "\f1d5"; -} -.fa-qq:before { - content: "\f1d6"; -} -.fa-wechat:before, -.fa-weixin:before { - content: "\f1d7"; -} -.fa-send:before, -.fa-paper-plane:before { - content: "\f1d8"; -} -.fa-send-o:before, -.fa-paper-plane-o:before { - content: "\f1d9"; -} -.fa-history:before { - content: "\f1da"; -} -.fa-circle-thin:before { - content: "\f1db"; -} -.fa-header:before { - content: "\f1dc"; -} -.fa-paragraph:before { - content: "\f1dd"; -} -.fa-sliders:before { - content: "\f1de"; -} -.fa-share-alt:before { - content: "\f1e0"; -} -.fa-share-alt-square:before { - content: "\f1e1"; -} -.fa-bomb:before { - content: "\f1e2"; -} -.fa-soccer-ball-o:before, -.fa-futbol-o:before { - content: "\f1e3"; -} -.fa-tty:before { - content: "\f1e4"; -} -.fa-binoculars:before { - content: "\f1e5"; -} -.fa-plug:before { - content: "\f1e6"; -} -.fa-slideshare:before { - content: "\f1e7"; -} -.fa-twitch:before { - content: "\f1e8"; -} -.fa-yelp:before { - content: "\f1e9"; -} -.fa-newspaper-o:before { - content: "\f1ea"; -} -.fa-wifi:before { - content: "\f1eb"; -} -.fa-calculator:before { - content: "\f1ec"; -} -.fa-paypal:before { - content: "\f1ed"; -} -.fa-google-wallet:before { - content: "\f1ee"; -} -.fa-cc-visa:before { - content: "\f1f0"; -} -.fa-cc-mastercard:before { - content: "\f1f1"; -} -.fa-cc-discover:before { - content: "\f1f2"; -} -.fa-cc-amex:before { - content: "\f1f3"; -} -.fa-cc-paypal:before { - content: "\f1f4"; -} -.fa-cc-stripe:before { - content: "\f1f5"; -} -.fa-bell-slash:before { - content: "\f1f6"; -} -.fa-bell-slash-o:before { - content: "\f1f7"; -} -.fa-trash:before { - content: "\f1f8"; -} -.fa-copyright:before { - content: "\f1f9"; -} -.fa-at:before { - content: "\f1fa"; -} -.fa-eyedropper:before { - content: "\f1fb"; -} -.fa-paint-brush:before { - content: "\f1fc"; -} -.fa-birthday-cake:before { - content: "\f1fd"; -} -.fa-area-chart:before { - content: "\f1fe"; -} -.fa-pie-chart:before { - content: "\f200"; -} -.fa-line-chart:before { - content: "\f201"; -} -.fa-lastfm:before { - content: "\f202"; -} -.fa-lastfm-square:before { - content: "\f203"; -} -.fa-toggle-off:before { - content: "\f204"; -} -.fa-toggle-on:before { - content: "\f205"; -} -.fa-bicycle:before { - content: "\f206"; -} -.fa-bus:before { - content: "\f207"; -} -.fa-ioxhost:before { - content: "\f208"; -} -.fa-angellist:before { - content: "\f209"; -} -.fa-cc:before { - content: "\f20a"; -} -.fa-shekel:before, -.fa-sheqel:before, -.fa-ils:before { - content: "\f20b"; -} -.fa-meanpath:before { - content: "\f20c"; -} -.fa-buysellads:before { - content: "\f20d"; -} -.fa-connectdevelop:before { - content: "\f20e"; -} -.fa-dashcube:before { - content: "\f210"; -} -.fa-forumbee:before { - content: "\f211"; -} -.fa-leanpub:before { - content: "\f212"; -} -.fa-sellsy:before { - content: "\f213"; -} -.fa-shirtsinbulk:before { - content: "\f214"; -} -.fa-simplybuilt:before { - content: "\f215"; -} -.fa-skyatlas:before { - content: "\f216"; -} -.fa-cart-plus:before { - content: "\f217"; -} -.fa-cart-arrow-down:before { - content: "\f218"; -} -.fa-diamond:before { - content: "\f219"; -} -.fa-ship:before { - content: "\f21a"; -} -.fa-user-secret:before { - content: "\f21b"; -} -.fa-motorcycle:before { - content: "\f21c"; -} -.fa-street-view:before { - content: "\f21d"; -} -.fa-heartbeat:before { - content: "\f21e"; -} -.fa-venus:before { - content: "\f221"; -} -.fa-mars:before { - content: "\f222"; -} -.fa-mercury:before { - content: "\f223"; -} -.fa-intersex:before, -.fa-transgender:before { - content: "\f224"; -} -.fa-transgender-alt:before { - content: "\f225"; -} -.fa-venus-double:before { - content: "\f226"; -} -.fa-mars-double:before { - content: "\f227"; -} -.fa-venus-mars:before { - content: "\f228"; -} -.fa-mars-stroke:before { - content: "\f229"; -} -.fa-mars-stroke-v:before { - content: "\f22a"; -} -.fa-mars-stroke-h:before { - content: "\f22b"; -} -.fa-neuter:before { - content: "\f22c"; -} -.fa-genderless:before { - content: "\f22d"; -} -.fa-facebook-official:before { - content: "\f230"; -} -.fa-pinterest-p:before { - content: "\f231"; -} -.fa-whatsapp:before { - content: "\f232"; -} -.fa-server:before { - content: "\f233"; -} -.fa-user-plus:before { - content: "\f234"; -} -.fa-user-times:before { - content: "\f235"; -} -.fa-hotel:before, -.fa-bed:before { - content: "\f236"; -} -.fa-viacoin:before { - content: "\f237"; -} -.fa-train:before { - content: "\f238"; -} -.fa-subway:before { - content: "\f239"; -} -.fa-medium:before { - content: "\f23a"; -} -.fa-yc:before, -.fa-y-combinator:before { - content: "\f23b"; -} -.fa-optin-monster:before { - content: "\f23c"; -} -.fa-opencart:before { - content: "\f23d"; -} -.fa-expeditedssl:before { - content: "\f23e"; -} -.fa-battery-4:before, -.fa-battery-full:before { - content: "\f240"; -} -.fa-battery-3:before, -.fa-battery-three-quarters:before { - content: "\f241"; -} -.fa-battery-2:before, -.fa-battery-half:before { - content: "\f242"; -} -.fa-battery-1:before, -.fa-battery-quarter:before { - content: "\f243"; -} -.fa-battery-0:before, -.fa-battery-empty:before { - content: "\f244"; -} -.fa-mouse-pointer:before { - content: "\f245"; -} -.fa-i-cursor:before { - content: "\f246"; -} -.fa-object-group:before { - content: "\f247"; -} -.fa-object-ungroup:before { - content: "\f248"; -} -.fa-sticky-note:before { - content: "\f249"; -} -.fa-sticky-note-o:before { - content: "\f24a"; -} -.fa-cc-jcb:before { - content: "\f24b"; -} -.fa-cc-diners-club:before { - content: "\f24c"; -} -.fa-clone:before { - content: "\f24d"; -} -.fa-balance-scale:before { - content: "\f24e"; -} -.fa-hourglass-o:before { - content: "\f250"; -} -.fa-hourglass-1:before, -.fa-hourglass-start:before { - content: "\f251"; -} -.fa-hourglass-2:before, -.fa-hourglass-half:before { - content: "\f252"; -} -.fa-hourglass-3:before, -.fa-hourglass-end:before { - content: "\f253"; -} -.fa-hourglass:before { - content: "\f254"; -} -.fa-hand-grab-o:before, -.fa-hand-rock-o:before { - content: "\f255"; -} -.fa-hand-stop-o:before, -.fa-hand-paper-o:before { - content: "\f256"; -} -.fa-hand-scissors-o:before { - content: "\f257"; -} -.fa-hand-lizard-o:before { - content: "\f258"; -} -.fa-hand-spock-o:before { - content: "\f259"; -} -.fa-hand-pointer-o:before { - content: "\f25a"; -} -.fa-hand-peace-o:before { - content: "\f25b"; -} -.fa-trademark:before { - content: "\f25c"; -} -.fa-registered:before { - content: "\f25d"; -} -.fa-creative-commons:before { - content: "\f25e"; -} -.fa-gg:before { - content: "\f260"; -} -.fa-gg-circle:before { - content: "\f261"; -} -.fa-tripadvisor:before { - content: "\f262"; -} -.fa-odnoklassniki:before { - content: "\f263"; -} -.fa-odnoklassniki-square:before { - content: "\f264"; -} -.fa-get-pocket:before { - content: "\f265"; -} -.fa-wikipedia-w:before { - content: "\f266"; -} -.fa-safari:before { - content: "\f267"; -} -.fa-chrome:before { - content: "\f268"; -} -.fa-firefox:before { - content: "\f269"; -} -.fa-opera:before { - content: "\f26a"; -} -.fa-internet-explorer:before { - content: "\f26b"; -} -.fa-tv:before, -.fa-television:before { - content: "\f26c"; -} -.fa-contao:before { - content: "\f26d"; -} -.fa-500px:before { - content: "\f26e"; -} -.fa-amazon:before { - content: "\f270"; -} -.fa-calendar-plus-o:before { - content: "\f271"; -} -.fa-calendar-minus-o:before { - content: "\f272"; -} -.fa-calendar-times-o:before { - content: "\f273"; -} -.fa-calendar-check-o:before { - content: "\f274"; -} -.fa-industry:before { - content: "\f275"; -} -.fa-map-pin:before { - content: "\f276"; -} -.fa-map-signs:before { - content: "\f277"; -} -.fa-map-o:before { - content: "\f278"; -} -.fa-map:before { - content: "\f279"; -} -.fa-commenting:before { - content: "\f27a"; -} -.fa-commenting-o:before { - content: "\f27b"; -} -.fa-houzz:before { - content: "\f27c"; -} -.fa-vimeo:before { - content: "\f27d"; -} -.fa-black-tie:before { - content: "\f27e"; -} -.fa-fonticons:before { - content: "\f280"; -} -.fa-reddit-alien:before { - content: "\f281"; -} -.fa-edge:before { - content: "\f282"; -} -.fa-credit-card-alt:before { - content: "\f283"; -} -.fa-codiepie:before { - content: "\f284"; -} -.fa-modx:before { - content: "\f285"; -} -.fa-fort-awesome:before { - content: "\f286"; -} -.fa-usb:before { - content: "\f287"; -} -.fa-product-hunt:before { - content: "\f288"; -} -.fa-mixcloud:before { - content: "\f289"; -} -.fa-scribd:before { - content: "\f28a"; -} -.fa-pause-circle:before { - content: "\f28b"; -} -.fa-pause-circle-o:before { - content: "\f28c"; -} -.fa-stop-circle:before { - content: "\f28d"; -} -.fa-stop-circle-o:before { - content: "\f28e"; -} -.fa-shopping-bag:before { - content: "\f290"; -} -.fa-shopping-basket:before { - content: "\f291"; -} -.fa-hashtag:before { - content: "\f292"; -} -.fa-bluetooth:before { - content: "\f293"; -} -.fa-bluetooth-b:before { - content: "\f294"; -} -.fa-percent:before { - content: "\f295"; -} diff --git a/www/css/fonts/FontAwesome.otf b/www/css/fonts/FontAwesome.otf deleted file mode 100644 index 3ed7f8b48ad9bfab52eb03822fefcd6b77d2e680..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 109688 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%0D}#zSrRb8FuaocFSDv)#ldj3 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 diff --git a/www/css/fonts/fontawesome-webfont.svg b/www/css/fonts/fontawesome-webfont.svg deleted file mode 100644 index d05688e9e..000000000 --- a/www/css/fonts/fontawesome-webfont.svg +++ /dev/null @@ -1,655 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/www/css/fonts/fontawesome-webfont.ttf b/www/css/fonts/fontawesome-webfont.ttf deleted file mode 100644 index 26dea7951a73079223b50653c455c5adf46a4648..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 142072 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 +|+&Vdls@0SAya!8#E9iVniRwHu0GY;H*n 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�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%0D0!#Bo^>xe$Z4SmykflG_VnkLvLv4@e#4_y4Q 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�>=olG4!T4BYNCc4f{&*Kgc`m= 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 Date: Mon, 27 Mar 2023 12:18:21 +0200 Subject: [PATCH 049/398] ui: qemu|lxc : fix firewall menu caps The current VM.Console cap is wrong. Only log panel needs VM.Console, the other ones only need VM.Audit. Signed-off-by: Alexandre Derumier [ D: rewrite commit message a bit ] Signed-off-by: Dominik Csapak --- www/manager6/lxc/Config.js | 7 ++++++- www/manager6/qemu/Config.js | 9 +++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/www/manager6/lxc/Config.js b/www/manager6/lxc/Config.js index 23c17d2ea..85d32e3c4 100644 --- a/www/manager6/lxc/Config.js +++ b/www/manager6/lxc/Config.js @@ -304,7 +304,7 @@ Ext.define('PVE.lxc.Config', { }); } - if (caps.vms['VM.Console']) { + if (caps.vms['VM.Audit']) { me.items.push( { xtype: 'pveFirewallRules', @@ -342,6 +342,11 @@ Ext.define('PVE.lxc.Config', { list_refs_url: base_url + '/firewall/refs', itemId: 'firewall-ipset', }, + ); + } + + if (caps.vms['VM.Console']) { + me.items.push( { title: gettext('Log'), groups: ['firewall'], diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js index 94c540c59..6acf589c9 100644 --- a/www/manager6/qemu/Config.js +++ b/www/manager6/qemu/Config.js @@ -339,7 +339,7 @@ Ext.define('PVE.qemu.Config', { }); } - if (caps.vms['VM.Console']) { + if (caps.vms['VM.Audit']) { me.items.push( { xtype: 'pveFirewallRules', @@ -377,7 +377,12 @@ Ext.define('PVE.qemu.Config', { list_refs_url: base_url + '/firewall/refs', itemId: 'firewall-ipset', }, - { + ); + } + + if (caps.vms['VM.Console']) { + me.items.push( + { title: gettext('Log'), groups: ['firewall'], iconCls: 'fa fa-list', From 2e37e77902f2b3712eda5b4d68cc907a1fd901e9 Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Mon, 27 Mar 2023 12:18:22 +0200 Subject: [PATCH 050/398] ui: firewall panel/grids : add privilege checks on buttons Use enableFn to enable/disable the toolbar buttons according to the existing privileges. Signed-off-by: Alexandre Derumier [ D: adapted commit subject and added commit message ] Signed-off-by: Dominik Csapak --- www/manager6/dc/SecurityGroups.js | 7 +++++++ www/manager6/grid/FirewallAliases.js | 6 ++++++ www/manager6/grid/FirewallOptions.js | 6 +++++- www/manager6/grid/FirewallRules.js | 17 ++++++++++++----- www/manager6/panel/IPSet.js | 18 +++++++++++++++++- 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/www/manager6/dc/SecurityGroups.js b/www/manager6/dc/SecurityGroups.js index 26172bf35..b19e370bd 100644 --- a/www/manager6/dc/SecurityGroups.js +++ b/www/manager6/dc/SecurityGroups.js @@ -100,6 +100,8 @@ Ext.define('PVE.SecurityGroupList', { let sm = Ext.create('Ext.selection.RowModel', {}); + let caps = Ext.state.Manager.get('GuiCap'); + let reload = function() { let oldrec = sm.getSelection()[0]; store.load((records, operation, success) => { @@ -130,12 +132,14 @@ Ext.define('PVE.SecurityGroupList', { me.editBtn = new Proxmox.button.Button({ text: gettext('Edit'), + enableFn: rec => !!caps.dc['Sys.Modify'], disabled: true, selModel: sm, handler: run_editor, }); me.addBtn = new Proxmox.button.Button({ text: gettext('Create'), + disabled: !caps.dc['Sys.Modify'], handler: function() { sm.deselectAll(); var win = Ext.create('PVE.SecurityGroupEdit', {}); @@ -148,6 +152,9 @@ Ext.define('PVE.SecurityGroupList', { selModel: sm, baseurl: me.base_url + '/', enableFn: function(rec) { + if (!caps.dc['Sys.Modify']) { + return false; + } return rec && me.base_url; }, callback: () => reload(), diff --git a/www/manager6/grid/FirewallAliases.js b/www/manager6/grid/FirewallAliases.js index 00d0d74b1..b6f073348 100644 --- a/www/manager6/grid/FirewallAliases.js +++ b/www/manager6/grid/FirewallAliases.js @@ -104,6 +104,8 @@ Ext.define('PVE.FirewallAliases', { let sm = Ext.create('Ext.selection.RowModel', {}); + let caps = Ext.state.Manager.get('GuiCap'); + let reload = function() { let oldrec = sm.getSelection()[0]; store.load(function(records, operation, success) { @@ -133,11 +135,13 @@ Ext.define('PVE.FirewallAliases', { text: gettext('Edit'), disabled: true, selModel: sm, + enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'], handler: run_editor, }); me.addBtn = Ext.create('Ext.Button', { text: gettext('Add'), + disabled: !caps.vms['VM.Config.Network'] && !caps.dc['Sys.Modify'] && !caps.nodes['Sys.Modify'], handler: function() { var win = Ext.create('PVE.FirewallAliasEdit', { base_url: me.base_url, @@ -148,7 +152,9 @@ Ext.define('PVE.FirewallAliases', { }); me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + disabled: true, selModel: sm, + enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'], baseurl: me.base_url + '/', callback: reload, }); diff --git a/www/manager6/grid/FirewallOptions.js b/www/manager6/grid/FirewallOptions.js index 4123bd9fe..98b1d258d 100644 --- a/www/manager6/grid/FirewallOptions.js +++ b/www/manager6/grid/FirewallOptions.js @@ -21,6 +21,8 @@ Ext.define('PVE.FirewallOptions', { throw "unknown firewall option type"; } + let caps = Ext.state.Manager.get('GuiCap'); + me.rows = {}; var add_boolean_row = function(name, text, defaultValue) { @@ -161,7 +163,9 @@ Ext.define('PVE.FirewallOptions', { return; } var rowdef = me.rows[rec.data.key]; - edit_btn.setDisabled(!rowdef.editor); + if (caps.vms['VM.Config.Network'] || caps.dc['Sys.Modify'] || caps.nodes['Sys.Modify']) { + edit_btn.setDisabled(!rowdef.editor); + } }; Ext.apply(me, { diff --git a/www/manager6/grid/FirewallRules.js b/www/manager6/grid/FirewallRules.js index 5777c7f49..6b3abb1ba 100644 --- a/www/manager6/grid/FirewallRules.js +++ b/www/manager6/grid/FirewallRules.js @@ -569,11 +569,14 @@ Ext.define('PVE.FirewallRules', { } me.store.removeAll(); } else { - me.addBtn.setDisabled(false); - me.removeBtn.baseurl = url + '/'; - if (me.groupBtn) { - me.groupBtn.setDisabled(false); + if (me.caps.vms['VM.Config.Network'] || me.caps.dc['Sys.Modify'] || me.caps.nodes['Sys.Modify']) { + me.addBtn.setDisabled(false); + if (me.groupBtn) { + me.groupBtn.setDisabled(false); + } } + me.removeBtn.baseurl = url + '/'; + me.store.setProxy({ type: 'proxmox', url: '/api2/json' + url, @@ -649,6 +652,8 @@ Ext.define('PVE.FirewallRules', { var sm = Ext.create('Ext.selection.RowModel', {}); + me.caps = Ext.state.Manager.get('GuiCap'); + var run_editor = function() { var rec = sm.getSelection()[0]; if (!rec) { @@ -680,6 +685,7 @@ Ext.define('PVE.FirewallRules', { me.editBtn = Ext.create('Proxmox.button.Button', { text: gettext('Edit'), disabled: true, + enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], selModel: sm, handler: run_editor, }); @@ -721,7 +727,7 @@ Ext.define('PVE.FirewallRules', { me.copyBtn = Ext.create('Proxmox.button.Button', { text: gettext('Copy'), selModel: sm, - enableFn: ({ data }) => data.type === 'in' || data.type === 'out', + enableFn: ({ data }) => (data.type === 'in' || data.type === 'out') && (!!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify']), disabled: true, handler: run_copy_editor, }); @@ -743,6 +749,7 @@ Ext.define('PVE.FirewallRules', { } me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], selModel: sm, baseurl: me.base_url + '/', confirmMsg: false, diff --git a/www/manager6/panel/IPSet.js b/www/manager6/panel/IPSet.js index a46067698..784d0ea7c 100644 --- a/www/manager6/panel/IPSet.js +++ b/www/manager6/panel/IPSet.js @@ -42,6 +42,8 @@ Ext.define('PVE.IPSetList', { }, }); + var caps = Ext.state.Manager.get('GuiCap'); + var sm = Ext.create('Ext.selection.RowModel', {}); var reload = function() { @@ -94,6 +96,7 @@ Ext.define('PVE.IPSetList', { me.editBtn = new Proxmox.button.Button({ text: gettext('Edit'), disabled: true, + enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'], selModel: sm, handler: run_editor, }); @@ -128,6 +131,7 @@ Ext.define('PVE.IPSetList', { }); me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'], selModel: sm, baseurl: me.base_url + '/', callback: reload, @@ -154,6 +158,10 @@ Ext.define('PVE.IPSetList', { }, }); + if (!caps.vms['VM.Config.Network'] && !caps.dc['Sys.Modify'] && !caps.nodes['Sys.Modify']) { + me.addBtn.setDisabled(true); + } + me.callParent(); store.load(); @@ -268,7 +276,9 @@ Ext.define('PVE.IPSetGrid', { me.addBtn.setDisabled(true); me.store.removeAll(); } else { - me.addBtn.setDisabled(false); + if (me.caps.vms['VM.Config.Network'] || me.caps.dc['Sys.Modify'] || me.caps.nodes['Sys.Modify']) { + me.addBtn.setDisabled(false); + } me.removeBtn.baseurl = url + '/'; me.store.setProxy({ type: 'proxmox', @@ -296,6 +306,8 @@ Ext.define('PVE.IPSetGrid', { var sm = Ext.create('Ext.selection.RowModel', {}); + me.caps = Ext.state.Manager.get('GuiCap'); + var run_editor = function() { var rec = sm.getSelection()[0]; if (!rec) { @@ -312,6 +324,7 @@ Ext.define('PVE.IPSetGrid', { me.editBtn = new Proxmox.button.Button({ text: gettext('Edit'), disabled: true, + enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], selModel: sm, handler: run_editor, }); @@ -319,6 +332,7 @@ Ext.define('PVE.IPSetGrid', { me.addBtn = new Proxmox.button.Button({ text: gettext('Add'), disabled: true, + enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], handler: function() { if (!me.base_url) { return; @@ -333,6 +347,8 @@ Ext.define('PVE.IPSetGrid', { }); me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { + disabled: true, + enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], selModel: sm, baseurl: me.base_url + '/', callback: reload, From 1056e10c4b42955a98d57b5452ece69a9b42d1df Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 7 Jun 2023 09:18:48 +0200 Subject: [PATCH 051/398] ui: firewall: refactor privilege checks and prevent double click factor out the relevant privilege checks in a variable and reuse that, also add the check in the run_editor (or wrap it with a check) so that the edit windows don't open with a double click without those privileges Signed-off-by: Dominik Csapak --- www/manager6/dc/SecurityGroups.js | 14 +++++--------- www/manager6/grid/FirewallAliases.js | 5 +++-- www/manager6/grid/FirewallOptions.js | 5 +++-- www/manager6/grid/FirewallRules.js | 11 ++++++----- www/manager6/panel/IPSet.js | 20 +++++++++++--------- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/www/manager6/dc/SecurityGroups.js b/www/manager6/dc/SecurityGroups.js index b19e370bd..9e26b84c9 100644 --- a/www/manager6/dc/SecurityGroups.js +++ b/www/manager6/dc/SecurityGroups.js @@ -101,6 +101,7 @@ Ext.define('PVE.SecurityGroupList', { let sm = Ext.create('Ext.selection.RowModel', {}); let caps = Ext.state.Manager.get('GuiCap'); + let canEdit = !!caps.dc['Sys.Modify']; let reload = function() { let oldrec = sm.getSelection()[0]; @@ -116,7 +117,7 @@ Ext.define('PVE.SecurityGroupList', { let run_editor = function() { let rec = sm.getSelection()[0]; - if (!rec) { + if (!rec || !canEdit) { return; } Ext.create('PVE.SecurityGroupEdit', { @@ -132,14 +133,14 @@ Ext.define('PVE.SecurityGroupList', { me.editBtn = new Proxmox.button.Button({ text: gettext('Edit'), - enableFn: rec => !!caps.dc['Sys.Modify'], + enableFn: rec => canEdit, disabled: true, selModel: sm, handler: run_editor, }); me.addBtn = new Proxmox.button.Button({ text: gettext('Create'), - disabled: !caps.dc['Sys.Modify'], + disabled: !canEdit, handler: function() { sm.deselectAll(); var win = Ext.create('PVE.SecurityGroupEdit', {}); @@ -151,12 +152,7 @@ Ext.define('PVE.SecurityGroupList', { me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { selModel: sm, baseurl: me.base_url + '/', - enableFn: function(rec) { - if (!caps.dc['Sys.Modify']) { - return false; - } - return rec && me.base_url; - }, + enableFn: (rec) => canEdit && rec && me.base_url, callback: () => reload(), }); diff --git a/www/manager6/grid/FirewallAliases.js b/www/manager6/grid/FirewallAliases.js index b6f073348..0fb6962eb 100644 --- a/www/manager6/grid/FirewallAliases.js +++ b/www/manager6/grid/FirewallAliases.js @@ -105,6 +105,7 @@ Ext.define('PVE.FirewallAliases', { let sm = Ext.create('Ext.selection.RowModel', {}); let caps = Ext.state.Manager.get('GuiCap'); + let canEdit = !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify']; let reload = function() { let oldrec = sm.getSelection()[0]; @@ -120,7 +121,7 @@ Ext.define('PVE.FirewallAliases', { let run_editor = function() { let rec = me.getSelectionModel().getSelection()[0]; - if (!rec) { + if (!rec || !canEdit) { return; } let win = Ext.create('PVE.FirewallAliasEdit', { @@ -135,7 +136,7 @@ Ext.define('PVE.FirewallAliases', { text: gettext('Edit'), disabled: true, selModel: sm, - enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'], + enableFn: rec => canEdit, handler: run_editor, }); diff --git a/www/manager6/grid/FirewallOptions.js b/www/manager6/grid/FirewallOptions.js index 98b1d258d..0ac9979c4 100644 --- a/www/manager6/grid/FirewallOptions.js +++ b/www/manager6/grid/FirewallOptions.js @@ -22,6 +22,7 @@ Ext.define('PVE.FirewallOptions', { } let caps = Ext.state.Manager.get('GuiCap'); + let canEdit = caps.vms['VM.Config.Network'] || caps.dc['Sys.Modify'] || caps.nodes['Sys.Modify']; me.rows = {}; @@ -163,7 +164,7 @@ Ext.define('PVE.FirewallOptions', { return; } var rowdef = me.rows[rec.data.key]; - if (caps.vms['VM.Config.Network'] || caps.dc['Sys.Modify'] || caps.nodes['Sys.Modify']) { + if (canEdit) { edit_btn.setDisabled(!rowdef.editor); } }; @@ -175,7 +176,7 @@ Ext.define('PVE.FirewallOptions', { url: '/api2/extjs/' + me.base_url, }, listeners: { - itemdblclick: me.run_editor, + itemdblclick: () => { if (canEdit) { me.run_editor(); } }, selectionchange: set_button_status, }, }); diff --git a/www/manager6/grid/FirewallRules.js b/www/manager6/grid/FirewallRules.js index 6b3abb1ba..18075eaa6 100644 --- a/www/manager6/grid/FirewallRules.js +++ b/www/manager6/grid/FirewallRules.js @@ -569,7 +569,7 @@ Ext.define('PVE.FirewallRules', { } me.store.removeAll(); } else { - if (me.caps.vms['VM.Config.Network'] || me.caps.dc['Sys.Modify'] || me.caps.nodes['Sys.Modify']) { + if (me.canEdit) { me.addBtn.setDisabled(false); if (me.groupBtn) { me.groupBtn.setDisabled(false); @@ -653,10 +653,11 @@ Ext.define('PVE.FirewallRules', { var sm = Ext.create('Ext.selection.RowModel', {}); me.caps = Ext.state.Manager.get('GuiCap'); + me.canEdit = !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify']; var run_editor = function() { var rec = sm.getSelection()[0]; - if (!rec) { + if (!rec || !me.canEdit) { return; } var type = rec.data.type; @@ -685,7 +686,7 @@ Ext.define('PVE.FirewallRules', { me.editBtn = Ext.create('Proxmox.button.Button', { text: gettext('Edit'), disabled: true, - enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], + enableFn: rec => me.canEdit, selModel: sm, handler: run_editor, }); @@ -727,7 +728,7 @@ Ext.define('PVE.FirewallRules', { me.copyBtn = Ext.create('Proxmox.button.Button', { text: gettext('Copy'), selModel: sm, - enableFn: ({ data }) => (data.type === 'in' || data.type === 'out') && (!!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify']), + enableFn: ({ data }) => (data.type === 'in' || data.type === 'out') && me.canEdit, disabled: true, handler: run_copy_editor, }); @@ -749,7 +750,7 @@ Ext.define('PVE.FirewallRules', { } me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { - enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], + enableFn: rec => me.canEdit, selModel: sm, baseurl: me.base_url + '/', confirmMsg: false, diff --git a/www/manager6/panel/IPSet.js b/www/manager6/panel/IPSet.js index 784d0ea7c..c449cdaa0 100644 --- a/www/manager6/panel/IPSet.js +++ b/www/manager6/panel/IPSet.js @@ -43,6 +43,7 @@ Ext.define('PVE.IPSetList', { }); var caps = Ext.state.Manager.get('GuiCap'); + let canEdit = !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify']; var sm = Ext.create('Ext.selection.RowModel', {}); @@ -60,7 +61,7 @@ Ext.define('PVE.IPSetList', { var run_editor = function() { var rec = sm.getSelection()[0]; - if (!rec) { + if (!rec || !canEdit) { return; } var win = Ext.create('Proxmox.window.Edit', { @@ -96,7 +97,7 @@ Ext.define('PVE.IPSetList', { me.editBtn = new Proxmox.button.Button({ text: gettext('Edit'), disabled: true, - enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'], + enableFn: rec => canEdit, selModel: sm, handler: run_editor, }); @@ -131,7 +132,7 @@ Ext.define('PVE.IPSetList', { }); me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { - enableFn: rec => !!caps.vms['VM.Config.Network'] || !!caps.dc['Sys.Modify'] || !!caps.nodes['Sys.Modify'], + enableFn: rec => canEdit, selModel: sm, baseurl: me.base_url + '/', callback: reload, @@ -158,7 +159,7 @@ Ext.define('PVE.IPSetList', { }, }); - if (!caps.vms['VM.Config.Network'] && !caps.dc['Sys.Modify'] && !caps.nodes['Sys.Modify']) { + if (!canEdit) { me.addBtn.setDisabled(true); } @@ -276,7 +277,7 @@ Ext.define('PVE.IPSetGrid', { me.addBtn.setDisabled(true); me.store.removeAll(); } else { - if (me.caps.vms['VM.Config.Network'] || me.caps.dc['Sys.Modify'] || me.caps.nodes['Sys.Modify']) { + if (me.canEdit) { me.addBtn.setDisabled(false); } me.removeBtn.baseurl = url + '/'; @@ -307,10 +308,11 @@ Ext.define('PVE.IPSetGrid', { var sm = Ext.create('Ext.selection.RowModel', {}); me.caps = Ext.state.Manager.get('GuiCap'); + me.canEdit = !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify']; var run_editor = function() { var rec = sm.getSelection()[0]; - if (!rec) { + if (!rec || !me.canEdit) { return; } var win = Ext.create('PVE.IPSetCidrEdit', { @@ -324,7 +326,7 @@ Ext.define('PVE.IPSetGrid', { me.editBtn = new Proxmox.button.Button({ text: gettext('Edit'), disabled: true, - enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], + enableFn: rec => me.canEdit, selModel: sm, handler: run_editor, }); @@ -332,7 +334,7 @@ Ext.define('PVE.IPSetGrid', { me.addBtn = new Proxmox.button.Button({ text: gettext('Add'), disabled: true, - enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], + enableFn: rec => me.canEdit, handler: function() { if (!me.base_url) { return; @@ -348,7 +350,7 @@ Ext.define('PVE.IPSetGrid', { me.removeBtn = Ext.create('Proxmox.button.StdRemoveButton', { disabled: true, - enableFn: rec => !!me.caps.vms['VM.Config.Network'] || !!me.caps.dc['Sys.Modify'] || !!me.caps.nodes['Sys.Modify'], + enableFn: rec => me.canEdit, selModel: sm, baseurl: me.base_url + '/', callback: reload, From bda3f2aab7f67bbd30286ab3b1f32ad6887b79c3 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 16 Nov 2022 15:04:31 +0100 Subject: [PATCH 052/398] api: backup: update: turn delete into a hash makes it easier to check for keys in the following patches. Signed-off-by: Fiona Ebner --- PVE/API2/Backup.pm | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/PVE/API2/Backup.pm b/PVE/API2/Backup.pm index 6aef5bb78..1d3d68963 100644 --- a/PVE/API2/Backup.pm +++ b/PVE/API2/Backup.pm @@ -435,9 +435,7 @@ __PACKAGE__->register_method({ my $id = extract_param($param, 'id'); my $delete = extract_param($param, 'delete'); - if ($delete) { - $delete = [PVE::Tools::split_list($delete)]; - } + $delete = { map { $_ => 1 } PVE::Tools::split_list($delete) } if $delete; my $update_job = sub { my $data = cfs_read_file('vzdump.cron'); @@ -472,7 +470,7 @@ __PACKAGE__->register_method({ 'repeat-missed' => 1, }; - foreach my $k (@$delete) { + for my $k (keys $delete->%*) { if (!PVE::VZDump::option_exists($k) && !$deletable->{$k}) { raise_param_exc({ delete => "unknown option '$k'" }); } From 659032f48e11c60ae0c7ed17218507ca38bbe8cc Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 16 Nov 2022 15:04:32 +0100 Subject: [PATCH 053/398] api: backup: update: allow only deleting Previously, it was required to set something at the same time. Signed-off-by: Fiona Ebner --- PVE/API2/Backup.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/API2/Backup.pm b/PVE/API2/Backup.pm index 1d3d68963..c0800bac2 100644 --- a/PVE/API2/Backup.pm +++ b/PVE/API2/Backup.pm @@ -443,7 +443,7 @@ __PACKAGE__->register_method({ my $jobs = $data->{jobs} || []; - die "no options specified\n" if !scalar(keys %$param); + die "no options specified\n" if !scalar(keys $param->%*) && !scalar(keys $delete->%*); PVE::VZDump::verify_vzdump_parameters($param); my $opts = PVE::VZDump::JobBase->check_config($id, $param, 0, 1); From 9f65a584b7781b7bb7ed6571513110f5ab0a18e8 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 16 Nov 2022 15:04:33 +0100 Subject: [PATCH 054/398] api: backup: update: check permissions of delete params too Signed-off-by: Fiona Ebner --- PVE/API2/Backup.pm | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/PVE/API2/Backup.pm b/PVE/API2/Backup.pm index c0800bac2..684a078e7 100644 --- a/PVE/API2/Backup.pm +++ b/PVE/API2/Backup.pm @@ -54,6 +54,14 @@ sub assert_param_permission_common { } } +my sub assert_param_permission_update { + my ($rpcenv, $user, $update, $delete) = @_; + return if $user eq 'root@pam'; # always OK + + assert_param_permission_common($rpcenv, $user, $update); + assert_param_permission_common($rpcenv, $user, $delete); +} + my $convert_to_schedule = sub { my ($job) = @_; @@ -424,8 +432,6 @@ __PACKAGE__->register_method({ my $rpcenv = PVE::RPCEnvironment::get(); my $user = $rpcenv->get_user(); - assert_param_permission_common($rpcenv, $user, $param); - if (my $pool = $param->{pool}) { $rpcenv->check_pool_exist($pool); $rpcenv->check($user, "/pool/$pool", ['VM.Backup']); @@ -437,6 +443,8 @@ __PACKAGE__->register_method({ my $delete = extract_param($param, 'delete'); $delete = { map { $_ => 1 } PVE::Tools::split_list($delete) } if $delete; + assert_param_permission_update($rpcenv, $user, $param, $delete); + my $update_job = sub { my $data = cfs_read_file('vzdump.cron'); my $jobs_data = cfs_read_file('jobs.cfg'); From b6e561304a6145c7637f354fb4355198bb11c5ba Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 16 Nov 2022 15:04:34 +0100 Subject: [PATCH 055/398] api: backup: require Datastore.Allocate on storage In particular this ensures that the user is allowed to remove data on the storage, because configuring low retention results in removed older backups. Of course setting the storage itself also needs to require the same privilege then. This is a breaking API change, but it seems sensible to require permissions on the affected storage too. Jobs with a dumpdir setting can be configured by root only. Suggested-by: Thomas Lamprecht Signed-off-by: Fiona Ebner --- PVE/API2/Backup.pm | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/PVE/API2/Backup.pm b/PVE/API2/Backup.pm index 684a078e7..74bf95ca4 100644 --- a/PVE/API2/Backup.pm +++ b/PVE/API2/Backup.pm @@ -54,12 +54,39 @@ sub assert_param_permission_common { } } +my sub assert_param_permission_create { + my ($rpcenv, $user, $param) = @_; + return if $user eq 'root@pam'; # always OK + + assert_param_permission_common($rpcenv, $user, $param); + + if (!$param->{dumpdir}) { + my $storeid = $param->{storage} || 'local'; + $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.Allocate' ]); + } # no else branch, because dumpdir is root-only +} + my sub assert_param_permission_update { - my ($rpcenv, $user, $update, $delete) = @_; + my ($rpcenv, $user, $update, $delete, $current) = @_; return if $user eq 'root@pam'; # always OK assert_param_permission_common($rpcenv, $user, $update); assert_param_permission_common($rpcenv, $user, $delete); + + if ($update->{storage}) { + $rpcenv->check($user, "/storage/$update->{storage}", [ 'Datastore.Allocate' ]) + } elsif ($delete->{storage}) { + $rpcenv->check($user, "/storage/local", [ 'Datastore.Allocate' ]); + } + + return if !$current; # early check done + + if ($current->{dumpdir}) { + die "only root\@pam may edit jobs with a 'dumpdir' option."; + } else { + my $storeid = $current->{storage} || 'local'; + $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.Allocate' ]); + } } my $convert_to_schedule = sub { @@ -220,7 +247,7 @@ __PACKAGE__->register_method({ my $rpcenv = PVE::RPCEnvironment::get(); my $user = $rpcenv->get_user(); - assert_param_permission_common($rpcenv, $user, $param); + assert_param_permission_create($rpcenv, $user, $param); if (my $pool = $param->{pool}) { $rpcenv->check_pool_exist($pool); @@ -473,6 +500,8 @@ __PACKAGE__->register_method({ die "no such vzdump job\n" if !$job || $job->{type} ne 'vzdump'; } + assert_param_permission_update($rpcenv, $user, $param, $delete, $job); + my $deletable = { comment => 1, 'repeat-missed' => 1, From 43f83ad9cee5eeab40abd8a451dd42dfd0c5edd0 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 16 Nov 2022 15:04:35 +0100 Subject: [PATCH 056/398] api: backup/vzdump: add get_storage_param helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit to capture the logic in a single place. Suggested-by: Fabian Grünbichler Signed-off-by: Fiona Ebner --- PVE/API2/Backup.pm | 10 +++++----- PVE/API2/VZDump.pm | 15 ++++++++------- PVE/VZDump.pm | 11 +++++++++-- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/PVE/API2/Backup.pm b/PVE/API2/Backup.pm index 74bf95ca4..25e615d17 100644 --- a/PVE/API2/Backup.pm +++ b/PVE/API2/Backup.pm @@ -60,10 +60,9 @@ my sub assert_param_permission_create { assert_param_permission_common($rpcenv, $user, $param); - if (!$param->{dumpdir}) { - my $storeid = $param->{storage} || 'local'; + if (my $storeid = PVE::VZDump::get_storage_param($param)) { $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.Allocate' ]); - } # no else branch, because dumpdir is root-only + } } my sub assert_param_permission_update { @@ -84,8 +83,9 @@ my sub assert_param_permission_update { if ($current->{dumpdir}) { die "only root\@pam may edit jobs with a 'dumpdir' option."; } else { - my $storeid = $current->{storage} || 'local'; - $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.Allocate' ]); + if (my $storeid = PVE::VZDump::get_storage_param($current)) { + $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.Allocate' ]); + } } } diff --git a/PVE/API2/VZDump.pm b/PVE/API2/VZDump.pm index 8e873c052..e3dcd0bd2 100644 --- a/PVE/API2/VZDump.pm +++ b/PVE/API2/VZDump.pm @@ -27,10 +27,11 @@ my sub assert_param_permission_vzdump { PVE::API2::Backup::assert_param_permission_common($rpcenv, $user, $param); - if (!$param->{dumpdir} && (defined($param->{maxfiles}) || defined($param->{'prune-backups'}))) { - my $storeid = $param->{storage} || 'local'; - $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.Allocate' ]); - } # no else branch, because dumpdir is root-only + if (defined($param->{maxfiles}) || defined($param->{'prune-backups'})) { + if (my $storeid = PVE::VZDump::get_storage_param($param)) { + $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.Allocate' ]); + } + } } __PACKAGE__->register_method ({ @@ -108,9 +109,9 @@ __PACKAGE__->register_method ({ die "you can only backup a single VM with option --stdout\n" if $param->{stdout} && scalar(@{$local_vmids}) != 1; - # If the root-only dumpdir is used rather than a storage, the check will succeed anyways. - my $storeid = $param->{storage} || 'local'; - $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.AllocateSpace' ]); + if (my $storeid = PVE::VZDump::get_storage_param($param)) { + $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.AllocateSpace' ]); + } my $worker = sub { my $upid = shift; diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index 64a5fd4fc..c58e5f78f 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -55,6 +55,13 @@ foreach my $plug (@pve_vzdump_classes) { } } +sub get_storage_param { + my ($param) = @_; + + return if $param->{dumpdir}; + return $param->{storage} || 'local'; +} + # helper functions sub debugmsg { @@ -567,8 +574,8 @@ sub new { die "cannot use options 'storage' and 'dumpdir' at the same time\n"; } - if (!$opts->{dumpdir} && !$opts->{storage}) { - $opts->{storage} = 'local'; + if (my $storage = get_storage_param($opts)) { + $opts->{storage} = $storage; } # Enforced by the API too, but these options might come in via defaults. Drop them if necessary. From e36bc441123a176a27758a40359d7f5e9a164545 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 7 Jun 2023 16:52:38 +0200 Subject: [PATCH 057/398] api: backup: check param permission before pool for consistency Like it did here before 9f65a584 ("api: backup: update: check permissions of delete params too") and like it does in the create case. This should not have a practical effect, it's mostly for consistency and to avoid anybody reading anything into the different orders of checks between update and create. Signed-off-by: Thomas Lamprecht --- PVE/API2/Backup.pm | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/PVE/API2/Backup.pm b/PVE/API2/Backup.pm index 25e615d17..45eb47e26 100644 --- a/PVE/API2/Backup.pm +++ b/PVE/API2/Backup.pm @@ -459,6 +459,12 @@ __PACKAGE__->register_method({ my $rpcenv = PVE::RPCEnvironment::get(); my $user = $rpcenv->get_user(); + my $id = extract_param($param, 'id'); + my $delete = extract_param($param, 'delete'); + $delete = { map { $_ => 1 } PVE::Tools::split_list($delete) } if $delete; + + assert_param_permission_update($rpcenv, $user, $param, $delete); + if (my $pool = $param->{pool}) { $rpcenv->check_pool_exist($pool); $rpcenv->check($user, "/pool/$pool", ['VM.Backup']); @@ -466,12 +472,6 @@ __PACKAGE__->register_method({ $schedule_param_check->($param); - my $id = extract_param($param, 'id'); - my $delete = extract_param($param, 'delete'); - $delete = { map { $_ => 1 } PVE::Tools::split_list($delete) } if $delete; - - assert_param_permission_update($rpcenv, $user, $param, $delete); - my $update_job = sub { my $data = cfs_read_file('vzdump.cron'); my $jobs_data = cfs_read_file('jobs.cfg'); From d2894179f4c2ba9fe59b38df19c95cf6bc209876 Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Tue, 6 Jun 2023 15:19:20 +0200 Subject: [PATCH 058/398] api2: network: check permissions for local bridges always check permissions, also when not filtered Signed-off-by: Alexandre Derumier --- PVE/API2/Network.pm | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/PVE/API2/Network.pm b/PVE/API2/Network.pm index a43579fad..8dc56482c 100644 --- a/PVE/API2/Network.pm +++ b/PVE/API2/Network.pm @@ -209,7 +209,7 @@ __PACKAGE__->register_method({ type => { description => "Only list specific interface types.", type => 'string', - enum => [ @$network_type_enum, 'any_bridge' ], + enum => [ @$network_type_enum, 'any_bridge', 'any_local_bridge' ], optional => 1, }, }, @@ -240,22 +240,17 @@ __PACKAGE__->register_method({ if (my $tfilter = $param->{type}) { my $vnets; - my $vnet_cfg; - my $can_access_vnet = sub { # only matters for the $have_sdn case, checked implict - return 1 if $authuser eq 'root@pam' || !defined($vnets); - return 1 if !defined(PVE::Network::SDN::Vnets::sdn_vnets_config($vnet_cfg, $_[0], 1)); # not a vnet - $rpcenv->check_any($authuser, "/sdn/vnets/$_[0]", ['SDN.Audit', 'SDN.Allocate'], 1) - }; if ($have_sdn && $param->{type} eq 'any_bridge') { $vnets = PVE::Network::SDN::get_local_vnets(); # returns already access-filtered - $vnet_cfg = PVE::Network::SDN::Vnets::config(); } for my $k (sort keys $ifaces->%*) { my $type = $ifaces->{$k}->{type}; - my $match = $tfilter eq $type || ($tfilter eq 'any_bridge' && ($type eq 'bridge' || $type eq 'OVSBridge')); - delete $ifaces->{$k} if !($match && $can_access_vnet->($k)); + my $match = ($param->{type} eq $type) || ( + ($param->{type} =~ /^any(_local)?_bridge$/) && + ($type eq 'bridge' || $type eq 'OVSBridge')); + delete $ifaces->{$k} if !$match; } if (defined($vnets)) { @@ -263,6 +258,16 @@ __PACKAGE__->register_method({ } } + #always check bridge access + my $can_access_vnet = sub { + return 1 if $authuser eq 'root@pam'; + return 1 if $rpcenv->check_sdn_bridge($authuser, "localnetwork", $_[0], ['SDN.Audit', 'SDN.Use'], 1); + }; + for my $k (sort keys $ifaces->%*) { + my $type = $ifaces->{$k}->{type}; + delete $ifaces->{$k} if ($type eq 'bridge' || $type eq 'OVSBridge') && !$can_access_vnet->($k); + } + return PVE::RESTHandler::hash_to_array($ifaces, 'iface'); }}); From 9df839bead862726dac0a2231df73993146b9c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Wed, 7 Jun 2023 11:40:21 +0200 Subject: [PATCH 059/398] api2: network: re-use existing variable tfilter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fabian Grünbichler --- PVE/API2/Network.pm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PVE/API2/Network.pm b/PVE/API2/Network.pm index 8dc56482c..558d78a99 100644 --- a/PVE/API2/Network.pm +++ b/PVE/API2/Network.pm @@ -241,14 +241,14 @@ __PACKAGE__->register_method({ if (my $tfilter = $param->{type}) { my $vnets; - if ($have_sdn && $param->{type} eq 'any_bridge') { + if ($have_sdn && $tfilter eq 'any_bridge') { $vnets = PVE::Network::SDN::get_local_vnets(); # returns already access-filtered } for my $k (sort keys $ifaces->%*) { my $type = $ifaces->{$k}->{type}; - my $match = ($param->{type} eq $type) || ( - ($param->{type} =~ /^any(_local)?_bridge$/) && + my $match = ($tfilter eq $type) || ( + ($tfilter =~ /^any(_local)?_bridge$/) && ($type eq 'bridge' || $type eq 'OVSBridge')); delete $ifaces->{$k} if !$match; } From 8961f9f7801628f766a6135c466091a0f254e3b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Wed, 7 Jun 2023 11:49:07 +0200 Subject: [PATCH 060/398] api2: network: improve code readability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nested conditionals stretching over multiple lines are always a bit hard to untangle, so let's make it explicit: 1. is the interface a bridge 2. if it is, are we looking for one? 3. is it something else that we are looking for? Signed-off-by: Fabian Grünbichler --- PVE/API2/Network.pm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PVE/API2/Network.pm b/PVE/API2/Network.pm index 558d78a99..00d964a79 100644 --- a/PVE/API2/Network.pm +++ b/PVE/API2/Network.pm @@ -247,9 +247,9 @@ __PACKAGE__->register_method({ for my $k (sort keys $ifaces->%*) { my $type = $ifaces->{$k}->{type}; - my $match = ($tfilter eq $type) || ( - ($tfilter =~ /^any(_local)?_bridge$/) && - ($type eq 'bridge' || $type eq 'OVSBridge')); + my $is_bridge = $type eq 'bridge' || $type eq 'OVSBridge'; + my $bridge_match = $is_bridge && $tfilter =~ /^any(_local)?_bridge$/; + my $match = $tfilter eq $type || $bridge_match; delete $ifaces->{$k} if !$match; } From 2387c1946a593dfbe809bcba3afb1013d5c38be5 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Tue, 6 Jun 2023 12:05:04 +0200 Subject: [PATCH 061/398] ui: user view: show tfa lock status Signed-off-by: Wolfgang Bumiller --- www/manager6/dc/UserView.js | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js index bbfc4f7c6..e46ed13e4 100644 --- a/www/manager6/dc/UserView.js +++ b/www/manager6/dc/UserView.js @@ -158,17 +158,31 @@ Ext.define('PVE.dc.UserView', { }, { header: 'TFA', - width: 50, + width: 120, sortable: true, - renderer: function(v) { + renderer: function(v, metaData, record) { let tfa_type = PVE.Parser.parseTfaType(v); if (tfa_type === undefined) { return Proxmox.Utils.noText; - } else if (tfa_type === 1) { - return Proxmox.Utils.yesText; - } else { + } + + if (tfa_type !== 1) { return tfa_type; } + + let locked_until = record.data['tfa-locked-until']; + if (locked_until !== undefined) { + let now = new Date().getTime() / 1000; + if (locked_until > now) { + return gettext('Locked'); + } + } + + if (record.data['totp-locked']) { + return gettext('TOTP Locked'); + } + + return Proxmox.Utils.yesText; }, dataIndex: 'keys', }, From 5970607408e4ed4837b43575e7ab0e0ec1ce59af Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Tue, 6 Jun 2023 12:05:05 +0200 Subject: [PATCH 062/398] ui: user view: add 'Unlock TFA' button Signed-off-by: Wolfgang Bumiller --- www/manager6/dc/UserView.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js index e46ed13e4..fec45deb7 100644 --- a/www/manager6/dc/UserView.js +++ b/www/manager6/dc/UserView.js @@ -93,6 +93,35 @@ Ext.define('PVE.dc.UserView', { }, }); + let unlock_btn = new Proxmox.button.Button({ + text: gettext('Unlock TFA'), + disabled: true, + selModel: sm, + enableFn: rec => !!(caps.access['User.Modify'] && + (rec.data['totp-locked'] || rec.data['tfa-locked-until'])), + handler: function(btn, event, rec) { + Ext.Msg.confirm( + gettext(Ext.String.format('Unlock TFA authentication for {0}', rec.data.userid)), + gettext("Locked 2nd factors can happen if the user's password was leaked. Are you sure you want to unlock the user?"), + function(btn_response) { + if (btn_response === 'yes') { + Proxmox.Utils.API2Request({ + url: `/access/users/${rec.data.userid}/unlock-tfa`, + waitMsgTarget: me, + method: 'PUT', + failure: function(response, options) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + success: function(response, options) { + reload(); + }, + }); + } + }, + ); + }, + }); + Ext.apply(me, { store: store, selModel: sm, @@ -116,6 +145,8 @@ Ext.define('PVE.dc.UserView', { pwchange_btn, '-', perm_btn, + '-', + unlock_btn, ], viewConfig: { trackOver: false, From d1c7fa02096ad025ea179ca7e749edb46e79bb36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Wed, 3 May 2023 09:52:01 +0200 Subject: [PATCH 063/398] ui: cloudinit: match backend privilege checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the cloudinit options except for ipconfig are all modifiable with just "VM.Config.Cloudinit". Signed-off-by: Fabian Grünbichler Signed-off-by: Thomas Lamprecht --- www/manager6/qemu/CloudInit.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/www/manager6/qemu/CloudInit.js b/www/manager6/qemu/CloudInit.js index 77ff93d41..bb0a7a458 100644 --- a/www/manager6/qemu/CloudInit.js +++ b/www/manager6/qemu/CloudInit.js @@ -22,8 +22,8 @@ Ext.define('PVE.qemu.CloudInit', { enableFn: function(record) { let view = this.up('grid'); var caps = Ext.state.Manager.get('GuiCap'); - if (view.rows[record.data.key].never_delete || - !caps.vms['VM.Config.Network']) { + let caps_ci = caps.vms['VM.Config.Network'] || caps.vms['VM.Config.Cloudinit']; + if (view.rows[record.data.key].never_delete || !caps_ci) { return false; } @@ -242,14 +242,14 @@ Ext.define('PVE.qemu.CloudInit', { searchdomain: { header: gettext('DNS domain'), iconCls: 'fa fa-globe', - editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined, + editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined, never_delete: true, defaultValue: gettext('use host settings'), }, nameserver: { header: gettext('DNS servers'), iconCls: 'fa fa-globe', - editor: caps.vms['VM.Config.Network'] ? 'PVE.lxc.DNSEdit' : undefined, + editor: caps_ci ? 'PVE.lxc.DNSEdit' : undefined, never_delete: true, defaultValue: gettext('use host settings'), }, @@ -303,7 +303,7 @@ Ext.define('PVE.qemu.CloudInit', { me.rows['net' + i.toString()] = { multiKey: ['ipconfig' + i.toString(), 'net' + i.toString()], header: gettext('IP Config') + ' (net' + i.toString() +')', - editor: caps.vms['VM.Config.Network'] ? 'PVE.qemu.IPConfigEdit' : undefined, + editor: caps_ci ? 'PVE.qemu.IPConfigEdit' : undefined, iconCls: 'fa fa-exchange', renderer: ipconfig_renderer, }; From a3862f699f9aec88e9e6975063366a4a5f45f2b7 Mon Sep 17 00:00:00 2001 From: Leo Nunner Date: Thu, 4 May 2023 12:55:02 +0200 Subject: [PATCH 064/398] fix #3428: cloud-init: add toggle for automatic upgrades to control the newly introduced "ciupgrade" config parameter. Signed-off-by: Leo Nunner --- www/manager6/qemu/CloudInit.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/www/manager6/qemu/CloudInit.js b/www/manager6/qemu/CloudInit.js index bb0a7a458..0e06d9621 100644 --- a/www/manager6/qemu/CloudInit.js +++ b/www/manager6/qemu/CloudInit.js @@ -286,6 +286,24 @@ Ext.define('PVE.qemu.CloudInit', { }, defaultValue: '', }, + ciupgrade: { + header: gettext('Upgrade packages'), + iconCls: 'fa fa-archive', + renderer: Proxmox.Utils.format_boolean, + defaultValue: '', + editor: { + xtype: 'proxmoxWindowEdit', + subject: gettext('Upgrade packages on boot'), + items: { + xtype: 'proxmoxcheckbox', + name: 'ciupgrade', + uncheckedValue: 0, + defaultValue: 0, + fieldLabel: gettext('Upgrade packages'), + labelWidth: 140, + }, + }, + }, }; var i; var ipconfig_renderer = function(value, md, record, ri, ci, store, pending) { From 8bb027f820bfdc9f4f8e4b75ac84095c473e3183 Mon Sep 17 00:00:00 2001 From: Christoph Heiss Date: Thu, 23 Mar 2023 12:02:47 +0100 Subject: [PATCH 065/398] ui: qga: Add option to turn off QGA fs-freeze/-thaw on backup Adds a default-on checkbox to the QEMU Guest Agent feature selector controlling the 'fs-freeze-on-backup' option. If unchecked, an additional warning is displayed that backups can potentially corrupt with this setting off. Signed-off-by: Christoph Heiss --- www/manager6/Utils.js | 2 ++ www/manager6/form/AgentFeatureSelector.js | 30 ++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index cc7e8ce17..24d82d5e9 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -483,6 +483,8 @@ Ext.define('PVE.Utils', { virtio: "VirtIO", }; displayText = map[value] || Proxmox.Utils.unknownText; + } else if (key === 'freeze-fs-on-backup' && PVE.Parser.parseBoolean(value)) { + continue; } else if (PVE.Parser.parseBoolean(value)) { displayText = Proxmox.Utils.enabledText; } diff --git a/www/manager6/form/AgentFeatureSelector.js b/www/manager6/form/AgentFeatureSelector.js index 0dcc6ecb4..81ea42eab 100644 --- a/www/manager6/form/AgentFeatureSelector.js +++ b/www/manager6/form/AgentFeatureSelector.js @@ -21,6 +21,26 @@ Ext.define('PVE.form.AgentFeatureSelector', { }, disabled: true, }, + { + xtype: 'proxmoxcheckbox', + boxLabel: gettext('Freeze/thaw guest filesystems on backup for consistency'), + name: 'freeze-fs-on-backup', + reference: 'freeze_fs_on_backup', + bind: { + disabled: '{!enabled.checked}', + }, + disabled: true, + uncheckedValue: '0', + defaultValue: '1', + }, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext('Freeze/thaw for guest filesystems disabled. This can lead to inconsistent disk backups.'), + bind: { + hidden: '{freeze_fs_on_backup.checked}', + }, + }, { xtype: 'displayfield', userCls: 'pmx-hint', @@ -47,12 +67,20 @@ Ext.define('PVE.form.AgentFeatureSelector', { ], onGetValues: function(values) { - var agentstr = PVE.Parser.printPropertyString(values, 'enabled'); + if (PVE.Parser.parseBoolean(values['freeze-fs-on-backup'])) { + delete values['freeze-fs-on-backup']; + } + + const agentstr = PVE.Parser.printPropertyString(values, 'enabled'); return { agent: agentstr }; }, setValues: function(values) { let res = PVE.Parser.parsePropertyString(values.agent, 'enabled'); + if (!Ext.isDefined(res['freeze-fs-on-backup'])) { + res['freeze-fs-on-backup'] = 1; + } + this.callParent([res]); }, }); From e81a10a4ab1af19ca35a992a357028f1d38552dc Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Tue, 30 May 2023 13:32:52 +0200 Subject: [PATCH 066/398] api: replication job status: allow querying disabled jobs too Rather than failing with an error claiming that the job doesn't exist. The disabled status will be visible in the result of the call. Signed-off-by: Fiona Ebner --- PVE/API2/Replication.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/API2/Replication.pm b/PVE/API2/Replication.pm index d70b4607d..89c5a802f 100644 --- a/PVE/API2/Replication.pm +++ b/PVE/API2/Replication.pm @@ -296,7 +296,7 @@ __PACKAGE__->register_method ({ my $rpcenv = PVE::RPCEnvironment::get(); my $authuser = $rpcenv->get_user(); - my $jobs = PVE::ReplicationState::job_status(); + my $jobs = PVE::ReplicationState::job_status(1); my $jobid = $param->{id}; my $jobcfg = $jobs->{$jobid}; From cce4b3d7b8b470b69c629733e3a2ba750ef0533c Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Tue, 30 May 2023 15:52:05 +0200 Subject: [PATCH 067/398] ui: override description for resize task Signed-off-by: Fiona Ebner --- www/manager6/Utils.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 24d82d5e9..a150e848f 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1982,6 +1982,7 @@ Ext.define('PVE.Utils', { qmstop: ['VM', gettext('Stop')], qmsuspend: ['VM', gettext('Hibernate')], qmtemplate: ['VM', gettext('Convert to template')], + resize: ['VM/CT', gettext('Resize')], spiceproxy: ['VM/CT', gettext('Console') + ' (Spice)'], spiceshell: ['', gettext('Shell') + ' (Spice)'], startall: ['', gettext('Start all VMs and Containers')], From 79007cfc408e26c02857051cc23af08fb94b8daa Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Wed, 22 Mar 2023 10:23:24 +0100 Subject: [PATCH 068/398] ui: ceph: pool: add pool number as column The pool number is shown in a few places, having it easily accessible can help to understand which pool a warning/error refers to. For example, the PG ID consists of '{pool nr}.{pg nr}' and is shown in every warning concerning that PG. Signed-off-by: Aaron Lauterer --- www/manager6/ceph/Pool.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/www/manager6/ceph/Pool.js b/www/manager6/ceph/Pool.js index 301a3f912..8de23ecf4 100644 --- a/www/manager6/ceph/Pool.js +++ b/www/manager6/ceph/Pool.js @@ -234,6 +234,14 @@ Ext.define('PVE.node.Ceph.PoolList', { features: [{ ftype: 'summary' }], columns: [ + { + text: gettext('Pool #'), + minWidth: 70, + flex: 1, + align: 'right', + sortable: true, + dataIndex: 'pool', + }, { text: gettext('Name'), minWidth: 120, From eed1e93ee970cdf4f0db8bab758ebcd77cb18506 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 8 Jun 2023 13:07:05 +0200 Subject: [PATCH 069/398] pve7to8: sync over from stable-7 branch for after-upgrade checks Signed-off-by: Thomas Lamprecht --- PVE/CLI/pve7to8.pm | 135 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 17 deletions(-) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 06f4e2bb2..6b51e98eb 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -3,6 +3,8 @@ package PVE::CLI::pve7to8; use strict; use warnings; +use Cwd (); + use PVE::API2::APT; use PVE::API2::Ceph; use PVE::API2::LXC; @@ -20,7 +22,7 @@ use PVE::NodeConfig; use PVE::RPCEnvironment; use PVE::Storage; use PVE::Storage::Plugin; -use PVE::Tools qw(run_command split_list); +use PVE::Tools qw(run_command split_list file_get_contents); use PVE::QemuConfig; use PVE::QemuServer; use PVE::VZDump::Common; @@ -36,6 +38,8 @@ use base qw(PVE::CLIHandler); my $nodename = PVE::INotify::nodename(); +my $upgraded = 0; # set in check_pve_packages + sub setup_environment { PVE::RPCEnvironment->setup_default_cli_env(); } @@ -175,7 +179,7 @@ sub check_pve_packages { my $pkgs = join(', ', map { $_->{Package} } @$updates); log_warn("updates for the following packages are available:\n $pkgs"); } else { - log_pass("all packages uptodate"); + log_pass("all packages up-to-date"); } print "\nChecking proxmox-ve package version..\n"; @@ -185,8 +189,6 @@ sub check_pve_packages { my ($maj, $min, $pkgrel) = $proxmox_ve->{OldVersion} =~ m/^(\d+)\.(\d+)[.-](\d+)/; - my $upgraded = 0; - if ($maj > $min_pve_major) { log_pass("already upgraded to Proxmox VE " . ($min_pve_major + 1)); $upgraded = 1; @@ -263,6 +265,8 @@ sub check_storage_health { } check_storage_content(); + eval { check_storage_content_dirs() }; + log_fail("failed to check storage content directories - $@") if $@; } sub check_cluster_corosync { @@ -467,7 +471,7 @@ sub check_ceph { # TODO: check OSD min-required version, if to low it breaks stuff! - log_info("cehcking local Ceph version.."); + log_info("checking local Ceph version.."); if (my $release = eval { PVE::Ceph::Tools::get_local_version(1) }) { my $code_name = $ceph_release2code->{"$release"} || 'unknown'; if ($release == $ceph_supported_release) { @@ -500,9 +504,24 @@ sub check_ceph { { 'key' => 'osd', 'name' => 'OSD' }, ]; + my $ceph_versions_simple = {}; + my $ceph_versions_commits = {}; + for my $type (keys %$ceph_versions) { + for my $full_version (keys $ceph_versions->{$type}->%*) { + if ($full_version =~ m/^(.*) \((.*)\).*\(.*\)$/) { + # String is in the form of + # ceph version 17.2.6 (810db68029296377607028a6c6da1ec06f5a2b27) quincy (stable) + # only check the first part, e.g. 'ceph version 17.2.6', the commit hash can + # be different + $ceph_versions_simple->{$type}->{$1} = 1; + $ceph_versions_commits->{$type}->{$2} = 1; + } + } + } + foreach my $service (@$services) { my ($name, $key) = $service->@{'name', 'key'}; - if (my $service_versions = $ceph_versions->{$key}) { + if (my $service_versions = $ceph_versions_simple->{$key}) { if (keys %$service_versions == 0) { log_skip("no running instances detected for daemon type $name."); } elsif (keys %$service_versions == 1) { @@ -513,6 +532,9 @@ sub check_ceph { } else { log_skip("unable to determine versions of running Ceph $name instances."); } + my $service_commits = $ceph_versions_commits->{$key}; + log_info("different builds of same version detected for an $name. Are you in the middle of the upgrade?") + if $service_commits && keys %$service_commits > 1; } my $overall_versions = $ceph_versions->{overall}; @@ -521,7 +543,7 @@ sub check_ceph { } elsif (keys %$overall_versions == 1) { log_pass("single running overall version detected for all Ceph daemon types."); $noout_wanted = 0; # off post-upgrade, on pre-upgrade - } else { + } elsif (keys $ceph_versions_simple->{overall}->%* != 1) { log_warn("overall version mismatch detected, check 'ceph versions' output for details!"); } } @@ -646,7 +668,7 @@ sub check_backup_retention_settings { log_warn("unable to parse node's VZDump configuration - $err"); } - log_pass("no problems found.") if $pass; + log_pass("no backup retention problems found.") if $pass; } sub check_cifs_credential_location { @@ -673,7 +695,7 @@ sub check_cifs_credential_location { } sub check_custom_pool_roles { - log_info("Checking custom roles for pool permissions.."); + log_info("Checking custom role IDs for clashes with new 'PVE' namespace.."); if (! -f "/etc/pve/user.cfg") { log_skip("user.cfg does not exist"); @@ -712,10 +734,22 @@ sub check_custom_pool_roles { } } - foreach my $role (sort keys %{$roles}) { + my ($custom_roles, $pve_namespace_clashes) = (0, 0); + for my $role (sort keys %{$roles}) { next if PVE::AccessControl::role_is_special($role); + $custom_roles++; - # TODO: any role updates? + if ($role =~ /^PVE/i) { + log_warn("custom role '$role' clashes with 'PVE' namespace for built-in roles"); + $pve_namespace_clashes++; + } + } + if ($pve_namespace_clashes > 0) { + log_fail("$pve_namespace_clashes custom role(s) will clash with 'PVE' namespace for built-in roles enforced in Proxmox VE 8"); + } elsif ($custom_roles > 0) { + log_pass("none of the $custom_roles custom roles will clash with newly enforced 'PVE' namespace") + } else { + log_pass("no custom roles defined, so no clash with 'PVE' role ID namespace enforced in Proxmox VE 8") } } @@ -725,7 +759,7 @@ my sub check_max_length { } sub check_node_and_guest_configurations { - log_info("Checking node and guest description/note legnth.."); + log_info("Checking node and guest description/note length.."); my @affected_nodes = grep { my $desc = PVE::NodeConfig::load_config($_)->{desc}; @@ -804,7 +838,7 @@ sub check_storage_content { next if $scfg->{content}->{images}; next if $scfg->{content}->{rootdir}; - # Skip 'iscsi(direct)' (and foreign plugins with potentially similiar behavior) with 'none', + # Skip 'iscsi(direct)' (and foreign plugins with potentially similar behavior) with 'none', # because that means "use LUNs directly" and vdisk_list() in PVE 6.x still lists those. # It's enough to *not* skip 'dir', because it is the only other storage that supports 'none' # and 'images' or 'rootdir', hence being potentially misconfigured. @@ -926,14 +960,52 @@ sub check_storage_content { } if ($pass) { - log_pass("no problems found"); + log_pass("no storage content problems found"); + } +} + +sub check_storage_content_dirs { + my $storage_cfg = PVE::Storage::config(); + + # check that content dirs are pairwise inequal + my $any_problematic = 0; + for my $storeid (sort keys $storage_cfg->{ids}->%*) { + my $scfg = $storage_cfg->{ids}->{$storeid}; + + next if !PVE::Storage::storage_check_enabled($storage_cfg, $storeid, undef, 1); + next if !$scfg->{path} || !$scfg->{content}; + + eval { PVE::Storage::activate_storage($storage_cfg, $storeid) }; + if (my $err = $@) { + log_warn("activating '$storeid' failed - $err"); + next; + } + + my $resolved_subdirs = {}; + my $plugin = PVE::Storage::Plugin->lookup($scfg->{type}); + for my $vtype (keys $scfg->{content}->%*) { + my $abs_subdir = Cwd::abs_path($plugin->get_subdir($scfg, $vtype)); + push $resolved_subdirs->{$abs_subdir}->@*, $vtype; + } + for my $subdir (keys $resolved_subdirs->%*) { + if (scalar($resolved_subdirs->{$subdir}->@*) > 1) { + my $types = join(", ", $resolved_subdirs->{$subdir}->@*); + log_warn("storage '$storeid' uses directory $subdir for multiple content types ($types)."); + $any_problematic = 1; + } + } + } + if ($any_problematic) { + log_fail("re-using directory for multiple content types (see above) is no longer supported in Proxmox VE 8!") + } else { + log_pass("no storage re-uses a directory for multiple content types.") } } sub check_containers_cgroup_compat { if ($forced_legacy_cgroup) { log_warn("System explicitly configured for legacy hybrid cgroup hierarchy.\n" - ." NOTE: support for the hybrid cgroup hierachy will be removed in future Proxmox VE 9 (~ 2025)." + ." NOTE: support for the hybrid cgroup hierarchy will be removed in future Proxmox VE 9 (~ 2025)." ); } @@ -1052,6 +1124,34 @@ sub check_containers_cgroup_compat { } }; +sub check_lxcfs_fuse_version { + log_info("Checking if LXCFS is running with FUSE3 library, if already upgraded.."); + if (!$upgraded) { + log_skip("not yet upgraded, no need to check the FUSE library version LXCFS uses"); + return; + } + + my $lxcfs_pid = eval { file_get_contents('/run/lxcfs.pid') }; + if (my $err = $@) { + log_fail("failed to get LXCFS pid - $err"); + return; + } + chomp $lxcfs_pid; + + my $lxcfs_maps = eval { file_get_contents("/proc/${lxcfs_pid}/maps") }; + if (my $err = $@) { + log_fail("failed to get LXCFS maps - $err"); + return; + } + + if ($lxcfs_maps =~ /\/libfuse.so.2/s) { + log_warn("systems seems to be upgraded but LXCFS is still running with FUSE 2 library, not yet rebooted?") + } elsif ($lxcfs_maps =~ /\/libfuse3.so.3/s) { + log_pass("systems seems to be upgraded and LXCFS is running with FUSE 3 library") + } + return; +} + sub check_apt_repos { log_info("Checking if the suite for the Debian security repository is correct.."); @@ -1109,7 +1209,7 @@ sub check_apt_repos { PVE::Tools::dir_glob_foreach($dir, '^.*\.list$', $check_file); if (!$found) { - # only warn, it might be defined in a .sources file or in a way not catched above + # only warn, it might be defined in a .sources file or in a way not caaught above log_warn("No Debian security repository detected in /etc/apt/sources.list and " . "/etc/apt/sources.list.d/*.list"); } @@ -1122,7 +1222,7 @@ sub check_time_sync { if ($unit_active->('systemd-timesyncd.service')) { log_warn( "systemd-timesyncd is not the best choice for time-keeping on servers, due to only applying" - ." updates on boot.\n While not necesarry for the upgrade it's recommended to use one of:\n" + ." updates on boot.\n While not necessary for the upgrade it's recommended to use one of:\n" ." * chrony (Default in new Proxmox VE installations)\n * ntpsec\n * openntpd\n" ); } elsif ($unit_active->('ntp.service')) { @@ -1234,6 +1334,7 @@ sub check_misc { check_backup_retention_settings(); check_cifs_credential_location(); check_custom_pool_roles(); + check_lxcfs_fuse_version(); check_node_and_guest_configurations(); check_apt_repos(); } From 185a94abee66d5126bbd7d7119b8e23ee5696476 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 8 Jun 2023 18:41:51 +0200 Subject: [PATCH 070/398] update shipped aplliance info index and pull it from the Proxmox VE 8 index Signed-off-by: Thomas Lamprecht --- aplinfo/Makefile | 2 +- aplinfo/aplinfo.dat | 293 +++++--------------------------------------- 2 files changed, 35 insertions(+), 260 deletions(-) diff --git a/aplinfo/Makefile b/aplinfo/Makefile index ec82550f6..56af6dfcb 100644 --- a/aplinfo/Makefile +++ b/aplinfo/Makefile @@ -17,7 +17,7 @@ install: aplinfo.dat trustedkeys.gpg .PHONY: update update: rm -f aplinfo.dat - wget http://download.proxmox.com/images/aplinfo-pve-7.dat -O aplinfo.dat.tmp + wget http://download.proxmox.com/images/aplinfo-pve-8.dat -O aplinfo.dat.tmp mv aplinfo.dat.tmp aplinfo.dat trustedkeys.gpg: $(TRUSTED_KEYS) diff --git a/aplinfo/aplinfo.dat b/aplinfo/aplinfo.dat index 6d858b963..74f773354 100644 --- a/aplinfo/aplinfo.dat +++ b/aplinfo/aplinfo.dat @@ -4,19 +4,6 @@ Description: News displayed on the admin interface For more information please visit our homepage at www.proxmox.com -Package: almalinux-8-default -Version: 20210928 -Type: lxc -OS: almalinux -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/almalinux-8-default_20210928_amd64.tar.xz -md5sum: fc828e03030fda1d3be6b98cd875f701 -sha512sum: 000070076d6a2ce15c489cbf76e8445a412ce0bdf529064084a2046f2eef113630c3ed91784720cea185ec2ed5fe345dd1d9bc1eb8ad51ed296848c823d34f7e -Infopage: https://linuxcontainers.org -Description: LXC default image for almalinux 8 (20210928) - Package: almalinux-9-default Version: 20221108 Type: lxc @@ -30,166 +17,47 @@ sha512sum: 9b4561fad0de45943c0c46d9a075796533b0941c442cb70a5f9a323a601aba41f2e5e Infopage: https://linuxcontainers.org Description: LXC default image for almalinux 9 (20221108) -Package: alpine-3.14-default -Version: 20210623 +Package: alpine-3.18-default +Version: 20230607 Type: lxc OS: alpine Section: system Maintainer: Proxmox Support Team Architecture: amd64 -Location: system/alpine-3.14-default_20210623_amd64.tar.xz -md5sum: 06f638cc942c7965f5b5414300e01e60 -sha512sum: 87709566840d4ef5fe7bba63afce183f12c27e7499315d1729886ab0ca0c14599093b9da937037b86093c0ba15385d51b987f79a612150950e9e197757deeb37 +Location: system/alpine-3.18-default_20230607_amd64.tar.xz +md5sum: 298d21830b18f9d1fc98f6797bd36967 +sha512sum: f9d19b4a6d3c6201cf7a4624baf2d16ef08e7668adec0b7ea1a9c0ab8d854c8a9d11da48cc6f89ddb50a873bd339cfd4d76c402e8548f425469afba1a148479a Infopage: https://linuxcontainers.org -Description: LXC default image for alpine 3.14 (20210623) - -Package: alpine-3.15-default -Version: 20211202 -Type: lxc -OS: alpine -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/alpine-3.15-default_20211202_amd64.tar.xz -md5sum: 31fac55c59c899e71500e39ea4d868be -sha512sum: 6fa966787de6f18fc502016cd3516bcac0a5de0dfa5e906aa08a1c0cc51b51b0df08d9efbe221e0914dbece295d1b32ccae14909a04fbfc263b8e0ecd4831491 -Infopage: https://linuxcontainers.org -Description: LXC default image for alpine 3.15 (20211202) - -Package: alpine-3.16-default -Version: 20220622 -Type: lxc -OS: alpine -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/alpine-3.16-default_20220622_amd64.tar.xz -md5sum: e64326c403f2f907c5b317a1c6cea28e -sha512sum: 85ad913768fe79c6b171f64661ef463772b78c6fa22a6e4e297565823f9b17522c31516b3a9ea428280dc81d74e36888d0acc7ed5dec1a6ae400b88c33a80453 -Infopage: https://linuxcontainers.org -Description: LXC default image for alpine 3.16 (20220622) - -Package: alpine-3.17-default -Version: 20221129 -Type: lxc -OS: alpine -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/alpine-3.17-default_20221129_amd64.tar.xz -md5sum: 7eae3bc612d8eb8283cdbac209e056f6 -sha512sum: 446185a5472c18cb7b60cb808e60730fd8fd438b0b94182f5317cddc78e95852248359bd57067f8f288130684360b8bf77616b24e9738203f4a3087bdf1d8494 -Infopage: https://linuxcontainers.org -Description: LXC default image for alpine 3.17 (20221129) +Description: LXC default image for alpine 3.18 (20230607) Package: archlinux-base -Version: 20221111-1 +Version: 20230608-1 Type: lxc OS: archlinux Section: system Maintainer: Proxmox Support Team Architecture: amd64 -Location: system/archlinux-base_20221111-1_amd64.tar.zst -md5sum: e83fea20d8975fe774c189ea0ec5e05a -sha512sum: 354871fe963c1cc185a11b4688c658d8a4d172e73ee47d78503889720752ea5818092d72464c860e4b4d5daea9cee7c0850bcc8d7ea8904e9d1ae1c1b6bb5054 +Location: system/archlinux-base_20230608-1_amd64.tar.zst +md5sum: 581d33c1c8b71aa72df47d84285eef8e +sha512sum: 7f1ece6ded3d8571da8e0e20695e68391bec7585746632c6d555c8a8ecff3611fd87cf136c823d33d795e2c9adae9501190796a6d54c097c134afb965c97600f Infopage: https://www.archlinux.org Description: ArchLinux base image. ArchLinux template with the 'base' group and the 'openssh' package installed. -Package: centos-7-default -Version: 20190926 -Type: lxc -OS: centos -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/centos-7-default_20190926_amd64.tar.xz -md5sum: f8544ba07b07706f9b74d5f7669d0645 -sha512sum: 7b4a888c38801981a3e8f9fa493c0a233bda3901f56a87763fa3d17c204bbf4e8650d3edf8492aa34cfe2a01631e7182c571b38bea5c7761ae6ea3b63de6c1b4 -Infopage: https://linuxcontainers.org -Description: LXC default image for centos 7 (20190926) - -Package: centos-8-default -Version: 20201210 -Type: lxc -OS: centos -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/centos-8-default_20201210_amd64.tar.xz -md5sum: 56c3c7623ec4b326e9459fe2e2f2fd06 -sha512sum: 006e9aadcbd018d4b5db0b250acd39299ad4b8fcb5f6c4ceb6fc0e9323c504c3f763f0238ecaba1b6dd75e652fe703a1d93333ed1d3d0d3ba4ea73a38f34cf94 -Infopage: https://linuxcontainers.org -Description: LXC default image for centos 8 (20201210) - -Package: centos-8-stream-default -Version: 20220327 -Type: lxc -OS: centos -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/centos-8-stream-default_20220327_amd64.tar.xz -md5sum: 21841a4476c3ba0c14055857a923dc72 -sha512sum: 27003cde9128336f4e01e0c3ccbe93c527ad66645e169743d556beb9c7719248b394c85d6d3abb0a547815bbefacfb1159cce9c6003f3ab38cde716c5958e250 -Infopage: https://linuxcontainers.org -Description: LXC default image for centos 8-stream (20220327) - -Package: centos-9-stream-default -Version: 20221109 -Type: lxc -OS: centos -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/centos-9-stream-default_20221109_amd64.tar.xz -md5sum: 13fccdcc2358b795ee613501eb88c850 -sha512sum: 04bb902992f74edf2333d215837e9bb21258dfcdb7bf23bd659176641f6538aeb25bc44286c9caffb10ceb87288ce93668c9410f4a69b8a3b316e09032ead3a8 -Infopage: https://linuxcontainers.org -Description: LXC default image for centos 9-stream (20221109) - -Package: debian-10-standard -Version: 10.7-1 -Type: lxc -OS: debian-10 -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/debian-10-standard_10.7-1_amd64.tar.gz -md5sum: f132e041a6e9c0c7f2bee8575be57af6 -sha512sum: 20abf18e40be931a71ec77fac4bc55cc1d87a826e96f7ab9a4f5bc24016533c20992fb0646810dff510c806080a244231d7f3e7d0b94775ddecfb0618a15eedd -Infopage: http://pve.proxmox.com/wiki/Debian_10.0_Standard -Description: Debian 10 Buster (standard) - A small Debian Buster system including all standard packages. - Package: debian-11-standard -Version: 11.3-1 +Version: 11.7-1 Type: lxc OS: debian-11 Section: system Maintainer: Proxmox Support Team Architecture: amd64 -Location: system/debian-11-standard_11.3-1_amd64.tar.zst -md5sum: 5a061ed422b8ef0a4de61abe56c6ce36 -sha512sum: da331993ba1a62a67ffff316dee361019491e48aa227ccb90db417095f7ff63c4c61ace3817573fc21567cd7a16f3dbd04c2966b21d1418864e037daec908143 +Location: system/debian-11-standard_11.7-1_amd64.tar.zst +md5sum: d07069fe196e89ef70a546625507882a +sha512sum: 973bb8ee260b5da8c02b023651370d53632ae0b838d878e5cbe38291ca2bcac9ab2d0ef80709e5e27865cfd48a67ff334ea1441d5744e3db372a1fcc84491557 Infopage: https://pve.proxmox.com/wiki/Linux_Container#pct_supported_distributions Description: Debian 11 Bullseye (standard) A small Debian Bullseye system including all standard packages. -Package: devuan-3.0-standard -Version: 3.0 -Type: lxc -OS: devuan-3.0 -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/devuan-3.0-standard_3.0_amd64.tar.gz -md5sum: 955cb0a7f6608dd396dc629794f57473 -sha512sum: 95319b369324482abc1fe67d5ced2561367b8f8a727797c4f75ee04b30472281991593b79a1ec8390d80f9f24c2fbbf66f612db6bc82b5f4f61f4bed2c1a8a14 -Infopage: https://devuan.org -Description: Devuan 3.0 (standard) - A small Devuan Beowulf system including a minimal set of essential packages. - Package: devuan-4.0-standard Version: 4.0 Type: lxc @@ -204,98 +72,33 @@ Infopage: https://devuan.org Description: Devuan 4.0 (standard) A small Devuan Chimaera system including a minimal set of essential packages. -Package: fedora-35-default -Version: 20211111 +Package: fedora-38-default +Version: 20230607 Type: lxc OS: fedora Section: system Maintainer: Proxmox Support Team Architecture: amd64 -Location: system/fedora-35-default_20211111_amd64.tar.xz -md5sum: 11bb241828ebba4bef8a4f12ae4fe0d5 -sha512sum: ddc3e4fbedeefb75e15098feddc2132242921eae52ff39b196dca2adc2e056e6fa88b39e38ac63d63f0806b1c10f681ad75bcf17387aab903b6770e62addcd8d +Location: system/fedora-38-default_20230607_amd64.tar.xz +md5sum: 45bbf8c641320aa91f2c8cf52a5280cc +sha512sum: 54328a3338ca9657d298a8a5d2ca15fe76f66fd407296d9e3e1c236ee60ea075d3406c175fdb46fe5c29e664224f2ce33bfe8cd1f634b7ea08d0183609d5e93c Infopage: https://linuxcontainers.org -Description: LXC default image for fedora 35 (20211111) +Description: LXC default image for fedora 38 (20230607) -Package: fedora-36-default -Version: 20220622 -Type: lxc -OS: fedora -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/fedora-36-default_20220622_amd64.tar.xz -md5sum: f799a34c2bb3a9e5a39fa4b16f503dbd -sha512sum: 0fe7d94bea81011aeacc13e820efa107feb6c11384335367d199d28f00e610010945569733873d31e11c0e32f6fb8384e09563c2f26b096d95d072d68fb68e9a -Infopage: https://linuxcontainers.org -Description: LXC default image for fedora 36 (20220622) - -Package: fedora-37-default -Version: 20221119 -Type: lxc -OS: fedora -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/fedora-37-default_20221119_amd64.tar.xz -md5sum: c74e6d7004d2951ef9d17beaf06fcb85 -sha512sum: b12d0bfd6fcfbb33c3b896a5a9c2bc5547817f69df2e72c8ed7d318b56412b550a8b9876f4358f5c649cbbbe7a7e2ccf950c14e1cd9dfa86e7252ec06cd9f336 -Infopage: https://linuxcontainers.org -Description: LXC default image for fedora 37 (20221119) - -Package: gentoo-current-openrc -Version: 20220622 -Type: lxc -OS: gentoo -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/gentoo-current-openrc_20220622_amd64.tar.xz -md5sum: 2fdde1b419bbe1cf1fc4b9944c691209 -sha512sum: 916217385f24f7eedbf406680de1c38767d24b42eba2b5267c67351892d3ecd7b8fb1d67b23974f43e44de2f4aafd1510cfe9261eab6e83c44af1f00708af5a2 -Infopage: https://linuxcontainers.org -Description: LXC openrc image for gentoo current (20220622) - -Package: opensuse-15.4-default -Version: 20221109 -Type: lxc -OS: opensuse -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/opensuse-15.4-default_20221109_amd64.tar.xz -md5sum: 1c66c3549b0684e788c17aa94c384262 -sha512sum: 8089309652a0db23ddff826d1e343e79c6eccb7b615fb309e0a6f6f1983ea697aa94044a795f3cbe35156b1a1b2f60489eb20ecb54c786cec23c9fd89e0f29c5 -Infopage: https://linuxcontainers.org -Description: LXC default image for opensuse 15.4 (20221109) - -Package: proxmox-mailgateway-7.2-standard -Version: 7.2-1 +Package: proxmox-mailgateway-7.3-standard +Version: 7.3-1 Type: lxc OS: debian-11 Section: mail Maintainer: Proxmox Support Team Architecture: amd64 -Location: mail/proxmox-mailgateway-7.2-standard_7.2-1_amd64.tar.gz -md5sum: a38d4284fc91d4d3cf2a324a702fd938 -sha512sum: d2ee3dba238d3b583e92f1b944cd7720fb4e79fcbf11902b1833700ebbe53b25093e100b66c240a5c55bbfb5e34187930455537dd400f7daa1c0ff26206995bf +Location: mail/proxmox-mailgateway-7.3-standard_7.3-1_amd64.tar.zst +md5sum: 6c130003f9880ae66dca0603d7b7ca87 +sha512sum: 2fdf1dc24306bbaa2ef9a0f322416ca15b97b7d19f84b83743c7afc896095c398241fbc2eb41a33a69f3f275ce4c4cb6425edc5538831b4650d39a5e44fdbc25 Infopage: https://www.proxmox.com/de/proxmox-mail-gateway -Description: Proxmox Mailgateway 7.2 +Description: Proxmox Mailgateway 7.3 A full featured mail proxy for spam and virus filtering, optimized for container environment. -Package: rockylinux-8-default -Version: 20210929 -Type: lxc -OS: rockylinux -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/rockylinux-8-default_20210929_amd64.tar.xz -md5sum: 7d327d28fa10eee199afe0366a3fdff6 -sha512sum: 6e20e4c6fc0aef4eba582d9cdcb44403449a97db0bf78ebaf2c78e5092cd307613654fb08ef7f54505d14f7f1946b476ff30e193bedd196c7118a1cd2134f23c -Infopage: https://linuxcontainers.org -Description: LXC default image for rockylinux 8 (20210929) - Package: rockylinux-9-default Version: 20221109 Type: lxc @@ -309,34 +112,6 @@ sha512sum: ddc2a29ee66598d4c3a4224a0fa9868882e80bbabb7a20ae9f53431bb0ff73e73d4bd Infopage: https://linuxcontainers.org Description: LXC default image for rockylinux 9 (20221109) -Package: ubuntu-18.04-standard -Version: 18.04.1-1 -Type: lxc -OS: ubuntu-18.04 -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/ubuntu-18.04-standard_18.04.1-1_amd64.tar.gz -md5sum: 6da8032da517264477b844aed82cb929 -sha512sum: 64ffa07e012115567b29cc460686687528d674ba2fbcb198d9cf4e3943bc0f58149b266bf0366c2ef03c6bb8909225e2a3010f7c29878ba83e1ef388ef56b1dd -Infopage: http://pve.proxmox.com/wiki/Ubuntu_Bionic_Standard -Description: Ubuntu Bionic (standard) - A small Ubuntu Bionic system including all standard packages. - -Package: ubuntu-20.04-standard -Version: 20.04-1 -Type: lxc -OS: ubuntu-20.04 -Section: system -Maintainer: Proxmox Support Team -Architecture: amd64 -Location: system/ubuntu-20.04-standard_20.04-1_amd64.tar.gz -md5sum: 2ceda507834a0a08ce9662257acb7dde -sha512sum: 9ebcf6cce7c3b760d27c2bfe03d00acf3d40a489cd98230b9ab8dd436f82f4ae8dc2de69b012dcd0affae62ffe4ab4863f624a665f3493983d330e6c015a26dd -Infopage: http://pve.proxmox.com/wiki/Ubuntu_Disco_Standard -Description: Ubuntu Focal (standard) - A small Ubuntu 20.04 Focal Fossa system including all standard packages. - Package: ubuntu-22.04-standard Version: 22.04-1 Type: lxc @@ -351,16 +126,16 @@ Infopage: https://pve.proxmox.com/wiki/Linux_Container#pct_supported_distributio Description: Ubuntu 22.04 Jammy (standard) A small Ubuntu 22.04 Jammy Jellyfish system including all standard packages. -Package: ubuntu-22.10-standard -Version: 22.10-1 +Package: ubuntu-23.04-standard +Version: 23.04-1 Type: lxc -OS: ubuntu-22.10 +OS: ubuntu-23.04 Section: system Maintainer: Proxmox Support Team Architecture: amd64 -Location: system/ubuntu-22.10-standard_22.10-1_amd64.tar.zst -md5sum: 9d9c20132f479905398921638a285584 -sha512sum: be2a5f3e749e8958fa8487f47aa67638d18c29d26c218cea289afc44923eb3efb0ef11572c78752ef3d707db1c5fe123aef7ca3f33e3b10d9feed1a34eb5362a +Location: system/ubuntu-23.04-standard_23.04-1_amd64.tar.zst +md5sum: 5dee55750bd72e210be6603e3b87005b +sha512sum: 6c7d916cc76865d5984b6b41e3d45426071967059ecc0a5d10029d2706cca0ea96a3c4f4dfcede08e7a86f73b9dfbd3d07ac86ee380b0f12be6c35e486033249 Infopage: https://pve.proxmox.com/wiki/Linux_Container#pct_supported_distributions -Description: Ubuntu 22.10 Kinetic (standard) - A small Ubuntu 22.10 Kinetic Kudu system including all standard packages. +Description: Ubuntu 23.04 Lunar (standard) + A small Ubuntu 23.04 Lunar Lobster system including all standard packages. From 3477c119ed74d4edbb48dc157e69de9a70f92aa5 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 9 Jun 2023 08:23:00 +0200 Subject: [PATCH 071/398] d/postinst: setup pvetest repo for beta Signed-off-by: Thomas Lamprecht --- debian/postinst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/debian/postinst b/debian/postinst index 5a3d94724..06fd0fb5a 100755 --- a/debian/postinst +++ b/debian/postinst @@ -160,9 +160,9 @@ case "$1" in # FIXME: remove after beta is over and add hunk to actively remove the repo BETA_SOURCES="/etc/apt/sources.list.d/pvetest-for-beta.list" - if test -f "$BETA_SOURCES" && dpkg --compare-versions "$2" 'lt' '7.0-9~' && dpkg --compare-versions "$2" 'gt' '7.0~'; then - echo "Removing the during beta added pvetest repository file again" - rm -v "$BETA_SOURCES" || true + if ! test -f "$BETA_SOURCES"; then + echo "Adding pvetest repo to '$BETA_SOURCES' to enable updates during Proxmox VE 8.0 BETA" + echo "deb http://download.proxmox.com/debian/pve bookworm pvetest" | tee "$BETA_SOURCES" fi # FIXME: remove in PVE 8.0 From 2ef204f91b780b879ba3545f10e6ed64e630bb0a Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 9 Jun 2023 08:26:12 +0200 Subject: [PATCH 072/398] d/postinst: remove re-generation of unique machine-ID for old ISOs Signed-off-by: Thomas Lamprecht --- debian/postinst | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) diff --git a/debian/postinst b/debian/postinst index 06fd0fb5a..a685191df 100755 --- a/debian/postinst +++ b/debian/postinst @@ -165,53 +165,6 @@ case "$1" in echo "deb http://download.proxmox.com/debian/pve bookworm pvetest" | tee "$BETA_SOURCES" fi - # FIXME: remove in PVE 8.0 - if test ! -e /proxmox_install_mode && test -n "$2" && dpkg --compare-versions "$2" 'lt' '7.0-6~'; then - # PVE 4.0 beta to 5.4 ISO had a bug and did not generated a unique machine-id. below is a - # very relaxed machine-id list from all ISOs (released, tests & internal) possibly affected - if grep -q \ - -e a0ee88c29b764c46a579dd89c86c2d84 \ - -e ecbf104295bd4f8b90bb82dc2fa5e9e5 \ - -e c8fa51cd0c254ea08b0e37c1e37afbb9 \ - -e 2ec24eda629a4c8d8c1f8dac50a9ee5f \ - -e ef8db290720047159b426bd322839d70 \ - -e bd94244c0da6419a82a383e62dc03b51 \ - -e 45d4e7046c3d4c26af8acd589f358ac6 \ - -e 8c445f96b3064ff79f825ea78a3eefde \ - -e 6f9fae0f0a794fd4b89b3abecfd7f182 \ - -e 6f9fae0f0a794fd4b89b3abecfd7f182 \ - -e 285de85759894b3f9ad9844a89045af6 \ - -e 89971dede7b04c98b2b0bc8845f53320 \ - -e 4e3b6e9550f24d638bc26211a7b37df5 \ - -e bc2f684e31ee4daf95e45c62410a95b1 \ - -e 8cc7bc883fd048b78a4af7433c48e341 \ - -e 9b46d99712854566bb02a656a3ff9191 \ - -e e7fc055af47048ee884dcb88a7474336 \ - -e 13d879f75e6447a69ed85179bd93759a \ - -e 5b59e448c3e74029af2ac91f572d68a7 \ - -e 5a2bd0d11a6c41f9a33fd527751224ea \ - -e 516afc72013c4b9da85b309aad987df2 \ - -e b0ce8d24684845e8ac337c588a7715cb \ - -e e0af064c16e9463e9fa980eac66427c1 \ - -e 6e925d11b497446e8e7f2ff38e7cf891 \ - -e eec280213051474d8bfe7e089a86744a \ - -e 708ded6ee82a46c08b77fecda2284c6c \ - -e 615cb2b78b2240289fef74da610c146f \ - -e b965b329a7e246d5be66a8d367f5760d \ - -e 5472a49c6436426fbebd7881f7b7f13b \ - /etc/machine-id - then - echo "found static machine-id bug from Proxmox VE ISO installer <= 5.4, regenerating machine-id" - systemd-id128 new | tee /etc/machine-id.new /var/lib/dbus/machine-id.new - # atomically replace - mv /etc/machine-id.new /etc/machine-id - mv /var/lib/dbus/machine-id.new /var/lib/dbus/machine-id - echo "new machine-id generated, a reboot is recommended" - else - echo "machine-id check OK" - fi - fi - set_lvm_conf if test ! -e /proxmox_install_mode; then From 22fcd0069ae268d86acd73e3dcebce182b32911b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 9 Jun 2023 09:52:42 +0200 Subject: [PATCH 073/398] ui: user view: fix calling order of gettext One must not call gettext on the already formatted string, as we cannot translate it for any possible value, rather the format string it self needs to be gettext'd, then the translator can position the variable template placeholders however it's correct for their language without having to care about any value this could be called with. Fixes: d057929f ("ui: user view: fix calling order of gettext") Signed-off-by: Thomas Lamprecht --- www/manager6/dc/UserView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js index fec45deb7..bfbc45958 100644 --- a/www/manager6/dc/UserView.js +++ b/www/manager6/dc/UserView.js @@ -101,7 +101,7 @@ Ext.define('PVE.dc.UserView', { (rec.data['totp-locked'] || rec.data['tfa-locked-until'])), handler: function(btn, event, rec) { Ext.Msg.confirm( - gettext(Ext.String.format('Unlock TFA authentication for {0}', rec.data.userid)), + Ext.String.format(gettext('Unlock TFA authentication for {0}'), rec.data.userid)), gettext("Locked 2nd factors can happen if the user's password was leaked. Are you sure you want to unlock the user?"), function(btn_response) { if (btn_response === 'yes') { From 7b54999a4719123512d07fb3580063b010f2a141 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 9 Jun 2023 09:55:43 +0200 Subject: [PATCH 074/398] ui: fixup lost closing parenthesis ... Signed-off-by: Thomas Lamprecht --- www/manager6/dc/UserView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js index bfbc45958..957ff6827 100644 --- a/www/manager6/dc/UserView.js +++ b/www/manager6/dc/UserView.js @@ -101,7 +101,7 @@ Ext.define('PVE.dc.UserView', { (rec.data['totp-locked'] || rec.data['tfa-locked-until'])), handler: function(btn, event, rec) { Ext.Msg.confirm( - Ext.String.format(gettext('Unlock TFA authentication for {0}'), rec.data.userid)), + Ext.String.format(gettext('Unlock TFA authentication for {0}'), rec.data.userid), gettext("Locked 2nd factors can happen if the user's password was leaked. Are you sure you want to unlock the user?"), function(btn_response) { if (btn_response === 'yes') { From eb859354981bca0e034826599babb61a47be72cf Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 9 Jun 2023 10:47:43 +0200 Subject: [PATCH 075/398] api: mark batch-execute api calls root-only This is weird and buggy and breaches the unpriv./priv. separation of our api daemons, so root-only for now and possibly removal soon. note that this had several limitations already anyway, like running in sync context and thus failing after 30s. Signed-off-by: Thomas Lamprecht --- PVE/API2/Nodes.pm | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index e642ab4f4..9269694d6 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -464,10 +464,7 @@ __PACKAGE__->register_method({ name => 'execute', path => 'execute', method => 'POST', - permissions => { - check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]], - }, - description => "Execute multiple commands in order.", + description => "Execute multiple commands in order, root only.", proxyto => 'node', protected => 1, # avoid problems with proxy code parameters => { From e7fc4411ad04cc5e07e1d16e685174df36370feb Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Fri, 9 Jun 2023 10:25:50 +0200 Subject: [PATCH 076/398] ui: qemu: show progress bar for resize task The API call was changed to spawn a task now. Signed-off-by: Fiona Ebner --- www/manager6/qemu/HDResize.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/www/manager6/qemu/HDResize.js b/www/manager6/qemu/HDResize.js index f9c7290d1..97bec73b1 100644 --- a/www/manager6/qemu/HDResize.js +++ b/www/manager6/qemu/HDResize.js @@ -16,6 +16,10 @@ Ext.define('PVE.window.HDResize', { Ext.Msg.alert(gettext('Error'), response.htmlStatus); }, success: function(response, options) { + Ext.create('Proxmox.window.TaskProgress', { + autoShow: true, + upid: response.result.data, + }); me.close(); }, }); From 61cf3e3d9af500c22c88e8ffcb726c3ae2d12621 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 9 Jun 2023 11:11:49 +0200 Subject: [PATCH 077/398] bump version to 8.0.0~8 Signed-off-by: Thomas Lamprecht --- debian/changelog | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/debian/changelog b/debian/changelog index b2b7983ed..3dda58b5f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,57 @@ +pve-manager (8.0.0~8) bookworm; urgency=medium + + * ui: add beta text with link to bugtracker + + * api: nodes: add 'migrateall' to index + + * fix #2641: ui: storage: expose CIFS subdir parameter on add + + * ui: fix duplicate references when using multiple disk storage selectors + + * pveceph: add osd details command + + * fix #4605: drop rsyncable from zstd invocation + + * fix #4678: ui: don't sort storage backup content by vmid by default + + * jobs: add RealmSync Plugin and register it + + * ui: add Realm Sync panel + + * appliances: switch over to Proxmox VE 8 index + + * ui: qemu, lxc : fix firewall menu caps + + * ui: firewall: refactor privilege checks and prevent double click + + * api: backup: require Datastore.Allocate on storage + + * api: network: check permissions for local bridges + + * ui: user view: show tfa lock status + + * ui: user view: add 'Unlock TFA' button + + * fix #3428: cloud-init: add toggle for automatic upgrades + + * ui: qga: Add option to turn off QGA fs-freeze/-thaw on backup + + * api: replication job status: allow querying disabled jobs too + + * ui: ceph: pool: add pool number as column + + * pve7to8: sync over from stable-7 branch + + * update shipped container appliances info index + + * d/postinst: setup pvetest repo for beta + + * api: mark batch-execute api call as root-only + + * ui: qemu: show progress bar for resize task + + -- Proxmox Support Team Fri, 09 Jun 2023 11:11:45 +0200 + pve-manager (8.0.0~7) bookworm; urgency=medium * pveceph: support new enterprise repository, new in Proxmox VE 8, which can From cdc140f0a3bed0db9e882ae6e90bf3e58025cbf4 Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Wed, 7 Jun 2023 14:03:53 +0200 Subject: [PATCH 078/398] api2: cluster: ressources: add "localnetwork" zone Signed-off-by: Alexandre Derumier --- PVE/API2/Cluster.pm | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm index 2e9423685..a7224d7f3 100644 --- a/PVE/API2/Cluster.pm +++ b/PVE/API2/Cluster.pm @@ -474,6 +474,20 @@ __PACKAGE__->register_method({ } } + #add default "localnetwork" zone + if ($rpcenv->check($authuser, "/sdn/zones/localnetwork", [ 'SDN.Audit' ], 1)) { + foreach my $node (@$nodelist) { + my $local_sdn = { + id => "sdn/$node/localnetwork", + sdn => 'localnetwork', + node => $node, + type => 'sdn', + status => 'ok', + }; + push @$res, $local_sdn; + } + } + if ($have_sdn) { if (!$param->{type} || $param->{type} eq 'sdn') { From 9ed5d4f5af77ca3aa82426d20e7af5a1272b716f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Mon, 12 Jun 2023 13:03:07 +0200 Subject: [PATCH 079/398] cluster resources: correctly filter 'localnetwork' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit it should only be displayed if sdn entries are requested, or all resource types. Signed-off-by: Fabian Grünbichler --- PVE/API2/Cluster.pm | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm index a7224d7f3..c1637af98 100644 --- a/PVE/API2/Cluster.pm +++ b/PVE/API2/Cluster.pm @@ -474,23 +474,22 @@ __PACKAGE__->register_method({ } } - #add default "localnetwork" zone - if ($rpcenv->check($authuser, "/sdn/zones/localnetwork", [ 'SDN.Audit' ], 1)) { - foreach my $node (@$nodelist) { - my $local_sdn = { - id => "sdn/$node/localnetwork", - sdn => 'localnetwork', - node => $node, - type => 'sdn', - status => 'ok', - }; - push @$res, $local_sdn; + if (!$param->{type} || $param->{type} eq 'sdn') { + #add default "localnetwork" zone + if ($rpcenv->check($authuser, "/sdn/zones/localnetwork", [ 'SDN.Audit' ], 1)) { + foreach my $node (@$nodelist) { + my $local_sdn = { + id => "sdn/$node/localnetwork", + sdn => 'localnetwork', + node => $node, + type => 'sdn', + status => 'ok', + }; + push @$res, $local_sdn; + } } - } - - if ($have_sdn) { - if (!$param->{type} || $param->{type} eq 'sdn') { + if ($have_sdn) { my $nodes = PVE::Cluster::get_node_kv("sdn"); for my $node (sort keys %{$nodes}) { @@ -508,7 +507,7 @@ __PACKAGE__->register_method({ }; push @$res, $entry; } - } + } } } From edc4a349ab4016b12c2c780dfeccbf9abf96a22f Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Wed, 7 Jun 2023 14:03:56 +0200 Subject: [PATCH 080/398] ui: add vnet permissions panel Signed-off-by: Alexandre Derumier --- www/manager6/Makefile | 2 + www/manager6/sdn/Browser.js | 17 +- www/manager6/sdn/VnetACLView.js | 289 +++++++++++++++++++++++++++ www/manager6/sdn/ZoneContentPanel.js | 41 ++++ www/manager6/sdn/ZoneContentView.js | 25 ++- 5 files changed, 356 insertions(+), 18 deletions(-) create mode 100644 www/manager6/sdn/VnetACLView.js create mode 100644 www/manager6/sdn/ZoneContentPanel.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 71ab928ff..9b6dd13bc 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -252,10 +252,12 @@ JSSRC= \ sdn/StatusView.js \ sdn/VnetEdit.js \ sdn/VnetView.js \ + sdn/VnetACLView.js \ sdn/VnetPanel.js \ sdn/SubnetEdit.js \ sdn/SubnetView.js \ sdn/ZoneContentView.js \ + sdn/ZoneContentPanel.js \ sdn/ZoneView.js \ sdn/OptionsPanel.js \ sdn/controllers/Base.js \ diff --git a/www/manager6/sdn/Browser.js b/www/manager6/sdn/Browser.js index 09b0c4fe5..3dc5a5ad7 100644 --- a/www/manager6/sdn/Browser.js +++ b/www/manager6/sdn/Browser.js @@ -25,14 +25,15 @@ Ext.define('PVE.sdn.Browser', { const caps = Ext.state.Manager.get('GuiCap'); - if (caps.sdn['SDN.Audit']) { - me.items.push({ - xtype: 'pveSDNZoneContentView', - title: gettext('Content'), - iconCls: 'fa fa-th', - itemId: 'content', - }); - } + me.items.push({ + nodename: nodename, + zone: sdnId, + xtype: 'pveSDNZoneContentPanel', + title: gettext('Content'), + iconCls: 'fa fa-th', + itemId: 'content', + }); + if (caps.sdn['Permissions.Modify']) { me.items.push({ xtype: 'pveACLView', diff --git a/www/manager6/sdn/VnetACLView.js b/www/manager6/sdn/VnetACLView.js new file mode 100644 index 000000000..af10d954a --- /dev/null +++ b/www/manager6/sdn/VnetACLView.js @@ -0,0 +1,289 @@ +Ext.define('PVE.sdn.VnetACLAdd', { + extend: 'Proxmox.window.Edit', + alias: ['widget.pveSDNVnetACLAdd'], + + url: '/access/acl', + method: 'PUT', + isAdd: true, + isCreate: true, + + width: 400, + initComponent: function() { + let me = this; + + let items = [ + { + xtype: 'hiddenfield', + name: 'path', + value: me.path, + allowBlank: false, + fieldLabel: gettext('Path'), + }, + ]; + + if (me.aclType === 'group') { + me.subject = gettext("Group Permission"); + items.push({ + xtype: 'pveGroupSelector', + name: 'groups', + fieldLabel: gettext('Group'), + }); + } else if (me.aclType === 'user') { + me.subject = gettext("User Permission"); + items.push({ + xtype: 'pmxUserSelector', + name: 'users', + fieldLabel: gettext('User'), + }); + } else if (me.aclType === 'token') { + me.subject = gettext("API Token Permission"); + items.push({ + xtype: 'pveTokenSelector', + name: 'tokens', + fieldLabel: gettext('API Token'), + }); + } else { + throw "unknown ACL type"; + } + + items.push({ + xtype: 'pmxRoleSelector', + name: 'roles', + value: 'NoAccess', + fieldLabel: gettext('Role'), + }); + + items.push({ + xtype: 'proxmoxintegerfield', + name: 'vlan', + minValue: 1, + maxValue: 4096, + allowBlank: true, + fieldLabel: 'Vlan', + emptyText: gettext('All'), + }); + + let ipanel = Ext.create('Proxmox.panel.InputPanel', { + items: items, + onlineHelp: 'pveum_permission_management', + onGetValues: function(values) { + if (values.vlan) { + values.path = values.path + "/" + values.vlan; + delete values.vlan; + } + return values; + }, + }); + + Ext.apply(me, { + items: [ipanel], + }); + + me.callParent(); + }, +}); + +Ext.define('PVE.sdn.VnetACLView', { + extend: 'Ext.grid.GridPanel', + + alias: ['widget.pveSDNVnetACLView'], + + onlineHelp: 'chapter_user_management', + + stateful: true, + stateId: 'grid-acls', + + // use fixed path + path: undefined, + + setPath: function(path) { + let me = this; + + me.path = path; + + if (path === undefined) { + me.down('#groupmenu').setDisabled(true); + me.down('#usermenu').setDisabled(true); + me.down('#tokenmenu').setDisabled(true); + } else { + me.down('#groupmenu').setDisabled(false); + me.down('#usermenu').setDisabled(false); + me.down('#tokenmenu').setDisabled(false); + me.store.load(); + } + }, + initComponent: function() { + let me = this; + + let store = Ext.create('Ext.data.Store', { + model: 'pve-acl', + proxy: { + type: 'proxmox', + url: "/api2/json/access/acl", + }, + sorters: { + property: 'path', + direction: 'ASC', + }, + }); + + store.addFilter(Ext.create('Ext.util.Filter', { + filterFn: item => item.data.path.replace(/(\/sdn\/zones\/(.*)\/(.*))\/[0-9]*$/, '$1') === me.path, + })); + + let render_ugid = function(ugid, metaData, record) { + if (record.data.type === 'group') { + return '@' + ugid; + } + + return Ext.String.htmlEncode(ugid); + }; + + let render_vlan = function(path, metaData, record) { + let vlan = 'any'; + const match = path.match(/(\/sdn\/zones\/)(.*)\/(.*)\/([0-9]*)$/); + if (match) { + vlan = match[4]; + } + + return Ext.String.htmlEncode(vlan); + }; + + let columns = [ + { + header: gettext('User') + '/' + gettext('Group') + '/' + gettext('API Token'), + flex: 1, + sortable: true, + renderer: render_ugid, + dataIndex: 'ugid', + }, + { + header: gettext('Role'), + flex: 1, + sortable: true, + dataIndex: 'roleid', + }, + { + header: gettext('Vlan'), + flex: 1, + sortable: true, + renderer: render_vlan, + dataIndex: 'path', + }, + ]; + + + let sm = Ext.create('Ext.selection.RowModel', {}); + + let remove_btn = new Proxmox.button.Button({ + text: gettext('Remove'), + disabled: true, + selModel: sm, + confirmMsg: gettext('Are you sure you want to remove this entry'), + handler: function(btn, event, rec) { + var params = { + 'delete': 1, + path: rec.data.path, + roles: rec.data.roleid, + }; + if (rec.data.type === 'group') { + params.groups = rec.data.ugid; + } else if (rec.data.type === 'user') { + params.users = rec.data.ugid; + } else if (rec.data.type === 'token') { + params.tokens = rec.data.ugid; + } else { + throw 'unknown data type'; + } + + Proxmox.Utils.API2Request({ + url: '/access/acl', + params: params, + method: 'PUT', + waitMsgTarget: me, + callback: () => store.load(), + failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }, + }); + + Proxmox.Utils.monStoreErrors(me, store); + + Ext.apply(me, { + store: store, + selModel: sm, + tbar: [ + { + text: gettext('Add'), + menu: { + xtype: 'menu', + items: [ + { + text: gettext('Group Permission'), + disabled: !me.path, + itemId: 'groupmenu', + iconCls: 'fa fa-fw fa-group', + handler: function() { + var win = Ext.create('PVE.sdn.VnetACLAdd', { + aclType: 'group', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + { + text: gettext('User Permission'), + disabled: !me.path, + itemId: 'usermenu', + iconCls: 'fa fa-fw fa-user', + handler: function() { + var win = Ext.create('PVE.sdn.VnetACLAdd', { + aclType: 'user', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + { + text: gettext('API Token Permission'), + disabled: !me.path, + itemId: 'tokenmenu', + iconCls: 'fa fa-fw fa-user-o', + handler: function() { + let win = Ext.create('PVE.sdn.VnetACLAdd', { + aclType: 'token', + path: me.path, + }); + win.on('destroy', () => store.load()); + win.show(); + }, + }, + ], + }, + }, + remove_btn, + ], + viewConfig: { + trackOver: false, + }, + columns: columns, + listeners: { + }, + }); + + me.callParent(); + }, +}, function() { + Ext.define('pve-acl-vnet', { + extend: 'Ext.data.Model', + fields: [ + 'path', 'type', 'ugid', 'roleid', + { + name: 'propagate', + type: 'boolean', + }, + ], + }); +}); diff --git a/www/manager6/sdn/ZoneContentPanel.js b/www/manager6/sdn/ZoneContentPanel.js new file mode 100644 index 000000000..5bb081bb9 --- /dev/null +++ b/www/manager6/sdn/ZoneContentPanel.js @@ -0,0 +1,41 @@ +Ext.define('PVE.sdn.ZoneContentPanel', { + extend: 'Ext.panel.Panel', + alias: 'widget.pveSDNZoneContentPanel', + + title: 'Vnet', + + onlineHelp: 'pvesdn_config_vnet', + + initComponent: function() { + var me = this; + + var permissions_panel = Ext.createWidget('pveSDNVnetACLView', { + title: gettext('Vnet Permissions'), + region: 'center', + border: false, + }); + + var vnetview_panel = Ext.createWidget('pveSDNZoneContentView', { + title: 'Vnets', + region: 'west', + permissions_panel: permissions_panel, + nodename: me.nodename, + zone: me.zone, + width: '50%', + border: false, + split: true, + }); + + Ext.apply(me, { + layout: 'border', + items: [vnetview_panel, permissions_panel], + listeners: { + show: function() { + permissions_panel.fireEvent('show', permissions_panel); + }, + }, + }); + + me.callParent(); + }, +}); diff --git a/www/manager6/sdn/ZoneContentView.js b/www/manager6/sdn/ZoneContentView.js index 1ea65450b..4bc927183 100644 --- a/www/manager6/sdn/ZoneContentView.js +++ b/www/manager6/sdn/ZoneContentView.js @@ -17,17 +17,15 @@ Ext.define('PVE.sdn.ZoneContentView', { initComponent: function() { var me = this; - var nodename = me.pveSelNode.data.node; - if (!nodename) { + if (!me.nodename) { throw "no node name specified"; } - var zone = me.pveSelNode.data.sdn; - if (!zone) { + if (!me.zone) { throw "no zone ID specified"; } - var baseurl = "/nodes/" + nodename + "/sdn/zones/" + zone + "/content"; + var baseurl = "/nodes/" + me.nodename + "/sdn/zones/" + me.zone + "/content"; var store = Ext.create('Ext.data.Store', { model: 'pve-sdnzone-content', groupField: 'content', @@ -48,7 +46,6 @@ Ext.define('PVE.sdn.ZoneContentView', { }; Proxmox.Utils.monStoreErrors(me, store); - Ext.apply(me, { store: store, selModel: sm, @@ -79,11 +76,19 @@ Ext.define('PVE.sdn.ZoneContentView', { dataIndex: 'statusmsg', }, ], - listeners: { - activate: reload, - }, + listeners: { + activate: reload, + show: reload, + select: function(_sm, rec) { + let path = `/sdn/zones/${me.zone}/${rec.data.vnet}`; + me.permissions_panel.setPath(path); + }, + deselect: function() { + me.permissions_panel.setPath(undefined); + }, + }, }); - + store.load(); me.callParent(); }, }, function() { From 12f7c578f7a6a22bd13a52be8182d63404672b69 Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Wed, 7 Jun 2023 14:03:57 +0200 Subject: [PATCH 081/398] ui: add permissions management for "localnetwork" zone add a default virtual zone called 'localnetwork' in the ressource tree, and handle permissions like a true sdn zone (no conflict with true sdn zone is possible, as they have 8 characters max) Signed-off-by: Alexandre Derumier --- www/manager6/sdn/ZoneContentView.js | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/www/manager6/sdn/ZoneContentView.js b/www/manager6/sdn/ZoneContentView.js index 4bc927183..2a3cbf523 100644 --- a/www/manager6/sdn/ZoneContentView.js +++ b/www/manager6/sdn/ZoneContentView.js @@ -26,6 +26,9 @@ Ext.define('PVE.sdn.ZoneContentView', { } var baseurl = "/nodes/" + me.nodename + "/sdn/zones/" + me.zone + "/content"; + if (me.zone === 'localnetwork') { + baseurl = "/nodes/" + me.nodename + "/network?type=any_local_bridge"; + } var store = Ext.create('Ext.data.Store', { model: 'pve-sdnzone-content', groupField: 'content', @@ -95,7 +98,29 @@ Ext.define('PVE.sdn.ZoneContentView', { Ext.define('pve-sdnzone-content', { extend: 'Ext.data.Model', fields: [ - 'vnet', 'status', 'statusmsg', + { + name: 'iface', + convert: function(value, record) { + //map local vmbr to vnet + if (record.data.iface) { + record.data.vnet = record.data.iface; + } + return value; + }, + }, + { + name: 'comments', + convert: function(value, record) { + //map local vmbr comments to vnet alias + if (record.data.comments) { + record.data.alias = record.data.comments; + } + return value; + }, + }, + 'vnet', + 'status', + 'statusmsg', { name: 'text', convert: function(value, record) { From 4ab96328670aa97cbedf22966bc28f09a0c0165a Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Thu, 8 Jun 2023 11:51:06 +0200 Subject: [PATCH 082/398] qemu: processor : set x86-64-v2-AES as default cputype for create wizard Signed-off-by: Alexandre Derumier --- www/manager6/qemu/OSDefaults.js | 1 + www/manager6/qemu/OSTypeEdit.js | 1 + 2 files changed, 2 insertions(+) diff --git a/www/manager6/qemu/OSDefaults.js b/www/manager6/qemu/OSDefaults.js index 5e588a589..58bc76ffb 100644 --- a/www/manager6/qemu/OSDefaults.js +++ b/www/manager6/qemu/OSDefaults.js @@ -43,6 +43,7 @@ Ext.define('PVE.qemu.OSDefaults', { virtio: 1, }, scsihw: 'virtio-scsi-single', + cputype: 'x86-64-v2-AES', }; // virtio-net is in kernel since 2.6.25 diff --git a/www/manager6/qemu/OSTypeEdit.js b/www/manager6/qemu/OSTypeEdit.js index d9a0988ef..3332a0bc1 100644 --- a/www/manager6/qemu/OSTypeEdit.js +++ b/www/manager6/qemu/OSTypeEdit.js @@ -27,6 +27,7 @@ Ext.define('PVE.qemu.OSTypeInputPanel', { me.setWidget('pveBusSelector', targetValues.busType); me.setWidget('pveNetworkCardSelector', targetValues.networkCard); + me.setWidget('CPUModelSelector', targetValues.cputype); var scsihw = targetValues.scsihw || '__default__'; this.getViewModel().set('current.scsihw', scsihw); this.getViewModel().set('current.ostype', ostype); From 5f936d95fcf71505d5686f8ea761f9db6c16b345 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 14 Jun 2023 14:10:17 +0200 Subject: [PATCH 083/398] ui: sdn: consistent usage of VNet & VLAN without gettext Signed-off-by: Thomas Lamprecht --- www/manager6/dc/Config.js | 2 +- www/manager6/form/SDNVnetSelector.js | 2 +- www/manager6/sdn/VnetACLView.js | 4 ++-- www/manager6/sdn/VnetPanel.js | 4 ++-- www/manager6/sdn/ZoneContentPanel.js | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 72a9bec13..bbe56125f 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -189,7 +189,7 @@ Ext.define('PVE.dc.Config', { { xtype: 'pveSDNVnet', groups: ['sdn'], - title: gettext('Vnets'), + title: 'VNets', hidden: true, iconCls: 'fa fa-network-wired', itemId: 'sdnvnet', diff --git a/www/manager6/form/SDNVnetSelector.js b/www/manager6/form/SDNVnetSelector.js index b6da85bef..4f26d11e2 100644 --- a/www/manager6/form/SDNVnetSelector.js +++ b/www/manager6/form/SDNVnetSelector.js @@ -23,7 +23,7 @@ Ext.define('PVE.form.SDNVnetSelector', { listConfig: { columns: [ { - header: gettext('Vnet'), + header: gettext('VNet'), sortable: true, dataIndex: 'vnet', flex: 1, diff --git a/www/manager6/sdn/VnetACLView.js b/www/manager6/sdn/VnetACLView.js index af10d954a..a8ab3c282 100644 --- a/www/manager6/sdn/VnetACLView.js +++ b/www/manager6/sdn/VnetACLView.js @@ -59,7 +59,7 @@ Ext.define('PVE.sdn.VnetACLAdd', { minValue: 1, maxValue: 4096, allowBlank: true, - fieldLabel: 'Vlan', + fieldLabel: 'VLAN', emptyText: gettext('All'), }); @@ -164,7 +164,7 @@ Ext.define('PVE.sdn.VnetACLView', { dataIndex: 'roleid', }, { - header: gettext('Vlan'), + header: gettext('VLAN'), flex: 1, sortable: true, renderer: render_vlan, diff --git a/www/manager6/sdn/VnetPanel.js b/www/manager6/sdn/VnetPanel.js index 48c09693c..b8377cbc4 100644 --- a/www/manager6/sdn/VnetPanel.js +++ b/www/manager6/sdn/VnetPanel.js @@ -2,7 +2,7 @@ Ext.define('PVE.sdn.Vnet', { extend: 'Ext.panel.Panel', alias: 'widget.pveSDNVnet', - title: 'Vnet', + title: 'VNet', onlineHelp: 'pvesdn_config_vnet', @@ -16,7 +16,7 @@ Ext.define('PVE.sdn.Vnet', { }); var vnetview_panel = Ext.createWidget('pveSDNVnetView', { - title: 'Vnets', + title: 'VNets', region: 'west', subnetview_panel: subnetview_panel, width: '50%', diff --git a/www/manager6/sdn/ZoneContentPanel.js b/www/manager6/sdn/ZoneContentPanel.js index 5bb081bb9..b5c7f492b 100644 --- a/www/manager6/sdn/ZoneContentPanel.js +++ b/www/manager6/sdn/ZoneContentPanel.js @@ -2,7 +2,7 @@ Ext.define('PVE.sdn.ZoneContentPanel', { extend: 'Ext.panel.Panel', alias: 'widget.pveSDNZoneContentPanel', - title: 'Vnet', + title: 'VNet', onlineHelp: 'pvesdn_config_vnet', @@ -10,13 +10,13 @@ Ext.define('PVE.sdn.ZoneContentPanel', { var me = this; var permissions_panel = Ext.createWidget('pveSDNVnetACLView', { - title: gettext('Vnet Permissions'), + title: gettext('VNet Permissions'), region: 'center', border: false, }); var vnetview_panel = Ext.createWidget('pveSDNZoneContentView', { - title: 'Vnets', + title: 'VNets', region: 'west', permissions_panel: permissions_panel, nodename: me.nodename, From 7679ff9e602bcc9530ae57f67f1269940250c805 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 14 Jun 2023 14:31:31 +0200 Subject: [PATCH 084/398] fix #4739: ui: user list: add column for group memberships To get a fast overview in which groups each user is add a column that shows all groups they are a member of. To get that info we need to pass the 'full=1' parameter to the API endpoint, which then adds tokens and groups for each user to the result. This is basically only increasing transmission size a bit, as the backend doesn't needs to do any extra parsing for this information. Signed-off-by: Dominik Csapak [T: Reword commit message ] Signed-off-by: Thomas Lamprecht --- www/manager6/dc/UserView.js | 6 ++++++ www/manager6/form/UserSelector.js | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js index 957ff6827..39ca15ec9 100644 --- a/www/manager6/dc/UserView.js +++ b/www/manager6/dc/UserView.js @@ -217,6 +217,12 @@ Ext.define('PVE.dc.UserView', { }, dataIndex: 'keys', }, + { + header: gettext('Groups'), + dataIndex: 'groups', + renderer: Ext.htmlEncode, + flex: 1, + }, { header: gettext('Comment'), sortable: false, diff --git a/www/manager6/form/UserSelector.js b/www/manager6/form/UserSelector.js index 8fb31d7e3..5af30441b 100644 --- a/www/manager6/form/UserSelector.js +++ b/www/manager6/form/UserSelector.js @@ -7,7 +7,7 @@ Ext.define('pmx-users', { ], proxy: { type: 'proxmox', - url: "/api2/json/access/users", + url: "/api2/json/access/users?full=1", }, idProperty: 'userid', }); From 7a1373829a6018bf0b3eeeb4d2e2a310125643d2 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 14 Jun 2023 16:17:39 +0200 Subject: [PATCH 085/398] ui: user list: fine-tune width-flex of group and comment column Signed-off-by: Thomas Lamprecht --- www/manager6/dc/UserView.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/dc/UserView.js b/www/manager6/dc/UserView.js index 39ca15ec9..6805e9ff9 100644 --- a/www/manager6/dc/UserView.js +++ b/www/manager6/dc/UserView.js @@ -221,14 +221,14 @@ Ext.define('PVE.dc.UserView', { header: gettext('Groups'), dataIndex: 'groups', renderer: Ext.htmlEncode, - flex: 1, + flex: 2, }, { header: gettext('Comment'), sortable: false, renderer: Ext.String.htmlEncode, dataIndex: 'comment', - flex: 1, + flex: 3, }, ], listeners: { From 99e276c3d99741e47868d433dee9c45c50d1856b Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 13 Jun 2023 10:43:58 +0200 Subject: [PATCH 086/398] ui: realm sync edit: improve ux when there is no ldap/ad realm by adding an empty text to the dropdown, and disabling the other possibly invalid fields, so that it's clear why the panel is invalid as soon as there is an ldap/ad realm, it gets autoselected anyway and the fields get re-enabled. Signed-off-by: Dominik Csapak --- www/manager6/dc/RealmSyncJob.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/www/manager6/dc/RealmSyncJob.js b/www/manager6/dc/RealmSyncJob.js index 5a903ef7d..e12ad858c 100644 --- a/www/manager6/dc/RealmSyncJob.js +++ b/www/manager6/dc/RealmSyncJob.js @@ -154,6 +154,11 @@ Ext.define('PVE.dc.RealmSyncJobEdit', { updateDefaults: function(_field, newValue) { let me = this; + + ['scope', 'enable-new', 'schedule'].forEach((reference) => { + me.lookup(reference)?.setDisabled(false); + }); + // only update on create if (!me.getView().isCreate) { return; @@ -232,6 +237,9 @@ Ext.define('PVE.dc.RealmSyncJobEdit', { xtype: 'pmxRealmComboBox', storeFilter: rec => rec.data.type === 'ldap' || rec.data.type === 'ad', }, + listConfig: { + emptyText: `
${gettext('No LDAP/AD Realm found')}
`, + }, cbind: { editable: '{isCreate}', }, @@ -245,6 +253,7 @@ Ext.define('PVE.dc.RealmSyncJobEdit', { { xtype: 'pveCalendarEvent', fieldLabel: gettext('Schedule'), + disabled: true, allowBlank: false, name: 'schedule', reference: 'schedule', @@ -265,6 +274,7 @@ Ext.define('PVE.dc.RealmSyncJobEdit', { xtype: 'proxmoxKVComboBox', name: 'scope', reference: 'scope', + disabled: true, fieldLabel: gettext('Scope'), value: '', emptyText: gettext('No default available'), @@ -280,6 +290,7 @@ Ext.define('PVE.dc.RealmSyncJobEdit', { xtype: 'proxmoxKVComboBox', value: '1', deleteEmpty: false, + disabled: true, allowBlank: false, comboItems: [ ['1', Proxmox.Utils.yesText], From 059abb7a309abb98a89b9b87439d3ad1ef7ff4e6 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 13 Jun 2023 10:43:59 +0200 Subject: [PATCH 087/398] ui: realm sync: change enabled column rendering to make it consistent with the repositories ui, since having a checkbox that is not clickable is confusing Signed-off-by: Dominik Csapak --- www/manager6/dc/RealmSyncJob.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/www/manager6/dc/RealmSyncJob.js b/www/manager6/dc/RealmSyncJob.js index e12ad858c..9541d7324 100644 --- a/www/manager6/dc/RealmSyncJob.js +++ b/www/manager6/dc/RealmSyncJob.js @@ -49,16 +49,19 @@ Ext.define('PVE.dc.RealmSyncJobView', { }, }, + viewConfig: { + getRowClass: (record, _index) => record.get('enabled') ? '' : 'proxmox-disabled-row', + }, + columns: [ { header: gettext('Enabled'), width: 80, dataIndex: 'enabled', - xtype: 'checkcolumn', sortable: true, - disabled: true, - disabledCls: 'x-item-enabled', + align: 'center', stopSelection: false, + renderer: Proxmox.Utils.renderEnabledIcon, }, { text: gettext('Name'), From 4c8fcdd7afcad0759d81afb0ace680c75e5d3abc Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 13 Jun 2023 10:44:00 +0200 Subject: [PATCH 088/398] ui: realm: move sync job panel into realm panel and make it collapsible, so that users can hide it if they're not interested in it Signed-off-by: Dominik Csapak --- www/manager6/dc/AuthView.js | 2 +- www/manager6/dc/Config.js | 28 ++++++++++++++++++++-------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/www/manager6/dc/AuthView.js b/www/manager6/dc/AuthView.js index 60332c3f3..229c944b7 100644 --- a/www/manager6/dc/AuthView.js +++ b/www/manager6/dc/AuthView.js @@ -130,11 +130,11 @@ Ext.define('PVE.dc.AuthView', { }, ], listeners: { - activate: () => me.reload(), itemdblclick: () => me.run_editor(), }, }); me.callParent(); + me.reload(); }, }); diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index bbe56125f..1223ec92e 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -134,18 +134,30 @@ Ext.define('PVE.dc.Config', { itemId: 'roles', }, { - xtype: 'pveAuthView', title: gettext('Realms'), + xtype: 'panel', + layout: { + type: 'border', + }, groups: ['permissions'], iconCls: 'fa fa-address-book-o', itemId: 'domains', - }, - { - xtype: 'pveRealmSyncJobView', - title: gettext('Realm Sync'), - groups: ['permissions'], - iconCls: 'fa fa-refresh', - itemId: 'realmsyncjobs', + items: [ + { + xtype: 'pveAuthView', + region: 'center', + border: false, + }, + { + xtype: 'pveRealmSyncJobView', + title: gettext('Sync Jobs'), + region: 'south', + collapsible: true, + animCollapse: false, + border: false, + height: '50%', + }, + ], }, { xtype: 'pveHAStatus', From ed65c1ca642f1d8d9f3faf620c191affecd70d54 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 13 Jun 2023 10:44:01 +0200 Subject: [PATCH 089/398] ui: realm sync: add 'run now' button by simply passing the sync job config to the 'sync' api endpoint, like we do for vzdump jobs Signed-off-by: Dominik Csapak --- www/manager6/dc/RealmSyncJob.js | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/www/manager6/dc/RealmSyncJob.js b/www/manager6/dc/RealmSyncJob.js index 9541d7324..bb2e8d2f6 100644 --- a/www/manager6/dc/RealmSyncJob.js +++ b/www/manager6/dc/RealmSyncJob.js @@ -35,6 +35,41 @@ Ext.define('PVE.dc.RealmSyncJobView', { }); }, + runNow: function() { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (!selection || selection.length < 1) { + return; + } + + let params = selection[0].data; + let realm = params.realm; + + let propertiesToDelete = ['comment', 'realm', 'id', 'type', 'schedule', 'last-run', 'next-run', 'enabled']; + for (const prop of propertiesToDelete) { + delete params[prop]; + } + + Proxmox.Utils.API2Request({ + url: `/access/domains/${realm}/sync`, + params, + waitMsgTarget: view, + method: 'POST', + success: function(response, options) { + let upid = response.result.data; + let win = Ext.create('Proxmox.window.TaskProgress', { + upid: upid, + taskDone: () => { me.reload(); }, + }); + win.show(); + }, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + reload: function() { this.getView().getStore().load(); }, @@ -110,6 +145,12 @@ Ext.define('PVE.dc.RealmSyncJobView', { baseurl: `/api2/extjs/cluster/jobs/realm-sync`, callback: 'reload', }, + { + xtype: 'proxmoxButton', + handler: 'runNow', + disabled: true, + text: gettext('Run Now'), + }, ], listeners: { From fc8f37ee9d76894a0e22e4e67873d3bc80686804 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 14 Jun 2023 17:33:38 +0200 Subject: [PATCH 090/398] ui: realm: clarify that the sync jobs really are for the realm it's somewhat redundant as onbe is already at the realm view, but for panel titles it slightly helps if one doesn't have to string together such "clues" oneself, i.e., it's easier to see where one is - e.g., if switching from some other task back to the web UI again, and we have enough space here, so we ain't winning anything if keeping it short. Also add an emptyText to the grid, mostly as view's without anything always look a bit off (like an error happened on load and one forgot to mask) Signed-off-by: Thomas Lamprecht --- www/manager6/dc/Config.js | 2 +- www/manager6/dc/RealmSyncJob.js | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 1223ec92e..f9f937a55 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -150,7 +150,7 @@ Ext.define('PVE.dc.Config', { }, { xtype: 'pveRealmSyncJobView', - title: gettext('Sync Jobs'), + title: gettext('Realm Sync Jobs'), region: 'south', collapsible: true, animCollapse: false, diff --git a/www/manager6/dc/RealmSyncJob.js b/www/manager6/dc/RealmSyncJob.js index bb2e8d2f6..d4e311946 100644 --- a/www/manager6/dc/RealmSyncJob.js +++ b/www/manager6/dc/RealmSyncJob.js @@ -5,6 +5,8 @@ Ext.define('PVE.dc.RealmSyncJobView', { stateful: true, stateId: 'grid-realmsyncjobs', + emptyText: Ext.String.format(gettext('No {0} configured'), gettext('Realm Sync Job')), + controller: { xclass: 'Ext.app.ViewController', From fc3fe9af77c72743478bd2a117f3248d0199d327 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 14 Jun 2023 17:37:45 +0200 Subject: [PATCH 091/398] ui: realm sync job: clarify the function of the two enable checkboxes Most of the time this isn't an issue for job edits, but here we have two "enable" checkboxes that control enabling newly synced users and enabling the job itself, try to be absolutely clear on both to avoid potential confusion. Signed-off-by: Thomas Lamprecht --- www/manager6/dc/RealmSyncJob.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/dc/RealmSyncJob.js b/www/manager6/dc/RealmSyncJob.js index d4e311946..738f0bfa5 100644 --- a/www/manager6/dc/RealmSyncJob.js +++ b/www/manager6/dc/RealmSyncJob.js @@ -306,7 +306,7 @@ Ext.define('PVE.dc.RealmSyncJobEdit', { }, { xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Enable'), + fieldLabel: gettext('Enable Job'), name: 'enabled', reference: 'enabled', uncheckedValue: 0, @@ -344,7 +344,7 @@ Ext.define('PVE.dc.RealmSyncJobEdit', { ], name: 'enable-new', reference: 'enable-new', - fieldLabel: gettext('Enable new'), + fieldLabel: gettext('Enable New'), }, ], From 1e7c70f5c20b2800e2ac68b8c2ae4b5a391c6505 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 14 Jun 2023 17:41:49 +0200 Subject: [PATCH 092/398] ui: realm sync job: code cleanup run-now handlers Signed-off-by: Thomas Lamprecht --- www/manager6/dc/RealmSyncJob.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/www/manager6/dc/RealmSyncJob.js b/www/manager6/dc/RealmSyncJob.js index 738f0bfa5..712bf1121 100644 --- a/www/manager6/dc/RealmSyncJob.js +++ b/www/manager6/dc/RealmSyncJob.js @@ -58,16 +58,13 @@ Ext.define('PVE.dc.RealmSyncJobView', { params, waitMsgTarget: view, method: 'POST', + failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus), success: function(response, options) { - let upid = response.result.data; - let win = Ext.create('Proxmox.window.TaskProgress', { - upid: upid, + Ext.create('Proxmox.window.TaskProgress', { + autoShow: true, + upid: response.result.data, taskDone: () => { me.reload(); }, }); - win.show(); - }, - failure: function(response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); }, }); }, From 5d6262aa5972db08b495a075e860651813153954 Mon Sep 17 00:00:00 2001 From: Leo Nunner Date: Tue, 13 Jun 2023 14:06:34 +0200 Subject: [PATCH 093/398] firewall: add scope field to IPRefSelector and send the scoped value to the firewall when choosing new values. This happens for both IPSets and aliases. Signed-off-by: Leo Nunner --- www/manager6/form/IPRefSelector.js | 35 +++++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/www/manager6/form/IPRefSelector.js b/www/manager6/form/IPRefSelector.js index 9ccc2fe10..b50ac1e10 100644 --- a/www/manager6/form/IPRefSelector.js +++ b/www/manager6/form/IPRefSelector.js @@ -8,7 +8,7 @@ Ext.define('PVE.form.IPRefSelector', { ref_type: undefined, // undefined = any [undefined, 'ipset' or 'alias'] - valueField: 'ref', + valueField: 'scopedref', displayField: 'ref', notFoundIsValid: true, @@ -26,7 +26,23 @@ Ext.define('PVE.form.IPRefSelector', { var store = Ext.create('Ext.data.Store', { autoLoad: true, - fields: ['type', 'name', 'ref', 'comment'], + fields: [ + 'type', + 'name', + 'ref', + 'comment', + 'scope', + { + name: 'scopedref', + calculate: function(v) { + if (v.type === 'alias') { + return `${v.scope}/${v.name}`; + } else { + return `+${v.scope}/${v.name}`; + } + }, + }, + ], idProperty: 'ref', proxy: { type: 'proxmox', @@ -65,17 +81,30 @@ Ext.define('PVE.form.IPRefSelector', { hideable: false, width: 140, }, + { + header: gettext('Scope'), + dataIndex: 'scope', + hideable: false, + width: 140, + renderer: function(value) { + return value === 'dc' ? gettext("Datacenter") : gettext("Guest"); + }, + }, { header: gettext('Comment'), dataIndex: 'comment', renderer: Ext.String.htmlEncode, + minWidth: 60, flex: 1, }, ); Ext.apply(me, { store: store, - listConfig: { columns: columns }, + listConfig: { + columns: columns, + width: 500, + }, }); me.on('change', disable_query_for_ips); From 0fe0a2dd2b8e6dcc0cfec8a28c8f2fa498a2650e Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 14 Jun 2023 10:46:07 +0200 Subject: [PATCH 094/398] pvesh: fix parameters for proxyto_callback in pve-http-server the proxyto_callback always has a complete list of parameters, not only the ones in the url, so adapt the implementation here to do the same Signed-off-by: Dominik Csapak --- PVE/CLI/pvesh.pm | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/PVE/CLI/pvesh.pm b/PVE/CLI/pvesh.pm index 9acf292ac..28e2518d5 100755 --- a/PVE/CLI/pvesh.pm +++ b/PVE/CLI/pvesh.pm @@ -82,13 +82,15 @@ my $method_map = { }; sub check_proxyto { - my ($info, $uri_param) = @_; + my ($info, $uri_param, $params) = @_; my $rpcenv = PVE::RPCEnvironment->get(); + my $all_params = { %$uri_param, %$params }; + if ($info->{proxyto} || $info->{proxyto_callback}) { my $node = PVE::API2Tools::resolve_proxyto( - $rpcenv, $info->{proxyto_callback}, $info->{proxyto}, $uri_param); + $rpcenv, $info->{proxyto_callback}, $info->{proxyto}, $all_params); if ($node ne 'localhost' && ($node ne PVE::INotify::nodename())) { die "proxy loop detected - aborting\n" if $disable_proxy; @@ -301,7 +303,7 @@ sub call_api_method { } my $data; - my ($node, $remip) = check_proxyto($info, $uri_param); + my ($node, $remip) = check_proxyto($info, $uri_param, $param); if ($node) { $data = proxy_handler($node, $remip, $path, $cmd, $param); } else { @@ -345,7 +347,7 @@ __PACKAGE__->register_method ({ my $res; - my ($node, $remip) = check_proxyto($info, $uri_param); + my ($node, $remip) = check_proxyto($info, $uri_param, $param); if ($node) { $res = proxy_handler($node, $remip, $path, 'ls', $param); } else { From 797bcf9aa292478ddf85354354a69c3751a01aad Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:28 +0200 Subject: [PATCH 095/398] api: add resource map api endpoints for PCI and USB this adds the typical section config crud API calls for USB and PCI resource mapping to /cluster/mapping/{TYPE} the only special thing that this series does is the list call for both has a special 'check-node' parameter that uses the 'proxyto_callback' to reroute the api call to the given node so that it can check the validity of the mapping for that node in the future when we e.g. broadcast the lspci output via pmxcfs we drop the proxyto_callback and directly use the info from pmxcfs (or we drop the parameter and always check all nodes) Signed-off-by: Dominik Csapak --- PVE/API2/Cluster.pm | 8 + PVE/API2/Cluster/Makefile | 5 + PVE/API2/Cluster/Mapping.pm | 53 ++++++ PVE/API2/Cluster/Mapping/Makefile | 18 ++ PVE/API2/Cluster/Mapping/PCI.pm | 300 ++++++++++++++++++++++++++++++ PVE/API2/Cluster/Mapping/USB.pm | 295 +++++++++++++++++++++++++++++ PVE/API2/Hardware.pm | 1 - 7 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 PVE/API2/Cluster/Mapping.pm create mode 100644 PVE/API2/Cluster/Mapping/Makefile create mode 100644 PVE/API2/Cluster/Mapping/PCI.pm create mode 100644 PVE/API2/Cluster/Mapping/USB.pm diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm index c1637af98..3daf6ae5b 100644 --- a/PVE/API2/Cluster.pm +++ b/PVE/API2/Cluster.pm @@ -26,6 +26,7 @@ use PVE::API2::ACMEPlugin; use PVE::API2::Backup; use PVE::API2::Cluster::BackupInfo; use PVE::API2::Cluster::Ceph; +use PVE::API2::Cluster::Mapping; use PVE::API2::Cluster::Jobs; use PVE::API2::Cluster::MetricServer; use PVE::API2::ClusterConfig; @@ -90,6 +91,12 @@ __PACKAGE__->register_method ({ subclass => "PVE::API2::Cluster::Jobs", path => 'jobs', }); + +__PACKAGE__->register_method ({ + subclass => "PVE::API2::Cluster::Mapping", + path => 'mapping', +}); + if ($have_sdn) { __PACKAGE__->register_method ({ subclass => "PVE::API2::Network::SDN", @@ -140,6 +147,7 @@ __PACKAGE__->register_method ({ { name => 'ha' }, { name => 'jobs' }, { name => 'log' }, + { name => 'mapping' }, { name => 'metrics' }, { name => 'nextid' }, { name => 'options' }, diff --git a/PVE/API2/Cluster/Makefile b/PVE/API2/Cluster/Makefile index 5c92e4f62..0c52a2410 100644 --- a/PVE/API2/Cluster/Makefile +++ b/PVE/API2/Cluster/Makefile @@ -1,10 +1,13 @@ include ../../../defines.mk +SUBDIRS=Mapping + # for node independent, cluster-wide applicable, API endpoints # ensure we do not conflict with files shipped by pve-cluster!! PERLSOURCE= \ BackupInfo.pm \ MetricServer.pm \ + Mapping.pm \ Jobs.pm \ Ceph.pm @@ -13,8 +16,10 @@ all: .PHONY: clean clean: rm -rf *~ + set -e && for i in ${SUBDIRS}; do ${MAKE} -C $$i $@; done .PHONY: install install: $(PERLSOURCE) install -d $(PERLLIBDIR)/PVE/API2/Cluster install -m 0644 $(PERLSOURCE) $(PERLLIBDIR)/PVE/API2/Cluster + set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i $@; done diff --git a/PVE/API2/Cluster/Mapping.pm b/PVE/API2/Cluster/Mapping.pm new file mode 100644 index 000000000..01fa986b9 --- /dev/null +++ b/PVE/API2/Cluster/Mapping.pm @@ -0,0 +1,53 @@ +package PVE::API2::Cluster::Mapping; + +use strict; +use warnings; + +use PVE::RESTHandler; + +use PVE::API2::Cluster::Mapping::PCI; +use PVE::API2::Cluster::Mapping::USB; + +use base qw(PVE::RESTHandler); + +__PACKAGE__->register_method ({ + subclass => "PVE::API2::Cluster::Mapping::PCI", + path => 'pci', +}); + +__PACKAGE__->register_method ({ + subclass => "PVE::API2::Cluster::Mapping::USB", + path => 'usb', +}); + +__PACKAGE__->register_method ({ + name => 'index', + path => '', + method => 'GET', + description => "List resource types.", + permissions => { + user => 'all', + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => "object", + }, + links => [ { rel => 'child', href => "{name}" } ], + }, + code => sub { + my ($param) = @_; + + my $result = [ + { name => 'pci' }, + { name => 'usb' }, + ]; + + return $result; + }}); + +1; diff --git a/PVE/API2/Cluster/Mapping/Makefile b/PVE/API2/Cluster/Mapping/Makefile new file mode 100644 index 000000000..e7345ab4a --- /dev/null +++ b/PVE/API2/Cluster/Mapping/Makefile @@ -0,0 +1,18 @@ +include ../../../../defines.mk + +# for node independent, cluster-wide applicable, API endpoints +# ensure we do not conflict with files shipped by pve-cluster!! +PERLSOURCE= \ + PCI.pm \ + USB.pm + +all: + +.PHONY: clean +clean: + rm -rf *~ + +.PHONY: install +install: ${PERLSOURCE} + install -d ${PERLLIBDIR}/PVE/API2/Cluster/Mapping + install -m 0644 ${PERLSOURCE} ${PERLLIBDIR}/PVE/API2/Cluster/Mapping diff --git a/PVE/API2/Cluster/Mapping/PCI.pm b/PVE/API2/Cluster/Mapping/PCI.pm new file mode 100644 index 000000000..2ccad888d --- /dev/null +++ b/PVE/API2/Cluster/Mapping/PCI.pm @@ -0,0 +1,300 @@ +package PVE::API2::Cluster::Mapping::PCI; + +use strict; +use warnings; + +use Storable qw(dclone); + +use PVE::Cluster qw(cfs_lock_file); +use PVE::Mapping::PCI; +use PVE::JSONSchema qw(get_standard_option); +use PVE::Tools qw(extract_param); + +use PVE::RESTHandler; + +use base qw(PVE::RESTHandler); + +__PACKAGE__->register_method ({ + name => 'index', + path => '', + method => 'GET', + # only proxy if we give the 'check-node' parameter + proxyto_callback => sub { + my ($rpcenv, $proxyto, $param) = @_; + return $param->{'check-node'} // 'localhost'; + }, + description => "List PCI Hardware Mapping", + permissions => { + description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or". + " 'Mapping.Audit' permissions on '/mapping/pci/'.", + user => 'all', + }, + parameters => { + additionalProperties => 0, + properties => { + 'check-node' => get_standard_option('pve-node', { + description => "If given, checks the configurations on the given node for ". + "correctness, and adds relevant errors to the devices.", + optional => 1, + }), + }, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => { + id => { + type => 'string', + description => "The logical ID of the mapping." + }, + map => { + type => 'array', + description => "The entries of the mapping.", + items => { + type => 'string', + description => "A mapping for a node.", + }, + }, + description => { + type => 'string', + description => "A description of the logical mapping.", + }, + error => { + description => "A list of errors when 'check_node' is given.", + items => { + type => 'object', + properties => { + severity => { + type => "string", + description => "The severity of the error", + }, + message => { + type => "string", + description => "The message of the error", + }, + }, + } + }, + }, + }, + links => [ { rel => 'child', href => "{id}" } ], + }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $node = $param->{'check-node'}; + + die "Wrong node to check\n" + if defined($node) && $node ne 'localhost' && $node ne PVE::INotify::nodename(); + + my $cfg = PVE::Mapping::PCI::config(); + + my $res = []; + + my $privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; + + for my $id (keys $cfg->{ids}->%*) { + next if !$rpcenv->check_full($authuser, "/mapping/pci/$id", $privs, 1, 1); + next if !$cfg->{ids}->{$id}; + + my $entry = dclone($cfg->{ids}->{$id}); + $entry->{id} = $id; + $entry->{digest} = $cfg->{digest}; + + if (defined($node)) { + $entry->{errors} = []; + if (my $mappings = PVE::Mapping::PCI::get_node_mapping($cfg, $id, $node)) { + if (!scalar($mappings->@*)) { + push $entry->{errors}->@*, { + severity => 'warning', + message => "No mapping for node $node.", + }; + } + for my $mapping ($mappings->@*) { + eval { + PVE::Mapping::PCI::assert_valid($id, $mapping); + }; + if (my $err = $@) { + push $entry->{errors}->@*, { + severity => 'error', + message => "Invalid configuration: $err", + }; + } + } + } + } + + push @$res, $entry; + } + + return $res; + }, +}); + +__PACKAGE__->register_method ({ + name => 'get', + protected => 1, + path => '{id}', + method => 'GET', + description => "Get PCI Mapping.", + permissions => { + check =>['or', + ['perm', '/mapping/pci/{id}', ['Mapping.Use']], + ['perm', '/mapping/pci/{id}', ['Mapping.Modify']], + ['perm', '/mapping/pci/{id}', ['Mapping.Audit']], + ], + }, + parameters => { + additionalProperties => 0, + properties => { + id => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'object' }, + code => sub { + my ($param) = @_; + + my $cfg = PVE::Mapping::PCI::config(); + my $id = $param->{id}; + + my $entry = $cfg->{ids}->{$id}; + die "mapping '$param->{id}' not found\n" if !defined($entry); + + my $data = dclone($entry); + + $data->{digest} = $cfg->{digest}; + + return $data; + }}); + +__PACKAGE__->register_method ({ + name => 'create', + protected => 1, + path => '', + method => 'POST', + description => "Create a new hardware mapping.", + permissions => { + check => ['perm', '/mapping/pci', ['Mapping.Modify']], + }, + parameters => PVE::Mapping::PCI->createSchema(1), + returns => { + type => 'null', + }, + code => sub { + my ($param) = @_; + + my $id = extract_param($param, 'id'); + + my $plugin = PVE::Mapping::PCI->lookup('pci'); + my $opts = $plugin->check_config($id, $param, 1, 1); + + PVE::Mapping::PCI::lock_pci_config(sub { + my $cfg = PVE::Mapping::PCI::config(); + + die "pci ID '$id' already defined\n" if defined($cfg->{ids}->{$id}); + + $cfg->{ids}->{$id} = $opts; + + PVE::Mapping::PCI::write_pci_config($cfg); + + }, "create hardware mapping failed"); + + return; + }, +}); + +__PACKAGE__->register_method ({ + name => 'update', + protected => 1, + path => '{id}', + method => 'PUT', + description => "Update a hardware mapping.", + permissions => { + check => ['perm', '/mapping/pci/{id}', ['Mapping.Modify']], + }, + parameters => PVE::Mapping::PCI->updateSchema(), + returns => { + type => 'null', + }, + code => sub { + my ($param) = @_; + + my $digest = extract_param($param, 'digest'); + my $delete = extract_param($param, 'delete'); + my $id = extract_param($param, 'id'); + + if ($delete) { + $delete = [ PVE::Tools::split_list($delete) ]; + } + + PVE::Mapping::PCI::lock_pci_config(sub { + my $cfg = PVE::Mapping::PCI::config(); + + PVE::Tools::assert_if_modified($cfg->{digest}, $digest) if defined($digest); + + die "pci ID '$id' does not exist\n" if !defined($cfg->{ids}->{$id}); + + my $plugin = PVE::Mapping::PCI->lookup('pci'); + my $opts = $plugin->check_config($id, $param, 1, 1); + + my $data = $cfg->{ids}->{$id}; + + my $options = $plugin->private()->{options}->{pci}; + PVE::SectionConfig::delete_from_config($data, $options, $opts, $delete); + + $data->{$_} = $opts->{$_} for keys $opts->%*; + + PVE::Mapping::PCI::write_pci_config($cfg); + + }, "update hardware mapping failed"); + + return; + }, +}); + +__PACKAGE__->register_method ({ + name => 'delete', + protected => 1, + path => '{id}', + method => 'DELETE', + description => "Remove Hardware Mapping.", + permissions => { + check => [ 'perm', '/mapping/pci', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + id => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $id = $param->{id}; + + PVE::Mapping::PCI::lock_pci_config(sub { + my $cfg = PVE::Mapping::PCI::config(); + + if ($cfg->{ids}->{$id}) { + delete $cfg->{ids}->{$id}; + } + + PVE::Mapping::PCI::write_pci_config($cfg); + + }, "delete pci mapping failed"); + + return; + } +}); + +1; diff --git a/PVE/API2/Cluster/Mapping/USB.pm b/PVE/API2/Cluster/Mapping/USB.pm new file mode 100644 index 000000000..3883cf7da --- /dev/null +++ b/PVE/API2/Cluster/Mapping/USB.pm @@ -0,0 +1,295 @@ +package PVE::API2::Cluster::Mapping::USB; + +use strict; +use warnings; + +use Storable qw(dclone); + +use PVE::Cluster qw(cfs_lock_file); +use PVE::Mapping::USB; +use PVE::JSONSchema qw(get_standard_option); +use PVE::Tools qw(extract_param); + +use PVE::RESTHandler; + +use base qw(PVE::RESTHandler); + +__PACKAGE__->register_method ({ + name => 'index', + path => '', + method => 'GET', + description => "List USB Hardware Mappings", + permissions => { + description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or". + " 'Mapping.Audit' permissions on '/mapping/usb/'.", + user => 'all', + }, + parameters => { + additionalProperties => 0, + properties => { + 'check-node' => get_standard_option('pve-node', { + description => "If given, checks the configurations on the given node for ". + "correctness, and adds relevant errors to the devices.", + optional => 1, + }), + }, + }, + returns => { + type => 'array', + items => { + type => "object", + properties => { + id => { + type => 'string', + description => "The logical ID of the mapping." + }, + map => { + type => 'array', + description => "The entries of the mapping.", + items => { + type => 'string', + description => "A mapping for a node.", + }, + }, + description => { + type => 'string', + description => "A description of the logical mapping.", + }, + error => { + description => "A list of errors when 'check_node' is given.", + items => { + type => 'object', + properties => { + severity => { + type => "string", + description => "The severity of the error", + }, + message => { + type => "string", + description => "The message of the error", + }, + }, + } + }, + }, + }, + links => [ { rel => 'child', href => "{id}" } ], + }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + my $node = $param->{'check-node'}; + + die "Wrong node to check\n" + if defined($node) && $node ne 'localhost' && $node ne PVE::INotify::nodename(); + + my $cfg = PVE::Mapping::USB::config(); + + my $res = []; + + my $privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; + + for my $id (keys $cfg->{ids}->%*) { + next if !$rpcenv->check_full($authuser, "/mapping/usb/$id", $privs, 1, 1); + next if !$cfg->{ids}->{$id}; + + my $entry = dclone($cfg->{ids}->{$id}); + $entry->{id} = $id; + $entry->{digest} = $cfg->{digest}; + + if (defined($node)) { + $entry->{errors} = []; + if (my $mappings = PVE::Mapping::USB::get_node_mapping($cfg, $id, $node)) { + if (!scalar($mappings->@*)) { + push $entry->{errors}->@*, { + severity => 'warning', + message => "No mapping for node $node.", + }; + } + for my $mapping ($mappings->@*) { + eval { + PVE::Mapping::USB::assert_valid($id, $mapping); + }; + if (my $err = $@) { + push $entry->{errors}->@*, { + severity => 'error', + message => "Invalid configuration: $err", + }; + } + } + } + } + + push @$res, $entry; + } + + return $res; + }, +}); + +__PACKAGE__->register_method ({ + name => 'get', + protected => 1, + path => '{id}', + method => 'GET', + description => "Get USB Mapping.", + permissions => { + check =>['or', + ['perm', '/mapping/usb/{id}', ['Mapping.Audit']], + ['perm', '/mapping/usb/{id}', ['Mapping.Use']], + ['perm', '/mapping/usb/{id}', ['Mapping.Modify']], + ], + }, + parameters => { + additionalProperties => 0, + properties => { + id => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'object' }, + code => sub { + my ($param) = @_; + + my $cfg = PVE::Mapping::USB::config(); + my $id = $param->{id}; + + my $entry = $cfg->{ids}->{$id}; + die "mapping '$param->{id}' not found\n" if !defined($entry); + + my $data = dclone($entry); + + $data->{digest} = $cfg->{digest}; + + return $data; + }}); + +__PACKAGE__->register_method ({ + name => 'create', + protected => 1, + path => '', + method => 'POST', + description => "Create a new hardware mapping.", + permissions => { + check => ['perm', '/mapping/usb', ['Mapping.Modify']], + }, + parameters => PVE::Mapping::USB->createSchema(1), + returns => { + type => 'null', + }, + code => sub { + my ($param) = @_; + + my $id = extract_param($param, 'id'); + + my $plugin = PVE::Mapping::USB->lookup('usb'); + my $opts = $plugin->check_config($id, $param, 1, 1); + + PVE::Mapping::USB::lock_usb_config(sub { + my $cfg = PVE::Mapping::USB::config(); + + die "usb ID '$id' already defined\n" if defined($cfg->{ids}->{$id}); + + $cfg->{ids}->{$id} = $opts; + + PVE::Mapping::USB::write_usb_config($cfg); + + }, "create hardware mapping failed"); + + return; + }, +}); + +__PACKAGE__->register_method ({ + name => 'update', + protected => 1, + path => '{id}', + method => 'PUT', + description => "Update a hardware mapping.", + permissions => { + check => ['perm', '/mapping/usb/{id}', ['Mapping.Modify']], + }, + parameters => PVE::Mapping::USB->updateSchema(), + returns => { + type => 'null', + }, + code => sub { + my ($param) = @_; + + my $digest = extract_param($param, 'digest'); + my $delete = extract_param($param, 'delete'); + my $id = extract_param($param, 'id'); + + if ($delete) { + $delete = [ PVE::Tools::split_list($delete) ]; + } + + PVE::Mapping::USB::lock_usb_config(sub { + my $cfg = PVE::Mapping::USB::config(); + + PVE::Tools::assert_if_modified($cfg->{digest}, $digest) if defined($digest); + + die "usb ID '$id' does not exist\n" if !defined($cfg->{ids}->{$id}); + + my $plugin = PVE::Mapping::USB->lookup('usb'); + my $opts = $plugin->check_config($id, $param, 1, 1); + + my $data = $cfg->{ids}->{$id}; + + my $options = $plugin->private()->{options}->{usb}; + PVE::SectionConfig::delete_from_config($data, $options, $opts, $delete); + + $data->{$_} = $opts->{$_} for keys $opts->%*; + + PVE::Mapping::USB::write_usb_config($cfg); + + }, "update hardware mapping failed"); + + return; + }, +}); + +__PACKAGE__->register_method ({ + name => 'delete', + protected => 1, + path => '{id}', + method => 'DELETE', + description => "Remove Hardware Mapping.", + permissions => { + check => [ 'perm', '/mapping/usb', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + id => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $id = $param->{id}; + + PVE::Mapping::USB::lock_usb_config(sub { + my $cfg = PVE::Mapping::USB::config(); + + if ($cfg->{ids}->{$id}) { + delete $cfg->{ids}->{$id}; + } + + PVE::Mapping::USB::write_usb_config($cfg); + + }, "delete usb mapping failed"); + + return; + } +}); + +1; diff --git a/PVE/API2/Hardware.pm b/PVE/API2/Hardware.pm index f59bfbe0e..1c6fd8f5c 100644 --- a/PVE/API2/Hardware.pm +++ b/PVE/API2/Hardware.pm @@ -21,7 +21,6 @@ __PACKAGE__->register_method ({ path => 'usb', }); - __PACKAGE__->register_method ({ name => 'index', path => '', From d5a9606710ad018c8ed9f6d0ea0139b230668552 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:29 +0200 Subject: [PATCH 096/398] ui: parser: add helper for lists of property strings namely the filtering while preserving the original string, it's just one line, but having a shorthand for it still makes it a bit nicer Signed-off-by: Dominik Csapak --- www/manager6/Parser.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/www/manager6/Parser.js b/www/manager6/Parser.js index c3772d3bb..bc6a43380 100644 --- a/www/manager6/Parser.js +++ b/www/manager6/Parser.js @@ -604,5 +604,9 @@ Ext.define('PVE.Parser', { }); return [res, extradata]; }, + + filterPropertyStringList: function(list, filterFn, defaultKey) { + return list.filter((entry) => filterFn(PVE.Parser.parsePropertyString(entry, defaultKey))); + }, }, }); From a88b4ef9ad6d09deb61f217c3df13e0ae0149b50 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:30 +0200 Subject: [PATCH 097/398] ui: form/USBSelector: make it more flexible with nodename similar to the pciselector, make it accept a plain nodename, or no node at all and provide a setNodename function to keep backwards compatibility, also check pveSelNode for the nodename Signed-off-by: Dominik Csapak --- www/manager6/form/USBSelector.js | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/www/manager6/form/USBSelector.js b/www/manager6/form/USBSelector.js index a67c87654..011778d7e 100644 --- a/www/manager6/form/USBSelector.js +++ b/www/manager6/form/USBSelector.js @@ -23,25 +23,39 @@ Ext.define('PVE.form.USBSelector', { return gettext("Invalid Value"); }, + setNodename: function(nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/nodes/${me.nodename}/hardware/usb`, + }); + + me.store.load(); + }, + initComponent: function() { var me = this; - var nodename = me.pveSelNode.data.node; - - if (!nodename) { - throw "no nodename specified"; + if (me.pveSelNode) { + me.nodename = me.pveSelNode.data.node; } + var nodename = me.nodename; + me.nodename = undefined; + if (me.type !== 'device' && me.type !== 'port') { throw "no valid type specified"; } let store = new Ext.data.Store({ model: `pve-usb-${me.type}`, - proxy: { - type: 'proxmox', - url: `/api2/json/nodes/${nodename}/hardware/usb`, - }, filters: [ ({ data }) => !!data.usbpath && !!data.prodid && String(data.class) !== "9", ], @@ -99,7 +113,7 @@ Ext.define('PVE.form.USBSelector', { me.callParent(); - store.load(); + me.setNodename(nodename); }, }, function() { From 0cf5c0d203b5828f1ae0a45e440c15e608de1bd5 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:31 +0200 Subject: [PATCH 098/398] ui: form: add PCIMapSelector akin to the PCISelector, but uses the api for mapped devices Signed-off-by: Dominik Csapak --- www/manager6/Makefile | 1 + www/manager6/form/PCIMapSelector.js | 112 ++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 www/manager6/form/PCIMapSelector.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 9b6dd13bc..8de983aab 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -49,6 +49,7 @@ JSSRC= \ form/NetworkCardSelector.js \ form/NodeSelector.js \ form/PCISelector.js \ + form/PCIMapSelector.js \ form/PermPathSelector.js \ form/PoolSelector.js \ form/PreallocationSelector.js \ diff --git a/www/manager6/form/PCIMapSelector.js b/www/manager6/form/PCIMapSelector.js new file mode 100644 index 000000000..3ded65dc4 --- /dev/null +++ b/www/manager6/form/PCIMapSelector.js @@ -0,0 +1,112 @@ +Ext.define('pve-mapped-pci-model', { + extend: 'Ext.data.Model', + + fields: ['id', 'path', 'vendor', 'device', 'iommugroup', 'mdev', 'description', 'map'], + idProperty: 'id', +}); + +Ext.define('PVE.form.PCIMapSelector', { + extend: 'Proxmox.form.ComboGrid', + xtype: 'pvePCIMapSelector', + + store: { + model: 'pve-mapped-pci-model', + filterOnLoad: true, + sorters: [ + { + property: 'id', + direction: 'ASC', + }, + ], + }, + + autoSelect: false, + valueField: 'id', + displayField: 'id', + + // can contain a load callback for the store + // useful to determine the state of the IOMMU + onLoadCallBack: undefined, + + listConfig: { + width: 800, + columns: [ + { + header: gettext('ID'), + dataIndex: 'id', + flex: 1, + }, + { + header: gettext('Description'), + dataIndex: 'description', + flex: 1, + }, + { + header: gettext('Status'), + dataIndex: 'errors', + renderer: function(value) { + let me = this; + + if (!Ext.isArray(value) || !value?.length) { + return ` ${gettext('Mapping OK')}`; + } + + let errors = []; + + value.forEach((error) => { + let iconCls; + switch (error?.severity) { + case 'warning': + iconCls = 'fa-exclamation-circle warning'; + break; + case 'error': + iconCls = 'fa-times-circle critical'; + break; + } + + let message = error?.message; + let icon = ``; + if (iconCls !== undefined) { + errors.push(`${icon} ${message}`); + } + }); + + return errors.join('
'); + }, + flex: 3, + }, + ], + }, + + setNodename: function(nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/cluster/mapping/pci?check-node=${nodename}`, + }); + + me.store.load(); + }, + + initComponent: function() { + var me = this; + + var nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + if (me.onLoadCallBack !== undefined) { + me.mon(me.getStore(), 'load', me.onLoadCallBack); + } + + me.setNodename(nodename); + }, +}); From dba03989371b13354e7ec850b3bc87bfb3acb456 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:32 +0200 Subject: [PATCH 099/398] ui: form: add USBMapSelector similar to PCIMapSelector Signed-off-by: Dominik Csapak --- www/manager6/Makefile | 1 + www/manager6/form/USBMapSelector.js | 98 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) create mode 100644 www/manager6/form/USBMapSelector.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 8de983aab..40a606392 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -69,6 +69,7 @@ JSSRC= \ form/TFASelector.js \ form/TokenSelector.js \ form/USBSelector.js \ + form/USBMapSelector.js \ form/UserSelector.js \ form/VLanField.js \ form/VMCPUFlagSelector.js \ diff --git a/www/manager6/form/USBMapSelector.js b/www/manager6/form/USBMapSelector.js new file mode 100644 index 000000000..990ef30fa --- /dev/null +++ b/www/manager6/form/USBMapSelector.js @@ -0,0 +1,98 @@ +Ext.define('PVE.form.USBMapSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: 'widget.pveUSBMapSelector', + + store: { + fields: ['name', 'vendor', 'device', 'path'], + filterOnLoad: true, + sorters: [ + { + property: 'name', + direction: 'ASC', + }, + ], + }, + + allowBlank: false, + autoSelect: false, + displayField: 'id', + valueField: 'id', + + listConfig: { + width: 800, + columns: [ + { + header: gettext('Name'), + dataIndex: 'id', + flex: 1, + }, + { + header: gettext('Status'), + dataIndex: 'errors', + flex: 2, + renderer: function(value) { + let me = this; + + if (!Ext.isArray(value) || !value?.length) { + return ` ${gettext('Mapping OK')}`; + } + + let errors = []; + + value.forEach((error) => { + let iconCls; + switch (error?.severity) { + case 'warning': + iconCls = 'fa-exclamation-circle warning'; + break; + case 'error': + iconCls = 'fa-times-circle critical'; + break; + } + + let message = error?.message; + let icon = ``; + if (iconCls !== undefined) { + errors.push(`${icon} ${message}`); + } + }); + + return errors.join('
'); + }, + }, + { + header: gettext('Comment'), + dataIndex: 'description', + flex: 1, + }, + ], + }, + + setNodename: function(nodename) { + var me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.store.setProxy({ + type: 'proxmox', + url: `/api2/json/cluster/mapping/usb?check-node=${nodename}`, + }); + + me.store.load(); + }, + + initComponent: function() { + var me = this; + + var nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + me.setNodename(nodename); + }, +}); From b1d42186d15b1d8764f305192b0a9eb8a93b811f Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:33 +0200 Subject: [PATCH 100/398] ui: qemu/PCIEdit: rework panel to add a mapped configuration reworks the panel to use a controller, so that we can easily add the selector for mapped pci devices shows now a selection between 'raw' and 'mapped' devices, where 'raw' ones work like before, and 'mapped' ones take the values form the hardware map config Signed-off-by: Dominik Csapak --- www/manager6/qemu/PCIEdit.js | 334 +++++++++++++++++++++++------------ 1 file changed, 220 insertions(+), 114 deletions(-) diff --git a/www/manager6/qemu/PCIEdit.js b/www/manager6/qemu/PCIEdit.js index 2f67aece9..8cef1b105 100644 --- a/www/manager6/qemu/PCIEdit.js +++ b/www/manager6/qemu/PCIEdit.js @@ -3,71 +3,155 @@ Ext.define('PVE.qemu.PCIInputPanel', { onlineHelp: 'qm_pci_passthrough_vm_config', + controller: { + xclass: 'Ext.app.ViewController', + + setVMConfig: function(vmconfig) { + let me = this; + let view = me.getView(); + me.vmconfig = vmconfig; + + let hostpci = me.vmconfig[view.confid] || ''; + + let values = PVE.Parser.parsePropertyString(hostpci, 'host'); + if (values.host) { + if (!values.host.match(/^[0-9a-f]{4}:/i)) { // add optional domain + values.host = "0000:" + values.host; + } + if (values.host.length < 11) { // 0000:00:00 format not 0000:00:00.0 + values.host += ".0"; + values.multifunction = true; + } + values.type = 'raw'; + } else if (values.mapping) { + values.type = 'mapped'; + } + + values['x-vga'] = PVE.Parser.parseBoolean(values['x-vga'], 0); + values.pcie = PVE.Parser.parseBoolean(values.pcie, 0); + values.rombar = PVE.Parser.parseBoolean(values.rombar, 1); + + view.setValues(values); + if (!me.vmconfig.machine || me.vmconfig.machine.indexOf('q35') === -1) { + // machine is not set to some variant of q35, so we disable pcie + let pcie = me.lookup('pcie'); + pcie.setDisabled(true); + pcie.setBoxLabel(gettext('Q35 only')); + } + + if (values.romfile) { + me.lookup('romfile').setVisible(true); + } + }, + + selectorEnable: function(selector) { + let me = this; + me.pciDevChange(selector, selector.getValue()); + }, + + pciDevChange: function(pcisel, value) { + let me = this; + let mdevfield = me.lookup('mdev'); + if (!value) { + if (!pcisel.isDisabled()) { + mdevfield.setDisabled(true); + } + return; + } + let pciDev = pcisel.getStore().getById(value); + + mdevfield.setDisabled(!pciDev || !pciDev.data.mdev); + if (!pciDev) { + return; + } + + let path = value; + if (pciDev.data.map) { + // find local mapping + for (const entry of pciDev.data.map) { + let mapping = PVE.Parser.parsePropertyString(entry); + if (mapping.node === pcisel.up('inputpanel').nodename) { + path = mapping.path.split(';')[0]; + break; + } + } + if (path.indexOf('.') === -1) { + path += '.0'; + } + } + + if (pciDev.data.mdev) { + mdevfield.setPciID(path); + } + if (pcisel.reference === 'selector') { + let iommu = pciDev.data.iommugroup; + if (iommu === -1) { + return; + } + // try to find out if there are more devices in that iommu group + let id = path.substring(0, 5); // 00:00 + let count = 0; + pcisel.getStore().each(({ data }) => { + if (data.iommugroup === iommu && data.id.substring(0, 5) !== id) { + count++; + return false; + } + return true; + }); + me.lookup('group_warning').setVisible(count > 0); + } + }, + + onGetValues: function(values) { + let me = this; + let view = me.getView(); + if (!view.confid) { + for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) { + if (!me.vmconfig['hostpci' + i.toString()]) { + view.confid = 'hostpci' + i.toString(); + break; + } + } + // FIXME: what if no confid was found?? + } + + values.host?.replace(/^0000:/, ''); // remove optional '0000' domain + + if (values.multifunction && values.host) { + values.host = values.host.substring(0, values.host.indexOf('.')); // skip the '.X' + delete values.multifunction; + } + + if (values.rombar) { + delete values.rombar; + } else { + values.rombar = 0; + } + + if (!values.romfile) { + delete values.romfile; + } + + delete values.type; + + let ret = {}; + ret[view.confid] = PVE.Parser.printPropertyString(values, 'host'); + return ret; + }, + }, + + viewModel: { + data: { + isMapped: true, + }, + }, + setVMConfig: function(vmconfig) { - var me = this; - me.vmconfig = vmconfig; - - var hostpci = me.vmconfig[me.confid] || ''; - - var values = PVE.Parser.parsePropertyString(hostpci, 'host'); - if (values.host) { - if (!values.host.match(/^[0-9a-f]{4}:/i)) { // add optional domain - values.host = "0000:" + values.host; - } - if (values.host.length < 11) { // 0000:00:00 format not 0000:00:00.0 - values.host += ".0"; - values.multifunction = true; - } - } - - values['x-vga'] = PVE.Parser.parseBoolean(values['x-vga'], 0); - values.pcie = PVE.Parser.parseBoolean(values.pcie, 0); - values.rombar = PVE.Parser.parseBoolean(values.rombar, 1); - - me.setValues(values); - if (!me.vmconfig.machine || me.vmconfig.machine.indexOf('q35') === -1) { - // machine is not set to some variant of q35, so we disable pcie - var pcie = me.down('field[name=pcie]'); - pcie.setDisabled(true); - pcie.setBoxLabel(gettext('Q35 only')); - } - - if (values.romfile) { - me.down('field[name=romfile]').setVisible(true); - } + return this.getController().setVMConfig(vmconfig); }, onGetValues: function(values) { - let me = this; - if (!me.confid) { - for (let i = 0; i < PVE.Utils.hardware_counts.hostpci; i++) { - if (!me.vmconfig['hostpci' + i.toString()]) { - me.confid = 'hostpci' + i.toString(); - break; - } - } - // FIXME: what if no confid was found?? - } - values.host.replace(/^0000:/, ''); // remove optional '0000' domain - - if (values.multifunction) { - values.host = values.host.substring(0, values.host.indexOf('.')); // skip the '.X' - delete values.multifunction; - } - - if (values.rombar) { - delete values.rombar; - } else { - values.rombar = 0; - } - - if (!values.romfile) { - delete values.romfile; - } - - let ret = {}; - ret[me.confid] = PVE.Parser.printPropertyString(values, 'host'); - return ret; + return this.getController().onGetValues(values); }, initComponent: function() { @@ -78,78 +162,97 @@ Ext.define('PVE.qemu.PCIInputPanel', { throw "no node name specified"; } + me.columnT = [ + { + xtype: 'displayfield', + reference: 'iommu_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + value: 'No IOMMU detected, please activate it.' + + 'See Documentation for further information.', + userCls: 'pmx-hint', + }, + { + xtype: 'displayfield', + reference: 'group_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + itemId: 'iommuwarning', + value: 'The selected Device is not in a seperate IOMMU group, make sure this is intended.', + userCls: 'pmx-hint', + }, + ]; + me.column1 = [ + { + xtype: 'radiofield', + name: 'type', + inputValue: 'mapped', + boxLabel: gettext('Mapped Device'), + bind: { + value: '{isMapped}', + }, + }, + { + xtype: 'pvePCIMapSelector', + fieldLabel: gettext('Device'), + reference: 'mapped_selector', + name: 'mapping', + labelAlign: 'right', + nodename: me.nodename, + allowBlank: false, + bind: { + disabled: '{!isMapped}', + }, + listeners: { + change: 'pciDevChange', + enable: 'selectorEnable', + }, + }, + { + xtype: 'radiofield', + name: 'type', + inputValue: 'raw', + checked: true, + boxLabel: gettext('Raw Device'), + }, { xtype: 'pvePCISelector', fieldLabel: gettext('Device'), name: 'host', + reference: 'selector', nodename: me.nodename, + labelAlign: 'right', allowBlank: false, + disabled: true, + bind: { + disabled: '{isMapped}', + }, onLoadCallBack: function(store, records, success) { if (!success || !records.length) { return; } - if (records.every((val) => val.data.iommugroup === -1)) { // no IOMMU groups - let warning = Ext.create('Ext.form.field.Display', { - columnWidth: 1, - padding: '0 0 10 0', - value: 'No IOMMU detected, please activate it.' + - 'See Documentation for further information.', - userCls: 'pmx-hint', - }); - me.items.insert(0, warning); - me.updateLayout(); // insert does not trigger that - } + me.lookup('iommu_warning').setVisible( + records.every((val) => val.data.iommugroup === -1), + ); }, listeners: { - change: function(pcisel, value) { - if (!value) { - return; - } - let pciDev = pcisel.getStore().getById(value); - let mdevfield = me.down('field[name=mdev]'); - mdevfield.setDisabled(!pciDev || !pciDev.data.mdev); - if (!pciDev) { - return; - } - if (pciDev.data.mdev) { - mdevfield.setPciID(value); - } - let iommu = pciDev.data.iommugroup; - if (iommu === -1) { - return; - } - // try to find out if there are more devices in that iommu group - let id = pciDev.data.id.substring(0, 5); // 00:00 - let count = 0; - pcisel.getStore().each(({ data }) => { - if (data.iommugroup === iommu && data.id.substring(0, 5) !== id) { - count++; - return false; - } - return true; - }); - let warning = me.down('#iommuwarning'); - if (count && !warning) { - warning = Ext.create('Ext.form.field.Display', { - columnWidth: 1, - padding: '0 0 10 0', - itemId: 'iommuwarning', - value: 'The selected Device is not in a seperate IOMMU group, make sure this is intended.', - userCls: 'pmx-hint', - }); - me.items.insert(0, warning); - me.updateLayout(); // insert does not trigger that - } else if (!count && warning) { - me.remove(warning); - } - }, + change: 'pciDevChange', + enable: 'selectorEnable', }, }, { xtype: 'proxmoxcheckbox', fieldLabel: gettext('All Functions'), + reference: 'all_functions', + disabled: true, + labelAlign: 'right', name: 'multifunction', + bind: { + disabled: '{isMapped}', + }, }, ]; @@ -157,6 +260,7 @@ Ext.define('PVE.qemu.PCIInputPanel', { { xtype: 'pveMDevSelector', name: 'mdev', + reference: 'mdev', disabled: true, fieldLabel: gettext('MDev Type'), nodename: me.nodename, @@ -188,6 +292,7 @@ Ext.define('PVE.qemu.PCIInputPanel', { submitValue: true, hidden: true, fieldLabel: 'ROM-File', + reference: 'romfile', name: 'romfile', }, { @@ -214,6 +319,7 @@ Ext.define('PVE.qemu.PCIInputPanel', { { xtype: 'proxmoxcheckbox', fieldLabel: 'PCI-Express', + reference: 'pcie', name: 'pcie', }, { From f7b5386a80ed95a3131ee3a80c3c9725f553a4e2 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:34 +0200 Subject: [PATCH 101/398] ui: qemu/USBEdit: add 'mapped' device case to be able to select 'mapped' usb devices Signed-off-by: Dominik Csapak --- www/manager6/qemu/USBEdit.js | 73 ++++++++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 20 deletions(-) diff --git a/www/manager6/qemu/USBEdit.js b/www/manager6/qemu/USBEdit.js index fe51d186f..b372d53d3 100644 --- a/www/manager6/qemu/USBEdit.js +++ b/www/manager6/qemu/USBEdit.js @@ -5,6 +5,15 @@ Ext.define('PVE.qemu.USBInputPanel', { autoComplete: false, onlineHelp: 'qm_usb_passthrough', + cbindData: function(initialConfig) { + let me = this; + if (!me.pveSelNode) { + throw "no pveSelNode given"; + } + + return { nodename: me.pveSelNode.data.node }; + }, + viewModel: { data: {}, }, @@ -36,6 +45,10 @@ Ext.define('PVE.qemu.USBInputPanel', { case 'spice': val = 'spice'; break; + case 'mapped': + val = `mapping=${values[type]}`; + delete values.mapped; + break; case 'hostdevice': case 'port': val = 'host=' + values[type]; @@ -66,6 +79,23 @@ Ext.define('PVE.qemu.USBInputPanel', { submitValue: false, checked: true, }, + { + name: 'usb', + inputValue: 'mapped', + boxLabel: gettext('Use mapped Device'), + reference: 'mapped', + submitValue: false, + }, + { + xtype: 'pveUSBMapSelector', + disabled: true, + name: 'mapped', + cbind: { nodename: '{nodename}' }, + bind: { disabled: '{!mapped.checked}' }, + allowBlank: false, + fieldLabel: gettext('Choose Device'), + labelAlign: 'right', + }, { name: 'usb', inputValue: 'hostdevice', @@ -149,30 +179,33 @@ Ext.define('PVE.qemu.USBEdit', { return; } - var data = response.result.data[me.confid].split(','); - var port, hostdevice, usb3 = false; - var type = 'spice'; + let data = PVE.Parser.parsePropertyString(response.result.data[me.confid], 'host'); + let port, hostdevice, mapped, usb3 = false; + let usb; - for (let i = 0; i < data.length; i++) { - if (/^(host=)?(0x)?[a-zA-Z0-9]{4}:(0x)?[a-zA-Z0-9]{4}$/.test(data[i])) { - hostdevice = data[i]; - hostdevice = hostdevice.replace('host=', '').replace('0x', ''); - type = 'hostdevice'; - } else if (/^(host=)?(\d+)-(\d+(\.\d+)*)$/.test(data[i])) { - port = data[i]; - port = port.replace('host=', ''); - type = 'port'; - } - - if (/^usb3=(1|on|true)$/.test(data[i])) { - usb3 = true; + if (data.host) { + if (/^(0x)?[a-zA-Z0-9]{4}:(0x)?[a-zA-Z0-9]{4}$/.test(data.host)) { + hostdevice = data.host.replace('0x', ''); + usb = 'hostdevice'; + } else if (/^(\d+)-(\d+(\.\d+)*)$/.test(data.host)) { + port = data.host; + usb = 'port'; + } else if (/^spice$/i.test(data.host)) { + usb = 'spice'; } + } else if (data.mapping) { + mapped = data.mapping; + usb = 'mapped'; } + + usb3 = data.usb3 ?? false; + var values = { - usb: type, - hostdevice: hostdevice, - port: port, - usb3: usb3, + usb, + hostdevice, + port, + usb3, + mapped, }; ipanel.setValues(values); From 02adfe172791f4a72d8609ba162b2b5e02bf670f Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:35 +0200 Subject: [PATCH 102/398] ui: form: add MultiPCISelector this is a grid field for selecting multiple pci devices at once, like we need for the mapped pci ui. There we want to be able to select multiple devices such that one gets selected automatically we can select a whole slot here, but that disables selecting the individual functions of that device. Signed-off-by: Dominik Csapak --- www/css/ext6-pve.css | 4 + www/manager6/Makefile | 1 + www/manager6/form/MultiPCISelector.js | 288 ++++++++++++++++++++++++++ 3 files changed, 293 insertions(+) create mode 100644 www/manager6/form/MultiPCISelector.js diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index a9ead5d3b..3af642553 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -700,3 +700,7 @@ table.osds td:first-of-type { cursor: pointer; padding-left: 2px; } + +.x-grid-item .x-item-disabled { + opacity: 0.3; +} diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 40a606392..e534cecd6 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -46,6 +46,7 @@ JSSRC= \ form/IPRefSelector.js \ form/MDevSelector.js \ form/MemoryField.js \ + form/MultiPCISelector.js \ form/NetworkCardSelector.js \ form/NodeSelector.js \ form/PCISelector.js \ diff --git a/www/manager6/form/MultiPCISelector.js b/www/manager6/form/MultiPCISelector.js new file mode 100644 index 000000000..99f9d50bc --- /dev/null +++ b/www/manager6/form/MultiPCISelector.js @@ -0,0 +1,288 @@ +Ext.define('PVE.form.MultiPCISelector', { + extend: 'Ext.grid.Panel', + alias: 'widget.pveMultiPCISelector', + + emptyText: gettext('No Devices found'), + + mixins: { + field: 'Ext.form.field.Field', + }, + + getValue: function() { + let me = this; + return me.value ?? []; + }, + + getSubmitData: function() { + let me = this; + let res = {}; + res[me.name] = me.getValue(); + return res; + }, + + setValue: function(value) { + let me = this; + + value ??= []; + + me.updateSelectedDevices(value); + + return me.mixins.field.setValue.call(me, value); + }, + + getErrors: function() { + let me = this; + + let errorCls = ['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']; + + if (me.getValue().length < 1) { + let error = gettext("Must choose at least one device"); + me.addCls(errorCls); + me.getActionEl()?.dom.setAttribute('data-errorqtip', error); + + return [error]; + } + + me.removeCls(errorCls); + me.getActionEl()?.dom.setAttribute('data-errorqtip', ""); + + return []; + }, + + viewConfig: { + getRowClass: function(record) { + if (record.data.disabled === true) { + return 'x-item-disabled'; + } + return ''; + }, + }, + + updateSelectedDevices: function(value = []) { + let me = this; + + let recs = []; + let store = me.getStore(); + + for (const map of value) { + let parsed = PVE.Parser.parsePropertyString(map); + if (parsed.node !== me.nodename) { + continue; + } + + let rec = store.getById(parsed.path); + if (rec) { + recs.push(rec); + } + } + + me.suspendEvent('change'); + me.setSelection([]); + me.setSelection(recs); + me.resumeEvent('change'); + }, + + setNodename: function(nodename) { + let me = this; + + if (!nodename || me.nodename === nodename) { + return; + } + + me.nodename = nodename; + + me.getStore().setProxy({ + type: 'proxmox', + url: '/api2/json/nodes/' + me.nodename + '/hardware/pci?pci-class-blacklist=', + }); + + me.setSelection([]); + + me.getStore().load({ + callback: (recs, op, success) => me.addSlotRecords(recs, op, success), + }); + }, + + setMdev: function(mdev) { + let me = this; + if (mdev) { + me.getStore().addFilter({ + id: 'mdev-filter', + property: 'mdev', + value: '1', + operator: '=', + }); + } else { + me.getStore().removeFilter('mdev-filter'); + } + }, + + // adds the virtual 'slot' records (e.g. '0000:01:00') to the store + addSlotRecords: function(records, _op, success) { + let me = this; + if (!success) { + return; + } + + let slots = {}; + records.forEach((rec) => { + let slotname = rec.data.id.slice(0, -2); // remove function + rec.set('slot', slotname); + if (slots[slotname] !== undefined) { + slots[slotname].count++; + return; + } + + slots[slotname] = { + count: 1, + }; + + if (rec.data.id.endsWith('.0')) { + slots[slotname].device = rec.data; + } + }); + + let store = me.getStore(); + + for (const [slot, { count, device }] of Object.entries(slots)) { + if (count === 1) { + continue; + } + store.add(Ext.apply({}, { + id: slot, + mdev: undefined, + device_name: gettext('Pass through all functions as one device'), + }, device)); + } + + me.updateSelectedDevices(me.value); + }, + + selectionChange: function(_grid, selection) { + let me = this; + + let ids = {}; + selection + .filter(rec => rec.data.id.indexOf('.') === -1) + .forEach((rec) => { ids[rec.data.id] = true; }); + + let to_disable = []; + + me.getStore().each(rec => { + let id = rec.data.id; + rec.set('disabled', false); + if (id.indexOf('.') === -1) { + return; + } + let slot = id.slice(0, -2); // remove function + + if (ids[slot]) { + to_disable.push(rec); + rec.set('disabled', true); + } + }); + + me.suspendEvent('selectionchange'); + me.getSelectionModel().deselect(to_disable); + me.resumeEvent('selectionchange'); + + me.value = me.getSelection().map((rec) => { + let res = { + path: rec.data.id, + node: me.nodename, + id: `${rec.data.vendor}:${rec.data.device}`.replace(/0x/g, ''), + 'subsystem-id': `${rec.data.subsystem_vendor}:${rec.data.subsystem_device}`.replace(/0x/g, ''), + }; + + if (rec.data.iommugroup !== -1) { + res.iommugroup = rec.data.iommugroup; + } + + return PVE.Parser.printPropertyString(res); + }); + me.checkChange(); + }, + + selModel: { + type: 'checkboxmodel', + mode: 'SIMPLE', + }, + + columns: [ + { + header: 'ID', + dataIndex: 'id', + width: 150, + }, + { + header: gettext('IOMMU Group'), + dataIndex: 'iommugroup', + renderer: (v, _md, rec) => rec.data.slot === rec.data.id ? '' : v === -1 ? '-' : v, + width: 50, + }, + { + header: gettext('Vendor'), + dataIndex: 'vendor_name', + flex: 3, + }, + { + header: gettext('Device'), + dataIndex: 'device_name', + flex: 6, + }, + { + header: gettext('Mediated Devices'), + dataIndex: 'mdev', + flex: 1, + renderer: function(val) { + return Proxmox.Utils.format_boolean(!!val); + }, + }, + ], + + listeners: { + selectionchange: function() { + this.selectionChange(...arguments); + }, + }, + + store: { + fields: [ + 'id', 'vendor_name', 'device_name', 'vendor', 'device', 'iommugroup', 'mdev', + 'subsystem_vendor', 'subsystem_device', 'disabled', + { + name: 'subsystem-vendor', + calculate: function(data) { + return data.subsystem_vendor; + }, + }, + { + name: 'subsystem-device', + calculate: function(data) { + return data.subsystem_device; + }, + }, + ], + sorters: [ + { + property: 'id', + direction: 'ASC', + }, + ], + }, + + initComponent: function() { + let me = this; + + let nodename = me.nodename; + me.nodename = undefined; + + me.callParent(); + + Proxmox.Utils.monStoreErrors(me, me.getStore(), true); + + me.setNodename(nodename); + + me.initField(); + }, +}); From 2ffe0f326215904f2aa2510839d9a4895f9d038d Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:36 +0200 Subject: [PATCH 103/398] ui: add edit window for pci mappings This contains the window to edit a PCI mapping for a single host. It is designed to work in 3 modes: * without an id and a nodename: for new mappings * with an id but without nodename: for adding new host mappings to an existing one * with id and nodename: when editing an existing host mapping Signed-off-by: Dominik Csapak --- www/manager6/Makefile | 1 + www/manager6/window/PCIMapEdit.js | 215 ++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 www/manager6/window/PCIMapEdit.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index e534cecd6..98a5b9a1e 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -123,6 +123,7 @@ JSSRC= \ window/Wizard.js \ window/GuestDiskReassign.js \ window/TreeSettingsEdit.js \ + window/PCIEdit.js \ ha/Fencing.js \ ha/GroupEdit.js \ ha/GroupSelector.js \ diff --git a/www/manager6/window/PCIMapEdit.js b/www/manager6/window/PCIMapEdit.js new file mode 100644 index 000000000..0b6d7d609 --- /dev/null +++ b/www/manager6/window/PCIMapEdit.js @@ -0,0 +1,215 @@ +Ext.define('PVE.window.PCIMapEditWindow', { + extend: 'Proxmox.window.Edit', + + mixins: ['Proxmox.Mixin.CBind'], + + width: 800, + + subject: gettext('PCI mapping'), + + onlineHelp: 'resource_mapping', + + method: 'POST', + + cbindData: function(initialConfig) { + let me = this; + me.isCreate = !me.name; + me.method = me.isCreate ? 'POST' : 'PUT'; + return { + name: me.name, + nodename: me.nodename, + }; + }, + + submitUrl: function(_url, data) { + let me = this; + let name = me.isCreate ? '' : me.name; + return `/cluster/mapping/pci/${name}`; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + onGetValues: function(values) { + let me = this; + let view = me.getView(); + if (view.method === "POST") { + delete me.digest; + } + + if (values.iommugroup === -1) { + delete values.iommugroup; + } + + let nodename = values.node ?? view.nodename; + delete values.node; + if (me.originalMap) { + let otherMaps = PVE.Parser + .filterPropertyStringList(me.originalMap, (e) => e.node !== nodename); + if (otherMaps.length) { + values.map = values.map.concat(otherMaps); + } + } + + return values; + }, + + onSetValues: function(values) { + let me = this; + let view = me.getView(); + me.originalMap = [...values.map]; + values.map = PVE.Parser.filterPropertyStringList(values.map, (e) => e.node === view.nodename); + return values; + }, + + checkIommu: function(store, records, success) { + let me = this; + if (!success || !records.length) { + return; + } + me.lookup('iommu_warning').setVisible( + records.every((val) => val.data.iommugroup === -1), + ); + }, + + mdevChange: function(mdevField, value) { + this.lookup('pciselector').setMdev(value); + }, + + nodeChange: function(_field, value) { + this.lookup('pciselector').setNodename(value); + }, + + pciChange: function(_field, value) { + let me = this; + me.lookup('multiple_warning').setVisible(Ext.isArray(value) && value.length > 1); + }, + + control: { + 'field[name=mdev]': { + change: 'mdevChange', + }, + 'pveNodeSelector': { + change: 'nodeChange', + }, + 'pveMultiPCISelector': { + change: 'pciChange', + }, + }, + }, + + items: [ + { + xtype: 'inputpanel', + onGetValues: function(values) { + return this.up('window').getController().onGetValues(values); + }, + + onSetValues: function(values) { + return this.up('window').getController().onSetValues(values); + }, + + columnT: [ + { + xtype: 'displayfield', + reference: 'iommu_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + value: 'No IOMMU detected, please activate it.' + + 'See Documentation for further information.', + userCls: 'pmx-hint', + }, + { + xtype: 'displayfield', + reference: 'multiple_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + value: 'When multiple devices are selected, the first free one will be chosen' + + ' on guest start.', + userCls: 'pmx-hint', + }, + { + xtype: 'displayfield', + reference: 'group_warning', + hidden: true, + columnWidth: 1, + padding: '0 0 10 0', + itemId: 'iommuwarning', + value: 'The selected Device is not in a seperate IOMMU group, make sure this is intended.', + userCls: 'pmx-hint', + }, + ], + + column1: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Name'), + labelWidth: 120, + cbind: { + editable: '{!name}', + value: '{name}', + submitValue: '{isCreate}', + }, + name: 'id', + allowBlank: false, + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Mediated Devices'), + labelWidth: 120, + reference: 'mdev', + name: 'mdev', + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + + column2: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Node'), + labelWidth: 120, + name: 'node', + editConfig: { + xtype: 'pveNodeSelector', + }, + cbind: { + editable: '{!nodename}', + value: '{nodename}', + }, + allowBlank: false, + }, + ], + + columnB: [ + { + xtype: 'pveMultiPCISelector', + fieldLabel: gettext('Device'), + labelWidth: 120, + height: 300, + reference: 'pciselector', + name: 'map', + cbind: { + nodename: '{nodename}', + }, + allowBlank: false, + onLoadCallBack: 'checkIommu', + margin: '0 0 10 0', + }, + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Comment'), + labelWidth: 120, + submitValue: true, + name: 'description', + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + }, + ], +}); From 5b775e5d8c5e78239e3eab41a6838972d5fcce25 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:37 +0200 Subject: [PATCH 104/398] ui: add edit window for usb mappings very similar to the PCIMapEdit window, but we only ever allow one mapping per host Signed-off-by: Dominik Csapak --- www/manager6/Makefile | 3 +- www/manager6/window/USBMapEdit.js | 217 ++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 www/manager6/window/USBMapEdit.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 98a5b9a1e..99ebc4dc7 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -123,7 +123,8 @@ JSSRC= \ window/Wizard.js \ window/GuestDiskReassign.js \ window/TreeSettingsEdit.js \ - window/PCIEdit.js \ + window/PCIMapEdit.js \ + window/USBMapEdit.js \ ha/Fencing.js \ ha/GroupEdit.js \ ha/GroupSelector.js \ diff --git a/www/manager6/window/USBMapEdit.js b/www/manager6/window/USBMapEdit.js new file mode 100644 index 000000000..80f8e785f --- /dev/null +++ b/www/manager6/window/USBMapEdit.js @@ -0,0 +1,217 @@ +Ext.define('PVE.window.USBMapEditWindow', { + extend: 'Proxmox.window.Edit', + + mixins: ['Proxmox.Mixin.CBind'], + + cbindData: function(initialConfig) { + let me = this; + me.isCreate = !me.name; + me.method = me.isCreate ? 'POST' : 'PUT'; + return { + name: me.name, + nodename: me.nodename, + }; + }, + + submitUrl: function(_url, data) { + let me = this; + let name = me.isCreate ? '' : me.name; + return `/cluster/mapping/usb/${name}`; + }, + + title: gettext('Add USB mapping'), + + onlineHelp: 'resource_mapping', + + method: 'POST', + + controller: { + xclass: 'Ext.app.ViewController', + + onGetValues: function(values) { + let me = this; + let view = me.getView(); + values.node ??= view.nodename; + + let type = me.getView().down('radiofield').getGroupValue(); + let name = values.name; + let description = values.description; + delete values.description; + delete values.name; + + if (type === 'path') { + let usbsel = me.lookup(type); + let usbDev = usbsel.getStore().findRecord('usbid', values[type], 0, false, true, true); + + if (!usbDev) { + return {}; + } + values.id = `${usbDev.data.vendid}:${usbDev.data.prodid}`; + } + + let map = []; + if (me.originalMap) { + map = PVE.Parser.filterPropertyStringList(me.originalMap, (e) => e.node !== values.node); + } + map.push(PVE.Parser.printPropertyString(values)); + + values = { + map, + description, + }; + + if (view.isCreate) { + values.id = name; + } + + return values; + }, + + onSetValues: function(values) { + let me = this; + let view = me.getView(); + me.originalMap = [...values.map]; + PVE.Parser.filterPropertyStringList(values.map, (e) => { + if (e.node === view.nodename) { + values = e; + } + return false; + }); + + if (values.path) { + values.usb = 'path'; + } + + return values; + }, + + modeChange: function(field, value) { + let me = this; + let type = field.inputValue; + let usbsel = me.lookup(type); + usbsel.setDisabled(!value); + }, + + nodeChange: function(_field, value) { + this.lookup('id').setNodename(value); + this.lookup('path').setNodename(value); + }, + + + init: function(view) { + let me = this; + + if (!view.nodename) { + //throw "no nodename given"; + } + }, + + control: { + 'radiofield': { + change: 'modeChange', + }, + 'pveNodeSelector': { + change: 'nodeChange', + }, + }, + }, + + items: [ + { + xtype: 'inputpanel', + onGetValues: function(values) { + return this.up('window').getController().onGetValues(values); + }, + + onSetValues: function(values) { + return this.up('window').getController().onSetValues(values); + }, + + column1: [ + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Name'), + cbind: { + editable: '{!name}', + value: '{name}', + submitValue: '{isCreate}', + }, + name: 'name', + allowBlank: false, + }, + { + xtype: 'pmxDisplayEditField', + fieldLabel: gettext('Node'), + name: 'node', + editConfig: { + xtype: 'pveNodeSelector', + }, + cbind: { + editable: '{!nodename}', + value: '{nodename}', + }, + allowBlank: false, + }, + ], + + column2: [ + { + xtype: 'fieldcontainer', + defaultType: 'radiofield', + layout: 'fit', + items: [ + { + name: 'usb', + inputValue: 'id', + checked: true, + boxLabel: gettext('Use USB Vendor/Device ID'), + submitValue: false, + }, + { + xtype: 'pveUSBSelector', + type: 'device', + reference: 'id', + name: 'id', + cbind: { + nodename: '{nodename}', + }, + editable: true, + allowBlank: false, + fieldLabel: gettext('Choose Device'), + labelAlign: 'right', + }, + { + name: 'usb', + inputValue: 'path', + boxLabel: gettext('Use USB Port'), + submitValue: false, + }, + { + xtype: 'pveUSBSelector', + disabled: true, + name: 'path', + reference: 'path', + cbind: { + nodename: '{nodename}', + }, + editable: true, + type: 'port', + allowBlank: false, + fieldLabel: gettext('Choose Port'), + labelAlign: 'right', + }, + ], + }, + ], + + columnB: [ + { + xtype: 'proxmoxtextfield', + fieldLabel: gettext('Comment'), + submitValue: true, + name: 'description', + }, + ], + }, + ], +}); From 386f8d97952aa61cf5fedbb76fb718bc97fecc74 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:38 +0200 Subject: [PATCH 105/398] ui: add ResourceMapTree this will be the base class for trees for the individual mapping types, e.g. pci and usb mapping. there are a few things to configure, but the overall code sharing is still significant, and should work out fine for future mapping types Signed-off-by: Dominik Csapak --- www/manager6/Makefile | 1 + www/manager6/tree/ResourceMapTree.js | 316 +++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 www/manager6/tree/ResourceMapTree.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 99ebc4dc7..0cb922d60 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -101,6 +101,7 @@ JSSRC= \ panel/MultiDiskEdit.js \ tree/ResourceTree.js \ tree/SnapshotTree.js \ + tree/ResourceMapTree.js \ window/Backup.js \ window/BackupConfig.js \ window/BulkAction.js \ diff --git a/www/manager6/tree/ResourceMapTree.js b/www/manager6/tree/ResourceMapTree.js new file mode 100644 index 000000000..df50b63af --- /dev/null +++ b/www/manager6/tree/ResourceMapTree.js @@ -0,0 +1,316 @@ +Ext.define('PVE.tree.ResourceMapTree', { + extend: 'Ext.tree.Panel', + alias: 'widget.pveResourceMapTree', + mixins: ['Proxmox.Mixin.CBind'], + + rootVisible: false, + + emptyText: gettext('No Mapping found'), + + // will be opened on edit + editWindowClass: undefined, + + // The base url of the resource + baseUrl: undefined, + + // icon class to show on the entries + mapIconCls: undefined, + + // if given, should be a function that takes a nodename and returns + // the url for getting the data to check the status + getStatusCheckUrl: undefined, + + // the result of above api call and the nodename is passed and can set the status + checkValidity: undefined, + + // the property that denotes a single map entry for a node + entryIdProperty: undefined, + + cbindData: function(initialConfig) { + let me = this; + const caps = Ext.state.Manager.get('GuiCap'); + me.canConfigure = !!caps.mapping['Mapping.Modify']; + + return {}; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + addMapping: function() { + let me = this; + let view = me.getView(); + Ext.create(view.editWindowClass, { + url: view.baseUrl, + autoShow: true, + listeners: { + destroy: () => me.load(), + }, + }); + }, + + addHost: function() { + let me = this; + me.edit(false); + }, + + edit: function(includeNodename = true) { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (!selection || !selection.length) { + return; + } + let rec = selection[0]; + if (!view.canConfigure || (rec.data.type === 'entry' && includeNodename)) { + return; + } + + Ext.create(view.editWindowClass, { + url: `${view.baseUrl}/${rec.data.name}`, + autoShow: true, + autoLoad: true, + nodename: includeNodename ? rec.data.node : undefined, + name: rec.data.name, + listeners: { + destroy: () => me.load(), + }, + }); + }, + + remove: function() { + let me = this; + let view = me.getView(); + let selection = view.getSelection(); + if (!selection || !selection.length) { + return; + } + + let data = selection[0].data; + let url = `${view.baseUrl}/${data.name}`; + let method = 'PUT'; + let params = { + digest: me.lookup[data.name].digest, + }; + let map = me.lookup[data.name].map; + switch (data.type) { + case 'entry': + method = 'DELETE'; + params = undefined; + break; + case 'node': + params.map = PVE.Parser.filterPropertyStringList(map, (e) => e.node !== data.node); + break; + case 'map': + params.map = PVE.Parser.filterPropertyStringList(map, (e) => + Object.entries(e).some(([key, value]) => data[key] !== value)); + break; + default: + throw "invalid type"; + } + if (!params?.map.length) { + method = 'DELETE'; + params = undefined; + } + Proxmox.Utils.API2Request({ + url, + method, + params, + success: function() { + me.load(); + }, + }); + }, + + load: function() { + let me = this; + let view = me.getView(); + Proxmox.Utils.API2Request({ + url: view.baseUrl, + method: 'GET', + failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function({ result: { data } }) { + let lookup = {}; + data.forEach((entry) => { + lookup[entry.id] = Ext.apply({}, entry); + entry.iconCls = 'fa fa-fw fa-folder-o'; + entry.name = entry.id; + entry.text = entry.id; + entry.type = 'entry'; + + let nodes = {}; + for (const map of entry.map) { + let parsed = PVE.Parser.parsePropertyString(map); + parsed.iconCls = view.mapIconCls; + parsed.leaf = true; + parsed.name = entry.id; + parsed.text = parsed[view.entryIdProperty]; + parsed.type = 'map'; + + if (nodes[parsed.node] === undefined) { + nodes[parsed.node] = { + children: [], + expanded: true, + iconCls: 'fa fa-fw fa-building-o', + leaf: false, + name: entry.id, + node: parsed.node, + text: parsed.node, + type: 'node', + }; + } + nodes[parsed.node].children.push(parsed); + } + delete entry.id; + entry.children = Object.values(nodes); + entry.leaf = entry.children.length === 0; + }); + me.lookup = lookup; + if (view.getStatusCheckUrl !== undefined && view.checkValidity !== undefined) { + me.loadStatusData(); + } + view.setRootNode({ + children: data, + }); + let root = view.getRootNode(); + root.expand(); + root.childNodes.forEach(node => node.expand()); + }, + }); + }, + + nodeLoadingState: {}, + + loadStatusData: function() { + let me = this; + let view = me.getView(); + PVE.data.ResourceStore.getNodes().forEach(({ node }) => { + me.nodeLoadingState[node] = true; + let url = view.getStatusCheckUrl(node); + Proxmox.Utils.API2Request({ + url, + method: 'GET', + failure: function(response) { + me.nodeLoadingState[node] = false; + view.getRootNode()?.cascade(function(rec) { + if (rec.data.node !== node) { + return; + } + + rec.set('valid', 0); + rec.set('errmsg', response.htmlStatus); + rec.commit(); + }); + }, + success: function({ result: { data } }) { + me.nodeLoadingState[node] = false; + view.checkValidity(data, node); + }, + }); + }); + }, + + renderStatus: function(value, _metadata, record) { + let me = this; + if (record.data.type !== 'map') { + return ''; + } + let iconCls; + let status; + if (value === undefined) { + if (me.nodeLoadingState[record.data.node]) { + iconCls = 'fa-spinner fa-spin'; + status = gettext('Loading...'); + } else { + iconCls = 'fa-question-circle'; + status = gettext('Unknown Node'); + } + } else { + let state = value ? 'good' : 'critical'; + iconCls = PVE.Utils.get_health_icon(state, true); + status = value ? gettext("OK") : record.data.errmsg || Proxmox.Utils.unknownText; + } + return ` ${status}`; + }, + + init: function(view) { + let me = this; + + ['editWindowClass', 'baseUrl', 'mapIconCls', 'entryIdProperty'].forEach((property) => { + if (view[property] === undefined) { + throw `No ${property} defined`; + } + }); + + me.load(); + }, + }, + + store: { + sorters: 'text', + data: {}, + }, + + + tbar: [ + { + text: gettext('Add mapping'), + handler: 'addMapping', + cbind: { + disabled: '{!canConfigure}', + }, + }, + { + xtype: 'proxmoxButton', + text: gettext('New Host mapping'), + disabled: true, + parentXType: 'treepanel', + enableFn: function(_rec) { + return this.up('treepanel').canConfigure; + }, + handler: 'addHost', + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit'), + disabled: true, + parentXType: 'treepanel', + enableFn: function(rec) { + return rec && rec.data.type !== 'entry' && this.up('treepanel').canConfigure; + }, + handler: 'edit', + }, + { + xtype: 'proxmoxButton', + parentXType: 'treepanel', + handler: 'remove', + disabled: true, + text: gettext('Remove'), + enableFn: function(rec) { + return rec && this.up('treepanel').canConfigure; + }, + confirmMsg: function(rec) { + let msg, id; + let view = this.up('treepanel'); + switch (rec.data.type) { + case 'entry': + msg = gettext("Are you sure you want to remove '{0}'"); + return Ext.String.format(msg, rec.data.name); + case 'node': + msg = gettext("Are you sure you want to remove '{0}' entries for '{1}'"); + return Ext.String.format(msg, rec.data.node, rec.data.name); + case 'map': + msg = gettext("Are you sure you want to remove '{0}' on '{1}' for '{2}'"); + id = rec.data[view.entryIdProperty]; + return Ext.String.format(msg, id, rec.data.node, rec.data.name); + default: + throw "invalid type"; + } + }, + }, + ], + + listeners: { + itemdblclick: 'edit', + }, +}); From 70eb18e8f033a28cedcf4ddd9c8d7d79cad30a0a Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:39 +0200 Subject: [PATCH 106/398] ui: allow configuring pci and usb mapping uses the new ResourceMapTree to add the CRUD interfaces for the mappings. We add both of them into a single panel, since the datacenter menu already has many entries, and without a proper summary for the group, we cannot really put them in a category Signed-off-by: Dominik Csapak --- www/manager6/Makefile | 2 + www/manager6/dc/Config.js | 46 ++++++++++++++- www/manager6/dc/PCIMapView.js | 106 ++++++++++++++++++++++++++++++++++ www/manager6/dc/USBMapView.js | 98 +++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 www/manager6/dc/PCIMapView.js create mode 100644 www/manager6/dc/USBMapView.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 0cb922d60..2d884f4a4 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -174,6 +174,8 @@ JSSRC= \ dc/UserTagAccessEdit.js \ dc/RegisteredTagsEdit.js \ dc/RealmSyncJob.js \ + dc/PCIMapView.js \ + dc/USBMapView.js \ lxc/CmdMenu.js \ lxc/Config.js \ lxc/CreateWizard.js \ diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index f9f937a55..10a4d83ae 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -274,8 +274,50 @@ Ext.define('PVE.dc.Config', { iconCls: 'fa fa-bar-chart', itemId: 'metricservers', onlineHelp: 'external_metric_server', - }, - { + }); + } + + if (caps.mapping['Mapping.Audit'] || + caps.mapping['Mapping.Use'] || + caps.mapping['Mapping.Modify']) { + me.items.push( + { + xtype: 'container', + onlineHelp: 'resource_mapping', + title: gettext('Resource Mappings'), + itemId: 'resources', + iconCls: 'fa fa-folder-o', + layout: { + type: 'vbox', + align: 'stretch', + multi: true, + }, + scrollable: true, + defaults: { + collapsible: true, + animCollapse: false, + margin: '7 10 3 10', + }, + items: [ + { + collapsible: true, + xtype: 'pveDcPCIMapView', + title: gettext('PCI Devices'), + flex: 1, + }, + { + collapsible: true, + xtype: 'pveDcUSBMapView', + title: gettext('USB Devices'), + flex: 1, + }, + ], + }, + ); + } + + if (caps.dc['Sys.Audit']) { + me.items.push({ xtype: 'pveDcSupport', title: gettext('Support'), itemId: 'support', diff --git a/www/manager6/dc/PCIMapView.js b/www/manager6/dc/PCIMapView.js new file mode 100644 index 000000000..3efa19d8c --- /dev/null +++ b/www/manager6/dc/PCIMapView.js @@ -0,0 +1,106 @@ +Ext.define('pve-resource-pci-tree', { + extend: 'Ext.data.Model', + idProperty: 'internalId', + fields: ['type', 'text', 'path', 'id', 'subsystem-id', 'iommugroup', 'description', 'digest'], +}); + +Ext.define('PVE.dc.PCIMapView', { + extend: 'PVE.tree.ResourceMapTree', + alias: 'widget.pveDcPCIMapView', + + editWindowClass: 'PVE.window.PCIMapEditWindow', + baseUrl: '/cluster/mapping/pci', + mapIconCls: 'pve-itype-icon-pci', + getStatusCheckUrl: (node) => `/nodes/${node}/hardware/pci?pci-class-blacklist=`, + entryIdProperty: 'path', + + checkValidity: function(data, node) { + let me = this; + let ids = {}; + data.forEach((entry) => { + ids[entry.id] = entry; + }); + me.getRootNode()?.cascade(function(rec) { + if (rec.data.node !== node || rec.data.type !== 'map') { + return; + } + + let id = rec.data.path; + if (!id.match(/\.\d$/)) { + id += '.0'; + } + let device = ids[id]; + if (!device) { + rec.set('valid', 0); + rec.set('errmsg', Ext.String.format(gettext("Cannot find PCI id {0}"), id)); + rec.commit(); + return; + } + + + let deviceId = `${device.vendor}:${device.device}`.replace(/0x/g, ''); + let subId = `${device.subsystem_vendor}:${device.subsystem_device}`.replace(/0x/g, ''); + + let toCheck = { + id: deviceId, + 'subsystem-id': subId, + iommugroup: device.iommugroup !== -1 ? device.iommugroup : undefined, + }; + + let valid = 1; + let errors = []; + let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')"); + for (const [key, validValue] of Object.entries(toCheck)) { + if (`${rec.data[key]}` !== `${validValue}`) { + errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue)); + valid = 0; + } + } + + rec.set('valid', valid); + rec.set('errmsg', errors.join('
')); + rec.commit(); + }); + }, + + store: { + sorters: 'text', + model: 'pve-resource-pci-tree', + data: {}, + }, + + columns: [ + { + xtype: 'treecolumn', + text: gettext('ID/Node/Path'), + dataIndex: 'text', + width: 200, + }, + { + text: gettext('Vendor/Device'), + dataIndex: 'id', + }, + { + text: gettext('Subsystem Vendor/Device'), + dataIndex: 'subsystem-id', + }, + { + text: gettext('IOMMU group'), + dataIndex: 'iommugroup', + }, + { + header: gettext('Status'), + dataIndex: 'valid', + flex: 1, + renderer: 'renderStatus', + }, + { + header: gettext('Comment'), + dataIndex: 'description', + renderer: function(value, _meta, record) { + return value ?? record.data.comment; + }, + flex: 1, + }, + ], +}); diff --git a/www/manager6/dc/USBMapView.js b/www/manager6/dc/USBMapView.js new file mode 100644 index 000000000..953e2425c --- /dev/null +++ b/www/manager6/dc/USBMapView.js @@ -0,0 +1,98 @@ +Ext.define('pve-resource-usb-tree', { + extend: 'Ext.data.Model', + idProperty: 'internalId', + fields: ['type', 'text', 'path', 'id', 'description', 'digest'], +}); + +Ext.define('PVE.dc.USBMapView', { + extend: 'PVE.tree.ResourceMapTree', + alias: 'widget.pveDcUSBMapView', + + editWindowClass: 'PVE.window.USBMapEditWindow', + baseUrl: '/cluster/mapping/usb', + mapIconCls: 'fa fa-usb', + getStatusCheckUrl: (node) => `/nodes/${node}/hardware/usb`, + entryIdProperty: 'id', + + checkValidity: function(data, node) { + let me = this; + let ids = {}; + let paths = {}; + data.forEach((entry) => { + ids[`${entry.vendid}:${entry.prodid}`] = entry; + paths[`${entry.busnum}-${entry.usbpath}`] = entry; + }); + me.getRootNode()?.cascade(function(rec) { + if (rec.data.node !== node || rec.data.type !== 'map') { + return; + } + + let device; + if (rec.data.path) { + device = paths[rec.data.path]; + } + device ??= ids[rec.data.id]; + + if (!device) { + rec.set('valid', 0); + rec.set('errmsg', Ext.String.format(gettext("Cannot find USB device {0}"), rec.data.id)); + rec.commit(); + return; + } + + + let deviceId = `${device.vendid}:${device.prodid}`.replace(/0x/g, ''); + + let toCheck = { + id: deviceId, + }; + + let valid = 1; + let errors = []; + let errText = gettext("Configuration for {0} not correct ('{1}' != '{2}')"); + for (const [key, validValue] of Object.entries(toCheck)) { + if (rec.data[key] !== validValue) { + errors.push(Ext.String.format(errText, key, rec.data[key] ?? '', validValue)); + valid = 0; + } + } + + rec.set('valid', valid); + rec.set('errmsg', errors.join('
')); + rec.commit(); + }); + }, + + store: { + sorters: 'text', + model: 'pve-resource-usb-tree', + data: {}, + }, + + columns: [ + { + xtype: 'treecolumn', + text: gettext('ID/Node/Vendor&Device'), + dataIndex: 'text', + width: 200, + }, + { + text: gettext('Path'), + dataIndex: 'path', + }, + { + header: gettext('Status'), + dataIndex: 'valid', + flex: 1, + renderer: 'renderStatus', + }, + { + header: gettext('Comment'), + dataIndex: 'description', + renderer: function(value, _meta, record) { + return value ?? record.data.comment; + }, + flex: 1, + }, + ], +}); From 02f14aa838e52a1c60b77c2e9b63966c3aa59812 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:40 +0200 Subject: [PATCH 107/398] ui: guest migrate: allow mapped devices for offline migrations if the migration is an offline migration and when the mapping on the target node exists, otherwise not this does not change the behaviour for 'raw' devices in the config those can still be forced to be migrated, like before Signed-off-by: Dominik Csapak --- www/manager6/window/Migrate.js | 52 +++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/www/manager6/window/Migrate.js b/www/manager6/window/Migrate.js index 1c23abb3e..c310342d0 100644 --- a/www/manager6/window/Migrate.js +++ b/www/manager6/window/Migrate.js @@ -219,36 +219,68 @@ Ext.define('PVE.window.Migrate', { let target = me.lookup('pveNodeSelector').value; if (target.length && !migrateStats.allowed_nodes.includes(target)) { let disallowed = migrateStats.not_allowed_nodes[target]; - let missingStorages = disallowed.unavailable_storages.join(', '); + if (disallowed.unavailable_storages !== undefined) { + let missingStorages = disallowed.unavailable_storages.join(', '); - migration.possible = false; - migration.preconditions.push({ - text: 'Storage (' + missingStorages + ') not available on selected target. ' + - 'Start VM to use live storage migration or select other target node', - severity: 'error', - }); + migration.possible = false; + migration.preconditions.push({ + text: 'Storage (' + missingStorages + ') not available on selected target. ' + + 'Start VM to use live storage migration or select other target node', + severity: 'error', + }); + } + + if (disallowed['unavailable-resources'] !== undefined) { + let unavailableResources = disallowed['unavailable-resources'].join(', '); + + migration.possible = false; + migration.preconditions.push({ + text: 'Mapped Resources (' + unavailableResources + ') not available on selected target. ', + severity: 'error', + }); + } } } - if (migrateStats.local_resources.length) { + let blockingResources = []; + let mappedResources = migrateStats['mapped-resources'] ?? []; + + for (const res of migrateStats.local_resources) { + if (mappedResources.indexOf(res) === -1) { + blockingResources.push(res); + } + } + + if (blockingResources.length) { migration.hasLocalResources = true; if (!migration.overwriteLocalResourceCheck || vm.get('running')) { migration.possible = false; migration.preconditions.push({ text: Ext.String.format('Can\'t migrate VM with local resources: {0}', - migrateStats.local_resources.join(', ')), + blockingResources.join(', ')), severity: 'error', }); } else { migration.preconditions.push({ text: Ext.String.format('Migrate VM with local resources: {0}. ' + 'This might fail if resources aren\'t available on the target node.', - migrateStats.local_resources.join(', ')), + blockingResources.join(', ')), severity: 'warning', }); } } + if (mappedResources && mappedResources.length) { + if (vm.get('running')) { + migration.possible = false; + migration.preconditions.push({ + text: Ext.String.format('Can\'t migrate running VM with mapped resources: {0}', + mappedResources.join(', ')), + severity: 'error', + }); + } + } + if (migrateStats.local_disks.length) { migrateStats.local_disks.forEach(function(disk) { if (disk.cdrom && disk.cdrom === 1) { From 7becf34fddb261f9720764b416db41b0f23505d3 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 16 Jun 2023 15:05:41 +0200 Subject: [PATCH 108/398] ui: improve permission handling for hardware qemu/HardwareView: with the new Hardware privileges, we want to adapt a few places where we now allow to show the add/edit window with those permissions. form/{PCI,USB}Selector: increase the minHeight property of the PCI/USBSelector, so that the user can see the error message if he has not enough permissions. data/PermPathStore: add '/hardware' to the list of acl paths Signed-off-by: Dominik Csapak --- www/manager6/data/PermPathStore.js | 1 + www/manager6/form/PCISelector.js | 1 + www/manager6/form/USBSelector.js | 1 + www/manager6/qemu/HardwareView.js | 17 +++++++++-------- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/www/manager6/data/PermPathStore.js b/www/manager6/data/PermPathStore.js index cf702c031..c3ac7f0ec 100644 --- a/www/manager6/data/PermPathStore.js +++ b/www/manager6/data/PermPathStore.js @@ -8,6 +8,7 @@ Ext.define('PVE.data.PermPathStore', { { 'value': '/access' }, { 'value': '/access/groups' }, { 'value': '/access/realm' }, + { 'value': '/mapping' }, { 'value': '/nodes' }, { 'value': '/pool' }, { 'value': '/sdn/zones' }, diff --git a/www/manager6/form/PCISelector.js b/www/manager6/form/PCISelector.js index 4e0a778fb..9bf57e21b 100644 --- a/www/manager6/form/PCISelector.js +++ b/www/manager6/form/PCISelector.js @@ -22,6 +22,7 @@ Ext.define('PVE.form.PCISelector', { onLoadCallBack: undefined, listConfig: { + minHeight: 80, width: 800, columns: [ { diff --git a/www/manager6/form/USBSelector.js b/www/manager6/form/USBSelector.js index 011778d7e..975b2646c 100644 --- a/www/manager6/form/USBSelector.js +++ b/www/manager6/form/USBSelector.js @@ -71,6 +71,7 @@ Ext.define('PVE.form.USBSelector', { store: store, emptyText: emptyText, listConfig: { + minHeight: 80, width: 520, columns: [ { diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js index af35a980f..5b33b1e23 100644 --- a/www/manager6/qemu/HardwareView.js +++ b/www/manager6/qemu/HardwareView.js @@ -259,8 +259,8 @@ Ext.define('PVE.qemu.HardwareView', { group: 25, order: i, iconCls: 'usb', - editor: caps.nodes['Sys.Console'] ? 'PVE.qemu.USBEdit' : undefined, - never_delete: !caps.nodes['Sys.Console'], + editor: caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] ? 'PVE.qemu.USBEdit' : undefined, + never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], header: gettext('USB Device') + ' (' + confid + ')', }; } @@ -270,8 +270,8 @@ Ext.define('PVE.qemu.HardwareView', { group: 30, order: i, tdCls: 'pve-itype-icon-pci', - never_delete: !caps.nodes['Sys.Console'], - editor: caps.nodes['Sys.Console'] ? 'PVE.qemu.PCIEdit' : undefined, + never_delete: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], + editor: caps.nodes['Sys.Console'] || caps.mapping['Mapping.Use'] ? 'PVE.qemu.PCIEdit' : undefined, header: gettext('PCI Device') + ' (' + confid + ')', }; } @@ -577,14 +577,15 @@ Ext.define('PVE.qemu.HardwareView', { // heuristic only for disabling some stuff, the backend has the final word. const noSysConsolePerm = !caps.nodes['Sys.Console']; + const noHWPerm = !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use']; const noVMConfigHWTypePerm = !caps.vms['VM.Config.HWType']; const noVMConfigNetPerm = !caps.vms['VM.Config.Network']; const noVMConfigDiskPerm = !caps.vms['VM.Config.Disk']; const noVMConfigCDROMPerm = !caps.vms['VM.Config.CDROM']; const noVMConfigCloudinitPerm = !caps.vms['VM.Config.Cloudinit']; - me.down('#addUsb').setDisabled(noSysConsolePerm || isAtUsbLimit()); - me.down('#addPci').setDisabled(noSysConsolePerm || isAtLimit('hostpci')); + me.down('#addUsb').setDisabled(noHWPerm || isAtUsbLimit()); + me.down('#addPci').setDisabled(noHWPerm || isAtLimit('hostpci')); me.down('#addAudio').setDisabled(noVMConfigHWTypePerm || isAtLimit('audio')); me.down('#addSerial').setDisabled(noVMConfigHWTypePerm || isAtLimit('serial')); me.down('#addNet').setDisabled(noVMConfigNetPerm || isAtLimit('net')); @@ -697,14 +698,14 @@ Ext.define('PVE.qemu.HardwareView', { text: gettext('USB Device'), itemId: 'addUsb', iconCls: 'fa fa-fw fa-usb black', - disabled: !caps.nodes['Sys.Console'], + disabled: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], handler: editorFactory('USBEdit'), }, { text: gettext('PCI Device'), itemId: 'addPci', iconCls: 'pve-itype-icon-pci', - disabled: !caps.nodes['Sys.Console'], + disabled: !caps.nodes['Sys.Console'] && !caps.mapping['Mapping.Use'], handler: editorFactory('PCIEdit'), }, { From d4830b941f7446c27b967e340fca310e66d2cd3c Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 06:58:33 +0200 Subject: [PATCH 109/398] api: PCI mappings: complete return schema Signed-off-by: Thomas Lamprecht --- PVE/API2/Cluster/Mapping/PCI.pm | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PVE/API2/Cluster/Mapping/PCI.pm b/PVE/API2/Cluster/Mapping/PCI.pm index 2ccad888d..55e503929 100644 --- a/PVE/API2/Cluster/Mapping/PCI.pm +++ b/PVE/API2/Cluster/Mapping/PCI.pm @@ -61,12 +61,15 @@ __PACKAGE__->register_method ({ description => "A description of the logical mapping.", }, error => { + type => "array", + optional => 1, description => "A list of errors when 'check_node' is given.", items => { type => 'object', properties => { severity => { type => "string", + enum => ['warning', 'error'], description => "The severity of the error", }, message => { From 3a8bf3b613e25e8f2096f0dff7fac0c71c194543 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 07:13:19 +0200 Subject: [PATCH 110/398] api: PCI mappings: rename errors to checks in response as it contains warnings too, so having it named errors might be confusing. Signed-off-by: Thomas Lamprecht --- PVE/API2/Cluster/Mapping/PCI.pm | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/PVE/API2/Cluster/Mapping/PCI.pm b/PVE/API2/Cluster/Mapping/PCI.pm index 55e503929..301f5f1c6 100644 --- a/PVE/API2/Cluster/Mapping/PCI.pm +++ b/PVE/API2/Cluster/Mapping/PCI.pm @@ -34,7 +34,7 @@ __PACKAGE__->register_method ({ properties => { 'check-node' => get_standard_option('pve-node', { description => "If given, checks the configurations on the given node for ". - "correctness, and adds relevant errors to the devices.", + "correctness, and adds relevant diagnostics for the devices to the response.", optional => 1, }), }, @@ -60,10 +60,10 @@ __PACKAGE__->register_method ({ type => 'string', description => "A description of the logical mapping.", }, - error => { + checks => { type => "array", optional => 1, - description => "A list of errors when 'check_node' is given.", + description => "A list of checks, only present if 'check_node' is set.", items => { type => 'object', properties => { @@ -108,10 +108,10 @@ __PACKAGE__->register_method ({ $entry->{digest} = $cfg->{digest}; if (defined($node)) { - $entry->{errors} = []; + $entry->{checks} = []; if (my $mappings = PVE::Mapping::PCI::get_node_mapping($cfg, $id, $node)) { if (!scalar($mappings->@*)) { - push $entry->{errors}->@*, { + push $entry->{checks}->@*, { severity => 'warning', message => "No mapping for node $node.", }; @@ -121,7 +121,7 @@ __PACKAGE__->register_method ({ PVE::Mapping::PCI::assert_valid($id, $mapping); }; if (my $err = $@) { - push $entry->{errors}->@*, { + push $entry->{checks}->@*, { severity => 'error', message => "Invalid configuration: $err", }; From ebed76a242378fd4a14ba86a9f83687aba1afe60 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 07:15:14 +0200 Subject: [PATCH 111/398] api: PCI mappings: code/style cleanups Signed-off-by: Thomas Lamprecht --- PVE/API2/Cluster/Mapping/PCI.pm | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/PVE/API2/Cluster/Mapping/PCI.pm b/PVE/API2/Cluster/Mapping/PCI.pm index 301f5f1c6..b503d879b 100644 --- a/PVE/API2/Cluster/Mapping/PCI.pm +++ b/PVE/API2/Cluster/Mapping/PCI.pm @@ -88,38 +88,37 @@ __PACKAGE__->register_method ({ my $rpcenv = PVE::RPCEnvironment::get(); my $authuser = $rpcenv->get_user(); - my $node = $param->{'check-node'}; - die "Wrong node to check\n" - if defined($node) && $node ne 'localhost' && $node ne PVE::INotify::nodename(); + my $check_node = $param->{'check-node'}; + my $local_node = PVE::INotify::nodename(); + + die "wrong node to check - $check_node != $local_node\n" + if defined($check_node) && $check_node ne 'localhost' && $check_node ne $local_node; my $cfg = PVE::Mapping::PCI::config(); + my $can_see_mapping_privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; + my $res = []; - - my $privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; - for my $id (keys $cfg->{ids}->%*) { - next if !$rpcenv->check_full($authuser, "/mapping/pci/$id", $privs, 1, 1); + next if !$rpcenv->check_any($authuser, "/mapping/pci/$id", $can_see_mapping_privs, 1); next if !$cfg->{ids}->{$id}; my $entry = dclone($cfg->{ids}->{$id}); $entry->{id} = $id; $entry->{digest} = $cfg->{digest}; - if (defined($node)) { + if (defined($check_node)) { $entry->{checks} = []; - if (my $mappings = PVE::Mapping::PCI::get_node_mapping($cfg, $id, $node)) { + if (my $mappings = PVE::Mapping::PCI::get_node_mapping($cfg, $id, $check_node)) { if (!scalar($mappings->@*)) { push $entry->{checks}->@*, { severity => 'warning', - message => "No mapping for node $node.", + message => "No mapping for node $check_node.", }; } for my $mapping ($mappings->@*) { - eval { - PVE::Mapping::PCI::assert_valid($id, $mapping); - }; + eval { PVE::Mapping::PCI::assert_valid($id, $mapping) }; if (my $err = $@) { push $entry->{checks}->@*, { severity => 'error', From b74e71f2c244f3bea0885f96df6d527ee947585c Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 07:16:29 +0200 Subject: [PATCH 112/398] api: cluster jobs: fix perl module not ending with a true value Signed-off-by: Thomas Lamprecht --- PVE/API2/Cluster/Jobs.pm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PVE/API2/Cluster/Jobs.pm b/PVE/API2/Cluster/Jobs.pm index 56b40fa25..0b003e70f 100644 --- a/PVE/API2/Cluster/Jobs.pm +++ b/PVE/API2/Cluster/Jobs.pm @@ -115,3 +115,5 @@ __PACKAGE__->register_method({ return $result; }}); + +1; From dd6433ff1cf5f0c81595ba397c40dc784937714b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 07:33:48 +0200 Subject: [PATCH 113/398] api: mappings: cleanup perl imports Signed-off-by: Thomas Lamprecht --- PVE/API2/Cluster/Mapping.pm | 2 -- PVE/API2/Cluster/Mapping/PCI.pm | 5 +---- PVE/API2/Cluster/Mapping/USB.pm | 5 +---- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/PVE/API2/Cluster/Mapping.pm b/PVE/API2/Cluster/Mapping.pm index 01fa986b9..40386579f 100644 --- a/PVE/API2/Cluster/Mapping.pm +++ b/PVE/API2/Cluster/Mapping.pm @@ -3,8 +3,6 @@ package PVE::API2::Cluster::Mapping; use strict; use warnings; -use PVE::RESTHandler; - use PVE::API2::Cluster::Mapping::PCI; use PVE::API2::Cluster::Mapping::USB; diff --git a/PVE/API2/Cluster/Mapping/PCI.pm b/PVE/API2/Cluster/Mapping/PCI.pm index b503d879b..64462d254 100644 --- a/PVE/API2/Cluster/Mapping/PCI.pm +++ b/PVE/API2/Cluster/Mapping/PCI.pm @@ -5,13 +5,10 @@ use warnings; use Storable qw(dclone); -use PVE::Cluster qw(cfs_lock_file); -use PVE::Mapping::PCI; +use PVE::Mapping::PCI (); use PVE::JSONSchema qw(get_standard_option); use PVE::Tools qw(extract_param); -use PVE::RESTHandler; - use base qw(PVE::RESTHandler); __PACKAGE__->register_method ({ diff --git a/PVE/API2/Cluster/Mapping/USB.pm b/PVE/API2/Cluster/Mapping/USB.pm index 3883cf7da..5495bce27 100644 --- a/PVE/API2/Cluster/Mapping/USB.pm +++ b/PVE/API2/Cluster/Mapping/USB.pm @@ -5,13 +5,10 @@ use warnings; use Storable qw(dclone); -use PVE::Cluster qw(cfs_lock_file); -use PVE::Mapping::USB; +use PVE::Mapping::USB (); use PVE::JSONSchema qw(get_standard_option); use PVE::Tools qw(extract_param); -use PVE::RESTHandler; - use base qw(PVE::RESTHandler); __PACKAGE__->register_method ({ From c46d34f4a271307052e46f481b428b8735246098 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 07:40:46 +0200 Subject: [PATCH 114/398] d/control: bump versioned libpve-guest-common dependency for the mapping infrastructure Signed-off-by: Thomas Lamprecht --- debian/control | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index 473df92cd..00e6144fd 100644 --- a/debian/control +++ b/debian/control @@ -12,7 +12,7 @@ Build-Depends: debhelper-compat (= 13), libpve-cluster-api-perl, libpve-cluster-perl (>= 6.1-6), libpve-common-perl (>= 7.2-6), - libpve-guest-common-perl (>= 4.2), + libpve-guest-common-perl (>= 5.0.2), libpve-http-server-perl (>= 2.0-12), libpve-rs-perl (>= 0.7.1), libpve-storage-perl (>= 6.3-2), @@ -59,7 +59,7 @@ Depends: apt (>= 1.5~), libpve-cluster-api-perl (>= 7.0-5), libpve-cluster-perl (>= 7.2-3), libpve-common-perl (>= 7.2-7), - libpve-guest-common-perl (>= 4.2-1), + libpve-guest-common-perl (>= 5.0.2), libpve-http-server-perl (>= 4.1-1), libpve-rs-perl (>= 0.7.1), libpve-storage-perl (>= 7.2-12), From 7abd9c27adffab50551522c5f0da2a3392067d42 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 07:41:34 +0200 Subject: [PATCH 115/398] d/control: bump versioned qemu-server dependency for the new x86-64-vN cpu models Signed-off-by: Thomas Lamprecht --- debian/control | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index 00e6144fd..8d311ba8b 100644 --- a/debian/control +++ b/debian/control @@ -24,7 +24,7 @@ Build-Depends: debhelper-compat (= 13), pve-container, pve-doc-generator (>= 7.2-3), pve-eslint (>= 7.28.0), - qemu-server (>= 6.0-15), + qemu-server (>= 8.0~~), sq, unzip, Standards-Version: 4.6.1 @@ -86,7 +86,7 @@ Depends: apt (>= 1.5~), pve-ha-manager, pve-i18n (>= 1.0-3), pve-xtermjs (>= 4.7.0-1), - qemu-server (>= 7.2-8), + qemu-server (>= 8.0.4), rsync, spiceterm, systemd, From 1ab6fc0b19d8c955c432f60de1f96eaf3eb1f16b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 07:42:19 +0200 Subject: [PATCH 116/398] d/control: bump versioned pve-docs & ifupdown2 dependency To ensure we got versions installed that can be even compatible with current code and testing. Signed-off-by: Thomas Lamprecht --- debian/control | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/debian/control b/debian/control index 8d311ba8b..3e36bb33a 100644 --- a/debian/control +++ b/debian/control @@ -22,7 +22,7 @@ Build-Depends: debhelper-compat (= 13), proxmox-widget-toolkit (>= 3.4-9), pve-cluster, pve-container, - pve-doc-generator (>= 7.2-3), + pve-doc-generator (>= 8.0~~), pve-eslint (>= 7.28.0), qemu-server (>= 8.0~~), sq, @@ -41,7 +41,7 @@ Depends: apt (>= 1.5~), fonts-font-awesome, gdisk, hdparm, - ifupdown2 (>= 2.0.1-1+pve8) | ifenslave (>= 2.6), + ifupdown2 (>= 3.0) | ifenslave (>= 2.6), libapt-pkg-perl, libcrypt-ssleay-perl, libfile-readbackwards-perl, @@ -81,7 +81,7 @@ Depends: apt (>= 1.5~), proxmox-widget-toolkit (>= 3.6.0), pve-cluster (>= 7.0-4), pve-container (>= 4.0-9), - pve-docs, + pve-docs (>= 8.0~~), pve-firewall, pve-ha-manager, pve-i18n (>= 1.0-3), From 3bca82d3a89ed95a6ef7c9297245088b153c735d Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 08:43:32 +0200 Subject: [PATCH 117/398] d/control: bump versioned pve-doc-generator buil-dependency for new resource mapping online help references Signed-off-by: Thomas Lamprecht --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 3e36bb33a..3206b514f 100644 --- a/debian/control +++ b/debian/control @@ -22,7 +22,7 @@ Build-Depends: debhelper-compat (= 13), proxmox-widget-toolkit (>= 3.4-9), pve-cluster, pve-container, - pve-doc-generator (>= 8.0~~), + pve-doc-generator (>= 8.0.2), pve-eslint (>= 7.28.0), qemu-server (>= 8.0~~), sq, From 7e4e6f00bd820aea62b120256dfb2dc4093ef33e Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 19 Jun 2023 08:51:02 +0200 Subject: [PATCH 118/398] bump version to 8.0.0~9 Signed-off-by: Thomas Lamprecht --- debian/changelog | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/debian/changelog b/debian/changelog index 3dda58b5f..a4dc5c0ee 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,42 @@ +pve-manager (8.0.0~9) bookworm; urgency=medium + + * api: cluster resources: add 'localnetwork' referring to local network + interfaces + + * ui: add vNet permissions panel + + * ui: create VM: use new x86-64-v2-AES as new default vCPU type. + The x86-64-v2-aes is compatible with Intel Westmere, launched in 2010 and + Opteron 6200-series "Interlagos" launched in 2011. + This model provides a few important extra fetures over the qemu64/kvm64 + model (which are basically v1 minus the -vme,-cx16 CPU flags) like SSE4.1 + and additionally also AES, while not supported by all v2 models it is by + all recent ones, improving performance of many computing operations + drastically. + + * fix #4739: ui: user list: add column for group memberships + + * ui: realm sync: change enabled column rendering + + * ui: realm sync: add 'run now' button + + * ui: realm: move sync job panel into realm panel + + * api: add resource map api endpoints for PCI and USB + + * ui: add overview panel and edit window for PCI resource mappings + + * ui: add overview panel and edit window for USB resource mappings + + * ui: allow using PCI and USB mappings for virtual machines + + * ui: improve permission handling for hardware and resource mappings + + * ui: guest migrate: allow mapped devices for offline migrations if target + node has a valid mapping for all devices used by VM. + + -- Proxmox Support Team Mon, 19 Jun 2023 08:32:38 +0200 + pve-manager (8.0.0~8) bookworm; urgency=medium * ui: add beta text with link to bugtracker From b7c7be3e50a90097593c7cc82f15ffdc8ae681fd Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 19 Jun 2023 11:13:30 +0200 Subject: [PATCH 119/398] ui: fix pci map selector status column the 'errors' property changed to 'checks' so we have to adapt here too Signed-off-by: Dominik Csapak --- www/manager6/form/PCIMapSelector.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/www/manager6/form/PCIMapSelector.js b/www/manager6/form/PCIMapSelector.js index 3ded65dc4..4dca62ea3 100644 --- a/www/manager6/form/PCIMapSelector.js +++ b/www/manager6/form/PCIMapSelector.js @@ -43,7 +43,7 @@ Ext.define('PVE.form.PCIMapSelector', { }, { header: gettext('Status'), - dataIndex: 'errors', + dataIndex: 'checks', renderer: function(value) { let me = this; @@ -51,11 +51,11 @@ Ext.define('PVE.form.PCIMapSelector', { return ` ${gettext('Mapping OK')}`; } - let errors = []; + let checks = []; - value.forEach((error) => { + value.forEach((check) => { let iconCls; - switch (error?.severity) { + switch (check?.severity) { case 'warning': iconCls = 'fa-exclamation-circle warning'; break; @@ -64,14 +64,14 @@ Ext.define('PVE.form.PCIMapSelector', { break; } - let message = error?.message; + let message = check?.message; let icon = ``; if (iconCls !== undefined) { - errors.push(`${icon} ${message}`); + checks.push(`${icon} ${message}`); } }); - return errors.join('
'); + return checks.join('
'); }, flex: 3, }, From aafc1f30409e7cae1717fc096d23fe5409229681 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 19 Jun 2023 11:13:31 +0200 Subject: [PATCH 120/398] ui: multi pci selector: reset the selection properly on nodename change and mdev change. giving an empty array did not have the desired effect of resetting the selection, but giving no parameter at all does. this now also clears the selection when the mdev filter/config changed (was just forgotten) Signed-off-by: Dominik Csapak --- www/manager6/form/MultiPCISelector.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/www/manager6/form/MultiPCISelector.js b/www/manager6/form/MultiPCISelector.js index 99f9d50bc..97241bb04 100644 --- a/www/manager6/form/MultiPCISelector.js +++ b/www/manager6/form/MultiPCISelector.js @@ -77,7 +77,7 @@ Ext.define('PVE.form.MultiPCISelector', { } me.suspendEvent('change'); - me.setSelection([]); + me.setSelection(); me.setSelection(recs); me.resumeEvent('change'); }, @@ -96,7 +96,7 @@ Ext.define('PVE.form.MultiPCISelector', { url: '/api2/json/nodes/' + me.nodename + '/hardware/pci?pci-class-blacklist=', }); - me.setSelection([]); + me.setSelection(); me.getStore().load({ callback: (recs, op, success) => me.addSlotRecords(recs, op, success), @@ -115,6 +115,7 @@ Ext.define('PVE.form.MultiPCISelector', { } else { me.getStore().removeFilter('mdev-filter'); } + me.setSelection(); }, // adds the virtual 'slot' records (e.g. '0000:01:00') to the store From b736219f89064356bde9a72c727da780f0d9b12b Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 19 Jun 2023 11:13:32 +0200 Subject: [PATCH 121/398] ui: multi pci selector: indent functions multifunction devices when there is more than one function for a device, indent the individual functions. This sets them visually apart from the 'pass all through as one' entry We have to use a html entity here, as extjs trims the normal whitespace. Signed-off-by: Dominik Csapak --- www/manager6/form/MultiPCISelector.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/www/manager6/form/MultiPCISelector.js b/www/manager6/form/MultiPCISelector.js index 97241bb04..e1ef691ae 100644 --- a/www/manager6/form/MultiPCISelector.js +++ b/www/manager6/form/MultiPCISelector.js @@ -128,16 +128,17 @@ Ext.define('PVE.form.MultiPCISelector', { let slots = {}; records.forEach((rec) => { let slotname = rec.data.id.slice(0, -2); // remove function - rec.set('slot', slotname); if (slots[slotname] !== undefined) { slots[slotname].count++; + rec.set('slot', slots[slotname]); return; } - slots[slotname] = { count: 1, }; + rec.set('slot', slots[slotname]); + if (rec.data.id.endsWith('.0')) { slots[slotname].device = rec.data; } @@ -213,6 +214,12 @@ Ext.define('PVE.form.MultiPCISelector', { { header: 'ID', dataIndex: 'id', + renderer: function(value, _md, rec) { + if (value.match(/\.[0-9a-f]/i) && rec.data.slot?.count > 1) { + return ` ${value}`; + } + return value; + }, width: 150, }, { From bd712824fa05c4595e7bd0d9e530ac5a4de1e70f Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 19 Jun 2023 11:13:33 +0200 Subject: [PATCH 122/398] ui: pci map edit: make top fields more clear by * moving the node to the left column and changing the label * moving the mdev filter to the right column * show only the create button for new node mappings (otherwise we'd have a reset button here that cannot do anything useful) Signed-off-by: Dominik Csapak --- www/manager6/window/PCIMapEdit.js | 38 +++++++++++++++++-------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/www/manager6/window/PCIMapEdit.js b/www/manager6/window/PCIMapEdit.js index 0b6d7d609..516678e09 100644 --- a/www/manager6/window/PCIMapEdit.js +++ b/www/manager6/window/PCIMapEdit.js @@ -13,8 +13,8 @@ Ext.define('PVE.window.PCIMapEditWindow', { cbindData: function(initialConfig) { let me = this; - me.isCreate = !me.name; - me.method = me.isCreate ? 'POST' : 'PUT'; + me.isCreate = !me.name || !me.nodename; + me.method = me.name ? 'PUT' : 'POST'; return { name: me.name, nodename: me.nodename, @@ -23,7 +23,7 @@ Ext.define('PVE.window.PCIMapEditWindow', { submitUrl: function(_url, data) { let me = this; - let name = me.isCreate ? '' : me.name; + let name = me.method === 'PUT' ? me.name : ''; return `/cluster/mapping/pci/${name}`; }, @@ -155,22 +155,9 @@ Ext.define('PVE.window.PCIMapEditWindow', { name: 'id', allowBlank: false, }, - { - xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Mediated Devices'), - labelWidth: 120, - reference: 'mdev', - name: 'mdev', - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, - ], - - column2: [ { xtype: 'pmxDisplayEditField', - fieldLabel: gettext('Node'), + fieldLabel: gettext('Mapping on Node'), labelWidth: 120, name: 'node', editConfig: { @@ -184,6 +171,23 @@ Ext.define('PVE.window.PCIMapEditWindow', { }, ], + column2: [ + { + // as spacer + xtype: 'displayfield', + }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Mediated Devices'), + labelWidth: 120, + reference: 'mdev', + name: 'mdev', + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + ], + columnB: [ { xtype: 'pveMultiPCISelector', From 0eb03eaa510e55cc039954c2b60993f0bcbe4df5 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 19 Jun 2023 11:13:34 +0200 Subject: [PATCH 123/398] ui: resource mappings: remove border and add resize handle aka a 'splitter'. that way the user can determine how much of each panel he wants to see himself Signed-off-by: Dominik Csapak --- www/manager6/dc/Config.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 10a4d83ae..04ed04f04 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -294,19 +294,20 @@ Ext.define('PVE.dc.Config', { }, scrollable: true, defaults: { - collapsible: true, - animCollapse: false, - margin: '7 10 3 10', + border: false, }, items: [ { - collapsible: true, xtype: 'pveDcPCIMapView', title: gettext('PCI Devices'), flex: 1, }, { - collapsible: true, + xtype: 'splitter', + collapsible: false, + performCollapse: false, + }, + { xtype: 'pveDcUSBMapView', title: gettext('USB Devices'), flex: 1, From 575a0e8a4f7bf83064673170927d32f191e7dfbd Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 19 Jun 2023 11:13:34 +0200 Subject: [PATCH 124/398] d/postinst: actively remove pvetest repository (added for beta) again in theory we'd need to be more cautios but this was added only during beta, which is when we do not really provided any stability guarantee, further, it's rather unlikely that one added very important repos that, when removed, break something (again *during* beta). The new APT repo management makes it also easy to see when one does not gets any PVE updates, and one can add the pvetest repo there again easily too. Signed-off-by: Thomas Lamprecht --- debian/postinst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/debian/postinst b/debian/postinst index a685191df..7bfe7e7fb 100755 --- a/debian/postinst +++ b/debian/postinst @@ -160,9 +160,9 @@ case "$1" in # FIXME: remove after beta is over and add hunk to actively remove the repo BETA_SOURCES="/etc/apt/sources.list.d/pvetest-for-beta.list" - if ! test -f "$BETA_SOURCES"; then - echo "Adding pvetest repo to '$BETA_SOURCES' to enable updates during Proxmox VE 8.0 BETA" - echo "deb http://download.proxmox.com/debian/pve bookworm pvetest" | tee "$BETA_SOURCES" + if ! test -f "$BETA_SOURCES" dpkg --compare-versions "$2" 'lt' '8.0.1' && dpkg --compare-versions "$2" 'gt' '8.0~'; then + echo "Removing the during beta added pvetest repository file again" + rm -v "$BETA_SOURCES" || true fi set_lvm_conf From 087a7f66938b6777ef882c10f46dc6dbe97a504b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 09:01:04 +0200 Subject: [PATCH 125/398] Revert "ui: add beta text with link to bugtracker" This reverts commit 44f9ab364d977aed0cc28322dd53678338cd893f. Signed-off-by: Thomas Lamprecht --- www/manager6/Workspace.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js index 3e678dc0f..18d574b75 100644 --- a/www/manager6/Workspace.js +++ b/www/manager6/Workspace.js @@ -335,10 +335,6 @@ Ext.define('PVE.StdWorkspace', { 'line-height': '18px', }, }, - { - padding: 5, - html: 'BETA', - }, { xtype: 'pveGlobalSearchField', tree: rtree, From 27f6d198482e6789b708cec9cb52f3379eb0abe3 Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Thu, 15 Jun 2023 09:39:57 +0200 Subject: [PATCH 126/398] api: ceph: remove deprecrated Pools path The replacement is Pool (singular). Signed-off-by: Aaron Lauterer --- PVE/API2/Ceph.pm | 7 - PVE/API2/Ceph/Makefile | 1 - PVE/API2/Ceph/Pools.pm | 766 ----------------------------------------- 3 files changed, 774 deletions(-) delete mode 100644 PVE/API2/Ceph/Pools.pm diff --git a/PVE/API2/Ceph.pm b/PVE/API2/Ceph.pm index 28df05c39..4893c9570 100644 --- a/PVE/API2/Ceph.pm +++ b/PVE/API2/Ceph.pm @@ -25,7 +25,6 @@ use PVE::API2::Ceph::MDS; use PVE::API2::Ceph::MGR; use PVE::API2::Ceph::MON; use PVE::API2::Ceph::Pool; -use PVE::API2::Ceph::Pools; use PVE::API2::Storage::Config; use base qw(PVE::RESTHandler); @@ -67,12 +66,6 @@ __PACKAGE__->register_method ({ path => 'pool', }); -# TODO: deprecrated, remove with PVE 8 -__PACKAGE__->register_method ({ - subclass => "PVE::API2::Ceph::Pools", - path => 'pools', -}); - __PACKAGE__->register_method ({ name => 'index', path => '', diff --git a/PVE/API2/Ceph/Makefile b/PVE/API2/Ceph/Makefile index f33f3735b..5f3469118 100644 --- a/PVE/API2/Ceph/Makefile +++ b/PVE/API2/Ceph/Makefile @@ -7,7 +7,6 @@ PERLSOURCE= \ OSD.pm \ FS.pm \ Pool.pm \ - Pools.pm \ MDS.pm all: diff --git a/PVE/API2/Ceph/Pools.pm b/PVE/API2/Ceph/Pools.pm deleted file mode 100644 index ffae73b93..000000000 --- a/PVE/API2/Ceph/Pools.pm +++ /dev/null @@ -1,766 +0,0 @@ -package PVE::API2::Ceph::Pools; -# TODO: Deprecated, drop with PVE 8.0! PVE::API2::Ceph::Pool is the replacement - -use strict; -use warnings; - -use PVE::Ceph::Tools; -use PVE::Ceph::Services; -use PVE::JSONSchema qw(get_standard_option parse_property_string); -use PVE::RADOS; -use PVE::RESTHandler; -use PVE::RPCEnvironment; -use PVE::Storage; -use PVE::Tools qw(extract_param); - -use PVE::API2::Storage::Config; - -use base qw(PVE::RESTHandler); - -my $get_autoscale_status = sub { - my ($rados) = shift; - - $rados = PVE::RADOS->new() if !defined($rados); - - my $autoscale = $rados->mon_command({ - prefix => 'osd pool autoscale-status'}); - - my $data; - foreach my $p (@$autoscale) { - $data->{$p->{pool_name}} = $p; - } - - return $data; -}; - - -__PACKAGE__->register_method ({ - name => 'lspools', - path => '', - method => 'GET', - description => "List all pools. Deprecated, please use `/nodes/{node}/ceph/pool`.", - proxyto => 'node', - protected => 1, - permissions => { - check => ['perm', '/', [ 'Sys.Audit', 'Datastore.Audit' ], any => 1], - }, - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - }, - }, - returns => { - type => 'array', - items => { - type => "object", - properties => { - pool => { - type => 'integer', - title => 'ID', - }, - pool_name => { - type => 'string', - title => 'Name', - }, - size => { - type => 'integer', - title => 'Size', - }, - type => { - type => 'string', - title => 'Type', - enum => ['replicated', 'erasure', 'unknown'], - }, - min_size => { - type => 'integer', - title => 'Min Size', - }, - pg_num => { - type => 'integer', - title => 'PG Num', - }, - pg_num_min => { - type => 'integer', - title => 'min. PG Num', - optional => 1, - }, - pg_num_final => { - type => 'integer', - title => 'Optimal PG Num', - optional => 1, - }, - pg_autoscale_mode => { - type => 'string', - title => 'PG Autoscale Mode', - optional => 1, - }, - crush_rule => { - type => 'integer', - title => 'Crush Rule', - }, - crush_rule_name => { - type => 'string', - title => 'Crush Rule Name', - }, - percent_used => { - type => 'number', - title => '%-Used', - }, - bytes_used => { - type => 'integer', - title => 'Used', - }, - target_size => { - type => 'integer', - title => 'PG Autoscale Target Size', - optional => 1, - }, - target_size_ratio => { - type => 'number', - title => 'PG Autoscale Target Ratio', - optional => 1, - }, - autoscale_status => { - type => 'object', - title => 'Autoscale Status', - optional => 1, - }, - application_metadata => { - type => 'object', - title => 'Associated Applications', - optional => 1, - }, - }, - }, - links => [ { rel => 'child', href => "{pool_name}" } ], - }, - code => sub { - my ($param) = @_; - - PVE::Ceph::Tools::check_ceph_inited(); - - my $rados = PVE::RADOS->new(); - - my $stats = {}; - my $res = $rados->mon_command({ prefix => 'df' }); - - foreach my $d (@{$res->{pools}}) { - next if !$d->{stats}; - next if !defined($d->{id}); - $stats->{$d->{id}} = $d->{stats}; - } - - $res = $rados->mon_command({ prefix => 'osd dump' }); - my $rulestmp = $rados->mon_command({ prefix => 'osd crush rule dump'}); - - my $rules = {}; - for my $rule (@$rulestmp) { - $rules->{$rule->{rule_id}} = $rule->{rule_name}; - } - - my $data = []; - my $attr_list = [ - 'pool', - 'pool_name', - 'size', - 'min_size', - 'pg_num', - 'crush_rule', - 'pg_autoscale_mode', - 'application_metadata', - ]; - - # pg_autoscaler module is not enabled in Nautilus - my $autoscale = eval { $get_autoscale_status->($rados) }; - - foreach my $e (@{$res->{pools}}) { - my $d = {}; - foreach my $attr (@$attr_list) { - $d->{$attr} = $e->{$attr} if defined($e->{$attr}); - } - - if ($autoscale) { - $d->{autoscale_status} = $autoscale->{$d->{pool_name}}; - $d->{pg_num_final} = $d->{autoscale_status}->{pg_num_final}; - # some info is nested under options instead - $d->{pg_num_min} = $e->{options}->{pg_num_min}; - $d->{target_size} = $e->{options}->{target_size_bytes}; - $d->{target_size_ratio} = $e->{options}->{target_size_ratio}; - } - - if (defined($d->{crush_rule}) && defined($rules->{$d->{crush_rule}})) { - $d->{crush_rule_name} = $rules->{$d->{crush_rule}}; - } - - if (my $s = $stats->{$d->{pool}}) { - $d->{bytes_used} = $s->{bytes_used}; - $d->{percent_used} = $s->{percent_used}; - } - - # Cephs numerical pool types are barely documented. Found the following in the Ceph - # codebase: https://github.com/ceph/ceph/blob/ff144995a849407c258bcb763daa3e03cfce5059/src/osd/osd_types.h#L1221-L1233 - if ($e->{type} == 1) { - $d->{type} = 'replicated'; - } elsif ($e->{type} == 3) { - $d->{type} = 'erasure'; - } else { - # we should never get here, but better be safe - $d->{type} = 'unknown'; - } - push @$data, $d; - } - - - return $data; - }}); - - -my $ceph_pool_common_options = sub { - my ($nodefault) = shift; - my $options = { - name => { - title => 'Name', - description => "The name of the pool. It must be unique.", - type => 'string', - }, - size => { - title => 'Size', - description => 'Number of replicas per object', - type => 'integer', - default => 3, - optional => 1, - minimum => 1, - maximum => 7, - }, - min_size => { - title => 'Min Size', - description => 'Minimum number of replicas per object', - type => 'integer', - default => 2, - optional => 1, - minimum => 1, - maximum => 7, - }, - pg_num => { - title => 'PG Num', - description => "Number of placement groups.", - type => 'integer', - default => 128, - optional => 1, - minimum => 1, - maximum => 32768, - }, - pg_num_min => { - title => 'min. PG Num', - description => "Minimal number of placement groups.", - type => 'integer', - optional => 1, - maximum => 32768, - }, - crush_rule => { - title => 'Crush Rule Name', - description => "The rule to use for mapping object placement in the cluster.", - type => 'string', - optional => 1, - }, - application => { - title => 'Application', - description => "The application of the pool.", - default => 'rbd', - type => 'string', - enum => ['rbd', 'cephfs', 'rgw'], - optional => 1, - }, - pg_autoscale_mode => { - title => 'PG Autoscale Mode', - description => "The automatic PG scaling mode of the pool.", - type => 'string', - enum => ['on', 'off', 'warn'], - default => 'warn', - optional => 1, - }, - target_size => { - description => "The estimated target size of the pool for the PG autoscaler.", - title => 'PG Autoscale Target Size', - type => 'string', - pattern => '^(\d+(\.\d+)?)([KMGT])?$', - optional => 1, - }, - target_size_ratio => { - description => "The estimated target ratio of the pool for the PG autoscaler.", - title => 'PG Autoscale Target Ratio', - type => 'number', - optional => 1, - }, - }; - - if ($nodefault) { - delete $options->{$_}->{default} for keys %$options; - } - return $options; -}; - - -my $add_storage = sub { - my ($pool, $storeid, $ec_data_pool) = @_; - - my $storage_params = { - type => 'rbd', - pool => $pool, - storage => $storeid, - krbd => 0, - content => 'rootdir,images', - }; - - $storage_params->{'data-pool'} = $ec_data_pool if $ec_data_pool; - - PVE::API2::Storage::Config->create($storage_params); -}; - -my $get_storages = sub { - my ($pool) = @_; - - my $cfg = PVE::Storage::config(); - - my $storages = $cfg->{ids}; - my $res = {}; - foreach my $storeid (keys %$storages) { - my $curr = $storages->{$storeid}; - next if $curr->{type} ne 'rbd'; - $curr->{pool} = 'rbd' if !defined $curr->{pool}; # set default - if ( - $pool eq $curr->{pool} || - (defined $curr->{'data-pool'} && $pool eq $curr->{'data-pool'}) - ) { - $res->{$storeid} = $storages->{$storeid}; - } - } - - return $res; -}; - -my $ec_format = { - k => { - type => 'integer', - description => "Number of data chunks. Will create an erasure coded pool plus a" - ." replicated pool for metadata.", - minimum => 2, - }, - m => { - type => 'integer', - description => "Number of coding chunks. Will create an erasure coded pool plus a" - ." replicated pool for metadata.", - minimum => 1, - }, - 'failure-domain' => { - type => 'string', - description => "CRUSH failure domain. Default is 'host'. Will create an erasure" - ." coded pool plus a replicated pool for metadata.", - format_description => 'domain', - optional => 1, - default => 'host', - }, - 'device-class' => { - type => 'string', - description => "CRUSH device class. Will create an erasure coded pool plus a" - ." replicated pool for metadata.", - format_description => 'class', - optional => 1, - }, - profile => { - description => "Override the erasure code (EC) profile to use. Will create an" - ." erasure coded pool plus a replicated pool for metadata.", - type => 'string', - format_description => 'profile', - optional => 1, - }, -}; - -sub ec_parse_and_check { - my ($property, $rados) = @_; - return if !$property; - - my $ec = parse_property_string($ec_format, $property); - - die "Erasure code profile '$ec->{profile}' does not exist.\n" - if $ec->{profile} && !PVE::Ceph::Tools::ecprofile_exists($ec->{profile}, $rados); - - return $ec; -} - - -__PACKAGE__->register_method ({ - name => 'createpool', - path => '', - method => 'POST', - description => "Create Ceph pool. Deprecated, please use `/nodes/{node}/ceph/pool`.", - proxyto => 'node', - protected => 1, - permissions => { - check => ['perm', '/', [ 'Sys.Modify' ]], - }, - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - add_storages => { - description => "Configure VM and CT storage using the new pool.", - type => 'boolean', - optional => 1, - default => "0; for erasure coded pools: 1", - }, - 'erasure-coding' => { - description => "Create an erasure coded pool for RBD with an accompaning" - ." replicated pool for metadata storage. With EC, the common ceph options 'size'," - ." 'min_size' and 'crush_rule' parameters will be applied to the metadata pool.", - type => 'string', - format => $ec_format, - optional => 1, - }, - %{ $ceph_pool_common_options->() }, - }, - }, - returns => { type => 'string' }, - code => sub { - my ($param) = @_; - - PVE::Cluster::check_cfs_quorum(); - PVE::Ceph::Tools::check_ceph_configured(); - - my $pool = my $name = extract_param($param, 'name'); - my $node = extract_param($param, 'node'); - my $add_storages = extract_param($param, 'add_storages'); - - my $rpcenv = PVE::RPCEnvironment::get(); - my $user = $rpcenv->get_user(); - # Ceph uses target_size_bytes - if (defined($param->{'target_size'})) { - my $target_sizestr = extract_param($param, 'target_size'); - $param->{target_size_bytes} = PVE::JSONSchema::parse_size($target_sizestr); - } - - my $rados = PVE::RADOS->new(); - my $ec = ec_parse_and_check(extract_param($param, 'erasure-coding'), $rados); - $add_storages = 1 if $ec && !defined($add_storages); - - if ($add_storages) { - $rpcenv->check($user, '/storage', ['Datastore.Allocate']); - die "pool name contains characters which are illegal for storage naming\n" - if !PVE::JSONSchema::parse_storage_id($pool); - } - - # pool defaults - $param->{pg_num} //= 128; - $param->{size} //= 3; - $param->{min_size} //= 2; - $param->{application} //= 'rbd'; - $param->{pg_autoscale_mode} //= 'warn'; - - my $worker = sub { - # reopen with longer timeout - $rados = PVE::RADOS->new(timeout => PVE::Ceph::Tools::get_config('long_rados_timeout')); - - if ($ec) { - if (!$ec->{profile}) { - $ec->{profile} = PVE::Ceph::Tools::get_ecprofile_name($pool, $rados); - eval { - PVE::Ceph::Tools::create_ecprofile( - $ec->@{'profile', 'k', 'm', 'failure-domain', 'device-class'}, - $rados, - ); - }; - die "could not create erasure code profile '$ec->{profile}': $@\n" if $@; - print "created new erasure code profile '$ec->{profile}'\n"; - } - - my $ec_data_param = {}; - # copy all params, should be a flat hash - $ec_data_param = { map { $_ => $param->{$_} } keys %$param }; - - $ec_data_param->{pool_type} = 'erasure'; - $ec_data_param->{allow_ec_overwrites} = 'true'; - $ec_data_param->{erasure_code_profile} = $ec->{profile}; - delete $ec_data_param->{size}; - delete $ec_data_param->{min_size}; - delete $ec_data_param->{crush_rule}; - - # metadata pool should be ok with 32 PGs - $param->{pg_num} = 32; - - $pool = "${name}-metadata"; - $ec->{data_pool} = "${name}-data"; - - PVE::Ceph::Tools::create_pool($ec->{data_pool}, $ec_data_param, $rados); - } - - PVE::Ceph::Tools::create_pool($pool, $param, $rados); - - if ($add_storages) { - eval { $add_storage->($pool, "${name}", $ec->{data_pool}) }; - die "adding PVE storage for ceph pool '$name' failed: $@\n" if $@; - } - }; - - return $rpcenv->fork_worker('cephcreatepool', $pool, $user, $worker); - }}); - - -__PACKAGE__->register_method ({ - name => 'destroypool', - path => '{name}', - method => 'DELETE', - description => "Destroy pool. Deprecated, please use `/nodes/{node}/ceph/pool/{name}`.", - proxyto => 'node', - protected => 1, - permissions => { - check => ['perm', '/', [ 'Sys.Modify' ]], - }, - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - name => { - description => "The name of the pool. It must be unique.", - type => 'string', - }, - force => { - description => "If true, destroys pool even if in use", - type => 'boolean', - optional => 1, - default => 0, - }, - remove_storages => { - description => "Remove all pveceph-managed storages configured for this pool", - type => 'boolean', - optional => 1, - default => 0, - }, - remove_ecprofile => { - description => "Remove the erasure code profile. Defaults to true, if applicable.", - type => 'boolean', - optional => 1, - default => 1, - }, - }, - }, - returns => { type => 'string' }, - code => sub { - my ($param) = @_; - - PVE::Ceph::Tools::check_ceph_inited(); - - my $rpcenv = PVE::RPCEnvironment::get(); - my $user = $rpcenv->get_user(); - $rpcenv->check($user, '/storage', ['Datastore.Allocate']) - if $param->{remove_storages}; - - my $pool = $param->{name}; - - my $worker = sub { - my $storages = $get_storages->($pool); - - # if not forced, destroy ceph pool only when no - # vm disks are on it anymore - if (!$param->{force}) { - my $storagecfg = PVE::Storage::config(); - foreach my $storeid (keys %$storages) { - my $storage = $storages->{$storeid}; - - # check if any vm disks are on the pool - print "checking storage '$storeid' for RBD images..\n"; - my $res = PVE::Storage::vdisk_list($storagecfg, $storeid); - die "ceph pool '$pool' still in use by storage '$storeid'\n" - if @{$res->{$storeid}} != 0; - } - } - my $rados = PVE::RADOS->new(); - - my $pool_properties = PVE::Ceph::Tools::get_pool_properties($pool, $rados); - - PVE::Ceph::Tools::destroy_pool($pool, $rados); - - if (my $ecprofile = $pool_properties->{erasure_code_profile}) { - print "found erasure coded profile '$ecprofile', destroying its CRUSH rule\n"; - my $crush_rule = $pool_properties->{crush_rule}; - eval { PVE::Ceph::Tools::destroy_crush_rule($crush_rule, $rados); }; - warn "removing crush rule '${crush_rule}' failed: $@\n" if $@; - - if ($param->{remove_ecprofile} // 1) { - print "destroying erasure coded profile '$ecprofile'\n"; - eval { PVE::Ceph::Tools::destroy_ecprofile($ecprofile, $rados) }; - warn "removing EC profile '${ecprofile}' failed: $@\n" if $@; - } - } - - if ($param->{remove_storages}) { - my $err; - foreach my $storeid (keys %$storages) { - # skip external clusters, not managed by pveceph - next if $storages->{$storeid}->{monhost}; - eval { PVE::API2::Storage::Config->delete({storage => $storeid}) }; - if ($@) { - warn "failed to remove storage '$storeid': $@\n"; - $err = 1; - } - } - die "failed to remove (some) storages - check log and remove manually!\n" - if $err; - } - }; - return $rpcenv->fork_worker('cephdestroypool', $pool, $user, $worker); - }}); - - -__PACKAGE__->register_method ({ - name => 'setpool', - path => '{name}', - method => 'PUT', - description => "Change POOL settings. Deprecated, please use `/nodes/{node}/ceph/pool/{name}`.", - proxyto => 'node', - protected => 1, - permissions => { - check => ['perm', '/', [ 'Sys.Modify' ]], - }, - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - %{ $ceph_pool_common_options->('nodefault') }, - }, - }, - returns => { type => 'string' }, - code => sub { - my ($param) = @_; - - PVE::Ceph::Tools::check_ceph_configured(); - - my $rpcenv = PVE::RPCEnvironment::get(); - my $authuser = $rpcenv->get_user(); - - my $pool = extract_param($param, 'name'); - my $node = extract_param($param, 'node'); - - # Ceph uses target_size_bytes - if (defined($param->{'target_size'})) { - my $target_sizestr = extract_param($param, 'target_size'); - $param->{target_size_bytes} = PVE::JSONSchema::parse_size($target_sizestr); - } - - my $worker = sub { - PVE::Ceph::Tools::set_pool($pool, $param); - }; - - return $rpcenv->fork_worker('cephsetpool', $pool, $authuser, $worker); - }}); - - -__PACKAGE__->register_method ({ - name => 'getpool', - path => '{name}', - method => 'GET', - description => "List pool settings. Deprecated, please use `/nodes/{node}/ceph/pool/{pool}/status`.", - proxyto => 'node', - protected => 1, - permissions => { - check => ['perm', '/', [ 'Sys.Audit', 'Datastore.Audit' ], any => 1], - }, - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - name => { - description => "The name of the pool. It must be unique.", - type => 'string', - }, - verbose => { - type => 'boolean', - default => 0, - optional => 1, - description => "If enabled, will display additional data". - "(eg. statistics).", - }, - }, - }, - returns => { - type => "object", - properties => { - id => { type => 'integer', title => 'ID' }, - pgp_num => { type => 'integer', title => 'PGP num' }, - noscrub => { type => 'boolean', title => 'noscrub' }, - 'nodeep-scrub' => { type => 'boolean', title => 'nodeep-scrub' }, - nodelete => { type => 'boolean', title => 'nodelete' }, - nopgchange => { type => 'boolean', title => 'nopgchange' }, - nosizechange => { type => 'boolean', title => 'nosizechange' }, - write_fadvise_dontneed => { type => 'boolean', title => 'write_fadvise_dontneed' }, - hashpspool => { type => 'boolean', title => 'hashpspool' }, - use_gmt_hitset => { type => 'boolean', title => 'use_gmt_hitset' }, - fast_read => { type => 'boolean', title => 'Fast Read' }, - application_list => { type => 'array', title => 'Application', optional => 1 }, - statistics => { type => 'object', title => 'Statistics', optional => 1 }, - autoscale_status => { type => 'object', title => 'Autoscale Status', optional => 1 }, - %{ $ceph_pool_common_options->() }, - }, - }, - code => sub { - my ($param) = @_; - - PVE::Ceph::Tools::check_ceph_inited(); - - my $verbose = $param->{verbose}; - my $pool = $param->{name}; - - my $rados = PVE::RADOS->new(); - my $res = $rados->mon_command({ - prefix => 'osd pool get', - pool => "$pool", - var => 'all', - }); - - my $data = { - id => $res->{pool_id}, - name => $pool, - size => $res->{size}, - min_size => $res->{min_size}, - pg_num => $res->{pg_num}, - pg_num_min => $res->{pg_num_min}, - pgp_num => $res->{pgp_num}, - crush_rule => $res->{crush_rule}, - pg_autoscale_mode => $res->{pg_autoscale_mode}, - noscrub => "$res->{noscrub}", - 'nodeep-scrub' => "$res->{'nodeep-scrub'}", - nodelete => "$res->{nodelete}", - nopgchange => "$res->{nopgchange}", - nosizechange => "$res->{nosizechange}", - write_fadvise_dontneed => "$res->{write_fadvise_dontneed}", - hashpspool => "$res->{hashpspool}", - use_gmt_hitset => "$res->{use_gmt_hitset}", - fast_read => "$res->{fast_read}", - target_size => $res->{target_size_bytes}, - target_size_ratio => $res->{target_size_ratio}, - }; - - if ($verbose) { - my $stats; - my $res = $rados->mon_command({ prefix => 'df' }); - - # pg_autoscaler module is not enabled in Nautilus - # avoid partial read further down, use new rados instance - my $autoscale_status = eval { $get_autoscale_status->() }; - $data->{autoscale_status} = $autoscale_status->{$pool}; - - foreach my $d (@{$res->{pools}}) { - next if !$d->{stats}; - next if !defined($d->{name}) && !$d->{name} ne "$pool"; - $data->{statistics} = $d->{stats}; - } - - my $apps = $rados->mon_command({ prefix => "osd pool application get", pool => "$pool", }); - $data->{application_list} = [ keys %$apps ]; - } - - return $data; - }}); - - -1; From cf2c8b2f52882fc8b5070ad54e7aab566adbfd3a Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Thu, 15 Jun 2023 09:39:58 +0200 Subject: [PATCH 127/398] api: ceph: remove deprecrated config and configdb endpoints Both are superseeded by ceph/cfg/raw and ceph/cfg/db Signed-off-by: Aaron Lauterer --- PVE/API2/Ceph.pm | 73 ------------------------------------------------ 1 file changed, 73 deletions(-) diff --git a/PVE/API2/Ceph.pm b/PVE/API2/Ceph.pm index 4893c9570..1b765b759 100644 --- a/PVE/API2/Ceph.pm +++ b/PVE/API2/Ceph.pm @@ -116,79 +116,6 @@ __PACKAGE__->register_method ({ return $result; }}); - -# TODO: deprecrated, remove with PVE 8 -__PACKAGE__->register_method ({ - name => 'config', - path => 'config', - method => 'GET', - proxyto => 'node', - permissions => { - check => ['perm', '/', [ 'Sys.Audit', 'Datastore.Audit' ], any => 1], - }, - description => "Get the Ceph configuration file. Deprecated, please use `/nodes/{node}/ceph/cfg/raw.", - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - }, - }, - returns => { type => 'string' }, - code => sub { - my ($param) = @_; - - PVE::Ceph::Tools::check_ceph_inited(); - - my $path = PVE::Ceph::Tools::get_config('pve_ceph_cfgpath'); - return file_get_contents($path); - - }}); - -# TODO: deprecrated, remove with PVE 8 -__PACKAGE__->register_method ({ - name => 'configdb', - path => 'configdb', - method => 'GET', - proxyto => 'node', - protected => 1, - permissions => { - check => ['perm', '/', [ 'Sys.Audit', 'Datastore.Audit' ], any => 1], - }, - description => "Get the Ceph configuration database. Deprecated, please use `/nodes/{node}/ceph/cfg/db.", - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - }, - }, - returns => { - type => 'array', - items => { - type => 'object', - properties => { - section => { type => "string", }, - name => { type => "string", }, - value => { type => "string", }, - level => { type => "string", }, - 'can_update_at_runtime' => { type => "boolean", }, - mask => { type => "string" }, - }, - }, - }, - code => sub { - my ($param) = @_; - - PVE::Ceph::Tools::check_ceph_inited(); - - my $rados = PVE::RADOS->new(); - my $res = $rados->mon_command( { prefix => 'config dump', format => 'json' }); - foreach my $entry (@$res) { - $entry->{can_update_at_runtime} = $entry->{can_update_at_runtime}? 1 : 0; # JSON::true/false -> 1/0 - } - - return $res; - }}); - __PACKAGE__->register_method ({ name => 'init', path => 'init', From 8bbd6a1cbddfebc969507e183aed9179d7e52924 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 09:26:38 +0200 Subject: [PATCH 128/398] update shipped aplliance info index Signed-off-by: Thomas Lamprecht --- aplinfo/aplinfo.dat | 54 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/aplinfo/aplinfo.dat b/aplinfo/aplinfo.dat index 74f773354..54069402f 100644 --- a/aplinfo/aplinfo.dat +++ b/aplinfo/aplinfo.dat @@ -44,6 +44,19 @@ Infopage: https://www.archlinux.org Description: ArchLinux base image. ArchLinux template with the 'base' group and the 'openssh' package installed. +Package: centos-9-stream-default +Version: 20221109 +Type: lxc +OS: centos +Section: system +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: system/centos-9-stream-default_20221109_amd64.tar.xz +md5sum: 13fccdcc2358b795ee613501eb88c850 +sha512sum: 04bb902992f74edf2333d215837e9bb21258dfcdb7bf23bd659176641f6538aeb25bc44286c9caffb10ceb87288ce93668c9410f4a69b8a3b316e09032ead3a8 +Infopage: https://linuxcontainers.org +Description: LXC default image for centos 9-stream (20221109) + Package: debian-11-standard Version: 11.7-1 Type: lxc @@ -58,6 +71,20 @@ Infopage: https://pve.proxmox.com/wiki/Linux_Container#pct_supported_distributio Description: Debian 11 Bullseye (standard) A small Debian Bullseye system including all standard packages. +Package: debian-12-standard +Version: 12.0-1 +Type: lxc +OS: debian-12 +Section: system +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: system/debian-12-standard_12.0-1_amd64.tar.zst +md5sum: 8afe6876381729eef9ce56fd5d2ac5d8 +sha512sum: 0cfbb0cd61fa9e5a11f83e2469e9175be79cab1f24385835715a9c29ea8e517d0526943cd6091b038e1f9d4bf57bfb30ddbc2802d9ada366f0b34019f940a3de +Infopage: https://pve.proxmox.com/wiki/Linux_Container#pct_supported_distributions +Description: Debian 12 Bookworm (standard) + A small Debian Bullseye system including all standard packages. + Package: devuan-4.0-standard Version: 4.0 Type: lxc @@ -85,6 +112,19 @@ sha512sum: 54328a3338ca9657d298a8a5d2ca15fe76f66fd407296d9e3e1c236ee60ea075d3406 Infopage: https://linuxcontainers.org Description: LXC default image for fedora 38 (20230607) +Package: opensuse-15.4-default +Version: 20221109 +Type: lxc +OS: opensuse +Section: system +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: system/opensuse-15.4-default_20221109_amd64.tar.xz +md5sum: 1c66c3549b0684e788c17aa94c384262 +sha512sum: 8089309652a0db23ddff826d1e343e79c6eccb7b615fb309e0a6f6f1983ea697aa94044a795f3cbe35156b1a1b2f60489eb20ecb54c786cec23c9fd89e0f29c5 +Infopage: https://linuxcontainers.org +Description: LXC default image for opensuse 15.4 (20221109) + Package: proxmox-mailgateway-7.3-standard Version: 7.3-1 Type: lxc @@ -112,6 +152,20 @@ sha512sum: ddc2a29ee66598d4c3a4224a0fa9868882e80bbabb7a20ae9f53431bb0ff73e73d4bd Infopage: https://linuxcontainers.org Description: LXC default image for rockylinux 9 (20221109) +Package: ubuntu-20.04-standard +Version: 20.04-1 +Type: lxc +OS: ubuntu-20.04 +Section: system +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: system/ubuntu-20.04-standard_20.04-1_amd64.tar.gz +md5sum: 2ceda507834a0a08ce9662257acb7dde +sha512sum: 9ebcf6cce7c3b760d27c2bfe03d00acf3d40a489cd98230b9ab8dd436f82f4ae8dc2de69b012dcd0affae62ffe4ab4863f624a665f3493983d330e6c015a26dd +Infopage: http://pve.proxmox.com/wiki/Ubuntu_Disco_Standard +Description: Ubuntu Focal (standard) + A small Ubuntu 20.04 Focal Fossa system including all standard packages. + Package: ubuntu-22.04-standard Version: 22.04-1 Type: lxc From f507ec30bb42bee3edae7b9c6ccf60eca9e228c5 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 09:30:09 +0200 Subject: [PATCH 129/398] api ceph: fix directory endpoint index actually drop the deprecated ones from the API routes index and ensure the replacement /pool is returned (/cfg already was) Signed-off-by: Thomas Lamprecht --- PVE/API2/Ceph.pm | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/PVE/API2/Ceph.pm b/PVE/API2/Ceph.pm index 1b765b759..7e0763cf5 100644 --- a/PVE/API2/Ceph.pm +++ b/PVE/API2/Ceph.pm @@ -95,8 +95,6 @@ __PACKAGE__->register_method ({ my $result = [ { name => 'cmd-safety' }, { name => 'cfg' }, - { name => 'config' }, - { name => 'configdb' }, { name => 'crush' }, { name => 'fs' }, { name => 'init' }, @@ -105,7 +103,7 @@ __PACKAGE__->register_method ({ { name => 'mgr' }, { name => 'mon' }, { name => 'osd' }, - { name => 'pools' }, + { name => 'pool' }, { name => 'restart' }, { name => 'rules' }, { name => 'start' }, From 0a571cffc0ab7edb4a4994dc6fe220b257088530 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 21 Jun 2023 09:41:38 +0200 Subject: [PATCH 130/398] ui: resource map tree: make 'ok' status clearer by changing into 'mapping matches host data' which indicates that the configured values matches the host information also for the pci and usb map selectors Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/form/PCIMapSelector.js | 2 +- www/manager6/form/USBMapSelector.js | 2 +- www/manager6/tree/ResourceMapTree.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/www/manager6/form/PCIMapSelector.js b/www/manager6/form/PCIMapSelector.js index 4dca62ea3..1bc73ec05 100644 --- a/www/manager6/form/PCIMapSelector.js +++ b/www/manager6/form/PCIMapSelector.js @@ -48,7 +48,7 @@ Ext.define('PVE.form.PCIMapSelector', { let me = this; if (!Ext.isArray(value) || !value?.length) { - return ` ${gettext('Mapping OK')}`; + return ` ${gettext('Mapping matches host data')}`; } let checks = []; diff --git a/www/manager6/form/USBMapSelector.js b/www/manager6/form/USBMapSelector.js index 990ef30fa..6a33754ac 100644 --- a/www/manager6/form/USBMapSelector.js +++ b/www/manager6/form/USBMapSelector.js @@ -34,7 +34,7 @@ Ext.define('PVE.form.USBMapSelector', { let me = this; if (!Ext.isArray(value) || !value?.length) { - return ` ${gettext('Mapping OK')}`; + return ` ${gettext('Mapping matches host data')}`; } let errors = []; diff --git a/www/manager6/tree/ResourceMapTree.js b/www/manager6/tree/ResourceMapTree.js index df50b63af..027170422 100644 --- a/www/manager6/tree/ResourceMapTree.js +++ b/www/manager6/tree/ResourceMapTree.js @@ -228,7 +228,7 @@ Ext.define('PVE.tree.ResourceMapTree', { } else { let state = value ? 'good' : 'critical'; iconCls = PVE.Utils.get_health_icon(state, true); - status = value ? gettext("OK") : record.data.errmsg || Proxmox.Utils.unknownText; + status = value ? gettext("Mapping matches host data") : record.data.errmsg || Proxmox.Utils.unknownText; } return ` ${status}`; }, From e633ac0f08629378a30fd336bf90087113fdcdca Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 21 Jun 2023 09:41:39 +0200 Subject: [PATCH 131/398] ui: pci map edit: reintroduce warnings checks they got lost in my last rebase/refactor. the onLoadCallBack is used to check by the window if there are iommu groups at all, and the checkIsolated function checks if the selected ones are in a separate group (in regards to the other devices) Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/form/MultiPCISelector.js | 5 ++++ www/manager6/window/PCIMapEdit.js | 41 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/www/manager6/form/MultiPCISelector.js b/www/manager6/form/MultiPCISelector.js index e1ef691ae..d4fb63645 100644 --- a/www/manager6/form/MultiPCISelector.js +++ b/www/manager6/form/MultiPCISelector.js @@ -8,6 +8,9 @@ Ext.define('PVE.form.MultiPCISelector', { field: 'Ext.form.field.Field', }, + // will be called after loading finished + onLoadCallBack: Ext.emptyFn, + getValue: function() { let me = this; return me.value ?? []; @@ -287,6 +290,8 @@ Ext.define('PVE.form.MultiPCISelector', { me.callParent(); + me.mon(me.getStore(), 'load', me.onLoadCallBack); + Proxmox.Utils.monStoreErrors(me, me.getStore(), true); me.setNodename(nodename); diff --git a/www/manager6/window/PCIMapEdit.js b/www/manager6/window/PCIMapEdit.js index 516678e09..8c1a95e32 100644 --- a/www/manager6/window/PCIMapEdit.js +++ b/www/manager6/window/PCIMapEdit.js @@ -70,6 +70,46 @@ Ext.define('PVE.window.PCIMapEditWindow', { me.lookup('iommu_warning').setVisible( records.every((val) => val.data.iommugroup === -1), ); + + let value = me.lookup('pciselector').getValue(); + me.checkIsolated(value); + }, + + checkIsolated: function(value) { + let me = this; + + let store = me.lookup('pciselector').getStore(); + + let isIsolated = function(entry) { + let isolated = true; + let parsed = PVE.Parser.parsePropertyString(entry); + parsed.iommugroup = parseInt(parsed.iommugroup, 10); + if (!parsed.iommugroup) { + return isolated; + } + store.each(({ data }) => { + let isSubDevice = data.id.startsWith(parsed.path); + if (data.iommugroup === parsed.iommugroup && data.id !== parsed.path && !isSubDevice) { + isolated = false; + return false; + } + return true; + }); + return isolated; + }; + + let showWarning = false; + if (Ext.isArray(value)) { + for (const entry of value) { + if (!isIsolated(entry)) { + showWarning = true; + break; + } + } + } else { + showWarning = isIsolated(value); + } + me.lookup('group_warning').setVisible(showWarning); }, mdevChange: function(mdevField, value) { @@ -83,6 +123,7 @@ Ext.define('PVE.window.PCIMapEditWindow', { pciChange: function(_field, value) { let me = this; me.lookup('multiple_warning').setVisible(Ext.isArray(value) && value.length > 1); + me.checkIsolated(value); }, control: { From 746323ce88143ff7a74f06d705369d8223414db2 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 21 Jun 2023 09:41:40 +0200 Subject: [PATCH 132/398] ui: pci/usb map edit: improve new host mappings dialog by disallowing nodes to be selected where a mapping already exists Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/window/PCIMapEdit.js | 9 ++++++++- www/manager6/window/USBMapEdit.js | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/www/manager6/window/PCIMapEdit.js b/www/manager6/window/PCIMapEdit.js index 8c1a95e32..f243362b1 100644 --- a/www/manager6/window/PCIMapEdit.js +++ b/www/manager6/window/PCIMapEdit.js @@ -58,7 +58,13 @@ Ext.define('PVE.window.PCIMapEditWindow', { let me = this; let view = me.getView(); me.originalMap = [...values.map]; - values.map = PVE.Parser.filterPropertyStringList(values.map, (e) => e.node === view.nodename); + let configuredNodes = []; + values.map = PVE.Parser.filterPropertyStringList(values.map, (e) => { + configuredNodes.push(e.node); + return e.node === view.nodename; + }); + + me.lookup('nodeselector').disallowedNodes = configuredNodes; return values; }, @@ -203,6 +209,7 @@ Ext.define('PVE.window.PCIMapEditWindow', { name: 'node', editConfig: { xtype: 'pveNodeSelector', + reference: 'nodeselector', }, cbind: { editable: '{!nodename}', diff --git a/www/manager6/window/USBMapEdit.js b/www/manager6/window/USBMapEdit.js index 80f8e785f..f36f1d034 100644 --- a/www/manager6/window/USBMapEdit.js +++ b/www/manager6/window/USBMapEdit.js @@ -71,13 +71,16 @@ Ext.define('PVE.window.USBMapEditWindow', { let me = this; let view = me.getView(); me.originalMap = [...values.map]; + let configuredNodes = []; PVE.Parser.filterPropertyStringList(values.map, (e) => { + configuredNodes.push(e.node); if (e.node === view.nodename) { values = e; } return false; }); + me.lookup('nodeselector').disallowedNodes = configuredNodes; if (values.path) { values.usb = 'path'; } @@ -145,6 +148,7 @@ Ext.define('PVE.window.USBMapEditWindow', { name: 'node', editConfig: { xtype: 'pveNodeSelector', + reference: 'nodeselector', }, cbind: { editable: '{!nodename}', From 035ce4d4b461d6d8e3abb88405f3d2fb106430cc Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 21 Jun 2023 09:41:41 +0200 Subject: [PATCH 133/398] ui: pci map edit: fix typos in warnings and use gettexts so they can be translated Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/window/PCIMapEdit.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/www/manager6/window/PCIMapEdit.js b/www/manager6/window/PCIMapEdit.js index f243362b1..2b2687199 100644 --- a/www/manager6/window/PCIMapEdit.js +++ b/www/manager6/window/PCIMapEdit.js @@ -163,8 +163,7 @@ Ext.define('PVE.window.PCIMapEditWindow', { hidden: true, columnWidth: 1, padding: '0 0 10 0', - value: 'No IOMMU detected, please activate it.' + - 'See Documentation for further information.', + value: gettext('No IOMMU detected, please activate it. See Documentation for further information.'), userCls: 'pmx-hint', }, { @@ -173,8 +172,7 @@ Ext.define('PVE.window.PCIMapEditWindow', { hidden: true, columnWidth: 1, padding: '0 0 10 0', - value: 'When multiple devices are selected, the first free one will be chosen' + - ' on guest start.', + value: gettext('When multiple devices are selected, the first free one will be chosen on guest start.'), userCls: 'pmx-hint', }, { @@ -184,7 +182,7 @@ Ext.define('PVE.window.PCIMapEditWindow', { columnWidth: 1, padding: '0 0 10 0', itemId: 'iommuwarning', - value: 'The selected Device is not in a seperate IOMMU group, make sure this is intended.', + value: gettext('A selected device is not in a separate IOMMU group, make sure this is intended.'), userCls: 'pmx-hint', }, ], From 08526cf56ec310fcecb6fcab9e71546f3de460c4 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 21 Jun 2023 09:41:42 +0200 Subject: [PATCH 134/398] ui: pci/usb mapping: rework mapping panel for better user experience by removing the confusing buttons in the toolbar and adding them as actions in an actioncolumn. There a only relevant actions are visible and get a more expressive tooltip with this, we now differentiate between 4 modes of the edit window: * create a new mapping altogether - shows all fields * edit existing mapping on top level - show only 'global' fields (comment, mdev), so no mappings * add new host mapping - shows nodeselector, mapping (and mdev, but disabled) (informational only) * edit existing host mapping - show selected node (displayfield) mdev and mappings, but only mappings are editable we have to split the nodeselector into two fields, since the disabling cbind does not pass through to the editconfig (and thus makes the form invalid if we try that) Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/css/ext6-pve.css | 5 + www/manager6/tree/ResourceMapTree.js | 184 +++++++++++++++++---------- www/manager6/window/PCIMapEdit.js | 40 ++++-- www/manager6/window/USBMapEdit.js | 49 +++++-- 4 files changed, 186 insertions(+), 92 deletions(-) diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index 3af642553..edae462b0 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -704,3 +704,8 @@ table.osds td:first-of-type { .x-grid-item .x-item-disabled { opacity: 0.3; } + +.pmx-action-hidden:before { + opacity: 0.0; + cursor: default; +} diff --git a/www/manager6/tree/ResourceMapTree.js b/www/manager6/tree/ResourceMapTree.js index 027170422..4c4769093 100644 --- a/www/manager6/tree/ResourceMapTree.js +++ b/www/manager6/tree/ResourceMapTree.js @@ -49,44 +49,89 @@ Ext.define('PVE.tree.ResourceMapTree', { }); }, - addHost: function() { + add: function(_grid, _rI, _cI, _item, _e, rec) { let me = this; - me.edit(false); + if (rec.data.type !== 'entry') { + return; + } + + me.openMapEditWindow(rec.data.name); }, - edit: function(includeNodename = true) { + editDblClick: function() { let me = this; let view = me.getView(); let selection = view.getSelection(); - if (!selection || !selection.length) { - return; - } - let rec = selection[0]; - if (!view.canConfigure || (rec.data.type === 'entry' && includeNodename)) { + if (!selection || selection.length < 1) { return; } + me.edit(selection[0]); + }, + + editAction: function(_grid, _rI, _cI, _item, _e, rec) { + this.edit(rec); + }, + + edit: function(rec) { + let me = this; + if (rec.data.type === 'map') { + return; + } + + me.openMapEditWindow(rec.data.name, rec.data.node, rec.data.type === 'entry'); + }, + + openMapEditWindow: function(name, nodename, entryOnly) { + let me = this; + let view = me.getView(); + Ext.create(view.editWindowClass, { - url: `${view.baseUrl}/${rec.data.name}`, + url: `${view.baseUrl}/${name}`, autoShow: true, autoLoad: true, - nodename: includeNodename ? rec.data.node : undefined, - name: rec.data.name, + entryOnly, + nodename, + name, listeners: { destroy: () => me.load(), }, }); }, - remove: function() { + remove: function(_grid, _rI, _cI, _item, _e, rec) { + let me = this; + let msg, id; + let view = me.getView(); + let confirmMsg; + switch (rec.data.type) { + case 'entry': + msg = gettext("Are you sure you want to remove '{0}'"); + confirmMsg = Ext.String.format(msg, rec.data.name); + break; + case 'node': + msg = gettext("Are you sure you want to remove '{0}' entries for '{1}'"); + confirmMsg = Ext.String.format(msg, rec.data.node, rec.data.name); + break; + case 'map': + msg = gettext("Are you sure you want to remove '{0}' on '{1}' for '{2}'"); + id = rec.data[view.entryIdProperty]; + confirmMsg = Ext.String.format(msg, id, rec.data.node, rec.data.name); + break; + default: + throw "invalid type"; + } + Ext.Msg.confirm(gettext('Confirm'), confirmMsg, function(btn) { + if (btn === 'yes') { + me.executeRemove(rec.data); + } + }); + }, + + executeRemove: function(data) { let me = this; let view = me.getView(); - let selection = view.getSelection(); - if (!selection || !selection.length) { - return; - } - let data = selection[0].data; let url = `${view.baseUrl}/${data.name}`; let method = 'PUT'; let params = { @@ -233,6 +278,18 @@ Ext.define('PVE.tree.ResourceMapTree', { return ` ${status}`; }, + getAddClass: function(v, mD, rec) { + let cls = 'fa fa-plus-circle'; + if (rec.data.type !== 'entry' || rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length) { + cls += ' pmx-action-hidden'; + } + return cls; + }, + + isAddDisabled: function(v, r, c, i, rec) { + return rec.data.type !== 'entry' || rec.data.children?.length >= PVE.data.ResourceStore.getNodes().length; + }, + init: function(view) { let me = this; @@ -254,63 +311,56 @@ Ext.define('PVE.tree.ResourceMapTree', { tbar: [ { - text: gettext('Add mapping'), + text: gettext('Add'), handler: 'addMapping', cbind: { disabled: '{!canConfigure}', }, }, - { - xtype: 'proxmoxButton', - text: gettext('New Host mapping'), - disabled: true, - parentXType: 'treepanel', - enableFn: function(_rec) { - return this.up('treepanel').canConfigure; - }, - handler: 'addHost', - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit'), - disabled: true, - parentXType: 'treepanel', - enableFn: function(rec) { - return rec && rec.data.type !== 'entry' && this.up('treepanel').canConfigure; - }, - handler: 'edit', - }, - { - xtype: 'proxmoxButton', - parentXType: 'treepanel', - handler: 'remove', - disabled: true, - text: gettext('Remove'), - enableFn: function(rec) { - return rec && this.up('treepanel').canConfigure; - }, - confirmMsg: function(rec) { - let msg, id; - let view = this.up('treepanel'); - switch (rec.data.type) { - case 'entry': - msg = gettext("Are you sure you want to remove '{0}'"); - return Ext.String.format(msg, rec.data.name); - case 'node': - msg = gettext("Are you sure you want to remove '{0}' entries for '{1}'"); - return Ext.String.format(msg, rec.data.node, rec.data.name); - case 'map': - msg = gettext("Are you sure you want to remove '{0}' on '{1}' for '{2}'"); - id = rec.data[view.entryIdProperty]; - return Ext.String.format(msg, id, rec.data.node, rec.data.name); - default: - throw "invalid type"; - } - }, - }, ], listeners: { - itemdblclick: 'edit', + itemdblclick: 'editDblClick', + }, + + initComponent: function() { + let me = this; + + let columns = [...me.columns]; + columns.splice(1, 0, { + xtype: 'actioncolumn', + text: gettext('Actions'), + width: 80, + items: [ + { + getTip: (v, m, { data }) => + Ext.String.format(gettext("Add new host mapping for '{0}'"), data.name), + getClass: 'getAddClass', + isActionDisabled: 'isAddDisabled', + handler: 'add', + }, + { + iconCls: 'fa fa-pencil', + getTip: (v, m, { data }) => data.type === 'entry' + ? Ext.String.format(gettext("Edit Mapping '{0}'"), data.name) + : Ext.String.format(gettext("Edit Mapping '{0}' for '{1}'"), data.name, data.node), + getClass: (v, m, { data }) => data.type !== 'map' ? 'fa fa-pencil' : 'pmx-hidden', + isActionDisabled: (v, r, c, i, rec) => rec.data.type === 'map', + handler: 'editAction', + }, + { + iconCls: 'fa fa-trash-o', + getTip: (v, m, { data }) => data.type === 'entry' + ? Ext.String.format(gettext("Remove '{0}'"), data.name) + : data.type === 'node' + ? Ext.String.format(gettext("Remove mapping for '{0}'"), data.node) + : Ext.String.format(gettext("Remove mapping '{0}'"), data.path), + handler: 'remove', + }, + ], + }); + me.columns = columns; + + me.callParent(); }, }); diff --git a/www/manager6/window/PCIMapEdit.js b/www/manager6/window/PCIMapEdit.js index 2b2687199..d43f04eb2 100644 --- a/www/manager6/window/PCIMapEdit.js +++ b/www/manager6/window/PCIMapEdit.js @@ -13,8 +13,12 @@ Ext.define('PVE.window.PCIMapEditWindow', { cbindData: function(initialConfig) { let me = this; - me.isCreate = !me.name || !me.nodename; + me.isCreate = (!me.name || !me.nodename) && !me.entryOnly; me.method = me.name ? 'PUT' : 'POST'; + me.hideMapping = !!me.entryOnly; + me.hideComment = me.name && !me.entryOnly; + me.hideNodeSelector = me.nodename || me.entryOnly; + me.hideNode = !me.nodename || !me.hideNodeSelector; return { name: me.name, nodename: me.nodename, @@ -201,35 +205,41 @@ Ext.define('PVE.window.PCIMapEditWindow', { allowBlank: false, }, { - xtype: 'pmxDisplayEditField', + xtype: 'displayfield', fieldLabel: gettext('Mapping on Node'), labelWidth: 120, name: 'node', - editConfig: { - xtype: 'pveNodeSelector', - reference: 'nodeselector', - }, cbind: { - editable: '{!nodename}', value: '{nodename}', + disabled: '{hideNode}', + hidden: '{hideNode}', + }, + allowBlank: false, + }, + { + xtype: 'pveNodeSelector', + reference: 'nodeselector', + fieldLabel: gettext('Mapping on Node'), + labelWidth: 120, + name: 'node', + cbind: { + disabled: '{hideNodeSelector}', + hidden: '{hideNodeSelector}', }, allowBlank: false, }, ], column2: [ - { - // as spacer - xtype: 'displayfield', - }, { xtype: 'proxmoxcheckbox', - fieldLabel: gettext('Mediated Devices'), - labelWidth: 120, + fieldLabel: gettext('Use with Mediated Devices'), + labelWidth: 200, reference: 'mdev', name: 'mdev', cbind: { deleteEmpty: '{!isCreate}', + disabled: '{hideComment}', }, }, ], @@ -244,6 +254,8 @@ Ext.define('PVE.window.PCIMapEditWindow', { name: 'map', cbind: { nodename: '{nodename}', + disabled: '{hideMapping}', + hidden: '{hideMapping}', }, allowBlank: false, onLoadCallBack: 'checkIommu', @@ -257,6 +269,8 @@ Ext.define('PVE.window.PCIMapEditWindow', { name: 'description', cbind: { deleteEmpty: '{!isCreate}', + disabled: '{hideComment}', + hidden: '{hideComment}', }, }, ], diff --git a/www/manager6/window/USBMapEdit.js b/www/manager6/window/USBMapEdit.js index f36f1d034..358f0778b 100644 --- a/www/manager6/window/USBMapEdit.js +++ b/www/manager6/window/USBMapEdit.js @@ -7,6 +7,10 @@ Ext.define('PVE.window.USBMapEditWindow', { let me = this; me.isCreate = !me.name; me.method = me.isCreate ? 'POST' : 'PUT'; + me.hideMapping = !!me.entryOnly; + me.hideComment = me.name && !me.entryOnly; + me.hideNodeSelector = me.nodename || me.entryOnly; + me.hideNode = !me.nodename || !me.hideNodeSelector; return { name: me.name, nodename: me.nodename, @@ -53,12 +57,14 @@ Ext.define('PVE.window.USBMapEditWindow', { if (me.originalMap) { map = PVE.Parser.filterPropertyStringList(me.originalMap, (e) => e.node !== values.node); } - map.push(PVE.Parser.printPropertyString(values)); + if (values.id) { + map.push(PVE.Parser.printPropertyString(values)); + } - values = { - map, - description, - }; + values = { map }; + if (description) { + values.description = description; + } if (view.isCreate) { values.id = name; @@ -143,16 +149,26 @@ Ext.define('PVE.window.USBMapEditWindow', { allowBlank: false, }, { - xtype: 'pmxDisplayEditField', - fieldLabel: gettext('Node'), + xtype: 'displayfield', + fieldLabel: gettext('Mapping on Node'), + labelWidth: 120, name: 'node', - editConfig: { - xtype: 'pveNodeSelector', - reference: 'nodeselector', - }, cbind: { - editable: '{!nodename}', value: '{nodename}', + disabled: '{hideNode}', + hidden: '{hideNode}', + }, + allowBlank: false, + }, + { + xtype: 'pveNodeSelector', + reference: 'nodeselector', + fieldLabel: gettext('Mapping on Node'), + labelWidth: 120, + name: 'node', + cbind: { + disabled: '{hideNodeSelector}', + hidden: '{hideNodeSelector}', }, allowBlank: false, }, @@ -163,6 +179,10 @@ Ext.define('PVE.window.USBMapEditWindow', { xtype: 'fieldcontainer', defaultType: 'radiofield', layout: 'fit', + cbind: { + disabled: '{hideMapping}', + hidden: '{hideMapping}', + }, items: [ { name: 'usb', @@ -178,6 +198,7 @@ Ext.define('PVE.window.USBMapEditWindow', { name: 'id', cbind: { nodename: '{nodename}', + disabled: '{hideMapping}', }, editable: true, allowBlank: false, @@ -214,6 +235,10 @@ Ext.define('PVE.window.USBMapEditWindow', { fieldLabel: gettext('Comment'), submitValue: true, name: 'description', + cbind: { + disabled: '{hideComment}', + hidden: '{hideComment}', + }, }, ], }, From 89eebc0c90605ad74d41f425f2cef17a84ee6622 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 09:35:36 +0200 Subject: [PATCH 135/398] bump version to 8.0.0 Signed-off-by: Thomas Lamprecht --- debian/changelog | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/debian/changelog b/debian/changelog index a4dc5c0ee..b40829ff9 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,36 @@ +pve-manager (8.0.0) bookworm; urgency=medium + + * api: ceph: remove deprecated API endpoints that have a more modern + replacement since Proxmox VE 7.4: + - ceph/pools got superseded by a more flexible ceph/pool one + - ceph/config and ceph/configdb endpoints got superseded by ceph/cfg/raw + and ceph/cfg/db, respectively + + * ui: fix pci map selector status column + + * ui: multi pci selector: reset the selection properly on nodename change + + * ui: multi pci selector: indent functions multifunction devices + + * ui: pci map edit: make top fields more clear + + * ui: resource mappings: remove border and add resize handle + + * ui: remove beta text again + + * ui: resource map tree: make 'ok' status clearer + + * ui: pci map edit: reintroduce warnings checks + + * ui: pci/usb map edit: improve new host mappings dialog + + * ui: pci/usb mapping: rework mapping panel for better user experience + + * d/postinst: actively remove pvetest repository (added for beta) again if + upgrading from a beta version and if the dedicated file exists. + + -- Proxmox Support Team Wed, 21 Jun 2023 09:21:16 +0200 + pve-manager (8.0.0~9) bookworm; urgency=medium * api: cluster resources: add 'localnetwork' referring to local network From f194715b9de65b638a548fb8f203dfc7b86aaab3 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 11:08:13 +0200 Subject: [PATCH 136/398] d/postins: fix condition for removal of pvetest added during beta Reported-by: Stoiko Ivanov Signed-off-by: Thomas Lamprecht --- debian/postinst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/postinst b/debian/postinst index 7bfe7e7fb..81eaaa212 100755 --- a/debian/postinst +++ b/debian/postinst @@ -160,7 +160,7 @@ case "$1" in # FIXME: remove after beta is over and add hunk to actively remove the repo BETA_SOURCES="/etc/apt/sources.list.d/pvetest-for-beta.list" - if ! test -f "$BETA_SOURCES" dpkg --compare-versions "$2" 'lt' '8.0.1' && dpkg --compare-versions "$2" 'gt' '8.0~'; then + if ! test -f "$BETA_SOURCES" && dpkg --compare-versions "$2" 'lt' '8.0.1' && dpkg --compare-versions "$2" 'gt' '8.0~'; then echo "Removing the during beta added pvetest repository file again" rm -v "$BETA_SOURCES" || true fi From 895abeec1b27ebfc2dda3c50cbbacc5cf775b45f Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 21 Jun 2023 12:04:33 +0200 Subject: [PATCH 137/398] ui: cloudinit: align default value for package upgrades with backend again The default in Proxmox VE 7 was true and it was decided to keep that and avoid a breaking change. Signed-off-by: Fiona Ebner --- www/manager6/qemu/CloudInit.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/qemu/CloudInit.js b/www/manager6/qemu/CloudInit.js index 0e06d9621..03d06d9c5 100644 --- a/www/manager6/qemu/CloudInit.js +++ b/www/manager6/qemu/CloudInit.js @@ -290,7 +290,7 @@ Ext.define('PVE.qemu.CloudInit', { header: gettext('Upgrade packages'), iconCls: 'fa fa-archive', renderer: Proxmox.Utils.format_boolean, - defaultValue: '', + defaultValue: 1, editor: { xtype: 'proxmoxWindowEdit', subject: gettext('Upgrade packages on boot'), @@ -298,7 +298,7 @@ Ext.define('PVE.qemu.CloudInit', { xtype: 'proxmoxcheckbox', name: 'ciupgrade', uncheckedValue: 0, - defaultValue: 0, + value: 1, // serves as default value, using defaultValue is not enough fieldLabel: gettext('Upgrade packages'), labelWidth: 140, }, From d958b1306fc8b83629b8584e23fad7989087dab8 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 21 Jun 2023 12:05:09 +0200 Subject: [PATCH 138/398] api: resource usb mapping: add missing proxyto_callback i have added it to the pci api call, but forgot to add it for usb otherwise adding a mapped usb device only works on the node where the gui is connected to Signed-off-by: Dominik Csapak --- PVE/API2/Cluster/Mapping/USB.pm | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/PVE/API2/Cluster/Mapping/USB.pm b/PVE/API2/Cluster/Mapping/USB.pm index 5495bce27..763d5c2ba 100644 --- a/PVE/API2/Cluster/Mapping/USB.pm +++ b/PVE/API2/Cluster/Mapping/USB.pm @@ -15,6 +15,11 @@ __PACKAGE__->register_method ({ name => 'index', path => '', method => 'GET', + # only proxy if we give the 'check-node' parameter + proxyto_callback => sub { + my ($rpcenv, $proxyto, $param) = @_; + return $param->{'check-node'} // 'localhost'; + }, description => "List USB Hardware Mappings", permissions => { description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or". From 4e7c7d58823b484b719ebc515335dc8b3e8c74a8 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 16:00:11 +0200 Subject: [PATCH 139/398] bump version to 8.0.1 Signed-off-by: Thomas Lamprecht --- debian/changelog | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/debian/changelog b/debian/changelog index b40829ff9..e15b99b3d 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,14 @@ +pve-manager (8.0.1) bookworm; urgency=medium + + * d/postinst: fix condition for removal of pvetest added during beta + + * ui: cloudinit: align default value for package upgrades with backend again + + * api: resource usb mapping: add missing proxy-to-node callback for advanced + checks + + -- Proxmox Support Team Wed, 21 Jun 2023 16:00:04 +0200 + pve-manager (8.0.0) bookworm; urgency=medium * api: ceph: remove deprecated API endpoints that have a more modern From 2f11eee57592f47dc0ee3ba113d596e850ee402f Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 17:06:31 +0200 Subject: [PATCH 140/398] d/postinst: remove beta sources for real Signed-off-by: Thomas Lamprecht --- debian/postinst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/postinst b/debian/postinst index 81eaaa212..4c9a1f250 100755 --- a/debian/postinst +++ b/debian/postinst @@ -160,7 +160,7 @@ case "$1" in # FIXME: remove after beta is over and add hunk to actively remove the repo BETA_SOURCES="/etc/apt/sources.list.d/pvetest-for-beta.list" - if ! test -f "$BETA_SOURCES" && dpkg --compare-versions "$2" 'lt' '8.0.1' && dpkg --compare-versions "$2" 'gt' '8.0~'; then + if test -f "$BETA_SOURCES" && dpkg --compare-versions "$2" 'lt' '8.0.2' && dpkg --compare-versions "$2" 'gt' '8.0~'; then echo "Removing the during beta added pvetest repository file again" rm -v "$BETA_SOURCES" || true fi From c442e671301723a45dab49679e13ad16f49dfba7 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 17:06:54 +0200 Subject: [PATCH 141/398] bump version to 8.0.2 Signed-off-by: Thomas Lamprecht --- debian/changelog | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/changelog b/debian/changelog index e15b99b3d..6ba3f5a41 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,4 +1,4 @@ -pve-manager (8.0.1) bookworm; urgency=medium +pve-manager (8.0.2) bookworm; urgency=medium * d/postinst: fix condition for removal of pvetest added during beta @@ -7,7 +7,7 @@ pve-manager (8.0.1) bookworm; urgency=medium * api: resource usb mapping: add missing proxy-to-node callback for advanced checks - -- Proxmox Support Team Wed, 21 Jun 2023 16:00:04 +0200 + -- Proxmox Support Team Wed, 21 Jun 2023 17:06:43 +0200 pve-manager (8.0.0) bookworm; urgency=medium From 5eb250e2f872c97d24ec20b4e8b5718cc3e5edc6 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 18:02:53 +0200 Subject: [PATCH 142/398] ui: use common gettext for IOMMU-Group Signed-off-by: Thomas Lamprecht --- www/manager6/dc/PCIMapView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/dc/PCIMapView.js b/www/manager6/dc/PCIMapView.js index 3efa19d8c..859ef58fa 100644 --- a/www/manager6/dc/PCIMapView.js +++ b/www/manager6/dc/PCIMapView.js @@ -85,7 +85,7 @@ Ext.define('PVE.dc.PCIMapView', { dataIndex: 'subsystem-id', }, { - text: gettext('IOMMU group'), + text: gettext('IOMMU-Group'), dataIndex: 'iommugroup', }, { From 1f8f027290410fc207656774a0276646536f3593 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 21 Jun 2023 18:19:48 +0200 Subject: [PATCH 143/398] pve7to8: add reminder comment for noout_wanted variable Signed-off-by: Fiona Ebner --- PVE/CLI/pve7to8.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 6b51e98eb..d988c7153 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -542,6 +542,7 @@ sub check_ceph { log_warn("unable to determine overall Ceph daemon versions!"); } elsif (keys %$overall_versions == 1) { log_pass("single running overall version detected for all Ceph daemon types."); + # TODO: needs to be set to 1 in the stable branch each time! - find better solution? $noout_wanted = 0; # off post-upgrade, on pre-upgrade } elsif (keys $ceph_versions_simple->{overall}->%* != 1) { log_warn("overall version mismatch detected, check 'ceph versions' output for details!"); From 2a7f4f7111b9e9d414808af11d627fab051ef1d5 Mon Sep 17 00:00:00 2001 From: Friedrich Weber Date: Thu, 15 Jun 2023 15:22:31 +0200 Subject: [PATCH 144/398] pve7to8: content-dirs check: skip paths that cannot be resolved The current inequality check for content-dirs does not correctly handle the case in which `abs_path` returns undef. This can result in confusing warnings: storage [...] uses directory for multiple content types [...] Fix this by skipping paths for which `abs_path` returns undef. This matches the behavior of the actual content-dirs check in PVE 8 [0]. [0]: https://git.proxmox.com/?p=pve-storage.git;a=commit;h=09f1f847a Fixes: ea0a4f1943ffafe94282afc800d5720db45df198 Signed-off-by: Friedrich Weber (cherry picked from commit 20fb9aa3f15f9d3ef89bfc3784d72a791c55b757) Signed-off-by: Thomas Lamprecht --- PVE/CLI/pve7to8.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index d988c7153..27cef7841 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -986,6 +986,7 @@ sub check_storage_content_dirs { my $plugin = PVE::Storage::Plugin->lookup($scfg->{type}); for my $vtype (keys $scfg->{content}->%*) { my $abs_subdir = Cwd::abs_path($plugin->get_subdir($scfg, $vtype)); + next if !defined($abs_subdir); push $resolved_subdirs->{$abs_subdir}->@*, $vtype; } for my $subdir (keys $resolved_subdirs->%*) { From e63cea8930306bea672955bf95b97afae301697a Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 21 Jun 2023 17:02:00 +0200 Subject: [PATCH 145/398] pve7to8: remove outdated warning about retention It just talks about the default behavior since PVE 7. It's rather confusing to mention this, because the behavior doesn't change anymore in PVE 8. Signed-off-by: Fiona Ebner (cherry picked from commit b58348b1b60c6546b14323ec9303c91d5b1a29b7) Signed-off-by: Thomas Lamprecht --- PVE/CLI/pve7to8.pm | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 27cef7841..c083b95e2 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -609,8 +609,6 @@ sub check_backup_retention_settings { my $pass = 1; - my $node_has_retention; - my $maxfiles_msg = "parameter 'maxfiles' is deprecated with PVE 7.x and will be removed in a " . "future version, use 'prune-backups' instead."; @@ -627,8 +625,6 @@ sub check_backup_retention_settings { $pass = 0; log_warn("$fn - $maxfiles_msg"); } - - $node_has_retention = defined($param->{maxfiles}) || defined($param->{'prune-backups'}); }; if (my $err = $@) { $pass = 0; @@ -644,15 +640,6 @@ sub check_backup_retention_settings { $pass = 0; log_warn("storage '$storeid' - $maxfiles_msg"); } - - next if !$scfg->{content}->{backup}; - next if defined($scfg->{maxfiles}) || defined($scfg->{'prune-backups'}); - next if $node_has_retention; - - log_info( - "storage '$storeid' - no backup retention settings defined - by default, since PVE 7.0" - ." it will no longer keep only the last backup, but all backups" - ); } eval { From daf74a20fd96bb8e9a418d9e06fea32a453ea5ff Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Wed, 21 Jun 2023 17:02:01 +0200 Subject: [PATCH 146/398] pve7to8: avoid confusing warning about required setting 'storage' for vzdump It's required in the schema for notes-template and protected, but when parsing vzdump.conf, it shouldn't matter whether the storage parameter is set or not. The warning is ugly and users might interpret it as something that needs to be acted upon for the upgrade: parse error in '/etc/vzdump.conf' - 'storage': missing property - 'notes-template' requires this property\nmissing property - 'protected' requires this property Signed-off-by: Fiona Ebner (cherry picked from commit 517abd0cd28a613598e2a3a8d5d8d057578c14b6) Signed-off-by: Thomas Lamprecht --- PVE/CLI/pve7to8.pm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index c083b95e2..29bb099dd 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -614,6 +614,8 @@ sub check_backup_retention_settings { eval { my $confdesc = PVE::VZDump::Common::get_confdesc(); + # vzdump.conf by itself doesn't need to honor any 'requires' + delete $confdesc->{$_}->{requires} for keys $confdesc->%*; my $fn = "/etc/vzdump.conf"; my $raw = PVE::Tools::file_get_contents($fn); From 35069cdbdc6da9cbc424abfe17ebae111fa71720 Mon Sep 17 00:00:00 2001 From: Stoiko Ivanov Date: Wed, 21 Jun 2023 19:35:57 +0200 Subject: [PATCH 147/398] pve7to8: add check for systemd-boot presence where needed since the package won't get installed for systems upgraded from 7 to 8 we warn users who need systemd-boot - to be able to initialize new ESPs - that they need to install it The check for package installation is based on existance of the changelog, since the package information used in pve7to8 comes from the API-modules, which limit it to the pve-relevant packages. tested in VMs with uefi and legacy mode, with existing proxmox-boot-uuids both with and w/o systemd-boot being installed Signed-off-by: Stoiko Ivanov --- PVE/CLI/pve7to8.pm | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 29bb099dd..712deb207 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -1229,6 +1229,34 @@ sub check_time_sync { } } +sub check_bootloader { + log_info("Checking bootloader configuration..."); + if (!$upgraded) { + log_skip("not yet upgraded, no need to check the presence of systemd-boot"); + return; + } + + if (! -f "/etc/kernel/proxmox-boot-uuids") { + log_skip("proxmox-boot-tool not used for bootloader configuration"); + return; + } + + if (! -d "/sys/firmware/efi") { + log_skip("System booted in legacy-mode - no need for systemd-boot"); + return; + } + + if ( -f "/usr/share/doc/systemd-boot/changelog.Debian.gz") { + log_pass("systemd-boot is installed"); + } else { + log_warn( + "proxmox-boot-tool is used for bootloader configuration in uefi mode" + . "but the separate systemd-boot package, existing in Debian Bookworm is not installed" + . "initializing new ESPs will not work until the package is installed" + ); + } +} + sub check_misc { print_header("MISCELLANEOUS CHECKS"); my $ssh_config = eval { PVE::Tools::file_get_contents('/root/.ssh/config') }; @@ -1328,6 +1356,7 @@ sub check_misc { check_lxcfs_fuse_version(); check_node_and_guest_configurations(); check_apt_repos(); + check_bootloader(); } my sub colored_if { From ffa79167b06d52d5db4e115b30af66c5c061c521 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 19:52:59 +0200 Subject: [PATCH 148/398] pve7to8: sync over from stable-7 branch Signed-off-by: Thomas Lamprecht --- PVE/CLI/pve7to8.pm | 74 +++++++++++++++++++++++++++++++++------------- 1 file changed, 53 insertions(+), 21 deletions(-) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 712deb207..2cb486029 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -109,7 +109,7 @@ sub print_header { } my $get_systemd_unit_state = sub { - my ($unit, $surpress_stderr) = @_; + my ($unit, $suppress_stderr) = @_; my $state; my $filter_output = sub { @@ -118,7 +118,7 @@ my $get_systemd_unit_state = sub { }; my %extra = (outfunc => $filter_output, noerr => 1); - $extra{errfunc} = sub { } if $surpress_stderr; + $extra{errfunc} = sub { } if $suppress_stderr; eval { run_command(['systemctl', 'is-enabled', "$unit"], %extra); @@ -198,7 +198,8 @@ sub check_pve_packages { log_fail("proxmox-ve package is too old, please upgrade to >= $min_pve_ver!"); } - my ($krunning, $kinstalled) = (qr/6\.(?:2|5)/, 'pve-kernel-6.2'); + # FIXME: better differentiate between 6.2 from bullseye or bookworm + my ($krunning, $kinstalled) = (qr/6\.(?:2\.(?:[2-9]\d+|1[6-8]|1\d\d+)|5)[^~]*$/, 'pve-kernel-6.2'); if (!$upgraded) { # we got a few that avoided 5.15 in cluster with mixed CPUs, so allow older too ($krunning, $kinstalled) = (qr/(?:5\.(?:13|15)|6\.2)/, 'pve-kernel-5.15'); @@ -251,7 +252,7 @@ sub check_storage_health { my $info = PVE::Storage::storage_info($cfg); - foreach my $storeid (sort keys %$info) { + for my $storeid (sort keys %$info) { my $d = $info->{$storeid}; if ($d->{enabled}) { if ($d->{active}) { @@ -519,7 +520,7 @@ sub check_ceph { } } - foreach my $service (@$services) { + for my $service (@$services) { my ($name, $key) = $service->@{'name', 'key'}; if (my $service_versions = $ceph_versions_simple->{$key}) { if (keys %$service_versions == 0) { @@ -685,7 +686,7 @@ sub check_cifs_credential_location { } sub check_custom_pool_roles { - log_info("Checking custom role IDs for clashes with new 'PVE' namespace.."); + log_info("Checking permission system changes.."); if (! -f "/etc/pve/user.cfg") { log_skip("user.cfg does not exist"); @@ -703,27 +704,38 @@ sub check_custom_pool_roles { my $line = $1; my @data; - foreach my $d (split (/:/, $line)) { + for my $d (split (/:/, $line)) { $d =~ s/^\s+//; $d =~ s/\s+$//; push @data, $d } my $et = shift @data; - next if $et ne 'role'; + if ($et eq 'role') { + my ($role, $privlist) = @data; + if (!PVE::AccessControl::verify_rolename($role, 1)) { + warn "user config - ignore role '$role' - invalid characters in role name\n"; + next; + } - my ($role, $privlist) = @data; - if (!PVE::AccessControl::verify_rolename($role, 1)) { - warn "user config - ignore role '$role' - invalid characters in role name\n"; - next; - } - - $roles->{$role} = {} if !$roles->{$role}; - foreach my $priv (split_list($privlist)) { - $roles->{$role}->{$priv} = 1; + $roles->{$role} = {} if !$roles->{$role}; + for my $priv (split_list($privlist)) { + $roles->{$role}->{$priv} = 1; + } + } elsif ($et eq 'acl') { + my ($propagate, $pathtxt, $uglist, $rolelist) = @data; + for my $role (split_list($rolelist)) { + if ($role eq 'PVESysAdmin' || $role eq 'PVEAdmin') { + log_warn( + "found ACL entry on '$pathtxt' for '$uglist' with role '$role' - this role" + ." will no longer have 'Permissions.Modify' after the upgrade!" + ); + } + } } } + log_info("Checking custom role IDs for clashes with new 'PVE' namespace.."); my ($custom_roles, $pve_namespace_clashes) = (0, 0); for my $role (sort keys %{$roles}) { next if PVE::AccessControl::role_is_special($role); @@ -1006,9 +1018,12 @@ sub check_containers_cgroup_compat { my $get_systemd_version = sub { my ($self) = @_; - my $sd_lib_dir = -d "/lib/systemd" ? "/lib/systemd" : "/usr/lib/systemd"; - my $libsd = PVE::Tools::dir_glob_regex($sd_lib_dir, "libsystemd-shared-.+\.so"); - if (defined($libsd) && $libsd =~ /libsystemd-shared-(\d+)\.so/) { + my $libsd; + for my $dir ('/lib/systemd', '/usr/lib/systemd', '/usr/lib/x86_64-linux-gnu/systemd') { + $libsd = PVE::Tools::dir_glob_regex($dir, "libsystemd-shared-.+\.so"); + last if defined($libsd); + } + if (defined($libsd) && $libsd =~ /libsystemd-shared-(\d+)(\.\d-\d)?\.so/) { return $1; } @@ -1206,6 +1221,22 @@ sub check_apt_repos { } } +sub check_nvidia_vgpu_service { + log_info("Checking for existence of NVIDIA vGPU Manager.."); + + my $msg = "NVIDIA vGPU Service found, possibly not compatible with newer kernel versions, check" + ." with their documentation and https://pve.proxmox.com/wiki/Upgrade_from_7_to_8#Known_upgrade_issues."; + + my $state = $get_systemd_unit_state->("nvidia-vgpu-mgr.service", 1); + if ($state && $state eq 'active') { + log_warn("Running $msg"); + } elsif ($state && $state ne 'unknown') { + log_warn($msg); + } else { + log_pass("No NVIDIA vGPU Service found."); + } +} + sub check_time_sync { my $unit_active = sub { return $get_systemd_unit_state->($_[0], 1) eq 'active' ? $_[0] : undef }; @@ -1329,7 +1360,7 @@ sub check_misc { }; my $certs_check_failed = 0; - foreach my $cert (@$certs) { + for my $cert (@$certs) { my ($type, $size, $fn) = $cert->@{qw(public-key-type public-key-bits filename)}; if (!defined($type) || !defined($size)) { @@ -1356,6 +1387,7 @@ sub check_misc { check_lxcfs_fuse_version(); check_node_and_guest_configurations(); check_apt_repos(); + check_nvidia_vgpu_service(); check_bootloader(); } From bbf3993334bfa9163a5b59be1eb0f4e58ac22cc1 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 21 Jun 2023 19:55:58 +0200 Subject: [PATCH 149/398] bump version to 8.0.3 Signed-off-by: Thomas Lamprecht --- debian/changelog | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/debian/changelog b/debian/changelog index 6ba3f5a41..2db5c88d4 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,16 @@ +pve-manager (8.0.3) bookworm; urgency=medium + + * ui: use common gettext for IOMMU-Group + + * pve7to8: content-dirs check: skip paths that cannot be resolved + + * pve7to8: avoid confusing warning about required setting 'storage' for + vzdump + + * pve7to8: add check for systemd-boot presence, where needed. + + -- Proxmox Support Team Wed, 21 Jun 2023 19:55:54 +0200 + pve-manager (8.0.2) bookworm; urgency=medium * d/postinst: fix condition for removal of pvetest added during beta From 6d727f81c8ca23b5cc6224389a5b2972891aecdf Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 22 Jun 2023 14:15:12 +0200 Subject: [PATCH 150/398] ui: migrate: fix disabled migrate button glitch under certain circumstances, the migrate button stays disabled, even when a valid target node was selected: * the first node that gets autoselected (most likely the second) is not a valid migration target * the user changes to a migration target that is a valid one if that happens, the migration button would stay disabled. switching once to a non valid target and would enable the button. To fix it, we have to do two things here: 'checkQemuPreconditions' is actually an async function that awaits an api call and uses the result to set the 'migration.allowedNodes' property 'checkMigratePreconditions' calls 'checkQemuPreconditions' and uses the 'migration.allowedNodes' property afterwards. but since 'checkMigratePreconditions' is not async, that happens before the api call can return the valid data and thus leaves it empty, making all nodes valid in the selector. (thus the initial selected node is valid) instead make 'checkMigratePreconditions' also async and await the result of 'checkQemuPreconditions' this unearthed another issue, namely we access an object that is possibly undefined (worked out before due to race conditions) so fallback to an empty object. and lastly, since we want the 'disallowedNodes' set before actually checking the qemu preconditions, we move the setting of that on the node selector above the qemu preconditions check (this is the only place where we set it anyway, and the source does not change, we probably could move that out of that function altogether) Signed-off-by: Dominik Csapak --- www/manager6/window/Migrate.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/www/manager6/window/Migrate.js b/www/manager6/window/Migrate.js index c310342d0..5473821b9 100644 --- a/www/manager6/window/Migrate.js +++ b/www/manager6/window/Migrate.js @@ -155,7 +155,7 @@ Ext.define('PVE.window.Migrate', { }); }, - checkMigratePreconditions: function(resetMigrationPossible) { + checkMigratePreconditions: async function(resetMigrationPossible) { var me = this, vm = me.getViewModel(); @@ -165,12 +165,13 @@ Ext.define('PVE.window.Migrate', { vm.set('running', true); } + me.lookup('pveNodeSelector').disallowedNodes = [vm.get('nodename')]; + if (vm.get('vmtype') === 'qemu') { - me.checkQemuPreconditions(resetMigrationPossible); + await me.checkQemuPreconditions(resetMigrationPossible); } else { me.checkLxcPreconditions(resetMigrationPossible); } - me.lookup('pveNodeSelector').disallowedNodes = [vm.get('nodename')]; // Only allow nodes where the local storage is available in case of offline migration // where storage migration is not possible @@ -218,7 +219,7 @@ Ext.define('PVE.window.Migrate', { migration.allowedNodes = migrateStats.allowed_nodes; let target = me.lookup('pveNodeSelector').value; if (target.length && !migrateStats.allowed_nodes.includes(target)) { - let disallowed = migrateStats.not_allowed_nodes[target]; + let disallowed = migrateStats.not_allowed_nodes[target] ?? {}; if (disallowed.unavailable_storages !== undefined) { let missingStorages = disallowed.unavailable_storages.join(', '); From 45c2099cbda818125422146f0d110689290c151a Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 23 Jun 2023 09:08:44 +0200 Subject: [PATCH 151/398] try using 'pve-eslint' if it exists but fallback to 'eslint' otherwise Suggested-by: Thomas Lamprecht Signed-off-by: Dominik Csapak [T: move into www/manager Makefile directly ] Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 2d884f4a4..5b455c809 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -1,5 +1,7 @@ include ../../defines.mk +ESLINT ?= $(if $(shell command -v pve-eslint), pve-eslint, eslint) + JSSRC= \ Parser.js \ StateProvider.js \ @@ -314,13 +316,13 @@ WIDGETKIT=/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js all: .lint-incremental: $(JSSRC) - eslint $? + $(ESLINT) $? touch "$@" .PHONY: lint check: lint lint: $(JSSRC) - eslint --strict $(JSSRC) + $(ESLINT) --strict $(JSSRC) touch ".lint-incremental" pvemanagerlib.js: .lint-incremental OnlineHelpInfo.js $(JSSRC) From 50bcf799d8435b794fe2a79a74aa6df6a1419292 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 30 Jun 2023 17:02:34 +0200 Subject: [PATCH 152/398] update shipped appliance info index Signed-off-by: Thomas Lamprecht --- aplinfo/aplinfo.dat | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/aplinfo/aplinfo.dat b/aplinfo/aplinfo.dat index 54069402f..95a8be5a5 100644 --- a/aplinfo/aplinfo.dat +++ b/aplinfo/aplinfo.dat @@ -139,6 +139,20 @@ Infopage: https://www.proxmox.com/de/proxmox-mail-gateway Description: Proxmox Mailgateway 7.3 A full featured mail proxy for spam and virus filtering, optimized for container environment. +Package: proxmox-mailgateway-8.0-standard +Version: 8.0-1 +Type: lxc +OS: debian-12 +Section: mail +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: mail/proxmox-mailgateway-8.0-standard_8.0-1_amd64.tar.zst +md5sum: 7d321e5dfc6e1005231586d1871e3625 +sha512sum: be5efcb8ee97f2bb1c638360191eda19f49e2063acb88da55c948c90c091063972cc9ea29e6aeaa4a85733e0fb2c99ea905d665ac693cb2bf06b091c4baf781f +Infopage: https://www.proxmox.com/de/proxmox-mail-gateway +Description: Proxmox Mailgateway 8.0 + A full featured mail proxy for spam and virus filtering, optimized for container environment. + Package: rockylinux-9-default Version: 20221109 Type: lxc From 38909c4667da5c7f887c2b3d56638b12b0e99ef8 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 3 Jul 2023 13:17:18 +0200 Subject: [PATCH 153/398] buildsys: ordering/style cleanups Signed-off-by: Thomas Lamprecht --- Makefile | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index fd93ec867..273cc186f 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,17 @@ -include /usr/share/dpkg/pkg-info.mk -include /usr/share/dpkg/architecture.mk +include /usr/share/dpkg/default.mk include defines.mk export PVERELEASE = $(shell echo $(DEB_VERSION_UPSTREAM) | cut -c 1-3) export VERSION = $(DEB_VERSION_UPSTREAM_REVISION) -DESTDIR= - -SUBDIRS = aplinfo PVE bin www services configs network-hooks test - -GITVERSION:=$(shell git rev-parse --short=16 HEAD) - - BUILDDIR = $(PACKAGE)-$(DEB_VERSION_UPSTREAM) DSC=$(PACKAGE)_$(DEB_VERSION).dsc DEB=$(PACKAGE)_$(DEB_VERSION)_$(DEB_HOST_ARCH).deb +DESTDIR= +SUBDIRS = aplinfo PVE bin www services configs network-hooks test + all: $(SUBDIRS) set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i; done @@ -26,10 +21,7 @@ check: bin test www $(MAKE) -C test check $(MAKE) -C www check -.PHONY: dinstall -dinstall: $(DEB) - dpkg -i $(DEB) - +GITVERSION:=$(shell git rev-parse --short=16 HEAD) $(BUILDDIR): rm -rf $@ $@.tmp mkdir $@.tmp @@ -79,3 +71,7 @@ clean: set -e && for i in $(SUBDIRS); do $(MAKE) -C $$i $@; done rm -f $(PACKAGE)*.tar* country.dat *.deb *.dsc *.build *.buildinfo *.changes rm -rf dest $(PACKAGE)-[0-9]*/ + +.PHONY: dinstall +dinstall: $(DEB) + dpkg -i $(DEB) From 114e5f2c4a29f021962c9d2729d76f487e88d70e Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 3 Jul 2023 13:19:13 +0200 Subject: [PATCH 154/398] pve7to8: sync over from stable-7 branch Signed-off-by: Thomas Lamprecht --- PVE/CLI/pve7to8.pm | 114 +++++++++++++++++++++++++++++---------------- 1 file changed, 74 insertions(+), 40 deletions(-) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 2cb486029..c7d3f19c1 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -44,6 +44,14 @@ sub setup_environment { PVE::RPCEnvironment->setup_default_cli_env(); } +my $new_suite = 'bookworm'; +my $old_suite = 'bullseye'; +my $older_suites = { + buster => 1, + stretch => 1, + jessie => 1, +}; + my ($min_pve_major, $min_pve_minor, $min_pve_pkgrel) = (7, 4, 1); my $ceph_release2code = { @@ -64,41 +72,38 @@ my $forced_legacy_cgroup = 0; my $counters = { pass => 0, skip => 0, + notice => 0, warn => 0, fail => 0, }; +my $level2color = { + pass => 'green', + notice => 'bold', + warn => 'yellow', + fail => 'bold red', +}; + my $log_line = sub { my ($level, $line) = @_; $counters->{$level}++ if defined($level) && defined($counters->{$level}); + my $color = $level2color->{$level} // ''; + print color($color) if $color && $color ne ''; + print uc($level), ': ' if defined($level); print "$line\n"; + + print color('reset'); }; -sub log_pass { - print color('green'); - $log_line->('pass', @_); - print color('reset'); -} - -sub log_info { - $log_line->('info', @_); -} -sub log_skip { - $log_line->('skip', @_); -} -sub log_warn { - print color('yellow'); - $log_line->('warn', @_); - print color('reset'); -} -sub log_fail { - print color('bold red'); - $log_line->('fail', @_); - print color('reset'); -} +sub log_pass { $log_line->('pass', @_); } +sub log_info { $log_line->('info', @_); } +sub log_skip { $log_line->('skip', @_); } +sub log_notice { $log_line->('notice', @_); } +sub log_warn { $log_line->('warn', @_); } +sub log_fail { $log_line->('fail', @_); } my $print_header_first = 1; sub print_header { @@ -543,8 +548,7 @@ sub check_ceph { log_warn("unable to determine overall Ceph daemon versions!"); } elsif (keys %$overall_versions == 1) { log_pass("single running overall version detected for all Ceph daemon types."); - # TODO: needs to be set to 1 in the stable branch each time! - find better solution? - $noout_wanted = 0; # off post-upgrade, on pre-upgrade + $noout_wanted = 1; # off post-upgrade, on pre-upgrade } elsif (keys $ceph_versions_simple->{overall}->%* != 1) { log_warn("overall version mismatch detected, check 'ceph versions' output for details!"); } @@ -1168,6 +1172,9 @@ sub check_apt_repos { # TODO: check that (original) debian and Proxmox VE mirrors are present. + my ($found_suite, $found_suite_where); + my ($mismatches, $strange_suites); + my $check_file = sub { my ($file) = @_; @@ -1188,23 +1195,32 @@ sub check_apt_repos { next if $line !~ m/^deb[[:space:]]/; # is case sensitive my $suite; - - # catch any of - # https://deb.debian.org/debian-security - # http://security.debian.org/debian-security - # http://security.debian.org/ - if ($line =~ m|https?://deb\.debian\.org/debian-security/?\s+(\S*)|i) { - $suite = $1; - } elsif ($line =~ m|https?://security\.debian\.org(?:.*?)\s+(\S*)|i) { + if ($line =~ m|deb\s+\w+://\S+\s+(\S*)|i) { $suite = $1; } else { next; } - - $found = 1; - my $where = "in ${file}:${number}"; - # TODO: is this useful (for some other checks)? + + $suite =~ s/-(?:(?:proposed-)?updates|backports|debug|security)(?:-debug)?$//; + if ($suite ne $old_suite && $suite ne $new_suite && !$older_suites->{$suite}) { + push $strange_suites->@*, { suite => $suite, where => $where }; + next; + } + + if (!defined($found_suite)) { + $found_suite = $suite; + $found_suite_where = $where; + } elsif ($suite ne $found_suite) { + if (!defined($mismatches)) { + $mismatches = []; + push $mismatches->@*, + { suite => $found_suite, where => $found_suite_where}, + { suite => $suite, where => $where}; + } else { + push $mismatches->@*, { suite => $suite, where => $where}; + } + } } }; @@ -1214,10 +1230,28 @@ sub check_apt_repos { PVE::Tools::dir_glob_foreach($dir, '^.*\.list$', $check_file); - if (!$found) { - # only warn, it might be defined in a .sources file or in a way not caaught above - log_warn("No Debian security repository detected in /etc/apt/sources.list and " . - "/etc/apt/sources.list.d/*.list"); + if ($strange_suites) { + my @strange_list = map { "found suite $_->{suite} at $_->{where}" } $strange_suites->@*; + log_notice( + "found unusual suites that are neither old '$old_suite' nor new '$new_suite':" + ."\n " . join("\n ", @strange_list) + ."\n Please ensure these repositories are shipping compatible packages for the upgrade!" + ); + } + if (defined($mismatches)) { + my @mismatch_list = map { "found suite $_->{suite} at $_->{where}" } $mismatches->@*; + + log_fail( + "Found mixed old and new package repository suites, fix before upgrading! Mismatches:" + ."\n " . join("\n ", @mismatch_list) + ."\n Configure the same base-suite for all Proxmox and Debian provided repos and ask" + ." original vendor for any third-party repos." + ."\n E.g., for the upgrade to Proxmox VE ".($min_pve_major + 1)." use the '$new_suite' suite." + ); + } elsif (defined($strange_suites)) { + log_notice("found no suite mismatches, but found at least one strange suite"); + } else { + log_pass("found no suite mismatch"); } } From f9b888b05564533090763723ae7c0b3707f03475 Mon Sep 17 00:00:00 2001 From: Noel Ullreich Date: Mon, 3 Jul 2023 14:15:59 +0200 Subject: [PATCH 155/398] fix #4551: ui: use gettext on hardcoded byte units Since some languages translate byte units like 'GiB' or write them in their own script, this patch wraps units in the `gettext` function. While most occurrences of byte strings can be translated within the `format_size` function in `proxmox-widget-toolkit/src/Utils.js`, this patch catches those instances that are not translated. Signed-off-by: Noel Ullreich --- www/manager6/ceph/OSD.js | 4 ++-- www/manager6/form/DiskStorageSelector.js | 2 +- www/manager6/lxc/MPResize.js | 2 +- www/manager6/qemu/HDResize.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/www/manager6/ceph/OSD.js b/www/manager6/ceph/OSD.js index 69d5061fc..d2caafa4a 100644 --- a/www/manager6/ceph/OSD.js +++ b/www/manager6/ceph/OSD.js @@ -83,7 +83,7 @@ Ext.define('PVE.CephCreateOsd', { { xtype: 'numberfield', name: 'db_dev_size', - fieldLabel: gettext('DB size') + ' (GiB)', + fieldLabel: `${gettext('DB size')} (${gettext('GiB')})`, minValue: 1, maxValue: 128*1024, decimalPrecision: 2, @@ -137,7 +137,7 @@ Ext.define('PVE.CephCreateOsd', { { xtype: 'numberfield', name: 'wal_dev_size', - fieldLabel: gettext('WAL size') + ' (GiB)', + fieldLabel: `${gettext('WAL size')} (${gettext('GiB')})`, minValue: 0.5, maxValue: 128*1024, decimalPrecision: 2, diff --git a/www/manager6/form/DiskStorageSelector.js b/www/manager6/form/DiskStorageSelector.js index 860a3b3ca..0ef48f512 100644 --- a/www/manager6/form/DiskStorageSelector.js +++ b/www/manager6/form/DiskStorageSelector.js @@ -145,7 +145,7 @@ Ext.define('PVE.form.DiskStorageSelector', { xtype: 'numberfield', itemId: 'disksize', name: 'disksize', - fieldLabel: gettext('Disk size') + ' (GiB)', + fieldLabel: `${gettext('Disk size')} (${gettext('GiB')})`, hidden: me.hideSize, disabled: me.hideSize, minValue: 0.001, diff --git a/www/manager6/lxc/MPResize.js b/www/manager6/lxc/MPResize.js index 881c037b0..d560b7886 100644 --- a/www/manager6/lxc/MPResize.js +++ b/www/manager6/lxc/MPResize.js @@ -52,7 +52,7 @@ Ext.define('PVE.window.MPResize', { maxValue: 128*1024, decimalPrecision: 3, value: '0', - fieldLabel: gettext('Size Increment') + ' (GiB)', + fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`, allowBlank: false, }); diff --git a/www/manager6/qemu/HDResize.js b/www/manager6/qemu/HDResize.js index 97bec73b1..e2a3ce491 100644 --- a/www/manager6/qemu/HDResize.js +++ b/www/manager6/qemu/HDResize.js @@ -53,7 +53,7 @@ Ext.define('PVE.window.HDResize', { maxValue: 128*1024, decimalPrecision: 3, value: '0', - fieldLabel: gettext('Size Increment') + ' (GiB)', + fieldLabel: `${gettext('Size Increment')} (${gettext('GiB')})`, allowBlank: false, }); From d1c49f5b64243668f320143514587fbcd020ed24 Mon Sep 17 00:00:00 2001 From: Max Carrara Date: Wed, 5 Jul 2023 20:02:40 +0200 Subject: [PATCH 156/398] fix #4364: pveceph: add confirmation dialogue for ceph installation Displays a confirmation dialogue if the user didn't explicitly provide a valid ceph version via the `--version` flag and if stdout is connected to a tty. Signed-off-by: Max Carrara --- PVE/CLI/pveceph.pm | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/PVE/CLI/pveceph.pm b/PVE/CLI/pveceph.pm index b47f8cc19..8cff04c54 100755 --- a/PVE/CLI/pveceph.pm +++ b/PVE/CLI/pveceph.pm @@ -176,6 +176,16 @@ __PACKAGE__->register_method ({ } else { die "unsupported ceph version: $cephver"; } + + if (-t STDOUT && !$param->{version}) { + print "This will install Ceph " . ucfirst($cephver) . " - continue (y/N)? "; + + my $answer = ; + my $continue = defined($answer) && $answer =~ m/^\s*y(?:es)?\s*$/i; + + die "Aborting installation as requested\n" if !$continue; + } + PVE::Tools::file_set_contents("/etc/apt/sources.list.d/ceph.list", $repolist); my $supported_re = join('|', $supported_ceph_versions->@*); From ba7002f5e69d76691f7a0bc4893ecccbfc8da084 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 17 Jul 2023 14:34:24 +0200 Subject: [PATCH 157/398] fix #4758: ui: lxc wizard: allow multiple ssh keys by converting the textfield into a textarea and validate the value line wise (if there is more than one line) also create a 'MultiFileButton' (mostly copied from extjs) that allows to select multiple files at once Signed-off-by: Dominik Csapak --- www/manager6/Makefile | 1 + www/manager6/form/MultiFileButton.js | 59 ++++++++++++++++++++++++++++ www/manager6/lxc/CreateWizard.js | 19 +++++---- 3 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 www/manager6/form/MultiFileButton.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 5b455c809..7ec9d7a56 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -84,6 +84,7 @@ JSSRC= \ form/ListField.js \ form/Tag.js \ form/TagEdit.js \ + form/MultiFileButton.js \ grid/BackupView.js \ grid/FirewallAliases.js \ grid/FirewallOptions.js \ diff --git a/www/manager6/form/MultiFileButton.js b/www/manager6/form/MultiFileButton.js new file mode 100644 index 000000000..27960876e --- /dev/null +++ b/www/manager6/form/MultiFileButton.js @@ -0,0 +1,59 @@ +// mostly copied from ExtJS FileButton, but added 'multiple' at the relevant +// places so we have a file picker where one can select multiple files +// changes are marked with an 'pmx:' comment +Ext.define('PVE.form.MultiFileButton', { + extend: 'Ext.form.field.FileButton', + alias: 'widget.pveMultiFileButton', + + afterTpl: [ + 'accept="{accept}"', + 'tabindex="{tabIndex}"', + '>', + ], + + createFileInput: function(isTemporary) { + var me = this, + fileInputEl, listeners; + + fileInputEl = me.fileInputEl = me.el.createChild({ + name: me.inputName || me.id, + multiple: true, // pmx: added multiple option + id: !isTemporary ? me.id + '-fileInputEl' : undefined, + cls: me.inputCls + (me.getInherited().rtl ? ' ' + Ext.baseCSSPrefix + 'rtl' : ''), + tag: 'input', + type: 'file', + size: 1, + unselectable: 'on', + }, me.afterInputGuard); // Nothing special happens outside of IE/Edge + + // This is our focusEl + fileInputEl.dom.setAttribute('data-componentid', me.id); + + if (me.tabIndex !== null) { + me.setTabIndex(me.tabIndex); + } + + if (me.accept) { + fileInputEl.dom.setAttribute('accept', me.accept); + } + + // We place focus and blur listeners on fileInputEl to activate Button's + // focus and blur style treatment + listeners = { + scope: me, + change: me.fireChange, + mousedown: me.handlePrompt, + keydown: me.handlePrompt, + focus: me.onFileFocus, + blur: me.onFileBlur, + }; + + if (me.useTabGuards) { + listeners.keydown = me.onFileInputKeydown; + } + + fileInputEl.on(listeners); + }, +}); diff --git a/www/manager6/lxc/CreateWizard.js b/www/manager6/lxc/CreateWizard.js index 0b82cc1cb..e36352974 100644 --- a/www/manager6/lxc/CreateWizard.js +++ b/www/manager6/lxc/CreateWizard.js @@ -120,16 +120,16 @@ Ext.define('PVE.lxc.CreateWizard', { }, }, { - xtype: 'proxmoxtextfield', + xtype: 'textarea', name: 'ssh-public-keys', value: '', - fieldLabel: gettext('SSH public key'), + fieldLabel: gettext('SSH public key(s)'), allowBlank: true, validator: function(value) { let pwfield = this.up().down('field[name=password]'); if (value.length) { - let key = PVE.Parser.parseSSHKey(value); - if (!key) { + let keys = value.indexOf('\n') !== -1 ? value.split('\n') : [value]; + if (keys.some(key => key !== '' && !PVE.Parser.parseSSHKey(key))) { return "Failed to recognize ssh key"; } pwfield.allowBlank = true; @@ -159,15 +159,20 @@ Ext.define('PVE.lxc.CreateWizard', { }, }, { - xtype: 'filebutton', + xtype: 'pveMultiFileButton', name: 'file', hidden: !window.FileReader, text: gettext('Load SSH Key File'), listeners: { change: function(btn, e, value) { e = e.event; - let field = this.up().down('proxmoxtextfield[name=ssh-public-keys]'); - PVE.Utils.loadSSHKeyFromFile(e.target.files[0], v => field.setValue(v)); + let field = this.up().down('textarea[name=ssh-public-keys]'); + for (const file of e?.target?.files ?? []) { + PVE.Utils.loadSSHKeyFromFile(file, v => { + let oldValue = field.getValue(); + field.setValue(oldValue ? `${oldValue}\n${v.trim()}` : v.trim()); + }); + } btn.reset(); }, }, From a083d410416741d5a7dc8dbefdb105e32f5a63d5 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Mon, 17 Jul 2023 10:38:19 +0200 Subject: [PATCH 158/398] pve7to8: fix Ceph noout check Commit 114e5f2c ("pve7to8: sync over from stable-7 branch") accidentally got rid of the correct value 0 here and also the new TODO message to improve the situation. But the TODO is actually easy, because there already is the $upgraded variable. Just rely on that instead of hard-coding and forgetting about it again. Signed-off-by: Fiona Ebner --- PVE/CLI/pve7to8.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index c7d3f19c1..5ba738372 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -548,7 +548,7 @@ sub check_ceph { log_warn("unable to determine overall Ceph daemon versions!"); } elsif (keys %$overall_versions == 1) { log_pass("single running overall version detected for all Ceph daemon types."); - $noout_wanted = 1; # off post-upgrade, on pre-upgrade + $noout_wanted = !$upgraded; # off post-upgrade, on pre-upgrade } elsif (keys $ceph_versions_simple->{overall}->%* != 1) { log_warn("overall version mismatch detected, check 'ceph versions' output for details!"); } From a8e18f85609c0f5521b5b213fe8345c333e09863 Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Sat, 17 Jun 2023 14:43:05 +0200 Subject: [PATCH 159/398] ui: sdn: zonedit: fix display && refactor move ipam selector to main items as it's non optional, and it's breaking display if present in advanced. move common id,mtu,nodes fields from modules to base Signed-off-by: Alexandre Derumier --- www/manager6/sdn/zones/Base.js | 34 ++++++++++++++++++++++++++-- www/manager6/sdn/zones/EvpnEdit.js | 27 ---------------------- www/manager6/sdn/zones/QinQEdit.js | 26 --------------------- www/manager6/sdn/zones/SimpleEdit.js | 30 +----------------------- www/manager6/sdn/zones/VlanEdit.js | 27 ---------------------- www/manager6/sdn/zones/VxlanEdit.js | 26 --------------------- 6 files changed, 33 insertions(+), 137 deletions(-) diff --git a/www/manager6/sdn/zones/Base.js b/www/manager6/sdn/zones/Base.js index 347889c04..655352a86 100644 --- a/www/manager6/sdn/zones/Base.js +++ b/www/manager6/sdn/zones/Base.js @@ -18,14 +18,44 @@ Ext.define('PVE.panel.SDNZoneBase', { initComponent: function() { var me = this; - me.advancedItems = [ + me.items.unshift({ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'zone', + maxLength: 8, + value: me.zone || '', + fieldLabel: 'ID', + allowBlank: false, + }); + + me.items.push( + { + xtype: 'proxmoxintegerfield', + name: 'mtu', + minValue: 100, + maxValue: 65000, + fieldLabel: 'MTU', + skipEmptyText: true, + allowBlank: true, + emptyText: 'auto', + }, + { + xtype: 'pveNodeSelector', + name: 'nodes', + fieldLabel: gettext('Nodes'), + emptyText: gettext('All') + ' (' + gettext('No restrictions') +')', + multiSelect: true, + autoSelect: false, + }, { xtype: 'pveSDNIpamSelector', fieldLabel: gettext('Ipam'), name: 'ipam', - value: 'pve', + value: me.ipam || 'pve', allowBlank: false, }, + ); + + me.advancedItems = [ { xtype: 'pveSDNDnsSelector', fieldLabel: gettext('Dns server'), diff --git a/www/manager6/sdn/zones/EvpnEdit.js b/www/manager6/sdn/zones/EvpnEdit.js index f1314ad57..1d13976c9 100644 --- a/www/manager6/sdn/zones/EvpnEdit.js +++ b/www/manager6/sdn/zones/EvpnEdit.js @@ -39,14 +39,6 @@ Ext.define('PVE.sdn.zones.EvpnInputPanel', { var me = this; me.items = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'zone', - maxLength: 8, - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }, { xtype: 'pveSDNControllerSelector', fieldLabel: gettext('Controller'), @@ -111,25 +103,6 @@ Ext.define('PVE.sdn.zones.EvpnInputPanel', { fieldLabel: gettext('Route-target import'), allowBlank: true, }, - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - minValue: 100, - maxValue: 65000, - fieldLabel: 'MTU', - skipEmptyText: true, - allowBlank: true, - emptyText: 'auto', - }, - { - xtype: 'pveNodeSelector', - name: 'nodes', - fieldLabel: gettext('Nodes'), - emptyText: gettext('All') + ' (' + gettext('No restrictions') +')', - multiSelect: true, - autoSelect: false, - }, - ]; me.callParent(); diff --git a/www/manager6/sdn/zones/QinQEdit.js b/www/manager6/sdn/zones/QinQEdit.js index d9e117d98..c059a7a23 100644 --- a/www/manager6/sdn/zones/QinQEdit.js +++ b/www/manager6/sdn/zones/QinQEdit.js @@ -19,14 +19,6 @@ Ext.define('PVE.sdn.zones.QinQInputPanel', { let me = this; me.items = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'zone', - maxLength: 8, - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }, { xtype: 'textfield', name: 'bridge', @@ -52,24 +44,6 @@ Ext.define('PVE.sdn.zones.QinQInputPanel', { ['802.1ad', '802.1ad'], ], }, - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - minValue: 100, - maxValue: 65000, - fieldLabel: 'MTU', - skipEmptyText: true, - allowBlank: true, - emptyText: 'auto', - }, - { - xtype: 'pveNodeSelector', - name: 'nodes', - fieldLabel: gettext('Nodes'), - emptyText: gettext('All') + ' (' + gettext('No restrictions') +')', - multiSelect: true, - autoSelect: false, - }, ]; me.callParent(); diff --git a/www/manager6/sdn/zones/SimpleEdit.js b/www/manager6/sdn/zones/SimpleEdit.js index 56df7952a..cb7c34035 100644 --- a/www/manager6/sdn/zones/SimpleEdit.js +++ b/www/manager6/sdn/zones/SimpleEdit.js @@ -18,35 +18,7 @@ Ext.define('PVE.sdn.zones.SimpleInputPanel', { initComponent: function() { var me = this; - me.items = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'zone', - maxLength: 10, - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }, - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - minValue: 100, - maxValue: 65000, - fieldLabel: 'MTU', - skipEmptyText: true, - allowBlank: true, - emptyText: 'auto', - }, - { - xtype: 'pveNodeSelector', - name: 'nodes', - fieldLabel: gettext('Nodes'), - emptyText: gettext('All') + ' (' + gettext('No restrictions') +')', - multiSelect: true, - autoSelect: false, - }, - - ]; + me.items = []; me.callParent(); }, diff --git a/www/manager6/sdn/zones/VlanEdit.js b/www/manager6/sdn/zones/VlanEdit.js index 93d2bedec..23530bfcf 100644 --- a/www/manager6/sdn/zones/VlanEdit.js +++ b/www/manager6/sdn/zones/VlanEdit.js @@ -19,39 +19,12 @@ Ext.define('PVE.sdn.zones.VlanInputPanel', { var me = this; me.items = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - name: 'zone', - maxLength: 10, - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }, { xtype: 'textfield', name: 'bridge', fieldLabel: 'Bridge', allowBlank: false, }, - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - minValue: 100, - maxValue: 65000, - fieldLabel: 'MTU', - skipEmptyText: true, - allowBlank: true, - emptyText: 'auto', - }, - { - xtype: 'pveNodeSelector', - name: 'nodes', - fieldLabel: gettext('Nodes'), - emptyText: gettext('All') + ' (' + gettext('No restrictions') +')', - multiSelect: true, - autoSelect: false, - }, - ]; me.callParent(); diff --git a/www/manager6/sdn/zones/VxlanEdit.js b/www/manager6/sdn/zones/VxlanEdit.js index 41cc7e68d..b556790df 100644 --- a/www/manager6/sdn/zones/VxlanEdit.js +++ b/www/manager6/sdn/zones/VxlanEdit.js @@ -21,38 +21,12 @@ Ext.define('PVE.sdn.zones.VxlanInputPanel', { var me = this; me.items = [ - { - xtype: me.isCreate ? 'textfield' : 'displayfield', - maxLength: 8, - name: 'zone', - value: me.zone || '', - fieldLabel: 'ID', - allowBlank: false, - }, { xtype: 'textfield', name: 'peers', fieldLabel: gettext('Peer Address List'), allowBlank: false, }, - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - minValue: 100, - maxValue: 65000, - fieldLabel: 'MTU', - skipEmptyText: true, - allowBlank: true, - emptyText: 'auto', - }, - { - xtype: 'pveNodeSelector', - name: 'nodes', - fieldLabel: gettext('Nodes'), - emptyText: gettext('All') + ' (' + gettext('No restrictions') +')', - multiSelect: true, - autoSelect: false, - }, ]; me.callParent(); From 41275bae6472bd7be3e0837735a53708a84d0fdc Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 20 Jul 2023 16:32:09 +0200 Subject: [PATCH 160/398] test: fix names of .PHONY targets They need to have the same name as the target. Took the opportunity to move the .PHONY right next to the target recipe, so that mistakes like these are hopefully easier caught. Signed-off-by: Lukas Wagner --- test/Makefile | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/Makefile b/test/Makefile index 670a3611a..cccdc1c93 100644 --- a/test/Makefile +++ b/test/Makefile @@ -4,29 +4,35 @@ all: export PERLLIB=.. -.PHONY: check balloon-test replication-test mail-test vzdump-test +.PHONY: check check: test-replication test-balloon test-mail test-vzdump test-osd +.PHONY: test-balloon test-balloon: ./balloontest.pl +.PHONY: test-replication test-replication: replication1.t replication2.t replication3.t replication4.t replication5.t replication6.t replication%.t: replication_test%.pl ./$< +.PHONY: test-mail test-mail: ./mail_test.pl +.PHONY: test-vzdump test-vzdump: test-vzdump-guest-included test-vzdump-new -.PHONY: test-vzdump-guest-included test-vzdump-new +.PHONY: test-vzdump-guest-included test-vzdump-guest-included: ./vzdump_guest_included_test.pl +.PHONY: test-vzdump-new test-vzdump-new: ./vzdump_new_test.pl +.PHONY: test-osd test-osd: ./OSD_test.pl From c34c541820636a2f8a8d76e7bab319f1fbefc085 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 25 Jul 2023 10:12:00 +0200 Subject: [PATCH 161/398] ui: active directory realm: expose case-sensitive option The case-sensitive option is not really something that should be CLI only and is quite common for Microsoft AD setups, so add it to the UI too as requested in the Forum [0], improving discoverability. [0]: https://forum.proxmox.com/threads/74547/#post-575854 Signed-off-by: Thomas Lamprecht --- www/manager6/dc/AuthEditAD.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/www/manager6/dc/AuthEditAD.js b/www/manager6/dc/AuthEditAD.js index d70d6f3b6..a1999cb74 100644 --- a/www/manager6/dc/AuthEditAD.js +++ b/www/manager6/dc/AuthEditAD.js @@ -17,6 +17,13 @@ Ext.define('PVE.panel.ADInputPanel', { emptyText: 'company.net', allowBlank: false, }, + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Case-Sensitive'), + name: 'case-sensitive', + uncheckedValue: 0, + checked: true, + }, ]; me.column2 = [ From 98cbec545f91ac553894c7ffbc8bc7f44688a68a Mon Sep 17 00:00:00 2001 From: Friedrich Weber Date: Tue, 25 Jul 2023 13:52:46 +0200 Subject: [PATCH 162/398] ui: add some missing `htmlEncode`s Signed-off-by: Friedrich Weber --- www/manager6/Utils.js | 9 ++++++--- www/manager6/dc/BackupJobDetail.js | 1 + www/manager6/dc/PCIMapView.js | 2 +- www/manager6/dc/USBMapView.js | 2 +- www/manager6/form/PCIMapSelector.js | 1 + www/manager6/form/USBMapSelector.js | 1 + www/manager6/qemu/CloudInit.js | 4 ++-- 7 files changed, 13 insertions(+), 7 deletions(-) diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index a150e848f..4e0942136 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1003,15 +1003,18 @@ Ext.define('PVE.Utils', { }, render_storage_content: function(value, metaData, record) { - var data = record.data; + let data = record.data; + let result; if (Ext.isNumber(data.channel) && Ext.isNumber(data.id) && Ext.isNumber(data.lun)) { - return "CH " + + result = "CH " + Ext.String.leftPad(data.channel, 2, '0') + " ID " + data.id + " LUN " + data.lun; + } else { + result = data.volid.replace(/^.*?:(.*?\/)?/, ''); } - return data.volid.replace(/^.*?:(.*?\/)?/, ''); + return Ext.String.htmlEncode(result); }, render_serverity: function(value) { diff --git a/www/manager6/dc/BackupJobDetail.js b/www/manager6/dc/BackupJobDetail.js index c4683a476..880784a23 100644 --- a/www/manager6/dc/BackupJobDetail.js +++ b/www/manager6/dc/BackupJobDetail.js @@ -249,6 +249,7 @@ Ext.define('PVE.dc.BackupInfo', { xtype: 'displayfield', name: 'comment', fieldLabel: gettext('Comment'), + renderer: Ext.String.htmlEncode, }, { xtype: 'fieldset', diff --git a/www/manager6/dc/PCIMapView.js b/www/manager6/dc/PCIMapView.js index 859ef58fa..80fe3c0f0 100644 --- a/www/manager6/dc/PCIMapView.js +++ b/www/manager6/dc/PCIMapView.js @@ -98,7 +98,7 @@ Ext.define('PVE.dc.PCIMapView', { header: gettext('Comment'), dataIndex: 'description', renderer: function(value, _meta, record) { - return value ?? record.data.comment; + return Ext.String.htmlEncode(value ?? record.data.comment); }, flex: 1, }, diff --git a/www/manager6/dc/USBMapView.js b/www/manager6/dc/USBMapView.js index 953e2425c..96edc5875 100644 --- a/www/manager6/dc/USBMapView.js +++ b/www/manager6/dc/USBMapView.js @@ -90,7 +90,7 @@ Ext.define('PVE.dc.USBMapView', { header: gettext('Comment'), dataIndex: 'description', renderer: function(value, _meta, record) { - return value ?? record.data.comment; + return Ext.String.htmlEncode(value ?? record.data.comment); }, flex: 1, }, diff --git a/www/manager6/form/PCIMapSelector.js b/www/manager6/form/PCIMapSelector.js index 1bc73ec05..49629bc2f 100644 --- a/www/manager6/form/PCIMapSelector.js +++ b/www/manager6/form/PCIMapSelector.js @@ -40,6 +40,7 @@ Ext.define('PVE.form.PCIMapSelector', { header: gettext('Description'), dataIndex: 'description', flex: 1, + renderer: Ext.String.htmlEncode, }, { header: gettext('Status'), diff --git a/www/manager6/form/USBMapSelector.js b/www/manager6/form/USBMapSelector.js index 6a33754ac..2e55c1003 100644 --- a/www/manager6/form/USBMapSelector.js +++ b/www/manager6/form/USBMapSelector.js @@ -64,6 +64,7 @@ Ext.define('PVE.form.USBMapSelector', { header: gettext('Comment'), dataIndex: 'description', flex: 1, + renderer: Ext.String.htmlEncode, }, ], }, diff --git a/www/manager6/qemu/CloudInit.js b/www/manager6/qemu/CloudInit.js index 03d06d9c5..495197265 100644 --- a/www/manager6/qemu/CloudInit.js +++ b/www/manager6/qemu/CloudInit.js @@ -214,7 +214,7 @@ Ext.define('PVE.qemu.CloudInit', { ], } : undefined, renderer: function(value) { - return value || Proxmox.Utils.defaultText; + return Ext.String.htmlEncode(value || Proxmox.Utils.defaultText); }, }, cipassword: { @@ -236,7 +236,7 @@ Ext.define('PVE.qemu.CloudInit', { ], } : undefined, renderer: function(value) { - return value || Proxmox.Utils.noneText; + return Ext.String.htmlEncode(value || Proxmox.Utils.noneText); }, }, searchdomain: { From c62f096e2b19572062697c76da21ebf8d6ebd016 Mon Sep 17 00:00:00 2001 From: Alexander Zeidler Date: Thu, 15 Jun 2023 16:14:42 +0200 Subject: [PATCH 163/398] api: backup: refactor backup permission check Alter style to make the parameter check more concise Signed-off-by: Alexander Zeidler Reviewed-by: Fiona Ebner --- PVE/API2/Backup.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/API2/Backup.pm b/PVE/API2/Backup.pm index 45eb47e26..70753c2e0 100644 --- a/PVE/API2/Backup.pm +++ b/PVE/API2/Backup.pm @@ -49,7 +49,7 @@ sub assert_param_permission_common { raise_param_exc({ $key => "Only root may set this option."}) if exists $param->{$key}; } - if (defined($param->{bwlimit}) || defined($param->{ionice}) || defined($param->{performance})) { + if (grep { defined($param->{$_}) } qw(bwlimit ionice performance)) { $rpcenv->check($user, "/", [ 'Sys.Modify' ]); } } From 3fc687f57be068afa5738c446243e0598d70b375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Tue, 18 Jul 2023 10:31:34 +0200 Subject: [PATCH 164/398] handle pve-kernel -> proxmox-kernel rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fabian Grünbichler --- PVE/API2/APT.pm | 2 +- PVE/CLI/pve7to8.pm | 2 +- bin/pveupgrade | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/PVE/API2/APT.pm b/PVE/API2/APT.pm index 6694dbeb6..f73535e15 100644 --- a/PVE/API2/APT.pm +++ b/PVE/API2/APT.pm @@ -789,7 +789,7 @@ __PACKAGE__->register_method({ my $aptver = $AptPkg::System::_system->versioning(); my $byver = sub { $aptver->compare($cache->{$b}->{CurrentVer}->{VerStr}, $cache->{$a}->{CurrentVer}->{VerStr}) }; - push @list, sort $byver grep { /^pve-kernel-/ && $cache->{$_}->{CurrentState} eq 'Installed' } keys %$cache; + push @list, sort $byver grep { /^(?:pve|proxmox)-kernel-/ && $cache->{$_}->{CurrentState} eq 'Installed' } keys %$cache; my @opt_pack = qw( ceph diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 5ba738372..ff8e6045f 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -204,7 +204,7 @@ sub check_pve_packages { } # FIXME: better differentiate between 6.2 from bullseye or bookworm - my ($krunning, $kinstalled) = (qr/6\.(?:2\.(?:[2-9]\d+|1[6-8]|1\d\d+)|5)[^~]*$/, 'pve-kernel-6.2'); + my ($krunning, $kinstalled) = (qr/6\.(?:2\.(?:[2-9]\d+|1[6-8]|1\d\d+)|5)[^~]*$/, 'proxmox-kernel-6.2'); if (!$upgraded) { # we got a few that avoided 5.15 in cluster with mixed CPUs, so allow older too ($krunning, $kinstalled) = (qr/(?:5\.(?:13|15)|6\.2)/, 'pve-kernel-5.15'); diff --git a/bin/pveupgrade b/bin/pveupgrade index 0ce01824d..04b3f7ac3 100755 --- a/bin/pveupgrade +++ b/bin/pveupgrade @@ -61,7 +61,7 @@ if (!$st || (time() - $st->mtime) > (3*24*3600)) { my $newkernel; foreach my $p (@$oldlist) { - if (($p->{Package} =~ m/^pve-kernel/) && + if (($p->{Package} =~ m/^(?:pve|proxmox)-kernel/) && !grep { $_->{Package} eq $p->{Package} } @$pkglist) { $newkernel = 1; last; From d258a813cfa6b390c623d4cd509ebd1a2f4b36a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Tue, 1 Aug 2023 11:55:46 +0200 Subject: [PATCH 165/398] bump version to 8.0.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fabian Grünbichler --- debian/changelog | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/debian/changelog b/debian/changelog index 2db5c88d4..1404a16f8 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,29 @@ +pve-manager (8.0.4) bookworm; urgency=medium + + * ui: migrate: fix disabled migrate button glitch + + * update shipped appliance info index + + * pve7to8: sync over from stable-7 branch + + * pve7to8: fix Ceph noout check + + * fix #4551: ui: use gettext on hardcoded byte units + + * fix #4364: pveceph: add confirmation dialogue for ceph installation + + * fix #4758: ui: lxc wizard: allow multiple ssh keys + + * ui: sdn: zonedit: fix non-advanced display + + * ui: active directory realm: expose case-sensitive option + + * ui: add some missing `htmlEncode`s + + * handle pve-kernel -> proxmox-kernel rename + + -- Proxmox Support Team Tue, 01 Aug 2023 11:53:07 +0200 + pve-manager (8.0.3) bookworm; urgency=medium * ui: use common gettext for IOMMU-Group From f7794ede56354158dbcf47330eb6c7e2f0c8fa09 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 27 Jul 2023 14:47:09 +0200 Subject: [PATCH 166/398] ui: form: listfield: add 5px padding between grid and 'Add' button Before, there was zero space between the the grid border line and the button, making it look a bit odd. The ListField form component is currently used in the 'User Tag Access' and 'Registered Tags' dialog windows in datacenter option view. Signed-off-by: Lukas Wagner --- www/manager6/form/ListField.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/form/ListField.js b/www/manager6/form/ListField.js index 61e37f7e5..0d0a4f6e0 100644 --- a/www/manager6/form/ListField.js +++ b/www/manager6/form/ListField.js @@ -144,6 +144,7 @@ Ext.define('PVE.form.ListField', { text: gettext('Add'), iconCls: 'fa fa-plus-circle', handler: 'addLine', + margin: '5 0 0 0', }, ], From d4b490009c999ec05158003b5b8e545f90dc05b4 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Wed, 2 Aug 2023 14:40:18 +0200 Subject: [PATCH 167/398] api: use standard vmid type for /cluster/resources Signed-off-by: Wolfgang Bumiller --- PVE/API2/Cluster.pm | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm index 3daf6ae5b..31e9f51e4 100644 --- a/PVE/API2/Cluster.pm +++ b/PVE/API2/Cluster.pm @@ -329,12 +329,10 @@ __PACKAGE__->register_method({ type => 'string', optional => 1, }, - vmid => { + vmid => get_standard_option('pve-vmid', { description => "The numerical vmid (when type in qemu,lxc).", - type => 'integer', optional => 1, - minimum => 1, - }, + }), 'cgroup-mode' => { description => "The cgroup mode the node operates under (when type == node).", type => 'integer', From 58ab77d189bdcd73871ced2ed3cdb263f937b850 Mon Sep 17 00:00:00 2001 From: Philipp Hufnagl Date: Tue, 1 Aug 2023 16:46:04 +0200 Subject: [PATCH 168/398] fix whitespaces Signed-off-by: Philipp Hufnagl --- PVE/API2/Nodes.pm | 16 ++++++++-------- www/manager6/window/DownloadUrlToStorage.js | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index 9269694d6..5a148d1d0 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -496,7 +496,7 @@ __PACKAGE__->register_method({ # just parse the json again, it should already be validated my $commands = eval { decode_json($param->{commands}); }; - foreach my $cmd (@$commands) { + foreach my $cmd (@$commands) { eval { $cmd->{args} //= {}; @@ -654,11 +654,11 @@ __PACKAGE__->register_method({ }, ds => { description => "The list of datasources you want to display.", - type => 'string', format => 'pve-configid-list', + type => 'string', format => 'pve-configid-list', }, cf => { description => "The RRD consolidation function", - type => 'string', + type => 'string', enum => [ 'AVERAGE', 'MAX' ], optional => 1, }, @@ -699,7 +699,7 @@ __PACKAGE__->register_method({ }, cf => { description => "The RRD consolidation function", - type => 'string', + type => 'string', enum => [ 'AVERAGE', 'MAX' ], optional => 1, }, @@ -1368,7 +1368,7 @@ __PACKAGE__->register_method({ description => "Read server time and time zone settings.", proxyto => 'node', parameters => { - additionalProperties => 0, + additionalProperties => 0, properties => { node => get_standard_option('pve-node'), }, @@ -1393,7 +1393,7 @@ __PACKAGE__->register_method({ minimum => 1297163644, renderer => 'timestamp_gmt', }, - }, + }, }, code => sub { my ($param) = @_; @@ -2086,14 +2086,14 @@ __PACKAGE__->register_method ({ additionalProperties => 0, properties => { node => get_standard_option('pve-node'), - target => get_standard_option('pve-node', { description => "Target node." }), + target => get_standard_option('pve-node', { description => "Target node." }), maxworkers => { description => "Maximal number of parallel migration job. If not set, uses" ."'max_workers' from datacenter.cfg. One of both must be set!", optional => 1, type => 'integer', minimum => 1 - }, + }, vms => { description => "Only consider Guests with these IDs.", type => 'string', format => 'pve-vmid-list', diff --git a/www/manager6/window/DownloadUrlToStorage.js b/www/manager6/window/DownloadUrlToStorage.js index 48543d28f..90320da4c 100644 --- a/www/manager6/window/DownloadUrlToStorage.js +++ b/www/manager6/window/DownloadUrlToStorage.js @@ -215,7 +215,7 @@ Ext.define('PVE.window.DownloadUrlToStorage', { ], initComponent: function() { - var me = this; + var me = this; if (!me.nodename) { throw "no node name specified"; @@ -224,7 +224,7 @@ Ext.define('PVE.window.DownloadUrlToStorage', { throw "no storage ID specified"; } - me.callParent(); + me.callParent(); }, }); From e1d996dc553ee1a50d9f8af002a1d36f50204dae Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Fri, 11 Aug 2023 13:37:55 +0200 Subject: [PATCH 169/398] bump pve-access-control dep to 8.0.5 This is required for the new check-connection parameter for ldap realms added in the next commit. Signed-off-by: Wolfgang Bumiller --- debian/control | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index 3206b514f..34a11f89a 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Build-Depends: debhelper-compat (= 13), libpod-parser-perl, libproxmox-acme-perl, libproxmox-rs-perl (>= 0.2.0), - libpve-access-control (>= 8.0.0~2), + libpve-access-control (>= 8.0.5), libpve-cluster-api-perl, libpve-cluster-perl (>= 6.1-6), libpve-common-perl (>= 7.2-6), @@ -55,7 +55,7 @@ Depends: apt (>= 1.5~), libproxmox-acme-perl, libproxmox-acme-plugins, libproxmox-rs-perl (>= 0.2.0), - libpve-access-control (>= 8.0.0~2), + libpve-access-control (>= 8.0.5), libpve-cluster-api-perl (>= 7.0-5), libpve-cluster-perl (>= 7.2-3), libpve-common-perl (>= 7.2-7), From b9d23c878796fdda34be53cefe3e400789be0f61 Mon Sep 17 00:00:00 2001 From: Christoph Heiss Date: Thu, 10 Aug 2023 14:37:08 +0200 Subject: [PATCH 170/398] ui: ldap: add 'Check connection' checkbox as advanced option The checkbox is enabled by default, setting the new `check-connection` parameter. See also [0] for the rationale. [0] https://lists.proxmox.com/pipermail/pve-devel/2023-July/058559.html Signed-off-by: Christoph Heiss --- www/manager6/dc/AuthEditAD.js | 15 +++++++++++++++ www/manager6/dc/AuthEditLDAP.js | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/www/manager6/dc/AuthEditAD.js b/www/manager6/dc/AuthEditAD.js index a1999cb74..3cbb47c9f 100644 --- a/www/manager6/dc/AuthEditAD.js +++ b/www/manager6/dc/AuthEditAD.js @@ -79,6 +79,21 @@ Ext.define('PVE.panel.ADInputPanel', { }, ]; + me.advancedItems = [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Check connection'), + name: 'check-connection', + uncheckedValue: 0, + checked: true, + autoEl: { + tag: 'div', + 'data-qtip': + gettext('Verify connection parameters and bind credentials on save'), + }, + }, + ]; + me.callParent(); }, onGetValues: function(values) { diff --git a/www/manager6/dc/AuthEditLDAP.js b/www/manager6/dc/AuthEditLDAP.js index 2ce16e58c..9986db8a9 100644 --- a/www/manager6/dc/AuthEditLDAP.js +++ b/www/manager6/dc/AuthEditLDAP.js @@ -79,6 +79,21 @@ Ext.define('PVE.panel.LDAPInputPanel', { }, ]; + me.advancedItems = [ + { + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Check connection'), + name: 'check-connection', + uncheckedValue: 0, + checked: true, + autoEl: { + tag: 'div', + 'data-qtip': + gettext('Verify connection parameters and bind credentials on save'), + }, + }, + ]; + me.callParent(); }, onGetValues: function(values) { From 3a9764ad80ff5a4d54c9ef97fcf0647212dac0ee Mon Sep 17 00:00:00 2001 From: Philipp Hufnagl Date: Thu, 10 Aug 2023 12:09:01 +0200 Subject: [PATCH 171/398] fix #474: api: allow transfer from container/vms When the newly introduced optional parameter "transfer" is set, the user add a vm/container to a pool even if it is already in one. If so it will be removed from the old pool Signed-off-by: Philipp Hufnagl --- PVE/API2/Pool.pm | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/PVE/API2/Pool.pm b/PVE/API2/Pool.pm index 007fc815e..3c0ae2a07 100644 --- a/PVE/API2/Pool.pm +++ b/PVE/API2/Pool.pm @@ -131,6 +131,11 @@ __PACKAGE__->register_method ({ type => 'string', format => 'pve-storage-id-list', optional => 1, }, + transfer => { + description => "Allow transferring VMs to another pool.", + type => 'boolean', + optional => 1, + }, delete => { description => "Remove vms/storage (instead of adding it).", type => 'boolean', @@ -165,8 +170,15 @@ __PACKAGE__->register_method ({ } else { die "VM $vmid is already a pool member\n" if $pool_config->{vms}->{$vmid}; my $existing_pool = $usercfg->{vms}->{$vmid}; - die "VM $vmid belongs already to pool '$existing_pool'\n" if defined($existing_pool); - + if (defined($existing_pool)) { + if ($param->{transfer}) { + $rpcenv->check($authuser, "/pool/$existing_pool", ['Pool.Allocate']); + my $existing_pool_config = $usercfg->{pools}->{$existing_pool}; + delete $existing_pool_config->{vms}->{$vmid}; + } else { + die "VM $vmid belongs already to pool '$existing_pool' and transfer is not set\n"; + } + } $pool_config->{vms}->{$vmid} = 1; $usercfg->{vms}->{$vmid} = $pool; } From 9d757d7185036b283b410a45d7562dd741de6e60 Mon Sep 17 00:00:00 2001 From: Philipp Hufnagl Date: Thu, 10 Aug 2023 12:09:02 +0200 Subject: [PATCH 172/398] fix #474: ui: allow transfer from container/vms A user can no see all vms/containers, even the ones that are already a member of a pool. They can be transfered now after checking the newly introduced "allow transfer" checkbox. Signed-off-by: Philipp Hufnagl --- www/manager6/grid/PoolMembers.js | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js index 6acb622d6..224daca34 100644 --- a/www/manager6/grid/PoolMembers.js +++ b/www/manager6/grid/PoolMembers.js @@ -1,7 +1,7 @@ Ext.define('PVE.pool.AddVM', { extend: 'Proxmox.window.Edit', width: 600, - height: 400, + height: 420, isAdd: true, isCreate: true, initComponent: function() { @@ -30,7 +30,7 @@ Ext.define('PVE.pool.AddVM', { ], filters: [ function(item) { - return (item.data.type === 'lxc' || item.data.type === 'qemu') && item.data.pool === ''; + return (item.data.type === 'lxc' || item.data.type === 'qemu') &&item.data.pool !== me.pool; }, ], }); @@ -63,6 +63,10 @@ Ext.define('PVE.pool.AddVM', { header: gettext('Node'), dataIndex: 'node', }, + { + header: gettext('Pool'), + dataIndex: 'pool', + }, { header: gettext('Status'), dataIndex: 'uptime', @@ -85,9 +89,16 @@ Ext.define('PVE.pool.AddVM', { }, ], }); + + let transfer = Ext.create('Ext.form.field.Checkbox', { + name: 'transfer', + boxLabel: gettext('Allow Transfer'), + inputValue: 1, + value: 0, + }); Ext.apply(me, { subject: gettext('Virtual Machine'), - items: [vmsField, vmGrid], + items: [vmsField, vmGrid, transfer], }); me.callParent(); From 7c236b5c11b0c1c020aa4735319e62f7f01bbbd4 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:51 +0200 Subject: [PATCH 173/398] d/control: add dependency to `libpve-notify-perl` Signed-off-by: Lukas Wagner --- debian/control | 2 ++ 1 file changed, 2 insertions(+) diff --git a/debian/control b/debian/control index 34a11f89a..c511efb19 100644 --- a/debian/control +++ b/debian/control @@ -14,6 +14,7 @@ Build-Depends: debhelper-compat (= 13), libpve-common-perl (>= 7.2-6), libpve-guest-common-perl (>= 5.0.2), libpve-http-server-perl (>= 2.0-12), + libpve-notify-perl, libpve-rs-perl (>= 0.7.1), libpve-storage-perl (>= 6.3-2), libtemplate-perl, @@ -61,6 +62,7 @@ Depends: apt (>= 1.5~), libpve-common-perl (>= 7.2-7), libpve-guest-common-perl (>= 5.0.2), libpve-http-server-perl (>= 4.1-1), + libpve-notify-perl, libpve-rs-perl (>= 0.7.1), libpve-storage-perl (>= 7.2-12), librados2-perl (>= 1.3-1), From c4afde55f2f9091644dd2e7cff8c069f59bb4215 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:52 +0200 Subject: [PATCH 174/398] vzdump: send notifications via new notification module ... instead of using sendmail directly. If the new 'notification-target' parameter is set, we send the notification to this endpoint or group. If 'mailto' is set, we add a temporary endpoint and a temporary group containg both targets. This commit also refactors the old 'sendmail' sub heavily: - Use template-based notification text instead of endless string concatenations - Removing the old plaintext/HTML table rendering in favor of the new template/property-based approach offered by the `proxmox-notify` crate. - Rename `sendmail` sub to `send_notification` - Breaking out some of the code into helper subs, hopefully reducing the spaghetti factor a bit Signed-off-by: Lukas Wagner --- PVE/API2/VZDump.pm | 10 +- PVE/VZDump.pm | 351 +++++++++++++++++++++++++-------------------- test/mail_test.pl | 36 ++--- 3 files changed, 222 insertions(+), 175 deletions(-) diff --git a/PVE/API2/VZDump.pm b/PVE/API2/VZDump.pm index e3dcd0bd2..3886772ed 100644 --- a/PVE/API2/VZDump.pm +++ b/PVE/API2/VZDump.pm @@ -44,7 +44,9 @@ __PACKAGE__->register_method ({ ."'Datastore.AllocateSpace' on the backup storage. The 'tmpdir', 'dumpdir' and " ."'script' parameters are restricted to the 'root\@pam' user. The 'maxfiles' and " ."'prune-backups' settings require 'Datastore.Allocate' on the backup storage. The " - ."'bwlimit', 'performance' and 'ionice' parameters require 'Sys.Modify' on '/'.", + ."'bwlimit', 'performance' and 'ionice' parameters require 'Sys.Modify' on '/'. " + ."If 'notification-target' is set, then the 'Mapping.Use' permission is needed on " + ."'/mapping/notification/'.", user => 'all', }, protected => 1, @@ -113,6 +115,10 @@ __PACKAGE__->register_method ({ $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.AllocateSpace' ]); } + if (my $target = $param->{'notification-target'}) { + PVE::Notify::check_may_use_target($target, $rpcenv); + } + my $worker = sub { my $upid = shift; @@ -127,7 +133,7 @@ __PACKAGE__->register_method ({ $vzdump->getlock($upid); # only one process allowed }; if (my $err = $@) { - $vzdump->sendmail([], 0, $err); + $vzdump->send_notification([], 0, $err); exit(-1); } diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index c58e5f78f..7dc9f31ef 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -19,6 +19,7 @@ use PVE::Exception qw(raise_param_exc); use PVE::HA::Config; use PVE::HA::Env::PVE2; use PVE::JSONSchema qw(get_standard_option); +use PVE::Notify; use PVE::RPCEnvironment; use PVE::Storage; use PVE::VZDump::Common; @@ -317,21 +318,90 @@ sub read_vzdump_defaults { return $res; } -use constant MAX_MAIL_SIZE => 1024*1024; -sub sendmail { - my ($self, $tasklist, $totaltime, $err, $detail_pre, $detail_post) = @_; +sub read_backup_task_logs { + my ($task_list) = @_; - my $opts = $self->{opts}; + my $task_logs = ""; - my $mailto = $opts->{mailto}; + for my $task (@$task_list) { + my $vmid = $task->{vmid}; + my $log_file = $task->{tmplog}; + if (!$task->{tmplog}) { + $task_logs .= "$vmid: no log available\n\n"; + next; + } + if (open (my $TMP, '<', "$log_file")) { + while (my $line = <$TMP>) { + next if $line =~ /^status: \d+/; # not useful in mails + $task_logs .= encode8bit ("$vmid: $line"); + } + close ($TMP); + } else { + $task_logs .= "$vmid: Could not open log file\n\n"; + } + $task_logs .= "\n"; + } - return if !($mailto && scalar(@$mailto)); + return $task_logs; +} - my $cmdline = $self->{cmdline}; +sub build_guest_table { + my ($task_list) = @_; - my $ecount = 0; - foreach my $task (@$tasklist) { - $ecount++ if $task->{state} ne 'ok'; + my $table = { + schema => { + columns => [ + { + label => "VMID", + id => "vmid" + }, + { + label => "Name", + id => "name" + }, + { + label => "Status", + id => "status" + }, + { + label => "Time", + id => "time", + renderer => "duration" + }, + { + label => "Size", + id => "size", + renderer => "human-bytes" + }, + { + label => "Filename", + id => "filename" + }, + ] + }, + data => [] + }; + + for my $task (@$task_list) { + my $successful = $task->{state} eq 'ok'; + my $size = $successful ? $task->{size} : 0; + my $filename = $successful ? $task->{target} : undef; + push @{$table->{data}}, { + "vmid" => $task->{vmid}, + "name" => $task->{hostname}, + "status" => $task->{state}, + "time" => $task->{backuptime}, + "size" => $size, + "filename" => $filename, + }; + } + + return $table; +} + +sub sanitize_task_list { + my ($task_list) = @_; + for my $task (@$task_list) { chomp $task->{msg} if $task->{msg}; $task->{backuptime} = 0 if !$task->{backuptime}; $task->{size} = 0 if !$task->{size}; @@ -342,164 +412,133 @@ sub sendmail { $task->{msg} = 'aborted'; } } +} - my $notify = $opts->{mailnotification} || 'always'; - return if (!$ecount && !$err && ($notify eq 'failure')); +sub count_failed_tasks { + my ($tasklist) = @_; + + my $error_count = 0; + for my $task (@$tasklist) { + $error_count++ if $task->{state} ne 'ok'; + } + + return $error_count; +} + +sub get_hostname { + my $hostname = `hostname -f` || PVE::INotify::nodename(); + chomp $hostname; + return $hostname; +} + +my $subject_template = "vzdump backup status ({{hostname}}): {{status-text}}"; + +my $body_template = < 1024*1024; + +sub send_notification { + my ($self, $tasklist, $total_time, $err, $detail_pre, $detail_post) = @_; + + my $opts = $self->{opts}; + my $mailto = $opts->{mailto}; + my $cmdline = $self->{cmdline}; + my $target = $opts->{"notification-target"}; + # Fall back to 'mailnotification' if 'notification-policy' is not set. + # If both are set, 'notification-policy' takes precedence + my $policy = $opts->{"notification-policy"} // $opts->{mailnotification} // 'always'; + + return if ($policy eq 'never'); + + sanitize_task_list($tasklist); + my $error_count = count_failed_tasks($tasklist); + + my $failed = ($error_count || $err); + + return if (!$failed && ($policy eq 'failure')); + + my $status_text = $failed ? 'backup failed' : 'backup successful'; - my $stat = ($ecount || $err) ? 'backup failed' : 'backup successful'; if ($err) { if ($err =~ /\n/) { - $stat .= ": multiple problems"; + $status_text .= ": multiple problems"; } else { - $stat .= ": $err"; + $status_text .= ": $err"; $err = undef; } } - my $hostname = `hostname -f` || PVE::INotify::nodename(); - chomp $hostname; + my $text_log_part = "$cmdline\n\n"; + $text_log_part .= $detail_pre . "\n" if defined($detail_pre); + $text_log_part .= read_backup_task_logs($tasklist); + $text_log_part .= $detail_post if defined($detail_post); - # text part - my $text = $err ? "$err\n\n" : ''; - my $namelength = 20; - $text .= sprintf ( - "%-10s %-${namelength}s %-6s %10s %10s %s\n", - qw(VMID NAME STATUS TIME SIZE FILENAME) - ); - foreach my $task (@$tasklist) { - my $name = substr($task->{hostname}, 0, $namelength); - my $successful = $task->{state} eq 'ok'; - my $size = $successful ? format_size ($task->{size}) : 0; - my $filename = $successful ? $task->{target} : '-'; - my $size_fmt = $successful ? "%10s": "%8.2fMB"; - $text .= sprintf( - "%-10s %-${namelength}s %-6s %10s $size_fmt %s\n", - $task->{vmid}, - $name, - $task->{state}, - format_time($task->{backuptime}), - $size, - $filename, + if (length($text_log_part) > MAX_LOG_SIZE) + { + # Let's limit the maximum length of included logs + $text_log_part = "Log output was too long to be sent. ". + "See Task History for details!\n"; + }; + + my $notification_props = { + "hostname" => get_hostname(), + "error-message" => $err, + "guest-table" => build_guest_table($tasklist), + "logs" => $text_log_part, + "status-text" => $status_text, + "total-time" => $total_time, + }; + + my $notification_config = PVE::Notify::read_config(); + + if ($mailto && scalar(@$mailto)) { + # <, >, @ is not allowed in endpoint names, but only it is only + # verified once the config is serialized. That means that + # we can rely on that fact that no other endpoint with this name exists. + my $endpoint_name = "mail-to-<" . join(",", @$mailto) . ">"; + $notification_config->add_sendmail_endpoint( + $endpoint_name, + $mailto, + undef, + undef, + "vzdump backup tool"); + + my $endpoints = [$endpoint_name]; + + # Create an anonymous group containing the sendmail endpoint and the + # $target endpoint, if specified + if ($target) { + push @$endpoints, $target; + } + + $target = "group-$endpoint_name"; + $notification_config->add_group( + $target, + $endpoints, ); } - my $text_log_part; - $text_log_part .= "\nDetailed backup logs:\n\n"; - $text_log_part .= "$cmdline\n\n"; + return if (!$target); - $text_log_part .= $detail_pre . "\n" if defined($detail_pre); - foreach my $task (@$tasklist) { - my $vmid = $task->{vmid}; - my $log = $task->{tmplog}; - if (!$log) { - $text_log_part .= "$vmid: no log available\n\n"; - next; - } - if (open (my $TMP, '<', "$log")) { - while (my $line = <$TMP>) { - next if $line =~ /^status: \d+/; # not useful in mails - $text_log_part .= encode8bit ("$vmid: $line"); - } - close ($TMP); - } else { - $text_log_part .= "$vmid: Could not open log file\n\n"; - } - $text_log_part .= "\n"; - } - $text_log_part .= $detail_post if defined($detail_post); + my $severity = $failed ? "error" : "info"; - # html part - my $html = "\n"; - $html .= "

" . (escape_html($err) =~ s/\n/
/gr) . "

\n" if $err; - $html .= "\n"; - $html .= "\n"; - - my $ssize = 0; - foreach my $task (@$tasklist) { - my $vmid = $task->{vmid}; - my $name = $task->{hostname}; - - if ($task->{state} eq 'ok') { - $ssize += $task->{size}; - - $html .= sprintf ( - "\n", - $vmid, - $name, - format_time($task->{backuptime}), - format_size ($task->{size}), - escape_html ($task->{target}), - ); - } else { - $html .= sprintf ( - "\n", - $vmid, - $name, - format_time($task->{backuptime}), - escape_html ($task->{msg}), - ); - } - } - - $html .= sprintf ("", - format_time ($totaltime), format_size ($ssize)); - - $html .= "\n
VMIDNAMESTATUSTIMESIZEFILENAME
%s%sOK%s%s%s
%s%sFAILED%s%s
TOTAL%s%s


\n"; - my $html_log_part; - $html_log_part .= "Detailed backup logs:

\n"; - $html_log_part .= "
\n";
-    $html_log_part .= escape_html($cmdline) . "\n\n";
-
-    $html_log_part .= escape_html($detail_pre) . "\n" if defined($detail_pre);
-    foreach my $task (@$tasklist) {
-	my $vmid = $task->{vmid};
-	my $log = $task->{tmplog};
-	if (!$log) {
-	    $html_log_part .= "$vmid: no log available\n\n";
-	    next;
-	}
-	if (open (my $TMP, '<', "$log")) {
-	    while (my $line = <$TMP>) {
-		next if $line =~ /^status: \d+/; # not useful in mails
-		if ($line =~ m/^\S+\s\d+\s+\d+:\d+:\d+\s+(ERROR|WARN):/) {
-		    $html_log_part .= encode8bit ("$vmid: ".
-			escape_html ($line) . "");
-		} else {
-		    $html_log_part .= encode8bit ("$vmid: " . escape_html ($line));
-		}
-	    }
-	    close ($TMP);
-	} else {
-	    $html_log_part .= "$vmid: Could not open log file\n\n";
-	}
-	$html_log_part .= "\n";
-    }
-    $html_log_part .= escape_html($detail_post) if defined($detail_post);
-    $html_log_part .= "
"; - my $html_end = "\n\n"; - # end html part - - if (length($text) + length($text_log_part) + - length($html) + length($html_log_part) + - length($html_end) < MAX_MAIL_SIZE) - { - $html .= $html_log_part; - $html .= $html_end; - $text .= $text_log_part; - } else { - my $msg = "Log output was too long to be sent by mail. ". - "See Task History for details!\n"; - $text .= $msg; - $html .= "

$msg

"; - $html .= $html_end; - } - - my $subject = "vzdump backup status ($hostname) : $stat"; - - my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); - my $mailfrom = $dcconf->{email_from} || "root"; - - PVE::Tools::sendmail($mailto, $subject, $text, $html, $mailfrom, "vzdump backup tool"); + PVE::Notify::notify( + $target, + $severity, + $subject_template, + $body_template, + $notification_props, + $notification_config + ); }; sub new { @@ -632,7 +671,7 @@ sub new { } if ($errors) { - eval { $self->sendmail([], 0, $errors); }; + eval { $self->send_notification([], 0, $errors); }; debugmsg ('err', $@) if $@; die "$errors\n"; } @@ -1322,11 +1361,11 @@ sub exec_backup { my $totaltime = time() - $starttime; eval { - # otherwise $self->sendmail() will interpret it as multiple problems + # otherwise $self->send_notification() will interpret it as multiple problems my $chomped_err = $err; chomp($chomped_err) if $chomped_err; - $self->sendmail( + $self->send_notification( $tasklist, $totaltime, $chomped_err, diff --git a/test/mail_test.pl b/test/mail_test.pl index d01144419..0635ebb74 100755 --- a/test/mail_test.pl +++ b/test/mail_test.pl @@ -5,7 +5,7 @@ use warnings; use lib '..'; -use Test::More tests => 5; +use Test::More tests => 3; use Test::MockModule; use PVE::VZDump; @@ -29,17 +29,19 @@ sub prepare_mail_with_status { sub prepare_long_mail { open(TEST_FILE, '>', $TEST_FILE_PATH); # Removes previous content # 0.5 MB * 2 parts + the overview tables gives more than 1 MB mail - print TEST_FILE "a" x (1024*1024/2); + print TEST_FILE "a" x (1024*1024); close(TEST_FILE); } -my ($result_text, $result_html); +my $result_text; +my $result_properties; + +my $mock_notification_module = Test::MockModule->new('PVE::Notify'); +$mock_notification_module->mock('send_notification', sub { + my ($channel, $severity, $title, $text, $properties) = @_; -my $mock_tools_module = Test::MockModule->new('PVE::Tools'); -$mock_tools_module->mock('sendmail', sub { - my (undef, undef, $text, $html, undef, undef) = @_; $result_text = $text; - $result_html = $html; + $result_properties = $properties; }); my $mock_cluster_module = Test::MockModule->new('PVE::Cluster'); @@ -47,7 +49,9 @@ $mock_cluster_module->mock('cfs_read_file', sub { my $path = shift; if ($path eq 'datacenter.cfg') { - return {}; + return {}; + } elsif ($path eq 'notifications.cfg' || $path eq 'priv/notifications.cfg') { + return ''; } else { die "unexpected cfs_read_file\n"; } @@ -62,28 +66,26 @@ my $SELF = { my $task = { state => 'ok', vmid => '100', }; my $tasklist; sub prepare_test { - $result_text = $result_html = undef; + $result_text = undef; $task->{tmplog} = shift; $tasklist = [ $task ]; } { prepare_test($TEST_FILE_WRONG_PATH); - PVE::VZDump::sendmail($SELF, $tasklist, 0, undef, undef, undef); - like($result_text, $NO_LOGFILE, "Missing logfile is detected"); + PVE::VZDump::send_notification($SELF, $tasklist, 0, undef, undef, undef); + like($result_properties->{logs}, $NO_LOGFILE, "Missing logfile is detected"); } { prepare_test($TEST_FILE_PATH); prepare_mail_with_status(); - PVE::VZDump::sendmail($SELF, $tasklist, 0, undef, undef, undef); - unlike($result_text, $STATUS, "Status are not in text part of mails"); - unlike($result_html, $STATUS, "Status are not in HTML part of mails"); + PVE::VZDump::send_notification($SELF, $tasklist, 0, undef, undef, undef); + unlike($result_properties->{"status-text"}, $STATUS, "Status are not in text part of mails"); } { prepare_test($TEST_FILE_PATH); prepare_long_mail(); - PVE::VZDump::sendmail($SELF, $tasklist, 0, undef, undef, undef); - like($result_text, $LOG_TOO_LONG, "Text part of mails gets shortened"); - like($result_html, $LOG_TOO_LONG, "HTML part of mails gets shortened"); + PVE::VZDump::send_notification($SELF, $tasklist, 0, undef, undef, undef); + like($result_properties->{logs}, $LOG_TOO_LONG, "Text part of mails gets shortened"); } unlink $TEST_FILE_PATH; From d8e4ddb4ffd204046845626a0684a24d095ab5e8 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:53 +0200 Subject: [PATCH 175/398] test: rename mail_test.pl to vzdump_notification_test.pl Signed-off-by: Lukas Wagner --- test/Makefile | 8 ++++---- test/{mail_test.pl => vzdump_notification_test.pl} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename test/{mail_test.pl => vzdump_notification_test.pl} (100%) diff --git a/test/Makefile b/test/Makefile index cccdc1c93..62d750506 100644 --- a/test/Makefile +++ b/test/Makefile @@ -5,7 +5,7 @@ all: export PERLLIB=.. .PHONY: check -check: test-replication test-balloon test-mail test-vzdump test-osd +check: test-replication test-balloon test-vzdump-notification test-vzdump test-osd .PHONY: test-balloon test-balloon: @@ -17,9 +17,9 @@ test-replication: replication1.t replication2.t replication3.t replication4.t re replication%.t: replication_test%.pl ./$< -.PHONY: test-mail -test-mail: - ./mail_test.pl +.PHONY: test-vzdump-notification +test-vzdump-notification: + ./vzdump_notification_test.pl .PHONY: test-vzdump test-vzdump: test-vzdump-guest-included test-vzdump-new diff --git a/test/mail_test.pl b/test/vzdump_notification_test.pl similarity index 100% rename from test/mail_test.pl rename to test/vzdump_notification_test.pl From 05855908c435f2293199af929188849fc0e53a91 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:54 +0200 Subject: [PATCH 176/398] api: apt: send notification via new notification module ... instead of using sendmail directly If the new 'target-package-updates' is set, we send a notification to this target. If not, we continue to send a mail to root@pam (if the mail address is configured) Signed-off-by: Lukas Wagner --- PVE/API2/APT.pm | 101 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 71 insertions(+), 30 deletions(-) diff --git a/PVE/API2/APT.pm b/PVE/API2/APT.pm index f73535e15..a213fc59d 100644 --- a/PVE/API2/APT.pm +++ b/PVE/API2/APT.pm @@ -19,6 +19,7 @@ use PVE::DataCenterConfig; use PVE::SafeSyslog; use PVE::INotify; use PVE::Exception; +use PVE::Notify; use PVE::RESTHandler; use PVE::RPCEnvironment; use PVE::API2Tools; @@ -272,6 +273,12 @@ __PACKAGE__->register_method({ return $pkglist; }}); +my $updates_available_subject_template = "New software packages available ({{hostname}})"; +my $updates_available_body_template = <register_method({ name => 'update_database', path => 'update', @@ -279,6 +286,8 @@ __PACKAGE__->register_method({ description => "This is used to resynchronize the package index files from their sources (apt-get update).", permissions => { check => ['perm', '/nodes/{node}', [ 'Sys.Modify' ]], + description => "If 'notify: target-package-updates' is set, then the user must have the " + . "'Mapping.Use' permission on '/mapping/notification/'", }, protected => 1, proxyto => 'node', @@ -307,6 +316,17 @@ __PACKAGE__->register_method({ my ($param) = @_; my $rpcenv = PVE::RPCEnvironment::get(); + my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); + my $target = $dcconf->{notify}->{'target-package-updates'} // + PVE::Notify::default_target(); + + if ($param->{notify} && $target ne PVE::Notify::default_target()) { + # If we notify via anything other than the default target (mail to root), + # then the user must have the proper permissions for the target. + # The mail-to-root target does not require these, as otherwise + # we would break compatibility. + PVE::Notify::check_may_use_target($target, $rpcenv); + } my $authuser = $rpcenv->get_user(); @@ -314,7 +334,6 @@ __PACKAGE__->register_method({ my $upid = shift; # setup proxy for apt - my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); my $aptconf = "// no proxy configured\n"; if ($dcconf->{http_proxy}) { @@ -336,39 +355,59 @@ __PACKAGE__->register_method({ my $pkglist = &$update_pve_pkgstatus(); if ($param->{notify} && scalar(@$pkglist)) { + my $updates_table = { + schema => { + columns => [ + { + label => "Package", + id => "package", + }, + { + label => "Old Version", + id => "old-version", + }, + { + label => "New Version", + id => "new-version", + } + ] + }, + data => [] + }; - my $usercfg = PVE::Cluster::cfs_read_file("user.cfg"); - my $rootcfg = $usercfg->{users}->{'root@pam'} || {}; - my $mailto = $rootcfg->{email}; + my $hostname = `hostname -f` || PVE::INotify::nodename(); + chomp $hostname; - if ($mailto) { - my $hostname = `hostname -f` || PVE::INotify::nodename(); - chomp $hostname; - my $mailfrom = $dcconf->{email_from} || "root"; - my $subject = "New software packages available ($hostname)"; + my $count = 0; + foreach my $p (sort {$a->{Package} cmp $b->{Package} } @$pkglist) { + next if $p->{NotifyStatus} && $p->{NotifyStatus} eq $p->{Version}; + $count++; - my $data = "The following updates are available:\n\n"; - - my $count = 0; - foreach my $p (sort {$a->{Package} cmp $b->{Package} } @$pkglist) { - next if $p->{NotifyStatus} && $p->{NotifyStatus} eq $p->{Version}; - $count++; - if ($p->{OldVersion}) { - $data .= "$p->{Package}: $p->{OldVersion} ==> $p->{Version}\n"; - } else { - $data .= "$p->{Package}: $p->{Version} (new)\n"; - } - } - - return if !$count; - - PVE::Tools::sendmail($mailto, $subject, $data, undef, $mailfrom, ''); - - foreach my $pi (@$pkglist) { - $pi->{NotifyStatus} = $pi->{Version}; - } - PVE::Tools::file_set_contents($pve_pkgstatus_fn, encode_json($pkglist)); + push @{$updates_table->{data}}, { + "package" => $p->{Package}, + "old-version" => $p->{OldVersion}, + "new-version" => $p->{Version} + }; } + + return if !$count; + + my $properties = { + updates => $updates_table, + hostname => $hostname, + }; + + PVE::Notify::info( + $target, + $updates_available_subject_template, + $updates_available_body_template, + $properties, + ); + + foreach my $pi (@$pkglist) { + $pi->{NotifyStatus} = $pi->{Version}; + } + PVE::Tools::file_set_contents($pve_pkgstatus_fn, encode_json($pkglist)); } return; @@ -378,6 +417,8 @@ __PACKAGE__->register_method({ }}); + + __PACKAGE__->register_method({ name => 'changelog', path => 'changelog', From 2ab19e8351ba5c719d72f1ae25cc2a4c9326c376 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:55 +0200 Subject: [PATCH 177/398] api: replication: send notifications via new notification module If the new 'target-replication' option in datacenter.cfg is set to a notification target, we send notifications that way. If it is not set, we continue send a notification to the default target (mail to root@pam). There is also a new 'replication' option. It controls whether to send a notification at all. Signed-off-by: Lukas Wagner --- PVE/API2/Replication.pm | 63 ++++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/PVE/API2/Replication.pm b/PVE/API2/Replication.pm index 89c5a802f..d61518ba6 100644 --- a/PVE/API2/Replication.pm +++ b/PVE/API2/Replication.pm @@ -15,6 +15,7 @@ use PVE::QemuConfig; use PVE::QemuServer; use PVE::LXC::Config; use PVE::LXC; +use PVE::Notify; use PVE::RESTHandler; @@ -91,6 +92,24 @@ my sub _should_mail_at_failcount { return $i * 48 == $fail_count; }; +my $replication_error_subject_template = "Replication Job: '{{job-id}}' failed"; +my $replication_error_body_template = <{schedule} // '*/15'; - - my $msg = "Replication job $job->{id} with target '$job->{target}' and schedule"; - $msg .= " '$schedule' failed!\n"; - - $msg .= " Last successful sync: "; - if (my $last_sync = $jobstate->{last_sync}) { - $msg .= render_timestamp($last_sync) ."\n"; - } else { - $msg .= "None/Unknown\n"; - } # not yet updated, so $job->next_sync here is actually the current one. # NOTE: Copied from PVE::ReplicationState::job_status() my $next_sync = $job->{next_sync} + 60 * ($fail_count <= 3 ? 5 * $fail_count : 30); - $msg .= " Next sync try: " . render_timestamp($next_sync) ."\n"; - $msg .= " Failure count: $fail_count\n"; + # The replication job is run every 15 mins if no schedule is set. + my $schedule = $job->{schedule} // '*/15'; - if ($fail_count == 3) { - $msg .= "\nNote: The system will now reduce the frequency of error reports,"; - $msg .= " as the job appears to be stuck.\n"; - } - - $msg .= "\nError:\n$err"; + my $properties = { + "failure-count" => $fail_count, + "last-sync" => $jobstate->{last_sync}, + "next-sync" => $next_sync, + "job-id" => $job->{id}, + "job-target" => $job->{target}, + "job-schedule" => $schedule, + "error" => $err, + }; eval { - PVE::Tools::sendmail('root', "Replication Job: $job->{id} failed", $msg) + my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); + my $target = $dcconf->{notify}->{'target-replication'} // PVE::Notify::default_target(); + my $notify = $dcconf->{notify}->{'replication'} // 'always'; + + if ($notify eq 'always') { + PVE::Notify::error( + $target, + $replication_error_subject_template, + $replication_error_body_template, + $properties + ); + } + }; warn ": $@" if $@; } From b6fa29f3f57039dc077fad5b91d6818f5ab70991 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:56 +0200 Subject: [PATCH 178/398] api: prepare api handler module for notification config This commit adds a new Perl module, PVE::API2::Cluster::Notification. The module will contain all API handlers for the new notification subsystem. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster.pm | 7 +++ PVE/API2/Cluster/Makefile | 1 + PVE/API2/Cluster/Notifications.pm | 71 +++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 PVE/API2/Cluster/Notifications.pm diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm index 31e9f51e4..04387ab48 100644 --- a/PVE/API2/Cluster.pm +++ b/PVE/API2/Cluster.pm @@ -29,6 +29,7 @@ use PVE::API2::Cluster::Ceph; use PVE::API2::Cluster::Mapping; use PVE::API2::Cluster::Jobs; use PVE::API2::Cluster::MetricServer; +use PVE::API2::Cluster::Notifications; use PVE::API2::ClusterConfig; use PVE::API2::Firewall::Cluster; use PVE::API2::HAConfig; @@ -52,6 +53,11 @@ __PACKAGE__->register_method ({ path => 'metrics', }); +__PACKAGE__->register_method ({ + subclass => "PVE::API2::Cluster::Notifications", + path => 'notifications', +}); + __PACKAGE__->register_method ({ subclass => "PVE::API2::ClusterConfig", path => 'config', @@ -149,6 +155,7 @@ __PACKAGE__->register_method ({ { name => 'log' }, { name => 'mapping' }, { name => 'metrics' }, + { name => 'notifications' }, { name => 'nextid' }, { name => 'options' }, { name => 'replication' }, diff --git a/PVE/API2/Cluster/Makefile b/PVE/API2/Cluster/Makefile index 0c52a2410..b109e5cb6 100644 --- a/PVE/API2/Cluster/Makefile +++ b/PVE/API2/Cluster/Makefile @@ -8,6 +8,7 @@ PERLSOURCE= \ BackupInfo.pm \ MetricServer.pm \ Mapping.pm \ + Notifications.pm \ Jobs.pm \ Ceph.pm diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm new file mode 100644 index 000000000..1efebbc12 --- /dev/null +++ b/PVE/API2/Cluster/Notifications.pm @@ -0,0 +1,71 @@ +package PVE::API2::Cluster::Notifications; + +use warnings; +use strict; + +use Storable qw(dclone); +use JSON; + +use PVE::Tools qw(extract_param); +use PVE::JSONSchema qw(get_standard_option); +use PVE::RESTHandler; +use PVE::Notify; + +use base qw(PVE::RESTHandler); + +sub make_properties_optional { + my ($properties) = @_; + $properties = dclone($properties); + + for my $key (keys %$properties) { + $properties->{$key}->{optional} = 1 if $key ne 'name'; + } + + return $properties; +} + +sub raise_api_error { + my ($api_error) = @_; + + if (!(ref($api_error) eq 'HASH' && $api_error->{message} && $api_error->{code})) { + die $api_error; + } + + my $msg = "$api_error->{message}\n"; + my $exc = PVE::Exception->new($msg, code => $api_error->{code}); + + my (undef, $filename, $line) = caller; + + $exc->{filename} = $filename; + $exc->{line} = $line; + + die $exc; +} + +__PACKAGE__->register_method ({ + name => 'index', + path => '', + method => 'GET', + description => 'Index for notification-related API endpoints.', + permissions => { user => 'all' }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => 'object', + properties => {}, + }, + links => [ { rel => 'child', href => '{name}' } ], + }, + code => sub { + my $result = [ + ]; + + return $result; + } +}); + +1; From 95c2dc1bc9fd78cf9c508e7eb1ec1b097b54d7b2 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:57 +0200 Subject: [PATCH 179/398] api: notification: add api routes for groups The Perl part of the API methods primarily defines the API schema, checks for any needed priviledges and then calls the actual Rust implementation exposed via perlmod. Any errors returned by the Rust code are translated into PVE::Exception, so that the API call fails with the correct HTTP error code. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 263 ++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index 1efebbc12..55dd650d1 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -42,6 +42,24 @@ sub raise_api_error { die $exc; } +sub filter_entities_by_privs { + my ($rpcenv, $entities) = @_; + my $authuser = $rpcenv->get_user(); + + my $can_see_mapping_privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; + + my $filtered = [grep { + $rpcenv->check_any( + $authuser, + "/mapping/notification/$_->{name}", + $can_see_mapping_privs, + 1 + ) + } @$entities]; + + return $filtered; +} + __PACKAGE__->register_method ({ name => 'index', path => '', @@ -62,10 +80,255 @@ __PACKAGE__->register_method ({ }, code => sub { my $result = [ + { name => 'groups' }, ]; return $result; } }); +my $group_properties = { + name => { + description => 'Name of the group.', + type => 'string', + format => 'pve-configid', + }, + 'endpoint' => { + type => 'array', + items => { + type => 'string', + format => 'pve-configid', + }, + description => 'List of included endpoints', + }, + 'comment' => { + description => 'Comment', + type => 'string', + optional => 1, + }, + filter => { + description => 'Name of the filter that should be applied.', + type => 'string', + format => 'pve-configid', + optional => 1, + }, +}; + +__PACKAGE__->register_method ({ + name => 'get_groups', + path => 'groups', + method => 'GET', + description => 'Returns a list of all groups', + protected => 1, + permissions => { + description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" + . " 'Mapping.Audit' permissions on '/mapping/notification/'.", + user => 'all', + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => 'object', + properties => $group_properties, + }, + links => [ { rel => 'child', href => '{name}' } ], + }, + code => sub { + my $config = PVE::Notify::read_config(); + my $rpcenv = PVE::RPCEnvironment::get(); + + my $entities = eval { + $config->get_groups(); + }; + raise_api_error($@) if $@; + + return filter_entities_by_privs($rpcenv, $entities); + } +}); + +__PACKAGE__->register_method ({ + name => 'get_group', + path => 'groups/{name}', + method => 'GET', + description => 'Return a specific group', + protected => 1, + permissions => { + check => ['or', + ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], + ], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { + type => 'object', + properties => { + %$group_properties, + digest => get_standard_option('pve-config-digest'), + }, + }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + my $config = PVE::Notify::read_config(); + + my $group = eval { + $config->get_group($name) + }; + + raise_api_error($@) if $@; + $group->{digest} = $config->digest(); + + return $group; + } +}); + +__PACKAGE__->register_method ({ + name => 'create_group', + path => 'groups', + protected => 1, + method => 'POST', + description => 'Create a new group', + permissions => { + check => ['perm', '/mapping/notification', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => $group_properties, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $endpoint = extract_param($param, 'endpoint'); + my $comment = extract_param($param, 'comment'); + my $filter = extract_param($param, 'filter'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->add_group( + $name, + $endpoint, + $comment, + $filter, + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'update_group', + path => 'groups/{name}', + protected => 1, + method => 'PUT', + description => 'Update existing group', + permissions => { + check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + %{ make_properties_optional($group_properties) }, + delete => { + type => 'array', + items => { + type => 'string', + format => 'pve-configid', + }, + optional => 1, + description => 'A list of settings you want to delete.', + }, + digest => get_standard_option('pve-config-digest'), + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $endpoint = extract_param($param, 'endpoint'); + my $comment = extract_param($param, 'comment'); + my $filter = extract_param($param, 'filter'); + my $digest = extract_param($param, 'digest'); + my $delete = extract_param($param, 'delete'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->update_group( + $name, + $endpoint, + $comment, + $filter, + $delete, + $digest, + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'delete_group', + protected => 1, + path => 'groups/{name}', + method => 'DELETE', + description => 'Remove group', + permissions => { + check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + $config->delete_group($name); + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + 1; From c2c31251265c9ffaddcecd46003affe5429145ca Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:58 +0200 Subject: [PATCH 180/398] api: notification: add api routes for sendmail endpoints The Perl part of the API methods primarily defines the API schema, checks for any needed priviledges and then calls the actual Rust implementation exposed via perlmod. Any errors returned by the Rust code are translated into PVE::Exception, so that the API call fails with the correct HTTP error code. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 305 ++++++++++++++++++++++++++++++ 1 file changed, 305 insertions(+) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index 55dd650d1..2d907c35a 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -80,6 +80,7 @@ __PACKAGE__->register_method ({ }, code => sub { my $result = [ + { name => 'endpoints' }, { name => 'groups' }, ]; @@ -87,6 +88,33 @@ __PACKAGE__->register_method ({ } }); +__PACKAGE__->register_method ({ + name => 'endpoints_index', + path => 'endpoints', + method => 'GET', + description => 'Index for all available endpoint types.', + permissions => { user => 'all' }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => 'object', + properties => {}, + }, + links => [ { rel => 'child', href => '{name}' } ], + }, + code => sub { + my $result = [ + { name => 'sendmail' }, + ]; + + return $result; + } +}); + my $group_properties = { name => { description => 'Name of the group.', @@ -331,4 +359,281 @@ __PACKAGE__->register_method ({ } }); +my $sendmail_properties = { + name => { + description => 'The name of the endpoint.', + type => 'string', + format => 'pve-configid', + }, + mailto => { + type => 'array', + items => { + type => 'string', + format => 'email-or-username', + }, + description => 'List of email recipients', + optional => 1, + }, + 'mailto-user' => { + type => 'array', + items => { + type => 'string', + format => 'pve-userid', + }, + description => 'List of users', + optional => 1, + }, + 'from-address' => { + description => '`From` address for the mail', + type => 'string', + optional => 1, + }, + author => { + description => 'Author of the mail', + type => 'string', + optional => 1, + }, + 'comment' => { + description => 'Comment', + type => 'string', + optional => 1, + }, + filter => { + description => 'Name of the filter that should be applied.', + type => 'string', + format => 'pve-configid', + optional => 1, + }, +}; + +__PACKAGE__->register_method ({ + name => 'get_sendmail_endpoints', + path => 'endpoints/sendmail', + method => 'GET', + description => 'Returns a list of all sendmail endpoints', + permissions => { + description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" + . " 'Mapping.Audit' permissions on '/mapping/notification/'.", + user => 'all', + }, + protected => 1, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => 'object', + properties => $sendmail_properties, + }, + links => [ { rel => 'child', href => '{name}' } ], + }, + code => sub { + my $config = PVE::Notify::read_config(); + my $rpcenv = PVE::RPCEnvironment::get(); + + my $entities = eval { + $config->get_sendmail_endpoints(); + }; + raise_api_error($@) if $@; + + return filter_entities_by_privs($rpcenv, $entities); + } +}); + +__PACKAGE__->register_method ({ + name => 'get_sendmail_endpoint', + path => 'endpoints/sendmail/{name}', + method => 'GET', + description => 'Return a specific sendmail endpoint', + permissions => { + check => ['or', + ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], + ], + }, + protected => 1, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { + type => 'object', + properties => { + %$sendmail_properties, + digest => get_standard_option('pve-config-digest'), + } + + }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + my $config = PVE::Notify::read_config(); + my $endpoint = eval { + $config->get_sendmail_endpoint($name) + }; + + raise_api_error($@) if $@; + $endpoint->{digest} = $config->digest(); + + return $endpoint; + } +}); + +__PACKAGE__->register_method ({ + name => 'create_sendmail_endpoint', + path => 'endpoints/sendmail', + protected => 1, + method => 'POST', + description => 'Create a new sendmail endpoint', + permissions => { + check => ['perm', '/mapping/notification', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => $sendmail_properties, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $mailto = extract_param($param, 'mailto'); + my $mailto_user = extract_param($param, 'mailto-user'); + my $from_address = extract_param($param, 'from-address'); + my $author = extract_param($param, 'author'); + my $comment = extract_param($param, 'comment'); + my $filter = extract_param($param, 'filter'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->add_sendmail_endpoint( + $name, + $mailto, + $mailto_user, + $from_address, + $author, + $comment, + $filter + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'update_sendmail_endpoint', + path => 'endpoints/sendmail/{name}', + protected => 1, + method => 'PUT', + description => 'Update existing sendmail endpoint', + permissions => { + check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + %{ make_properties_optional($sendmail_properties) }, + delete => { + type => 'array', + items => { + type => 'string', + format => 'pve-configid', + }, + optional => 1, + description => 'A list of settings you want to delete.', + }, + digest => get_standard_option('pve-config-digest'), + + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $mailto = extract_param($param, 'mailto'); + my $mailto_user = extract_param($param, 'mailto-user'); + my $from_address = extract_param($param, 'from-address'); + my $author = extract_param($param, 'author'); + my $comment = extract_param($param, 'comment'); + my $filter = extract_param($param, 'filter'); + + my $delete = extract_param($param, 'delete'); + my $digest = extract_param($param, 'digest'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->update_sendmail_endpoint( + $name, + $mailto, + $mailto_user, + $from_address, + $author, + $comment, + $filter, + $delete, + $digest, + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'delete_sendmail_endpoint', + protected => 1, + path => 'endpoints/sendmail/{name}', + method => 'DELETE', + description => 'Remove sendmail endpoint', + permissions => { + check => ['perm', '/mapping/notification', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + $config->delete_sendmail_endpoint($param->{name}); + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if ($@); + return; + } +}); + 1; From aed4eff9cffb515f4977e31a6ad1affe794c8824 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:16:59 +0200 Subject: [PATCH 181/398] api: notification: add api routes for gotify endpoints The Perl part of the API methods primarily defines the API schema, checks for any needed priviledges and then calls the actual Rust implementation exposed via perlmod. Any errors returned by the Rust code are translated into PVE::Exception, so that the API call fails with the correct HTTP error code. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 262 ++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index 2d907c35a..b65c6957a 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -24,6 +24,19 @@ sub make_properties_optional { return $properties; } +sub remove_protected_properties { + my ($properties, $to_remove) = @_; + $properties = dclone($properties); + + for my $key (keys %$properties) { + if (grep /^$key$/, @$to_remove) { + delete $properties->{$key}; + } + } + + return $properties; +} + sub raise_api_error { my ($api_error) = @_; @@ -108,6 +121,7 @@ __PACKAGE__->register_method ({ }, code => sub { my $result = [ + { name => 'gotify' }, { name => 'sendmail' }, ]; @@ -636,4 +650,252 @@ __PACKAGE__->register_method ({ } }); +my $gotify_properties = { + name => { + description => 'The name of the endpoint.', + type => 'string', + format => 'pve-configid', + }, + 'server' => { + description => 'Server URL', + type => 'string', + }, + 'token' => { + description => 'Secret token', + type => 'string', + }, + 'comment' => { + description => 'Comment', + type => 'string', + optional => 1, + }, + 'filter' => { + description => 'Name of the filter that should be applied.', + type => 'string', + format => 'pve-configid', + optional => 1, + } +}; + +__PACKAGE__->register_method ({ + name => 'get_gotify_endpoints', + path => 'endpoints/gotify', + method => 'GET', + description => 'Returns a list of all gotify endpoints', + protected => 1, + permissions => { + description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" + . " 'Mapping.Audit' permissions on '/mapping/notification/'.", + user => 'all', + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => 'object', + properties => remove_protected_properties($gotify_properties, ['token']), + }, + links => [ { rel => 'child', href => '{name}' } ], + }, + code => sub { + my $config = PVE::Notify::read_config(); + my $rpcenv = PVE::RPCEnvironment::get(); + + my $entities = eval { + $config->get_gotify_endpoints(); + }; + raise_api_error($@) if $@; + + return filter_entities_by_privs($rpcenv, $entities); + } +}); + +__PACKAGE__->register_method ({ + name => 'get_gotify_endpoint', + path => 'endpoints/gotify/{name}', + method => 'GET', + description => 'Return a specific gotify endpoint', + protected => 1, + permissions => { + check => ['or', + ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], + ], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + description => 'Name of the endpoint.' + }, + } + }, + returns => { + type => 'object', + properties => { + %{ remove_protected_properties($gotify_properties, ['token']) }, + digest => get_standard_option('pve-config-digest'), + } + }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + my $config = PVE::Notify::read_config(); + my $endpoint = eval { + $config->get_gotify_endpoint($name) + }; + + raise_api_error($@) if $@; + $endpoint->{digest} = $config->digest(); + + return $endpoint; + } +}); + +__PACKAGE__->register_method ({ + name => 'create_gotify_endpoint', + path => 'endpoints/gotify', + protected => 1, + method => 'POST', + description => 'Create a new gotify endpoint', + permissions => { + check => ['perm', '/mapping/notification', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => $gotify_properties, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $server = extract_param($param, 'server'); + my $token = extract_param($param, 'token'); + my $comment = extract_param($param, 'comment'); + my $filter = extract_param($param, 'filter'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->add_gotify_endpoint( + $name, + $server, + $token, + $comment, + $filter + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'update_gotify_endpoint', + path => 'endpoints/gotify/{name}', + protected => 1, + method => 'PUT', + description => 'Update existing gotify endpoint', + permissions => { + check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + %{ make_properties_optional($gotify_properties) }, + delete => { + type => 'array', + items => { + type => 'string', + format => 'pve-configid', + }, + optional => 1, + description => 'A list of settings you want to delete.', + }, + digest => get_standard_option('pve-config-digest'), + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $server = extract_param($param, 'server'); + my $token = extract_param($param, 'token'); + my $comment = extract_param($param, 'comment'); + my $filter = extract_param($param, 'filter'); + + my $delete = extract_param($param, 'delete'); + my $digest = extract_param($param, 'digest'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->update_gotify_endpoint( + $name, + $server, + $token, + $comment, + $filter, + $delete, + $digest, + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'delete_gotify_endpoint', + protected => 1, + path => 'endpoints/gotify/{name}', + method => 'DELETE', + description => 'Remove gotify endpoint', + permissions => { + check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + $config->delete_gotify_endpoint($name); + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); 1; From 56977d48a9f9fd9600b0a44c97d690cf7a57502e Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:00 +0200 Subject: [PATCH 182/398] api: notification: add api routes for filters The Perl part of the API methods primarily defines the API schema, checks for any needed priviledges and then calls the actual Rust implementation exposed via perlmod. Any errors returned by the Rust code are translated into PVE::Exception, so that the API call fails with the correct HTTP error code. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 255 ++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index b65c6957a..b4db7f8e6 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -94,6 +94,7 @@ __PACKAGE__->register_method ({ code => sub { my $result = [ { name => 'endpoints' }, + { name => 'filters' }, { name => 'groups' }, ]; @@ -898,4 +899,258 @@ __PACKAGE__->register_method ({ return; } }); + +my $filter_properties = { + name => { + description => 'Name of the endpoint.', + type => 'string', + format => 'pve-configid', + }, + 'min-severity' => { + type => 'string', + description => 'Minimum severity to match', + optional => 1, + enum => [qw(info notice warning error)], + }, + mode => { + type => 'string', + description => "Choose between 'and' and 'or' for when multiple properties are specified", + optional => 1, + enum => [qw(and or)], + default => 'and', + }, + 'invert-match' => { + type => 'boolean', + description => 'Invert match of the whole filter', + optional => 1, + }, + 'comment' => { + description => 'Comment', + type => 'string', + optional => 1, + }, +}; + +__PACKAGE__->register_method ({ + name => 'get_filters', + path => 'filters', + method => 'GET', + description => 'Returns a list of all filters', + protected => 1, + permissions => { + description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" + . " 'Mapping.Audit' permissions on '/mapping/notification/'.", + user => 'all', + }, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => 'object', + properties => $filter_properties, + }, + links => [ { rel => 'child', href => '{name}' } ], + }, + code => sub { + my $config = PVE::Notify::read_config(); + my $rpcenv = PVE::RPCEnvironment::get(); + + my $entities = eval { + $config->get_filters(); + }; + raise_api_error($@) if $@; + + return filter_entities_by_privs($rpcenv, $entities); + } +}); + +__PACKAGE__->register_method ({ + name => 'get_filter', + path => 'filters/{name}', + method => 'GET', + description => 'Return a specific filter', + protected => 1, + permissions => { + check => ['or', + ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], + ], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { + type => 'object', + properties => { + %$filter_properties, + digest => get_standard_option('pve-config-digest'), + }, + }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + my $config = PVE::Notify::read_config(); + + my $filter = eval { + $config->get_filter($name) + }; + + raise_api_error($@) if $@; + $filter->{digest} = $config->digest(); + + return $filter; + } +}); + +__PACKAGE__->register_method ({ + name => 'create_filter', + path => 'filters', + protected => 1, + method => 'POST', + description => 'Create a new filter', + protected => 1, + permissions => { + check => ['perm', '/mapping/notification', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => $filter_properties, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $min_severity = extract_param($param, 'min-severity'); + my $mode = extract_param($param, 'mode'); + my $invert_match = extract_param($param, 'invert-match'); + my $comment = extract_param($param, 'comment'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->add_filter( + $name, + $min_severity, + $mode, + $invert_match, + $comment, + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'update_filter', + path => 'filters/{name}', + protected => 1, + method => 'PUT', + description => 'Update existing filter', + permissions => { + check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + %{ make_properties_optional($filter_properties) }, + delete => { + type => 'array', + items => { + type => 'string', + format => 'pve-configid', + }, + optional => 1, + description => 'A list of settings you want to delete.', + }, + digest => get_standard_option('pve-config-digest'), + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $min_severity = extract_param($param, 'min-severity'); + my $mode = extract_param($param, 'mode'); + my $invert_match = extract_param($param, 'invert-match'); + my $comment = extract_param($param, 'comment'); + my $digest = extract_param($param, 'digest'); + my $delete = extract_param($param, 'delete'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->update_filter( + $name, + $min_severity, + $mode, + $invert_match, + $comment, + $delete, + $digest, + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'delete_filter', + protected => 1, + path => 'filters/{name}', + method => 'DELETE', + description => 'Remove filter', + permissions => { + check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + $config->delete_filter($name); + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + 1; From e678a5dbfa5c5139127b4277fed94b1c52633b30 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:01 +0200 Subject: [PATCH 183/398] api: notification: allow fetching notification targets The API call returns all entities that can be used as notification targets (endpoints, groups). Only targets for which the user has appropriate permissions are returned. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 81 +++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index b4db7f8e6..d6f29291a 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -96,6 +96,7 @@ __PACKAGE__->register_method ({ { name => 'endpoints' }, { name => 'filters' }, { name => 'groups' }, + { name => 'targets' }, ]; return $result; @@ -130,6 +131,86 @@ __PACKAGE__->register_method ({ } }); +__PACKAGE__->register_method ({ + name => 'get_all_targets', + path => 'targets', + method => 'GET', + description => 'Returns a list of all entities that can be used as notification targets' . + ' (endpoints and groups).', + permissions => { + description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" + . " 'Mapping.Audit' permissions on '/mapping/notification/'.", + user => 'all', + }, + protected => 1, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => 'object', + properties => { + name => { + description => 'Name of the endpoint/group.', + type => 'string', + format => 'pve-configid', + }, + 'type' => { + description => 'Type of the endpoint or group.', + type => 'string', + enum => [qw(sendmail gotify group)], + }, + 'comment' => { + description => 'Comment', + type => 'string', + optional => 1, + }, + }, + }, + links => [ { rel => 'child', href => '{name}' } ], + }, + code => sub { + my $config = PVE::Notify::read_config(); + my $rpcenv = PVE::RPCEnvironment::get(); + + my $targets = eval { + my $result = []; + + for my $target (@{$config->get_sendmail_endpoints()}) { + push @$result, { + name => $target->{name}, + comment => $target->{comment}, + type => 'sendmail', + }; + } + + for my $target (@{$config->get_gotify_endpoints()}) { + push @$result, { + name => $target->{name}, + comment => $target->{comment}, + type => 'gotify', + }; + } + + for my $target (@{$config->get_groups()}) { + push @$result, { + name => $target->{name}, + comment => $target->{comment}, + type => 'group', + }; + } + + $result + }; + + raise_api_error($@) if $@; + + return filter_entities_by_privs($rpcenv, $targets); + } +}); + my $group_properties = { name => { description => 'Name of the group.', From 7e6efd39050d7ac7ff7acf77210c02e68f34ccd4 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:02 +0200 Subject: [PATCH 184/398] api: notification: allow to test targets This API call allows the user to test a notification target. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index d6f29291a..065d6690e 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -211,6 +211,46 @@ __PACKAGE__->register_method ({ } }); +__PACKAGE__->register_method ({ + name => 'test_target', + path => 'targets/{name}/test', + protected => 1, + method => 'POST', + description => 'Send a test notification to a provided target.', + permissions => { + check => ['or', + ['perm', '/mapping/notification/{name}', ['Mapping.Use']], + ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], + ], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + description => 'Name of the target.', + type => 'string', + format => 'pve-configid' + }, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + my $config = PVE::Notify::read_config(); + + eval { + $config->test_target($name); + }; + + raise_api_error($@) if $@; + + return; + } +}); + my $group_properties = { name => { description => 'Name of the group.', From 1ba1988dcf0e1039eb8f3e01a5ff8379c71e1f01 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:03 +0200 Subject: [PATCH 185/398] api: notification: disallow removing targets if they are used Check notification targets configured in datacenter.cfg and jobs.cfg, failing if the group/endpoint to be removed is still in use there. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 44 ++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index 065d6690e..9c9e82438 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -6,6 +6,7 @@ use strict; use Storable qw(dclone); use JSON; +use PVE::Exception qw(raise_param_exc); use PVE::Tools qw(extract_param); use PVE::JSONSchema qw(get_standard_option); use PVE::RESTHandler; @@ -73,6 +74,31 @@ sub filter_entities_by_privs { return $filtered; } +sub target_used_by { + my ($target) = @_; + + my $used_by = []; + + # Check keys in datacenter.cfg + my $dc_conf = PVE::Cluster::cfs_read_file('datacenter.cfg'); + for my $key (qw(target-package-updates target-replication target-fencing)) { + if ($dc_conf->{notify} && $dc_conf->{notify}->{$key} eq $target) { + push @$used_by, $key; + } + } + + # Check backup jobs + my $jobs_conf = PVE::Cluster::cfs_read_file('jobs.cfg'); + for my $key (keys %{$jobs_conf->{ids}}) { + my $job = $jobs_conf->{ids}->{$key}; + if ($job->{'notification-target'} eq $target) { + push @$used_by, $key; + } + } + + return join(', ', @$used_by); +} + __PACKAGE__->register_method ({ name => 'index', path => '', @@ -482,6 +508,11 @@ __PACKAGE__->register_method ({ my ($param) = @_; my $name = extract_param($param, 'name'); + my $used_by = target_used_by($name); + if ($used_by) { + raise_param_exc({'name' => "Cannot remove $name, used by: $used_by"}); + } + eval { PVE::Notify::lock_config(sub { my $config = PVE::Notify::read_config(); @@ -758,11 +789,17 @@ __PACKAGE__->register_method ({ returns => { type => 'null' }, code => sub { my ($param) = @_; + my $name = extract_param($param, 'name'); + + my $used_by = target_used_by($name); + if ($used_by) { + raise_param_exc({'name' => "Cannot remove $name, used by: $used_by"}); + } eval { PVE::Notify::lock_config(sub { my $config = PVE::Notify::read_config(); - $config->delete_sendmail_endpoint($param->{name}); + $config->delete_sendmail_endpoint($name); PVE::Notify::write_config($config); }); }; @@ -1008,6 +1045,11 @@ __PACKAGE__->register_method ({ my ($param) = @_; my $name = extract_param($param, 'name'); + my $used_by = target_used_by($name); + if ($used_by) { + raise_param_exc({'name' => "Cannot remove $name, used by: $used_by"}); + } + eval { PVE::Notify::lock_config(sub { my $config = PVE::Notify::read_config(); From 2c4780cc189daa6916fe3b9a342a0a7fb4eb9427 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:04 +0200 Subject: [PATCH 186/398] ui: backup: allow to select notification target for jobs This commit adds a possibility to choose between different options for notifications for backup jobs: - Notify via email, in the same manner as before - Notify via an endpoint/group If 'notify via mail' is selected, a text field where an email address can be entered is displayed: Notify: | Always notify v | Notify via: | E-Mail v | Send Mail to: | foo@example.com | Compression: | ..... v | If the other option is selected selected, a combo picker for selecting a channel is displayed: Notify: | Always notify v | Notify via: | Endpoint/Group v | Target: | endpoint-foo v | Compression: | ..... v | The code has also been adapted to use the newly introduced 'notification-policy' parameter, which replaces the 'mailnotification' paramter for backup jobs. Some logic which automatically migrates from 'mailnotification' has been added. Signed-off-by: Lukas Wagner --- www/manager6/Makefile | 4 +- www/manager6/dc/Backup.js | 84 +++++++++++++++++-- www/manager6/form/NotificationModeSelector.js | 8 ++ ...ector.js => NotificationPolicySelector.js} | 1 + .../form/NotificationTargetSelector.js | 54 ++++++++++++ 5 files changed, 143 insertions(+), 8 deletions(-) create mode 100644 www/manager6/form/NotificationModeSelector.js rename www/manager6/form/{EmailNotificationSelector.js => NotificationPolicySelector.js} (87%) create mode 100644 www/manager6/form/NotificationTargetSelector.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 7ec9d7a56..5ea4e4a26 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -36,7 +36,6 @@ JSSRC= \ form/DayOfWeekSelector.js \ form/DiskFormatSelector.js \ form/DiskStorageSelector.js \ - form/EmailNotificationSelector.js \ form/FileSelector.js \ form/FirewallPolicySelector.js \ form/GlobalSearchField.js \ @@ -51,6 +50,9 @@ JSSRC= \ form/MultiPCISelector.js \ form/NetworkCardSelector.js \ form/NodeSelector.js \ + form/NotificationModeSelector.js \ + form/NotificationTargetSelector.js \ + form/NotificationPolicySelector.js \ form/PCISelector.js \ form/PCIMapSelector.js \ form/PermPathSelector.js \ diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js index 03a026513..625b54300 100644 --- a/www/manager6/dc/Backup.js +++ b/www/manager6/dc/Backup.js @@ -36,6 +36,30 @@ Ext.define('PVE.dc.BackupEdit', { delete values.node; } + if (!isCreate) { + // 'mailnotification' is deprecated in favor of 'notification-policy' + // -> Migration to the new parameter happens in init, so we are + // safe to remove the old parameter here. + Proxmox.Utils.assemble_field_data(values, { 'delete': 'mailnotification' }); + + // If sending notifications via mail, remove the current value of + // 'notification-target' + if (values['notification-mode'] === "mailto") { + Proxmox.Utils.assemble_field_data( + values, + { 'delete': 'notification-target' }, + ); + } else { + // and vice versa... + Proxmox.Utils.assemble_field_data( + values, + { 'delete': 'mailto' }, + ); + } + } + + delete values['notification-mode']; + if (!values.id && isCreate) { values.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); } @@ -146,6 +170,22 @@ Ext.define('PVE.dc.BackupEdit', { success: function(response, _options) { let data = response.result.data; + // 'mailnotification' is deprecated. Let's automatically + // migrate to the compatible 'notification-policy' parameter + if (data.mailnotification) { + if (!data["notification-policy"]) { + data["notification-policy"] = data.mailnotification; + } + + delete data.mailnotification; + } + + if (data['notification-target']) { + data['notification-mode'] = 'notification-target'; + } else if (data.mailto) { + data['notification-mode'] = 'mailto'; + } + if (data.exclude) { data.vmid = data.exclude; data.selMode = 'exclude'; @@ -188,11 +228,13 @@ Ext.define('PVE.dc.BackupEdit', { viewModel: { data: { selMode: 'include', + notificationMode: 'notification-target', }, formulas: { poolMode: (get) => get('selMode') === 'pool', disableVMSelection: (get) => get('selMode') !== 'include' && get('selMode') !== 'exclude', + mailNotificationSelected: (get) => get('notificationMode') === 'mailto', }, }, @@ -282,20 +324,48 @@ Ext.define('PVE.dc.BackupEdit', { }, ], column2: [ - { - xtype: 'textfield', - fieldLabel: gettext('Send email to'), - name: 'mailto', - }, { xtype: 'pveEmailNotificationSelector', - fieldLabel: gettext('Email'), - name: 'mailnotification', + fieldLabel: gettext('Notify'), + name: 'notification-policy', cbind: { value: (get) => get('isCreate') ? 'always' : '', deleteEmpty: '{!isCreate}', }, }, + { + xtype: 'pveNotificationModeSelector', + fieldLabel: gettext('Notify via'), + name: 'notification-mode', + bind: { + value: '{notificationMode}', + }, + }, + { + xtype: 'pveNotificationTargetSelector', + fieldLabel: gettext('Notification Target'), + name: 'notification-target', + allowBlank: true, + editable: true, + autoSelect: false, + bind: { + hidden: '{mailNotificationSelected}', + disabled: '{mailNotificationSelected}', + }, + cbind: { + deleteEmpty: '{!isCreate}', + }, + }, + { + xtype: 'textfield', + fieldLabel: gettext('Send email to'), + name: 'mailto', + hidden: true, + bind: { + hidden: '{!mailNotificationSelected}', + disabled: '{!mailNotificationSelected}', + }, + }, { xtype: 'pveCompressionSelector', reference: 'compressionSelector', diff --git a/www/manager6/form/NotificationModeSelector.js b/www/manager6/form/NotificationModeSelector.js new file mode 100644 index 000000000..58fddd56e --- /dev/null +++ b/www/manager6/form/NotificationModeSelector.js @@ -0,0 +1,8 @@ +Ext.define('PVE.form.NotificationModeSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveNotificationModeSelector'], + comboItems: [ + ['notification-target', gettext('Target')], + ['mailto', gettext('E-Mail')], + ], +}); diff --git a/www/manager6/form/EmailNotificationSelector.js b/www/manager6/form/NotificationPolicySelector.js similarity index 87% rename from www/manager6/form/EmailNotificationSelector.js rename to www/manager6/form/NotificationPolicySelector.js index f318ea18d..68087275e 100644 --- a/www/manager6/form/EmailNotificationSelector.js +++ b/www/manager6/form/NotificationPolicySelector.js @@ -4,5 +4,6 @@ Ext.define('PVE.form.EmailNotificationSelector', { comboItems: [ ['always', gettext('Notify always')], ['failure', gettext('On failure only')], + ['never', gettext('Notify never')], ], }); diff --git a/www/manager6/form/NotificationTargetSelector.js b/www/manager6/form/NotificationTargetSelector.js new file mode 100644 index 000000000..9ead28e70 --- /dev/null +++ b/www/manager6/form/NotificationTargetSelector.js @@ -0,0 +1,54 @@ +Ext.define('PVE.form.NotificationTargetSelector', { + extend: 'Proxmox.form.ComboGrid', + alias: ['widget.pveNotificationTargetSelector'], + + // set default value to empty array, else it inits it with + // null and after the store load it is an empty array, + // triggering dirtychange + value: [], + valueField: 'name', + displayField: 'name', + deleteEmpty: true, + skipEmptyText: true, + + store: { + fields: ['name', 'type', 'comment'], + proxy: { + type: 'proxmox', + url: '/api2/json/cluster/notifications/targets', + }, + sorters: [ + { + property: 'name', + direction: 'ASC', + }, + ], + autoLoad: true, + }, + + listConfig: { + columns: [ + { + header: gettext('Target'), + dataIndex: 'name', + sortable: true, + hideable: false, + flex: 1, + }, + { + header: gettext('Type'), + dataIndex: 'type', + sortable: true, + hideable: false, + flex: 1, + }, + { + header: gettext('Comment'), + dataIndex: 'comment', + sortable: true, + hideable: false, + flex: 2, + }, + ], + }, +}); From 3be4491221d61ed7c762bd20ec532466b6f0bea7 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:05 +0200 Subject: [PATCH 187/398] ui: backup: adapt backup job details to new notification params Adapt the backup job detail view so that it shows notification targets. Signed-off-by: Lukas Wagner --- www/manager6/dc/BackupJobDetail.js | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/www/manager6/dc/BackupJobDetail.js b/www/manager6/dc/BackupJobDetail.js index 880784a23..e154fec14 100644 --- a/www/manager6/dc/BackupJobDetail.js +++ b/www/manager6/dc/BackupJobDetail.js @@ -202,15 +202,27 @@ Ext.define('PVE.dc.BackupInfo', { column2: [ { xtype: 'displayfield', - name: 'mailnotification', + name: 'notification-policy', fieldLabel: gettext('Notification'), renderer: function(value) { - let mailto = this.up('pveBackupInfo')?.record?.mailto || 'root@localhost'; + let record = this.up('pveBackupInfo')?.record; + + // Fall back to old value, in case this option is not migrated yet. + let policy = value || record?.mailnotification || 'always'; + let when = gettext('Always'); - if (value === 'failure') { + if (policy === 'failure') { when = gettext('On failure only'); + } else if (policy === 'never') { + when = gettext('Never'); } - return `${when} (${mailto})`; + + // Notification-target takes precedence + let target = record?.['notification-target'] || + record?.mailto || + gettext('No target configured'); + + return `${when} (${target})`; }, }, { From d7e19768106ab33de53a16aa2b4e65339fcfa4d9 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:06 +0200 Subject: [PATCH 188/398] ui: backup: allow to set notification-target for one-off backups In essence the same change as for backup jobs. Signed-off-by: Lukas Wagner --- www/manager6/window/Backup.js | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/www/manager6/window/Backup.js b/www/manager6/window/Backup.js index 4b21c746c..17a37e129 100644 --- a/www/manager6/window/Backup.js +++ b/www/manager6/window/Backup.js @@ -30,12 +30,32 @@ Ext.define('PVE.window.Backup', { name: 'mode', }); + let notificationTargetSelector = Ext.create('PVE.form.NotificationTargetSelector', { + fieldLabel: gettext('Notification target'), + name: 'notification-target', + emptyText: Proxmox.Utils.noneText, + hidden: true, + }); + let mailtoField = Ext.create('Ext.form.field.Text', { fieldLabel: gettext('Send email to'), name: 'mailto', emptyText: Proxmox.Utils.noneText, }); + let notificationModeSelector = Ext.create('PVE.form.NotificationModeSelector', { + fieldLabel: gettext('Notify via'), + value: 'mailto', + name: 'notification-mode', + listeners: { + change: function(f, v) { + let mailSelected = v === 'mailto'; + notificationTargetSelector.setHidden(mailSelected); + mailtoField.setHidden(!mailSelected); + }, + }, + }); + const keepNames = [ ['keep-last', gettext('Keep Last')], ['keep-hourly', gettext('Keep Hourly')], @@ -107,6 +127,12 @@ Ext.define('PVE.window.Backup', { success: function(response, opts) { const data = response.result.data; + if (!initialDefaults && data['notification-mode'] !== undefined) { + notificationModeSelector.setValue(data['notification-mode']); + } + if (!initialDefaults && data['notification-channel'] !== undefined) { + notificationTargetSelector.setValue(data['notification-channel']); + } if (!initialDefaults && data.mailto !== undefined) { mailtoField.setValue(data.mailto); } @@ -176,6 +202,8 @@ Ext.define('PVE.window.Backup', { ], column2: [ compressionSelector, + notificationModeSelector, + notificationTargetSelector, mailtoField, removeCheckbox, ], @@ -252,10 +280,15 @@ Ext.define('PVE.window.Backup', { remove: values.remove, }; - if (values.mailto) { + if (values.mailto && values['notification-mode'] === 'mailto') { params.mailto = values.mailto; } + if (values['notification-target'] && + values['notification-mode'] === 'notification-target') { + params['notification-target'] = values['notification-target']; + } + if (values.compress) { params.compress = values.compress; } From 80c49bb56d786755697a3b701e3af9e9744d1c11 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:07 +0200 Subject: [PATCH 189/398] ui: allow to configure notification event -> target mapping This commit adds a new view that allows configuring notification targets for all existing notification events (replication, updates, fencing). Signed-off-by: Lukas Wagner --- www/manager6/Makefile | 1 + www/manager6/dc/Config.js | 12 ++ www/manager6/dc/NotificationEvents.js | 277 ++++++++++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 www/manager6/dc/NotificationEvents.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 5ea4e4a26..59a5d8a7f 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -159,6 +159,7 @@ JSSRC= \ dc/Health.js \ dc/Log.js \ dc/NodeView.js \ + dc/NotificationEvents.js \ dc/OptionView.js \ dc/PermissionView.js \ dc/PoolEdit.js \ diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 04ed04f04..aa025c8db 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -317,6 +317,18 @@ Ext.define('PVE.dc.Config', { ); } + if (caps.dc['Sys.Audit']) { + me.items.push( + { + xtype: 'pveNotificationEvents', + title: gettext('Notifications'), + onlineHelp: 'notification_events', + iconCls: 'fa fa-bell-o', + itemId: 'notifications', + }, + ); + } + if (caps.dc['Sys.Audit']) { me.items.push({ xtype: 'pveDcSupport', diff --git a/www/manager6/dc/NotificationEvents.js b/www/manager6/dc/NotificationEvents.js new file mode 100644 index 000000000..f2ee12e07 --- /dev/null +++ b/www/manager6/dc/NotificationEvents.js @@ -0,0 +1,277 @@ +Ext.define('PVE.dc.NotificationEventsPolicySelector', { + alias: ['widget.pveNotificationEventsPolicySelector'], + extend: 'Proxmox.form.KVComboBox', + deleteEmpty: false, + value: '__default__', + + config: { + warningRef: null, + warnIfValIs: null, + }, + + listeners: { + change: function(field, newValue) { + let me = this; + if (!me.warningRef && !me.warnIfValIs) { + return; + } + + let warningField = field.nextSibling( + `displayfield[reference=${me.warningRef}]`, + ); + warningField.setVisible(newValue === me.warnIfValIs); + }, + }, +}); + +Ext.define('PVE.dc.NotificationEventDisabledWarning', { + alias: ['widget.pveNotificationEventDisabledWarning'], + extend: 'Ext.form.field.Display', + userCls: 'pmx-hint', + hidden: true, + value: gettext('Disabling notifications is not ' + + 'recommended for production systems!'), +}); + +Ext.define('PVE.dc.NotificationEventsTargetSelector', { + alias: ['widget.pveNotificationEventsTargetSelector'], + extend: 'PVE.form.NotificationTargetSelector', + fieldLabel: gettext('Notification Target'), + allowBlank: true, + editable: true, + autoSelect: false, + deleteEmpty: false, + emptyText: `${Proxmox.Utils.defaultText} (${gettext("mail-to-root")})`, +}); + +Ext.define('PVE.dc.NotificationEvents', { + extend: 'Proxmox.grid.ObjectGrid', + alias: ['widget.pveNotificationEvents'], + + // Taken from OptionView.js, but adapted slightly. + // The modified version allows us to have multiple rows in the ObjectGrid + // for the same underlying property (notify). + // Every setting is eventually stored as a property string in the + // notify key of datacenter.cfg. + // When updating 'notify', all properties that were already set + // also have to be submitted, even if they were not modified. + // This means that we need to save the old value somewhere. + addInputPanelRow: function(name, propertyName, text, opts) { + let me = this; + + opts = opts || {}; + me.rows = me.rows || {}; + + me.rows[name] = { + required: true, + defaultValue: opts.defaultValue, + header: text, + renderer: opts.renderer, + name: propertyName, + editor: { + xtype: 'proxmoxWindowEdit', + width: opts.width || 400, + subject: text, + onlineHelp: opts.onlineHelp, + fieldDefaults: { + labelWidth: opts.labelWidth || 150, + }, + setValues: function(values) { + let value = values[propertyName]; + + if (opts.parseBeforeSet) { + value = PVE.Parser.parsePropertyString(value); + } + + Ext.Array.each(this.query('inputpanel'), function(panel) { + panel.setValues(value); + + // Save the original value + panel.originalValue = { + ...value, + }; + }); + }, + url: opts.url, + items: [{ + xtype: 'inputpanel', + onGetValues: function(values) { + let fields = this.config.items.map(field => field.name).filter(n => n); + + // Restore old, unchanged values + for (const [key, value] of Object.entries(this.originalValue)) { + if (!fields.includes(key)) { + values[key] = value; + } + } + + let value = {}; + if (Object.keys(values).length > 0) { + value[propertyName] = PVE.Parser.printPropertyString(values); + } else { + Proxmox.Utils.assemble_field_data(value, { 'delete': propertyName }); + } + + return value; + }, + items: opts.items, + }], + }, + }; + }, + + initComponent: function() { + let me = this; + + // Helper function for rendering the property + // Needed since the actual value is always stored in the 'notify' property + let render_value = (store, target_key, mode_key, default_val) => { + let value = store.getById('notify')?.get('value') ?? {}; + let target = value[target_key] ?? gettext('mail-to-root'); + let template; + + switch (value[mode_key]) { + case 'always': + template = gettext('Always, notify via target \'{0}\''); + break; + case 'never': + template = gettext('Never'); + break; + case 'auto': + template = gettext('Automatically, notify via target \'{0}\''); + break; + default: + template = gettext('{1} ({2}), notify via target \'{0}\''); + break; + } + + return Ext.String.format(template, target, Proxmox.Utils.defaultText, default_val); + }; + + me.addInputPanelRow('fencing', 'notify', gettext('Node Fencing'), { + renderer: (value, metaData, record, rowIndex, colIndex, store) => + render_value(store, 'target-fencing', 'fencing', gettext('Always')), + url: "/api2/extjs/cluster/options", + items: [ + { + xtype: 'pveNotificationEventsPolicySelector', + name: 'fencing', + fieldLabel: gettext('Notify'), + comboItems: [ + ['__default__', `${Proxmox.Utils.defaultText} (${gettext('Always')})`], + ['always', gettext('Always')], + ['never', gettext('Never')], + ], + warningRef: 'warning', + warnIfValIs: 'never', + }, + { + xtype: 'pveNotificationEventsTargetSelector', + name: 'target-fencing', + }, + { + xtype: 'pveNotificationEventDisabledWarning', + reference: 'warning', + }, + ], + }); + + me.addInputPanelRow('replication', 'notify', gettext('Replication'), { + renderer: (value, metaData, record, rowIndex, colIndex, store) => + render_value(store, 'target-replication', 'replication', gettext('Always')), + url: "/api2/extjs/cluster/options", + items: [ + { + xtype: 'pveNotificationEventsPolicySelector', + name: 'replication', + fieldLabel: gettext('Notify'), + comboItems: [ + ['__default__', `${Proxmox.Utils.defaultText} (${gettext('Always')})`], + ['always', gettext('Always')], + ['never', gettext('Never')], + ], + warningRef: 'warning', + warnIfValIs: 'never', + }, + { + xtype: 'pveNotificationEventsTargetSelector', + name: 'target-replication', + }, + { + xtype: 'pveNotificationEventDisabledWarning', + reference: 'warning', + }, + ], + }); + + me.addInputPanelRow('updates', 'notify', gettext('Package Updates'), { + renderer: (value, metaData, record, rowIndex, colIndex, store) => + render_value( + store, + 'target-package-updates', + 'package-updates', + gettext('Automatically'), + ), + url: "/api2/extjs/cluster/options", + items: [ + { + xtype: 'pveNotificationEventsPolicySelector', + name: 'package-updates', + fieldLabel: gettext('Notify'), + comboItems: [ + [ + '__default__', + `${Proxmox.Utils.defaultText} (${gettext('Automatically')})`, + ], + ['auto', gettext('Automatically')], + ['always', gettext('Always')], + ['never', gettext('Never')], + ], + warningRef: 'warning', + warnIfValIs: 'never', + }, + { + xtype: 'pveNotificationEventsTargetSelector', + name: 'target-package-updates', + }, + { + xtype: 'pveNotificationEventDisabledWarning', + reference: 'warning', + }, + ], + }); + + // Hack: Also load the notify property to make it accessible + // for our render functions. + me.rows.notify = { + visible: false, + }; + + me.selModel = Ext.create('Ext.selection.RowModel', {}); + + Ext.apply(me, { + tbar: [{ + text: gettext('Edit'), + xtype: 'proxmoxButton', + disabled: true, + handler: () => me.run_editor(), + selModel: me.selModel, + }], + url: "/api2/json/cluster/options", + editorConfig: { + url: "/api2/extjs/cluster/options", + }, + interval: 5000, + cwidth1: 200, + listeners: { + itemdblclick: me.run_editor, + }, + }); + + me.callParent(); + + me.on('activate', me.rstore.startUpdate); + me.on('destroy', me.rstore.stopUpdate); + me.on('deactivate', me.rstore.stopUpdate); + }, +}); From e4eb04d653aaf22379de3b509c9cb3f86f8809b1 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:08 +0200 Subject: [PATCH 190/398] ui: add notification target configuration panel Embed the new notification target configuration panel, implemented in proxmox-widget-toolkit. Signed-off-by: Lukas Wagner --- www/manager6/dc/Config.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index aa025c8db..9ba7b301f 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -329,6 +329,22 @@ Ext.define('PVE.dc.Config', { ); } + if (caps.mapping['Mapping.Audit'] || + caps.mapping['Mapping.Use'] || + caps.mapping['Mapping.Modify']) { + me.items.push( + { + xtype: 'pmxNotificationConfigView', + title: gettext('Notification Targets'), + onlineHelp: 'notification_targets', + itemId: 'notification-targets', + iconCls: 'fa fa-dot-circle-o', + baseUrl: '/cluster/notifications', + groups: ['notifications'], + }, + ); + } + if (caps.dc['Sys.Audit']) { me.items.push({ xtype: 'pveDcSupport', From 56f677ebd74f3e23df5705ae5cefb839c4635f68 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:09 +0200 Subject: [PATCH 191/398] ui: perm path: add ACL paths for notifications, usb and pci mappings Suggested-by: Dominik Csapak Signed-off-by: Lukas Wagner --- www/manager6/data/PermPathStore.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/manager6/data/PermPathStore.js b/www/manager6/data/PermPathStore.js index c3ac7f0ec..64ab2f03e 100644 --- a/www/manager6/data/PermPathStore.js +++ b/www/manager6/data/PermPathStore.js @@ -9,6 +9,9 @@ Ext.define('PVE.data.PermPathStore', { { 'value': '/access/groups' }, { 'value': '/access/realm' }, { 'value': '/mapping' }, + { 'value': '/mapping/notification' }, + { 'value': '/mapping/pci' }, + { 'value': '/mapping/usb' }, { 'value': '/nodes' }, { 'value': '/pool' }, { 'value': '/sdn/zones' }, From 2cb9b31cb37df3c6a8dacd7928258c5183e0aa95 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:10 +0200 Subject: [PATCH 192/398] ui: perm path: increase width of the perm path selector combobox ACL paths for notification targets can become quite long, e.g.: /mappings/notifications/ Signed-off-by: Lukas Wagner --- www/manager6/form/PermPathSelector.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/form/PermPathSelector.js b/www/manager6/form/PermPathSelector.js index c20d8b65c..e8d395fc2 100644 --- a/www/manager6/form/PermPathSelector.js +++ b/www/manager6/form/PermPathSelector.js @@ -6,6 +6,7 @@ Ext.define('PVE.form.PermPathSelector', { displayField: 'value', typeAhead: true, queryMode: 'local', + width: 380, store: { type: 'pvePermPath', From c81bca2d28744616098448b81fa58e133d3ac5ed Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:11 +0200 Subject: [PATCH 193/398] ui: dc: remove notify key from datacenter option view Settings for notifications have been moved to their own view. Signed-off-by: Lukas Wagner --- www/manager6/dc/OptionView.js | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/www/manager6/dc/OptionView.js b/www/manager6/dc/OptionView.js index 8b7aca327..4717277fc 100644 --- a/www/manager6/dc/OptionView.js +++ b/www/manager6/dc/OptionView.js @@ -91,26 +91,6 @@ Ext.define('PVE.dc.OptionView', { vtype: 'proxmoxMail', defaultValue: 'root@$hostname', }); - me.add_inputpanel_row('notify', gettext('Notify'), { - renderer: v => !v ? 'package-updates=auto' : PVE.Parser.printPropertyString(v), - labelWidth: 120, - url: "/api2/extjs/cluster/options", - //onlineHelp: 'ha_manager_shutdown_policy', - items: [{ - xtype: 'proxmoxKVComboBox', - name: 'package-updates', - fieldLabel: gettext('Package Updates'), - deleteEmpty: false, - value: '__default__', - comboItems: [ - ['__default__', Proxmox.Utils.defaultText + ' (auto)'], - ['auto', gettext('Automatically')], - ['always', gettext('Always')], - ['never', gettext('Never')], - ], - defaultValue: '__default__', - }], - }); me.add_text_row('mac_prefix', gettext('MAC address prefix'), { deleteEmpty: true, vtype: 'MacPrefix', From 4c40d7cbed418eeb22a7df786ce9725bb5539cf5 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:13 +0200 Subject: [PATCH 194/398] api: notification: make the 'mail-to-root' target visible to any user Since the target does not require Mapping.Use, it should also be visible and testable by all users. Short explanation why the 'mail-to-root' is exempt from priv checks: To ensure backwards compatibility, the 'mail-to-root' target does not require the `Mapping.Use` privs. This is needed due to the fact that this target is used as a fallback in case no other target is configured for an event. For instance, the /node//apt/update API call only requires Sys.Modify for the node, but it can also send a notification. If we were to require Mapping.Use, we could break the apt/update API compat in the case that a notification shall be sent, but without any configured notification target (which will then default to 'mail-to-root'). Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index 9c9e82438..ec6669034 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -68,7 +68,7 @@ sub filter_entities_by_privs { "/mapping/notification/$_->{name}", $can_see_mapping_privs, 1 - ) + ) || $_->{name} eq PVE::Notify::default_target(); } @$entities]; return $filtered; @@ -165,7 +165,8 @@ __PACKAGE__->register_method ({ ' (endpoints and groups).', permissions => { description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" - . " 'Mapping.Audit' permissions on '/mapping/notification/'.", + . " 'Mapping.Audit' permissions on '/mapping/notification/'." + . " The special 'mail-to-root' target is available to all users.", user => 'all', }, protected => 1, @@ -244,11 +245,10 @@ __PACKAGE__->register_method ({ method => 'POST', description => 'Send a test notification to a provided target.', permissions => { - check => ['or', - ['perm', '/mapping/notification/{name}', ['Mapping.Use']], - ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], - ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], - ], + description => "The user requires 'Mapping.Modify', 'Mapping.Use' or" + . " 'Mapping.Audit' permissions on '/mapping/notification/'." + . " The special 'mail-to-root' target can be accessed by all users.", + user => 'all', }, parameters => { additionalProperties => 0, @@ -264,10 +264,23 @@ __PACKAGE__->register_method ({ code => sub { my ($param) = @_; my $name = extract_param($param, 'name'); + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); - my $config = PVE::Notify::read_config(); + my $privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; + + if ($name ne PVE::Notify::default_target()) { + # Due to backwards compatibility reasons the 'mail-to-root' + # target must be accessible for any user + $rpcenv->check_any( + $authuser, + "/mapping/notification/$name", + $privs, + ); + } eval { + my $config = PVE::Notify::read_config(); $config->test_target($name); }; From 51f54177e9d611038c8654746373a0485c365fb5 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Wed, 16 Aug 2023 11:11:57 +0200 Subject: [PATCH 195/398] bump proxmox-widget-toolkit dependency to 4.0.7 for the notification ui Signed-off-by: Wolfgang Bumiller --- debian/control | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index c511efb19..68d258347 100644 --- a/debian/control +++ b/debian/control @@ -20,7 +20,7 @@ Build-Depends: debhelper-compat (= 13), libtemplate-perl, libtest-mockmodule-perl, lintian, - proxmox-widget-toolkit (>= 3.4-9), + proxmox-widget-toolkit (>= 4.0.7), pve-cluster, pve-container, pve-doc-generator (>= 8.0.2), @@ -80,7 +80,7 @@ Depends: apt (>= 1.5~), postfix | mail-transport-agent, proxmox-mail-forward, proxmox-mini-journalreader (>= 1.3-1), - proxmox-widget-toolkit (>= 3.6.0), + proxmox-widget-toolkit (>= 4.0.7), pve-cluster (>= 7.0-4), pve-container (>= 4.0-9), pve-docs (>= 8.0~~), From e5721b9062807d0a75900ea5cabe52b7fa2a6f5b Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 3 Aug 2023 14:17:12 +0200 Subject: [PATCH 196/398] vzdump: use as a convention for virtual endpoints/groups Virtual (or anonymous) endpoints/groups are used for sending one-off notifications to a target that does not exist in the config. VZDump uses this to send out notification mails to those addresses configured by the `mailto` parameter. Suggested-by: Wolfgang Bumiller Signed-off-by: Lukas Wagner --- PVE/VZDump.pm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index 7dc9f31ef..2671e3b1e 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -501,10 +501,10 @@ sub send_notification { my $notification_config = PVE::Notify::read_config(); if ($mailto && scalar(@$mailto)) { - # <, >, @ is not allowed in endpoint names, but only it is only + # <, >, @ are not allowed in endpoint names, but that is only # verified once the config is serialized. That means that # we can rely on that fact that no other endpoint with this name exists. - my $endpoint_name = "mail-to-<" . join(",", @$mailto) . ">"; + my $endpoint_name = ""; $notification_config->add_sendmail_endpoint( $endpoint_name, $mailto, @@ -520,7 +520,7 @@ sub send_notification { push @$endpoints, $target; } - $target = "group-$endpoint_name"; + $target = ""; $notification_config->add_group( $target, $endpoints, From 3571d98b5f6dba73c753f4ac70a66294a0c5ea86 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Wed, 16 Aug 2023 11:58:20 +0200 Subject: [PATCH 197/398] fix mocking in notification tests PVE::Notify::send_notification is now private (the mocking was for the old api) 'cfs_read_file' gets exported into PVE::Notify before it gets mocked, so it needs to be mocked inside PVE::Notify, not PVE::Cluster. Signed-off-by: Wolfgang Bumiller --- test/vzdump_notification_test.pl | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/test/vzdump_notification_test.pl b/test/vzdump_notification_test.pl index 0635ebb74..21c31651b 100755 --- a/test/vzdump_notification_test.pl +++ b/test/vzdump_notification_test.pl @@ -37,15 +37,25 @@ my $result_text; my $result_properties; my $mock_notification_module = Test::MockModule->new('PVE::Notify'); -$mock_notification_module->mock('send_notification', sub { +my $mocked_notify = sub { my ($channel, $severity, $title, $text, $properties) = @_; $result_text = $text; $result_properties = $properties; -}); +}; +my $mocked_notify_short = sub { + my ($channel, @rest) = @_; + return $mocked_notify->($channel, '', @rest); +}; -my $mock_cluster_module = Test::MockModule->new('PVE::Cluster'); -$mock_cluster_module->mock('cfs_read_file', sub { +$mock_notification_module->mock( + 'notify' => $mocked_notify, + 'info' => $mocked_notify_short, + 'notice' => $mocked_notify_short, + 'warning' => $mocked_notify_short, + 'error' => $mocked_notify_short, +); +$mock_notification_module->mock('cfs_read_file', sub { my $path = shift; if ($path eq 'datacenter.cfg') { From 10821ca32229ed1ef54f209090c9ab068051096d Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Wed, 16 Aug 2023 12:10:33 +0200 Subject: [PATCH 198/398] bump pve-doc-generator dependency Signed-off-by: Wolfgang Bumiller --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 68d258347..f84f3083f 100644 --- a/debian/control +++ b/debian/control @@ -23,7 +23,7 @@ Build-Depends: debhelper-compat (= 13), proxmox-widget-toolkit (>= 4.0.7), pve-cluster, pve-container, - pve-doc-generator (>= 8.0.2), + pve-doc-generator (>= 8.0.5), pve-eslint (>= 7.28.0), qemu-server (>= 8.0~~), sq, From a699b7d8969250bb044226ab09de395cbccda5d1 Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Wed, 16 Aug 2023 12:12:47 +0200 Subject: [PATCH 199/398] bump version to 8.0.5 Signed-off-by: Wolfgang Bumiller --- debian/changelog | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/debian/changelog b/debian/changelog index 1404a16f8..6de5ba8a2 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +pve-manager (8.0.5) bookworm; urgency=medium + + * add 'check connection' checkbox for ldap as advanced option + + * fix #474: allow transferring VMs between pools + + * add api endpoints and UI for notification subsystem + + -- Proxmox Support Team Wed, 16 Aug 2023 12:12:34 +0200 + pve-manager (8.0.4) bookworm; urgency=medium * ui: migrate: fix disabled migrate button glitch From 72cfb3d4a18ecca17deaa7fd80fd4ef77bdec7e0 Mon Sep 17 00:00:00 2001 From: Filip Schauer Date: Wed, 16 Aug 2023 11:54:57 +0200 Subject: [PATCH 200/398] fix #4663: Prevent Web UI reload on cert order for other node While it makes sense to reload the Web UI after ordering a certificate for the same node, it is unnecessary to reload the Web UI when ordering a certificate for a different node. Signed-off-by: Filip Schauer --- www/manager6/node/ACME.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/www/manager6/node/ACME.js b/www/manager6/node/ACME.js index 0642e7c5e..9f1dabced 100644 --- a/www/manager6/node/ACME.js +++ b/www/manager6/node/ACME.js @@ -523,6 +523,10 @@ Ext.define('PVE.node.ACME', { orderFinished: function(success) { if (!success) return; + // reload only if the Web UI is open on the same node that the cert was ordered for + if (this.getView().nodename !== Proxmox.NodeName) { + return; + } var txt = gettext('pveproxy will be restarted with new certificates, please reload the GUI!'); Ext.getBody().mask(txt, ['pve-static-mask']); // reload after 10 seconds automatically From 347f88fecd938da30fa2cab4724159225cf6f7bb Mon Sep 17 00:00:00 2001 From: Christian Ebner Date: Fri, 11 Aug 2023 12:46:54 +0200 Subject: [PATCH 201/398] website: update external links to www.proxmox.com During the redesign of www.proxmox.com the menu structure and therefore some url changed. Update the external link in order to avoid an unneccessary redirect Signed-off-by: Christian Ebner --- PVE/API2/Subscription.pm | 2 +- aplinfo/aplinfo.dat | 4 ++-- www/manager6/Utils.js | 2 +- www/manager6/dc/Summary.js | 2 +- www/mobile/WidgetToolkitUtils.js | 1 - 5 files changed, 5 insertions(+), 6 deletions(-) diff --git a/PVE/API2/Subscription.pm b/PVE/API2/Subscription.pm index c7b81ee93..96fcd4e50 100644 --- a/PVE/API2/Subscription.pm +++ b/PVE/API2/Subscription.pm @@ -128,7 +128,7 @@ __PACKAGE__->register_method ({ my $has_permission = $rpcenv->check($authuser, "/nodes/$node", ['Sys.Audit'], 1); my $server_id = PVE::API2Tools::get_hwaddress(); - my $url = "https://www.proxmox.com/proxmox-ve/pricing"; + my $url = "https://www.proxmox.com/en/proxmox-virtual-environment/pricing"; my $info = read_etc_subscription(); if (!$info) { diff --git a/aplinfo/aplinfo.dat b/aplinfo/aplinfo.dat index 95a8be5a5..8382bd7d8 100644 --- a/aplinfo/aplinfo.dat +++ b/aplinfo/aplinfo.dat @@ -135,7 +135,7 @@ Architecture: amd64 Location: mail/proxmox-mailgateway-7.3-standard_7.3-1_amd64.tar.zst md5sum: 6c130003f9880ae66dca0603d7b7ca87 sha512sum: 2fdf1dc24306bbaa2ef9a0f322416ca15b97b7d19f84b83743c7afc896095c398241fbc2eb41a33a69f3f275ce4c4cb6425edc5538831b4650d39a5e44fdbc25 -Infopage: https://www.proxmox.com/de/proxmox-mail-gateway +Infopage: https://www.proxmox.com/en/proxmox-mail-gateway/overview Description: Proxmox Mailgateway 7.3 A full featured mail proxy for spam and virus filtering, optimized for container environment. @@ -149,7 +149,7 @@ Architecture: amd64 Location: mail/proxmox-mailgateway-8.0-standard_8.0-1_amd64.tar.zst md5sum: 7d321e5dfc6e1005231586d1871e3625 sha512sum: be5efcb8ee97f2bb1c638360191eda19f49e2063acb88da55c948c90c091063972cc9ea29e6aeaa4a85733e0fb2c99ea905d665ac693cb2bf06b091c4baf781f -Infopage: https://www.proxmox.com/de/proxmox-mail-gateway +Infopage: https://www.proxmox.com/en/proxmox-mail-gateway/overview Description: Proxmox Mailgateway 8.0 A full featured mail proxy for spam and virus filtering, optimized for container environment. diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 4e0942136..6d4842df2 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -34,7 +34,7 @@ Ext.define('PVE.Utils', { }, noSubKeyHtml: 'You do not have a valid subscription for this server. Please visit ' - +'' + +'' +'www.proxmox.com to get a list of available options.', getClusterSubscriptionLevel: async function() { diff --git a/www/manager6/dc/Summary.js b/www/manager6/dc/Summary.js index 371c8980f..efb44daed 100644 --- a/www/manager6/dc/Summary.js +++ b/www/manager6/dc/Summary.js @@ -64,7 +64,7 @@ Ext.define('PVE.dc.Summary', { element: 'el', click: function() { if (this.component.userCls === 'pointer') { - window.open('https://www.proxmox.com/en/proxmox-ve/pricing', '_blank'); + window.open('https://www.proxmox.com/en/proxmox-virtual-environment/pricing', '_blank'); } }, }, diff --git a/www/mobile/WidgetToolkitUtils.js b/www/mobile/WidgetToolkitUtils.js index e11aa89e3..b292fcd5d 100644 --- a/www/mobile/WidgetToolkitUtils.js +++ b/www/mobile/WidgetToolkitUtils.js @@ -115,7 +115,6 @@ utilities: { }, getNoSubKeyHtml: function(url) { - // url http://www.proxmox.com/products/proxmox-ve/subscription-service-plans return Ext.String.format('You do not have a valid subscription for this server. Please visit www.proxmox.com to get a list of available options.', url || 'https://www.proxmox.com'); }, From 461c5ee29081f92e87d3641d8f0336996a7a07ff Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Fri, 25 Aug 2023 11:45:12 +0200 Subject: [PATCH 202/398] vzdump: fix notifications for backing up VMs with 2+ disks to PBS In some situations, such as backing up VMs with 2 or more disks to PBS, we get passed the backup archive size as a string instead of as an integer. This led to errors rendering the notification template down the line. This commit explicitly casts the data from the task table to an int. It would be a good idea to actually hunt down the places that produced the string instead of an integer, but as a quick fix and as a safeguard against similar lurking errors this approach is fine, IMO. Signed-off-by: Lukas Wagner --- PVE/VZDump.pm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index 2671e3b1e..454ab4944 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -387,11 +387,11 @@ sub build_guest_table { my $size = $successful ? $task->{size} : 0; my $filename = $successful ? $task->{target} : undef; push @{$table->{data}}, { - "vmid" => $task->{vmid}, + "vmid" => int($task->{vmid}), "name" => $task->{hostname}, "status" => $task->{state}, - "time" => $task->{backuptime}, - "size" => $size, + "time" => int($task->{backuptime}), + "size" => int($size), "filename" => $filename, }; } From bacb4173fbb2874d4ac6fa1692fa92341ca36d06 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 18 Jul 2023 11:53:05 +0200 Subject: [PATCH 203/398] ui: replication: backup: use renderEnabledIcon to render the enabled column The new helper is available since proxmox-widget-toolkit version 3.6.1 which we can be sure to be available since a while in praxis, but definitively by d/control constraints, since 51f54177 ("bump proxmox-widget-toolkit dependency to 4.0.7") Signed-off-by: Lukas Wagner [TL: add context since when this function is available ] Signed-off-by: Thomas Lamprecht --- www/manager6/dc/Backup.js | 3 +-- www/manager6/grid/Replication.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js index 625b54300..990769573 100644 --- a/www/manager6/dc/Backup.js +++ b/www/manager6/dc/Backup.js @@ -792,8 +792,7 @@ Ext.define('PVE.dc.BackupView', { width: 80, dataIndex: 'enabled', align: 'center', - // TODO: switch to Proxmox.Utils.renderEnabledIcon once available - renderer: enabled => ``, + renderer: Proxmox.Utils.renderEnabledIcon, sortable: true, }, { diff --git a/www/manager6/grid/Replication.js b/www/manager6/grid/Replication.js index 30cd6718f..1e4e00fcb 100644 --- a/www/manager6/grid/Replication.js +++ b/www/manager6/grid/Replication.js @@ -283,8 +283,7 @@ Ext.define('PVE.grid.ReplicaView', { width: 80, dataIndex: 'enabled', align: 'center', - // TODO: switch to Proxmox.Utils.renderEnabledIcon once available - renderer: enabled => ``, + renderer: Proxmox.Utils.renderEnabledIcon, sortable: true, }, { From 808eb12f8cc61480457e836f9d97e94373f3a644 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Mon, 4 Sep 2023 11:18:07 +0200 Subject: [PATCH 204/398] api: ceph: improve reporting of ceph OSD memory usage Currently we are using the MemoryCurrent property of the OSD service to determine the used memory of a Ceph OSD. This includes, among other things, the memory used by buffers [1]. Since BlueFS uses buffered I/O, this can lead to extremely high values shown in the UI. Instead we are now reading the PSS value from the proc filesystem, which should more accurately reflect the amount of memory currently used by the Ceph OSD. Aaron and I decided on PSS over RSS, since this should give a better idea of used memory - particularly when using a large amount of OSDs on one host, since the OSDs share some of the pages. [1] https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt Signed-off-by: Stefan Hanreich Tested-by: Aaron Lauterer --- PVE/API2/Ceph/OSD.pm | 21 ++++++++++++++++----- www/manager6/ceph/OSDDetails.js | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/PVE/API2/Ceph/OSD.pm b/PVE/API2/Ceph/OSD.pm index ded359904..389802971 100644 --- a/PVE/API2/Ceph/OSD.pm +++ b/PVE/API2/Ceph/OSD.pm @@ -687,13 +687,10 @@ __PACKAGE__->register_method ({ my $raw = ''; my $pid; - my $memory; my $parser = sub { my $line = shift; if ($line =~ m/^MainPID=([0-9]*)$/) { $pid = $1; - } elsif ($line =~ m/^MemoryCurrent=([0-9]*|\[not set\])$/) { - $memory = $1 eq "[not set]" ? 0 : $1; } }; @@ -702,12 +699,26 @@ __PACKAGE__->register_method ({ 'show', "ceph-osd\@${osdid}.service", '--property', - 'MainPID,MemoryCurrent', + 'MainPID', ]; run_command($cmd, errmsg => 'fetching OSD PID and memory usage failed', outfunc => $parser); $pid = defined($pid) ? int($pid) : undef; - $memory = defined($memory) ? int($memory) : undef; + + my $memory = 0; + if ($pid && $pid > 0) { + open (my $SMAPS, '<', "/proc/$pid/smaps_rollup") + or die "failed to read PSS memory-stat from process - $!\n"; + + while (my $line = <$SMAPS>) { + if ($line =~ m/^Pss:\s+([0-9]+) kB$/) { + $memory = $1 * 1024; + last; + } + } + + close $SMAPS; + } my $data = { osd => { diff --git a/www/manager6/ceph/OSDDetails.js b/www/manager6/ceph/OSDDetails.js index f0765d4fe..3b1c1d9c0 100644 --- a/www/manager6/ceph/OSDDetails.js +++ b/www/manager6/ceph/OSDDetails.js @@ -148,7 +148,7 @@ Ext.define('PVE.CephOsdDetails', { { xtype: 'text', name: 'mem_usage', - text: gettext('Memory usage'), + text: gettext('Memory usage (PSS)'), renderer: Proxmox.Utils.render_size, }, { From f7b7e942a7d03202e00d4171071f93efc046710b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 4 Sep 2023 14:22:27 +0200 Subject: [PATCH 205/398] api: ceph osd: drop unused variable and useless intermediate code $raw isn't used anywhere here and probably just a left over from copy pasting, and the "int cast ternary" can be avoided by just directly casting to int when assigning the variable in the first place. Signed-off-by: Thomas Lamprecht --- PVE/API2/Ceph/OSD.pm | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/PVE/API2/Ceph/OSD.pm b/PVE/API2/Ceph/OSD.pm index 389802971..bcc4521f7 100644 --- a/PVE/API2/Ceph/OSD.pm +++ b/PVE/API2/Ceph/OSD.pm @@ -685,12 +685,11 @@ __PACKAGE__->register_method ({ die "OSD '${osdid}' does not exists on host '${nodename}'\n" if $nodename ne $metadata->{hostname}; - my $raw = ''; my $pid; my $parser = sub { my $line = shift; if ($line =~ m/^MainPID=([0-9]*)$/) { - $pid = $1; + $pid = int($1); } }; @@ -703,8 +702,6 @@ __PACKAGE__->register_method ({ ]; run_command($cmd, errmsg => 'fetching OSD PID and memory usage failed', outfunc => $parser); - $pid = defined($pid) ? int($pid) : undef; - my $memory = 0; if ($pid && $pid > 0) { open (my $SMAPS, '<', "/proc/$pid/smaps_rollup") From b4b39b55f856d2a4384e587af421f8fd9bbf0fed Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 4 Sep 2023 14:24:17 +0200 Subject: [PATCH 206/398] api: ceph osd: factor out getting PSS stat & improve error handling Do not crowd the higher level API endpoint handler code directly with some rather low level procfs parsing code, rather factor that out in a helper. Make said helper private for now so that anybody wanting to use cannot do so, and thus increase the chance that said dev will actually think about if this makes sense as is as a general interface. Avoid fatal die's for the odd case that the smaps_rollup file cannot be opened, or the even less likely case where PSS stats cannot be found in the content. The former could happen due to the general TOCTOU race here, i.e., the PID we get from systemctl service status parsing isn't guaranteed to exist anymore when we read from procfs, and if, it's actually not guaranteed to still be the OSD - but we cannot easily use pidfd's here and OSD stops are not something that happens frequently, but in anyway avoid that such a thing fails the whole API call only because a single metric is affected. In the long rung it might be better to add a "errors" array to the response, so that the user can be informed about such an (odd) thing happening. Signed-off-by: Thomas Lamprecht --- PVE/API2/Ceph/OSD.pm | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/PVE/API2/Ceph/OSD.pm b/PVE/API2/Ceph/OSD.pm index bcc4521f7..0f501c016 100644 --- a/PVE/API2/Ceph/OSD.pm +++ b/PVE/API2/Ceph/OSD.pm @@ -70,6 +70,24 @@ my $get_osd_usage = sub { return $osdstat; }; +my sub get_proc_pss_from_pid { + my ($pid) = @_; + return if !defined($pid) || $pid <= 1; + + open (my $SMAPS_FH, '<', "/proc/$pid/smaps_rollup") + or die "failed to open PSS memory-stat from process - $!\n"; + + while (my $line = <$SMAPS_FH>) { + if ($line =~ m/^Pss:\s+([0-9]+) kB$/) { # using PSS avoids bias with many OSDs + close $SMAPS_FH; + return int($1) * 1024; + } + } + close $SMAPS_FH; + die "internal error: failed to find PSS memory-stat in procfs for PID $pid\n"; +} + + __PACKAGE__->register_method ({ name => 'index', path => '', @@ -702,26 +720,14 @@ __PACKAGE__->register_method ({ ]; run_command($cmd, errmsg => 'fetching OSD PID and memory usage failed', outfunc => $parser); - my $memory = 0; - if ($pid && $pid > 0) { - open (my $SMAPS, '<', "/proc/$pid/smaps_rollup") - or die "failed to read PSS memory-stat from process - $!\n"; - - while (my $line = <$SMAPS>) { - if ($line =~ m/^Pss:\s+([0-9]+) kB$/) { - $memory = $1 * 1024; - last; - } - } - - close $SMAPS; - } + my $osd_pss_memory = eval { get_proc_pss_from_pid($pid) } // 0; + warn $@ if $@; my $data = { osd => { hostname => $metadata->{hostname}, id => $metadata->{id}, - mem_usage => $memory, + mem_usage => $osd_pss_memory, osd_data => $metadata->{osd_data}, osd_objectstore => $metadata->{osd_objectstore}, pid => $pid, From 12c8efb59ea725f5af22e061eab0a30a667eb622 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 4 Sep 2023 18:05:08 +0200 Subject: [PATCH 207/398] pveceph: support installing Ceph 18.2 Reef Signed-off-by: Thomas Lamprecht --- PVE/CLI/pveceph.pm | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/PVE/CLI/pveceph.pm b/PVE/CLI/pveceph.pm index 8cff04c54..5f45610b7 100755 --- a/PVE/CLI/pveceph.pm +++ b/PVE/CLI/pveceph.pm @@ -114,7 +114,7 @@ my sub has_valid_subscription { return $info->{status} && $info->{status} eq 'active'; # age check? } -my $supported_ceph_versions = ['quincy']; +my $supported_ceph_versions = ['quincy', 'reef']; my $default_ceph_version = 'quincy'; __PACKAGE__->register_method ({ @@ -171,7 +171,9 @@ __PACKAGE__->register_method ({ } my $repolist; - if ($cephver eq 'quincy') { + if ($cephver eq 'reef') { + $repolist = "deb ${cdn}/debian/ceph-reef bookworm $repo\n"; + } elsif ($cephver eq 'quincy') { $repolist = "deb ${cdn}/debian/ceph-quincy bookworm $repo\n"; } else { die "unsupported ceph version: $cephver"; From 0e1178d8d212049a6f5f2c486d5ad774abd460f7 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 4 Sep 2023 18:05:28 +0200 Subject: [PATCH 208/398] ui: ceph wizard: drop unused gettext Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 1 - 1 file changed, 1 deletion(-) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index 46272fc9b..bda6f8d9f 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -162,7 +162,6 @@ Ext.define('PVE.ceph.CephInstallWizard', { if (!nodeSub) { return gettext('The enterprise repository is enabled, but there is no active subscription!'); } else if (!allSub) { - //return gettext('Not all nodes in the cluster have an active subscription, so not all have access to the enterprise repository and therefore may receive upgrades sooner!'); return gettext('Not all nodes have an active subscription, which is required for cluster-wide enterprise repo access'); } return ''; // should be hidden From 41af7c5e7e6934ae1bb5c8d00518c609d3700b43 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 4 Sep 2023 18:05:44 +0200 Subject: [PATCH 209/398] ui: ceph wizard: add Ceph 18.2 Reef to available releases still default to Ceph 17.2 Quincy for now, at least if there isn't a Ceph Reef set-up in the cluster already. Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index bda6f8d9f..af0b16688 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -50,6 +50,7 @@ Ext.define('PVE.ceph.CephVersionSelector', { }, data: [ { release: "quincy", version: "17.2" }, + { release: "reef", version: "18.2" }, ], }, }); From 8b4f117858c9858e750c027bfa2c7e267bab96d6 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 4 Sep 2023 18:06:46 +0200 Subject: [PATCH 210/398] ui: ceph: add Ceph 19 Squid to release map Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index af0b16688..63dec054c 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -109,6 +109,7 @@ Ext.define('PVE.ceph.CephHighestVersionDisplay', { 16: 'pacific', 17: 'quincy', 18: 'reef', + 19: 'squid', }; let release = major2release[maxversion[0]] || 'unknown'; let newestVersionTxt = `${Ext.String.capitalize(release)} (${maxversiontext})`; From 09a2f33458585f8530e8ef25dcb9763af0b9a4af Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 27 Jul 2023 10:57:45 +0200 Subject: [PATCH 211/398] ui: ldap: ad: support 'mode' paramter, replacing 'secure' The backend has supported the 'mode' parameter for quite a while, however it has not yet been exposed in the GUI, contrary to PMG and PBS. The benefit of 'mode' is that it supports LDAP, LDAPS and LDAP via STARTTLS, compared to just LDAP/LDAPS for the 'secure' parameter. The modified AuthEdit{LDAP,AD} panel will now automatically migrate to the new paramter by hooking into onGetValues/onSetValues. Signed-off-by: Lukas Wagner --- www/manager6/dc/AuthEditAD.js | 43 +++++++++++++++++++++++++++------ www/manager6/dc/AuthEditLDAP.js | 42 ++++++++++++++++++++++++++------ 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/www/manager6/dc/AuthEditAD.js b/www/manager6/dc/AuthEditAD.js index 3cbb47c9f..fe4186614 100644 --- a/www/manager6/dc/AuthEditAD.js +++ b/www/manager6/dc/AuthEditAD.js @@ -49,18 +49,26 @@ Ext.define('PVE.panel.ADInputPanel', { submitEmptyText: false, }, { - xtype: 'proxmoxcheckbox', - fieldLabel: 'SSL', - name: 'secure', - uncheckedValue: 0, + xtype: 'proxmoxKVComboBox', + name: 'mode', + fieldLabel: gettext('Mode'), + editable: false, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (LDAP)'], + ['ldap', 'LDAP'], + ['ldap+starttls', 'STARTTLS'], + ['ldaps', 'LDAPS'], + ], + value: '__default__', + deleteEmpty: !me.isCreate, listeners: { change: function(field, newValue) { let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]'); - if (newValue === true) { - verifyCheckbox.enable(); - } else { + if (newValue === 'ldap' || newValue === '__default__') { verifyCheckbox.disable(); verifyCheckbox.setValue(0); + } else { + verifyCheckbox.enable(); } }, }, @@ -106,6 +114,27 @@ Ext.define('PVE.panel.ADInputPanel', { delete values.verify; } + if (!me.isCreate) { + // Delete old `secure` parameter. It has been deprecated in favor to the + // `mode` parameter. Migration happens automatically in `onSetValues`. + Proxmox.Utils.assemble_field_data(values, { 'delete': 'secure' }); + } + + + return me.callParent([values]); + }, + + onSetValues(values) { + let me = this; + + if (values.secure !== undefined && !values.mode) { + // If `secure` is set, use it to determine the correct setting for `mode` + // `secure` is later deleted by `onSetValues` . + // In case *both* are set, we simply ignore `secure` and use + // whatever `mode` is set to. + values.mode = values.secure ? 'ldaps' : 'ldap'; + } + return me.callParent([values]); }, }); diff --git a/www/manager6/dc/AuthEditLDAP.js b/www/manager6/dc/AuthEditLDAP.js index 9986db8a9..f4aecef89 100644 --- a/www/manager6/dc/AuthEditLDAP.js +++ b/www/manager6/dc/AuthEditLDAP.js @@ -49,18 +49,26 @@ Ext.define('PVE.panel.LDAPInputPanel', { submitEmptyText: false, }, { - xtype: 'proxmoxcheckbox', - fieldLabel: 'SSL', - name: 'secure', - uncheckedValue: 0, + xtype: 'proxmoxKVComboBox', + name: 'mode', + fieldLabel: gettext('Mode'), + editable: false, + comboItems: [ + ['__default__', Proxmox.Utils.defaultText + ' (LDAP)'], + ['ldap', 'LDAP'], + ['ldap+starttls', 'STARTTLS'], + ['ldaps', 'LDAPS'], + ], + value: '__default__', + deleteEmpty: !me.isCreate, listeners: { change: function(field, newValue) { let verifyCheckbox = field.nextSibling('proxmoxcheckbox[name=verify]'); - if (newValue === true) { - verifyCheckbox.enable(); - } else { + if (newValue === 'ldap' || newValue === '__default__') { verifyCheckbox.disable(); verifyCheckbox.setValue(0); + } else { + verifyCheckbox.enable(); } }, }, @@ -106,6 +114,26 @@ Ext.define('PVE.panel.LDAPInputPanel', { delete values.verify; } + if (!me.isCreate) { + // Delete old `secure` parameter. It has been deprecated in favor to the + // `mode` parameter. Migration happens automatically in `onSetValues`. + Proxmox.Utils.assemble_field_data(values, { 'delete': 'secure' }); + } + + return me.callParent([values]); + }, + + onSetValues(values) { + let me = this; + + if (values.secure !== undefined && !values.mode) { + // If `secure` is set, use it to determine the correct setting for `mode` + // `secure` is later deleted by `onSetValues` . + // In case *both* are set, we simply ignore `secure` and use + // whatever `mode` is set to. + values.mode = values.secure ? 'ldaps' : 'ldap'; + } + return me.callParent([values]); }, }); From 2b9b6bc401eabba5b5b3660d5636ac94a05437b2 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 27 Jul 2023 10:57:46 +0200 Subject: [PATCH 212/398] ui: ldap: ad: fix typo for verify certificate combobox Signed-off-by: Lukas Wagner --- www/manager6/dc/AuthEditAD.js | 2 +- www/manager6/dc/AuthEditLDAP.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/dc/AuthEditAD.js b/www/manager6/dc/AuthEditAD.js index fe4186614..57365f84d 100644 --- a/www/manager6/dc/AuthEditAD.js +++ b/www/manager6/dc/AuthEditAD.js @@ -77,7 +77,7 @@ Ext.define('PVE.panel.ADInputPanel', { xtype: 'proxmoxcheckbox', fieldLabel: gettext('Verify Certificate'), name: 'verify', - unceckedValue: 0, + uncheckedValue: 0, disabled: true, checked: false, autoEl: { diff --git a/www/manager6/dc/AuthEditLDAP.js b/www/manager6/dc/AuthEditLDAP.js index f4aecef89..6982c3ebd 100644 --- a/www/manager6/dc/AuthEditLDAP.js +++ b/www/manager6/dc/AuthEditLDAP.js @@ -77,7 +77,7 @@ Ext.define('PVE.panel.LDAPInputPanel', { xtype: 'proxmoxcheckbox', fieldLabel: gettext('Verify Certificate'), name: 'verify', - unceckedValue: 0, + uncheckedValue: 0, disabled: true, checked: false, autoEl: { From c54ff6e901a55b7eb9b40ba2f4b8468e42d81af7 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 27 Jul 2023 10:57:47 +0200 Subject: [PATCH 213/398] ui: ldap: ad: replace occurences of SSL with TLS Although 'SSL' is used colloquially, the proper term is 'TLS'. Signed-off-by: Lukas Wagner --- www/manager6/dc/AuthEditAD.js | 2 +- www/manager6/dc/AuthEditLDAP.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/dc/AuthEditAD.js b/www/manager6/dc/AuthEditAD.js index 57365f84d..cc4480289 100644 --- a/www/manager6/dc/AuthEditAD.js +++ b/www/manager6/dc/AuthEditAD.js @@ -82,7 +82,7 @@ Ext.define('PVE.panel.ADInputPanel', { checked: false, autoEl: { tag: 'div', - 'data-qtip': gettext('Verify SSL certificate of the server'), + 'data-qtip': gettext('Verify TLS certificate of the server'), }, }, ]; diff --git a/www/manager6/dc/AuthEditLDAP.js b/www/manager6/dc/AuthEditLDAP.js index 6982c3ebd..7078c0a5c 100644 --- a/www/manager6/dc/AuthEditLDAP.js +++ b/www/manager6/dc/AuthEditLDAP.js @@ -82,7 +82,7 @@ Ext.define('PVE.panel.LDAPInputPanel', { checked: false, autoEl: { tag: 'div', - 'data-qtip': gettext('Verify SSL certificate of the server'), + 'data-qtip': gettext('Verify TLS certificate of the server'), }, }, ]; From e8ed907b7e3266450750ea6f4125857f3129325c Mon Sep 17 00:00:00 2001 From: Markus Frank Date: Wed, 6 Sep 2023 11:37:03 +0200 Subject: [PATCH 214/398] fix #4947 spice: correct filename extension safari Fix file extension for SPICE config download on AppleWebKit browsers to ensure proper application association on MacOS. Signed-off-by: Markus Frank Signed-off-by: Thomas Lamprecht --- www/manager6/Utils.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 6d4842df2..06b63315f 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1401,10 +1401,11 @@ Ext.define('PVE.Utils', { css: 'display:none;visibility:hidden;height:0px;', }); - // Note: we need to tell Android and Chrome the correct file name extension + // Note: we need to tell Android, AppleWebKit and Chrome + // the correct file name extension // but we do not set 'download' tag for other environments, because // It can have strange side effects (additional user prompt on firefox) - if (navigator.userAgent.match(/Android|Chrome/i)) { + if (navigator.userAgent.match(/Android|AppleWebKit|Chrome/i)) { link.download = name; } From ab70343982f36a5343d3fcf4a1a6489bd3f52a66 Mon Sep 17 00:00:00 2001 From: Maximiliano Sandoval Date: Tue, 5 Sep 2023 10:53:31 +0200 Subject: [PATCH 215/398] fix #4808: ceph: mds create: use snake_case when setting options As suggested in [1], it is recommended to use `_` in all cases when dealing with config files. Note that this is for creation only, and we enforce that there cannot be an existing MDS with the same ID, so we do not have to bother how ceph would handle the case where both exist. [1] https://docs.ceph.com/en/reef/rados/configuration/ceph-conf/#option-names Signed-off-by: Maximiliano Sandoval Signed-off-by: Thomas Lamprecht --- PVE/API2/Ceph/MDS.pm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PVE/API2/Ceph/MDS.pm b/PVE/API2/Ceph/MDS.pm index 1cb0b74f8..6fc0ae450 100644 --- a/PVE/API2/Ceph/MDS.pm +++ b/PVE/API2/Ceph/MDS.pm @@ -153,10 +153,10 @@ __PACKAGE__->register_method ({ } $cfg->{$section}->{host} = $nodename; - $cfg->{$section}->{"mds standby for name"} = 'pve'; + $cfg->{$section}->{'mds_standby_for_name'} = 'pve'; if ($param->{hotstandby}) { - $cfg->{$section}->{"mds standby replay"} = 'true'; + $cfg->{$section}->{'mds_standby_replay'} = 'true'; } cfs_write_file('ceph.conf', $cfg); From 672e81df36dbed9de865bc6c4336c36aef84c620 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 8 Sep 2023 13:08:03 +0200 Subject: [PATCH 216/398] ui: pool view: replace allow-transfer checkbox with simple hint It's not really providing good UX, as user needs to extra tick this but cannot be sure what transfer means in this case. Just replace this with a simple, more telling hint that will inform users about what happens. Signed-off-by: Thomas Lamprecht --- www/manager6/grid/PoolMembers.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js index 224daca34..6d0eb4a6a 100644 --- a/www/manager6/grid/PoolMembers.js +++ b/www/manager6/grid/PoolMembers.js @@ -1,9 +1,15 @@ Ext.define('PVE.pool.AddVM', { extend: 'Proxmox.window.Edit', + width: 600, height: 420, isAdd: true, isCreate: true, + + extraRequestParams: { + transfer: 1, + }, + initComponent: function() { var me = this; @@ -90,15 +96,17 @@ Ext.define('PVE.pool.AddVM', { ], }); - let transfer = Ext.create('Ext.form.field.Checkbox', { - name: 'transfer', - boxLabel: gettext('Allow Transfer'), - inputValue: 1, - value: 0, - }); Ext.apply(me, { subject: gettext('Virtual Machine'), - items: [vmsField, vmGrid, transfer], + items: [ + vmsField, + vmGrid, + { + xtype: 'displayfield', + userCls: 'pmx-hint', + value: gettext('Selected guests who are already part of a pool will be removed from it first.'), + }, + ], }); me.callParent(); From 207cd6e0e6abf82593633e829e22b0570777e2f4 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 8 Sep 2023 13:09:46 +0200 Subject: [PATCH 217/398] ui: pool view: label pool-column as 'Current Pool' to make it clearer, as having such a column could be slightly confusing to some. Signed-off-by: Thomas Lamprecht --- www/manager6/grid/PoolMembers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js index 6d0eb4a6a..b2bf86a4b 100644 --- a/www/manager6/grid/PoolMembers.js +++ b/www/manager6/grid/PoolMembers.js @@ -70,7 +70,7 @@ Ext.define('PVE.pool.AddVM', { dataIndex: 'node', }, { - header: gettext('Pool'), + header: gettext('Current Pool'), dataIndex: 'pool', }, { From c0f2b236598f0d1fe8c3ec4afc502f3c17a9565f Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 8 Sep 2023 13:11:09 +0200 Subject: [PATCH 218/398] ui: pool view: simplify guest-status renderer Signed-off-by: Thomas Lamprecht --- www/manager6/grid/PoolMembers.js | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js index b2bf86a4b..b602289ac 100644 --- a/www/manager6/grid/PoolMembers.js +++ b/www/manager6/grid/PoolMembers.js @@ -76,13 +76,7 @@ Ext.define('PVE.pool.AddVM', { { header: gettext('Status'), dataIndex: 'uptime', - renderer: function(value) { - if (value) { - return Proxmox.Utils.runningText; - } else { - return Proxmox.Utils.stoppedText; - } - }, + renderer: v => v ? Proxmox.Utils.runningText : Proxmox.Utils.stoppedText, }, { header: gettext('Name'), From 67f5dbd3057740df03581c8ad51dd4a8022d4503 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 8 Sep 2023 13:13:54 +0200 Subject: [PATCH 219/398] ui: pool view: make window bigger and 4:3 ratio Signed-off-by: Thomas Lamprecht --- www/manager6/grid/PoolMembers.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js index b602289ac..04e967e41 100644 --- a/www/manager6/grid/PoolMembers.js +++ b/www/manager6/grid/PoolMembers.js @@ -1,8 +1,8 @@ Ext.define('PVE.pool.AddVM', { extend: 'Proxmox.window.Edit', - width: 600, - height: 420, + width: 640, + height: 480, isAdd: true, isCreate: true, @@ -44,7 +44,7 @@ Ext.define('PVE.pool.AddVM', { var vmGrid = Ext.create('widget.grid', { store: vmStore, border: true, - height: 300, + height: 360, scrollable: true, selModel: { selType: 'checkboxmodel', From a06d2fac4486dd14070cef62412819eff6ea08dc Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 8 Sep 2023 13:29:26 +0200 Subject: [PATCH 220/398] api: pool update: improve description and document defaults Signed-off-by: Thomas Lamprecht --- PVE/API2/Pool.pm | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/PVE/API2/Pool.pm b/PVE/API2/Pool.pm index 3c0ae2a07..2463207e3 100644 --- a/PVE/API2/Pool.pm +++ b/PVE/API2/Pool.pm @@ -122,24 +122,27 @@ __PACKAGE__->register_method ({ poolid => { type => 'string', format => 'pve-poolid' }, comment => { type => 'string', optional => 1 }, vms => { - description => "List of virtual machines.", + description => 'List of guest VMIDs to add or remove from this pool.', type => 'string', format => 'pve-vmid-list', optional => 1, }, storage => { - description => "List of storage IDs.", + description => 'List of storage IDs to add or remove from this pool.', type => 'string', format => 'pve-storage-id-list', optional => 1, }, transfer => { - description => "Allow transferring VMs to another pool.", + description => 'Allow adding a guest even if already in another pool.' + .' The guest will be removed from its current pool and added to this one.', type => 'boolean', optional => 1, + default => 0, }, delete => { - description => "Remove vms/storage (instead of adding it).", + description => 'Remove the passed VMIDs and/or storage IDs instead of adding them.', type => 'boolean', optional => 1, + default => 0, }, }, }, From b7bc7ae9dadc53a9590d447204c1ca6b1bb0effb Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 8 Sep 2023 13:30:41 +0200 Subject: [PATCH 221/398] api: pool update: refactor/code-clean-up guest handling Signed-off-by: Thomas Lamprecht --- PVE/API2/Pool.pm | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/PVE/API2/Pool.pm b/PVE/API2/Pool.pm index 2463207e3..ffcc3946e 100644 --- a/PVE/API2/Pool.pm +++ b/PVE/API2/Pool.pm @@ -172,15 +172,12 @@ __PACKAGE__->register_method ({ delete $usercfg->{vms}->{$vmid}; } else { die "VM $vmid is already a pool member\n" if $pool_config->{vms}->{$vmid}; - my $existing_pool = $usercfg->{vms}->{$vmid}; - if (defined($existing_pool)) { - if ($param->{transfer}) { - $rpcenv->check($authuser, "/pool/$existing_pool", ['Pool.Allocate']); - my $existing_pool_config = $usercfg->{pools}->{$existing_pool}; - delete $existing_pool_config->{vms}->{$vmid}; - } else { - die "VM $vmid belongs already to pool '$existing_pool' and transfer is not set\n"; - } + if (defined(my $existing_pool = $usercfg->{vms}->{$vmid})) { + die "VM $vmid belongs already to pool '$existing_pool' and 'transfer' is not set\n" + if !$param->{transfer}; + + $rpcenv->check($authuser, "/pool/$existing_pool", ['Pool.Allocate']); + delete $usercfg->{pools}->{$existing_pool}->{vms}->{$vmid}; } $pool_config->{vms}->{$vmid} = 1; $usercfg->{vms}->{$vmid} = $pool; From f9cfa38ee7df859c4620f0e286a51308346a14ca Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 8 Sep 2023 13:31:30 +0200 Subject: [PATCH 222/398] api: pool update: rename 'transfer' parameter to 'allow-move' The 'allow' wording makes it clearer that we just not block something, but do not really do anything else. And we use the 'move' wording also for when moving volumes between guests, which is in the same spirit as this here (remove something from a entity and add it to another). While this was already bumped, we did not move it outside of pvetest, so I do not see practical concerns with API breakage. Signed-off-by: Thomas Lamprecht --- PVE/API2/Pool.pm | 6 +++--- www/manager6/grid/PoolMembers.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/PVE/API2/Pool.pm b/PVE/API2/Pool.pm index ffcc3946e..51ac71941 100644 --- a/PVE/API2/Pool.pm +++ b/PVE/API2/Pool.pm @@ -131,7 +131,7 @@ __PACKAGE__->register_method ({ type => 'string', format => 'pve-storage-id-list', optional => 1, }, - transfer => { + 'allow-move' => { description => 'Allow adding a guest even if already in another pool.' .' The guest will be removed from its current pool and added to this one.', type => 'boolean', @@ -173,8 +173,8 @@ __PACKAGE__->register_method ({ } else { die "VM $vmid is already a pool member\n" if $pool_config->{vms}->{$vmid}; if (defined(my $existing_pool = $usercfg->{vms}->{$vmid})) { - die "VM $vmid belongs already to pool '$existing_pool' and 'transfer' is not set\n" - if !$param->{transfer}; + die "VM $vmid belongs already to pool '$existing_pool' and 'allow-move' is not set\n" + if !$param->{'allow-move'}; $rpcenv->check($authuser, "/pool/$existing_pool", ['Pool.Allocate']); delete $usercfg->{pools}->{$existing_pool}->{vms}->{$vmid}; diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js index 04e967e41..74950d80e 100644 --- a/www/manager6/grid/PoolMembers.js +++ b/www/manager6/grid/PoolMembers.js @@ -7,7 +7,7 @@ Ext.define('PVE.pool.AddVM', { isCreate: true, extraRequestParams: { - transfer: 1, + 'allow-move': 1, }, initComponent: function() { From 57490ff2c6a38448d511d32ec1b4c9bc825545d9 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 8 Sep 2023 13:41:30 +0200 Subject: [PATCH 223/398] bump version to 8.0.6 Signed-off-by: Thomas Lamprecht --- debian/changelog | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/debian/changelog b/debian/changelog index 6de5ba8a2..c5022e251 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,33 @@ +pve-manager (8.0.6) bookworm; urgency=medium + + * fix #4663: Prevent Web UI reload on cert order for other node + + * website: update external links to www.proxmox.com + + * vzdump: fix notifications for backing up VMs with 2+ disks to PBS + + * api: ceph: improve reporting of ceph OSD memory usage, using PSS metric + now which avoids ignores page-cache that can be freed easily and also + measures shared memory in proportional relations. + + * pveceph: support installing Ceph 18.2 Reef + + * ui: ceph wizard: add Ceph 18.2 Reef to available releases + + * ui: ldap: ad: support 'mode' parameter, replacing 'secure' + + * ui: ldap: ad: replace occurrences of SSL with TLS + + * fix #4947 spice: correct filename-extension handling for Safari + + * fix #4808: ceph: mds create: use snake_case when setting options + + * ui: pool view: replace allow-transfer checkbox with simple hint + + * api: pool update: rename 'transfer' parameter to 'allow-move' + + -- Proxmox Support Team Fri, 08 Sep 2023 13:42:00 +0200 + pve-manager (8.0.5) bookworm; urgency=medium * add 'check connection' checkbox for ldap as advanced option From 3dff70eb7ebf5d7f422ee18f05efc62b27b2f10b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 14 Sep 2023 08:15:02 +0200 Subject: [PATCH 224/398] ui: notifications event: fix using gettext Signed-off-by: Thomas Lamprecht --- www/manager6/dc/NotificationEvents.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/www/manager6/dc/NotificationEvents.js b/www/manager6/dc/NotificationEvents.js index f2ee12e07..216282047 100644 --- a/www/manager6/dc/NotificationEvents.js +++ b/www/manager6/dc/NotificationEvents.js @@ -29,8 +29,7 @@ Ext.define('PVE.dc.NotificationEventDisabledWarning', { extend: 'Ext.form.field.Display', userCls: 'pmx-hint', hidden: true, - value: gettext('Disabling notifications is not ' + - 'recommended for production systems!'), + value: gettext('Disabling notifications is not recommended for production systems!'), }); Ext.define('PVE.dc.NotificationEventsTargetSelector', { From 84846fdc10048a59d8b5b1de2bca2f2e94d97b75 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Mon, 18 Sep 2023 10:20:26 +0200 Subject: [PATCH 225/398] ui: notification: remove unneeded gettext calls 'mail-to-root' is the name of the default notification target and should thus not be translated. Signed-off-by: Lukas Wagner --- www/manager6/dc/NotificationEvents.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/dc/NotificationEvents.js b/www/manager6/dc/NotificationEvents.js index 216282047..188162908 100644 --- a/www/manager6/dc/NotificationEvents.js +++ b/www/manager6/dc/NotificationEvents.js @@ -40,7 +40,7 @@ Ext.define('PVE.dc.NotificationEventsTargetSelector', { editable: true, autoSelect: false, deleteEmpty: false, - emptyText: `${Proxmox.Utils.defaultText} (${gettext("mail-to-root")})`, + emptyText: `${Proxmox.Utils.defaultText} (mail-to-root)`, }); Ext.define('PVE.dc.NotificationEvents', { @@ -126,7 +126,7 @@ Ext.define('PVE.dc.NotificationEvents', { // Needed since the actual value is always stored in the 'notify' property let render_value = (store, target_key, mode_key, default_val) => { let value = store.getById('notify')?.get('value') ?? {}; - let target = value[target_key] ?? gettext('mail-to-root'); + let target = value[target_key] ?? 'mail-to-root'; let template; switch (value[mode_key]) { From b0c2d8980fd09fd1f8ac95b29916d239466a15b4 Mon Sep 17 00:00:00 2001 From: Alexander Zeidler Date: Fri, 28 Jul 2023 15:28:55 +0200 Subject: [PATCH 226/398] fix #3069: vzdump: add property 'performance: pbs-entries-max=N' configuring pbs-entries-max can avoid failing backups due to a high amount of files in folders where a folder exclusion is not possible Signed-off-by: Alexander Zeidler --- configs/vzdump.conf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/vzdump.conf b/configs/vzdump.conf index 2ea09ae09..33893e699 100644 --- a/configs/vzdump.conf +++ b/configs/vzdump.conf @@ -5,7 +5,7 @@ #storage: STORAGE_ID #mode: snapshot|suspend|stop #bwlimit: KBPS -#performance: max-workers=N +#performance: [max-workers=N][,pbs-entries-max=N] #ionice: PRI #lockwait: MINUTES #stopwait: MINUTES From acaa1e40d6b131f39db395bc763f164cfc94172b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 18 Sep 2023 16:55:08 +0200 Subject: [PATCH 227/398] d/control: bump dependency of pve-container and guest-common While not a must, it helps to ensure we got the newly documented pbs-entries-max feature actually available and avoids all to freaky set ups. Signed-off-by: Thomas Lamprecht --- debian/control | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index f84f3083f..34875c6cf 100644 --- a/debian/control +++ b/debian/control @@ -60,7 +60,7 @@ Depends: apt (>= 1.5~), libpve-cluster-api-perl (>= 7.0-5), libpve-cluster-perl (>= 7.2-3), libpve-common-perl (>= 7.2-7), - libpve-guest-common-perl (>= 5.0.2), + libpve-guest-common-perl (>= 5.0.5), libpve-http-server-perl (>= 4.1-1), libpve-notify-perl, libpve-rs-perl (>= 0.7.1), @@ -82,7 +82,7 @@ Depends: apt (>= 1.5~), proxmox-mini-journalreader (>= 1.3-1), proxmox-widget-toolkit (>= 4.0.7), pve-cluster (>= 7.0-4), - pve-container (>= 4.0-9), + pve-container (>= 5.0.5), pve-docs (>= 8.0~~), pve-firewall, pve-ha-manager, From d61728e289c82c7ce35a03b5b6b3da45ae177c5c Mon Sep 17 00:00:00 2001 From: Philipp Hufnagl Date: Thu, 21 Sep 2023 15:09:16 +0200 Subject: [PATCH 228/398] api: query_url_metadata: optionally detect compression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extend the query_url_metadata endpoint with the option to detect and return used compression algorithms, if supported by PVE. this will be used to support decompression as part of the download flow for certain file types (ISO files for now). Signed-off-by: Philipp Hufnagl Slightly reworded commit title/message Signed-off-by: Fabian Grünbichler Reviewed-by: Dominik Csapak Tested-by: Dominik Csapak --- PVE/API2/Nodes.pm | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index 5a148d1d0..1e8ed09e0 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -34,6 +34,7 @@ use PVE::RRD; use PVE::Report; use PVE::SafeSyslog; use PVE::Storage; +use PVE::Storage::Plugin; use PVE::Tools; use PVE::pvecfg; @@ -1564,7 +1565,13 @@ __PACKAGE__->register_method({ type => 'boolean', optional => 1, default => 1, - } + }, + 'detect-compression' => { + description => "If true an auto detection of used compression will be attempted", + type => 'boolean', + optional => 1, + default => 0, + }, }, }, returns => { @@ -1583,6 +1590,11 @@ __PACKAGE__->register_method({ type => 'string', optional => 1, }, + compression => { + type => 'string', + enum => $PVE::Storage::Plugin::KNOWN_COMPRESSION_FORMATS, + optional => 1, + }, }, }, code => sub { @@ -1605,6 +1617,7 @@ __PACKAGE__->register_method({ SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, ); } + my $detect_compression = $param->{'detect-compression'} // 0; my $req = HTTP::Request->new(HEAD => $url); my $res = $ua->request($req); @@ -1615,6 +1628,7 @@ __PACKAGE__->register_method({ my $disposition = $res->header("Content-Disposition"); my $type = $res->header("Content-Type"); + my $compression; my $filename; if ($disposition && ($disposition =~ m/filename="([^"]*)"/ || $disposition =~ m/filename=([^;]*)/)) { @@ -1628,10 +1642,16 @@ __PACKAGE__->register_method({ $type = $1; } + if ($detect_compression && $filename =~ m!^(.+)\.(${\PVE::Storage::Plugin::COMPRESSOR_RE})$!) { + $filename = $1; + $compression = $2; + } + my $ret = {}; $ret->{filename} = $filename if $filename; $ret->{size} = $size + 0 if $size; $ret->{mimetype} = $type if $type; + $ret->{compression} = $compression if $compression; return $ret; }}); From e86862bf27de57c6aa14dacb2b0c2b2c25e7258b Mon Sep 17 00:00:00 2001 From: Philipp Hufnagl Date: Thu, 21 Sep 2023 15:09:17 +0200 Subject: [PATCH 229/398] fix #4849: ui: allow decompressing ISO files when downloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compression algorithm will be automatically detected when querying the download URL. It can be overriden by the user using the "compression algorithm" drop down under advanced. Signed-off-by: Philipp Hufnagl Reworded title and message, updated d/control for libpve-storage-perl version Signed-off-by: Fabian Grünbichler Reviewed-by: Dominik Csapak Tested-by: Dominik Csapak --- debian/control | 2 +- www/manager6/Makefile | 1 + www/manager6/form/DecompressionSelector.js | 13 +++++++++++++ www/manager6/window/DownloadUrlToStorage.js | 14 +++++++++++++- 4 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 www/manager6/form/DecompressionSelector.js diff --git a/debian/control b/debian/control index 34875c6cf..259376d36 100644 --- a/debian/control +++ b/debian/control @@ -64,7 +64,7 @@ Depends: apt (>= 1.5~), libpve-http-server-perl (>= 4.1-1), libpve-notify-perl, libpve-rs-perl (>= 0.7.1), - libpve-storage-perl (>= 7.2-12), + libpve-storage-perl (>= 8.0.3), librados2-perl (>= 1.3-1), libtemplate-perl, libterm-readline-gnu-perl, diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 59a5d8a7f..87e66ece7 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -34,6 +34,7 @@ JSSRC= \ form/ContentTypeSelector.js \ form/ControllerSelector.js \ form/DayOfWeekSelector.js \ + form/DecompressionSelector.js \ form/DiskFormatSelector.js \ form/DiskStorageSelector.js \ form/FileSelector.js \ diff --git a/www/manager6/form/DecompressionSelector.js b/www/manager6/form/DecompressionSelector.js new file mode 100644 index 000000000..abd193165 --- /dev/null +++ b/www/manager6/form/DecompressionSelector.js @@ -0,0 +1,13 @@ +Ext.define('PVE.form.DecompressionSelector', { + extend: 'Proxmox.form.KVComboBox', + alias: ['widget.pveDecompressionSelector'], + config: { + deleteEmpty: false, + }, + comboItems: [ + ['__default__', Proxmox.Utils.NoneText], + ['lzo', 'LZO'], + ['gz', 'GZIP'], + ['zst', 'ZSTD'], + ], +}); diff --git a/www/manager6/window/DownloadUrlToStorage.js b/www/manager6/window/DownloadUrlToStorage.js index 90320da4c..36ad13fa4 100644 --- a/www/manager6/window/DownloadUrlToStorage.js +++ b/www/manager6/window/DownloadUrlToStorage.js @@ -66,6 +66,7 @@ Ext.define('PVE.window.DownloadUrlToStorage', { params: { url: queryParam.url, 'verify-certificates': queryParam['verify-certificates'], + 'detect-compression': view.content === 'iso' ? 1 : 0, }, waitMsgTarget: view, failure: res => { @@ -84,6 +85,7 @@ Ext.define('PVE.window.DownloadUrlToStorage', { filename: data.filename || "", size: (data.size && Proxmox.Utils.format_size(data.size)) || gettext("Unknown"), mimetype: data.mimetype || gettext("Unknown"), + compression: data.compression || '__default__', }); }, }); @@ -203,6 +205,17 @@ Ext.define('PVE.window.DownloadUrlToStorage', { change: 'setQueryEnabled', }, }, + { + xtype: 'pveDecompressionSelector', + name: 'compression', + fieldLabel: gettext('Decompression algorithm'), + allowBlank: true, + hasNoneOption: true, + value: '__default__', + cbind: { + hidden: get => get('content') !== 'iso', + }, + }, ], }, { @@ -223,7 +236,6 @@ Ext.define('PVE.window.DownloadUrlToStorage', { if (!me.storage) { throw "no storage ID specified"; } - me.callParent(); }, }); From 65704cc2a88729479fb15ec2a5b3df683b8f2aac Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 26 Sep 2023 17:55:34 +0200 Subject: [PATCH 230/398] ui: avoid trivial decompression widget, only used once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Currently it was only used once and it had not really any benefit, as the one thing it did is defining a list of compressors – the KVComboBox is made such, so that this can be done on definition directly, no need for inheritance. Also, if one would think about adopting this more for other similar selectors: While we have some uses of compressors all over the place, and most of them are not really coupled to each other, so having a single widget for them does not make sense. Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 2 -- www/manager6/form/DecompressionSelector.js | 13 ------------- www/manager6/window/DownloadUrlToStorage.js | 9 ++++++++- 3 files changed, 8 insertions(+), 16 deletions(-) delete mode 100644 www/manager6/form/DecompressionSelector.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 87e66ece7..5bb8fa062 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -30,11 +30,9 @@ JSSRC= \ form/CephPoolSelector.js \ form/CephFSSelector.js \ form/ComboBoxSetStoreNode.js \ - form/CompressionSelector.js \ form/ContentTypeSelector.js \ form/ControllerSelector.js \ form/DayOfWeekSelector.js \ - form/DecompressionSelector.js \ form/DiskFormatSelector.js \ form/DiskStorageSelector.js \ form/FileSelector.js \ diff --git a/www/manager6/form/DecompressionSelector.js b/www/manager6/form/DecompressionSelector.js deleted file mode 100644 index abd193165..000000000 --- a/www/manager6/form/DecompressionSelector.js +++ /dev/null @@ -1,13 +0,0 @@ -Ext.define('PVE.form.DecompressionSelector', { - extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveDecompressionSelector'], - config: { - deleteEmpty: false, - }, - comboItems: [ - ['__default__', Proxmox.Utils.NoneText], - ['lzo', 'LZO'], - ['gz', 'GZIP'], - ['zst', 'ZSTD'], - ], -}); diff --git a/www/manager6/window/DownloadUrlToStorage.js b/www/manager6/window/DownloadUrlToStorage.js index 36ad13fa4..481cb2ed4 100644 --- a/www/manager6/window/DownloadUrlToStorage.js +++ b/www/manager6/window/DownloadUrlToStorage.js @@ -206,12 +206,19 @@ Ext.define('PVE.window.DownloadUrlToStorage', { }, }, { - xtype: 'pveDecompressionSelector', + xtype: 'proxmoxKVComboBox', name: 'compression', fieldLabel: gettext('Decompression algorithm'), allowBlank: true, hasNoneOption: true, + deleteEmpty: false, value: '__default__', + comboItems: [ + ['__default__', Proxmox.Utils.NoneText], + ['lzo', 'LZO'], + ['gz', 'GZIP'], + ['zst', 'ZSTD'], + ], cbind: { hidden: get => get('content') !== 'iso', }, From 9493f69cd77251b6cca9e186d1b59b63dbe3d5e5 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 26 Sep 2023 17:58:55 +0200 Subject: [PATCH 231/398] ui: storage content: extract possible compressor extension client side Signed-off-by: Thomas Lamprecht --- www/manager6/window/DownloadUrlToStorage.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/www/manager6/window/DownloadUrlToStorage.js b/www/manager6/window/DownloadUrlToStorage.js index 481cb2ed4..335d6aa6a 100644 --- a/www/manager6/window/DownloadUrlToStorage.js +++ b/www/manager6/window/DownloadUrlToStorage.js @@ -66,7 +66,6 @@ Ext.define('PVE.window.DownloadUrlToStorage', { params: { url: queryParam.url, 'verify-certificates': queryParam['verify-certificates'], - 'detect-compression': view.content === 'iso' ? 1 : 0, }, waitMsgTarget: view, failure: res => { @@ -81,11 +80,22 @@ Ext.define('PVE.window.DownloadUrlToStorage', { urlField.validate(); let data = res.result.data; + + let filename = data.filename || ""; + let compression = '__default__'; + if (view.content === 'iso') { + const matches = filename.match(/^(.+)\.(gz|lzo|zst)$/i); + if (matches) { + filename = matches[1]; + compression = matches[2]; + } + } + view.setValues({ - filename: data.filename || "", + filename, + compression, size: (data.size && Proxmox.Utils.format_size(data.size)) || gettext("Unknown"), mimetype: data.mimetype || gettext("Unknown"), - compression: data.compression || '__default__', }); }, }); From aec571de431ee623a6f56925cb28f8a34eef15e4 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 26 Sep 2023 13:00:12 +0200 Subject: [PATCH 232/398] Revert "api: query_url_metadata: optionally detect compression" A simple string regex match on data that the API returns anyway can be the job of a frontend/client.. Safe to do as we never released this API change in a bumped manager version and switched the UI to extract this info client-side. This reverts commit d61728e289c82c7ce35a03b5b6b3da45ae177c5c. Signed-off-by: Thomas Lamprecht --- PVE/API2/Nodes.pm | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index 1e8ed09e0..0843c3a3c 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -34,7 +34,6 @@ use PVE::RRD; use PVE::Report; use PVE::SafeSyslog; use PVE::Storage; -use PVE::Storage::Plugin; use PVE::Tools; use PVE::pvecfg; @@ -1566,12 +1565,6 @@ __PACKAGE__->register_method({ optional => 1, default => 1, }, - 'detect-compression' => { - description => "If true an auto detection of used compression will be attempted", - type => 'boolean', - optional => 1, - default => 0, - }, }, }, returns => { @@ -1590,11 +1583,6 @@ __PACKAGE__->register_method({ type => 'string', optional => 1, }, - compression => { - type => 'string', - enum => $PVE::Storage::Plugin::KNOWN_COMPRESSION_FORMATS, - optional => 1, - }, }, }, code => sub { @@ -1617,7 +1605,6 @@ __PACKAGE__->register_method({ SSL_verify_mode => IO::Socket::SSL::SSL_VERIFY_NONE, ); } - my $detect_compression = $param->{'detect-compression'} // 0; my $req = HTTP::Request->new(HEAD => $url); my $res = $ua->request($req); @@ -1628,7 +1615,6 @@ __PACKAGE__->register_method({ my $disposition = $res->header("Content-Disposition"); my $type = $res->header("Content-Type"); - my $compression; my $filename; if ($disposition && ($disposition =~ m/filename="([^"]*)"/ || $disposition =~ m/filename=([^;]*)/)) { @@ -1642,16 +1628,10 @@ __PACKAGE__->register_method({ $type = $1; } - if ($detect_compression && $filename =~ m!^(.+)\.(${\PVE::Storage::Plugin::COMPRESSOR_RE})$!) { - $filename = $1; - $compression = $2; - } - my $ret = {}; $ret->{filename} = $filename if $filename; $ret->{size} = $size + 0 if $size; $ret->{mimetype} = $type if $type; - $ret->{compression} = $compression if $compression; return $ret; }}); From 68278ea36d792569f8e3a2848743cfe4d6b28568 Mon Sep 17 00:00:00 2001 From: Christian Ebner Date: Mon, 28 Aug 2023 09:54:14 +0200 Subject: [PATCH 233/398] pve7to8: Fix Fedora 38 systemd unified cgroupv2 check For Fedora 38 the systemd shared object files used to check the systemd version are located at /usr/lib64/systemd or /usr/lib/systemd. Therefore, include /usr/lib64/systemd in the list of directories to check. Further, Fedora 38 adds a fc38 postfix to the filename, so expand the regex to cover that as well. Signed-off-by: Christian Ebner --- PVE/CLI/pve7to8.pm | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index ff8e6045f..3947b260e 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -1022,12 +1022,18 @@ sub check_containers_cgroup_compat { my $get_systemd_version = sub { my ($self) = @_; + my @dirs = ( + '/lib/systemd', + '/usr/lib/systemd', + '/usr/lib/x86_64-linux-gnu/systemd', + '/usr/lib64/systemd' + ); my $libsd; - for my $dir ('/lib/systemd', '/usr/lib/systemd', '/usr/lib/x86_64-linux-gnu/systemd') { + for my $dir (@dirs) { $libsd = PVE::Tools::dir_glob_regex($dir, "libsystemd-shared-.+\.so"); last if defined($libsd); } - if (defined($libsd) && $libsd =~ /libsystemd-shared-(\d+)(\.\d-\d)?\.so/) { + if (defined($libsd) && $libsd =~ /libsystemd-shared-(\d+)(\.\d-\d)?(\.fc\d\d)?\.so/) { return $1; } From 4a862f4f02c23e0bd7072a16bf6dd0ff02edf0e6 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Mon, 25 Sep 2023 13:58:39 +0200 Subject: [PATCH 234/398] ui: vm selector: gracefully handle empty IDs in setValue function An empty string is passed by the backup job window when using selection mode 'all', would be converted to [""] and wrongly add an entry with VMID 0 because the item "" could not be found in the store. Reported in the community forum: https://forum.proxmox.com/threads/130164/ Fixes: 7a5ca76a ("fix #4239: ui: show selected but non-existing vmids in backup edit") Suggested-by: Dominik Csapak Signed-off-by: Fiona Ebner --- www/manager6/form/VMSelector.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/form/VMSelector.js b/www/manager6/form/VMSelector.js index 4c0bba137..0c884aaea 100644 --- a/www/manager6/form/VMSelector.js +++ b/www/manager6/form/VMSelector.js @@ -162,7 +162,7 @@ Ext.define('PVE.form.VMSelector', { setValue: function(value) { let me = this; if (!Ext.isArray(value)) { - value = value.split(','); + value = value.split(',').filter(v => v !== ''); } let store = me.getStore(); From 651221aec65def804df56102a7235af3949f3c9e Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Mon, 25 Sep 2023 13:58:40 +0200 Subject: [PATCH 235/398] ui: vm selector: don't add invalid not found items Doing a simple numericity check and warn in the console so developers can notice if there is something off. Signed-off-by: Fiona Ebner Reviewed-by: Dominik Csapak --- www/manager6/form/VMSelector.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/www/manager6/form/VMSelector.js b/www/manager6/form/VMSelector.js index 0c884aaea..22f7dd11d 100644 --- a/www/manager6/form/VMSelector.js +++ b/www/manager6/form/VMSelector.js @@ -136,7 +136,11 @@ Ext.define('PVE.form.VMSelector', { let selection = value.map(item => { let found = store.findRecord('vmid', item, 0, false, true, true); if (!found) { - notFound.push(item); + if (Ext.isNumeric(item)) { + notFound.push(item); + } else { + console.warn(`invalid item in vm selection: ${item}`); + } } return found; }).filter(r => r); From e11316b6f425bf7e3349c17340132a1ad2877505 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Mon, 25 Sep 2023 13:58:41 +0200 Subject: [PATCH 236/398] ui: vm selector: gracefully handle undefined/null in setValue function Suggested-by: Dominik Csapak Signed-off-by: Fiona Ebner --- www/manager6/form/VMSelector.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/form/VMSelector.js b/www/manager6/form/VMSelector.js index 22f7dd11d..d59847f2f 100644 --- a/www/manager6/form/VMSelector.js +++ b/www/manager6/form/VMSelector.js @@ -165,6 +165,7 @@ Ext.define('PVE.form.VMSelector', { setValue: function(value) { let me = this; + value ??= []; if (!Ext.isArray(value)) { value = value.split(',').filter(v => v !== ''); } From a622cbf0d998d01f13dd41649c9a4a991ed6f798 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 27 Sep 2023 16:58:58 +0200 Subject: [PATCH 237/398] ui: storage content: transform detected compression extension to lower-case otherwise the form will be invalid if a uppercase one comes in. Signed-off-by: Thomas Lamprecht --- www/manager6/window/DownloadUrlToStorage.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/window/DownloadUrlToStorage.js b/www/manager6/window/DownloadUrlToStorage.js index 335d6aa6a..5523a1523 100644 --- a/www/manager6/window/DownloadUrlToStorage.js +++ b/www/manager6/window/DownloadUrlToStorage.js @@ -87,7 +87,7 @@ Ext.define('PVE.window.DownloadUrlToStorage', { const matches = filename.match(/^(.+)\.(gz|lzo|zst)$/i); if (matches) { filename = matches[1]; - compression = matches[2]; + compression = matches[2].toLowerCase(); } } From 0329876ccf1d78b848897718bb0c2337c6a55fbb Mon Sep 17 00:00:00 2001 From: Christian Ebner Date: Tue, 1 Aug 2023 10:42:09 +0200 Subject: [PATCH 238/398] pve7to8: Add check for dkms modules ... and warn if at least one is present. Signed-off-by: Christian Ebner --- PVE/CLI/pve7to8.pm | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index 3947b260e..d1a71eff3 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -1328,6 +1328,27 @@ sub check_bootloader { } } +sub check_dkms_modules { + log_info("Check for dkms modules..."); + + my $count; + my $set_count = sub { + $count = scalar @_; + }; + + my $exit_code = eval { + run_command(['dkms', 'status', '-k', '`uname -r`'], outfunc => $set_count, noerr => 1) + }; + + if ($exit_code != 0) { + log_skip("could not get dkms status"); + } elsif (!$count) { + log_pass("no dkms modules found"); + } else { + log_warn("dkms modules found, this might cause issues during upgrade."); + } +} + sub check_misc { print_header("MISCELLANEOUS CHECKS"); my $ssh_config = eval { PVE::Tools::file_get_contents('/root/.ssh/config') }; @@ -1429,6 +1450,7 @@ sub check_misc { check_apt_repos(); check_nvidia_vgpu_service(); check_bootloader(); + check_dkms_modules(); } my sub colored_if { From 67dc81829f0e40e4bd746ed9115abeca6f8b11ab Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 5 Oct 2023 13:31:23 +0200 Subject: [PATCH 239/398] ui: subscription panel: code reduction and modernization Drop quite a few lines while actually improving readability. Signed-off-by: Thomas Lamprecht --- www/manager6/node/Subscription.js | 74 ++++++++++++------------------- 1 file changed, 28 insertions(+), 46 deletions(-) diff --git a/www/manager6/node/Subscription.js b/www/manager6/node/Subscription.js index 5ca36d428..c7123cf58 100644 --- a/www/manager6/node/Subscription.js +++ b/www/manager6/node/Subscription.js @@ -1,13 +1,16 @@ Ext.define('PVE.node.SubscriptionKeyEdit', { extend: 'Proxmox.window.Edit', + title: gettext('Upload Subscription Key'), width: 300, + items: { xtype: 'textfield', name: 'key', value: '', fieldLabel: gettext('Subscription Key'), }, + initComponent: function() { var me = this; @@ -101,21 +104,7 @@ Ext.define('PVE.node.Subscription', { throw "no node name specified"; } - var reload = function() { - me.rstore.load(); - }; - - var baseurl = '/nodes/' + me.nodename + '/subscription'; - - var render_status = function(value) { - var message = me.getObjectValue('message'); - if (message) { - return value + ": " + message; - } - return value; - }; - - var rows = { + let rows = { productname: { header: gettext('Type'), }, @@ -124,7 +113,10 @@ Ext.define('PVE.node.Subscription', { }, status: { header: gettext('Status'), - renderer: render_status, + renderer: v => { + let message = me.getObjectValue('message'); + return message ? `${v}: ${message}` : v; + }, }, message: { visible: false, @@ -144,53 +136,43 @@ Ext.define('PVE.node.Subscription', { }, signature: { header: gettext('Signed/Offline'), - renderer: (value) => { - if (value) { - return gettext('Yes'); - } else { - return gettext('No'); - } - }, + renderer: v => v ? gettext('Yes') : gettext('No'), }, }; Ext.apply(me, { - url: '/api2/json' + baseurl, + url: `/api2/json/nodes/${me.nodename}/subscription`, cwidth1: 170, tbar: [ { text: gettext('Upload Subscription Key'), - handler: function() { - var win = Ext.create('PVE.node.SubscriptionKeyEdit', { - url: '/api2/extjs/' + baseurl, - }); - win.show(); - win.on('destroy', reload); - }, + handler: () => Ext.create('PVE.node.SubscriptionKeyEdit', { + autoShow: true, + url: `/api2/extjs/nodes/${me.nodename}/subscription`, + listeners: { + destroy: () => me.rstore.load(), + }, + }), }, { text: gettext('Check'), - handler: function() { - Proxmox.Utils.API2Request({ - params: { force: 1 }, - url: baseurl, - method: 'POST', - waitMsgTarget: me, - failure: function(response, opts) { - Ext.Msg.alert(gettext('Error'), response.htmlStatus); - }, - callback: reload, - }); - }, + handler: () => Proxmox.Utils.API2Request({ + params: { force: 1 }, + url: `/nodes/${me.nodename}/subscription`, + method: 'POST', + waitMsgTarget: me, + failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + callback: () => me.rstore.load(), + }), }, { text: gettext('Remove Subscription'), xtype: 'proxmoxStdRemoveButton', confirmMsg: gettext('Are you sure you want to remove the subscription key?'), - baseurl: baseurl, + baseurl: `/nodes/${me.nodename}/subscription`, dangerous: true, selModel: false, - callback: reload, + callback: () => me.rstore.load(), }, '-', { @@ -202,7 +184,7 @@ Ext.define('PVE.node.Subscription', { ], rows: rows, listeners: { - activate: reload, + activate: () => me.rstore.load(), }, }); From a341730bd4546d2c916cadc8c009c5381a03079b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 5 Oct 2023 13:32:55 +0200 Subject: [PATCH 240/398] ui: subscription upload: increase window and label width Quite a few translations produce a longer label here, so account for that, the window is still very small. Signed-off-by: Thomas Lamprecht --- www/manager6/node/Subscription.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/www/manager6/node/Subscription.js b/www/manager6/node/Subscription.js index c7123cf58..fdcf84bc6 100644 --- a/www/manager6/node/Subscription.js +++ b/www/manager6/node/Subscription.js @@ -2,13 +2,14 @@ Ext.define('PVE.node.SubscriptionKeyEdit', { extend: 'Proxmox.window.Edit', title: gettext('Upload Subscription Key'), - width: 300, + width: 350, items: { xtype: 'textfield', name: 'key', value: '', fieldLabel: gettext('Subscription Key'), + labelWidth: 120, }, initComponent: function() { From 8e642410c749f4ad439bb0f8bdcaf493f578725e Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 5 Oct 2023 13:35:29 +0200 Subject: [PATCH 241/398] ui: subscription upload: trim key from whitespace on submission Signed-off-by: Thomas Lamprecht --- www/manager6/node/Subscription.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/www/manager6/node/Subscription.js b/www/manager6/node/Subscription.js index fdcf84bc6..6f3f7dab7 100644 --- a/www/manager6/node/Subscription.js +++ b/www/manager6/node/Subscription.js @@ -10,6 +10,9 @@ Ext.define('PVE.node.SubscriptionKeyEdit', { value: '', fieldLabel: gettext('Subscription Key'), labelWidth: 120, + getSubmitValue: function() { + return this.processRawValue(this.getRawValue())?.trim(); + }, }, initComponent: function() { From b7588bcdef79d01fefbd805439e063938d2d8456 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 5 Oct 2023 10:24:30 +0200 Subject: [PATCH 242/398] api: subscription update: actually ignore surrounding whitespace We already trim correctly in the API endpoint's code, but that happens after the parameter verification from the REST server, and as patterns are anchored between ^$pattern$ there by default, it fails if someone sends some whitespace before/after the actual key. Simply allow arbitrary whitespace, but only at the API endpoint itself, do not adapt the subscription pattern to avoid that an actual whitespace sneaks in and let some lower level code throw up on it. Signed-off-by: Thomas Lamprecht --- PVE/API2/Subscription.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/API2/Subscription.pm b/PVE/API2/Subscription.pm index 96fcd4e50..7c1e300ba 100644 --- a/PVE/API2/Subscription.pm +++ b/PVE/API2/Subscription.pm @@ -223,7 +223,7 @@ __PACKAGE__->register_method ({ key => { description => "Proxmox VE subscription key", type => 'string', - pattern => $subscription_pattern, + pattern => "\\s*${subscription_pattern}\\s*", maxLength => 32, }, }, From f6395eb69cc19bdb0ea6000f653609cf0688cc87 Mon Sep 17 00:00:00 2001 From: Maximiliano Sandoval Date: Mon, 9 Oct 2023 11:35:58 +0200 Subject: [PATCH 243/398] hw-address: check if source file changed so cache needs update We cache the hash of this file, it makes sense to first check if the file changed via `stat` and recompute the hash if needed. This mirrors similar changes done for PMG [0]. [0]: https://git.proxmox.com/?p=pmg-api.git;a=commit;h=16d2ff9f8e90db64114a66d78672f5a03f5ee990. Signed-off-by: Maximiliano Sandoval --- PVE/API2Tools.pm | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/PVE/API2Tools.pm b/PVE/API2Tools.pm index a3d7ca84c..618c13b39 100644 --- a/PVE/API2Tools.pm +++ b/PVE/API2Tools.pm @@ -17,14 +17,22 @@ use PVE::SafeSyslog; use PVE::Storage::Plugin; my $hwaddress; +my $hwaddress_st = {}; sub get_hwaddress { - - return $hwaddress if defined ($hwaddress); - my $fn = '/etc/ssh/ssh_host_rsa_key.pub'; + my $st = stat($fn); + + if (defined($hwaddress) + && $hwaddress_st->{mtime} == $st->mtime + && $hwaddress_st->{ino} == $st->ino + && $hwaddress_st->{dev} == $st->dev) { + return $hwaddress; + } + my $sshkey = PVE::Tools::file_get_contents($fn); $hwaddress = uc(md5_hex($sshkey)); + $hwaddress_st->@{'mtime', 'ino', 'dev'} = ($st->mtime, $st->ino, $st->dev); return $hwaddress; } From e02db471ab7328f84e420c66a6b5a27bc9632c32 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 10 Oct 2023 15:33:41 +0200 Subject: [PATCH 244/398] api tools: improve use-statement sorting and grouping Signed-off-by: Thomas Lamprecht --- PVE/API2Tools.pm | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/PVE/API2Tools.pm b/PVE/API2Tools.pm index 618c13b39..6bec3844b 100644 --- a/PVE/API2Tools.pm +++ b/PVE/API2Tools.pm @@ -2,19 +2,20 @@ package PVE::API2Tools; use strict; use warnings; -use Net::IP; -use PVE::Exception qw(raise_param_exc); -use PVE::Tools; -use PVE::INotify; -use PVE::Cluster; -use PVE::DataCenterConfig; -use PVE::RPCEnvironment; use Digest::MD5 qw(md5_hex); -use URI; +use Net::IP; use URI::Escape; +use URI; + +use PVE::Cluster; +use PVE::DataCenterConfig; # so we can cfs-read datacenter.cfg +use PVE::Exception qw(raise_param_exc); +use PVE::INotify; +use PVE::RPCEnvironment; use PVE::SafeSyslog; use PVE::Storage::Plugin; +use PVE::Tools; my $hwaddress; my $hwaddress_st = {}; From fddf562bf9403c2b2c4f8cb7fd4ed502149637a7 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 10 Oct 2023 15:38:07 +0200 Subject: [PATCH 245/398] api tools: fix usage of stat by-name interface Fixes: f6395eb6 ("hw-address: check if source file changed so cache needs update") Signed-off-by: Thomas Lamprecht --- PVE/API2Tools.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/PVE/API2Tools.pm b/PVE/API2Tools.pm index 6bec3844b..a56eb7325 100644 --- a/PVE/API2Tools.pm +++ b/PVE/API2Tools.pm @@ -4,6 +4,7 @@ use strict; use warnings; use Digest::MD5 qw(md5_hex); +use File::stat; use Net::IP; use URI::Escape; use URI; From 459b6c313673ba8c316d488ce242f21013ba82cc Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Mon, 2 Oct 2023 11:00:26 +0200 Subject: [PATCH 246/398] ui: ceph: improve discoverability of warning details by * replacing the info button with expandable rows that contain the details of the warning * adding two action buttons to copy the summary and details * making the text selectable The row expander works like the one in the mail gateway tracking center -> doubleclick only opens it. The height of the warning grid is limited to not grow too large. A Diffstore is used to avoid expanded rows being collapsed on an update. The rowexpander cannot hide the toggle out of the box. Therefore, if there is no detailed message for a warning, we show a placeholder text. We could consider extending it in the future to only show the toggle if a defined condition is met. Signed-off-by: Aaron Lauterer Signed-off-by: Thomas Lamprecht --- www/css/ext6-pve.css | 6 +++ www/manager6/ceph/Status.js | 89 +++++++++++++++++++++++++------------ 2 files changed, 67 insertions(+), 28 deletions(-) diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index edae462b0..837b22102 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -709,3 +709,9 @@ table.osds td:first-of-type { opacity: 0.0; cursor: default; } + +.pve-ceph-warning-detail { + overflow: auto; + margin: 0; + padding-bottom: 10px; +} diff --git a/www/manager6/ceph/Status.js b/www/manager6/ceph/Status.js index 46338b4ad..6bbe33b47 100644 --- a/www/manager6/ceph/Status.js +++ b/www/manager6/ceph/Status.js @@ -1,3 +1,10 @@ +Ext.define('pve-ceph-warnings', { + extend: 'Ext.data.Model', + fields: ['id', 'summary', 'detail', 'severity'], + idProperty: 'id', +}); + + Ext.define('PVE.node.CephStatus', { extend: 'Ext.panel.Panel', alias: 'widget.pveNodeCephStatus', @@ -70,35 +77,51 @@ Ext.define('PVE.node.CephStatus', { xtype: 'grid', itemId: 'warnings', flex: 2, + maxHeight: 430, stateful: true, stateId: 'ceph-status-warnings', + viewConfig: { + enableTextSelection: true, + }, // we load the store manually, to show an emptyText specify an empty intermediate store store: { + type: 'diff', trackRemoved: false, data: [], + rstore: { + storeid: 'pve-ceph-warnings', + type: 'update', + model: 'pve-ceph-warnings', + }, }, updateHealth: function(health) { let checks = health.checks || {}; let checkRecords = Object.keys(checks).sort().map(key => { let check = checks[key]; - return { + let data = { id: key, summary: check.summary.message, - detail: check.detail.reduce((acc, v) => `${acc}\n${v.message}`, ''), + detail: check.detail.reduce((acc, v) => `${acc}\n${v.message}`, '').trimStart(), severity: check.severity, }; + if (data.detail.length === 0) { + data.detail = "no additional data"; + } + return data; }); - this.getStore().loadRawData(checkRecords, false); + let rstore = this.getStore().rstore; + rstore.loadData(checkRecords, false); + rstore.fireEvent('load', rstore, checkRecords, true); }, emptyText: gettext('No Warnings/Errors'), columns: [ { dataIndex: 'severity', - header: gettext('Severity'), + tooltip: gettext('Severity'), align: 'center', - width: 70, + width: 38, renderer: function(value) { let health = PVE.Utils.map_ceph_health[value]; let icon = PVE.Utils.get_health_icon(health); @@ -118,38 +141,48 @@ Ext.define('PVE.node.CephStatus', { }, { xtype: 'actioncolumn', - width: 40, + width: 50, align: 'center', - tooltip: gettext('Detail'), + tooltip: gettext('Actions'), items: [ { - iconCls: 'x-fa fa-info-circle', + iconCls: 'x-fa fa-files-o', + tooltip: gettext('Copy summary'), handler: function(grid, rowindex, colindex, item, e, record) { - var win = Ext.create('Ext.window.Window', { - title: gettext('Detail'), - resizable: true, - modal: true, - width: 650, - height: 400, - layout: { - type: 'fit', - }, - items: [{ - scrollable: true, - padding: 10, - xtype: 'box', - html: [ - '' + Ext.htmlEncode(record.data.summary) + '', - '
' + Ext.htmlEncode(record.data.detail) + '
', - ], - }], - }); - win.show(); + navigator.clipboard.writeText(record.data.summary); + }, + }, + { + iconCls: 'x-fa fa-clipboard', + tooltip: gettext('Copy details'), + handler: function(grid, rowindex, colindex, item, e, record) { + navigator.clipboard.writeText(record.data.detail); }, }, ], }, ], + listeners: { + itemdblclick: function(view, record, row, rowIdx, e) { + // inspired by RowExpander.js + + let rowNode = view.getNode(rowIdx); let + normalRow = Ext.fly(rowNode); + + let collapsedCls = view.rowBodyFeature.rowCollapsedCls; + + if (normalRow.hasCls(collapsedCls)) { + view.rowBodyFeature.rowExpander.toggleRow(rowIdx, record); + } + }, + }, + plugins: [ + { + ptype: 'rowexpander', + expandOnDblClick: false, + rowBodyTpl: '
{detail}
', + }, + ], }, ], }, From feb192207aac9d65f18a7ae9b5c3b30e1ef8e74c Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 10 Oct 2023 08:24:09 +0200 Subject: [PATCH 247/398] ui: ceph warnings: code cleanups Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/Status.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/www/manager6/ceph/Status.js b/www/manager6/ceph/Status.js index 6bbe33b47..590a46b3f 100644 --- a/www/manager6/ceph/Status.js +++ b/www/manager6/ceph/Status.js @@ -149,14 +149,18 @@ Ext.define('PVE.node.CephStatus', { iconCls: 'x-fa fa-files-o', tooltip: gettext('Copy summary'), handler: function(grid, rowindex, colindex, item, e, record) { - navigator.clipboard.writeText(record.data.summary); + navigator.clipboard + .writeText(record.data.summary) + .catch(err => Ext.Msg.alert(gettext('Error'), err)); }, }, { iconCls: 'x-fa fa-clipboard', tooltip: gettext('Copy details'), handler: function(grid, rowindex, colindex, item, e, record) { - navigator.clipboard.writeText(record.data.detail); + navigator.clipboard + .writeText(record.data.detail) + .catch(err => Ext.Msg.alert(gettext('Error'), err)); }, }, ], @@ -164,10 +168,9 @@ Ext.define('PVE.node.CephStatus', { ], listeners: { itemdblclick: function(view, record, row, rowIdx, e) { - // inspired by RowExpander.js - - let rowNode = view.getNode(rowIdx); let - normalRow = Ext.fly(rowNode); + // inspired by Ext.grid.plugin.RowExpander, but for double click + let rowNode = view.getNode(rowIdx); + let normalRow = Ext.fly(rowNode); let collapsedCls = view.rowBodyFeature.rowCollapsedCls; From b2fcefa0677cfcaae15648f959dc5ac5d2d70ade Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 10 Oct 2023 08:17:29 +0200 Subject: [PATCH 248/398] ui: ceph warnings: render whitespace as pre-wrap To avoid potential horizontal scrolling on smaller screens, which can be a PITA as the scroll bar is at the bottom, so users have to scroll down to move it left and right.. Signed-off-by: Thomas Lamprecht --- www/css/ext6-pve.css | 1 + 1 file changed, 1 insertion(+) diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index 837b22102..feac11033 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -712,6 +712,7 @@ table.osds td:first-of-type { .pve-ceph-warning-detail { overflow: auto; + white-space: pre-wrap; margin: 0; padding-bottom: 10px; } From d3cbdb217c58fef23e061bcae6db7f4824d4f159 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 10 Oct 2023 08:19:33 +0200 Subject: [PATCH 249/398] ui: ceph warnings: use normal font-weight The use of the
 tag will result in font-family `monospace`, and
monospace fonts are often a bit odd w.r.t. size and weight. E.g.,
without this I get a light-font selected, which is hardly visible.

Set the weight to normal, which should not hurt those that got a
better font selection by there system/browser already.

Signed-off-by: Thomas Lamprecht 
---
 www/css/ext6-pve.css | 1 +
 1 file changed, 1 insertion(+)

diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css
index feac11033..f386fd12e 100644
--- a/www/css/ext6-pve.css
+++ b/www/css/ext6-pve.css
@@ -715,4 +715,5 @@ table.osds td:first-of-type {
     white-space: pre-wrap;
     margin: 0;
     padding-bottom: 10px;
+    font-weight: normal;
 }

From f2a0f0ec72119677a43adc0a650aed2f8a63dd06 Mon Sep 17 00:00:00 2001
From: Thomas Lamprecht 
Date: Tue, 10 Oct 2023 15:27:45 +0200
Subject: [PATCH 250/398] ui: ceph warnings: switch copy-details to copy-all

With health level, summary and full details.

Signed-off-by: Thomas Lamprecht 
---
 www/manager6/ceph/Status.js | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/www/manager6/ceph/Status.js b/www/manager6/ceph/Status.js
index 590a46b3f..bedd4e14c 100644
--- a/www/manager6/ceph/Status.js
+++ b/www/manager6/ceph/Status.js
@@ -147,7 +147,7 @@ Ext.define('PVE.node.CephStatus', {
 			    items: [
 				{
 				    iconCls: 'x-fa fa-files-o',
-				    tooltip: gettext('Copy summary'),
+				    tooltip: gettext('Copy Summary'),
 				    handler: function(grid, rowindex, colindex, item, e, record) {
 					navigator.clipboard
 					    .writeText(record.data.summary)
@@ -156,10 +156,10 @@ Ext.define('PVE.node.CephStatus', {
 				},
 				{
 				    iconCls: 'x-fa fa-clipboard',
-				    tooltip: gettext('Copy details'),
-				    handler: function(grid, rowindex, colindex, item, e, record) {
+				    tooltip: gettext('Copy All'),
+				    handler: function(grid, rowindex, colindex, item, e, { data }) {
 					navigator.clipboard
-					    .writeText(record.data.detail)
+					    .writeText(`${data.severity}: ${data.summary}\n${data.detail}`)
 					    .catch(err => Ext.Msg.alert(gettext('Error'), err));
 				    },
 				},

From 43fdec754dd5bdfadc70fa9396d4c9ed1e1d0a14 Mon Sep 17 00:00:00 2001
From: Thomas Lamprecht 
Date: Tue, 10 Oct 2023 15:28:45 +0200
Subject: [PATCH 251/398] ui: ceph warnings: disable copy-all if there are no
 additional infos

Signed-off-by: Thomas Lamprecht 
---
 www/manager6/ceph/Status.js | 2 ++
 1 file changed, 2 insertions(+)

diff --git a/www/manager6/ceph/Status.js b/www/manager6/ceph/Status.js
index bedd4e14c..a72ee761e 100644
--- a/www/manager6/ceph/Status.js
+++ b/www/manager6/ceph/Status.js
@@ -105,6 +105,7 @@ Ext.define('PVE.node.CephStatus', {
 				detail: check.detail.reduce((acc, v) => `${acc}\n${v.message}`, '').trimStart(),
 				severity: check.severity,
 			    };
+			    data.noDetails = data.detail.length === 0;
 			    if (data.detail.length === 0) {
 				data.detail = "no additional data";
 			    }
@@ -157,6 +158,7 @@ Ext.define('PVE.node.CephStatus', {
 				{
 				    iconCls: 'x-fa fa-clipboard',
 				    tooltip: gettext('Copy All'),
+				    isActionDisabled: (v, r, c, i, { data }) => !!data.noDetails,
 				    handler: function(grid, rowindex, colindex, item, e, { data }) {
 					navigator.clipboard
 					    .writeText(`${data.severity}: ${data.summary}\n${data.detail}`)

From aa730149a9aeaecdbe4ffedc61fab7140d04c1fb Mon Sep 17 00:00:00 2001
From: Thomas Lamprecht 
Date: Tue, 10 Oct 2023 15:29:21 +0200
Subject: [PATCH 252/398] ui: ceph warnings: lower opacity for no-details text

to make it more clear that this is not the details, but a UI text
placeholder.
Add a `pmx-faded` class that reduced opacity, as there where recent
discussion about adding such a utility class to widget-toolkit anyway.

Signed-off-by: Thomas Lamprecht 
---
 www/css/ext6-pve.css        | 4 ++++
 www/manager6/ceph/Status.js | 3 ++-
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css
index f386fd12e..e2d3dce97 100644
--- a/www/css/ext6-pve.css
+++ b/www/css/ext6-pve.css
@@ -717,3 +717,7 @@ table.osds td:first-of-type {
     padding-bottom: 10px;
     font-weight: normal;
 }
+
+.pmx-faded {
+    opacity: 0.75;
+}
diff --git a/www/manager6/ceph/Status.js b/www/manager6/ceph/Status.js
index a72ee761e..e985a4cc7 100644
--- a/www/manager6/ceph/Status.js
+++ b/www/manager6/ceph/Status.js
@@ -106,6 +106,7 @@ Ext.define('PVE.node.CephStatus', {
 				severity: check.severity,
 			    };
 			    data.noDetails = data.detail.length === 0;
+			    data.detailsCls = data.detail.length === 0 ? 'pmx-faded' : '';
 			    if (data.detail.length === 0) {
 				data.detail = "no additional data";
 			    }
@@ -185,7 +186,7 @@ Ext.define('PVE.node.CephStatus', {
 			{
 			    ptype: 'rowexpander',
 			    expandOnDblClick: false,
-			    rowBodyTpl: '
{detail}
', + rowBodyTpl: '
{detail}
', }, ], }, From e4a2efc6116dc2dc7d0f5f9f9d51e5714246f394 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 10 Oct 2023 15:31:14 +0200 Subject: [PATCH 253/398] ui: ceph warnings: do not scroll expanded content into view this causes jumps and is IMO rather irritating, keep hands off from scrolling, that's best done by user/browser. Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/Status.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/ceph/Status.js b/www/manager6/ceph/Status.js index e985a4cc7..386eadf4d 100644 --- a/www/manager6/ceph/Status.js +++ b/www/manager6/ceph/Status.js @@ -186,6 +186,7 @@ Ext.define('PVE.node.CephStatus', { { ptype: 'rowexpander', expandOnDblClick: false, + scrollIntoViewOnExpand: false, rowBodyTpl: '
{detail}
', }, ], From 7e7e2dc42102fcac8f5147d34af63e5dd13ab1ee Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 10 Oct 2023 18:03:16 +0200 Subject: [PATCH 254/398] ui: ceph warnings: drop summary copy button talked with Aaron off-list and he found it OK to drop this button now that "Copy Details" became a "Copy All". This reduces cognitive load on the user as there are half as many buttons. Rename "Copy All" to "Copy to Clipboard" now that there's only one and drop the disable logic. Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/Status.js | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/www/manager6/ceph/Status.js b/www/manager6/ceph/Status.js index 386eadf4d..430d8df5a 100644 --- a/www/manager6/ceph/Status.js +++ b/www/manager6/ceph/Status.js @@ -147,22 +147,13 @@ Ext.define('PVE.node.CephStatus', { align: 'center', tooltip: gettext('Actions'), items: [ - { - iconCls: 'x-fa fa-files-o', - tooltip: gettext('Copy Summary'), - handler: function(grid, rowindex, colindex, item, e, record) { - navigator.clipboard - .writeText(record.data.summary) - .catch(err => Ext.Msg.alert(gettext('Error'), err)); - }, - }, { iconCls: 'x-fa fa-clipboard', - tooltip: gettext('Copy All'), - isActionDisabled: (v, r, c, i, { data }) => !!data.noDetails, + tooltip: gettext('Copy to Clipboard'), handler: function(grid, rowindex, colindex, item, e, { data }) { + let detail = data.noDetails ? '': `\n${data.detail}`; navigator.clipboard - .writeText(`${data.severity}: ${data.summary}\n${data.detail}`) + .writeText(`${data.severity}: ${data.summary}${detail}`) .catch(err => Ext.Msg.alert(gettext('Error'), err)); }, }, From 5cd1349071b2d4fc3f45be164cc0224c593ad9ee Mon Sep 17 00:00:00 2001 From: Christian Ebner Date: Wed, 11 Oct 2023 12:29:12 +0200 Subject: [PATCH 255/398] ui: makefile: readd compression selector form Commit 65704cc2a88729479fb15ec2a5b3df683b8f2aac apparently removed by misstake the form/CompressionSelector.js from the Makefile. It is however required by the backup view to select the compression method, so readd it. Signed-off-by: Christian Ebner --- www/manager6/Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 5bb8fa062..79c079ab1 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -30,6 +30,7 @@ JSSRC= \ form/CephPoolSelector.js \ form/CephFSSelector.js \ form/ComboBoxSetStoreNode.js \ + form/CompressionSelector.js \ form/ContentTypeSelector.js \ form/ControllerSelector.js \ form/DayOfWeekSelector.js \ From 5265a2d1a9dce716c92b375f2c9035d74e906533 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 11 Oct 2023 15:59:42 +0200 Subject: [PATCH 256/398] ui: clarify that compression selector is for backup only other targets/sources might have a different list of available compressions. Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 2 +- www/manager6/dc/Backup.js | 2 +- .../{CompressionSelector.js => BackupCompressionSelector.js} | 4 ++-- www/manager6/window/Backup.js | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) rename www/manager6/form/{CompressionSelector.js => BackupCompressionSelector.js} (74%) diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 79c079ab1..17e0ad051 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -20,6 +20,7 @@ JSSRC= \ form/ACMEAccountSelector.js \ form/ACMEPluginSelector.js \ form/AgentFeatureSelector.js \ + form/BackupCompressionSelector.js \ form/BackupModeSelector.js \ form/BandwidthSelector.js \ form/BridgeSelector.js \ @@ -30,7 +31,6 @@ JSSRC= \ form/CephPoolSelector.js \ form/CephFSSelector.js \ form/ComboBoxSetStoreNode.js \ - form/CompressionSelector.js \ form/ContentTypeSelector.js \ form/ControllerSelector.js \ form/DayOfWeekSelector.js \ diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js index 990769573..0c8d2d4fe 100644 --- a/www/manager6/dc/Backup.js +++ b/www/manager6/dc/Backup.js @@ -367,7 +367,7 @@ Ext.define('PVE.dc.BackupEdit', { }, }, { - xtype: 'pveCompressionSelector', + xtype: 'pveBackupCompressionSelector', reference: 'compressionSelector', fieldLabel: gettext('Compression'), name: 'compress', diff --git a/www/manager6/form/CompressionSelector.js b/www/manager6/form/BackupCompressionSelector.js similarity index 74% rename from www/manager6/form/CompressionSelector.js rename to www/manager6/form/BackupCompressionSelector.js index 745b96d6f..014b8f7ec 100644 --- a/www/manager6/form/CompressionSelector.js +++ b/www/manager6/form/BackupCompressionSelector.js @@ -1,6 +1,6 @@ -Ext.define('PVE.form.CompressionSelector', { +Ext.define('PVE.form.BackupCompressionSelector', { extend: 'Proxmox.form.KVComboBox', - alias: ['widget.pveCompressionSelector'], + alias: ['widget.pveBackupCompressionSelector'], comboItems: [ ['0', Proxmox.Utils.noneText], ['lzo', 'LZO (' + gettext('fast') + ')'], diff --git a/www/manager6/window/Backup.js b/www/manager6/window/Backup.js index 17a37e129..8e6fa77ea 100644 --- a/www/manager6/window/Backup.js +++ b/www/manager6/window/Backup.js @@ -18,7 +18,7 @@ Ext.define('PVE.window.Backup', { throw "no VM type specified"; } - let compressionSelector = Ext.create('PVE.form.CompressionSelector', { + let compressionSelector = Ext.create('PVE.form.BackupCompressionSelector', { name: 'compress', value: 'zstd', fieldLabel: gettext('Compression'), From e828af06bf70fc765f5a5c22db7d0577b2da544e Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 13 Oct 2023 17:26:52 +0200 Subject: [PATCH 257/398] ui: css: rename pmx-faded utility class to pmx-opacity-75 Signed-off-by: Thomas Lamprecht --- www/css/ext6-pve.css | 2 +- www/manager6/ceph/Status.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index e2d3dce97..85cf4039f 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -718,6 +718,6 @@ table.osds td:first-of-type { font-weight: normal; } -.pmx-faded { +.pmx-opacity-75 { opacity: 0.75; } diff --git a/www/manager6/ceph/Status.js b/www/manager6/ceph/Status.js index 430d8df5a..ab8823276 100644 --- a/www/manager6/ceph/Status.js +++ b/www/manager6/ceph/Status.js @@ -106,7 +106,7 @@ Ext.define('PVE.node.CephStatus', { severity: check.severity, }; data.noDetails = data.detail.length === 0; - data.detailsCls = data.detail.length === 0 ? 'pmx-faded' : ''; + data.detailsCls = data.detail.length === 0 ? 'pmx-opacity-75' : ''; if (data.detail.length === 0) { data.detail = "no additional data"; } From 39d58bb5b29e26934465f3a6255e19247079706b Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 17 Oct 2023 12:54:57 +0200 Subject: [PATCH 258/398] ui: ceph wizard: switch to reef as default release for new setups Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/CephInstallWizard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/ceph/CephInstallWizard.js b/www/manager6/ceph/CephInstallWizard.js index 63dec054c..f33ae868e 100644 --- a/www/manager6/ceph/CephInstallWizard.js +++ b/www/manager6/ceph/CephInstallWizard.js @@ -147,7 +147,7 @@ Ext.define('PVE.ceph.CephInstallWizard', { viewModel: { data: { nodename: '', - cephRelease: 'quincy', + cephRelease: 'reef', cephRepo: 'enterprise', configuration: true, isInstalled: false, From 86f9a0acea8c6a34aada789fb8f9db996161b997 Mon Sep 17 00:00:00 2001 From: Christoph Heiss Date: Wed, 7 Jun 2023 16:15:11 +0200 Subject: [PATCH 259/398] pvesh: decode streamed responses This allows to use `pvesh` on endpoints like /nodes/{node}/journal, which return streamed (and possibly gzip'd) responses. Currently, e.g. `pvesh get /nodes/localhost/journal --lastentries 10` fails with: gzip: stdout: Broken pipe got hash object, but result schema specified array! Using e.g. `--output-format yaml` resulted in: --- download: content-encoding: gzip content-type: application/json fh: &1 !!perl/ref =: *1 stream: 1 gzip: stdout: Broken pipe Failed to write This is due the API call returning a "download" object (as seen above), which contains (among some other things) a file handle to read the response from. With this patch, the response from such endpoints is now correctly read and displayed. Only handles combinations of `Content-Encoding` == 'gzip' and either 'text/plain' or 'application/json' for `Content-Type`. This tries to mimic the behavior of the API server implementation when encountering `download` objects. Tested this with all four output formats 'text', 'json', 'json-pretty' and 'yaml', as well as "cross-node" in a local test cluster. Signed-off-by: Christoph Heiss --- PVE/CLI/pvesh.pm | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/PVE/CLI/pvesh.pm b/PVE/CLI/pvesh.pm index 28e2518d5..61b4464d6 100755 --- a/PVE/CLI/pvesh.pm +++ b/PVE/CLI/pvesh.pm @@ -15,6 +15,7 @@ use PVE::CLIHandler; use PVE::API2Tools; use PVE::API2; use JSON; +use IO::Uncompress::Gunzip qw(gunzip); use base qw(PVE::CLIHandler); @@ -283,6 +284,41 @@ my $cond_add_standard_output_properties = sub { return PVE::RESTHandler::add_standard_output_properties($props, $keys); }; +my $handle_streamed_response = sub { + my ($download) = @_; + my ($fh, $path, $encoding, $type) = + $download->@{'fh', 'path', 'content-encoding', 'content-type'}; + + die "{download} returned but neither fh nor path given\n" + if !defined($fh) && !defined($path); + + die "unknown 'content-encoding' $encoding\n" + if defined($encoding) && $encoding ne 'gzip'; + + die "unknown 'content-type' $type\n" + if defined($type) && $type !~ qw!^(text/plain)|(application/json)$!; + + if (defined($path)) { + open($fh, '<', $path) + or die "open stream path '$path' for reading failed: $!\n"; + } + + local $/; + my $data = <$fh>; + + if (defined($encoding)) { + my $out; + gunzip(\$data => \$out); + $data = $out; + } + + if (defined($type) && $type eq 'application/json') { + $data = decode_json($data)->{data}; + } + + return $data; +}; + sub call_api_method { my ($cmd, $param) = @_; @@ -312,6 +348,9 @@ sub call_api_method { } $data = $handler->handle($info, $param); + + $data = &$handle_streamed_response($data->{download}) + if ref($data) eq 'HASH' && ref($data->{download}) eq 'HASH'; } return if $opt_nooutput || $stdopts->{quiet}; From 6d7deac70d4110db765ef58b8f11a1d75f320a11 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 19 Oct 2023 15:26:08 +0200 Subject: [PATCH 260/398] pvesh: code cleanups for streamed response handling Signed-off-by: Thomas Lamprecht --- PVE/CLI/pvesh.pm | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/PVE/CLI/pvesh.pm b/PVE/CLI/pvesh.pm index 61b4464d6..803553d9f 100755 --- a/PVE/CLI/pvesh.pm +++ b/PVE/CLI/pvesh.pm @@ -289,18 +289,13 @@ my $handle_streamed_response = sub { my ($fh, $path, $encoding, $type) = $download->@{'fh', 'path', 'content-encoding', 'content-type'}; - die "{download} returned but neither fh nor path given\n" - if !defined($fh) && !defined($path); + die "{download} returned but neither fh nor path given\n" if !defined($fh) && !defined($path); - die "unknown 'content-encoding' $encoding\n" - if defined($encoding) && $encoding ne 'gzip'; - - die "unknown 'content-type' $type\n" - if defined($type) && $type !~ qw!^(text/plain)|(application/json)$!; + die "unknown 'content-encoding' $encoding\n" if defined($encoding) && $encoding ne 'gzip'; + die "unknown 'content-type' $type\n" if defined($type) && $type !~ qw!^(?:text/plain|application/json)$!; if (defined($path)) { - open($fh, '<', $path) - or die "open stream path '$path' for reading failed: $!\n"; + open($fh, '<', $path) or die "open stream path '$path' for reading failed - $!\n"; } local $/; From 2944acf97e86eb2eba2e0c731fa50b81e7d412fe Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Sat, 21 Oct 2023 16:32:17 +0200 Subject: [PATCH 261/398] pvesh: code clean-ups Signed-off-by: Thomas Lamprecht --- PVE/CLI/pvesh.pm | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/PVE/CLI/pvesh.pm b/PVE/CLI/pvesh.pm index 803553d9f..730e09af1 100755 --- a/PVE/CLI/pvesh.pm +++ b/PVE/CLI/pvesh.pm @@ -112,18 +112,20 @@ sub proxy_handler { push @$args, "--$key", $_ for split(/\0/, $param->{$key}); } - my $remcmd = ['ssh', '-o', 'BatchMode=yes', "root\@$remip", - 'pvesh', '--noproxy', $cmd, $path, - '--output-format', 'json']; + my @ssh_tunnel_cmd = ('ssh', '-o', 'BatchMode=yes', "root\@$remip"); + my @pvesh_cmd = ('pvesh', '--noproxy', $cmd, $path, '--output-format', 'json'); if (scalar(@$args)) { - my $cmdargs = [String::ShellQuote::shell_quote(@$args)]; - push @$remcmd, @$cmdargs; + my $cmdargs = [ String::ShellQuote::shell_quote(@$args) ]; + push @pvesh_cmd, @$cmdargs; } my $res = ''; - PVE::Tools::run_command($remcmd, errmsg => "proxy handler failed", - outfunc => sub { $res .= shift }); + PVE::Tools::run_command( + [ @ssh_tunnel_cmd, '--', @pvesh_cmd ], + errmsg => "proxy handler failed", + outfunc => sub { $res .= shift }, + ); my $decoded_json = eval { decode_json($res) }; if ($@) { @@ -322,8 +324,9 @@ sub call_api_method { my $path = PVE::Tools::extract_param($param, 'api_path'); die "missing API path\n" if !defined($path); - my $stdopts = $extract_std_options ? - PVE::RESTHandler::extract_standard_output_properties($param) : {}; + my $stdopts = $extract_std_options + ? PVE::RESTHandler::extract_standard_output_properties($param) + : {}; $opt_nooutput = 1 if $stdopts->{quiet}; @@ -344,8 +347,9 @@ sub call_api_method { $data = $handler->handle($info, $param); - $data = &$handle_streamed_response($data->{download}) - if ref($data) eq 'HASH' && ref($data->{download}) eq 'HASH'; + if (ref($data) eq 'HASH' && ref($data->{download}) eq 'HASH') { + $data = $handle_streamed_response->($data->{download}) + } } return if $opt_nooutput || $stdopts->{quiet}; From 4845cca7e2f1b40178f534560ca2897cab15dbac Mon Sep 17 00:00:00 2001 From: Christoph Heiss Date: Wed, 5 Jul 2023 13:12:47 +0200 Subject: [PATCH 262/398] expose font-logos via API server and load in UI Signed-off-by: Christoph Heiss Reviewed-by: Dominik Csapak Tested-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- PVE/Service/pveproxy.pm | 2 ++ debian/control | 1 + www/index.html.tpl | 1 + 3 files changed, 4 insertions(+) diff --git a/PVE/Service/pveproxy.pm b/PVE/Service/pveproxy.pm index 9e8a2fecd..ac1085457 100755 --- a/PVE/Service/pveproxy.pm +++ b/PVE/Service/pveproxy.pm @@ -50,6 +50,7 @@ my $basedirs = { docs => '/usr/share/pve-docs', extjs => '/usr/share/javascript/extjs', fontawesome => '/usr/share/fonts-font-awesome', + fontlogos => '/usr/share/fonts-font-logos', i18n => '/usr/share/pve-i18n', manager => '/usr/share/pve-manager', novnc => '/usr/share/novnc-pve', @@ -81,6 +82,7 @@ sub init { add_dirs($dirs, '/pve2/ext6/', "$basedirs->{extjs}/"); add_dirs($dirs, '/pve2/fa/css/' => "$basedirs->{fontawesome}/css/"); add_dirs($dirs, '/pve2/fa/fonts/' => "$basedirs->{fontawesome}/fonts/"); + add_dirs($dirs, '/pve2/font-logos/' => "$basedirs->{fontlogos}/"); add_dirs($dirs, '/pve2/images/' => "$basedirs->{manager}/images/"); add_dirs($dirs, '/pve2/js/' => "$basedirs->{manager}/js/"); add_dirs($dirs, '/pve2/locale/', "$basedirs->{i18n}/"); diff --git a/debian/control b/debian/control index 259376d36..3666e337e 100644 --- a/debian/control +++ b/debian/control @@ -40,6 +40,7 @@ Depends: apt (>= 1.5~), cstream, dtach, fonts-font-awesome, + fonts-font-logos, gdisk, hdparm, ifupdown2 (>= 3.0) | ifenslave (>= 2.6), diff --git a/www/index.html.tpl b/www/index.html.tpl index b07ce5f17..46dc877bc 100644 --- a/www/index.html.tpl +++ b/www/index.html.tpl @@ -10,6 +10,7 @@ + [%- IF theme != 'crisp' %] From b1785dbc652b53c104f2e813fd1eb5c36dda9296 Mon Sep 17 00:00:00 2001 From: Christoph Heiss Date: Wed, 5 Jul 2023 13:12:48 +0200 Subject: [PATCH 263/398] ui: container guest status: show privileged status as new row As that info is not available through the store (which stores the status), it must be fetched separately. Signed-off-by: Christoph Heiss Reviewed-by: Dominik Csapak Tested-by: Dominik Csapak [ TL: rework subject and avoid arror-fn for controller to keep `this` working, as reviewed by Dominik ] Signed-off-by: Thomas Lamprecht --- www/manager6/panel/GuestStatusView.js | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/www/manager6/panel/GuestStatusView.js b/www/manager6/panel/GuestStatusView.js index 8db1f492c..9ecff7bec 100644 --- a/www/manager6/panel/GuestStatusView.js +++ b/www/manager6/panel/GuestStatusView.js @@ -11,6 +11,29 @@ Ext.define('PVE.panel.GuestStatusView', { }; }, + controller: { + xclass: 'Ext.app.ViewController', + + init: function(view) { + if (view.pveSelNode.data.type !== 'lxc') { + return; + } + + const nodename = view.pveSelNode.data.node; + const vmid = view.pveSelNode.data.vmid; + + Proxmox.Utils.API2Request({ + url: `/api2/extjs/nodes/${nodename}/lxc/${vmid}/config`, + waitMsgTargetView: view, + method: 'GET', + success: ({ result }) => { + view.down('#unprivileged').updateValue( + Proxmox.Utils.format_boolean(result.data.unprivileged)); + }, + }); + }, + }, + layout: { type: 'vbox', align: 'stretch', @@ -58,6 +81,15 @@ Ext.define('PVE.panel.GuestStatusView', { }, printBar: false, }, + { + itemId: 'unprivileged', + iconCls: 'fa fa-lock fa-fw', + title: gettext('Unprivileged'), + printBar: false, + cbind: { + hidden: '{isQemu}', + }, + }, { xtype: 'box', height: 15, From 8dfdcff8f053808b50eb335520e1a3f1d093c8ee Mon Sep 17 00:00:00 2001 From: Christoph Heiss Date: Wed, 5 Jul 2023 13:12:49 +0200 Subject: [PATCH 264/398] ui: container guest status: show distro logo and name in summary header It fits neatly there, is rather intrusive and yet still visible at first sight. It also solves the problem of having to create a bigger row, so that the icon is still easily recognisable. At the default font-size of 13pt, this really wasn't the case. Verified that each supported distro is present in the font and the name matches up and tested through all supported distros (including 'unmanaged'). Signed-off-by: Christoph Heiss Reviewed-by: Dominik Csapak Tested-by: Dominik Csapak [ TL: html-encode, just to be sure, as reviewed by Dominik ] Signed-off-by: Thomas Lamprecht --- www/manager6/panel/GuestStatusView.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/www/manager6/panel/GuestStatusView.js b/www/manager6/panel/GuestStatusView.js index 9ecff7bec..6401811c7 100644 --- a/www/manager6/panel/GuestStatusView.js +++ b/www/manager6/panel/GuestStatusView.js @@ -29,6 +29,7 @@ Ext.define('PVE.panel.GuestStatusView', { success: ({ result }) => { view.down('#unprivileged').updateValue( Proxmox.Utils.format_boolean(result.data.unprivileged)); + view.ostype = Ext.htmlEncode(result.data.ostype); }, }); }, @@ -166,6 +167,22 @@ Ext.define('PVE.panel.GuestStatusView', { + ')'; } - me.setTitle(me.getRecordValue('name') + text); + let title = `
${me.getRecordValue('name') + text}
`; + + if (me.pveSelNode.data.type === 'lxc' && me.ostype && me.ostype !== 'unmanaged') { + // Manual mappings for distros with special casing + const namemap = { + 'archlinux': 'Arch Linux', + 'nixos': 'NixOS', + 'opensuse': 'openSUSE', + 'centos': 'CentOS', + }; + + const distro = namemap[me.ostype] ?? Ext.String.capitalize(me.ostype); + title += `
+  ${distro}
`; + } + + me.setTitle(title); }, }); From 33506fa03d361a5dcb1408bb647f53ba073dc1a9 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 19 Oct 2023 15:36:05 +0200 Subject: [PATCH 265/398] ui: tags: fix focus for edit mode such that one can tab through the editable tag fields. We have to handle that manually, since ExtJs does not expect contenteditable html tags for focus handling. Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/form/Tag.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/www/manager6/form/Tag.js b/www/manager6/form/Tag.js index be72d7ba9..6b1d6aa55 100644 --- a/www/manager6/form/Tag.js +++ b/www/manager6/form/Tag.js @@ -13,6 +13,15 @@ Ext.define('Proxmox.form.Tag', { '', ], + focusable: true, + getFocusEl: function() { + return Ext.get(this.tagEl()); + }, + + onFocus: function() { + this.selectText(); + }, + // contains tags not to show in the picker and not allowing to set filter: [], From 202e26d425d61e5592fbd1a879ad33b3c314f5df Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Wed, 13 Sep 2023 13:38:42 +0200 Subject: [PATCH 266/398] sdn: controllers: add isis controller --- www/manager6/Makefile | 1 + www/manager6/Utils.js | 5 ++ www/manager6/sdn/controllers/IsisEdit.js | 61 ++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 www/manager6/sdn/controllers/IsisEdit.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 17e0ad051..57e1b48fa 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -277,6 +277,7 @@ JSSRC= \ sdn/controllers/Base.js \ sdn/controllers/EvpnEdit.js \ sdn/controllers/BgpEdit.js \ + sdn/controllers/IsisEdit.js \ sdn/IpamView.js \ sdn/ipams/Base.js \ sdn/ipams/NetboxEdit.js \ diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 06b63315f..8f46c07e2 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -895,6 +895,11 @@ Ext.define('PVE.Utils', { ipanel: 'BgpInputPanel', faIcon: 'crosshairs', }, + isis: { + name: 'isis', + ipanel: 'IsisInputPanel', + faIcon: 'crosshairs', + }, }, sdnipamSchema: { diff --git a/www/manager6/sdn/controllers/IsisEdit.js b/www/manager6/sdn/controllers/IsisEdit.js new file mode 100644 index 000000000..2e333fa5e --- /dev/null +++ b/www/manager6/sdn/controllers/IsisEdit.js @@ -0,0 +1,61 @@ +Ext.define('PVE.sdn.controllers.IsisInputPanel', { + extend: 'PVE.panel.SDNControllerBase', + + onlineHelp: 'pvesdn_controller_plugin_evpn', + + onGetValues: function(values) { + var me = this; + + if (me.isCreate) { + values.type = me.type; + values.controller = 'isis' + values.node; + } else { + delete values.controller; + } + + return values; + }, + + initComponent: function() { + var me = this; + + me.items = [ + { + xtype: 'pveNodeSelector', + name: 'node', + fieldLabel: gettext('Node'), + multiSelect: false, + autoSelect: false, + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'isis-domain', + fieldLabel: 'Domain', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'isis-net', + fieldLabel: 'Network entity title', + allowBlank: false, + }, + { + xtype: 'textfield', + name: 'isis-ifaces', + fieldLabel: gettext('Interfaces'), + allowBlank: false, + }, + ]; + + me.advancedItems = [ + { + xtype: 'textfield', + name: 'loopback', + fieldLabel: gettext('Loopback Interface'), + }, + ]; + + me.callParent(); + }, +}); From 406d820f86b0806eab774123e8ebb7ecedd29b42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Wed, 25 Oct 2023 13:14:26 +0200 Subject: [PATCH 267/398] d/control: bump libpve-network-perl suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fabian Grünbichler --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 3666e337e..d70bb0d5c 100644 --- a/debian/control +++ b/debian/control @@ -99,7 +99,7 @@ Depends: apt (>= 1.5~), ${perl:Depends}, ${shlibs:Depends} Recommends: proxmox-offline-mirror-helper -Suggests: libpve-network-perl (>= 0.5-1) +Suggests: libpve-network-perl (>= 0.8.2) Conflicts: vlan, vzdump, Replaces: vlan, From 506134df52d28304b51915f6bef9c6f4cb07cd77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Wed, 25 Oct 2023 15:34:35 +0200 Subject: [PATCH 268/398] subscription: remove ceph APT auth if invalid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit like we do for the main APT auth file(s) in proxmox-subscription. Signed-off-by: Fabian Grünbichler --- PVE/API2/Subscription.pm | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/PVE/API2/Subscription.pm b/PVE/API2/Subscription.pm index 7c1e300ba..836e7a86f 100644 --- a/PVE/API2/Subscription.pm +++ b/PVE/API2/Subscription.pm @@ -94,14 +94,18 @@ sub write_etc_subscription { Proxmox::RS::Subscription::write_subscription( $filename, "/etc/apt/auth.conf.d/pve.conf", "enterprise.proxmox.com/debian/pve", $info); - # FIXME: improve this, especially the selection of valid ceph-releases - # NOTE: currently we should add future ceph releases as early as possible, to ensure that - my $ceph_auth = ''; - for my $ceph_release ('quincy', 'reef') { - $ceph_auth .= "machine enterprise.proxmox.com/debian/ceph-${ceph_release}" + if (!(defined($info->{key}) && defined($info->{serverid}))) { + unlink "/etc/apt/auth.conf.d/ceph.conf"; + } else { + # FIXME: improve this, especially the selection of valid ceph-releases + # NOTE: currently we should add future ceph releases as early as possible, to ensure that + my $ceph_auth = ''; + for my $ceph_release ('quincy', 'reef') { + $ceph_auth .= "machine enterprise.proxmox.com/debian/ceph-${ceph_release}" ." login $info->{key} password $info->{serverid}\n" + } + PVE::Tools::file_set_contents("/etc/apt/auth.conf.d/ceph.conf", $ceph_auth); } - PVE::Tools::file_set_contents("/etc/apt/auth.conf.d/ceph.conf", $ceph_auth); } __PACKAGE__->register_method ({ From fea56c69dde04fce4b5605f07cc76b131004adad Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Sun, 29 Oct 2023 18:02:20 +0100 Subject: [PATCH 269/398] ui: datacenter config: adapt to new default MAC prefix The official Proxmox OUI, assigned by the IEEE, is now used as default. Signed-off-by: Thomas Lamprecht --- www/manager6/dc/OptionView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/dc/OptionView.js b/www/manager6/dc/OptionView.js index 4717277fc..b200fd12d 100644 --- a/www/manager6/dc/OptionView.js +++ b/www/manager6/dc/OptionView.js @@ -94,7 +94,7 @@ Ext.define('PVE.dc.OptionView', { me.add_text_row('mac_prefix', gettext('MAC address prefix'), { deleteEmpty: true, vtype: 'MacPrefix', - defaultValue: Proxmox.Utils.noneText, + defaultValue: 'BC:24:11', }); me.add_inputpanel_row('migration', gettext('Migration Settings'), { renderer: PVE.Utils.render_as_property_string, From 81e102ebc2b63bc461508f2f7268864e804a5344 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Sun, 29 Oct 2023 19:47:40 +0100 Subject: [PATCH 270/398] ui: disable new notification UI for now, will be reworked Lukas is currently reworking this so that we have a single panel, where the filters are match-entries that can also provide the functionality of the hard-coded filters in the other panel, reducing complexity and adding flexibility. Signed-off-by: Thomas Lamprecht --- www/manager6/dc/Config.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 9ba7b301f..7d01da5fb 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -317,7 +317,9 @@ Ext.define('PVE.dc.Config', { ); } - if (caps.dc['Sys.Audit']) { + // this is being reworked, but we need to release newer manager versions already.. + let notification_enabled = false; + if (notification_enabled && caps.dc['Sys.Audit']) { me.items.push( { xtype: 'pveNotificationEvents', @@ -329,9 +331,12 @@ Ext.define('PVE.dc.Config', { ); } - if (caps.mapping['Mapping.Audit'] || - caps.mapping['Mapping.Use'] || - caps.mapping['Mapping.Modify']) { + if (notification_enabled && ( + caps.mapping['Mapping.Audit'] || + caps.mapping['Mapping.Use'] || + caps.mapping['Mapping.Modify'] + ) + ) { me.items.push( { xtype: 'pmxNotificationConfigView', From 2018dc0774619ba1f8f13f4cdc43c0ea163e3d73 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Sun, 29 Oct 2023 19:52:45 +0100 Subject: [PATCH 271/398] bump version to 8.0.7 Signed-off-by: Thomas Lamprecht --- debian/changelog | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/debian/changelog b/debian/changelog index c5022e251..62842695f 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,41 @@ +pve-manager (8.0.7) bookworm; urgency=medium + + * fix #3069: vzdump: add property 'performance: pbs-entries-max=N' + + * fix #4849: ui: allow decompressing ISO files when downloading + + * pve7to8: update checks from stable-7 branch + + * ui: vm selector: gracefully handle empty IDs in setValue function + + * ui: subscription upload: trim key from whitespace on submission + + * api: subscription update: actually ignore surrounding whitespace + + * hw-address: check if source file changed so cache needs update + + * ui: ceph: improve discoverability of warning details + + * ui: ceph wizard: switch to reef as default release for new setups + + * pvesh: decode streamed responses + + * ui: container guest status: show privileged status as new row + + * ui: container guest status: show distro logo and name in summary header + + * ui: tags: fix focus for edit mode + + * sdn: controllers: add isis controller + + * subscription: remove ceph APT auth if invalid + + * ui: datacenter config: adapt to new default MAC prefix + + * ui: disable new notification UI for now, will be reworked + + -- Proxmox Support Team Sun, 29 Oct 2023 19:52:42 +0100 + pve-manager (8.0.6) bookworm; urgency=medium * fix #4663: Prevent Web UI reload on cert order for other node From bb4c00c0738ddeade76f5a4a24b194d4e777e12a Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 30 Oct 2023 10:31:02 +0100 Subject: [PATCH 272/398] update shipped aplliance info index Signed-off-by: Thomas Lamprecht --- aplinfo/aplinfo.dat | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/aplinfo/aplinfo.dat b/aplinfo/aplinfo.dat index 8382bd7d8..3c8d13fe1 100644 --- a/aplinfo/aplinfo.dat +++ b/aplinfo/aplinfo.dat @@ -72,15 +72,15 @@ Description: Debian 11 Bullseye (standard) A small Debian Bullseye system including all standard packages. Package: debian-12-standard -Version: 12.0-1 +Version: 12.2-1 Type: lxc OS: debian-12 Section: system Maintainer: Proxmox Support Team Architecture: amd64 -Location: system/debian-12-standard_12.0-1_amd64.tar.zst -md5sum: 8afe6876381729eef9ce56fd5d2ac5d8 -sha512sum: 0cfbb0cd61fa9e5a11f83e2469e9175be79cab1f24385835715a9c29ea8e517d0526943cd6091b038e1f9d4bf57bfb30ddbc2802d9ada366f0b34019f940a3de +Location: system/debian-12-standard_12.2-1_amd64.tar.zst +md5sum: 0c40b2b49499c827bbf7db2d7a3efadc +sha512sum: 1846c5e64253256832c6f7b8780c5cb241abada3ab0913940b831bf8f7f869220277f5551f0abeb796852e448c178be22bd44eb1af8c0be3d5a13decf943398a Infopage: https://pve.proxmox.com/wiki/Linux_Container#pct_supported_distributions Description: Debian 12 Bookworm (standard) A small Debian Bullseye system including all standard packages. @@ -112,6 +112,19 @@ sha512sum: 54328a3338ca9657d298a8a5d2ca15fe76f66fd407296d9e3e1c236ee60ea075d3406 Infopage: https://linuxcontainers.org Description: LXC default image for fedora 38 (20230607) +Package: gentoo-current-openrc +Version: 20231009 +Type: lxc +OS: gentoo +Section: system +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: system/gentoo-current-openrc_20231009_amd64.tar.xz +md5sum: ecf4836300bb0973618b7ae30077f2c1 +sha512sum: 40926859f0a777e8dfa7de10c76eb78ee92aa554e0598a09beec7f260ec7a02f19dfc2f620beaa1ea42feb02c682111d70e81e67e7641da3e0643f2692e75430 +Infopage: https://linuxcontainers.org +Description: LXC openrc image for gentoo current (20231009) + Package: opensuse-15.4-default Version: 20221109 Type: lxc @@ -135,7 +148,7 @@ Architecture: amd64 Location: mail/proxmox-mailgateway-7.3-standard_7.3-1_amd64.tar.zst md5sum: 6c130003f9880ae66dca0603d7b7ca87 sha512sum: 2fdf1dc24306bbaa2ef9a0f322416ca15b97b7d19f84b83743c7afc896095c398241fbc2eb41a33a69f3f275ce4c4cb6425edc5538831b4650d39a5e44fdbc25 -Infopage: https://www.proxmox.com/en/proxmox-mail-gateway/overview +Infopage: https://www.proxmox.com/de/proxmox-mail-gateway Description: Proxmox Mailgateway 7.3 A full featured mail proxy for spam and virus filtering, optimized for container environment. @@ -149,7 +162,7 @@ Architecture: amd64 Location: mail/proxmox-mailgateway-8.0-standard_8.0-1_amd64.tar.zst md5sum: 7d321e5dfc6e1005231586d1871e3625 sha512sum: be5efcb8ee97f2bb1c638360191eda19f49e2063acb88da55c948c90c091063972cc9ea29e6aeaa4a85733e0fb2c99ea905d665ac693cb2bf06b091c4baf781f -Infopage: https://www.proxmox.com/en/proxmox-mail-gateway/overview +Infopage: https://www.proxmox.com/de/proxmox-mail-gateway Description: Proxmox Mailgateway 8.0 A full featured mail proxy for spam and virus filtering, optimized for container environment. @@ -207,3 +220,17 @@ sha512sum: 6c7d916cc76865d5984b6b41e3d45426071967059ecc0a5d10029d2706cca0ea96a3c Infopage: https://pve.proxmox.com/wiki/Linux_Container#pct_supported_distributions Description: Ubuntu 23.04 Lunar (standard) A small Ubuntu 23.04 Lunar Lobster system including all standard packages. + +Package: ubuntu-23.10-standard +Version: 23.10-1 +Type: lxc +OS: ubuntu-23.10 +Section: system +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: system/ubuntu-23.10-standard_23.10-1_amd64.tar.zst +md5sum: 91b92c717f09d2172471b4c85a00aea3 +sha512sum: 84bcb7348ba86026176ed35e5b798f89cb64b0bcbe27081e3f97e3002a6ec34d836bbc1e1b1ce5f327151d1bc79cf1d08edf5a9d2b21a60ffa91e96284e5a67f +Infopage: https://pve.proxmox.com/wiki/Linux_Container#pct_supported_distributions +Description: Ubuntu 23.10 Mantic (standard) + A small Ubuntu 23.10 Mantic Minotaur system including all standard packages. From 798bd39a37d1a49ae18f48ffc519b2ef1e1dfcbe Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Tue, 3 Oct 2023 13:36:37 +0200 Subject: [PATCH 273/398] report: dir2text: ignore special . and .. files So far this hasn't been an issue as each user of dir2text wanted files with a specific pattern. But if we want every file in the directory, we need to skip the special files '.' and '..'. Signed-off-by: Aaron Lauterer --- PVE/Report.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/PVE/Report.pm b/PVE/Report.pm index c9109dcac..435458b99 100644 --- a/PVE/Report.pm +++ b/PVE/Report.pm @@ -13,6 +13,7 @@ my sub dir2text { my $text = ''; PVE::Tools::dir_glob_foreach($target_dir, $regexp, sub { my ($file) = @_; + return if $file eq '.' || $file eq '..'; $text .= "\n# cat $target_dir$file\n"; $text .= PVE::Tools::file_get_contents($target_dir.$file)."\n"; }); From b2b516f3fc7cd91601e2b2344a0af2fb0def6238 Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Tue, 3 Oct 2023 13:36:38 +0200 Subject: [PATCH 274/398] report: add interfaces.d directory With the SDN becoming more prevalent, it is a good idea to include any additional config files in '/etc/network/interfaces.d'. Since no special suffix is enforced, we need to match against any file. Signed-off-by: Aaron Lauterer --- PVE/Report.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/PVE/Report.pm b/PVE/Report.pm index 435458b99..34ddd2042 100644 --- a/PVE/Report.pm +++ b/PVE/Report.pm @@ -76,6 +76,7 @@ my $init_report_cmds = sub { 'ip -details -4 route show', 'ip -details -6 route show', 'cat /etc/network/interfaces', + sub { dir2text('/etc/network/interfaces.d/', '.*') }, ], }, firewall => { From e5ecadec9eec99c52e0283a4821c8621e3bc779d Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Tue, 31 Oct 2023 12:27:41 +0100 Subject: [PATCH 275/398] report: add sdn config directory the /etc/pve/sdn directory contains the config files, not just what they translate to in interface configs (/etc/network/interfaces.d/snd). The current way will also include dotifiles that may contain the current/running state. Which can be useful to troubleshoot. Signed-off-by: Aaron Lauterer --- PVE/Report.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/PVE/Report.pm b/PVE/Report.pm index 34ddd2042..12d7d9a57 100644 --- a/PVE/Report.pm +++ b/PVE/Report.pm @@ -77,6 +77,7 @@ my $init_report_cmds = sub { 'ip -details -6 route show', 'cat /etc/network/interfaces', sub { dir2text('/etc/network/interfaces.d/', '.*') }, + sub { dir2text('/etc/pve/sdn/', '.*') }, ], }, firewall => { From 6e167f9a9a46e52dfe83aa009729d59bbc7a65ea Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 2 Nov 2023 20:59:49 +0100 Subject: [PATCH 276/398] report: add hint for dir2text Signed-off-by: Thomas Lamprecht --- PVE/Report.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/Report.pm b/PVE/Report.pm index 12d7d9a57..2024285e6 100644 --- a/PVE/Report.pm +++ b/PVE/Report.pm @@ -10,7 +10,7 @@ my sub dir2text { my ($target_dir, $regexp) = @_; print STDERR "dir2text '${target_dir}${regexp}'..."; - my $text = ''; + my $text = "# output '${target_dir}${regexp}' file(s)\n"; PVE::Tools::dir_glob_foreach($target_dir, $regexp, sub { my ($file) = @_; return if $file eq '.' || $file eq '..'; From 4fb92ae88ae937f3b43b7839b4356b4a6e30a50a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Wed, 14 Jun 2023 12:42:13 +0200 Subject: [PATCH 277/398] node console: restrict all non-login commands to root@pam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit and not just upgrade. note that the only other non-login command (ceph_install) is restricted to root@pam in the web UI anyway, and that the termproxy endpoint is lacking this check and thus always falls back to a login prompt for non-login commands requested by non-root users. Signed-off-by: Fabian Grünbichler --- PVE/API2/Nodes.pm | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index 0843c3a3c..58fa3f4c1 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -949,7 +949,7 @@ __PACKAGE__->register_method ({ node => get_standard_option('pve-node'), cmd => { type => 'string', - description => "Run specific command or default to login.", + description => "Run specific command or default to login (requires 'root\@pam')", enum => [keys %$shell_cmd_map], optional => 1, default => 'login', @@ -1000,7 +1000,7 @@ __PACKAGE__->register_method ({ raise_perm_exc("realm != pam") if $realm ne 'pam'; - if (defined($param->{cmd}) && $param->{cmd} eq 'upgrade' && $user ne 'root@pam') { + if (defined($param->{cmd}) && $param->{cmd} ne 'login' && $user ne 'root@pam') { raise_perm_exc('user != root@pam'); } @@ -1089,7 +1089,7 @@ __PACKAGE__->register_method ({ node => get_standard_option('pve-node'), cmd => { type => 'string', - description => "Run specific command or default to login.", + description => "Run specific command or default to login (requires 'root\@pam')", enum => [keys %$shell_cmd_map], optional => 1, default => 'login', @@ -1223,7 +1223,7 @@ __PACKAGE__->register_method ({ proxy => get_standard_option('spice-proxy', { optional => 1 }), cmd => { type => 'string', - description => "Run specific command or default to login.", + description => "Run specific command or default to login (requires 'root\@pam')", enum => [keys %$shell_cmd_map], optional => 1, default => 'login', @@ -1248,7 +1248,7 @@ __PACKAGE__->register_method ({ raise_perm_exc("realm != pam") if $realm ne 'pam'; - if (defined($param->{cmd}) && $param->{cmd} eq 'upgrade' && $user ne 'root@pam') { + if (defined($param->{cmd}) && $param->{cmd} ne 'login' && $user ne 'root@pam') { raise_perm_exc('user != root@pam'); } From 7914f5e7b2d6c21cfe0268d144666378b74c2d2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Wed, 14 Jun 2023 12:42:14 +0200 Subject: [PATCH 278/398] node console: allow usage for non-pam realms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit non-login commands are still restricted to root@pam if they where before. Signed-off-by: Fabian Grünbichler --- PVE/API2/Nodes.pm | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index 58fa3f4c1..a73fca3f3 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -939,7 +939,6 @@ __PACKAGE__->register_method ({ method => 'POST', protected => 1, permissions => { - description => "Restricted to users on realm 'pam'", check => ['perm', '/nodes/{node}', [ 'Sys.Console' ]], }, description => "Creates a VNC Shell proxy.", @@ -998,7 +997,6 @@ __PACKAGE__->register_method ({ my $rpcenv = PVE::RPCEnvironment::get(); my ($user, undef, $realm) = PVE::AccessControl::verify_username($rpcenv->get_user()); - raise_perm_exc("realm != pam") if $realm ne 'pam'; if (defined($param->{cmd}) && $param->{cmd} ne 'login' && $user ne 'root@pam') { raise_perm_exc('user != root@pam'); @@ -1079,7 +1077,6 @@ __PACKAGE__->register_method ({ method => 'POST', protected => 1, permissions => { - description => "Restricted to users on realm 'pam'", check => ['perm', '/nodes/{node}', [ 'Sys.Console' ]], }, description => "Creates a VNC Shell proxy.", @@ -1117,7 +1114,6 @@ __PACKAGE__->register_method ({ my $rpcenv = PVE::RPCEnvironment::get(); my ($user, undef, $realm) = PVE::AccessControl::verify_username($rpcenv->get_user()); - raise_perm_exc("realm $realm != pam") if $realm ne 'pam'; my $node = $param->{node}; my $authpath = "/nodes/$node"; @@ -1160,7 +1156,7 @@ __PACKAGE__->register_method({ path => 'vncwebsocket', method => 'GET', permissions => { - description => "Restricted to users on realm 'pam'. You also need to pass a valid ticket (vncticket).", + description => "You also need to pass a valid ticket (vncticket).", check => ['perm', '/nodes/{node}', [ 'Sys.Console' ]], }, description => "Opens a websocket for VNC traffic.", @@ -1194,8 +1190,6 @@ __PACKAGE__->register_method({ my ($user, undef, $realm) = PVE::AccessControl::verify_username($rpcenv->get_user()); - raise_perm_exc("realm != pam") if $realm ne 'pam'; - my $authpath = "/nodes/$param->{node}"; PVE::AccessControl::verify_vnc_ticket($param->{vncticket}, $user, $authpath); @@ -1212,7 +1206,6 @@ __PACKAGE__->register_method ({ protected => 1, proxyto => 'node', permissions => { - description => "Restricted to users on realm 'pam'", check => ['perm', '/nodes/{node}', [ 'Sys.Console' ]], }, description => "Creates a SPICE shell.", @@ -1246,7 +1239,6 @@ __PACKAGE__->register_method ({ my ($user, undef, $realm) = PVE::AccessControl::verify_username($authuser); - raise_perm_exc("realm != pam") if $realm ne 'pam'; if (defined($param->{cmd}) && $param->{cmd} ne 'login' && $user ne 'root@pam') { raise_perm_exc('user != root@pam'); From ca97f6301ae00eaf70186ffafc27d5da64e8102f Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 25 Oct 2023 13:09:38 +0200 Subject: [PATCH 279/398] ui: wizards: allow adding tags in the qemu/lxc create wizard in the general tab in the advanced section. For that to work, we introduce a new option for the TagEditContainer named 'editOnly', which controls now the cancel/finish buttons, automatically enter edit mode and disable enter/escape keypresses. We also prevent now the loading of tags while in edit mode, so the tags don't change while editing (this can be jarring and unexpected). Then we wrap that all in a FieldSet that implements the Field mixin, so we can easily use that in the wizard. There we set a maxHeight so that the field can grow so that it still fits in the wizard. To properly align the input with the '+' button, we have to add a custom css class there. (In the hbox we could set the alignment, but this is not possible in the 'column' layout) Signed-off-by: Dominik Csapak --- www/css/ext6-pve.css | 5 +++ www/manager6/Makefile | 1 + www/manager6/form/TagEdit.js | 35 +++++++++++++++-- www/manager6/form/TagFieldSet.js | 64 +++++++++++++++++++++++++++++++ www/manager6/lxc/CreateWizard.js | 7 ++++ www/manager6/qemu/CreateWizard.js | 9 +++++ 6 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 www/manager6/form/TagFieldSet.js diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index 85cf4039f..105adc45d 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -721,3 +721,8 @@ table.osds td:first-of-type { .pmx-opacity-75 { opacity: 0.75; } + +/* tag edit fields must be aligned manually in the fieldset */ +.proxmox-tag-fieldset.proxmox-tags-full .x-component.x-column { + margin: 2px; +} diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 57e1b48fa..dccd2ba1c 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -87,6 +87,7 @@ JSSRC= \ form/Tag.js \ form/TagEdit.js \ form/MultiFileButton.js \ + form/TagFieldSet.js \ grid/BackupView.js \ grid/FirewallAliases.js \ grid/FirewallOptions.js \ diff --git a/www/manager6/form/TagEdit.js b/www/manager6/form/TagEdit.js index 094f44626..7d5b19ec3 100644 --- a/www/manager6/form/TagEdit.js +++ b/www/manager6/form/TagEdit.js @@ -9,6 +9,7 @@ Ext.define('PVE.panel.TagEditContainer', { // set to false to hide the 'no tags' field and the edit button canEdit: true, + editOnly: false, controller: { xclass: 'Ext.app.ViewController', @@ -216,6 +217,9 @@ Ext.define('PVE.panel.TagEditContainer', { me.tagsChanged(); }, keypress: function(key) { + if (vm.get('hideFinishButtons')) { + return; + } if (key === 'Enter') { me.editClick(); } else if (key === 'Escape') { @@ -253,20 +257,40 @@ Ext.define('PVE.panel.TagEditContainer', { me.loadTags(view.tags); } me.getViewModel().set('canEdit', view.canEdit); + me.getViewModel().set('editOnly', view.editOnly); me.mon(Ext.GlobalEvents, 'loadedUiOptions', () => { + let vm = me.getViewModel(); view.toggleCls('hide-handles', PVE.UIOptions.shouldSortTags()); - me.loadTags(me.oldTags, true); // refresh tag colors and order + me.loadTags(me.oldTags, !vm.get('editMode')); // refresh tag colors and order }); + + if (view.editOnly) { + me.toggleEdit(); + } }, }, + getTags: function() { + let me =this; + let controller = me.getController(); + let tags = []; + controller.forEachTag((cmp) => { + if (cmp.tag.length) { + tags.push(cmp.tag); + } + }); + + return tags; + }, + viewModel: { data: { tagCount: 0, editMode: false, canEdit: true, isDirty: false, + editOnly: true, }, formulas: { @@ -276,6 +300,9 @@ Ext.define('PVE.panel.TagEditContainer', { hideEditBtn: function(get) { return get('editMode') || !get('canEdit'); }, + hideFinishButtons: function(get) { + return !get('editMode') || get('editOnly'); + }, }, }, @@ -311,7 +338,7 @@ Ext.define('PVE.panel.TagEditContainer', { xtype: 'tbseparator', ui: 'horizontal', bind: { - hidden: '{!editMode}', + hidden: '{hideFinishButtons}', }, hidden: true, }, @@ -320,7 +347,7 @@ Ext.define('PVE.panel.TagEditContainer', { iconCls: 'fa fa-times', tooltip: gettext('Cancel Edit'), bind: { - hidden: '{!editMode}', + hidden: '{hideFinishButtons}', }, hidden: true, margin: '0 5 0 0', @@ -332,7 +359,7 @@ Ext.define('PVE.panel.TagEditContainer', { iconCls: 'fa fa-check', tooltip: gettext('Finish Edit'), bind: { - hidden: '{!editMode}', + hidden: '{hideFinishButtons}', disabled: '{!isDirty}', }, hidden: true, diff --git a/www/manager6/form/TagFieldSet.js b/www/manager6/form/TagFieldSet.js new file mode 100644 index 000000000..132b215c9 --- /dev/null +++ b/www/manager6/form/TagFieldSet.js @@ -0,0 +1,64 @@ +Ext.define('PVE.form.TagFieldSet', { + extend: 'Ext.form.FieldSet', + alias: 'widget.pveTagFieldSet', + mixins: ['Ext.form.field.Field'], + + title: gettext('Tags'), + padding: '0 5 5 5', + + getValue: function() { + let me = this; + let tags = me.down('pveTagEditContainer').getTags().filter(t => t !== ''); + return tags.join(';'); + }, + + setValue: function(value) { + let me = this; + value ??= []; + if (!Ext.isArray(value)) { + value = value.split(/[;, ]/).filter(t => t !== ''); + } + me.down('pveTagEditContainer').loadTags(value.join(';')); + }, + + getErrors: function(value) { + value ??= []; + if (!Ext.isArray(value)) { + value = value.split(/[;, ]/).filter(t => t !== ''); + } + if (value.some(t => !t.match(PVE.Utils.tagCharRegex))) { + return [gettext("Tags contain invalid characters.")]; + } + return []; + }, + + getSubmitData: function() { + let me = this; + let value = me.getValue(); + if (me.disabled || !me.submitValue || value === '') { + return null; + } + let data = {}; + data[me.getName()] = value; + return data; + }, + + layout: 'fit', + + items: [ + { + xtype: 'pveTagEditContainer', + userCls: 'proxmox-tags-full proxmox-tag-fieldset', + editOnly: true, + allowBlank: true, + layout: 'column', + scrollable: true, + }, + ], + + initComponent: function() { + let me = this; + me.callParent(); + me.initField(); + }, +}); diff --git a/www/manager6/lxc/CreateWizard.js b/www/manager6/lxc/CreateWizard.js index e36352974..b57b30500 100644 --- a/www/manager6/lxc/CreateWizard.js +++ b/www/manager6/lxc/CreateWizard.js @@ -178,6 +178,13 @@ Ext.define('PVE.lxc.CreateWizard', { }, }, ], + advancedColumnB: [ + { + xtype: 'pveTagFieldSet', + name: 'tags', + maxHeight: 150, + }, + ], }, { xtype: 'inputpanel', diff --git a/www/manager6/qemu/CreateWizard.js b/www/manager6/qemu/CreateWizard.js index a65067eae..74b1feb61 100644 --- a/www/manager6/qemu/CreateWizard.js +++ b/www/manager6/qemu/CreateWizard.js @@ -108,6 +108,15 @@ Ext.define('PVE.qemu.CreateWizard', { fieldLabel: gettext('Shutdown timeout'), }, ], + + advancedColumnB: [ + { + xtype: 'pveTagFieldSet', + name: 'tags', + maxHeight: 150, + }, + ], + onGetValues: function(values) { ['name', 'pool', 'onboot', 'agent'].forEach(function(field) { if (!values[field]) { From 3632a02974173c162cfa9ff51770249ba981873e Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 6 Nov 2023 17:26:59 +0100 Subject: [PATCH 280/398] ui: guest wizard: increase height to match 4:3 ratio solving an issue where the CPU extra-flags grid had less space than it's fixed height allowed. While we also could have reduced that height, having a nicer ratio and a bit more vertical "breathing room" seem slightly nicer to me. Signed-off-by: Thomas Lamprecht --- www/manager6/window/Wizard.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js index 4aacb8a33..6afadc79a 100644 --- a/www/manager6/window/Wizard.js +++ b/www/manager6/window/Wizard.js @@ -4,7 +4,7 @@ Ext.define('PVE.window.Wizard', { activeTitle: '', // used for automated testing width: 720, - height: 510, + height: 540, modal: true, border: false, From 67c655b9333714f31d5115de80961a2abc4b6506 Mon Sep 17 00:00:00 2001 From: Stoiko Ivanov Date: Wed, 11 Oct 2023 15:23:42 +0200 Subject: [PATCH 281/398] pve7to8: check for proper grub meta-package for bootmode This should catch installations from our ISO on non-ZFS in uefi mode, which won't get the updated grub efi binary installed upon upgrade, because grub-pc is installed instead of grub-efi-amd64. Adding this to pve7to8 should make this even more visible, than the corresponding patch for promxox-kernel-helper (warnings printed during regular package upgrades might be overlooked more easily than a yellow line in the major upgrade checkscript) The if/else order was chosen to limit the nesting level of the long messages. Signed-off-by: Stoiko Ivanov --- PVE/CLI/pve7to8.pm | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/PVE/CLI/pve7to8.pm b/PVE/CLI/pve7to8.pm index d1a71eff3..b34c83620 100644 --- a/PVE/CLI/pve7to8.pm +++ b/PVE/CLI/pve7to8.pm @@ -1302,29 +1302,36 @@ sub check_time_sync { sub check_bootloader { log_info("Checking bootloader configuration..."); - if (!$upgraded) { - log_skip("not yet upgraded, no need to check the presence of systemd-boot"); + + if (! -d '/sys/firmware/efi') { + log_skip("System booted in legacy-mode - no need for additional packages"); return; } - if (! -f "/etc/kernel/proxmox-boot-uuids") { - log_skip("proxmox-boot-tool not used for bootloader configuration"); - return; - } - - if (! -d "/sys/firmware/efi") { - log_skip("System booted in legacy-mode - no need for systemd-boot"); - return; - } - - if ( -f "/usr/share/doc/systemd-boot/changelog.Debian.gz") { - log_pass("systemd-boot is installed"); - } else { + if ( -f "/etc/kernel/proxmox-boot-uuids") { + if (!$upgraded) { + log_skip("not yet upgraded, no need to check the presence of systemd-boot"); + return; + } + if ( -f "/usr/share/doc/systemd-boot/changelog.Debian.gz") { + log_pass("bootloader packages installed correctly"); + return; + } log_warn( "proxmox-boot-tool is used for bootloader configuration in uefi mode" - . "but the separate systemd-boot package, existing in Debian Bookworm is not installed" - . "initializing new ESPs will not work until the package is installed" + . " but the separate systemd-boot package is not installed," + . " initializing new ESPs will not work until the package is installed" ); + return; + } elsif ( ! -f "/usr/share/doc/grub-efi-amd64/changelog.Debian.gz" ) { + log_warn( + "System booted in uefi mode but grub-efi-amd64 meta-package not installed," + . " new grub versions will not be installed to /boot/efi!" + . " Install grub-efi-amd64." + ); + return; + } else { + log_pass("bootloader packages installed correctly"); } } From 9f17d274684a92cbd0225f016a3cb2ec1b3b5446 Mon Sep 17 00:00:00 2001 From: Stefan Lendl Date: Tue, 10 Oct 2023 15:30:52 +0200 Subject: [PATCH 282/398] ui: refer to SDN subnets as 'Subnet' not as ID The Subnet's CIDR in the Edit view is called 'Subnet'. Also refer to it as Subnet in the list view. Signed-off-by: Stefan Lendl [ TL: prefix commit subject with sub-system ] Signed-off-by: Thomas Lamprecht --- www/manager6/sdn/SubnetView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/sdn/SubnetView.js b/www/manager6/sdn/SubnetView.js index 4a8b0754b..851583d19 100644 --- a/www/manager6/sdn/SubnetView.js +++ b/www/manager6/sdn/SubnetView.js @@ -101,7 +101,7 @@ Ext.define('PVE.sdn.SubnetView', { ], columns: [ { - header: 'ID', + header: gettext('Subnet'), flex: 2, dataIndex: 'cidr', renderer: function(value, metaData, rec) { From e07191a0819339982a790bc7faf29d177b5beb02 Mon Sep 17 00:00:00 2001 From: Stefan Lendl Date: Tue, 10 Oct 2023 15:30:53 +0200 Subject: [PATCH 283/398] ui: sdn: homogenize the casing of labels use title case, or upper case for abbreviations, everywhere. Signed-off-by: Stefan Lendl [ TL: adapt commit subject to our style guides ] Signed-off-by: Thomas Lamprecht --- www/manager6/sdn/OptionsPanel.js | 2 +- www/manager6/sdn/SubnetEdit.js | 2 +- www/manager6/sdn/SubnetView.js | 2 +- www/manager6/sdn/dns/PowerdnsEdit.js | 6 +++--- www/manager6/sdn/ipams/NetboxEdit.js | 2 +- www/manager6/sdn/ipams/PhpIpamEdit.js | 2 +- www/manager6/sdn/zones/Base.js | 8 ++++---- www/manager6/sdn/zones/EvpnEdit.js | 10 +++++----- www/manager6/sdn/zones/QinQEdit.js | 2 +- 9 files changed, 18 insertions(+), 18 deletions(-) diff --git a/www/manager6/sdn/OptionsPanel.js b/www/manager6/sdn/OptionsPanel.js index 2cc2cff6c..58cb0772a 100644 --- a/www/manager6/sdn/OptionsPanel.js +++ b/www/manager6/sdn/OptionsPanel.js @@ -21,7 +21,7 @@ Ext.define('PVE.sdn.Options', { }, { xtype: 'pveSDNIpamView', - title: 'IPAMs', + title: 'IPAM', flex: 1, padding: '0 0 20 0', border: 0, diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js index 154de8ef2..b9825d2a3 100644 --- a/www/manager6/sdn/SubnetEdit.js +++ b/www/manager6/sdn/SubnetEdit.js @@ -50,7 +50,7 @@ Ext.define('PVE.sdn.SubnetInputPanel', { xtype: 'proxmoxtextfield', name: 'dnszoneprefix', skipEmptyText: true, - fieldLabel: gettext('DNS zone prefix'), + fieldLabel: gettext('DNS Zone Prefix'), allowBlank: true, }, ], diff --git a/www/manager6/sdn/SubnetView.js b/www/manager6/sdn/SubnetView.js index 851583d19..d342f0ba6 100644 --- a/www/manager6/sdn/SubnetView.js +++ b/www/manager6/sdn/SubnetView.js @@ -125,7 +125,7 @@ Ext.define('PVE.sdn.SubnetView', { }, }, { - header: gettext('Dns prefix'), + header: gettext('DNS Prefix'), flex: 1, dataIndex: 'dnszoneprefix', renderer: function(value, metaData, rec) { diff --git a/www/manager6/sdn/dns/PowerdnsEdit.js b/www/manager6/sdn/dns/PowerdnsEdit.js index f35b89af4..8d5d88726 100644 --- a/www/manager6/sdn/dns/PowerdnsEdit.js +++ b/www/manager6/sdn/dns/PowerdnsEdit.js @@ -30,19 +30,19 @@ Ext.define('PVE.sdn.dns.PowerdnsInputPanel', { { xtype: 'textfield', name: 'url', - fieldLabel: 'url', + fieldLabel: 'URL', allowBlank: false, }, { xtype: 'textfield', name: 'key', - fieldLabel: gettext('api key'), + fieldLabel: gettext('API Key'), allowBlank: false, }, { xtype: 'proxmoxintegerfield', name: 'ttl', - fieldLabel: 'ttl', + fieldLabel: 'TTL', allowBlank: true, }, ]; diff --git a/www/manager6/sdn/ipams/NetboxEdit.js b/www/manager6/sdn/ipams/NetboxEdit.js index 2133b48c6..b85043fe3 100644 --- a/www/manager6/sdn/ipams/NetboxEdit.js +++ b/www/manager6/sdn/ipams/NetboxEdit.js @@ -30,7 +30,7 @@ Ext.define('PVE.sdn.ipams.NetboxInputPanel', { { xtype: 'textfield', name: 'url', - fieldLabel: gettext('Url'), + fieldLabel: gettext('URL'), allowBlank: false, }, { diff --git a/www/manager6/sdn/ipams/PhpIpamEdit.js b/www/manager6/sdn/ipams/PhpIpamEdit.js index 8726e0c89..a4848578b 100644 --- a/www/manager6/sdn/ipams/PhpIpamEdit.js +++ b/www/manager6/sdn/ipams/PhpIpamEdit.js @@ -30,7 +30,7 @@ Ext.define('PVE.sdn.ipams.PhpIpamInputPanel', { { xtype: 'textfield', name: 'url', - fieldLabel: gettext('Url'), + fieldLabel: gettext('URL'), allowBlank: false, }, { diff --git a/www/manager6/sdn/zones/Base.js b/www/manager6/sdn/zones/Base.js index 655352a86..602e4c16b 100644 --- a/www/manager6/sdn/zones/Base.js +++ b/www/manager6/sdn/zones/Base.js @@ -48,7 +48,7 @@ Ext.define('PVE.panel.SDNZoneBase', { }, { xtype: 'pveSDNIpamSelector', - fieldLabel: gettext('Ipam'), + fieldLabel: gettext('IPAM'), name: 'ipam', value: me.ipam || 'pve', allowBlank: false, @@ -58,14 +58,14 @@ Ext.define('PVE.panel.SDNZoneBase', { me.advancedItems = [ { xtype: 'pveSDNDnsSelector', - fieldLabel: gettext('Dns server'), + fieldLabel: gettext('DNS Server'), name: 'dns', value: '', allowBlank: true, }, { xtype: 'pveSDNDnsSelector', - fieldLabel: gettext('Reverse Dns server'), + fieldLabel: gettext('Reverse DNS Server'), name: 'reversedns', value: '', allowBlank: true, @@ -74,7 +74,7 @@ Ext.define('PVE.panel.SDNZoneBase', { xtype: 'proxmoxtextfield', name: 'dnszone', skipEmptyText: true, - fieldLabel: gettext('DNS zone'), + fieldLabel: gettext('DNS Zone'), allowBlank: true, }, ]; diff --git a/www/manager6/sdn/zones/EvpnEdit.js b/www/manager6/sdn/zones/EvpnEdit.js index 1d13976c9..cac1ef4d5 100644 --- a/www/manager6/sdn/zones/EvpnEdit.js +++ b/www/manager6/sdn/zones/EvpnEdit.js @@ -57,7 +57,7 @@ Ext.define('PVE.sdn.zones.EvpnInputPanel', { { xtype: 'textfield', name: 'mac', - fieldLabel: gettext('Vnet MAC address'), + fieldLabel: gettext('VNet MAC Address'), vtype: 'MacAddress', allowBlank: true, emptyText: 'auto', @@ -81,26 +81,26 @@ Ext.define('PVE.sdn.zones.EvpnInputPanel', { name: 'exitnodes-local-routing', uncheckedValue: 0, checked: false, - fieldLabel: gettext('Exit Nodes local routing'), + fieldLabel: gettext('Exit Nodes Local Routing'), }, { xtype: 'proxmoxcheckbox', name: 'advertise-subnets', uncheckedValue: 0, checked: false, - fieldLabel: gettext('Advertise subnets'), + fieldLabel: gettext('Advertise Subnets'), }, { xtype: 'proxmoxcheckbox', name: 'disable-arp-nd-suppression', uncheckedValue: 0, checked: false, - fieldLabel: gettext('Disable arp-nd suppression'), + fieldLabel: gettext('Disable ARP-nd Suppression'), }, { xtype: 'textfield', name: 'rt-import', - fieldLabel: gettext('Route-target import'), + fieldLabel: gettext('Route Target Import'), allowBlank: true, }, ]; diff --git a/www/manager6/sdn/zones/QinQEdit.js b/www/manager6/sdn/zones/QinQEdit.js index c059a7a23..795ff9dfd 100644 --- a/www/manager6/sdn/zones/QinQEdit.js +++ b/www/manager6/sdn/zones/QinQEdit.js @@ -36,7 +36,7 @@ Ext.define('PVE.sdn.zones.QinQInputPanel', { { xtype: 'proxmoxKVComboBox', name: 'vlan-protocol', - fieldLabel: gettext('Service-VLAN Protocol'), + fieldLabel: gettext('Service VLAN Protocol'), allowBlank: true, value: '802.1q', comboItems: [ From ad1677d221b87b68d01c1504efd81e4fde4b5459 Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Wed, 23 Aug 2023 11:44:27 +0200 Subject: [PATCH 284/398] fix #4631: ceph: osd: create: add osds-per-device Allows to automatically create multiple OSDs per physical device. The main use case are fast NVME drives that would be bottlenecked by a single OSD service. By using the 'ceph-volume lvm batch' command instead of the 'ceph-volume lvm create' for multiple OSDs / device, we don't have to deal with the split of the drive ourselves. But this means that the parameters to specify a DB or WAL device won't work as the 'batch' command doesn't use them. Dedicated DB and WAL devices don't make much sense anyway if we place the OSDs on fast NVME drives. Some other changes to how the command is built were needed as well, as the 'batch' command needs the path to the disk as a positional argument, not as '--data /dev/sdX'. We drop the '--cluster-fsid' parameter because the 'batch' command doesn't accept it. The 'create' will fall back to reading it from the ceph.conf file. Removal of OSDs works as expected without any code changes. As long as there are other OSDs on a disk, the VG & PV won't be removed, even if 'cleanup' is enabled. The '--no-auto' parameter is used to avoid the following deprecation warning: ``` --> DEPRECATION NOTICE --> You are using the legacy automatic disk sorting behavior --> The Pacific release will change the default to --no-auto --> passed data devices: 1 physical, 0 LVM --> relative data size: 0.3333333333333333 ``` Signed-off-by: Aaron Lauterer --- PVE/API2/Ceph/OSD.pm | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/PVE/API2/Ceph/OSD.pm b/PVE/API2/Ceph/OSD.pm index 0f501c016..e6b9f8842 100644 --- a/PVE/API2/Ceph/OSD.pm +++ b/PVE/API2/Ceph/OSD.pm @@ -293,6 +293,13 @@ __PACKAGE__->register_method ({ type => 'string', description => "Set the device class of the OSD in crush." }, + 'osds-per-device' => { + optional => 1, + type => 'integer', + minimum => '1', + description => 'OSD services per physical device. Only useful for fast ". + "NVME devices to utilize their performance better.', + }, }, }, returns => { type => 'string' }, @@ -312,6 +319,15 @@ __PACKAGE__->register_method ({ # extract parameter info and fail if a device is set more than once my $devs = {}; + # allow 'osds-per-device' only without dedicated db and/or wal devs. We cannot specify them with + # 'ceph-volume lvm batch' and they don't make a lot of sense on fast NVMEs anyway. + if ($param->{'osds-per-device'}) { + for my $type ( qw(db_dev wal_dev) ) { + raise_param_exc({ $type => "canot use 'osds-per-device' parameter with '${type}'" }) + if $param->{$type}; + } + } + my $ceph_conf = cfs_read_file('ceph.conf'); my $osd_network = $ceph_conf->{global}->{cluster_network}; @@ -381,10 +397,6 @@ __PACKAGE__->register_method ({ my $rados = PVE::RADOS->new(); my $monstat = $rados->mon_command({ prefix => 'quorum_status' }); - die "unable to get fsid\n" if !$monstat->{monmap} || !$monstat->{monmap}->{fsid}; - my $fsid = $monstat->{monmap}->{fsid}; - $fsid = $1 if $fsid =~ m/^([0-9a-f\-]+)$/; - my $ceph_bootstrap_osd_keyring = PVE::Ceph::Tools::get_config('ceph_bootstrap_osd_keyring'); if (! -f $ceph_bootstrap_osd_keyring && $ceph_conf->{global}->{auth_client_required} eq 'cephx') { @@ -488,7 +500,10 @@ __PACKAGE__->register_method ({ $test_disk_requirements->($disklist); my $dev_class = $param->{'crush-device-class'}; - my $cmd = ['ceph-volume', 'lvm', 'create', '--cluster-fsid', $fsid ]; + # create allows for detailed configuration of DB and WAL devices + # batch for easy creation of multiple OSDs (per device) + my $create_mode = $param->{'osds-per-device'} ? 'batch' : 'create'; + my $cmd = ['ceph-volume', 'lvm', $create_mode ]; push @$cmd, '--crush-device-class', $dev_class if $dev_class; my $devname = $devs->{dev}->{name}; @@ -522,9 +537,17 @@ __PACKAGE__->register_method ({ push @$cmd, "--block.$type", $part_or_lv; } - push @$cmd, '--data', $devpath; + push @$cmd, '--data', $devpath if $create_mode eq 'create'; push @$cmd, '--dmcrypt' if $param->{encrypted}; + if ($create_mode eq 'batch') { + push @$cmd, + '--osds-per-device', $param->{'osds-per-device'}, + '--yes', + '--no-auto', + '--', + $devpath; + } PVE::Diskmanage::wipe_blockdev($devpath); if (PVE::Diskmanage::is_partition($devpath)) { From 2f6467d8eb1f420edf6c88086b1c962d16499f17 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 6 Nov 2023 18:26:53 +0100 Subject: [PATCH 285/398] api: ceph osd: fix description line-wrapping style Signed-off-by: Thomas Lamprecht --- PVE/API2/Ceph/OSD.pm | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/PVE/API2/Ceph/OSD.pm b/PVE/API2/Ceph/OSD.pm index e6b9f8842..0c07e7cee 100644 --- a/PVE/API2/Ceph/OSD.pm +++ b/PVE/API2/Ceph/OSD.pm @@ -253,11 +253,11 @@ __PACKAGE__->register_method ({ }, db_dev_size => { description => "Size in GiB for block.db.", - verbose_description => "If a block.db is requested but the size is not given, ". - "will be automatically selected by: bluestore_block_db_size from the ". - "ceph database (osd or global section) or config (osd or global section)". - "in that order. If this is not available, it will be sized 10% of the size ". - "of the OSD device. Fails if the available size is not enough.", + verbose_description => "If a block.db is requested but the size is not given, will" + ." be automatically selected by: bluestore_block_db_size from the ceph database" + ." (osd or global section) or config (osd or global section) in that order." + ." If this is not available, it will be sized 10% of the size of the OSD device." + ." Fails if the available size is not enough.", optional => 1, type => 'number', default => 'bluestore_block_db_size or 10% of OSD size', @@ -271,11 +271,11 @@ __PACKAGE__->register_method ({ }, wal_dev_size => { description => "Size in GiB for block.wal.", - verbose_description => "If a block.wal is requested but the size is not given, ". - "will be automatically selected by: bluestore_block_wal_size from the ". - "ceph database (osd or global section) or config (osd or global section)". - "in that order. If this is not available, it will be sized 1% of the size ". - "of the OSD device. Fails if the available size is not enough.", + verbose_description => "If a block.wal is requested but the size is not given, will" + ." be automatically selected by: bluestore_block_wal_size from the ceph database" + ." (osd or global section) or config (osd or global section) in that order." + ." If this is not available, it will be sized 1% of the size of the OSD device." + ." Fails if the available size is not enough.", optional => 1, minimum => 0.5, default => 'bluestore_block_wal_size or 1% of OSD size', @@ -297,8 +297,8 @@ __PACKAGE__->register_method ({ optional => 1, type => 'integer', minimum => '1', - description => 'OSD services per physical device. Only useful for fast ". - "NVME devices to utilize their performance better.', + description => 'OSD services per physical device. Only useful for fast NVMe devices" + ." to utilize their performance better.', }, }, }, From 2dac21405a0dc75e6f5a6a97e99deeb0ef2e8d2e Mon Sep 17 00:00:00 2001 From: Folke Gleumes Date: Tue, 7 Nov 2023 12:38:52 +0100 Subject: [PATCH 286/398] fix #2336: ui: adjust message for bulk start/stop/migrate The message in the Task Log has been 'Start/Stop/Migrate all...', which is misleading since not everything might be affected by bulk actions. This also affects the messages send at a nodes startup and shutdown, but since this just affects a subgroup of VMs/Containers (those who are onboot=1) the new wording still applies better than the previous. Signed-off-by: Folke Gleumes --- www/manager6/Utils.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 8f46c07e2..be30393eb 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1967,7 +1967,7 @@ Ext.define('PVE.Utils', { lvmremove: ['Volume Group', gettext('Remove')], lvmthincreate: [gettext('LVM-Thin Storage'), gettext('Create')], lvmthinremove: ['Thinpool', gettext('Remove')], - migrateall: ['', gettext('Migrate all VMs and Containers')], + migrateall: ['', gettext('Bulk migrate VMs and Containers')], 'move_volume': ['CT', gettext('Move Volume')], 'pbs-download': ['VM/CT', gettext('File Restore Download')], pull_file: ['CT', gettext('Pull file')], @@ -1994,8 +1994,8 @@ Ext.define('PVE.Utils', { resize: ['VM/CT', gettext('Resize')], spiceproxy: ['VM/CT', gettext('Console') + ' (Spice)'], spiceshell: ['', gettext('Shell') + ' (Spice)'], - startall: ['', gettext('Start all VMs and Containers')], - stopall: ['', gettext('Stop all VMs and Containers')], + startall: ['', gettext('Bulk start VMs and Containers')], + stopall: ['', gettext('Bulk shutdown VMs and Containers')], unknownimgdel: ['', gettext('Destroy image from unknown guest')], wipedisk: ['Device', gettext('Wipe Disk')], vncproxy: ['VM/CT', gettext('Console')], From 1aa92bac271da19945146961b6b7eaafdca426de Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 9 Nov 2023 11:47:58 +0100 Subject: [PATCH 287/398] ui: bulk actions: reorder fields and drop local-storage warning For a better screen-space utilization use two columns and remove the local-storage warning, since this is rather obvious anyway. Signed-off-by: Dominik Csapak [ TL: slight commit message rewording ] Signed-off-by: Thomas Lamprecht --- www/manager6/window/BulkAction.js | 85 +++++++++++++++++-------------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/www/manager6/window/BulkAction.js b/www/manager6/window/BulkAction.js index 949e167e2..950b454da 100644 --- a/www/manager6/window/BulkAction.js +++ b/www/manager6/window/BulkAction.js @@ -54,51 +54,52 @@ Ext.define('PVE.window.BulkAction', { let items = []; if (me.action === 'migrateall') { items.push( - { - xtype: 'pveNodeSelector', - name: 'target', - disallowedNodes: [me.nodename], - fieldLabel: gettext('Target node'), - allowBlank: false, - onlineValidator: true, - }, - { - xtype: 'proxmoxintegerfield', - name: 'maxworkers', - minValue: 1, - maxValue: 100, - value: 1, - fieldLabel: gettext('Parallel jobs'), - allowBlank: false, - }, { xtype: 'fieldcontainer', - fieldLabel: gettext('Allow local disk migration'), layout: 'hbox', items: [{ - xtype: 'proxmoxcheckbox', - name: 'with-local-disks', - checked: true, - uncheckedValue: 0, - listeners: { - change: (cb, val) => me.down('#localdiskwarning').setVisible(val), - }, + flex: 1, + xtype: 'pveNodeSelector', + name: 'target', + disallowedNodes: [me.nodename], + fieldLabel: gettext('Target node'), + labelWidth: 200, + allowBlank: false, + onlineValidator: true, + padding: '0 10 0 0', }, { - itemId: 'localdiskwarning', - xtype: 'displayfield', + xtype: 'proxmoxintegerfield', + name: 'maxworkers', + minValue: 1, + maxValue: 100, + value: 1, + fieldLabel: gettext('Parallel jobs'), + allowBlank: false, flex: 1, - padding: '0 0 0 10', - userCls: 'pmx-hint', - value: 'Note: Migration with local disks might take long.', }], }, { - itemId: 'lxcwarning', - xtype: 'displayfield', - userCls: 'pmx-hint', - value: 'Warning: Running CTs will be migrated in Restart Mode.', - hidden: true, // only visible if running container chosen + xtype: 'fieldcontainer', + layout: 'hbox', + items: [{ + xtype: 'proxmoxcheckbox', + fieldLabel: gettext('Allow local disk migration'), + name: 'with-local-disks', + labelWidth: 200, + checked: true, + uncheckedValue: 0, + flex: 1, + padding: '0 10 0 0', + }, + { + itemId: 'lxcwarning', + xtype: 'displayfield', + userCls: 'pmx-hint', + value: 'Warning: Running CTs will be migrated in Restart Mode.', + hidden: true, // only visible if running container chosen + flex: 1, + }], }, ); } else if (me.action === 'startall') { @@ -108,25 +109,31 @@ Ext.define('PVE.window.BulkAction', { value: 1, }); } else if (me.action === 'stopall') { - items.push( - { + items.push({ + xtype: 'fieldcontainer', + layout: 'hbox', + items: [{ xtype: 'proxmoxcheckbox', name: 'force-stop', + labelWidth: 120, fieldLabel: gettext('Force Stop'), boxLabel: gettext('Force stop guest if shutdown times out.'), checked: true, uncheckedValue: 0, + flex: 1, }, { xtype: 'proxmoxintegerfield', name: 'timeout', fieldLabel: gettext('Timeout (s)'), + labelWidth: 120, emptyText: '180', minValue: 0, maxValue: 7200, allowBlank: true, - }, - ); + flex: 1, + }], + }); } items.push({ From f0535b036dba565412f0b736c536fd8dcfe0c9c9 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 9 Nov 2023 11:47:59 +0100 Subject: [PATCH 288/398] ui: bulk actions: rework filters and include tags This moves the filters out of the grid header for the BulkActions and puts them into their own fieldset above the grid. With that, we can easily include a tags filter (one include and one exclude list). The filter fieldset is collapsible and shows the active filters in parenthesis. aside from that the filter should be the same as before. To achieve the result, we regenerate the filterFn on every change of every filter field, and set it with an 'id' so that only that filter is overridden each time. To make this work, we have to change three tiny details: * manually set the labelWidths for the fields, otherwise it breaks the ones in the fieldset. * change the counting in the 'getErrors' of the VMSelector, so that we actually get the count of selected VMs, not the one from the selectionModel * override the plugins to '' in the BulkAction windows, so that e.g. in the backup window we still have the filters in the grid header (we could add a filter box there too, but that is already very crowded and would take up too much space for now) Signed-off-by: Dominik Csapak --- www/manager6/form/VMSelector.js | 10 +- www/manager6/window/BulkAction.js | 278 +++++++++++++++++++++++++++++- 2 files changed, 281 insertions(+), 7 deletions(-) diff --git a/www/manager6/form/VMSelector.js b/www/manager6/form/VMSelector.js index d59847f2f..43e917492 100644 --- a/www/manager6/form/VMSelector.js +++ b/www/manager6/form/VMSelector.js @@ -18,6 +18,8 @@ Ext.define('PVE.form.VMSelector', { sorters: 'vmid', }, + userCls: 'proxmox-tags-circle', + columnsDeclaration: [ { header: 'ID', @@ -80,6 +82,12 @@ Ext.define('PVE.form.VMSelector', { }, }, }, + { + header: gettext('Tags'), + dataIndex: 'tags', + renderer: tags => PVE.Utils.renderTags(tags, PVE.UIOptions.tagOverrides), + flex: 1, + }, { header: 'HA ' + gettext('Status'), dataIndex: 'hastate', @@ -186,7 +194,7 @@ Ext.define('PVE.form.VMSelector', { getErrors: function(value) { let me = this; if (!me.isDisabled() && me.allowBlank === false && - me.getSelectionModel().getCount() === 0) { + me.getValue().length === 0) { me.addBodyCls(['x-form-trigger-wrap-default', 'x-form-trigger-wrap-invalid']); return [gettext('No VM selected')]; } diff --git a/www/manager6/window/BulkAction.js b/www/manager6/window/BulkAction.js index 950b454da..1ffc7538c 100644 --- a/www/manager6/window/BulkAction.js +++ b/www/manager6/window/BulkAction.js @@ -136,6 +136,273 @@ Ext.define('PVE.window.BulkAction', { }); } + let refreshLxcWarning = function(vmids, records) { + let showWarning = records.some( + item => vmids.includes(item.data.vmid) && item.data.type === 'lxc' && item.data.status === 'running', + ); + me.down('#lxcwarning').setVisible(showWarning); + }; + + let defaultStatus = me.action === 'migrateall' ? '' : me.action === 'startall' ? 'stopped' : 'running'; + + let statusMap = []; + let poolMap = []; + let haMap = []; + let tagMap = []; + PVE.data.ResourceStore.each((rec) => { + if (['qemu', 'lxc'].indexOf(rec.data.type) !== -1) { + statusMap[rec.data.status] = true; + } + if (rec.data.type === 'pool') { + poolMap[rec.data.pool] = true; + } + if (rec.data.hastate !== "") { + haMap[rec.data.hastate] = true; + } + if (rec.data.tags !== "") { + rec.data.tags.split(/[,; ]/).forEach((tag) => { + if (tag !== '') { + tagMap[tag] = true; + } + }); + } + }); + + let statusList = Object.keys(statusMap).map(key => [key, key]); + statusList.unshift(['', gettext('All')]); + let poolList = Object.keys(poolMap).map(key => [key, key]); + let tagList = Object.keys(tagMap).map(key => ({ value: key })); + let haList = Object.keys(haMap).map(key => [key, key]); + + let filterChange = function() { + let nameValue = me.down('#namefilter').getValue(); + let filterCount = 0; + + if (nameValue !== '') { + filterCount++; + } + + let arrayFiltersData = []; + ['pool', 'hastate'].forEach((filter) => { + let selected = me.down(`#${filter}filter`).getValue() ?? []; + if (selected.length) { + filterCount++; + arrayFiltersData.push([filter, [...selected]]); + } + }); + + let singleFiltersData = []; + ['status', 'type'].forEach((filter) => { + let selected = me.down(`#${filter}filter`).getValue() ?? ''; + if (selected.length) { + filterCount++; + singleFiltersData.push([filter, selected]); + } + }); + + let includeTags = me.down('#includetagfilter').getValue() ?? []; + if (includeTags.length) { + filterCount++; + } + let excludeTags = me.down('#excludetagfilter').getValue() ?? []; + if (excludeTags.length) { + filterCount++; + } + + let fieldSet = me.down('#filters'); + if (filterCount) { + fieldSet.setTitle(Ext.String.format(gettext('Filters ({0})'), filterCount)); + } else { + fieldSet.setTitle(gettext('Filters')); + } + + let filterFn = function(value) { + let name = value.data.name.toLowerCase().indexOf(nameValue.toLowerCase()) !== -1; + let arrayFilters = arrayFiltersData.every(([filter, selected]) => + !selected.length || selected.indexOf(value.data[filter]) !== -1); + let singleFilters = singleFiltersData.every(([filter, selected]) => + !selected.length || value.data[filter].indexOf(selected) !== -1); + let tags = value.data.tags.split(/[;, ]/).filter(t => !!t); + let includeFilter = !includeTags.length || tags.some(tag => includeTags.indexOf(tag) !== -1); + let excludeFilter = !excludeTags.length || tags.every(tag => excludeTags.indexOf(tag) === -1); + + return name && arrayFilters && singleFilters && includeFilter && excludeFilter; + }; + let vmselector = me.down('#vms'); + vmselector.getStore().setFilters({ + id: 'customFilter', + filterFn, + }); + vmselector.checkChange(); + if (me.action === 'migrateall') { + let records = vmselector.getSelection(); + refreshLxcWarning(vmselector.getValue(), records); + } + }; + + items.push({ + xtype: 'fieldset', + itemId: 'filters', + collapsible: true, + title: gettext('Filters'), + layout: 'hbox', + items: [ + { + xtype: 'container', + flex: 1, + padding: 5, + layout: { + type: 'vbox', + align: 'stretch', + }, + defaults: { + listeners: { + change: filterChange, + }, + isFormField: false, + }, + items: [ + { + fieldLabel: gettext("Name"), + itemId: 'namefilter', + xtype: 'textfield', + }, + { + xtype: 'combobox', + itemId: 'statusfilter', + fieldLabel: gettext("Status"), + emptyText: gettext('All'), + editable: false, + value: defaultStatus, + store: statusList, + }, + { + xtype: 'combobox', + itemId: 'poolfilter', + fieldLabel: gettext("Pool"), + emptyText: gettext('All'), + editable: false, + multiSelect: true, + store: poolList, + }, + ], + }, + { + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + flex: 1, + padding: 5, + defaults: { + listeners: { + change: filterChange, + }, + isFormField: false, + }, + items: [ + { + xtype: 'combobox', + itemId: 'typefilter', + fieldLabel: gettext("Type"), + emptyText: gettext('All'), + editable: false, + value: '', + store: [ + ['', gettext('All')], + ['lxc', gettext('CT')], + ['qemu', gettext('VM')], + ], + }, + { + xtype: 'proxmoxComboGrid', + itemId: 'includetagfilter', + fieldLabel: gettext("Include Tags"), + emptyText: gettext('All'), + editable: false, + multiSelect: true, + valueField: 'value', + displayField: 'value', + listConfig: { + userCls: 'proxmox-tags-full', + columns: [ + { + dataIndex: 'value', + flex: 1, + renderer: value => + PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides), + }, + ], + }, + store: { + data: tagList, + }, + listeners: { + change: filterChange, + }, + }, + { + xtype: 'proxmoxComboGrid', + itemId: 'excludetagfilter', + fieldLabel: gettext("Exclude Tags"), + emptyText: gettext('None'), + multiSelect: true, + editable: false, + valueField: 'value', + displayField: 'value', + listConfig: { + userCls: 'proxmox-tags-full', + columns: [ + { + dataIndex: 'value', + flex: 1, + renderer: value => + PVE.Utils.renderTags(value, PVE.UIOptions.tagOverrides), + }, + ], + }, + store: { + data: tagList, + }, + listeners: { + change: filterChange, + }, + }, + ], + }, + { + xtype: 'container', + layout: { + type: 'vbox', + align: 'stretch', + }, + flex: 1, + padding: 5, + defaults: { + listeners: { + change: filterChange, + }, + isFormField: false, + }, + items: [ + { + xtype: 'combobox', + itemId: 'hastatefilter', + fieldLabel: gettext("HA status"), + emptyText: gettext('All'), + multiSelect: true, + editable: false, + store: haList, + listeners: { + change: filterChange, + }, + }, + ], + }, + ], + }); + items.push({ xtype: 'vmselector', itemId: 'vms', @@ -144,15 +411,13 @@ Ext.define('PVE.window.BulkAction', { height: 300, selectAll: true, allowBlank: false, + plugins: '', nodename: me.nodename, - action: me.action, listeners: { selectionchange: function(vmselector, records) { if (me.action === 'migrateall') { - let showWarning = records.some( - item => item.data.type === 'lxc' && item.data.status === 'running', - ); - me.down('#lxcwarning').setVisible(showWarning); + let vmids = me.down('#vms').getValue(); + refreshLxcWarning(vmids, records); } }, }, @@ -166,7 +431,6 @@ Ext.define('PVE.window.BulkAction', { align: 'stretch', }, fieldDefaults: { - labelWidth: me.action === 'migrateall' ? 300 : 120, anchor: '100%', }, items: items, @@ -194,5 +458,7 @@ Ext.define('PVE.window.BulkAction', { submitBtn.setDisabled(!valid); }); form.isValid(); + + filterChange(); }, }); From 9fc36be82af6ef0208865cc14bcf64a0fc67bfd0 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 9 Nov 2023 11:48:00 +0100 Subject: [PATCH 289/398] ui: bulk actions: add clear filters button to be able to clear all of them at once Signed-off-by: Dominik Csapak --- www/manager6/window/BulkAction.js | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/www/manager6/window/BulkAction.js b/www/manager6/window/BulkAction.js index 1ffc7538c..5f76ef7a4 100644 --- a/www/manager6/window/BulkAction.js +++ b/www/manager6/window/BulkAction.js @@ -174,6 +174,13 @@ Ext.define('PVE.window.BulkAction', { let tagList = Object.keys(tagMap).map(key => ({ value: key })); let haList = Object.keys(haMap).map(key => [key, key]); + let clearFilters = function() { + me.down('#namefilter').setValue(''); + ['name', 'status', 'pool', 'type', 'hastate', 'includetag', 'excludetag', 'vmid'].forEach((filter) => { + me.down(`#${filter}filter`).setValue(''); + }); + }; + let filterChange = function() { let nameValue = me.down('#namefilter').getValue(); let filterCount = 0; @@ -210,10 +217,13 @@ Ext.define('PVE.window.BulkAction', { } let fieldSet = me.down('#filters'); + let clearBtn = me.down('#clearBtn'); if (filterCount) { fieldSet.setTitle(Ext.String.format(gettext('Filters ({0})'), filterCount)); + clearBtn.setDisabled(false); } else { fieldSet.setTitle(gettext('Filters')); + clearBtn.setDisabled(true); } let filterFn = function(value) { @@ -398,6 +408,22 @@ Ext.define('PVE.window.BulkAction', { change: filterChange, }, }, + { + xtype: 'container', + layout: { + type: 'vbox', + align: 'end', + }, + items: [ + { + xtype: 'button', + itemId: 'clearBtn', + text: gettext('Clear Filters'), + disabled: true, + handler: clearFilters, + }, + ], + }, ], }, ], From 2f414c50c191ae5c40f774869d70b7c14b86ee5e Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 9 Nov 2023 11:56:23 +0100 Subject: [PATCH 290/398] ui: resource tree: limit tooltip to icon and text and exclude the tags for that, since we want the tags to have their own tooltips we use the delegate function of the tooltips for that Signed-off-by: Dominik Csapak --- www/manager6/tree/ResourceTree.js | 40 ++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/www/manager6/tree/ResourceTree.js b/www/manager6/tree/ResourceTree.js index 54c6403d8..90f85fba7 100644 --- a/www/manager6/tree/ResourceTree.js +++ b/www/manager6/tree/ResourceTree.js @@ -122,7 +122,7 @@ Ext.define('PVE.tree.ResourceTree', { status = '
'; status += `
`; status += `
`; - status += '
'; + status += ''; } } if (Ext.isNumeric(info.vmid) && info.vmid > 0) { @@ -130,15 +130,16 @@ Ext.define('PVE.tree.ResourceTree', { info.text = `${info.name} (${String(info.vmid)})`; } } - + info.text = `${status} ${info.text}`; info.text += PVE.Utils.renderTags(info.tags, PVE.UIOptions.tagOverrides); - - info.text = status + info.text; }, - setToolTip: function(info) { + getToolTip: function(info) { + if (info.tip) { + return info.tip; + } if (info.type === 'pool' || info.groupbyid !== undefined) { - return; + return undefined; } let qtips = [gettext('Status') + ': ' + (info.qmpstatus || info.status)]; @@ -149,7 +150,9 @@ Ext.define('PVE.tree.ResourceTree', { qtips.push(gettext('HA State') + ": " + info.hastate); } - info.qtip = qtips.join(', '); + let tip = qtips.join(', '); + info.tip = tip; + return tip; }, // private @@ -158,7 +161,6 @@ Ext.define('PVE.tree.ResourceTree', { me.setIconCls(info); me.setText(info); - me.setToolTip(info); if (info.groupbyid) { info.text = info.groupbyid; @@ -315,7 +317,6 @@ Ext.define('PVE.tree.ResourceTree', { Ext.apply(info, item.data); me.setIconCls(info); me.setText(info); - me.setToolTip(info); olditem.commit(); } if ((!item || moved) && olditem.isLeaf()) { @@ -403,6 +404,27 @@ Ext.define('PVE.tree.ResourceTree', { return allow; }, itemdblclick: PVE.Utils.openTreeConsole, + afterrender: function() { + if (me.tip) { + return; + } + let selectors = [ + '.x-tree-node-text > span:not(.proxmox-tag-dark):not(.proxmox-tag-light)', + '.x-tree-icon', + ]; + me.tip = Ext.create('Ext.tip.ToolTip', { + target: me.el, + delegate: selectors.join(', '), + trackMouse: true, + renderTo: Ext.getBody(), + listeners: { + beforeshow: function(tip) { + let rec = me.getView().getRecord(tip.triggerElement); + tip.update(me.getToolTip(rec.data)); + }, + }, + }); + }, }, setViewFilter: function(view) { me.viewFilter = view; From e3bc13e14a91355de07884d171a699d39398e42f Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 9 Nov 2023 11:56:24 +0100 Subject: [PATCH 291/398] ui: add tooltips to non-full tags globally by using the delegate function of ExtJS' tooltips on the global Workspace element and using the proper css selectors this way, we can limit the tooltips to the non-full ones (in contrast to using data-qtip on the element, which would always be show, even for tags with the 'full' style) Signed-off-by: Dominik Csapak --- www/css/ext6-pve.css | 10 ++++++++++ www/manager6/Workspace.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index 105adc45d..782c90442 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -726,3 +726,13 @@ table.osds td:first-of-type { .proxmox-tag-fieldset.proxmox-tags-full .x-component.x-column { margin: 2px; } + +/* we have to override some styles for the tag tooltips, + * otherwise extjs styling interferes */ +.pmx-tag-tooltip { + background-color: transparent; +} + +.pmx-tag-tooltip .proxmox-tags-full > span { + margin: 0px; +} diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js index 18d574b75..6e465f8d3 100644 --- a/www/manager6/Workspace.js +++ b/www/manager6/Workspace.js @@ -526,6 +526,35 @@ Ext.define('PVE.StdWorkspace', { modalWindows.forEach(win => win.alignTo(me, 'c-c')); } }); + + let tagSelectors = []; + ['circle', 'dense'].forEach((style) => { + ['dark', 'light'].forEach((variant) => { + tagSelectors.push(`.proxmox-tags-${style} .proxmox-tag-${variant}`); + }); + }); + + Ext.create('Ext.tip.ToolTip', { + target: me.el, + delegate: tagSelectors.join(', '), + trackMouse: true, + renderTo: Ext.getBody(), + border: 0, + minWidth: 0, + padding: 0, + bodyBorder: 0, + bodyPadding: 0, + dismissDelay: 0, + userCls: 'pmx-tag-tooltip', + shadow: false, + listeners: { + beforeshow: function(tip) { + let tag = Ext.htmlEncode(tip.triggerElement.innerHTML); + let tagEl = Proxmox.Utils.getTagElement(tag, PVE.UIOptions.tagOverrides); + tip.update(`${tagEl}`); + }, + }, + }); }, }); From 128e8e826a62c790044d40a15c986c8de4bcdc9a Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 10 Nov 2023 11:12:58 +0100 Subject: [PATCH 292/398] ui: resource tree: don't save the tooltip it shouldn't be called that often, and if we save it, it gets outdated, e.g. when starting/stopping a guest Signed-off-by: Dominik Csapak --- www/manager6/tree/ResourceTree.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/www/manager6/tree/ResourceTree.js b/www/manager6/tree/ResourceTree.js index 90f85fba7..3d2dd931e 100644 --- a/www/manager6/tree/ResourceTree.js +++ b/www/manager6/tree/ResourceTree.js @@ -135,9 +135,6 @@ Ext.define('PVE.tree.ResourceTree', { }, getToolTip: function(info) { - if (info.tip) { - return info.tip; - } if (info.type === 'pool' || info.groupbyid !== undefined) { return undefined; } From 1ed8e0096b00de37e0c145255f9f196a5cc44e3f Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 10 Nov 2023 11:12:59 +0100 Subject: [PATCH 293/398] ui: resource tree: fix showing empty tooltips stop the tooltip show when the there is no text this could happen for e.g. nodes that should not have a tooltip Signed-off-by: Dominik Csapak --- www/manager6/tree/ResourceTree.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/www/manager6/tree/ResourceTree.js b/www/manager6/tree/ResourceTree.js index 3d2dd931e..ed51ac32e 100644 --- a/www/manager6/tree/ResourceTree.js +++ b/www/manager6/tree/ResourceTree.js @@ -417,7 +417,12 @@ Ext.define('PVE.tree.ResourceTree', { listeners: { beforeshow: function(tip) { let rec = me.getView().getRecord(tip.triggerElement); - tip.update(me.getToolTip(rec.data)); + let tipText = me.getToolTip(rec.data); + if (tipText) { + tip.update(tipText); + return true; + } + return false; }, }, }); From 872e9978fd4c69337e2766dd5ca89b6bf2bc3e27 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Fri, 10 Nov 2023 11:13:00 +0100 Subject: [PATCH 294/398] ui: resource tree: add usage percentage to storage tooltip it is a bit more verbose than the usage bar Signed-off-by: Dominik Csapak --- www/manager6/tree/ResourceTree.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/www/manager6/tree/ResourceTree.js b/www/manager6/tree/ResourceTree.js index ed51ac32e..acfa545ae 100644 --- a/www/manager6/tree/ResourceTree.js +++ b/www/manager6/tree/ResourceTree.js @@ -146,6 +146,12 @@ Ext.define('PVE.tree.ResourceTree', { if (info.hastate !== 'unmanaged') { qtips.push(gettext('HA State') + ": " + info.hastate); } + if (info.type === 'storage') { + let usage = info.disk / info.maxdisk; + if (usage >= 0.0 && usage <= 1.0) { + qtips.push(Ext.String.format(gettext("Usage: {0}%"), (usage*100).toFixed(2))); + } + } let tip = qtips.join(', '); info.tip = tip; From 9b15baf29f02ff6c760bbcd3f81223957414dc55 Mon Sep 17 00:00:00 2001 From: Maximiliano Sandoval Date: Fri, 22 Sep 2023 11:25:25 +0200 Subject: [PATCH 295/398] ceph: mark global pg bits setting as deprecated This setting was removed in [1] as part of the v13.0.2 tag. Running ceph config set global osd_pg_bits 42 results in Error EINVAL: unrecognized config option 'osd_pg_bits' So we mark this api as deprecated and make it a no-op operation. [1] https://github.com/ceph/ceph/commit/e6acf2d1d528a2395947d446a57bec04a3a002dc Signed-off-by: Maximiliano Sandoval --- PVE/API2/Ceph.pm | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/PVE/API2/Ceph.pm b/PVE/API2/Ceph.pm index 7e0763cf5..fab4637cc 100644 --- a/PVE/API2/Ceph.pm +++ b/PVE/API2/Ceph.pm @@ -158,10 +158,11 @@ __PACKAGE__->register_method ({ minimum => 1, maximum => 7, }, + # TODO: deprecrated, remove with PVE 9 pg_bits => { description => "Placement group bits, used to specify the " . - "default number of placement groups.\n\nNOTE: 'osd pool " . - "default pg num' does not work for default pools.", + "default number of placement groups.\n\nDepreacted. This " . + "setting was deprecated in recent Ceph versions.", type => 'integer', default => 6, optional => 1, @@ -224,11 +225,6 @@ __PACKAGE__->register_method ({ $cfg->{client}->{keyring} = '/etc/pve/priv/$cluster.$name.keyring'; } - if ($param->{pg_bits}) { - $cfg->{global}->{'osd pg bits'} = $param->{pg_bits}; - $cfg->{global}->{'osd pgp bits'} = $param->{pg_bits}; - } - if ($param->{network}) { $cfg->{global}->{'public network'} = $param->{network}; $cfg->{global}->{'cluster network'} = $param->{network}; From 69cdcb12e9bd6ceadbae6708dcbcdae860c12df3 Mon Sep 17 00:00:00 2001 From: Maximiliano Sandoval Date: Fri, 22 Sep 2023 11:25:26 +0200 Subject: [PATCH 296/398] ceph: api: use snake_case when setting options Continuation of ab70343982f36a5343d3fcf4a1a6489bd3f52a66. Discussion at https://lists.proxmox.com/pipermail/pve-devel/2023-September/059013.html. Signed-off-by: Maximiliano Sandoval --- PVE/API2/Ceph.pm | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/PVE/API2/Ceph.pm b/PVE/API2/Ceph.pm index fab4637cc..81c17d6e4 100644 --- a/PVE/API2/Ceph.pm +++ b/PVE/API2/Ceph.pm @@ -208,12 +208,12 @@ __PACKAGE__->register_method ({ $cfg->{global} = { 'fsid' => $fsid, - 'auth cluster required' => $auth, - 'auth service required' => $auth, - 'auth client required' => $auth, - 'osd pool default size' => $param->{size} // 3, - 'osd pool default min size' => $param->{min_size} // 2, - 'mon allow pool delete' => 'true', + 'auth_cluster_required' => $auth, + 'auth_service_required' => $auth, + 'auth_client_required' => $auth, + 'osd_pool_default_size' => $param->{size} // 3, + 'osd_pool_default_min_size' => $param->{min_size} // 2, + 'mon_allow_pool_delete' => 'true', }; # this does not work for default pools @@ -226,12 +226,12 @@ __PACKAGE__->register_method ({ } if ($param->{network}) { - $cfg->{global}->{'public network'} = $param->{network}; - $cfg->{global}->{'cluster network'} = $param->{network}; + $cfg->{global}->{'public_network'} = $param->{network}; + $cfg->{global}->{'cluster_network'} = $param->{network}; } if ($param->{'cluster-network'}) { - $cfg->{global}->{'cluster network'} = $param->{'cluster-network'}; + $cfg->{global}->{'cluster_network'} = $param->{'cluster-network'}; } cfs_write_file('ceph.conf', $cfg); From 01740cb2f705e3e2156da1ed0e50daa46944bbef Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 13 Nov 2023 09:59:30 +0100 Subject: [PATCH 297/398] ui: factor out standalone node check into Utils and use it where we manually checked that Signed-off-by: Dominik Csapak --- www/manager6/Utils.js | 4 ++++ www/manager6/form/ComboBoxSetStoreNode.js | 2 +- www/manager6/grid/Replication.js | 4 ++-- www/manager6/lxc/CmdMenu.js | 2 +- www/manager6/lxc/Config.js | 2 +- www/manager6/menu/TemplateMenu.js | 2 +- www/manager6/qemu/CmdMenu.js | 2 +- www/manager6/qemu/Config.js | 2 +- www/manager6/storage/LVMEdit.js | 2 +- 9 files changed, 13 insertions(+), 9 deletions(-) diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index be30393eb..9b77ebd3e 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -1921,6 +1921,10 @@ Ext.define('PVE.Utils', { 'ok': 2, '__default__': 3, }, + + isStandaloneNode: function() { + return PVE.data.ResourceStore.getNodes().length < 2; + }, }, singleton: true, diff --git a/www/manager6/form/ComboBoxSetStoreNode.js b/www/manager6/form/ComboBoxSetStoreNode.js index d5695bad2..26b1f95bd 100644 --- a/www/manager6/form/ComboBoxSetStoreNode.js +++ b/www/manager6/form/ComboBoxSetStoreNode.js @@ -56,7 +56,7 @@ Ext.define('PVE.form.ComboBoxSetStoreNode', { initComponent: function() { let me = this; - if (me.showNodeSelector && PVE.data.ResourceStore.getNodes().length > 1) { + if (me.showNodeSelector && !PVE.Utils.isStandaloneNode()) { me.errorHeight = 140; Ext.apply(me.listConfig ?? {}, { tbar: { diff --git a/www/manager6/grid/Replication.js b/www/manager6/grid/Replication.js index 1e4e00fcb..79824b9b1 100644 --- a/www/manager6/grid/Replication.js +++ b/www/manager6/grid/Replication.js @@ -220,7 +220,7 @@ Ext.define('PVE.grid.ReplicaView', { // currently replication is for cluster only, so disable the whole component for non-cluster checkPrerequisites: function() { let view = this.getView(); - if (PVE.data.ResourceStore.getNodes().length < 2) { + if (PVE.Utils.isStandaloneNode()) { view.mask(gettext("Replication needs at least two nodes"), ['pve-static-mask']); } }, @@ -450,7 +450,7 @@ Ext.define('PVE.grid.ReplicaView', { // if we set the warning mask, we do not want to load // or set the mask on store errors - if (PVE.data.ResourceStore.getNodes().length < 2) { + if (PVE.Utils.isStandaloneNode()) { return; } diff --git a/www/manager6/lxc/CmdMenu.js b/www/manager6/lxc/CmdMenu.js index 56f36b5e7..b1403fc64 100644 --- a/www/manager6/lxc/CmdMenu.js +++ b/www/manager6/lxc/CmdMenu.js @@ -31,7 +31,7 @@ Ext.define('PVE.lxc.CmdMenu', { }; let caps = Ext.state.Manager.get('GuiCap'); - let standalone = PVE.data.ResourceStore.getNodes().length < 2; + let standalone = PVE.Utils.isStandaloneNode(); let running = false, stopped = true, suspended = false; switch (info.status) { diff --git a/www/manager6/lxc/Config.js b/www/manager6/lxc/Config.js index 85d32e3c4..4516ee8f9 100644 --- a/www/manager6/lxc/Config.js +++ b/www/manager6/lxc/Config.js @@ -92,7 +92,7 @@ Ext.define('PVE.lxc.Config', { var migrateBtn = Ext.create('Ext.Button', { text: gettext('Migrate'), disabled: !caps.vms['VM.Migrate'], - hidden: PVE.data.ResourceStore.getNodes().length < 2, + hidden: PVE.Utils.isStandaloneNode(), handler: function() { var win = Ext.create('PVE.window.Migrate', { vmtype: 'lxc', diff --git a/www/manager6/menu/TemplateMenu.js b/www/manager6/menu/TemplateMenu.js index eb91481cd..7cd87f6a4 100644 --- a/www/manager6/menu/TemplateMenu.js +++ b/www/manager6/menu/TemplateMenu.js @@ -22,7 +22,7 @@ Ext.define('PVE.menu.TemplateMenu', { me.title = (guestType === 'qemu' ? 'VM ' : 'CT ') + info.vmid; let caps = Ext.state.Manager.get('GuiCap'); - let standaloneNode = PVE.data.ResourceStore.getNodes().length < 2; + let standaloneNode = PVE.Utils.isStandaloneNode(); me.items = [ { diff --git a/www/manager6/qemu/CmdMenu.js b/www/manager6/qemu/CmdMenu.js index ccc5f74d4..4f59d5f7c 100644 --- a/www/manager6/qemu/CmdMenu.js +++ b/www/manager6/qemu/CmdMenu.js @@ -32,7 +32,7 @@ Ext.define('PVE.qemu.CmdMenu', { }; let caps = Ext.state.Manager.get('GuiCap'); - let standalone = PVE.data.ResourceStore.getNodes().length < 2; + let standalone = PVE.Utils.isStandaloneNode(); let running = false, stopped = true, suspended = false; switch (info.status) { diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js index 6acf589c9..fb0d9cded 100644 --- a/www/manager6/qemu/Config.js +++ b/www/manager6/qemu/Config.js @@ -67,7 +67,7 @@ Ext.define('PVE.qemu.Config', { var migrateBtn = Ext.create('Ext.Button', { text: gettext('Migrate'), disabled: !caps.vms['VM.Migrate'], - hidden: PVE.data.ResourceStore.getNodes().length < 2, + hidden: PVE.Utils.isStandaloneNode(), handler: function() { var win = Ext.create('PVE.window.Migrate', { vmtype: 'qemu', diff --git a/www/manager6/storage/LVMEdit.js b/www/manager6/storage/LVMEdit.js index 75c7bdb83..fde302fce 100644 --- a/www/manager6/storage/LVMEdit.js +++ b/www/manager6/storage/LVMEdit.js @@ -114,7 +114,7 @@ Ext.define('PVE.storage.LunSelector', { initComponent: function() { let me = this; - if (PVE.data.ResourceStore.getNodes().length > 1) { + if (!PVE.Utils.isStandaloneNode()) { me.errorHeight = 140; Ext.apply(me.listConfig ?? {}, { tbar: { From ed38c56b2baf72be8046ad000b812cd62f278ae2 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 13 Nov 2023 09:59:31 +0100 Subject: [PATCH 298/398] ui: hide bulk migrate options on standalone nodes since there is nowhere to migrate to and we hide the regular migrate buttons/options too. Signed-off-by: Dominik Csapak --- www/manager6/node/CmdMenu.js | 4 ++++ www/manager6/node/Config.js | 1 + 2 files changed, 5 insertions(+) diff --git a/www/manager6/node/CmdMenu.js b/www/manager6/node/CmdMenu.js index dc56ef081..1dbcd781a 100644 --- a/www/manager6/node/CmdMenu.js +++ b/www/manager6/node/CmdMenu.js @@ -139,5 +139,9 @@ Ext.define('PVE.node.CmdMenu', { if (me.pveSelNode.data.running) { me.getComponent('wakeonlan').setDisabled(true); } + + if (PVE.Utils.isStandaloneNode()) { + me.getComponent('bulkmigrate').setVisible(false); + } }, }); diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js index 6ed2172aa..6deafcdf6 100644 --- a/www/manager6/node/Config.js +++ b/www/manager6/node/Config.js @@ -69,6 +69,7 @@ Ext.define('PVE.node.Config', { text: gettext('Bulk Migrate'), iconCls: 'fa fa-fw fa-send-o', disabled: !caps.vms['VM.Migrate'], + hidden: PVE.Utils.isStandaloneNode(), handler: function() { Ext.create('PVE.window.BulkAction', { autoShow: true, From fe64969b63eea76d3c2721edf8048ff65bd390e1 Mon Sep 17 00:00:00 2001 From: Folke Gleumes Date: Tue, 31 Oct 2023 10:05:11 +0100 Subject: [PATCH 299/398] fix #4497: acme: add support for external account bindings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Folke Gleumes Reviewed-by: Fabian.Grünbichler Tested-by: Fabian.Grünbichler --- PVE/API2/ACMEAccount.pm | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/PVE/API2/ACMEAccount.pm b/PVE/API2/ACMEAccount.pm index b790843aa..ec4eba24f 100644 --- a/PVE/API2/ACMEAccount.pm +++ b/PVE/API2/ACMEAccount.pm @@ -115,6 +115,18 @@ __PACKAGE__->register_method ({ default => $acme_default_directory_url, optional => 1, }), + 'eab-kid' => { + type => 'string', + description => 'Key Identifier for External Account Binding.', + requires => 'eab-hmac-key', + optional => 1, + }, + 'eab-hmac-key' => { + type => 'string', + description => 'HMAC key for External Account Binding.', + requires => 'eab-kid', + optional => 1, + }, }, }, returns => { @@ -130,6 +142,9 @@ __PACKAGE__->register_method ({ my $account_file = "${acme_account_dir}/${account_name}"; mkdir $acme_account_dir if ! -e $acme_account_dir; + my $eab_kid = extract_param($param, 'eab-kid'); + my $eab_hmac_key = extract_param($param, 'eab-hmac-key'); + raise_param_exc({'name' => "ACME account config file '${account_name}' already exists."}) if -e $account_file; @@ -145,7 +160,17 @@ __PACKAGE__->register_method ({ print "Generating ACME account key..\n"; $acme->init(4096); print "Registering ACME account..\n"; - eval { $acme->new_account($param->{tos_url}, contact => $contact); }; + + my %info = (contact => $contact); + if (defined($eab_kid)) { + $info{eab} = { + kid => $eab_kid, + hmac_key => $eab_hmac_key + }; + } + + eval { $acme->new_account($param->{tos_url}, %info); }; + if (my $err = $@) { unlink $account_file; die "Registration failed: $err\n"; From bd3f27adbc20f5de304764072a768edcc97545cb Mon Sep 17 00:00:00 2001 From: Folke Gleumes Date: Tue, 31 Oct 2023 10:05:12 +0100 Subject: [PATCH 300/398] api/acme: deprecate tos endpoint in favor of meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ToS endpoint ignored data that is needed to detect if EAB needs to be used. Instead of adding a new endpoint that does the same request, the tos endpoint is deprecated and replaced by the meta endpoint, that returns all information returned by the directory. Signed-off-by: Folke Gleumes Reviewed-by: Fabian.Grünbichler Tested-by: Fabian.Grünbichler --- PVE/API2/ACMEAccount.pm | 56 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/PVE/API2/ACMEAccount.pm b/PVE/API2/ACMEAccount.pm index ec4eba24f..bc45d5ab1 100644 --- a/PVE/API2/ACMEAccount.pm +++ b/PVE/API2/ACMEAccount.pm @@ -62,6 +62,7 @@ __PACKAGE__->register_method ({ return [ { name => 'account' }, { name => 'tos' }, + { name => 'meta' }, { name => 'directories' }, { name => 'plugins' }, { name => 'challenge-schema' }, @@ -333,11 +334,12 @@ __PACKAGE__->register_method ({ return $update_account->($param, 'deactivate', status => 'deactivated'); }}); +# TODO: deprecated, remove with pve 9 __PACKAGE__->register_method ({ name => 'get_tos', path => 'tos', method => 'GET', - description => "Retrieve ACME TermsOfService URL from CA.", + description => "Retrieve ACME TermsOfService URL from CA. Deprecated, please use /cluster/acme/meta.", permissions => { user => 'all' }, parameters => { additionalProperties => 0, @@ -364,6 +366,58 @@ __PACKAGE__->register_method ({ return $meta ? $meta->{termsOfService} : undef; }}); +__PACKAGE__->register_method ({ + name => 'get_meta', + path => 'meta', + method => 'GET', + description => "Retrieve ACME Directory Meta Information", + permissions => { user => 'all' }, + parameters => { + additionalProperties => 0, + properties => { + directory => get_standard_option('pve-acme-directory-url', { + default => $acme_default_directory_url, + optional => 1, + }), + }, + }, + returns => { + type => 'object', + additionalProperties => 1, + properties => { + termsOfService => { + type => 'string', + optional => 1, + description => 'ACME TermsOfService URL.', + }, + externalAccountRequired => { + type => 'boolean', + optional => 1, + description => 'EAB Required' + }, + website => { + type => 'string', + optional => 1, + description => 'URL to more information about the ACME server.' + }, + caaIdentities => { + type => 'string', + optional => 1, + description => 'Hostnames referring to the ACME servers.' + }, + }, + }, + code => sub { + my ($param) = @_; + + my $directory = extract_param($param, 'directory') // $acme_default_directory_url; + + my $acme = PVE::ACME->new(undef, $directory); + my $meta = $acme->get_meta(); + + return $meta; + }}); + __PACKAGE__->register_method ({ name => 'get_directories', path => 'directories', From 7fb70c3b59ad7b4e409d21df4e590a0243da2b16 Mon Sep 17 00:00:00 2001 From: Folke Gleumes Date: Tue, 31 Oct 2023 10:05:14 +0100 Subject: [PATCH 301/398] ui/acme: switch to new meta endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Besides the switch from tos to meta endpoint, this fixes a visual bug, where the 'Accept TOS' button would show up, even if no ToS was needed. Signed-off-by: Folke Gleumes Reviewed-by: Fabian.Grünbichler Tested-by: Fabian.Grünbichler --- www/manager6/node/ACME.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/www/manager6/node/ACME.js b/www/manager6/node/ACME.js index 9f1dabced..21137b1a9 100644 --- a/www/manager6/node/ACME.js +++ b/www/manager6/node/ACME.js @@ -79,15 +79,19 @@ Ext.define('PVE.node.ACMEAccountCreate', { checkbox.setHidden(true); Proxmox.Utils.API2Request({ - url: '/cluster/acme/tos', + url: '/cluster/acme/meta', method: 'GET', params: { directory: value, }, success: function(response, opt) { - field.setValue(response.result.data); - disp.setValue(response.result.data); - checkbox.setHidden(false); + if (response.result.data.termsOfService) { + field.setValue(response.result.data.termsOfService); + disp.setValue(response.result.data.termsOfService); + checkbox.setHidden(false); + } else { + disp.setValue(undefined); + } }, failure: function(response, opt) { Ext.Msg.alert(gettext('Error'), response.htmlStatus); From c8dba92e1b03e7932d040797d6f165d5a9ed402f Mon Sep 17 00:00:00 2001 From: Folke Gleumes Date: Tue, 31 Oct 2023 10:05:13 +0100 Subject: [PATCH 302/398] fix #4497: cli/acme: detect eab and ask for credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since external account binding is advertised the same way as the ToS, it can be detected when creating an account and asked for if needed. Signed-off-by: Folke Gleumes Reviewed-by: Fabian.Grünbichler Tested-by: Fabian.Grünbichler --- PVE/CLI/pvenode.pm | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/PVE/CLI/pvenode.pm b/PVE/CLI/pvenode.pm index acef6c3bf..a6fbb34e4 100644 --- a/PVE/CLI/pvenode.pm +++ b/PVE/CLI/pvenode.pm @@ -83,6 +83,7 @@ __PACKAGE__->register_method({ code => sub { my ($param) = @_; + my $custom_directory = 0; if (!$param->{directory}) { my $directories = PVE::API2::ACMEAccount->get_directories({}); print "Directory endpoints:\n"; @@ -100,6 +101,7 @@ __PACKAGE__->register_method({ $selection = $1; if ($selection == $i) { $param->{directory} = $term->readline("Enter custom URL: "); + $custom_directory = 1; return; } elsif ($selection < $i && $selection >= 0) { $param->{directory} = $directories->[$selection]->{url}; @@ -117,8 +119,9 @@ __PACKAGE__->register_method({ } } print "\nAttempting to fetch Terms of Service from '$param->{directory}'..\n"; - my $tos = PVE::API2::ACMEAccount->get_tos({ directory => $param->{directory} }); - if ($tos) { + my $meta = PVE::API2::ACMEAccount->get_meta({ directory => $param->{directory} }); + if ($meta->{termsOfService}) { + my $tos = $meta->{termsOfService}; print "Terms of Service: $tos\n"; my $term = Term::ReadLine->new('pvenode'); my $agreed = $term->readline('Do you agree to the above terms? [y|N]: '); @@ -129,6 +132,25 @@ __PACKAGE__->register_method({ } else { print "No Terms of Service found, proceeding.\n"; } + + my $eab_enabled = $meta->{externalAccountRequired}; + if (!$eab_enabled && $custom_directory) { + my $term = Term::ReadLine->new('pvenode'); + my $agreed = $term->readline('Do you want to use external account binding? [y|N]: '); + $eab_enabled = ($agreed =~ /^y$/i); + } elsif ($eab_enabled) { + print "The CA requires external account binding.\n"; + } + if ($eab_enabled) { + print "You should have received a key id and a key from your CA.\n"; + my $term = Term::ReadLine->new('pvenode'); + my $eab_kid = $term->readline('Enter EAB key id: '); + my $eab_hmac_key = $term->readline('Enter EAB key: '); + + $param->{'eab-kid'} = $eab_kid; + $param->{'eab-hmac-key'} = $eab_hmac_key; + } + print "\nAttempting to register account with '$param->{directory}'..\n"; $upid_exit->(PVE::API2::ACMEAccount->register_account($param)); From 0231e3043b3df998d7d0b1dbb74836f3c2c0d4a1 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 13 Nov 2023 12:22:59 +0100 Subject: [PATCH 303/398] api: acme: move description to the top Signed-off-by: Thomas Lamprecht --- PVE/API2/ACMEAccount.pm | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/PVE/API2/ACMEAccount.pm b/PVE/API2/ACMEAccount.pm index bc45d5ab1..633b8574b 100644 --- a/PVE/API2/ACMEAccount.pm +++ b/PVE/API2/ACMEAccount.pm @@ -108,8 +108,8 @@ __PACKAGE__->register_method ({ name => get_standard_option('pve-acme-account-name'), contact => get_standard_option('pve-acme-account-contact'), tos_url => { - type => 'string', description => 'URL of CA TermsOfService - setting this indicates agreement.', + type => 'string', optional => 1, }, directory => get_standard_option('pve-acme-directory-url', { @@ -117,14 +117,14 @@ __PACKAGE__->register_method ({ optional => 1, }), 'eab-kid' => { - type => 'string', description => 'Key Identifier for External Account Binding.', + type => 'string', requires => 'eab-hmac-key', optional => 1, }, 'eab-hmac-key' => { - type => 'string', description => 'HMAC key for External Account Binding.', + type => 'string', requires => 'eab-kid', optional => 1, }, @@ -386,24 +386,24 @@ __PACKAGE__->register_method ({ additionalProperties => 1, properties => { termsOfService => { + description => 'ACME TermsOfService URL.', type => 'string', optional => 1, - description => 'ACME TermsOfService URL.', }, externalAccountRequired => { + description => 'EAB Required' type => 'boolean', optional => 1, - description => 'EAB Required' }, website => { + description => 'URL to more information about the ACME server.' type => 'string', optional => 1, - description => 'URL to more information about the ACME server.' }, caaIdentities => { + description => 'Hostnames referring to the ACME servers.' type => 'string', optional => 1, - description => 'Hostnames referring to the ACME servers.' }, }, }, From c0ab227ab4cbc06687513ae0353a12af81fe17aa Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 13 Nov 2023 12:28:34 +0100 Subject: [PATCH 304/398] api: fixup missing trailing commas Signed-off-by: Thomas Lamprecht --- PVE/API2/ACMEAccount.pm | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/PVE/API2/ACMEAccount.pm b/PVE/API2/ACMEAccount.pm index 633b8574b..93820ec4b 100644 --- a/PVE/API2/ACMEAccount.pm +++ b/PVE/API2/ACMEAccount.pm @@ -391,17 +391,17 @@ __PACKAGE__->register_method ({ optional => 1, }, externalAccountRequired => { - description => 'EAB Required' + description => 'EAB Required', type => 'boolean', optional => 1, }, website => { - description => 'URL to more information about the ACME server.' + description => 'URL to more information about the ACME server.', type => 'string', optional => 1, }, caaIdentities => { - description => 'Hostnames referring to the ACME servers.' + description => 'Hostnames referring to the ACME servers.', type => 'string', optional => 1, }, From dab65f73952feebccf193bf4662cddcb850a7cff Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 13 Nov 2023 14:12:33 +0100 Subject: [PATCH 305/398] api: acme meta: require Sys.Audit on the node As even though restricted to some specific endpoints and formats, one can still scan HTTP, potentially also on the LAN. We can do this here as the API call is new and was never packaged since introduced, so this isn't a breaking change. The TOS one will be removed with the next major release, so not a problem anymore from then one. Signed-off-by: Thomas Lamprecht --- PVE/API2/ACMEAccount.pm | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/PVE/API2/ACMEAccount.pm b/PVE/API2/ACMEAccount.pm index 93820ec4b..a0d0d773b 100644 --- a/PVE/API2/ACMEAccount.pm +++ b/PVE/API2/ACMEAccount.pm @@ -371,7 +371,9 @@ __PACKAGE__->register_method ({ path => 'meta', method => 'GET', description => "Retrieve ACME Directory Meta Information", - permissions => { user => 'all' }, + permissions => { + check => ['perm', '/nodes/{node}', [ 'Sys.Audit' ]], + }, parameters => { additionalProperties => 0, properties => { From 5f04abc2c3d08c42392651a5bb47aa7d5f8a7cec Mon Sep 17 00:00:00 2001 From: Hannes Laimer Date: Mon, 13 Nov 2023 11:20:45 +0100 Subject: [PATCH 306/398] api: add suspendall endpoint Signed-off-by: Hannes Laimer --- PVE/API2/Nodes.pm | 124 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index a73fca3f3..0956eb0a1 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -289,6 +289,7 @@ __PACKAGE__->register_method ({ { name => 'stopall' }, { name => 'storage' }, { name => 'subscription' }, + { name => 'suspendall' }, { name => 'syslog' }, { name => 'tasks' }, { name => 'termproxy' }, @@ -2011,6 +2012,129 @@ __PACKAGE__->register_method ({ return $rpcenv->fork_worker('stopall', undef, $authuser, $code); }}); +my $create_suspend_worker = sub { + my ($nodename, $vmid) = @_; + return if !PVE::QemuServer::check_running($vmid, 1); + print STDERR "Suspending VM $vmid\n"; + return PVE::API2::Qemu->vm_suspend( + { node => $nodename, vmid => $vmid, todisk => 1 } + ); +}; + +__PACKAGE__->register_method ({ + name => 'suspendall', + path => 'suspendall', + method => 'POST', + protected => 1, + permissions => { + description => "The 'VM.PowerMgmt' permission is required on '/' or on '/vms/' for each" + ." ID passed via the 'vms' parameter. Additionally, you need 'VM.Config.Disk' on the" + ." '/vms/{vmid}' path and 'Datastore.AllocateSpace' for the configured state-storage(s)", + user => 'all', + }, + proxyto => 'node', + description => "Suspend all VMs.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + vms => { + description => "Only consider Guests with these IDs.", + type => 'string', format => 'pve-vmid-list', + optional => 1, + }, + }, + }, + returns => { + type => 'string', + }, + code => sub { + my ($param) = @_; + + my $rpcenv = PVE::RPCEnvironment::get(); + my $authuser = $rpcenv->get_user(); + + # we cannot really check access to the state-storage here, that's happening per worker. + if (!$rpcenv->check($authuser, "/", [ 'VM.PowerMgmt', 'VM.Config.Disk' ], 1)) { + my @vms = PVE::Tools::split_list($param->{vms}); + if (scalar(@vms) > 0) { + $rpcenv->check($authuser, "/vms/$_", [ 'VM.PowerMgmt' ]) for @vms; + } else { + raise_perm_exc("/, VM.PowerMgmt && VM.Config.Disk"); + } + } + + my $nodename = $param->{node}; + $nodename = PVE::INotify::nodename() if $nodename eq 'localhost'; + + my $code = sub { + + $rpcenv->{type} = 'priv'; # to start tasks in background + + my $stopList = $get_start_stop_list->($nodename, undef, $param->{vms}); + + my $cpuinfo = PVE::ProcFSTools::read_cpuinfo(); + my $datacenterconfig = cfs_read_file('datacenter.cfg'); + # if not set by user spawn max cpu count number of workers + my $maxWorkers = $datacenterconfig->{max_workers} || $cpuinfo->{cpus}; + + for my $order (sort {$b <=> $a} keys %$stopList) { + my $vmlist = $stopList->{$order}; + my $workers = {}; + + my $finish_worker = sub { + my $pid = shift; + my $worker = delete $workers->{$pid} || return; + + syslog('info', "end task $worker->{upid}"); + }; + + for my $vmid (sort {$b <=> $a} keys %$vmlist) { + my $d = $vmlist->{$vmid}; + if ($d->{type} eq 'lxc') { + print STDERR "Skipping $vmid, only VMs can be suspended\n"; + next; + } + my $upid = eval { + $create_suspend_worker->($nodename, $vmid) + }; + warn $@ if $@; + next if !$upid; + + my $task = PVE::Tools::upid_decode($upid, 1); + next if !$task; + + my $pid = $task->{pid}; + + $workers->{$pid} = { type => $d->{type}, upid => $upid, vmid => $vmid }; + while (scalar(keys %$workers) >= $maxWorkers) { + foreach my $p (keys %$workers) { + if (!PVE::ProcFSTools::check_process_running($p)) { + $finish_worker->($p); + } + } + sleep(1); + } + } + while (scalar(keys %$workers)) { + for my $p (keys %$workers) { + if (!PVE::ProcFSTools::check_process_running($p)) { + $finish_worker->($p); + } + } + sleep(1); + } + } + + syslog('info', "all VMs suspended"); + + return; + }; + + return $rpcenv->fork_worker('suspendall', undef, $authuser, $code); + }}); + + my $create_migrate_worker = sub { my ($nodename, $type, $vmid, $target, $with_local_disks) = @_; From 9ed1408b096640f1d50293f222e9dd6c088bbd97 Mon Sep 17 00:00:00 2001 From: Hannes Laimer Date: Mon, 13 Nov 2023 11:20:46 +0100 Subject: [PATCH 307/398] ui: add bulk suspend support Signed-off-by: Hannes Laimer --- www/manager6/Utils.js | 1 + www/manager6/node/CmdMenu.js | 15 +++++++++++++++ www/manager6/node/Config.js | 14 ++++++++++++++ www/manager6/window/BulkAction.js | 5 +++-- 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/www/manager6/Utils.js b/www/manager6/Utils.js index 9b77ebd3e..9f44e5601 100644 --- a/www/manager6/Utils.js +++ b/www/manager6/Utils.js @@ -2000,6 +2000,7 @@ Ext.define('PVE.Utils', { spiceshell: ['', gettext('Shell') + ' (Spice)'], startall: ['', gettext('Bulk start VMs and Containers')], stopall: ['', gettext('Bulk shutdown VMs and Containers')], + suspendall: ['', gettext('Suspend all VMs')], unknownimgdel: ['', gettext('Destroy image from unknown guest')], wipedisk: ['Device', gettext('Wipe Disk')], vncproxy: ['VM/CT', gettext('Console')], diff --git a/www/manager6/node/CmdMenu.js b/www/manager6/node/CmdMenu.js index 1dbcd781a..7bdfebc59 100644 --- a/www/manager6/node/CmdMenu.js +++ b/www/manager6/node/CmdMenu.js @@ -56,6 +56,20 @@ Ext.define('PVE.node.CmdMenu', { }); }, }, + { + text: gettext('Bulk Suspend'), + itemId: 'bulksuspend', + iconCls: 'fa fa-fw fa-download', + handler: function() { + Ext.create('PVE.window.BulkAction', { + nodename: this.up('menu').nodename, + title: gettext('Bulk Suspend'), + btnText: gettext('Suspend'), + action: 'suspendall', + autoShow: true, + }); + }, + }, { text: gettext('Bulk Migrate'), itemId: 'bulkmigrate', @@ -129,6 +143,7 @@ Ext.define('PVE.node.CmdMenu', { if (!caps.vms['VM.PowerMgmt']) { me.getComponent('bulkstart').setDisabled(true); me.getComponent('bulkstop').setDisabled(true); + me.getComponent('bulksuspend').setDisabled(true); } if (!caps.nodes['Sys.PowerMgmt']) { me.getComponent('wakeonlan').setDisabled(true); diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js index 6deafcdf6..6ad48544f 100644 --- a/www/manager6/node/Config.js +++ b/www/manager6/node/Config.js @@ -65,6 +65,20 @@ Ext.define('PVE.node.Config', { }); }, }, + { + text: gettext('Bulk Suspend'), + iconCls: 'fa fa-fw fa-download', + disabled: !caps.vms['VM.PowerMgmt'], + handler: function() { + Ext.create('PVE.window.BulkAction', { + autoShow: true, + nodename: nodename, + title: gettext('Bulk Suspend'), + btnText: gettext('Suspend'), + action: 'suspendall', + }); + }, + }, { text: gettext('Bulk Migrate'), iconCls: 'fa fa-fw fa-send-o', diff --git a/www/manager6/window/BulkAction.js b/www/manager6/window/BulkAction.js index 5f76ef7a4..c8132753e 100644 --- a/www/manager6/window/BulkAction.js +++ b/www/manager6/window/BulkAction.js @@ -10,7 +10,7 @@ Ext.define('PVE.window.BulkAction', { }, border: false, - // the action to set, currently there are: `startall`, `migrateall`, `stopall` + // the action to set, currently there are: `startall`, `migrateall`, `stopall`, `suspendall` action: undefined, submit: function(params) { @@ -144,6 +144,7 @@ Ext.define('PVE.window.BulkAction', { }; let defaultStatus = me.action === 'migrateall' ? '' : me.action === 'startall' ? 'stopped' : 'running'; + let defaultType = me.action === 'suspendall' ? 'qemu' : ''; let statusMap = []; let poolMap = []; @@ -318,7 +319,7 @@ Ext.define('PVE.window.BulkAction', { fieldLabel: gettext("Type"), emptyText: gettext('All'), editable: false, - value: '', + value: defaultType, store: [ ['', gettext('All')], ['lxc', gettext('CT')], From ebb71cb5053c4a663830d77b0937699a715bc617 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 13 Nov 2023 14:04:25 +0100 Subject: [PATCH 308/398] api: bulk suspension: increase log severity to warn when guest is not a VM That way it shows up in the task-log that something was requested that cannot work currently. Signed-off-by: Thomas Lamprecht --- PVE/API2/Nodes.pm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index 0956eb0a1..e1e2c16b5 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -28,6 +28,7 @@ use PVE::LXC; use PVE::ProcFSTools; use PVE::QemuConfig; use PVE::QemuServer; +use PVE::RESTEnvironment qw(log_warn); use PVE::RESTHandler; use PVE::RPCEnvironment; use PVE::RRD; @@ -2091,8 +2092,8 @@ __PACKAGE__->register_method ({ for my $vmid (sort {$b <=> $a} keys %$vmlist) { my $d = $vmlist->{$vmid}; - if ($d->{type} eq 'lxc') { - print STDERR "Skipping $vmid, only VMs can be suspended\n"; + if ($d->{type} ne 'qemu') { + log_warn("skipping $vmid, only VMs can be suspended"); next; } my $upid = eval { From 84e1e9d99648dfea190469b14606fdde54e17c25 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 13 Nov 2023 14:06:01 +0100 Subject: [PATCH 309/398] api: bulk suspension: code clean-ups Signed-off-by: Thomas Lamprecht --- PVE/API2/Nodes.pm | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index e1e2c16b5..39b049d89 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -2061,7 +2061,7 @@ __PACKAGE__->register_method ({ if (scalar(@vms) > 0) { $rpcenv->check($authuser, "/vms/$_", [ 'VM.PowerMgmt' ]) for @vms; } else { - raise_perm_exc("/, VM.PowerMgmt && VM.Config.Disk"); + raise_perm_exc("/, VM.PowerMgmt && VM.Config.Disk"); } } @@ -2072,15 +2072,15 @@ __PACKAGE__->register_method ({ $rpcenv->{type} = 'priv'; # to start tasks in background - my $stopList = $get_start_stop_list->($nodename, undef, $param->{vms}); + my $toSuspendList = $get_start_stop_list->($nodename, undef, $param->{vms}); my $cpuinfo = PVE::ProcFSTools::read_cpuinfo(); my $datacenterconfig = cfs_read_file('datacenter.cfg'); # if not set by user spawn max cpu count number of workers my $maxWorkers = $datacenterconfig->{max_workers} || $cpuinfo->{cpus}; - for my $order (sort {$b <=> $a} keys %$stopList) { - my $vmlist = $stopList->{$order}; + for my $order (sort {$b <=> $a} keys %$toSuspendList) { + my $vmlist = $toSuspendList->{$order}; my $workers = {}; my $finish_worker = sub { @@ -2106,10 +2106,10 @@ __PACKAGE__->register_method ({ next if !$task; my $pid = $task->{pid}; - $workers->{$pid} = { type => $d->{type}, upid => $upid, vmid => $vmid }; + while (scalar(keys %$workers) >= $maxWorkers) { - foreach my $p (keys %$workers) { + for my $p (keys %$workers) { if (!PVE::ProcFSTools::check_process_running($p)) { $finish_worker->($p); } From 25c0052ac8b74b9d848ff1cfb650b8864ae11fe5 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 13 Nov 2023 14:12:16 +0100 Subject: [PATCH 310/398] api: bulk suspension: log if VMs are skipped due to not running Signed-off-by: Thomas Lamprecht --- PVE/API2/Nodes.pm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index 39b049d89..be000d945 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -2015,7 +2015,10 @@ __PACKAGE__->register_method ({ my $create_suspend_worker = sub { my ($nodename, $vmid) = @_; - return if !PVE::QemuServer::check_running($vmid, 1); + if (!PVE::QemuServer::check_running($vmid, 1)) { + print "VM $vmid not running, skipping suspension\n"; + return; + } print STDERR "Suspending VM $vmid\n"; return PVE::API2::Qemu->vm_suspend( { node => $nodename, vmid => $vmid, todisk => 1 } From b4050780a634a5656f8b7e10d0abd1332547cb36 Mon Sep 17 00:00:00 2001 From: Folke Gleumes Date: Mon, 13 Nov 2023 15:11:25 +0100 Subject: [PATCH 311/398] acme: mark caaIdentities as an array caaIdentities was mistakenly labled as a string in a previous patch and not as an array of strings, as it is defined in the rfc [0]. [0] https://datatracker.ietf.org/doc/html/rfc8555#section-7.1.1 Signed-off-by: Folke Gleumes --- PVE/API2/ACMEAccount.pm | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/PVE/API2/ACMEAccount.pm b/PVE/API2/ACMEAccount.pm index a0d0d773b..aac1f59ab 100644 --- a/PVE/API2/ACMEAccount.pm +++ b/PVE/API2/ACMEAccount.pm @@ -404,7 +404,10 @@ __PACKAGE__->register_method ({ }, caaIdentities => { description => 'Hostnames referring to the ACME servers.', - type => 'string', + type => 'array', + items => { + type => 'string', + }, optional => 1, }, }, From 038e94bbbee764fbf70a026f5795263624c98bfb Mon Sep 17 00:00:00 2001 From: Christian Ebner Date: Wed, 9 Aug 2023 12:55:28 +0200 Subject: [PATCH 312/398] fix #4442: Add date-time filtering for firewall logs Extend the current firewall log view to add date time based filtering. The user can switch between live view, which shows logs from the unrotated log file, or to filter mode, where date time based filtering, including rotated logs can be performed. Enable the feature by setting the property and the submit format for since and until timestamps expected by the api. Signed-off-by: Christian Ebner --- www/manager6/node/Config.js | 2 ++ www/manager6/qemu/Config.js | 2 ++ 2 files changed, 4 insertions(+) diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js index 6ad48544f..f1a82e6e4 100644 --- a/www/manager6/node/Config.js +++ b/www/manager6/node/Config.js @@ -412,6 +412,8 @@ Ext.define('PVE.node.Config', { onlineHelp: 'chapter_pve_firewall', url: '/api2/extjs/nodes/' + nodename + '/firewall/log', itemId: 'firewall-fwlog', + log_select_timespan: true, + submitFormat: 'U', }, { xtype: 'cephLogView', diff --git a/www/manager6/qemu/Config.js b/www/manager6/qemu/Config.js index fb0d9cded..2f9605a49 100644 --- a/www/manager6/qemu/Config.js +++ b/www/manager6/qemu/Config.js @@ -390,6 +390,8 @@ Ext.define('PVE.qemu.Config', { itemId: 'firewall-fwlog', xtype: 'proxmoxLogView', url: '/api2/extjs' + base_url + '/firewall/log', + log_select_timespan: true, + submitFormat: 'U', }, ); } From 19823a19c49b847508a1ea0032fa19e1ede6230f Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Fri, 23 Jun 2023 11:45:12 +0200 Subject: [PATCH 313/398] ui: ceph status: add pg warning state Like ceph mgr dashboard, we need a warning state. - set degraded as warning instead working - set undersized as warning instead error - rename error as critical - add "busy" (info-blue) color for working state - use warning (orange) color for warning state Signed-off-by: Alexandre Derumier Tested-By: Aaron Lauterer Reviewed-By: Aaron Lauterer [ TL: fold in CSS class addition ] Signed-off-by: Thomas Lamprecht --- www/css/ext6-pve.css | 4 ++++ www/manager6/ceph/StatusDetail.js | 31 +++++++++++++++++++------------ 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index 782c90442..e18b173f5 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -718,6 +718,10 @@ table.osds td:first-of-type { font-weight: normal; } +.pve-ceph-status-busy { + color: #3892d4; +} + .pmx-opacity-75 { opacity: 0.75; } diff --git a/www/manager6/ceph/StatusDetail.js b/www/manager6/ceph/StatusDetail.js index d6c0763be..68896c821 100644 --- a/www/manager6/ceph/StatusDetail.js +++ b/www/manager6/ceph/StatusDetail.js @@ -94,6 +94,7 @@ Ext.define('PVE.ceph.StatusDetail', { colors: [ '#CFCFCF', '#21BF4B', + '#3892d4', '#FFCC00', '#FF6C59', ], @@ -152,7 +153,6 @@ Ext.define('PVE.ceph.StatusDetail', { backfilling: 2, creating: 2, deep: 2, - degraded: 2, forced_backfill: 2, forced_recovery: 2, peered: 2, @@ -165,17 +165,20 @@ Ext.define('PVE.ceph.StatusDetail', { snaptrim: 2, snaptrim_wait: 2, - // error - backfill_toofull: 3, - backfill_unfound: 3, - down: 3, - incomplete: 3, - inconsistent: 3, - recovery_toofull: 3, - recovery_unfound: 3, - snaptrim_error: 3, - stale: 3, + // warning + degraded: 3, undersized: 3, + + // critical + backfill_toofull: 4, + backfill_unfound: 4, + down: 4, + incomplete: 4, + inconsistent: 4, + recovery_toofull: 4, + recovery_unfound: 4, + snaptrim_error: 4, + stale: 4, }, statecategories: [ @@ -191,10 +194,14 @@ Ext.define('PVE.ceph.StatusDetail', { }, { text: gettext('Working'), + cls: 'pve-ceph-status-busy', + }, + { + text: gettext('Warning'), cls: 'warning', }, { - text: gettext('Error'), + text: gettext('Critical'), cls: 'critical', }, ], From a8d1bc80b5af33c6285ad22dc125b66b7be9550d Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Mon, 13 Nov 2023 16:20:53 +0100 Subject: [PATCH 314/398] ui: ceph status: rename working state into busy Working could be confused with "being ok", which isn't what we want to convey here, as the lack of this status doesn't mean something "isn't working". So use busy, not 100% perfect but a bit closer to what we want to convey while not taking up a whole paragraph or the like. Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/StatusDetail.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/ceph/StatusDetail.js b/www/manager6/ceph/StatusDetail.js index 68896c821..2196f1db5 100644 --- a/www/manager6/ceph/StatusDetail.js +++ b/www/manager6/ceph/StatusDetail.js @@ -147,7 +147,7 @@ Ext.define('PVE.ceph.StatusDetail', { clean: 1, active: 1, - // working + // busy activating: 2, backfill_wait: 2, backfilling: 2, @@ -193,7 +193,7 @@ Ext.define('PVE.ceph.StatusDetail', { cls: 'good', }, { - text: gettext('Working'), + text: gettext('Busy'), cls: 'pve-ceph-status-busy', }, { From e337b2948bc1d62cfee6e8ca39461b7715154580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Tue, 4 Jul 2023 11:45:07 +0200 Subject: [PATCH 315/398] apt: use `apt changelog` for changelog fetching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit support for it got added to Proxmox repositories, so there is no need to use custom logic and manual fetching for this anymore. Signed-off-by: Fabian Grünbichler --- PVE/API2/APT.pm | 101 ++++++++---------------------------------------- 1 file changed, 16 insertions(+), 85 deletions(-) diff --git a/PVE/API2/APT.pm b/PVE/API2/APT.pm index a213fc59d..60884cb13 100644 --- a/PVE/API2/APT.pm +++ b/PVE/API2/APT.pm @@ -89,38 +89,6 @@ my $get_pkgfile = sub { return undef; }; -my $get_changelog_url =sub { - my ($pkgname, $info, $pkgver, $pkgfile) = @_; - - my $base; - $base = dirname($info->{FileName}) if defined($info->{FileName}); - - my $origin = $pkgfile->{Origin}; - - if ($origin && $base) { - $pkgver =~ s/^\d+://; # strip epoch - my $srcpkg = $info->{SourcePkg} || $pkgname; - if ($origin eq 'Debian' || $origin eq 'Debian Backports') { - $base =~ s!pool/updates/!pool/!; # for security channel - return "http://packages.debian.org/changelogs/$base/${srcpkg}_${pkgver}/changelog"; - } elsif ($origin eq 'Proxmox') { - # the product is just for getting the standard repos and is currently a required param, - # but we actually only care about `files`, which includes _all_ configured repos - my $data = Proxmox::RS::APT::Repositories::repositories("pve"); - - for my $file ($data->{files}->@*) { - for my $repo (grep { $_->{Enabled} } $file->{repositories}->@*) { - next if !grep(/$pkgfile->{Component}/, $repo->{Components}->@*); - next if !$repo->{URIs}[0] =~ m/$pkgfile->{Site}/; - - return $repo->{URIs}[0] . "/$base/${pkgname}_${pkgver}.changelog"; - } - } - } - } - return; # none found, with our heuristic that is.. -}; - my $assemble_pkginfo = sub { my ($pkgname, $info, $current_ver, $candidate_ver) = @_; @@ -132,9 +100,6 @@ my $assemble_pkginfo = sub { if (my $pkgfile = &$get_pkgfile($candidate_ver)) { $data->{Origin} = $pkgfile->{Origin}; - my $changelog_url = $get_changelog_url->($pkgname, $info, $candidate_ver->{VerStr}, $pkgfile); - - $data->{ChangeLogUrl} = $changelog_url if $changelog_url; } if (my $desc = $info->{LongDesc}) { @@ -451,62 +416,28 @@ __PACKAGE__->register_method({ my $pkgname = $param->{name}; - my $cache = &$get_apt_cache(); - my $policy = $cache->policy; - my $p = $cache->{$pkgname} || die "no such package '$pkgname'\n"; - my $pkgrecords = $cache->packages(); - - my $ver; - if ($param->{version}) { - if (my $available = $p->{VersionList}) { - for my $v (@$available) { - if ($v->{VerStr} eq $param->{version}) { - $ver = $v; - last; - } - } - } - die "package '$pkgname' version '$param->{version}' is not available\n" if !$ver; + my $cmd = ['apt-get', 'changelog', '-qq']; + if (my $version = $param->{version}) { + push @$cmd, "$pkgname=$version"; } else { - $ver = $policy->candidate($p) || die "no installation candidate for package '$pkgname'\n"; + push @$cmd, "$pkgname"; } - my $info = $pkgrecords->lookup($pkgname); + my $output = ""; - my $pkgfile = $get_pkgfile->($ver) or die "couldn't find package info file for ${pkgname}=$ver->{VerStr}\n"; + my $rc = PVE::Tools::run_command( + $cmd, + timeout => 10, + logfunc => sub { + my $line = shift; + $output .= "$line\n"; + }, + noerr => 1, + ); - my $url = $get_changelog_url->($pkgname, $info, $ver->{VerStr}, $pkgfile) - or die "changelog for '${pkgname}_$ver->{VerStr}' not available\n"; + $output .= "RC: $rc" if $rc != 0; - my $ua = LWP::UserAgent->new(); - $ua->agent("PVE/1.0"); - $ua->timeout(10); - $ua->max_size(1024 * 1024); - $ua->ssl_opts(verify_hostname => 0); # don't care for changelogs - - my $datacenter_cfg = PVE::Cluster::cfs_read_file('datacenter.cfg'); - if (my $proxy = $datacenter_cfg->{http_proxy}) { - $ua->proxy(['http', 'https'], $proxy); - } else { - $ua->env_proxy; - } - - if ($pkgfile->{Origin} eq 'Proxmox' && $pkgfile->{Component} eq 'pve-enterprise') { - my $info = PVE::API2::Subscription::read_etc_subscription(); - if ($info->{status} eq 'active') { - my $pw = PVE::API2Tools::get_hwaddress(); - $ua->credentials("enterprise.proxmox.com:443", 'pve-enterprise-repository', $info->{key}, $pw); - } - } - - my $response = $ua->get($url); - - if ($response->is_success) { - return $response->decoded_content; - } else { - PVE::Exception::raise($response->message, code => $response->code); - } - return ''; + return $output; }}); __PACKAGE__->register_method({ From 6cfe65ff2e61d161d9e415f1760f9fcc8e3624a5 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 19 Jul 2023 14:11:10 +0200 Subject: [PATCH 316/398] ui: ipset: make ip/cidr required it is in the backend, so make it required in the gui too Signed-off-by: Dominik Csapak --- www/manager6/panel/IPSet.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/panel/IPSet.js b/www/manager6/panel/IPSet.js index c449cdaa0..d42d062d4 100644 --- a/www/manager6/panel/IPSet.js +++ b/www/manager6/panel/IPSet.js @@ -203,6 +203,7 @@ Ext.define('PVE.IPSetCidrEdit', { editable: true, base_url: me.list_refs_url, value: '', + allowBlank: false, fieldLabel: gettext('IP/CIDR'), }); } else { From 574a6da6b9449ccb3e00f8916f3e3d9f924ac429 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 19 Jul 2023 14:11:11 +0200 Subject: [PATCH 317/398] ui: don't set the default value of combogrids to '' the combogrid does that itself already Signed-off-by: Dominik Csapak --- www/manager6/grid/FirewallRules.js | 2 -- www/manager6/panel/IPSet.js | 1 - 2 files changed, 3 deletions(-) diff --git a/www/manager6/grid/FirewallRules.js b/www/manager6/grid/FirewallRules.js index 18075eaa6..11881bf79 100644 --- a/www/manager6/grid/FirewallRules.js +++ b/www/manager6/grid/FirewallRules.js @@ -234,7 +234,6 @@ Ext.define('PVE.FirewallRulePanel', { autoSelect: false, editable: true, base_url: me.list_refs_url, - value: '', fieldLabel: gettext('Source'), maxLength: 512, maxLengthText: gettext('Too long, consider using IP sets.'), @@ -245,7 +244,6 @@ Ext.define('PVE.FirewallRulePanel', { autoSelect: false, editable: true, base_url: me.list_refs_url, - value: '', fieldLabel: gettext('Destination'), maxLength: 512, maxLengthText: gettext('Too long, consider using IP sets.'), diff --git a/www/manager6/panel/IPSet.js b/www/manager6/panel/IPSet.js index d42d062d4..d96ed18a6 100644 --- a/www/manager6/panel/IPSet.js +++ b/www/manager6/panel/IPSet.js @@ -202,7 +202,6 @@ Ext.define('PVE.IPSetCidrEdit', { autoSelect: false, editable: true, base_url: me.list_refs_url, - value: '', allowBlank: false, fieldLabel: gettext('IP/CIDR'), }); From 108a99ef1570cd8e5464f35cdc319c4957522037 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Wed, 19 Jul 2023 14:11:12 +0200 Subject: [PATCH 318/398] ui: don't set the default value of combogrids to [] the combogrid sets the default itself correctly Signed-off-by: Dominik Csapak --- www/manager6/form/NodeSelector.js | 5 +---- www/manager6/ha/GroupSelector.js | 1 - 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/www/manager6/form/NodeSelector.js b/www/manager6/form/NodeSelector.js index c3e3da315..bc2822872 100644 --- a/www/manager6/form/NodeSelector.js +++ b/www/manager6/form/NodeSelector.js @@ -12,10 +12,7 @@ Ext.define('PVE.form.NodeSelector', { // only allow those nodes (array) allowedNodes: undefined, - // set default value to empty array, else it inits it with - // null and after the store load it is an empty array, - // triggering dirtychange - value: [], + valueField: 'node', displayField: 'node', store: { diff --git a/www/manager6/ha/GroupSelector.js b/www/manager6/ha/GroupSelector.js index 61ab0c03b..1823472fe 100644 --- a/www/manager6/ha/GroupSelector.js +++ b/www/manager6/ha/GroupSelector.js @@ -2,7 +2,6 @@ Ext.define('PVE.ha.GroupSelector', { extend: 'Proxmox.form.ComboGrid', alias: ['widget.pveHAGroupSelector'], - value: [], autoSelect: false, valueField: 'group', displayField: 'group', From 51fcf81434554a8e9783883b2a306e853670a8f6 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 14 Nov 2023 14:57:13 +0100 Subject: [PATCH 319/398] d/control: bump versioned dependencies for proxmox-widget-toolkit to 4.1.0 to ensure new combo grid default value and firewall log (date time range stuff) is available Signed-off-by: Thomas Lamprecht --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index d70bb0d5c..33aaac72b 100644 --- a/debian/control +++ b/debian/control @@ -81,7 +81,7 @@ Depends: apt (>= 1.5~), postfix | mail-transport-agent, proxmox-mail-forward, proxmox-mini-journalreader (>= 1.3-1), - proxmox-widget-toolkit (>= 4.0.7), + proxmox-widget-toolkit (>= 4.1.0), pve-cluster (>= 7.0-4), pve-container (>= 5.0.5), pve-docs (>= 8.0~~), From 5caa663f3ebf00c3ab7e3135761d1bb17ae23227 Mon Sep 17 00:00:00 2001 From: Alexandre Derumier Date: Sun, 17 Sep 2023 15:44:44 +0200 Subject: [PATCH 320/398] ship default link config to disable systemd link mac-policy since debian 11, systemd is changing behaviour of MAC address of bridge, but also bond, where the mac is generated randomly instead inherit from the first slave. We tried to fix that with ifupdown2, but that seems to produce some regressions and independent of that there was still another problem. Namely, if a bridge don't have any slaves, systemd is keeping bridge offline. https://www.justinsteven.com/posts/2023/03/26/virtualbox-bridge-ports-none-no-carrier-debian-11/ That mean that a dhcp daemon like kea can't bind on a standalone bridge (used for s-nat for example), until a tap interface is started. So, set up a systemd link config to disable the systemd mac policy by default (this don't break already fixed ifupdown2 mac). Funnily CentOS && Fedora also disable it already: https://fedoraproject.org/wiki/Changes/MAC_Address_Policy_none https://gitlab.com/redhat/centos-stream/rpms/systemd/-/blob/c8953519504bf2e694bfbc2b02a456c1056f252e/0028-udev-net-setup-link-change-the-default-MACAddressPol.patch#L43 Before this patch: ``` ~ ip a sh dev vmbr1 vmbr1: mtu 1500 qdisc noqueue state DOWN group default qlen 10 ``` After this patch: ``` ~ ip a sh dev vmbr1 vmbr1: mtu 1500 qdisc noqueue state UNKNOWN group default qlen 1000 ``` Signed-off-by: Alexandre Derumier [ TL: move to /usr/lib/.. where distro files belong and add comment ] Signed-off-by: Thomas Lamprecht --- configs/Makefile | 1 + configs/proxmox-ve-default.link | 11 +++++++++++ 2 files changed, 12 insertions(+) create mode 100644 configs/proxmox-ve-default.link diff --git a/configs/Makefile b/configs/Makefile index fd446b5bc..8cdb11e32 100644 --- a/configs/Makefile +++ b/configs/Makefile @@ -13,6 +13,7 @@ install: country.dat vzdump.conf pve-sources.list pve-initramfs.conf pve-blackli install -D -m 0644 vzdump.conf $(DESTDIR)/etc/vzdump.conf install -D -m 0644 pve-initramfs.conf $(DESTDIR)/etc/initramfs-tools/conf.d/pve-initramfs.conf install -D -m 0644 country.dat $(DESTDIR)/usr/share/$(PACKAGE)/country.dat + install -D -m 0644 proxmox-ve-default.link $(DESTDIR)/usr/lib/systemd/network/98-proxmox-ve-default.link clean: rm -f country.dat diff --git a/configs/proxmox-ve-default.link b/configs/proxmox-ve-default.link new file mode 100644 index 000000000..63953020e --- /dev/null +++ b/configs/proxmox-ve-default.link @@ -0,0 +1,11 @@ +[Match] +OriginalName=* + +[Link] +# Fixes two issues for Proxmox VE systems: +# 1. inheriting MAC from the first slave, instead of using a random one, avoids +# that locked down network environments (e.g., at most hosting providers) +# will block traffic due to a unexpected MAC in the outgoing network packets +# 2. Avoids that systemd keeps bridge offline if there are no slaves connected, +# failing, e.g., setting up s-NAT if no guest is (yet) started. +MACAddressPolicy=none From 38fa08d07401d2b51a97ed0377db339cfbafb5d7 Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Wed, 8 Nov 2023 13:10:34 +0100 Subject: [PATCH 321/398] api: osd: destroy: remove mclock max iops settings Ceph does a quick benchmark when creating a new OSD and stores the osd_mclock_max_capacity_iops_{ssd,hdd} settings in the config DB. When destroying the OSD, Ceph does not automatically remove these settings. Keeping them can be problematic if a new OSD with potentially more performance is added and ends up getting the same OSD ID. Therefore, we remove these settings ourselves when destroying an OSD. Removing both variants, hdd and ssd should be fine, as the MON does not complain if the setting does not exist. Signed-off-by: Aaron Lauterer Tested-by: Maximiliano Sandoval --- PVE/API2/Ceph/OSD.pm | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/PVE/API2/Ceph/OSD.pm b/PVE/API2/Ceph/OSD.pm index 0c07e7cee..2893456af 100644 --- a/PVE/API2/Ceph/OSD.pm +++ b/PVE/API2/Ceph/OSD.pm @@ -985,6 +985,10 @@ __PACKAGE__->register_method ({ print "Remove OSD $osdsection\n"; $rados->mon_command({ prefix => "osd rm", ids => [ $osdsection ], format => 'plain' }); + print "Remove $osdsection mclock max capacity iops settings from config\n"; + $rados->mon_command({ prefix => "config rm", who => $osdsection, name => 'osd_mclock_max_capacity_iops_ssd' }); + $rados->mon_command({ prefix => "config rm", who => $osdsection, name => 'osd_mclock_max_capacity_iops_hdd' }); + # try to unmount from standard mount point my $mountpoint = "/var/lib/ceph/osd/ceph-$osdid"; From ec0610acb2f5155a1c2b6e43da1af11a42617388 Mon Sep 17 00:00:00 2001 From: Stefan Lendl Date: Fri, 17 Nov 2023 15:26:13 +0100 Subject: [PATCH 322/398] gitignore: add more build artefacts to ignore list and anchor to root Signed-off-by: Stefan Lendl [ TL: fix subject & use more specific glob ] Signed-off-by: Thomas Lamprecht --- .gitignore | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index a6ab4ea78..fd293e1c0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,8 @@ dest/ *.buildinfo *.changes -www/manager6/OnlineHelpInfo.js -www/manager6/pvemanagerlib.js -www/mobile/pvemanager-mobile.js -www/touch/touch-2.4.2/ \ No newline at end of file +/www/manager6/OnlineHelpInfo.js +/www/manager6/pvemanagerlib.js +/www/mobile/pvemanager-mobile.js +/www/touch/touch-*/ +/pve-manager-[0-9]*/ From 6ea1b6da7c5d20ebab7cc2736d37a8ecc32fb517 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 17 Nov 2023 16:02:06 +0100 Subject: [PATCH 323/398] gitignore: also make glob for sencha touch build more specific Signed-off-by: Thomas Lamprecht --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fd293e1c0..e8d1eb27f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,5 @@ dest/ /www/manager6/OnlineHelpInfo.js /www/manager6/pvemanagerlib.js /www/mobile/pvemanager-mobile.js -/www/touch/touch-*/ +/www/touch/touch-[0-9]*/ /pve-manager-[0-9]*/ From 96aee647a8d216e0e0a43597ba820cc5490f200c Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Fri, 17 Nov 2023 15:58:05 +0100 Subject: [PATCH 324/398] pvesh: proxy handler: fix handling array parameters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As reported in the community forum and reproduced locally, issuing a QEMU guest agent command would lead to an error when proxying to another node: > root@pve8a2 ~ # pvesh create /nodes/pve8a1/qemu/126/agent/exec --command 'whoami' > Wide character in die at /usr/share/perl5/PVE/RESTHandler.pm line 918. > proxy handler failed: Agent error: Guest agent command failed, error was 'Failed to execute child process “ARRAY(0x55842bb161a0)” (No such file or directory)' Fix it, by splitting up array references correctly. [0]: https://forum.proxmox.com/threads/136520/ Signed-off-by: Fiona Ebner --- PVE/CLI/pvesh.pm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/PVE/CLI/pvesh.pm b/PVE/CLI/pvesh.pm index 730e09af1..44a65213c 100755 --- a/PVE/CLI/pvesh.pm +++ b/PVE/CLI/pvesh.pm @@ -109,7 +109,11 @@ sub proxy_handler { my $args = []; foreach my $key (keys %$param) { next if $key eq 'quiet' || $key eq 'output-format'; # just to be sure - push @$args, "--$key", $_ for split(/\0/, $param->{$key}); + if (ref($param->{$key}) eq 'ARRAY') { + push @$args, "--$key", $_ for $param->{$key}->@*; + } else { + push @$args, "--$key", $_ for split(/\0/, $param->{$key}); + } } my @ssh_tunnel_cmd = ('ssh', '-o', 'BatchMode=yes', "root\@$remip"); From 97a6a189cd7db9e1731dea340ea088ebaf3c83a3 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:32 +0100 Subject: [PATCH 325/398] api: notification: remove notification groups Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 267 +----------------------------- 1 file changed, 4 insertions(+), 263 deletions(-) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index ec6669034..b34802c83 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -121,7 +121,6 @@ __PACKAGE__->register_method ({ my $result = [ { name => 'endpoints' }, { name => 'filters' }, - { name => 'groups' }, { name => 'targets' }, ]; @@ -161,8 +160,7 @@ __PACKAGE__->register_method ({ name => 'get_all_targets', path => 'targets', method => 'GET', - description => 'Returns a list of all entities that can be used as notification targets' . - ' (endpoints and groups).', + description => 'Returns a list of all entities that can be used as notification targets.', permissions => { description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" . " 'Mapping.Audit' permissions on '/mapping/notification/'." @@ -180,14 +178,14 @@ __PACKAGE__->register_method ({ type => 'object', properties => { name => { - description => 'Name of the endpoint/group.', + description => 'Name of the target.', type => 'string', format => 'pve-configid', }, 'type' => { - description => 'Type of the endpoint or group.', + description => 'Type of the target.', type => 'string', - enum => [qw(sendmail gotify group)], + enum => [qw(sendmail gotify)], }, 'comment' => { description => 'Comment', @@ -221,14 +219,6 @@ __PACKAGE__->register_method ({ }; } - for my $target (@{$config->get_groups()}) { - push @$result, { - name => $target->{name}, - comment => $target->{comment}, - type => 'group', - }; - } - $result }; @@ -290,255 +280,6 @@ __PACKAGE__->register_method ({ } }); -my $group_properties = { - name => { - description => 'Name of the group.', - type => 'string', - format => 'pve-configid', - }, - 'endpoint' => { - type => 'array', - items => { - type => 'string', - format => 'pve-configid', - }, - description => 'List of included endpoints', - }, - 'comment' => { - description => 'Comment', - type => 'string', - optional => 1, - }, - filter => { - description => 'Name of the filter that should be applied.', - type => 'string', - format => 'pve-configid', - optional => 1, - }, -}; - -__PACKAGE__->register_method ({ - name => 'get_groups', - path => 'groups', - method => 'GET', - description => 'Returns a list of all groups', - protected => 1, - permissions => { - description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" - . " 'Mapping.Audit' permissions on '/mapping/notification/'.", - user => 'all', - }, - parameters => { - additionalProperties => 0, - properties => {}, - }, - returns => { - type => 'array', - items => { - type => 'object', - properties => $group_properties, - }, - links => [ { rel => 'child', href => '{name}' } ], - }, - code => sub { - my $config = PVE::Notify::read_config(); - my $rpcenv = PVE::RPCEnvironment::get(); - - my $entities = eval { - $config->get_groups(); - }; - raise_api_error($@) if $@; - - return filter_entities_by_privs($rpcenv, $entities); - } -}); - -__PACKAGE__->register_method ({ - name => 'get_group', - path => 'groups/{name}', - method => 'GET', - description => 'Return a specific group', - protected => 1, - permissions => { - check => ['or', - ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], - ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], - ], - }, - parameters => { - additionalProperties => 0, - properties => { - name => { - type => 'string', - format => 'pve-configid', - }, - } - }, - returns => { - type => 'object', - properties => { - %$group_properties, - digest => get_standard_option('pve-config-digest'), - }, - }, - code => sub { - my ($param) = @_; - my $name = extract_param($param, 'name'); - - my $config = PVE::Notify::read_config(); - - my $group = eval { - $config->get_group($name) - }; - - raise_api_error($@) if $@; - $group->{digest} = $config->digest(); - - return $group; - } -}); - -__PACKAGE__->register_method ({ - name => 'create_group', - path => 'groups', - protected => 1, - method => 'POST', - description => 'Create a new group', - permissions => { - check => ['perm', '/mapping/notification', ['Mapping.Modify']], - }, - parameters => { - additionalProperties => 0, - properties => $group_properties, - }, - returns => { type => 'null' }, - code => sub { - my ($param) = @_; - - my $name = extract_param($param, 'name'); - my $endpoint = extract_param($param, 'endpoint'); - my $comment = extract_param($param, 'comment'); - my $filter = extract_param($param, 'filter'); - - eval { - PVE::Notify::lock_config(sub { - my $config = PVE::Notify::read_config(); - - $config->add_group( - $name, - $endpoint, - $comment, - $filter, - ); - - PVE::Notify::write_config($config); - }); - }; - - raise_api_error($@) if $@; - return; - } -}); - -__PACKAGE__->register_method ({ - name => 'update_group', - path => 'groups/{name}', - protected => 1, - method => 'PUT', - description => 'Update existing group', - permissions => { - check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], - }, - parameters => { - additionalProperties => 0, - properties => { - %{ make_properties_optional($group_properties) }, - delete => { - type => 'array', - items => { - type => 'string', - format => 'pve-configid', - }, - optional => 1, - description => 'A list of settings you want to delete.', - }, - digest => get_standard_option('pve-config-digest'), - }, - }, - returns => { type => 'null' }, - code => sub { - my ($param) = @_; - - my $name = extract_param($param, 'name'); - my $endpoint = extract_param($param, 'endpoint'); - my $comment = extract_param($param, 'comment'); - my $filter = extract_param($param, 'filter'); - my $digest = extract_param($param, 'digest'); - my $delete = extract_param($param, 'delete'); - - eval { - PVE::Notify::lock_config(sub { - my $config = PVE::Notify::read_config(); - - $config->update_group( - $name, - $endpoint, - $comment, - $filter, - $delete, - $digest, - ); - - PVE::Notify::write_config($config); - }); - }; - - raise_api_error($@) if $@; - return; - } -}); - -__PACKAGE__->register_method ({ - name => 'delete_group', - protected => 1, - path => 'groups/{name}', - method => 'DELETE', - description => 'Remove group', - permissions => { - check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], - }, - parameters => { - additionalProperties => 0, - properties => { - name => { - type => 'string', - format => 'pve-configid', - }, - } - }, - returns => { type => 'null' }, - code => sub { - my ($param) = @_; - my $name = extract_param($param, 'name'); - - my $used_by = target_used_by($name); - if ($used_by) { - raise_param_exc({'name' => "Cannot remove $name, used by: $used_by"}); - } - - eval { - PVE::Notify::lock_config(sub { - my $config = PVE::Notify::read_config(); - $config->delete_group($name); - PVE::Notify::write_config($config); - }); - }; - - raise_api_error($@) if $@; - return; - } -}); - my $sendmail_properties = { name => { description => 'The name of the endpoint.', From 46499a47b4f0929f67b92ba90cd3d67393293e5e Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:33 +0100 Subject: [PATCH 326/398] api: notification: add new matcher-based notification API This renames filters -> matchers and adds new configuration options needed by matchers (e.g. match-field, match-calendar, etc.) Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 195 ++++++++++++++---------------- 1 file changed, 88 insertions(+), 107 deletions(-) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index b34802c83..8f716f264 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -68,37 +68,12 @@ sub filter_entities_by_privs { "/mapping/notification/$_->{name}", $can_see_mapping_privs, 1 - ) || $_->{name} eq PVE::Notify::default_target(); + ); } @$entities]; return $filtered; } -sub target_used_by { - my ($target) = @_; - - my $used_by = []; - - # Check keys in datacenter.cfg - my $dc_conf = PVE::Cluster::cfs_read_file('datacenter.cfg'); - for my $key (qw(target-package-updates target-replication target-fencing)) { - if ($dc_conf->{notify} && $dc_conf->{notify}->{$key} eq $target) { - push @$used_by, $key; - } - } - - # Check backup jobs - my $jobs_conf = PVE::Cluster::cfs_read_file('jobs.cfg'); - for my $key (keys %{$jobs_conf->{ids}}) { - my $job = $jobs_conf->{ids}->{$key}; - if ($job->{'notification-target'} eq $target) { - push @$used_by, $key; - } - } - - return join(', ', @$used_by); -} - __PACKAGE__->register_method ({ name => 'index', path => '', @@ -120,7 +95,7 @@ __PACKAGE__->register_method ({ code => sub { my $result = [ { name => 'endpoints' }, - { name => 'filters' }, + { name => 'matchers' }, { name => 'targets' }, ]; @@ -259,15 +234,11 @@ __PACKAGE__->register_method ({ my $privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; - if ($name ne PVE::Notify::default_target()) { - # Due to backwards compatibility reasons the 'mail-to-root' - # target must be accessible for any user - $rpcenv->check_any( - $authuser, - "/mapping/notification/$name", - $privs, - ); - } + $rpcenv->check_any( + $authuser, + "/mapping/notification/$name", + $privs, + ); eval { my $config = PVE::Notify::read_config(); @@ -319,12 +290,6 @@ my $sendmail_properties = { type => 'string', optional => 1, }, - filter => { - description => 'Name of the filter that should be applied.', - type => 'string', - format => 'pve-configid', - optional => 1, - }, }; __PACKAGE__->register_method ({ @@ -431,7 +396,6 @@ __PACKAGE__->register_method ({ my $from_address = extract_param($param, 'from-address'); my $author = extract_param($param, 'author'); my $comment = extract_param($param, 'comment'); - my $filter = extract_param($param, 'filter'); eval { PVE::Notify::lock_config(sub { @@ -444,7 +408,6 @@ __PACKAGE__->register_method ({ $from_address, $author, $comment, - $filter ); PVE::Notify::write_config($config); @@ -492,7 +455,6 @@ __PACKAGE__->register_method ({ my $from_address = extract_param($param, 'from-address'); my $author = extract_param($param, 'author'); my $comment = extract_param($param, 'comment'); - my $filter = extract_param($param, 'filter'); my $delete = extract_param($param, 'delete'); my $digest = extract_param($param, 'digest'); @@ -508,7 +470,6 @@ __PACKAGE__->register_method ({ $from_address, $author, $comment, - $filter, $delete, $digest, ); @@ -545,11 +506,6 @@ __PACKAGE__->register_method ({ my ($param) = @_; my $name = extract_param($param, 'name'); - my $used_by = target_used_by($name); - if ($used_by) { - raise_param_exc({'name' => "Cannot remove $name, used by: $used_by"}); - } - eval { PVE::Notify::lock_config(sub { my $config = PVE::Notify::read_config(); @@ -582,12 +538,6 @@ my $gotify_properties = { type => 'string', optional => 1, }, - 'filter' => { - description => 'Name of the filter that should be applied.', - type => 'string', - format => 'pve-configid', - optional => 1, - } }; __PACKAGE__->register_method ({ @@ -692,7 +642,6 @@ __PACKAGE__->register_method ({ my $server = extract_param($param, 'server'); my $token = extract_param($param, 'token'); my $comment = extract_param($param, 'comment'); - my $filter = extract_param($param, 'filter'); eval { PVE::Notify::lock_config(sub { @@ -703,7 +652,6 @@ __PACKAGE__->register_method ({ $server, $token, $comment, - $filter ); PVE::Notify::write_config($config); @@ -748,7 +696,6 @@ __PACKAGE__->register_method ({ my $server = extract_param($param, 'server'); my $token = extract_param($param, 'token'); my $comment = extract_param($param, 'comment'); - my $filter = extract_param($param, 'filter'); my $delete = extract_param($param, 'delete'); my $digest = extract_param($param, 'digest'); @@ -762,7 +709,6 @@ __PACKAGE__->register_method ({ $server, $token, $comment, - $filter, $delete, $digest, ); @@ -799,11 +745,6 @@ __PACKAGE__->register_method ({ my ($param) = @_; my $name = extract_param($param, 'name'); - my $used_by = target_used_by($name); - if ($used_by) { - raise_param_exc({'name' => "Cannot remove $name, used by: $used_by"}); - } - eval { PVE::Notify::lock_config(sub { my $config = PVE::Notify::read_config(); @@ -817,28 +758,56 @@ __PACKAGE__->register_method ({ } }); -my $filter_properties = { +my $matcher_properties = { name => { - description => 'Name of the endpoint.', + description => 'Name of the matcher.', type => 'string', format => 'pve-configid', }, - 'min-severity' => { - type => 'string', - description => 'Minimum severity to match', + 'match-field' => { + type => 'array', + items => { + type => 'string', + }, optional => 1, - enum => [qw(info notice warning error)], + description => 'Metadata fields to match (regex or exact match).' + . ' Must be in the form (regex|exact):=', + }, + 'match-severity' => { + type => 'array', + items => { + type => 'string', + }, + optional => 1, + description => 'Notification severities to match', + }, + 'match-calendar' => { + type => 'array', + items => { + type => 'string', + }, + optional => 1, + description => 'Match notification timestamp', + }, + 'target' => { + type => 'array', + items => { + type => 'string', + format => 'pve-configid', + }, + optional => 1, + description => 'Targets to notify on match', }, mode => { type => 'string', - description => "Choose between 'and' and 'or' for when multiple properties are specified", + description => "Choose between 'all' and 'any' for when multiple properties are specified", optional => 1, - enum => [qw(and or)], - default => 'and', + enum => [qw(all any)], + default => 'all', }, 'invert-match' => { type => 'boolean', - description => 'Invert match of the whole filter', + description => 'Invert match of the whole matcher', optional => 1, }, 'comment' => { @@ -849,10 +818,10 @@ my $filter_properties = { }; __PACKAGE__->register_method ({ - name => 'get_filters', - path => 'filters', + name => 'get_matchers', + path => 'matchers', method => 'GET', - description => 'Returns a list of all filters', + description => 'Returns a list of all matchers', protected => 1, permissions => { description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" @@ -867,7 +836,7 @@ __PACKAGE__->register_method ({ type => 'array', items => { type => 'object', - properties => $filter_properties, + properties => $matcher_properties, }, links => [ { rel => 'child', href => '{name}' } ], }, @@ -876,7 +845,7 @@ __PACKAGE__->register_method ({ my $rpcenv = PVE::RPCEnvironment::get(); my $entities = eval { - $config->get_filters(); + $config->get_matchers(); }; raise_api_error($@) if $@; @@ -885,10 +854,10 @@ __PACKAGE__->register_method ({ }); __PACKAGE__->register_method ({ - name => 'get_filter', - path => 'filters/{name}', + name => 'get_matcher', + path => 'matchers/{name}', method => 'GET', - description => 'Return a specific filter', + description => 'Return a specific matcher', protected => 1, permissions => { check => ['or', @@ -908,7 +877,7 @@ __PACKAGE__->register_method ({ returns => { type => 'object', properties => { - %$filter_properties, + %$matcher_properties, digest => get_standard_option('pve-config-digest'), }, }, @@ -918,37 +887,40 @@ __PACKAGE__->register_method ({ my $config = PVE::Notify::read_config(); - my $filter = eval { - $config->get_filter($name) + my $matcher = eval { + $config->get_matcher($name) }; raise_api_error($@) if $@; - $filter->{digest} = $config->digest(); + $matcher->{digest} = $config->digest(); - return $filter; + return $matcher; } }); __PACKAGE__->register_method ({ - name => 'create_filter', - path => 'filters', + name => 'create_matcher', + path => 'matchers', protected => 1, method => 'POST', - description => 'Create a new filter', + description => 'Create a new matcher', protected => 1, permissions => { check => ['perm', '/mapping/notification', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, - properties => $filter_properties, + properties => $matcher_properties, }, returns => { type => 'null' }, code => sub { my ($param) = @_; my $name = extract_param($param, 'name'); - my $min_severity = extract_param($param, 'min-severity'); + my $match_severity = extract_param($param, 'match-severity'); + my $match_field = extract_param($param, 'match-field'); + my $match_calendar = extract_param($param, 'match-calendar'); + my $target = extract_param($param, 'target'); my $mode = extract_param($param, 'mode'); my $invert_match = extract_param($param, 'invert-match'); my $comment = extract_param($param, 'comment'); @@ -957,9 +929,12 @@ __PACKAGE__->register_method ({ PVE::Notify::lock_config(sub { my $config = PVE::Notify::read_config(); - $config->add_filter( + $config->add_matcher( $name, - $min_severity, + $target, + $match_severity, + $match_field, + $match_calendar, $mode, $invert_match, $comment, @@ -975,18 +950,18 @@ __PACKAGE__->register_method ({ }); __PACKAGE__->register_method ({ - name => 'update_filter', - path => 'filters/{name}', + name => 'update_matcher', + path => 'matchers/{name}', protected => 1, method => 'PUT', - description => 'Update existing filter', + description => 'Update existing matcher', permissions => { check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, properties => { - %{ make_properties_optional($filter_properties) }, + %{ make_properties_optional($matcher_properties) }, delete => { type => 'array', items => { @@ -1004,7 +979,10 @@ __PACKAGE__->register_method ({ my ($param) = @_; my $name = extract_param($param, 'name'); - my $min_severity = extract_param($param, 'min-severity'); + my $match_severity = extract_param($param, 'match-severity'); + my $match_field = extract_param($param, 'match-field'); + my $match_calendar = extract_param($param, 'match-calendar'); + my $target = extract_param($param, 'target'); my $mode = extract_param($param, 'mode'); my $invert_match = extract_param($param, 'invert-match'); my $comment = extract_param($param, 'comment'); @@ -1015,9 +993,12 @@ __PACKAGE__->register_method ({ PVE::Notify::lock_config(sub { my $config = PVE::Notify::read_config(); - $config->update_filter( + $config->update_matcher( $name, - $min_severity, + $target, + $match_severity, + $match_field, + $match_calendar, $mode, $invert_match, $comment, @@ -1035,11 +1016,11 @@ __PACKAGE__->register_method ({ }); __PACKAGE__->register_method ({ - name => 'delete_filter', + name => 'delete_matcher', protected => 1, - path => 'filters/{name}', + path => 'matchers/{name}', method => 'DELETE', - description => 'Remove filter', + description => 'Remove matcher', permissions => { check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], }, @@ -1060,7 +1041,7 @@ __PACKAGE__->register_method ({ eval { PVE::Notify::lock_config(sub { my $config = PVE::Notify::read_config(); - $config->delete_filter($name); + $config->delete_matcher($name); PVE::Notify::write_config($config); }); }; From 2cb6c8df37fa2e450285e3e743f8c91820a6741d Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:34 +0100 Subject: [PATCH 327/398] ui: dc: remove unneeded notification events panel The notification event settings are replaced by notification matchers, which will combine the notification routing and filtering into a single concept. Signed-off-by: Lukas Wagner --- www/manager6/Makefile | 4 - www/manager6/dc/Config.js | 17 +- www/manager6/dc/NotificationEvents.js | 276 -------------------------- 3 files changed, 2 insertions(+), 295 deletions(-) delete mode 100644 www/manager6/dc/NotificationEvents.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index dccd2ba1c..ee09f0b80 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -160,7 +160,6 @@ JSSRC= \ dc/Health.js \ dc/Log.js \ dc/NodeView.js \ - dc/NotificationEvents.js \ dc/OptionView.js \ dc/PermissionView.js \ dc/PoolEdit.js \ @@ -347,6 +346,3 @@ install: pvemanagerlib.js .PHONY: clean clean: rm -rf pvemanagerlib.js OnlineHelpInfo.js .lint-incremental - - - diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 7d01da5fb..0dea1c67b 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -319,18 +319,6 @@ Ext.define('PVE.dc.Config', { // this is being reworked, but we need to release newer manager versions already.. let notification_enabled = false; - if (notification_enabled && caps.dc['Sys.Audit']) { - me.items.push( - { - xtype: 'pveNotificationEvents', - title: gettext('Notifications'), - onlineHelp: 'notification_events', - iconCls: 'fa fa-bell-o', - itemId: 'notifications', - }, - ); - } - if (notification_enabled && ( caps.mapping['Mapping.Audit'] || caps.mapping['Mapping.Use'] || @@ -340,12 +328,11 @@ Ext.define('PVE.dc.Config', { me.items.push( { xtype: 'pmxNotificationConfigView', - title: gettext('Notification Targets'), + title: gettext('Notifications'), onlineHelp: 'notification_targets', itemId: 'notification-targets', - iconCls: 'fa fa-dot-circle-o', + iconCls: 'fa fa-bell-o', baseUrl: '/cluster/notifications', - groups: ['notifications'], }, ); } diff --git a/www/manager6/dc/NotificationEvents.js b/www/manager6/dc/NotificationEvents.js deleted file mode 100644 index 188162908..000000000 --- a/www/manager6/dc/NotificationEvents.js +++ /dev/null @@ -1,276 +0,0 @@ -Ext.define('PVE.dc.NotificationEventsPolicySelector', { - alias: ['widget.pveNotificationEventsPolicySelector'], - extend: 'Proxmox.form.KVComboBox', - deleteEmpty: false, - value: '__default__', - - config: { - warningRef: null, - warnIfValIs: null, - }, - - listeners: { - change: function(field, newValue) { - let me = this; - if (!me.warningRef && !me.warnIfValIs) { - return; - } - - let warningField = field.nextSibling( - `displayfield[reference=${me.warningRef}]`, - ); - warningField.setVisible(newValue === me.warnIfValIs); - }, - }, -}); - -Ext.define('PVE.dc.NotificationEventDisabledWarning', { - alias: ['widget.pveNotificationEventDisabledWarning'], - extend: 'Ext.form.field.Display', - userCls: 'pmx-hint', - hidden: true, - value: gettext('Disabling notifications is not recommended for production systems!'), -}); - -Ext.define('PVE.dc.NotificationEventsTargetSelector', { - alias: ['widget.pveNotificationEventsTargetSelector'], - extend: 'PVE.form.NotificationTargetSelector', - fieldLabel: gettext('Notification Target'), - allowBlank: true, - editable: true, - autoSelect: false, - deleteEmpty: false, - emptyText: `${Proxmox.Utils.defaultText} (mail-to-root)`, -}); - -Ext.define('PVE.dc.NotificationEvents', { - extend: 'Proxmox.grid.ObjectGrid', - alias: ['widget.pveNotificationEvents'], - - // Taken from OptionView.js, but adapted slightly. - // The modified version allows us to have multiple rows in the ObjectGrid - // for the same underlying property (notify). - // Every setting is eventually stored as a property string in the - // notify key of datacenter.cfg. - // When updating 'notify', all properties that were already set - // also have to be submitted, even if they were not modified. - // This means that we need to save the old value somewhere. - addInputPanelRow: function(name, propertyName, text, opts) { - let me = this; - - opts = opts || {}; - me.rows = me.rows || {}; - - me.rows[name] = { - required: true, - defaultValue: opts.defaultValue, - header: text, - renderer: opts.renderer, - name: propertyName, - editor: { - xtype: 'proxmoxWindowEdit', - width: opts.width || 400, - subject: text, - onlineHelp: opts.onlineHelp, - fieldDefaults: { - labelWidth: opts.labelWidth || 150, - }, - setValues: function(values) { - let value = values[propertyName]; - - if (opts.parseBeforeSet) { - value = PVE.Parser.parsePropertyString(value); - } - - Ext.Array.each(this.query('inputpanel'), function(panel) { - panel.setValues(value); - - // Save the original value - panel.originalValue = { - ...value, - }; - }); - }, - url: opts.url, - items: [{ - xtype: 'inputpanel', - onGetValues: function(values) { - let fields = this.config.items.map(field => field.name).filter(n => n); - - // Restore old, unchanged values - for (const [key, value] of Object.entries(this.originalValue)) { - if (!fields.includes(key)) { - values[key] = value; - } - } - - let value = {}; - if (Object.keys(values).length > 0) { - value[propertyName] = PVE.Parser.printPropertyString(values); - } else { - Proxmox.Utils.assemble_field_data(value, { 'delete': propertyName }); - } - - return value; - }, - items: opts.items, - }], - }, - }; - }, - - initComponent: function() { - let me = this; - - // Helper function for rendering the property - // Needed since the actual value is always stored in the 'notify' property - let render_value = (store, target_key, mode_key, default_val) => { - let value = store.getById('notify')?.get('value') ?? {}; - let target = value[target_key] ?? 'mail-to-root'; - let template; - - switch (value[mode_key]) { - case 'always': - template = gettext('Always, notify via target \'{0}\''); - break; - case 'never': - template = gettext('Never'); - break; - case 'auto': - template = gettext('Automatically, notify via target \'{0}\''); - break; - default: - template = gettext('{1} ({2}), notify via target \'{0}\''); - break; - } - - return Ext.String.format(template, target, Proxmox.Utils.defaultText, default_val); - }; - - me.addInputPanelRow('fencing', 'notify', gettext('Node Fencing'), { - renderer: (value, metaData, record, rowIndex, colIndex, store) => - render_value(store, 'target-fencing', 'fencing', gettext('Always')), - url: "/api2/extjs/cluster/options", - items: [ - { - xtype: 'pveNotificationEventsPolicySelector', - name: 'fencing', - fieldLabel: gettext('Notify'), - comboItems: [ - ['__default__', `${Proxmox.Utils.defaultText} (${gettext('Always')})`], - ['always', gettext('Always')], - ['never', gettext('Never')], - ], - warningRef: 'warning', - warnIfValIs: 'never', - }, - { - xtype: 'pveNotificationEventsTargetSelector', - name: 'target-fencing', - }, - { - xtype: 'pveNotificationEventDisabledWarning', - reference: 'warning', - }, - ], - }); - - me.addInputPanelRow('replication', 'notify', gettext('Replication'), { - renderer: (value, metaData, record, rowIndex, colIndex, store) => - render_value(store, 'target-replication', 'replication', gettext('Always')), - url: "/api2/extjs/cluster/options", - items: [ - { - xtype: 'pveNotificationEventsPolicySelector', - name: 'replication', - fieldLabel: gettext('Notify'), - comboItems: [ - ['__default__', `${Proxmox.Utils.defaultText} (${gettext('Always')})`], - ['always', gettext('Always')], - ['never', gettext('Never')], - ], - warningRef: 'warning', - warnIfValIs: 'never', - }, - { - xtype: 'pveNotificationEventsTargetSelector', - name: 'target-replication', - }, - { - xtype: 'pveNotificationEventDisabledWarning', - reference: 'warning', - }, - ], - }); - - me.addInputPanelRow('updates', 'notify', gettext('Package Updates'), { - renderer: (value, metaData, record, rowIndex, colIndex, store) => - render_value( - store, - 'target-package-updates', - 'package-updates', - gettext('Automatically'), - ), - url: "/api2/extjs/cluster/options", - items: [ - { - xtype: 'pveNotificationEventsPolicySelector', - name: 'package-updates', - fieldLabel: gettext('Notify'), - comboItems: [ - [ - '__default__', - `${Proxmox.Utils.defaultText} (${gettext('Automatically')})`, - ], - ['auto', gettext('Automatically')], - ['always', gettext('Always')], - ['never', gettext('Never')], - ], - warningRef: 'warning', - warnIfValIs: 'never', - }, - { - xtype: 'pveNotificationEventsTargetSelector', - name: 'target-package-updates', - }, - { - xtype: 'pveNotificationEventDisabledWarning', - reference: 'warning', - }, - ], - }); - - // Hack: Also load the notify property to make it accessible - // for our render functions. - me.rows.notify = { - visible: false, - }; - - me.selModel = Ext.create('Ext.selection.RowModel', {}); - - Ext.apply(me, { - tbar: [{ - text: gettext('Edit'), - xtype: 'proxmoxButton', - disabled: true, - handler: () => me.run_editor(), - selModel: me.selModel, - }], - url: "/api2/json/cluster/options", - editorConfig: { - url: "/api2/extjs/cluster/options", - }, - interval: 5000, - cwidth1: 200, - listeners: { - itemdblclick: me.run_editor, - }, - }); - - me.callParent(); - - me.on('activate', me.rstore.startUpdate); - me.on('destroy', me.rstore.stopUpdate); - me.on('deactivate', me.rstore.stopUpdate); - }, -}); From e95a9a334478f84ad21ec503fdc4ba541728382b Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:35 +0100 Subject: [PATCH 328/398] vzdump: adapt to new matcher based notification system To ease the migration from old-style mailto/mailnotification paramters for backup jobs, the code will add a ephemeral sendmail endpoint and a matcher. Signed-off-by: Lukas Wagner --- PVE/API2/VZDump.pm | 8 +------- PVE/VZDump.pm | 40 +++++++++++++++++++--------------------- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/PVE/API2/VZDump.pm b/PVE/API2/VZDump.pm index 3886772ed..f66fc7403 100644 --- a/PVE/API2/VZDump.pm +++ b/PVE/API2/VZDump.pm @@ -44,9 +44,7 @@ __PACKAGE__->register_method ({ ."'Datastore.AllocateSpace' on the backup storage. The 'tmpdir', 'dumpdir' and " ."'script' parameters are restricted to the 'root\@pam' user. The 'maxfiles' and " ."'prune-backups' settings require 'Datastore.Allocate' on the backup storage. The " - ."'bwlimit', 'performance' and 'ionice' parameters require 'Sys.Modify' on '/'. " - ."If 'notification-target' is set, then the 'Mapping.Use' permission is needed on " - ."'/mapping/notification/'.", + ."'bwlimit', 'performance' and 'ionice' parameters require 'Sys.Modify' on '/'. ", user => 'all', }, protected => 1, @@ -115,10 +113,6 @@ __PACKAGE__->register_method ({ $rpcenv->check($user, "/storage/$storeid", [ 'Datastore.AllocateSpace' ]); } - if (my $target = $param->{'notification-target'}) { - PVE::Notify::check_may_use_target($target, $rpcenv); - } - my $worker = sub { my $upid = shift; diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index 454ab4944..b0574d412 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -452,20 +452,18 @@ sub send_notification { my $opts = $self->{opts}; my $mailto = $opts->{mailto}; my $cmdline = $self->{cmdline}; - my $target = $opts->{"notification-target"}; - # Fall back to 'mailnotification' if 'notification-policy' is not set. - # If both are set, 'notification-policy' takes precedence - my $policy = $opts->{"notification-policy"} // $opts->{mailnotification} // 'always'; + # Old-style notification policy. This parameter will influce + # if an ad-hoc notification target/matcher will be created. + my $policy = $opts->{"notification-policy"} // + $opts->{mailnotification} // + 'always'; - return if ($policy eq 'never'); sanitize_task_list($tasklist); my $error_count = count_failed_tasks($tasklist); my $failed = ($error_count || $err); - return if (!$failed && ($policy eq 'failure')); - my $status_text = $failed ? 'backup failed' : 'backup successful'; if ($err) { @@ -489,8 +487,10 @@ sub send_notification { "See Task History for details!\n"; }; + my $hostname = get_hostname(); + my $notification_props = { - "hostname" => get_hostname(), + "hostname" => $hostname, "error-message" => $err, "guest-table" => build_guest_table($tasklist), "logs" => $text_log_part, @@ -498,9 +498,16 @@ sub send_notification { "total-time" => $total_time, }; + my $fields = { + type => "vzdump", + hostname => $hostname, + }; + my $notification_config = PVE::Notify::read_config(); - if ($mailto && scalar(@$mailto)) { + my $legacy_sendmail = $policy eq "always" || ($policy eq "failure" && $failed); + + if ($mailto && scalar(@$mailto) && $legacy_sendmail) { # <, >, @ are not allowed in endpoint names, but that is only # verified once the config is serialized. That means that # we can rely on that fact that no other endpoint with this name exists. @@ -514,29 +521,20 @@ sub send_notification { my $endpoints = [$endpoint_name]; - # Create an anonymous group containing the sendmail endpoint and the - # $target endpoint, if specified - if ($target) { - push @$endpoints, $target; - } - - $target = ""; - $notification_config->add_group( - $target, + $notification_config->add_matcher( + "", $endpoints, ); } - return if (!$target); - my $severity = $failed ? "error" : "info"; PVE::Notify::notify( - $target, $severity, $subject_template, $body_template, $notification_props, + $fields, $notification_config ); }; From 5fa9db35b7c80841a1b511f9235f7338cbcba44a Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:36 +0100 Subject: [PATCH 329/398] api: apt: adapt to matcher-based notifications Signed-off-by: Lukas Wagner --- PVE/API2/APT.pm | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/PVE/API2/APT.pm b/PVE/API2/APT.pm index 60884cb13..2ea003e2a 100644 --- a/PVE/API2/APT.pm +++ b/PVE/API2/APT.pm @@ -251,8 +251,6 @@ __PACKAGE__->register_method({ description => "This is used to resynchronize the package index files from their sources (apt-get update).", permissions => { check => ['perm', '/nodes/{node}', [ 'Sys.Modify' ]], - description => "If 'notify: target-package-updates' is set, then the user must have the " - . "'Mapping.Use' permission on '/mapping/notification/'", }, protected => 1, proxyto => 'node', @@ -262,7 +260,7 @@ __PACKAGE__->register_method({ node => get_standard_option('pve-node'), notify => { type => 'boolean', - description => "Send notification mail about new packages (to email address specified for user 'root\@pam').", + description => "Send notification about new packages.", optional => 1, default => 0, }, @@ -282,16 +280,6 @@ __PACKAGE__->register_method({ my $rpcenv = PVE::RPCEnvironment::get(); my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); - my $target = $dcconf->{notify}->{'target-package-updates'} // - PVE::Notify::default_target(); - - if ($param->{notify} && $target ne PVE::Notify::default_target()) { - # If we notify via anything other than the default target (mail to root), - # then the user must have the proper permissions for the target. - # The mail-to-root target does not require these, as otherwise - # we would break compatibility. - PVE::Notify::check_may_use_target($target, $rpcenv); - } my $authuser = $rpcenv->get_user(); @@ -357,16 +345,23 @@ __PACKAGE__->register_method({ return if !$count; - my $properties = { + my $template_data = { updates => $updates_table, hostname => $hostname, }; + # Additional metadata fields that can be used in notification + # matchers. + my $metadata_fields = { + type => 'package-updates', + hostname => $hostname, + }; + PVE::Notify::info( - $target, $updates_available_subject_template, $updates_available_body_template, - $properties, + $template_data, + $metadata_fields, ); foreach my $pi (@$pkglist) { From 3f0ffa0efe08d4eb84c2127dfde667a2353a9711 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:37 +0100 Subject: [PATCH 330/398] api: replication: adapt to matcher-based notification system Signed-off-by: Lukas Wagner --- PVE/API2/Replication.pm | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/PVE/API2/Replication.pm b/PVE/API2/Replication.pm index d61518ba6..0dc944c9d 100644 --- a/PVE/API2/Replication.pm +++ b/PVE/API2/Replication.pm @@ -129,7 +129,7 @@ my sub _handle_job_err { # The replication job is run every 15 mins if no schedule is set. my $schedule = $job->{schedule} // '*/15'; - my $properties = { + my $template_data = { "failure-count" => $fail_count, "last-sync" => $jobstate->{last_sync}, "next-sync" => $next_sync, @@ -139,19 +139,18 @@ my sub _handle_job_err { "error" => $err, }; - eval { - my $dcconf = PVE::Cluster::cfs_read_file('datacenter.cfg'); - my $target = $dcconf->{notify}->{'target-replication'} // PVE::Notify::default_target(); - my $notify = $dcconf->{notify}->{'replication'} // 'always'; + my $metadata_fields = { + # TODO: Add job-id? + type => "replication", + }; - if ($notify eq 'always') { - PVE::Notify::error( - $target, - $replication_error_subject_template, - $replication_error_body_template, - $properties - ); - } + eval { + PVE::Notify::error( + $replication_error_subject_template, + $replication_error_body_template, + $template_data, + $metadata_fields + ); }; warn ": $@" if $@; From a63ecef5a31d5cc8f07a267f2f1b21e862b3c0c1 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:38 +0100 Subject: [PATCH 331/398] test: fix vzdump notification test The signature of the PVE::Notify functions have changed, this commit adapts the mocked functions so that the tests work again. Signed-off-by: Lukas Wagner --- test/vzdump_notification_test.pl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/vzdump_notification_test.pl b/test/vzdump_notification_test.pl index 21c31651b..631606bbf 100755 --- a/test/vzdump_notification_test.pl +++ b/test/vzdump_notification_test.pl @@ -38,14 +38,14 @@ my $result_properties; my $mock_notification_module = Test::MockModule->new('PVE::Notify'); my $mocked_notify = sub { - my ($channel, $severity, $title, $text, $properties) = @_; + my ($severity, $title, $text, $properties, $metadata) = @_; $result_text = $text; $result_properties = $properties; }; my $mocked_notify_short = sub { - my ($channel, @rest) = @_; - return $mocked_notify->($channel, '', @rest); + my (@params) = @_; + return $mocked_notify->('', @params); }; $mock_notification_module->mock( From 75601945de0c7b998beba7ba5b35e27573726fa3 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:39 +0100 Subject: [PATCH 332/398] ui: vzdump: remove left-overs from target/policy based notifications Signed-off-by: Lukas Wagner --- www/manager6/dc/Backup.js | 81 ++++--------------- .../form/NotificationPolicySelector.js | 1 - www/manager6/window/Backup.js | 35 +------- 3 files changed, 15 insertions(+), 102 deletions(-) diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js index 0c8d2d4fe..e1c76a1d9 100644 --- a/www/manager6/dc/Backup.js +++ b/www/manager6/dc/Backup.js @@ -36,29 +36,11 @@ Ext.define('PVE.dc.BackupEdit', { delete values.node; } - if (!isCreate) { - // 'mailnotification' is deprecated in favor of 'notification-policy' - // -> Migration to the new parameter happens in init, so we are - // safe to remove the old parameter here. - Proxmox.Utils.assemble_field_data(values, { 'delete': 'mailnotification' }); - - // If sending notifications via mail, remove the current value of - // 'notification-target' - if (values['notification-mode'] === "mailto") { - Proxmox.Utils.assemble_field_data( - values, - { 'delete': 'notification-target' }, - ); - } else { - // and vice versa... - Proxmox.Utils.assemble_field_data( - values, - { 'delete': 'mailto' }, - ); - } - } - - delete values['notification-mode']; + // Get rid of new-old parameters for notification settings. + // These should only be set for those selected few who ran + // pve-manager from pvetest. + Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-policy' }); + Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-target' }); if (!values.id && isCreate) { values.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); @@ -170,20 +152,14 @@ Ext.define('PVE.dc.BackupEdit', { success: function(response, _options) { let data = response.result.data; - // 'mailnotification' is deprecated. Let's automatically - // migrate to the compatible 'notification-policy' parameter - if (data.mailnotification) { - if (!data["notification-policy"]) { - data["notification-policy"] = data.mailnotification; - } - - delete data.mailnotification; - } - - if (data['notification-target']) { - data['notification-mode'] = 'notification-target'; - } else if (data.mailto) { - data['notification-mode'] = 'mailto'; + // Migrate 'new'-old notification-policy back to + // old-old mailnotification. Only should affect + // users who used pve-manager from pvetest. + // This was a remnant of notifications before the + // overhaul. + let policy = data['notification-policy']; + if (policy === 'always' || policy === 'failure') { + data.mailnotification = policy; } if (data.exclude) { @@ -228,7 +204,6 @@ Ext.define('PVE.dc.BackupEdit', { viewModel: { data: { selMode: 'include', - notificationMode: 'notification-target', }, formulas: { @@ -327,44 +302,16 @@ Ext.define('PVE.dc.BackupEdit', { { xtype: 'pveEmailNotificationSelector', fieldLabel: gettext('Notify'), - name: 'notification-policy', + name: 'mailnotification', cbind: { value: (get) => get('isCreate') ? 'always' : '', deleteEmpty: '{!isCreate}', }, }, - { - xtype: 'pveNotificationModeSelector', - fieldLabel: gettext('Notify via'), - name: 'notification-mode', - bind: { - value: '{notificationMode}', - }, - }, - { - xtype: 'pveNotificationTargetSelector', - fieldLabel: gettext('Notification Target'), - name: 'notification-target', - allowBlank: true, - editable: true, - autoSelect: false, - bind: { - hidden: '{mailNotificationSelected}', - disabled: '{mailNotificationSelected}', - }, - cbind: { - deleteEmpty: '{!isCreate}', - }, - }, { xtype: 'textfield', fieldLabel: gettext('Send email to'), name: 'mailto', - hidden: true, - bind: { - hidden: '{!mailNotificationSelected}', - disabled: '{!mailNotificationSelected}', - }, }, { xtype: 'pveBackupCompressionSelector', diff --git a/www/manager6/form/NotificationPolicySelector.js b/www/manager6/form/NotificationPolicySelector.js index 68087275e..f318ea18d 100644 --- a/www/manager6/form/NotificationPolicySelector.js +++ b/www/manager6/form/NotificationPolicySelector.js @@ -4,6 +4,5 @@ Ext.define('PVE.form.EmailNotificationSelector', { comboItems: [ ['always', gettext('Notify always')], ['failure', gettext('On failure only')], - ['never', gettext('Notify never')], ], }); diff --git a/www/manager6/window/Backup.js b/www/manager6/window/Backup.js index 8e6fa77ea..8d8c9ff01 100644 --- a/www/manager6/window/Backup.js +++ b/www/manager6/window/Backup.js @@ -30,32 +30,12 @@ Ext.define('PVE.window.Backup', { name: 'mode', }); - let notificationTargetSelector = Ext.create('PVE.form.NotificationTargetSelector', { - fieldLabel: gettext('Notification target'), - name: 'notification-target', - emptyText: Proxmox.Utils.noneText, - hidden: true, - }); - let mailtoField = Ext.create('Ext.form.field.Text', { fieldLabel: gettext('Send email to'), name: 'mailto', emptyText: Proxmox.Utils.noneText, }); - let notificationModeSelector = Ext.create('PVE.form.NotificationModeSelector', { - fieldLabel: gettext('Notify via'), - value: 'mailto', - name: 'notification-mode', - listeners: { - change: function(f, v) { - let mailSelected = v === 'mailto'; - notificationTargetSelector.setHidden(mailSelected); - mailtoField.setHidden(!mailSelected); - }, - }, - }); - const keepNames = [ ['keep-last', gettext('Keep Last')], ['keep-hourly', gettext('Keep Hourly')], @@ -127,12 +107,6 @@ Ext.define('PVE.window.Backup', { success: function(response, opts) { const data = response.result.data; - if (!initialDefaults && data['notification-mode'] !== undefined) { - notificationModeSelector.setValue(data['notification-mode']); - } - if (!initialDefaults && data['notification-channel'] !== undefined) { - notificationTargetSelector.setValue(data['notification-channel']); - } if (!initialDefaults && data.mailto !== undefined) { mailtoField.setValue(data.mailto); } @@ -202,8 +176,6 @@ Ext.define('PVE.window.Backup', { ], column2: [ compressionSelector, - notificationModeSelector, - notificationTargetSelector, mailtoField, removeCheckbox, ], @@ -280,15 +252,10 @@ Ext.define('PVE.window.Backup', { remove: values.remove, }; - if (values.mailto && values['notification-mode'] === 'mailto') { + if (values.mailto) { params.mailto = values.mailto; } - if (values['notification-target'] && - values['notification-mode'] === 'notification-target') { - params['notification-target'] = values['notification-target']; - } - if (values.compress) { params.compress = values.compress; } From 8fc1f4a9c941f50bacb78c2efeff67a85a278a50 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:40 +0100 Subject: [PATCH 333/398] ui: dc: config: show notification panel again Rework should be done now. Signed-off-by: Lukas Wagner --- www/manager6/dc/Config.js | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 0dea1c67b..74a84e911 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -317,14 +317,9 @@ Ext.define('PVE.dc.Config', { ); } - // this is being reworked, but we need to release newer manager versions already.. - let notification_enabled = false; - if (notification_enabled && ( - caps.mapping['Mapping.Audit'] || - caps.mapping['Mapping.Use'] || - caps.mapping['Mapping.Modify'] - ) - ) { + if (caps.mapping['Mapping.Audit'] || + caps.mapping['Mapping.Use'] || + caps.mapping['Mapping.Modify']) { me.items.push( { xtype: 'pmxNotificationConfigView', From 1d66f8879e9f28a01a87ef7a88866e017d09333c Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:41 +0100 Subject: [PATCH 334/398] notify: add API routes for smtp endpoints The Perl part of the API methods primarily defines the API schema, checks for any needed privileges and then calls the actual Rust implementation exposed via perlmod. Any errors returned by the Rust code are translated into PVE::Exception, so that the API call fails with the correct HTTP error code. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 323 ++++++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index 8f716f264..42207aaad 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -194,6 +194,14 @@ __PACKAGE__->register_method ({ }; } + for my $target (@{$config->get_smtp_endpoints()}) { + push @$result, { + name => $target->{name}, + comment => $target->{comment}, + type => 'smtp', + }; + } + $result }; @@ -758,6 +766,321 @@ __PACKAGE__->register_method ({ } }); +my $smtp_properties= { + name => { + description => 'The name of the endpoint.', + type => 'string', + format => 'pve-configid', + }, + server => { + description => 'The address of the SMTP server.', + type => 'string', + }, + port => { + description => 'The port to be used. Defaults to 465 for TLS based connections,' + . ' 587 for STARTTLS based connections and port 25 for insecure plain-text' + . ' connections.', + type => 'integer', + optional => 1, + }, + mode => { + description => 'Determine which encryption method shall be used for the connection.', + type => 'string', + enum => [ qw(insecure starttls tls) ], + default => 'tls', + optional => 1, + }, + username => { + description => 'Username for SMTP authentication', + type => 'string', + optional => 1, + }, + password => { + description => 'Password for SMTP authentication', + type => 'string', + optional => 1, + }, + mailto => { + type => 'array', + items => { + type => 'string', + format => 'email-or-username', + }, + description => 'List of email recipients', + optional => 1, + }, + 'mailto-user' => { + type => 'array', + items => { + type => 'string', + format => 'pve-userid', + }, + description => 'List of users', + optional => 1, + }, + 'from-address' => { + description => '`From` address for the mail', + type => 'string', + }, + author => { + description => 'Author of the mail. Defaults to \'Proxmox VE\'.', + type => 'string', + optional => 1, + }, + 'comment' => { + description => 'Comment', + type => 'string', + optional => 1, + }, +}; + +__PACKAGE__->register_method ({ + name => 'get_smtp_endpoints', + path => 'endpoints/smtp', + method => 'GET', + description => 'Returns a list of all smtp endpoints', + permissions => { + description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" + . " 'Mapping.Audit' permissions on '/mapping/notification/targets/'.", + user => 'all', + }, + protected => 1, + parameters => { + additionalProperties => 0, + properties => {}, + }, + returns => { + type => 'array', + items => { + type => 'object', + properties => $smtp_properties, + }, + links => [ { rel => 'child', href => '{name}' } ], + }, + code => sub { + my $config = PVE::Notify::read_config(); + my $rpcenv = PVE::RPCEnvironment::get(); + + my $entities = eval { + $config->get_smtp_endpoints(); + }; + raise_api_error($@) if $@; + + return filter_entities_by_privs($rpcenv, "targets", $entities); + } +}); + +__PACKAGE__->register_method ({ + name => 'get_smtp_endpoint', + path => 'endpoints/smtp/{name}', + method => 'GET', + description => 'Return a specific smtp endpoint', + permissions => { + check => ['or', + ['perm', '/mapping/notification/targets/{name}', ['Mapping.Modify']], + ['perm', '/mapping/notification/targets/{name}', ['Mapping.Audit']], + ], + }, + protected => 1, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { + type => 'object', + properties => { + %{ remove_protected_properties($smtp_properties, ['password']) }, + digest => get_standard_option('pve-config-digest'), + } + + }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + my $config = PVE::Notify::read_config(); + my $endpoint = eval { + $config->get_smtp_endpoint($name) + }; + + raise_api_error($@) if $@; + $endpoint->{digest} = $config->digest(); + + return $endpoint; + } +}); + +__PACKAGE__->register_method ({ + name => 'create_smtp_endpoint', + path => 'endpoints/smtp', + protected => 1, + method => 'POST', + description => 'Create a new smtp endpoint', + permissions => { + check => ['perm', '/mapping/notification/targets', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => $smtp_properties, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $server = extract_param($param, 'server'); + my $port = extract_param($param, 'port'); + my $mode = extract_param($param, 'mode'); + my $username = extract_param($param, 'username'); + my $password = extract_param($param, 'password'); + my $mailto = extract_param($param, 'mailto'); + my $mailto_user = extract_param($param, 'mailto-user'); + my $from_address = extract_param($param, 'from-address'); + my $author = extract_param($param, 'author'); + my $comment = extract_param($param, 'comment'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->add_smtp_endpoint( + $name, + $server, + $port, + $mode, + $username, + $password, + $mailto, + $mailto_user, + $from_address, + $author, + $comment, + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'update_smtp_endpoint', + path => 'endpoints/smtp/{name}', + protected => 1, + method => 'PUT', + description => 'Update existing smtp endpoint', + permissions => { + check => ['perm', '/mapping/notification/targets/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + %{ make_properties_optional($smtp_properties) }, + delete => { + type => 'array', + items => { + type => 'string', + format => 'pve-configid', + }, + optional => 1, + description => 'A list of settings you want to delete.', + }, + digest => get_standard_option('pve-config-digest'), + + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + my $name = extract_param($param, 'name'); + my $server = extract_param($param, 'server'); + my $port = extract_param($param, 'port'); + my $mode = extract_param($param, 'mode'); + my $username = extract_param($param, 'username'); + my $password = extract_param($param, 'password'); + my $mailto = extract_param($param, 'mailto'); + my $mailto_user = extract_param($param, 'mailto-user'); + my $from_address = extract_param($param, 'from-address'); + my $author = extract_param($param, 'author'); + my $comment = extract_param($param, 'comment'); + + my $delete = extract_param($param, 'delete'); + my $digest = extract_param($param, 'digest'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + + $config->update_smtp_endpoint( + $name, + $server, + $port, + $mode, + $username, + $password, + $mailto, + $mailto_user, + $from_address, + $author, + $comment, + $delete, + $digest, + ); + + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if $@; + return; + } +}); + +__PACKAGE__->register_method ({ + name => 'delete_smtp_endpoint', + protected => 1, + path => 'endpoints/smtp/{name}', + method => 'DELETE', + description => 'Remove smtp endpoint', + permissions => { + check => ['perm', '/mapping/notification/targets/{name}', ['Mapping.Modify']], + }, + parameters => { + additionalProperties => 0, + properties => { + name => { + type => 'string', + format => 'pve-configid', + }, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + my $name = extract_param($param, 'name'); + + eval { + PVE::Notify::lock_config(sub { + my $config = PVE::Notify::read_config(); + $config->delete_smtp_endpoint($name); + PVE::Notify::write_config($config); + }); + }; + + raise_api_error($@) if ($@); + return; + } +}); + my $matcher_properties = { name => { description => 'Name of the matcher.', From 26825ac0581cfd4c33e0af37f13149c024671e54 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:42 +0100 Subject: [PATCH 335/398] api: notification: add disable and origin params 'disable' can be set to disable a matcher/target. 'origin' signals whether the configuration entry was created by the user or whether it was built-in/ built-in-and-modified. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 113 ++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 14 deletions(-) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index 42207aaad..27e3a66dc 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -164,8 +164,19 @@ __PACKAGE__->register_method ({ }, 'comment' => { description => 'Comment', - type => 'string', - optional => 1, + type => 'string', + optional => 1, + }, + 'disable' => { + description => 'Show if this target is disabled', + type => 'boolean', + optional => 1, + default => 0, + }, + 'origin' => { + description => 'Show if this entry was created by a user or was built-in', + type => 'string', + enum => [qw(user-created builtin modified-builtin)], }, }, }, @@ -183,6 +194,8 @@ __PACKAGE__->register_method ({ name => $target->{name}, comment => $target->{comment}, type => 'sendmail', + disable => $target->{disable}, + origin => $target->{origin}, }; } @@ -191,6 +204,8 @@ __PACKAGE__->register_method ({ name => $target->{name}, comment => $target->{comment}, type => 'gotify', + disable => $target->{disable}, + origin => $target->{origin}, }; } @@ -199,6 +214,8 @@ __PACKAGE__->register_method ({ name => $target->{name}, comment => $target->{comment}, type => 'smtp', + disable => $target->{disable}, + origin => $target->{origin}, }; } @@ -295,8 +312,14 @@ my $sendmail_properties = { }, 'comment' => { description => 'Comment', - type => 'string', - optional => 1, + type => 'string', + optional => 1, + }, + 'disable' => { + description => 'Disable this target', + type => 'boolean', + optional => 1, + default => 0, }, }; @@ -319,7 +342,14 @@ __PACKAGE__->register_method ({ type => 'array', items => { type => 'object', - properties => $sendmail_properties, + properties => { + %$sendmail_properties, + 'origin' => { + description => 'Show if this entry was created by a user or was built-in', + type => 'string', + enum => [qw(user-created builtin modified-builtin)], + }, + }, }, links => [ { rel => 'child', href => '{name}' } ], }, @@ -404,6 +434,7 @@ __PACKAGE__->register_method ({ my $from_address = extract_param($param, 'from-address'); my $author = extract_param($param, 'author'); my $comment = extract_param($param, 'comment'); + my $disable = extract_param($param, 'disable'); eval { PVE::Notify::lock_config(sub { @@ -416,6 +447,7 @@ __PACKAGE__->register_method ({ $from_address, $author, $comment, + $disable, ); PVE::Notify::write_config($config); @@ -463,6 +495,7 @@ __PACKAGE__->register_method ({ my $from_address = extract_param($param, 'from-address'); my $author = extract_param($param, 'author'); my $comment = extract_param($param, 'comment'); + my $disable = extract_param($param, 'disable'); my $delete = extract_param($param, 'delete'); my $digest = extract_param($param, 'digest'); @@ -478,6 +511,7 @@ __PACKAGE__->register_method ({ $from_address, $author, $comment, + $disable, $delete, $digest, ); @@ -543,8 +577,14 @@ my $gotify_properties = { }, 'comment' => { description => 'Comment', - type => 'string', - optional => 1, + type => 'string', + optional => 1, + }, + 'disable' => { + description => 'Disable this target', + type => 'boolean', + optional => 1, + default => 0, }, }; @@ -567,7 +607,14 @@ __PACKAGE__->register_method ({ type => 'array', items => { type => 'object', - properties => remove_protected_properties($gotify_properties, ['token']), + properties => { + % {remove_protected_properties($gotify_properties, ['token'])}, + 'origin' => { + description => 'Show if this entry was created by a user or was built-in', + type => 'string', + enum => [qw(user-created builtin modified-builtin)], + }, + }, }, links => [ { rel => 'child', href => '{name}' } ], }, @@ -650,6 +697,7 @@ __PACKAGE__->register_method ({ my $server = extract_param($param, 'server'); my $token = extract_param($param, 'token'); my $comment = extract_param($param, 'comment'); + my $disable = extract_param($param, 'disable'); eval { PVE::Notify::lock_config(sub { @@ -660,6 +708,7 @@ __PACKAGE__->register_method ({ $server, $token, $comment, + $disable, ); PVE::Notify::write_config($config); @@ -704,6 +753,7 @@ __PACKAGE__->register_method ({ my $server = extract_param($param, 'server'); my $token = extract_param($param, 'token'); my $comment = extract_param($param, 'comment'); + my $disable = extract_param($param, 'disable'); my $delete = extract_param($param, 'delete'); my $digest = extract_param($param, 'digest'); @@ -717,6 +767,7 @@ __PACKAGE__->register_method ({ $server, $token, $comment, + $disable, $delete, $digest, ); @@ -829,8 +880,14 @@ my $smtp_properties= { }, 'comment' => { description => 'Comment', - type => 'string', - optional => 1, + type => 'string', + optional => 1, + }, + 'disable' => { + description => 'Disable this target', + type => 'boolean', + optional => 1, + default => 0, }, }; @@ -853,7 +910,14 @@ __PACKAGE__->register_method ({ type => 'array', items => { type => 'object', - properties => $smtp_properties, + properties => { + %{ remove_protected_properties($smtp_properties, ['password']) }, + 'origin' => { + description => 'Show if this entry was created by a user or was built-in', + type => 'string', + enum => [qw(user-created builtin modified-builtin)], + }, + }, }, links => [ { rel => 'child', href => '{name}' } ], }, @@ -943,6 +1007,7 @@ __PACKAGE__->register_method ({ my $from_address = extract_param($param, 'from-address'); my $author = extract_param($param, 'author'); my $comment = extract_param($param, 'comment'); + my $disable = extract_param($param, 'disable'); eval { PVE::Notify::lock_config(sub { @@ -960,6 +1025,7 @@ __PACKAGE__->register_method ({ $from_address, $author, $comment, + $disable, ); PVE::Notify::write_config($config); @@ -1012,6 +1078,7 @@ __PACKAGE__->register_method ({ my $from_address = extract_param($param, 'from-address'); my $author = extract_param($param, 'author'); my $comment = extract_param($param, 'comment'); + my $disable = extract_param($param, 'disable'); my $delete = extract_param($param, 'delete'); my $digest = extract_param($param, 'digest'); @@ -1032,6 +1099,7 @@ __PACKAGE__->register_method ({ $from_address, $author, $comment, + $disable, $delete, $digest, ); @@ -1135,8 +1203,14 @@ my $matcher_properties = { }, 'comment' => { description => 'Comment', - type => 'string', - optional => 1, + type => 'string', + optional => 1, + }, + 'disable' => { + description => 'Disable this matcher', + type => 'boolean', + optional => 1, + default => 0, }, }; @@ -1159,7 +1233,14 @@ __PACKAGE__->register_method ({ type => 'array', items => { type => 'object', - properties => $matcher_properties, + properties => { + %$matcher_properties, + 'origin' => { + description => 'Show if this entry was created by a user or was built-in', + type => 'string', + enum => [qw(user-created builtin modified-builtin)], + }, + } }, links => [ { rel => 'child', href => '{name}' } ], }, @@ -1247,6 +1328,7 @@ __PACKAGE__->register_method ({ my $mode = extract_param($param, 'mode'); my $invert_match = extract_param($param, 'invert-match'); my $comment = extract_param($param, 'comment'); + my $disable = extract_param($param, 'disable'); eval { PVE::Notify::lock_config(sub { @@ -1261,6 +1343,7 @@ __PACKAGE__->register_method ({ $mode, $invert_match, $comment, + $disable, ); PVE::Notify::write_config($config); @@ -1309,6 +1392,7 @@ __PACKAGE__->register_method ({ my $mode = extract_param($param, 'mode'); my $invert_match = extract_param($param, 'invert-match'); my $comment = extract_param($param, 'comment'); + my $disable = extract_param($param, 'disable'); my $digest = extract_param($param, 'digest'); my $delete = extract_param($param, 'delete'); @@ -1325,6 +1409,7 @@ __PACKAGE__->register_method ({ $mode, $invert_match, $comment, + $disable, $delete, $digest, ); From d90157e0baf89be3cabd88602bf0ecf26af01236 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 14 Nov 2023 13:59:43 +0100 Subject: [PATCH 336/398] api: notification: simplify ACLs for notification Use coarse-grained /mapping/notifications for now. We can always extend later if we need to. Signed-off-by: Lukas Wagner --- PVE/API2/Cluster/Notifications.pm | 131 ++++++++++++------------------ 1 file changed, 54 insertions(+), 77 deletions(-) diff --git a/PVE/API2/Cluster/Notifications.pm b/PVE/API2/Cluster/Notifications.pm index 27e3a66dc..7047f0b12 100644 --- a/PVE/API2/Cluster/Notifications.pm +++ b/PVE/API2/Cluster/Notifications.pm @@ -56,24 +56,6 @@ sub raise_api_error { die $exc; } -sub filter_entities_by_privs { - my ($rpcenv, $entities) = @_; - my $authuser = $rpcenv->get_user(); - - my $can_see_mapping_privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; - - my $filtered = [grep { - $rpcenv->check_any( - $authuser, - "/mapping/notification/$_->{name}", - $can_see_mapping_privs, - 1 - ); - } @$entities]; - - return $filtered; -} - __PACKAGE__->register_method ({ name => 'index', path => '', @@ -137,10 +119,11 @@ __PACKAGE__->register_method ({ method => 'GET', description => 'Returns a list of all entities that can be used as notification targets.', permissions => { - description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" - . " 'Mapping.Audit' permissions on '/mapping/notification/'." - . " The special 'mail-to-root' target is available to all users.", - user => 'all', + check => ['or', + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], + ['perm', '/mapping/notifications', ['Mapping.Use']], + ], }, protected => 1, parameters => { @@ -184,7 +167,6 @@ __PACKAGE__->register_method ({ }, code => sub { my $config = PVE::Notify::read_config(); - my $rpcenv = PVE::RPCEnvironment::get(); my $targets = eval { my $result = []; @@ -224,7 +206,7 @@ __PACKAGE__->register_method ({ raise_api_error($@) if $@; - return filter_entities_by_privs($rpcenv, $targets); + return $targets; } }); @@ -235,10 +217,11 @@ __PACKAGE__->register_method ({ method => 'POST', description => 'Send a test notification to a provided target.', permissions => { - description => "The user requires 'Mapping.Modify', 'Mapping.Use' or" - . " 'Mapping.Audit' permissions on '/mapping/notification/'." - . " The special 'mail-to-root' target can be accessed by all users.", - user => 'all', + check => ['or', + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], + ['perm', '/mapping/notifications', ['Mapping.Use']], + ], }, parameters => { additionalProperties => 0, @@ -254,16 +237,6 @@ __PACKAGE__->register_method ({ code => sub { my ($param) = @_; my $name = extract_param($param, 'name'); - my $rpcenv = PVE::RPCEnvironment::get(); - my $authuser = $rpcenv->get_user(); - - my $privs = ['Mapping.Modify', 'Mapping.Use', 'Mapping.Audit']; - - $rpcenv->check_any( - $authuser, - "/mapping/notification/$name", - $privs, - ); eval { my $config = PVE::Notify::read_config(); @@ -329,9 +302,10 @@ __PACKAGE__->register_method ({ method => 'GET', description => 'Returns a list of all sendmail endpoints', permissions => { - description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" - . " 'Mapping.Audit' permissions on '/mapping/notification/'.", - user => 'all', + check => ['or', + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], + ], }, protected => 1, parameters => { @@ -355,14 +329,13 @@ __PACKAGE__->register_method ({ }, code => sub { my $config = PVE::Notify::read_config(); - my $rpcenv = PVE::RPCEnvironment::get(); my $entities = eval { $config->get_sendmail_endpoints(); }; raise_api_error($@) if $@; - return filter_entities_by_privs($rpcenv, $entities); + return $entities; } }); @@ -373,8 +346,8 @@ __PACKAGE__->register_method ({ description => 'Return a specific sendmail endpoint', permissions => { check => ['or', - ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], - ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], ], }, protected => 1, @@ -418,7 +391,7 @@ __PACKAGE__->register_method ({ method => 'POST', description => 'Create a new sendmail endpoint', permissions => { - check => ['perm', '/mapping/notification', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -466,7 +439,7 @@ __PACKAGE__->register_method ({ method => 'PUT', description => 'Update existing sendmail endpoint', permissions => { - check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -532,7 +505,7 @@ __PACKAGE__->register_method ({ method => 'DELETE', description => 'Remove sendmail endpoint', permissions => { - check => ['perm', '/mapping/notification', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -595,9 +568,8 @@ __PACKAGE__->register_method ({ description => 'Returns a list of all gotify endpoints', protected => 1, permissions => { - description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" - . " 'Mapping.Audit' permissions on '/mapping/notification/'.", - user => 'all', + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Audit']], }, parameters => { additionalProperties => 0, @@ -627,7 +599,7 @@ __PACKAGE__->register_method ({ }; raise_api_error($@) if $@; - return filter_entities_by_privs($rpcenv, $entities); + return $entities; } }); @@ -639,8 +611,8 @@ __PACKAGE__->register_method ({ protected => 1, permissions => { check => ['or', - ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], - ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], ], }, parameters => { @@ -683,7 +655,7 @@ __PACKAGE__->register_method ({ method => 'POST', description => 'Create a new gotify endpoint', permissions => { - check => ['perm', '/mapping/notification', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -727,7 +699,7 @@ __PACKAGE__->register_method ({ method => 'PUT', description => 'Update existing gotify endpoint', permissions => { - check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -788,7 +760,7 @@ __PACKAGE__->register_method ({ method => 'DELETE', description => 'Remove gotify endpoint', permissions => { - check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -897,9 +869,10 @@ __PACKAGE__->register_method ({ method => 'GET', description => 'Returns a list of all smtp endpoints', permissions => { - description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" - . " 'Mapping.Audit' permissions on '/mapping/notification/targets/'.", - user => 'all', + check => ['or', + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], + ], }, protected => 1, parameters => { @@ -923,14 +896,13 @@ __PACKAGE__->register_method ({ }, code => sub { my $config = PVE::Notify::read_config(); - my $rpcenv = PVE::RPCEnvironment::get(); my $entities = eval { $config->get_smtp_endpoints(); }; raise_api_error($@) if $@; - return filter_entities_by_privs($rpcenv, "targets", $entities); + return $entities; } }); @@ -941,8 +913,8 @@ __PACKAGE__->register_method ({ description => 'Return a specific smtp endpoint', permissions => { check => ['or', - ['perm', '/mapping/notification/targets/{name}', ['Mapping.Modify']], - ['perm', '/mapping/notification/targets/{name}', ['Mapping.Audit']], + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], ], }, protected => 1, @@ -986,7 +958,9 @@ __PACKAGE__->register_method ({ method => 'POST', description => 'Create a new smtp endpoint', permissions => { - check => ['perm', '/mapping/notification/targets', ['Mapping.Modify']], + check => ['or', + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ], }, parameters => { additionalProperties => 0, @@ -1044,7 +1018,9 @@ __PACKAGE__->register_method ({ method => 'PUT', description => 'Update existing smtp endpoint', permissions => { - check => ['perm', '/mapping/notification/targets/{name}', ['Mapping.Modify']], + check => ['or', + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ], }, parameters => { additionalProperties => 0, @@ -1120,7 +1096,7 @@ __PACKAGE__->register_method ({ method => 'DELETE', description => 'Remove smtp endpoint', permissions => { - check => ['perm', '/mapping/notification/targets/{name}', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -1221,9 +1197,11 @@ __PACKAGE__->register_method ({ description => 'Returns a list of all matchers', protected => 1, permissions => { - description => "Only lists entries where you have 'Mapping.Modify', 'Mapping.Use' or" - . " 'Mapping.Audit' permissions on '/mapping/notification/'.", - user => 'all', + check => ['or', + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], + ['perm', '/mapping/notifications', ['Mapping.Use']], + ], }, parameters => { additionalProperties => 0, @@ -1246,14 +1224,13 @@ __PACKAGE__->register_method ({ }, code => sub { my $config = PVE::Notify::read_config(); - my $rpcenv = PVE::RPCEnvironment::get(); my $entities = eval { $config->get_matchers(); }; raise_api_error($@) if $@; - return filter_entities_by_privs($rpcenv, $entities); + return $entities; } }); @@ -1265,8 +1242,8 @@ __PACKAGE__->register_method ({ protected => 1, permissions => { check => ['or', - ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], - ['perm', '/mapping/notification/{name}', ['Mapping.Audit']], + ['perm', '/mapping/notifications', ['Mapping.Modify']], + ['perm', '/mapping/notifications', ['Mapping.Audit']], ], }, parameters => { @@ -1310,7 +1287,7 @@ __PACKAGE__->register_method ({ description => 'Create a new matcher', protected => 1, permissions => { - check => ['perm', '/mapping/notification', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -1362,7 +1339,7 @@ __PACKAGE__->register_method ({ method => 'PUT', description => 'Update existing matcher', permissions => { - check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, @@ -1430,7 +1407,7 @@ __PACKAGE__->register_method ({ method => 'DELETE', description => 'Remove matcher', permissions => { - check => ['perm', '/mapping/notification/{name}', ['Mapping.Modify']], + check => ['perm', '/mapping/notifications', ['Mapping.Modify']], }, parameters => { additionalProperties => 0, From f2aa317aa319f749df611c0898210a3867fc4ba1 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 16 Nov 2023 12:52:02 +0100 Subject: [PATCH 337/398] ui: fix backup job create 'delete' is only possible for editing jobs, not creating them Signed-off-by: Dominik Csapak --- www/manager6/dc/Backup.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js index e1c76a1d9..9aae4090a 100644 --- a/www/manager6/dc/Backup.js +++ b/www/manager6/dc/Backup.js @@ -39,8 +39,10 @@ Ext.define('PVE.dc.BackupEdit', { // Get rid of new-old parameters for notification settings. // These should only be set for those selected few who ran // pve-manager from pvetest. - Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-policy' }); - Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-target' }); + if (!isCreate) { + Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-policy' }); + Proxmox.Utils.assemble_field_data(values, { 'delete': 'notification-target' }); + } if (!values.id && isCreate) { values.id = 'backup-' + Ext.data.identifier.Uuid.Global.generate().slice(0, 13); From a1ea14f452e1ce33162b52979ad54582b123c413 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 17 Nov 2023 19:54:37 +0100 Subject: [PATCH 338/398] d/control: bump versioned dependencies for pve-cluster and libpve-notify-perl to ensure the rework of the notification system can be used Signed-off-by: Thomas Lamprecht --- debian/control | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index 33aaac72b..d839dae09 100644 --- a/debian/control +++ b/debian/control @@ -63,7 +63,7 @@ Depends: apt (>= 1.5~), libpve-common-perl (>= 7.2-7), libpve-guest-common-perl (>= 5.0.5), libpve-http-server-perl (>= 4.1-1), - libpve-notify-perl, + libpve-notify-perl (>= 8.0.5), libpve-rs-perl (>= 0.7.1), libpve-storage-perl (>= 8.0.3), librados2-perl (>= 1.3-1), @@ -82,7 +82,7 @@ Depends: apt (>= 1.5~), proxmox-mail-forward, proxmox-mini-journalreader (>= 1.3-1), proxmox-widget-toolkit (>= 4.1.0), - pve-cluster (>= 7.0-4), + pve-cluster (>= 8.0.5), pve-container (>= 5.0.5), pve-docs (>= 8.0~~), pve-firewall, From 856eafa3c7c708631a6d606398f6cd5da729f6a9 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 17 Nov 2023 19:56:45 +0100 Subject: [PATCH 339/398] d/control: bump versioned dependencies for proxmox-widget-toolkit for the UI side of the notification system rework Signed-off-by: Thomas Lamprecht --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index d839dae09..36790b834 100644 --- a/debian/control +++ b/debian/control @@ -81,7 +81,7 @@ Depends: apt (>= 1.5~), postfix | mail-transport-agent, proxmox-mail-forward, proxmox-mini-journalreader (>= 1.3-1), - proxmox-widget-toolkit (>= 4.1.0), + proxmox-widget-toolkit (>= 4.1.1), pve-cluster (>= 8.0.5), pve-container (>= 5.0.5), pve-docs (>= 8.0~~), From f58ecd9d4ebb5ea21abd01da6fa1a3b2e9e00655 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Fri, 17 Nov 2023 19:54:57 +0100 Subject: [PATCH 340/398] bump version to 8.0.8 Signed-off-by: Thomas Lamprecht --- debian/changelog | 71 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/debian/changelog b/debian/changelog index 62842695f..b77fb84e3 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,74 @@ +pve-manager (8.0.8) bookworm; urgency=medium + + * update shipped apliance info index + + * report: add files from interfaces.d and SDN config directories + + * node console: allow usage for non-pam realms, users still need to login + with credential recoginzed by PAM + + * ui: wizards: allow adding tags in the qemu/lxc create wizard + + * ui: guest wizard: increase height slightly to match 4:3 ratio + + * pve7to8: check for proper grub meta-package for bootmode + + * fix #4631: ceph: osd: create: add osds-per-device + + * fix #2336: ui: adjust message for bulk start/stop/migrate to better + reflect that not always all guests are selected + + * ui: bulk actions: rework filters and allow one to filter by tags + + * ui: add tooltips to non-fully-expaned tags globally + + * ceph: mark global pg bits setting as deprecated + + * ui: hide bulk migrate options on standalone nodes + + * fix #4497: acme: add support for external account bindings + + * api/acme: deprecate 'tos' endpoint in favor of new, more general, 'meta' + one + + * api: add bulk-action endpoint for mass-suspendig (hibernating) virtual + machines + + * ui: add bulk-action suspend to node menu + + * fix #4442: add date-time range filtering for firewall logs + + * ui: ceph status: add pg warning state + + * ui: ceph status: rename 'working' state into 'busy' + + * apt: use `apt changelog` for changelog fetching + + * ui: ipset: make ip/cidr required + + * ship default link config to disable systemd link mac-policy, avoiding that + systemd marks the network as being without carrier, which can result into + issues for some network configurations like source-NAT if no TAP device is + (yet) connected to the bridge. + + * api: osd destroy: remove the mclock max iops settings to avoid that it's + reused when a new OSD gets the same OSD ID again + + * pvesh: proxy handler: fix handling array parameters + + * api: notification: remove notification groups and filters, they are + replaced by a matcher based routing system. + + * api: various adaptions to new matcher based notification system + + * notify: add API routes for smtp endpoints + + * api: notification: add 'disable' and 'origin' params + + * api: notification: simplify ACLs for notification + + -- Proxmox Support Team Fri, 17 Nov 2023 19:54:54 +0100 + pve-manager (8.0.7) bookworm; urgency=medium * fix #3069: vzdump: add property 'performance: pbs-entries-max=N' From 27ba78f8b15315a4625605daadb586f36c3bb2f0 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Sat, 18 Nov 2023 12:14:18 +0100 Subject: [PATCH 341/398] move default link config to drop-in snippet because otherwise we need to house *all* defaults, like the interface naming policy ones, too. This can be fine for one release, but easily overlooked if those, or other important fall-back defaults change. A user can now also easier override this, e.g., by simply adding a drop-in file in the respective /etc path. Fixes failure to rename network names to "predictable" names on boot as reported in the forum: https://forum.proxmox.com/threads/135635/page-6#post-606130 Signed-off-by: Thomas Lamprecht --- configs/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/configs/Makefile b/configs/Makefile index 8cdb11e32..fa586e280 100644 --- a/configs/Makefile +++ b/configs/Makefile @@ -13,7 +13,7 @@ install: country.dat vzdump.conf pve-sources.list pve-initramfs.conf pve-blackli install -D -m 0644 vzdump.conf $(DESTDIR)/etc/vzdump.conf install -D -m 0644 pve-initramfs.conf $(DESTDIR)/etc/initramfs-tools/conf.d/pve-initramfs.conf install -D -m 0644 country.dat $(DESTDIR)/usr/share/$(PACKAGE)/country.dat - install -D -m 0644 proxmox-ve-default.link $(DESTDIR)/usr/lib/systemd/network/98-proxmox-ve-default.link + install -D -m 0644 proxmox-ve-default.link $(DESTDIR)/usr/lib/systemd/network/99-default.link.d/proxmox-mac-address-policy.conf clean: rm -f country.dat From fd1a0ae1b385cdcd77032b28305523b10bc79a8f Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Sat, 18 Nov 2023 12:23:34 +0100 Subject: [PATCH 342/398] bump version to 8.0.9 Signed-off-by: Thomas Lamprecht --- debian/changelog | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/debian/changelog b/debian/changelog index b77fb84e3..15b0e77f7 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,11 @@ +pve-manager (8.0.9) bookworm; urgency=medium + + * move default link config to drop-in snippet to avoid that the all default + fallback settings are overridden, not just the MACAddressPolicy one we + actually want to change. + + -- Proxmox Support Team Sat, 18 Nov 2023 12:18:49 +0100 + pve-manager (8.0.8) bookworm; urgency=medium * update shipped apliance info index From 4f4941f77b81daf7efb5588c1ea7db8e88b4a3fd Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Sun, 19 Nov 2023 19:54:35 +0100 Subject: [PATCH 343/398] update shipped appliance info index Signed-off-by: Thomas Lamprecht --- aplinfo/aplinfo.dat | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/aplinfo/aplinfo.dat b/aplinfo/aplinfo.dat index 3c8d13fe1..02d9af232 100644 --- a/aplinfo/aplinfo.dat +++ b/aplinfo/aplinfo.dat @@ -112,6 +112,19 @@ sha512sum: 54328a3338ca9657d298a8a5d2ca15fe76f66fd407296d9e3e1c236ee60ea075d3406 Infopage: https://linuxcontainers.org Description: LXC default image for fedora 38 (20230607) +Package: fedora-39-default +Version: 20231118 +Type: lxc +OS: fedora +Section: system +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: system/fedora-39-default_20231118_amd64.tar.xz +md5sum: 5e50babc8d5a70ec0bb00a0bc147c64d +sha512sum: 921cde6021e3c109e0d560b3e8ff4968c885fad8bcd8c2796d9ca733d362be8dd407d0cb4322d5039f306e4a9bfc8f00b66e138bb513c0c9853c20a5fed379f7 +Infopage: https://linuxcontainers.org +Description: LXC default image for fedora 39 (20231118) + Package: gentoo-current-openrc Version: 20231009 Type: lxc @@ -138,6 +151,19 @@ sha512sum: 8089309652a0db23ddff826d1e343e79c6eccb7b615fb309e0a6f6f1983ea697aa940 Infopage: https://linuxcontainers.org Description: LXC default image for opensuse 15.4 (20221109) +Package: opensuse-15.5-default +Version: 20231118 +Type: lxc +OS: opensuse +Section: system +Maintainer: Proxmox Support Team +Architecture: amd64 +Location: system/opensuse-15.5-default_20231118_amd64.tar.xz +md5sum: 18c8566b848c907ec74cee3a45348957 +sha512sum: f3a6785c347da3867d074345b68db9c99ec2b269e454f715d234935014ca1dc9f723956f15e4fcc714fa993e4d7c7997faf6bbcc2add6c74d4754c217b6d1712 +Infopage: https://linuxcontainers.org +Description: LXC default image for opensuse 15.5 (20231118) + Package: proxmox-mailgateway-7.3-standard Version: 7.3-1 Type: lxc From a5216e22bdebe7965412577a674ec10f76fc40f7 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 20 Nov 2023 09:02:42 +0100 Subject: [PATCH 344/398] ui: fix zero-sized panels on fresh chrome start it seems in new versions of chrome , this triggers too early on a fresh start (when autostarting a pve tab), resulting in the 'viewWidth'/'viewHeight' being zero pixels. This means we set the width of the left and the height of the bottom panel to zero pixels, making them functionally invisible. To prevent that, check that the 'viewWidth'/'viewHeight' is big enough so that the panels still have least 50 pixels left before setting their size. Reported in the Forum: https://forum.proxmox.com/threads/136636/ Signed-off-by: Dominik Csapak [ TL: point to forum thread ] Signed-off-by: Thomas Lamprecht --- www/manager6/Workspace.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js index 6e465f8d3..89ca47b7c 100644 --- a/www/manager6/Workspace.js +++ b/www/manager6/Workspace.js @@ -485,7 +485,7 @@ Ext.define('PVE.StdWorkspace', { listeners: { resize: function(panel, width, height) { var viewWidth = me.getSize().width; - if (width > viewWidth - 100) { + if (width > viewWidth - 100 && viewWidth > 150) { panel.setWidth(viewWidth - 100); } }, @@ -506,7 +506,7 @@ Ext.define('PVE.StdWorkspace', { listeners: { resize: function(panel, width, height) { var viewHeight = me.getSize().height; - if (height > viewHeight - 150) { + if (height > viewHeight - 150 && viewHeight > 200) { panel.setHeight(viewHeight - 150); } }, From c5026e468779abb9dbabeef2e687822f14420e3b Mon Sep 17 00:00:00 2001 From: Wolfgang Bumiller Date: Mon, 20 Nov 2023 12:26:12 +0100 Subject: [PATCH 345/398] bump access-control to 8.0.7 for nested pools Signed-off-by: Wolfgang Bumiller --- debian/control | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index 36790b834..7771bd9e3 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Build-Depends: debhelper-compat (= 13), libpod-parser-perl, libproxmox-acme-perl, libproxmox-rs-perl (>= 0.2.0), - libpve-access-control (>= 8.0.5), + libpve-access-control (>= 8.0.7), libpve-cluster-api-perl, libpve-cluster-perl (>= 6.1-6), libpve-common-perl (>= 7.2-6), @@ -57,7 +57,7 @@ Depends: apt (>= 1.5~), libproxmox-acme-perl, libproxmox-acme-plugins, libproxmox-rs-perl (>= 0.2.0), - libpve-access-control (>= 8.0.5), + libpve-access-control (>= 8.0.7), libpve-cluster-api-perl (>= 7.0-5), libpve-cluster-perl (>= 7.2-3), libpve-common-perl (>= 7.2-7), From 4448f8d3fa399576f161cad73c13032f8b6d3ca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Mon, 20 Nov 2023 08:22:41 +0100 Subject: [PATCH 346/398] fix #1148: api: pools: support nested pools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit since poolid can now contain `/`, it's not possible to use it (properly) as path parameter anymore. accordingly: - merge `read_pool` (`GET /pools/{poolid}`) into 'index' (`GET /pools/?poolid={poolid}`) (requires clients to extract the only member of the returned array if they want to query an individual pool) - move `update_pool` to `/pools`, deprecating the old variant with path parameter - move `delete_pool` to `/pools`, deprecating the old variant with path parameter - deprecate `read_pool` API endpoint pool creation is blocked for nested pools where the parent does not already exist. similarly, the checks for deletion are extended to block deletion if sub-pools still exist. the old API endpoints continue to work for non-nested pools. `pvesh ls /pools` is semi-broken for nested pools, listing the entries, but no methods on them, since they reference the old API. fixing this would require extending the REST handling to support a new type of child reference. Signed-off-by: Fabian Grünbichler --- PVE/API2/Pool.pm | 249 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 187 insertions(+), 62 deletions(-) diff --git a/PVE/API2/Pool.pm b/PVE/API2/Pool.pm index 51ac71941..54e744558 100644 --- a/PVE/API2/Pool.pm +++ b/PVE/API2/Pool.pm @@ -20,14 +20,26 @@ __PACKAGE__->register_method ({ name => 'index', path => '', method => 'GET', - description => "Pool index.", + description => "List pools or get pool configuration.", permissions => { - description => "List all pools where you have Pool.Audit permissions on /pool/.", + description => "List all pools where you have Pool.Audit permissions on /pool/, or the pool specific with {poolid}", user => 'all', }, parameters => { additionalProperties => 0, - properties => {}, + properties => { + poolid => { + type => 'string', + format => 'pve-poolid', + optional => 1, + }, + type => { + type => 'string', + enum => [ 'qemu', 'lxc', 'storage' ], + optional => 1, + requires => 'poolid', + }, + }, }, returns => { type => 'array', @@ -35,6 +47,38 @@ __PACKAGE__->register_method ({ type => "object", properties => { poolid => { type => 'string' }, + comment => { + type => 'string', + optional => 1, + }, + members => { + type => 'array', + optional => 1, + items => { + type => "object", + additionalProperties => 1, + properties => { + type => { + type => 'string', + enum => [ 'qemu', 'lxc', 'openvz', 'storage' ], + }, + id => { + type => 'string', + }, + node => { + type => 'string', + }, + vmid => { + type => 'integer', + optional => 1, + }, + storage => { + type => 'string', + optional => 1, + }, + }, + }, + }, }, }, links => [ { rel => 'child', href => "{poolid}" } ], @@ -47,15 +91,63 @@ __PACKAGE__->register_method ({ my $usercfg = $rpcenv->{user_cfg}; - my $res = []; - for my $pool (sort keys %{$usercfg->{pools}}) { - next if !$rpcenv->check($authuser, "/pool/$pool", [ 'Pool.Audit' ], 1); + if (my $poolid = $param->{poolid}) { + $rpcenv->check($authuser, "/pool/$poolid", [ 'Pool.Audit' ], 1); - my $entry = { poolid => $pool }; - my $pool_config = $usercfg->{pools}->{$pool}; - $entry->{comment} = $pool_config->{comment} if defined($pool_config->{comment}); - push @$res, $entry; + my $vmlist = PVE::Cluster::get_vmlist() || {}; + my $idlist = $vmlist->{ids} || {}; + + my $rrd = PVE::Cluster::rrd_dump(); + + my $pool_config = $usercfg->{pools}->{$poolid}; + + die "pool '$poolid' does not exist\n" if !$pool_config; + + my $members = []; + for my $vmid (sort keys %{$pool_config->{vms}}) { + my $vmdata = $idlist->{$vmid}; + next if !$vmdata || defined($param->{type}) && $param->{type} ne $vmdata->{type}; + my $entry = PVE::API2Tools::extract_vm_stats($vmid, $vmdata, $rrd); + push @$members, $entry; + } + + my $nodename = PVE::INotify::nodename(); + my $cfg = PVE::Storage::config(); + if (!defined($param->{type}) || $param->{type} eq 'storage') { + for my $storeid (sort keys %{$pool_config->{storage}}) { + my $scfg = PVE::Storage::storage_config ($cfg, $storeid, 1); + next if !$scfg; + + my $storage_node = $nodename; # prefer local node + if ($scfg->{nodes} && !$scfg->{nodes}->{$storage_node}) { + for my $node (sort keys(%{$scfg->{nodes}})) { + $storage_node = $node; + last; + } + } + + my $entry = PVE::API2Tools::extract_storage_stats($storeid, $scfg, $storage_node, $rrd); + push @$members, $entry; + } + } + + my $pool_info = { + members => $members, + }; + $pool_info->{comment} = $pool_config->{comment} if defined($pool_config->{comment}); + $pool_info->{poolid} = $poolid; + + push @$res, $pool_info; + } else { + for my $pool (sort keys %{$usercfg->{pools}}) { + next if !$rpcenv->check($authuser, "/pool/$pool", [ 'Pool.Audit' ], 1); + + my $entry = { poolid => $pool }; + my $pool_config = $usercfg->{pools}->{$pool}; + $entry->{comment} = $pool_config->{comment} if defined($pool_config->{comment}); + push @$res, $entry; + } } return $res; @@ -92,6 +184,11 @@ __PACKAGE__->register_method ({ my $pool = $param->{poolid}; die "pool '$pool' already exists\n" if $usercfg->{pools}->{$pool}; + if ($pool =~ m!^(.*)/[^/]+$!) { + my $parent = $1; + die "parent '$parent' of pool '$pool' does not exist\n" + if !defined($usercfg->{pools}->{$parent}); + } $usercfg->{pools}->{$pool} = { vms => {}, @@ -107,7 +204,7 @@ __PACKAGE__->register_method ({ }}); __PACKAGE__->register_method ({ - name => 'update_pool', + name => 'update_pool_deprecated', protected => 1, path => '{poolid}', method => 'PUT', @@ -115,9 +212,56 @@ __PACKAGE__->register_method ({ description => "You also need the right to modify permissions on any object you add/delete.", check => ['perm', '/pool/{poolid}', ['Pool.Allocate']], }, - description => "Update pool data.", + description => "Update pool data (deprecated, no support for nested pools - use 'PUT /pools/?poolid={poolid}' instead).", parameters => { - additionalProperties => 0, + additionalProperties => 0, + properties => { + poolid => { type => 'string', format => 'pve-poolid' }, + comment => { type => 'string', optional => 1 }, + vms => { + description => 'List of guest VMIDs to add or remove from this pool.', + type => 'string', format => 'pve-vmid-list', + optional => 1, + }, + storage => { + description => 'List of storage IDs to add or remove from this pool.', + type => 'string', format => 'pve-storage-id-list', + optional => 1, + }, + 'allow-move' => { + description => 'Allow adding a guest even if already in another pool.' + .' The guest will be removed from its current pool and added to this one.', + type => 'boolean', + optional => 1, + default => 0, + }, + delete => { + description => 'Remove the passed VMIDs and/or storage IDs instead of adding them.', + type => 'boolean', + optional => 1, + default => 0, + }, + }, + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + return __PACKAGE__->update_pool($param); + }}); + +__PACKAGE__->register_method ({ + name => 'update_pool', + protected => 1, + path => '', + method => 'PUT', + permissions => { + description => "You also need the right to modify permissions on any object you add/delete.", + check => ['perm', '/pool/{poolid}', ['Pool.Allocate']], + }, + description => "Update pool.", + parameters => { + additionalProperties => 0, properties => { poolid => { type => 'string', format => 'pve-poolid' }, comment => { type => 'string', optional => 1 }, @@ -215,7 +359,7 @@ __PACKAGE__->register_method ({ permissions => { check => ['perm', '/pool/{poolid}', ['Pool.Audit']], }, - description => "Get pool configuration.", + description => "Get pool configuration (deprecated, no support for nested pools, use 'GET /pools/?poolid={poolid}').", parameters => { additionalProperties => 0, properties => { @@ -270,58 +414,13 @@ __PACKAGE__->register_method ({ code => sub { my ($param) = @_; - my $usercfg = cfs_read_file("user.cfg"); - - my $vmlist = PVE::Cluster::get_vmlist() || {}; - my $idlist = $vmlist->{ids} || {}; - - my $rrd = PVE::Cluster::rrd_dump(); - - my $pool = $param->{poolid}; - - my $pool_config = $usercfg->{pools}->{$pool}; - - die "pool '$pool' does not exist\n" if !$pool_config; - - my $members = []; - for my $vmid (sort keys %{$pool_config->{vms}}) { - my $vmdata = $idlist->{$vmid}; - next if !$vmdata || defined($param->{type}) && $param->{type} ne $vmdata->{type}; - my $entry = PVE::API2Tools::extract_vm_stats($vmid, $vmdata, $rrd); - push @$members, $entry; - } - - my $nodename = PVE::INotify::nodename(); - my $cfg = PVE::Storage::config(); - if (!defined($param->{type}) || $param->{type} eq 'storage') { - for my $storeid (sort keys %{$pool_config->{storage}}) { - my $scfg = PVE::Storage::storage_config ($cfg, $storeid, 1); - next if !$scfg; - - my $storage_node = $nodename; # prefer local node - if ($scfg->{nodes} && !$scfg->{nodes}->{$storage_node}) { - for my $node (sort keys(%{$scfg->{nodes}})) { - $storage_node = $node; - last; - } - } - - my $entry = PVE::API2Tools::extract_storage_stats($storeid, $scfg, $storage_node, $rrd); - push @$members, $entry; - } - } - - my $res = { - members => $members, - }; - $res->{comment} = $pool_config->{comment} if defined($pool_config->{comment}); - - return $res; + my $pool_info = __PACKAGE__->index($param); + return $pool_info->[0]; }}); __PACKAGE__->register_method ({ - name => 'delete_pool', + name => 'delete_pool_deprecated', protected => 1, path => '{poolid}', method => 'DELETE', @@ -329,6 +428,29 @@ __PACKAGE__->register_method ({ description => "You can only delete empty pools (no members).", check => ['perm', '/pool/{poolid}', ['Pool.Allocate']], }, + description => "Delete pool (deprecated, no support for nested pools, use 'DELETE /pools/?poolid={poolid}').", + parameters => { + additionalProperties => 0, + properties => { + poolid => { type => 'string', format => 'pve-poolid' }, + } + }, + returns => { type => 'null' }, + code => sub { + my ($param) = @_; + + return __PACKAGE__->delete_pool($param); + }}); + +__PACKAGE__->register_method ({ + name => 'delete_pool', + protected => 1, + path => '', + method => 'DELETE', + permissions => { + description => "You can only delete empty pools (no members).", + check => ['perm', '/pool/{poolid}', ['Pool.Allocate']], + }, description => "Delete pool.", parameters => { additionalProperties => 0, @@ -354,6 +476,9 @@ __PACKAGE__->register_method ({ my $pool_config = $usercfg->{pools}->{$pool}; die "pool '$pool' does not exist\n" if !$pool_config; + for my $subpool (sort keys %{$pool_config->{pools}}) { + die "pool '$pool' is not empty (contains pool '$subpool')\n"; + } for my $vmid (sort keys %{$pool_config->{vms}}) { next if !$idlist->{$vmid}; # ignore destroyed guests From cd731902b7a724b1ab747276f9c6343734f1d8cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Mon, 20 Nov 2023 08:22:42 +0100 Subject: [PATCH 347/398] ui: pools: switch to new API endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit which support nested pools. mostly straight-forward, only pool deletion and the members grid need some special attention. Signed-off-by: Fabian Grünbichler --- www/manager6/dc/PoolView.js | 3 +++ www/manager6/grid/PoolMembers.js | 14 ++++++++------ www/manager6/pool/StatusView.js | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/www/manager6/dc/PoolView.js b/www/manager6/dc/PoolView.js index db97cbe72..741b2025b 100644 --- a/www/manager6/dc/PoolView.js +++ b/www/manager6/dc/PoolView.js @@ -31,6 +31,9 @@ Ext.define('PVE.dc.PoolView', { callback: function() { reload(); }, + getUrl: function(rec) { + return '/pools/?poolid=' + rec.getId(); + }, }); var run_editor = function() { diff --git a/www/manager6/grid/PoolMembers.js b/www/manager6/grid/PoolMembers.js index 74950d80e..75f20cab1 100644 --- a/www/manager6/grid/PoolMembers.js +++ b/www/manager6/grid/PoolMembers.js @@ -17,8 +17,9 @@ Ext.define('PVE.pool.AddVM', { throw "no pool specified"; } - me.url = "/pools/" + me.pool; + me.url = '/pools/'; me.method = 'PUT'; + me.extraRequestParams.poolid = me.pool; var vmsField = Ext.create('Ext.form.field.Text', { name: 'vms', @@ -120,8 +121,9 @@ Ext.define('PVE.pool.AddStorage', { me.isCreate = true; me.isAdd = true; - me.url = "/pools/" + me.pool; + me.url = "/pools/"; me.method = 'PUT'; + me.extraRequestParams.poolid = me.pool; Ext.apply(me, { subject: gettext('Storage'), @@ -168,8 +170,8 @@ Ext.define('PVE.grid.PoolMembers', { ], proxy: { type: 'proxmox', - root: 'data.members', - url: "/api2/json/pools/" + me.pool, + root: 'data[0].members', + url: "/api2/json/pools/?poolid=" + me.pool, }, }); @@ -192,7 +194,7 @@ Ext.define('PVE.grid.PoolMembers', { "'" + rec.data.id + "'"); }, handler: function(btn, event, rec) { - var params = { 'delete': 1 }; + var params = { 'delete': 1, poolid: me.pool }; if (rec.data.type === 'storage') { params.storage = rec.data.storage; } else if (rec.data.type === 'qemu' || rec.data.type === 'lxc' || rec.data.type === 'openvz') { @@ -202,7 +204,7 @@ Ext.define('PVE.grid.PoolMembers', { } Proxmox.Utils.API2Request({ - url: '/pools/' + me.pool, + url: '/pools/', method: 'PUT', params: params, waitMsgTarget: me, diff --git a/www/manager6/pool/StatusView.js b/www/manager6/pool/StatusView.js index 302ae5ab0..3d46b3b1a 100644 --- a/www/manager6/pool/StatusView.js +++ b/www/manager6/pool/StatusView.js @@ -24,7 +24,7 @@ Ext.define('PVE.pool.StatusView', { }; Ext.apply(me, { - url: "/api2/json/pools/" + pool, + url: "/api2/json/pools/?poolid=" + pool, rows: rows, }); From fc7b556d4ff7dee169615028efa06acad6fecacf Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 20 Nov 2023 16:45:42 +0100 Subject: [PATCH 348/398] ui: refactor iso-selector out of the cd input panel and make it into a proper field. it's intended to be used like a single field, otherwise exactly as before Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/Makefile | 1 + www/manager6/form/IsoSelector.js | 107 +++++++++++++++++++++++++++++++ www/manager6/qemu/CDEdit.js | 40 +++--------- 3 files changed, 116 insertions(+), 32 deletions(-) create mode 100644 www/manager6/form/IsoSelector.js diff --git a/www/manager6/Makefile b/www/manager6/Makefile index ee09f0b80..c63361c30 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -88,6 +88,7 @@ JSSRC= \ form/TagEdit.js \ form/MultiFileButton.js \ form/TagFieldSet.js \ + form/IsoSelector.js \ grid/BackupView.js \ grid/FirewallAliases.js \ grid/FirewallOptions.js \ diff --git a/www/manager6/form/IsoSelector.js b/www/manager6/form/IsoSelector.js new file mode 100644 index 000000000..632ee7f0a --- /dev/null +++ b/www/manager6/form/IsoSelector.js @@ -0,0 +1,107 @@ +Ext.define('PVE.form.IsoSelector', { + extend: 'Ext.container.Container', + alias: 'widget.pveIsoSelector', + mixins: [ + 'Ext.form.field.Field', + 'Proxmox.Mixin.CBind', + ], + + nodename: undefined, + insideWizard: false, + + cbindData: function() { + let me = this; + return { + nodename: me.nodename, + insideWizard: me.insideWizard, + }; + }, + + getValue: function() { + return this.lookup('file').getValue(); + }, + + setValue: function(value) { + let me = this; + if (!value) { + me.lookup('file').reset(); + return; + } + var match = value.match(/^([^:]+):/); + if (match) { + me.lookup('storage').setValue(match[1]); + me.lookup('file').setValue(value); + } + }, + + getErrors: function() { + let me = this; + me.lookup('storage').validate(); + let file = me.lookup('file'); + file.validate(); + let value = file.getValue(); + if (!value || !value.length) { + return [""]; // for validation + } + return []; + }, + + setNodename: function(nodename) { + let me = this; + me.lookup('storage').setNodename(nodename); + me.lookup('file').setStorage(undefined, nodename); + }, + + setDisabled: function(disabled) { + let me = this; + me.lookup('storage').setDisabled(disabled); + me.lookup('file').setDisabled(disabled); + me.callParent(); + }, + + referenceHolder: true, + + items: [ + { + xtype: 'pveStorageSelector', + reference: 'storage', + isFormField: false, + fieldLabel: gettext('Storage'), + labelAlign: 'right', + storageContent: 'iso', + allowBlank: false, + cbind: { + nodename: '{nodename}', + autoSelect: '{insideWizard}', + insideWizard: '{insideWizard}', + disabled: '{disabled}', + }, + listeners: { + change: function(f, value) { + let me = this; + let selector = me.up('pveIsoSelector'); + selector.lookup('file').setStorage(value); + selector.checkChange(); + }, + }, + }, + { + xtype: 'pveFileSelector', + reference: 'file', + isFormField: false, + storageContent: 'iso', + fieldLabel: gettext('ISO image'), + labelAlign: 'right', + cbind: { + nodename: '{nodename}', + disabled: '{disabled}', + }, + allowBlank: false, + listeners: { + change: function() { + this.up('pveIsoSelector').checkChange(); + }, + }, + }, + ], +}); diff --git a/www/manager6/qemu/CDEdit.js b/www/manager6/qemu/CDEdit.js index fc7a59cc7..3cc16205e 100644 --- a/www/manager6/qemu/CDEdit.js +++ b/www/manager6/qemu/CDEdit.js @@ -43,11 +43,7 @@ Ext.define('PVE.qemu.CDInputPanel', { values.mediaType = 'none'; } else { values.mediaType = 'iso'; - var match = drive.file.match(/^([^:]+):/); - if (match) { - values.cdstorage = match[1]; - values.cdimage = drive.file; - } + values.cdimage = drive.file; } me.drive = drive; @@ -58,8 +54,7 @@ Ext.define('PVE.qemu.CDInputPanel', { setNodename: function(nodename) { var me = this; - me.cdstoragesel.setNodename(nodename); - me.cdfilesel.setStorage(undefined, nodename); + me.isosel.setNodename(nodename); }, initComponent: function() { @@ -87,8 +82,7 @@ Ext.define('PVE.qemu.CDInputPanel', { if (!me.rendered) { return; } - me.down('field[name=cdstorage]').setDisabled(!value); - var cdImageField = me.down('field[name=cdimage]'); + var cdImageField = me.down('pveIsoSelector'); cdImageField.setDisabled(!value); if (value) { cdImageField.validate(); @@ -99,32 +93,14 @@ Ext.define('PVE.qemu.CDInputPanel', { }, }); - me.cdfilesel = Ext.create('PVE.form.FileSelector', { + + me.isosel = Ext.create('PVE.form.IsoSelector', { + nodename: me.nodename, + insideWizard: me.insideWizard, name: 'cdimage', - nodename: me.nodename, - storageContent: 'iso', - fieldLabel: gettext('ISO image'), - labelAlign: 'right', - allowBlank: false, }); - me.cdstoragesel = Ext.create('PVE.form.StorageSelector', { - name: 'cdstorage', - nodename: me.nodename, - fieldLabel: gettext('Storage'), - labelAlign: 'right', - storageContent: 'iso', - allowBlank: false, - autoSelect: me.insideWizard, - listeners: { - change: function(f, value) { - me.cdfilesel.setStorage(value); - }, - }, - }); - - items.push(me.cdstoragesel); - items.push(me.cdfilesel); + items.push(me.isosel); items.push({ xtype: 'radiofield', From 5b2e8bd4d1ac0a40248fdf38cf862240d4c5bffa Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Mon, 20 Nov 2023 16:45:43 +0100 Subject: [PATCH 349/398] ui: vm wizard: allow second iso for windows vms Having a second CD-drive is useful for adding the virtio-win driver ISO for new installs, and thus we change the default disk type to scsi and network type to VirtIO. Add special logic to the OSTypeInputPanel when 'insideWizard' is true to add an additional checkbox + iso selector Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/qemu/CreateWizard.js | 3 ++ www/manager6/qemu/MultiHDEdit.js | 8 ++- www/manager6/qemu/OSTypeEdit.js | 84 ++++++++++++++++++++++++++++++- 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/www/manager6/qemu/CreateWizard.js b/www/manager6/qemu/CreateWizard.js index 74b1feb61..443e42c6b 100644 --- a/www/manager6/qemu/CreateWizard.js +++ b/www/manager6/qemu/CreateWizard.js @@ -161,6 +161,9 @@ Ext.define('PVE.qemu.CreateWizard', { { xtype: 'pveQemuOSTypePanel', insideWizard: true, + bind: { + nodename: '{nodename}', + }, }, ], }, diff --git a/www/manager6/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js index caf74fad6..27884f3f3 100644 --- a/www/manager6/qemu/MultiHDEdit.js +++ b/www/manager6/qemu/MultiHDEdit.js @@ -37,11 +37,17 @@ Ext.define('PVE.qemu.MultiHDPanel', { let me = this; let vm = me.getViewModel(); - return { + let res = { ide2: 'media=cdrom', scsihw: vm.get('current.scsihw'), ostype: vm.get('current.ostype'), }; + + if (vm.get('current.ide0') === "some") { + res.ide0 = "media=cdrom"; + } + + return res; }, diskSorter: { diff --git a/www/manager6/qemu/OSTypeEdit.js b/www/manager6/qemu/OSTypeEdit.js index 3332a0bc1..109f645dc 100644 --- a/www/manager6/qemu/OSTypeEdit.js +++ b/www/manager6/qemu/OSTypeEdit.js @@ -14,9 +14,21 @@ Ext.define('PVE.qemu.OSTypeInputPanel', { afterrender: 'onOSTypeChange', change: 'onOSTypeChange', }, + 'checkbox[reference=enableSecondCD]': { + change: 'onSecondCDChange', + }, }, onOSBaseChange: function(field, value) { - this.lookup('ostype').getStore().setData(PVE.Utils.kvm_ostypes[value]); + let me = this; + me.lookup('ostype').getStore().setData(PVE.Utils.kvm_ostypes[value]); + if (me.getView().insideWizard) { + let isWindows = value === 'Microsoft Windows'; + let enableSecondCD = me.lookup('enableSecondCD'); + enableSecondCD.setVisible(isWindows); + if (!isWindows) { + enableSecondCD.setValue(false); + } + } }, onOSTypeChange: function(field) { var me = this, ostype = field.getValue(); @@ -42,6 +54,48 @@ Ext.define('PVE.qemu.OSTypeInputPanel', { // ignore multiple disks, we only want to set the type if there is a single disk } }, + onSecondCDChange: function(widget, value, lastValue) { + let me = this; + let vm = me.getViewModel(); + let updateVMConfig = function () { + let widgets = Ext.ComponentQuery.query('pveMultiHDPanel'); + if (widgets.length === 1) { + widgets[0].getController().updateVMConfig(); + } + }; + if (value) { + // only for windows + vm.set('current.ide0', "some"); + vm.notify(); + updateVMConfig(); + me.setWidget('pveBusSelector', 'scsi'); + me.setWidget('pveNetworkCardSelector', 'virtio'); + } else { + vm.set('current.ide0', ""); + vm.notify(); + updateVMConfig(); + me.setWidget('pveBusSelector', 'scsi'); + let ostype = me.lookup('ostype').getValue(); + var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype); + me.setWidget('pveBusSelector', targetValues.busType); + } + }, + }, + + setNodename: function(nodename) { + var me = this; + me.lookup('isoSelector').setNodename(nodename); + }, + + onGetValues: function(values) { + if (values.ide0) { + let drive = { + media: 'cdrom', + file: values.ide0, + }; + values.ide0 = PVE.Parser.printQemuDrive(drive); + } + return values; }, initComponent: function() { @@ -92,6 +146,34 @@ Ext.define('PVE.qemu.OSTypeInputPanel', { }, ]; + if (me.insideWizard) { + me.items.push( + { + xtype: 'proxmoxcheckbox', + reference: 'enableSecondCD', + isFormField: false, + hidden: true, + checked: false, + boxLabel: gettext('Add Second CD/DVD Image file (iso)'), + listeners: { + change: function(cb, value) { + me.lookup('isoSelector').setDisabled(!value); + me.lookup('isoSelector').setHidden(!value); + }, + }, + }, + { + xtype: 'pveIsoSelector', + reference: 'isoSelector', + name: 'ide0', + nodename: me.nodename, + insideWizard: true, + hidden: true, + disabled: true, + }, + ); + } + me.callParent(); }, }); From fc3a88ad0d842f3405c0ecc753acd0e3d1721ab9 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Tue, 21 Nov 2023 09:35:51 +0100 Subject: [PATCH 350/398] ui: qemu wizard: use better boot order for second cd drive in the case we add a second cd drive (for windows), we don't want the backend logic to only include the first one, since we cannot know which is bootable and which is (probably) the virtio iso. so instead, emulate the backend logic for the wizard but include both cd drives in that case, otherwise let the backend decide like before Signed-off-by: Dominik Csapak --- www/manager6/qemu/CreateWizard.js | 49 ++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/www/manager6/qemu/CreateWizard.js b/www/manager6/qemu/CreateWizard.js index 443e42c6b..375f0c667 100644 --- a/www/manager6/qemu/CreateWizard.js +++ b/www/manager6/qemu/CreateWizard.js @@ -26,6 +26,41 @@ Ext.define('PVE.qemu.CreateWizard', { subject: gettext('Virtual Machine'), + // fot the special case that we have 2 cdrom drives + // + // emulates part of the backend bootorder logic, but includes all + // cdrom drives since we don't know which one the user put in a bootable iso + // and hardcodes the known values (ide0/2, net0) + calculateBootOrder: function(values) { + // user selected windows + second cdrom + if (values.ide0 && values.ide0.match(/media=cdrom/)) { + let disk; + PVE.Utils.forEachBus(['ide', 'scsi', 'virtio', 'sata'], (type, id) => { + let confId = type + id; + if (!values[confId]) { + return undefined; + } + if (values[confId].match(/media=cdrom/)) { + return undefined; + } + disk = confId; + return false; // abort loop + }); + + let order = []; + if (disk) { + order.push(disk); + } + order.push('ide0', 'ide2'); + if (values.net0) { + order.push('net0'); + } + + return `order=${order.join(';')}`; + } + return undefined; + }, + items: [ { xtype: 'inputpanel', @@ -228,8 +263,15 @@ Ext.define('PVE.qemu.CreateWizard', { ], listeners: { show: function(panel) { - var kv = this.up('window').getValues(); + let wizard = this.up('window'); + var kv = wizard.getValues(); var data = []; + + let boot = wizard.calculateBootOrder(kv); + if (boot) { + kv.boot = boot; + } + Ext.Object.each(kv, function(key, value) { if (key === 'delete') { // ignore return; @@ -254,6 +296,11 @@ Ext.define('PVE.qemu.CreateWizard', { var nodename = kv.nodename; delete kv.nodename; + let boot = wizard.calculateBootOrder(kv); + if (boot) { + kv.boot = boot; + } + Proxmox.Utils.API2Request({ url: '/nodes/' + nodename + '/qemu', waitMsgTarget: wizard, From 76543c7397f355e436ca106a7e2e4c7134336caa Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 21 Nov 2023 14:16:34 +0100 Subject: [PATCH 351/398] ui: vm wizard: reword label for extra drive for virtio-drivers while a user can attach anything, we change the defaults for, e.g., scsi controller or network to virtio if this is ticked, so try to hint that a bit better Signed-off-by: Thomas Lamprecht --- www/manager6/qemu/OSTypeEdit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/qemu/OSTypeEdit.js b/www/manager6/qemu/OSTypeEdit.js index 109f645dc..0a59de323 100644 --- a/www/manager6/qemu/OSTypeEdit.js +++ b/www/manager6/qemu/OSTypeEdit.js @@ -154,7 +154,7 @@ Ext.define('PVE.qemu.OSTypeInputPanel', { isFormField: false, hidden: true, checked: false, - boxLabel: gettext('Add Second CD/DVD Image file (iso)'), + boxLabel: gettext('Add additional drive for VirtIO drivers'), listeners: { change: function(cb, value) { me.lookup('isoSelector').setDisabled(!value); From 1572e9735863103bb525b16b8e0a2af95ce11dd2 Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Fri, 29 Sep 2023 15:02:01 +0200 Subject: [PATCH 352/398] api: ceph: add endpoint to fetch config keys This new endpoint allows to get the values of config keys that are either set in the config db or the ceph.conf file. Values that are set in the ceph.conf file have priority over values set in the conifg db via 'ceph config set'. Expects the --config-keys parameter as a semicolon separated list of "
:" where the section is a section in the ceph.conf or config db. For example: global:osd_pool_default_size Signed-off-by: Aaron Lauterer Tested-by: Maximiliano Sandoval Signed-off-by: Thomas Lamprecht --- PVE/API2/Ceph/Cfg.pm | 82 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/PVE/API2/Ceph/Cfg.pm b/PVE/API2/Ceph/Cfg.pm index 2225a1acb..f06c42f4c 100644 --- a/PVE/API2/Ceph/Cfg.pm +++ b/PVE/API2/Ceph/Cfg.pm @@ -4,6 +4,7 @@ use strict; use warnings; use PVE::Ceph::Tools; +use PVE::Cluster qw(cfs_read_file); use PVE::JSONSchema qw(get_standard_option); use PVE::RADOS; use PVE::Tools qw(file_get_contents); @@ -36,6 +37,7 @@ __PACKAGE__->register_method ({ my $result = [ { name => 'raw' }, { name => 'db' }, + { name => 'value' }, ]; return $result; @@ -110,3 +112,83 @@ __PACKAGE__->register_method ({ return $res; }}); + + +my $SINGLE_CONFIGKEY_RE = qr/[0-9a-z\-_\.]+:[0-9a-zA-Z\-_]+/i; +my $CONFIGKEYS_RE = qr/^(:?${SINGLE_CONFIGKEY_RE})(:?[;, ]${SINGLE_CONFIGKEY_RE})*$/; + +__PACKAGE__->register_method ({ + name => 'value', + path => 'value', + method => 'GET', + proxyto => 'node', + protected => 1, + permissions => { + check => ['perm', '/', [ 'Sys.Audit' ]], + }, + description => "Get configured values from either the config file or config DB.", + parameters => { + additionalProperties => 0, + properties => { + node => get_standard_option('pve-node'), + 'config-keys' => { + type => "string", + typetext => "
:[;
:]", + pattern => $CONFIGKEYS_RE, + description => "List of
: items.", + } + }, + }, + returns => { + type => 'object', + description => "Contains {section}->{key} children with the values", + }, + code => sub { + my ($param) = @_; + + PVE::Ceph::Tools::check_ceph_inited(); + + # Ceph treats '-' and '_' the same in parameter names, stick with '-' + my $normalize = sub { + my $t = shift; + $t =~ s/_/-/g; + return $t; + }; + + my $requested_keys = {}; + for my $pair (PVE::Tools::split_list($param->{'config-keys'})) { + my ($section, $key) = split(":", $pair); + $section = $normalize->($section); + $key = $normalize->($key); + + $requested_keys->{$section}->{$key} = 1; + } + + my $config = {}; + + my $rados = PVE::RADOS->new(); + my $configdb = $rados->mon_command( { prefix => 'config dump', format => 'json' }); + for my $s (@{$configdb}) { + my ($section, $name, $value) = $s->@{'section', 'name', 'value'}; + my $n_section = $normalize->($section); + my $n_name = $normalize->($name); + + $config->{$n_section}->{$n_name} = $value + if defined $requested_keys->{$n_section} && $n_name eq $n_name; + } + + # read ceph.conf after config db as it has priority if settings are present in both + my $config_file = cfs_read_file('ceph.conf'); # cfs_read_file to get it parsed + for my $section (keys %{$config_file}) { + my $n_section = $normalize->($section); + next if !defined $requested_keys->{$n_section}; + + for my $key (keys %{$config_file->{$section}}) { + my $n_key = $normalize->($key); + $config->{$n_section}->{$n_key} = $config_file->{$section}->{$key} + if $requested_keys->{$n_section}->{$n_key}; + } + } + + return $config; + }}); From b555a412089fbbc6864d0577cc0d012a251223ce Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Fri, 29 Sep 2023 15:02:02 +0200 Subject: [PATCH 353/398] fix #2515: ui: ceph pool create: use configured defaults for size and min_size Instead of hard coded defaults for the size and min_size parameter, check if we have defaults configured in the ceph.conf or config db and use those. There are clusters where different defaults are needed. For example if the cluster spans two rooms and needs to survive the loss of one. A size/min_size of 4/2 are common defaults in such a situation. Signed-off-by: Aaron Lauterer Tested-by: Maximiliano Sandoval Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/Pool.js | 62 +++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/www/manager6/ceph/Pool.js b/www/manager6/ceph/Pool.js index 8de23ecf4..0ad59baf4 100644 --- a/www/manager6/ceph/Pool.js +++ b/www/manager6/ceph/Pool.js @@ -7,6 +7,10 @@ Ext.define('PVE.CephPoolInputPanel', { onlineHelp: 'pve_ceph_pools', subject: 'Ceph Pool', + + defaultSize: undefined, + defaultMinSize: undefined, + column1: [ { xtype: 'pmxDisplayEditField', @@ -27,7 +31,9 @@ Ext.define('PVE.CephPoolInputPanel', { name: 'size', editConfig: { xtype: 'proxmoxintegerfield', - value: 3, + cbind: { + value: (get) => get('defaultSize'), + }, minValue: 2, maxValue: 7, allowBlank: false, @@ -40,7 +46,6 @@ Ext.define('PVE.CephPoolInputPanel', { }, }, }, - }, ], column2: [ @@ -78,9 +83,15 @@ Ext.define('PVE.CephPoolInputPanel', { xtype: 'proxmoxintegerfield', fieldLabel: gettext('Min. Size'), name: 'min_size', - value: 2, cbind: { - minValue: (get) => get('isCreate') ? 2 : 1, + value: (get) => get('defaultMinSize'), + minValue: (get) => { + if (Number(get('defaultMinSize')) === 1) { + return 1; + } else { + return get('isCreate') ? 2 : 1; + } + }, }, maxValue: 7, allowBlank: false, @@ -195,6 +206,8 @@ Ext.define('PVE.Ceph.PoolEdit', { cbindData: { pool_name: '', isCreate: (cfg) => !cfg.pool_name, + defaultSize: undefined, + defaultMinSize: undefined, }, cbind: { @@ -217,6 +230,8 @@ Ext.define('PVE.Ceph.PoolEdit', { pool_name: '{pool_name}', isErasure: '{isErasure}', isCreate: '{isCreate}', + defaultSize: '{defaultSize}', + defaultMinSize: '{defaultMinSize}', }, }], }); @@ -397,14 +412,37 @@ Ext.define('PVE.node.Ceph.PoolList', { { text: gettext('Create'), handler: function() { - Ext.create('PVE.Ceph.PoolEdit', { - title: gettext('Create') + ': Ceph Pool', - isCreate: true, - isErasure: false, - nodename: nodename, - autoShow: true, - listeners: { - destroy: () => rstore.load(), + let keys = [ + 'global:osd-pool-default-min-size', + 'global:osd-pool-default-size', + ]; + let params = { + 'config-keys': keys.join(';'), + }; + + Proxmox.Utils.API2Request({ + url: '/nodes/localhost/ceph/cfg/value', + method: 'GET', + params, + waitMsgTarget: me.getView(), + failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + success: function({ result: { data } }) { + let global = data.global; + let defaultSize = global?.['osd-pool-default-size'] ?? 3; + let defaultMinSize = global?.['osd-pool-default-min-size'] ?? 2; + + Ext.create('PVE.Ceph.PoolEdit', { + title: gettext('Create') + ': Ceph Pool', + isCreate: true, + isErasure: false, + defaultSize, + defaultMinSize, + nodename: nodename, + autoShow: true, + listeners: { + destroy: () => rstore.load(), + }, + }); }, }); }, From cfcb74ab969143bfe457fe951303939cc8f633a0 Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Fri, 29 Sep 2023 15:02:03 +0200 Subject: [PATCH 354/398] ui: ceph pool edit: rework with controller and formulas instead of relying purely on listeners that then manually change other components, we can use binds, formulas and a basic controller. This makes it quite a bit easier to let multiple components react to changes. A cbind is used for the size component to set the initial start value. Other options, like using setValue in the controller init, will trigger the change listener and therefore can affect the min size without any user interaction. Signed-off-by: Aaron Lauterer Tested-by: Maximiliano Sandoval Signed-off-by: Thomas Lamprecht --- www/manager6/ceph/Pool.js | 82 ++++++++++++++++++++++++++++----------- 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/www/manager6/ceph/Pool.js b/www/manager6/ceph/Pool.js index 0ad59baf4..c61d4f71b 100644 --- a/www/manager6/ceph/Pool.js +++ b/www/manager6/ceph/Pool.js @@ -11,6 +11,48 @@ Ext.define('PVE.CephPoolInputPanel', { defaultSize: undefined, defaultMinSize: undefined, + controller: { + xclass: 'Ext.app.ViewController', + + init: function(view) { + let vm = this.getViewModel(); + vm.set('size', Number(view.defaultSize)); + vm.set('minSize', Number(view.defaultMinSize)); + }, + sizeChange: function(field, val) { + let vm = this.getViewModel(); + let minSize = Math.round(val / 2); + if (minSize > 1) { + vm.set('minSize', minSize); + } + vm.set('size', val); // bind does not work in a pmxDisplayEditField, update manually + }, + }, + + viewModel: { + data: { + minSize: null, + size: null, + }, + formulas: { + minSizeLabel: (get) => { + if (get('showMinSizeOneWarning') || get('showMinSizeHalfWarning')) { + return `${gettext('Min. Size')} `; + } + return gettext('Min. Size'); + }, + showMinSizeOneWarning: (get) => get('minSize') === 1, + showMinSizeHalfWarning: (get) => { + let minSize = get('minSize'); + let size = get('size'); + if (minSize === 1) { + return false; + } + return minSize < (size / 2) && minSize !== size; + }, + }, + }, + column1: [ { xtype: 'pmxDisplayEditField', @@ -38,12 +80,7 @@ Ext.define('PVE.CephPoolInputPanel', { maxValue: 7, allowBlank: false, listeners: { - change: function(field, val) { - let size = Math.round(val / 2); - if (size > 1) { - field.up('inputpanel').down('field[name=min_size]').setValue(size); - } - }, + change: 'sizeChange', }, }, }, @@ -81,7 +118,10 @@ Ext.define('PVE.CephPoolInputPanel', { advancedColumn1: [ { xtype: 'proxmoxintegerfield', - fieldLabel: gettext('Min. Size'), + bind: { + fieldLabel: '{minSizeLabel}', + value: '{minSize}', + }, name: 'min_size', cbind: { value: (get) => get('defaultMinSize'), @@ -95,28 +135,24 @@ Ext.define('PVE.CephPoolInputPanel', { }, maxValue: 7, allowBlank: false, - listeners: { - change: function(field, minSize) { - let panel = field.up('inputpanel'); - let size = panel.down('field[name=size]').getValue(); - - let showWarning = minSize < (size / 2) && minSize !== size; - - let fieldLabel = gettext('Min. Size'); - if (showWarning) { - fieldLabel = gettext('Min. Size') + ' '; - } - panel.down('field[name=min_size-warning]').setHidden(!showWarning); - field.setFieldLabel(fieldLabel); - }, - }, }, { xtype: 'displayfield', - name: 'min_size-warning', + bind: { + hidden: '{!showMinSizeHalfWarning}', + }, + hidden: true, userCls: 'pmx-hint', value: gettext('min_size < size/2 can lead to data loss, incomplete PGs or unfound objects.'), + }, + { + xtype: 'displayfield', + bind: { + hidden: '{!showMinSizeOneWarning}', + }, hidden: true, + userCls: 'pmx-hint', + value: gettext('a min_size of 1 is not recommended and can lead to data loss'), }, { xtype: 'pmxDisplayEditField', From 571bc9e2f7a2fea186f7ab8130c4e26038f3a099 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Tue, 21 Nov 2023 14:40:20 +0100 Subject: [PATCH 355/398] ui: qemu os type edit: eslint fixes Signed-off-by: Thomas Lamprecht --- www/manager6/qemu/OSTypeEdit.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/qemu/OSTypeEdit.js b/www/manager6/qemu/OSTypeEdit.js index 0a59de323..d42cb2836 100644 --- a/www/manager6/qemu/OSTypeEdit.js +++ b/www/manager6/qemu/OSTypeEdit.js @@ -57,7 +57,7 @@ Ext.define('PVE.qemu.OSTypeInputPanel', { onSecondCDChange: function(widget, value, lastValue) { let me = this; let vm = me.getViewModel(); - let updateVMConfig = function () { + let updateVMConfig = function() { let widgets = Ext.ComponentQuery.query('pveMultiHDPanel'); if (widgets.length === 1) { widgets[0].getController().updateVMConfig(); From 38035bdf605e0a6f0ff1c46b016c519fe55f4c6a Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 21 Nov 2023 13:52:37 +0100 Subject: [PATCH 356/398] vzdump: support 'notification-mode' parameter This parameter lets us choose between the 'legacy' notification system (sendmail to some email addresses) and the 'new' notification system (pub-sub based system with targets and matchers). 'auto' (default) will use the 'legacy' system if a mail address is provided and the 'new' system if not. This is allows users to opt-in/opt-out from the new notification system, which might be a bit chatty by default. Signed-off-by: Lukas Wagner --- PVE/VZDump.pm | 89 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/PVE/VZDump.pm b/PVE/VZDump.pm index b0574d412..4185ed625 100644 --- a/PVE/VZDump.pm +++ b/PVE/VZDump.pm @@ -452,12 +452,8 @@ sub send_notification { my $opts = $self->{opts}; my $mailto = $opts->{mailto}; my $cmdline = $self->{cmdline}; - # Old-style notification policy. This parameter will influce - # if an ad-hoc notification target/matcher will be created. - my $policy = $opts->{"notification-policy"} // - $opts->{mailnotification} // - 'always'; - + my $policy = $opts->{mailnotification} // 'always'; + my $mode = $opts->{"notification-mode"} // 'auto'; sanitize_task_list($tasklist); my $error_count = count_failed_tasks($tasklist); @@ -499,44 +495,69 @@ sub send_notification { }; my $fields = { + # TODO: There is no straight-forward way yet to get the + # backup job id here... (I think pvescheduler would need + # to pass that to the vzdump call?) type => "vzdump", hostname => $hostname, }; - my $notification_config = PVE::Notify::read_config(); + my $severity = $failed ? "error" : "info"; + my $email_configured = $mailto && scalar(@$mailto); - my $legacy_sendmail = $policy eq "always" || ($policy eq "failure" && $failed); + if (($mode eq 'auto' && $email_configured) || $mode eq 'legacy-sendmail') { + if ($email_configured && ($policy eq "always" || ($policy eq "failure" && $failed))) { + # Start out with an empty config. Might still contain + # built-ins, so we need to disable/remove them. + my $notification_config = Proxmox::RS::Notify->parse_config('', ''); - if ($mailto && scalar(@$mailto) && $legacy_sendmail) { - # <, >, @ are not allowed in endpoint names, but that is only - # verified once the config is serialized. That means that - # we can rely on that fact that no other endpoint with this name exists. - my $endpoint_name = ""; - $notification_config->add_sendmail_endpoint( - $endpoint_name, - $mailto, - undef, - undef, - "vzdump backup tool"); + # Remove built-in matchers, since we only want to send an + # email to the specified recipients and nobody else. + for my $matcher (@{$notification_config->get_matchers()}) { + $notification_config->delete_matcher($matcher->{name}); + } - my $endpoints = [$endpoint_name]; + # <, >, @ are not allowed in endpoint names, but that is only + # verified once the config is serialized. That means that + # we can rely on that fact that no other endpoint with this name exists. + my $endpoint_name = "<" . join(",", @$mailto) . ">"; + $notification_config->add_sendmail_endpoint( + $endpoint_name, + $mailto, + undef, + undef, + "vzdump backup tool" + ); - $notification_config->add_matcher( - "", - $endpoints, + my $endpoints = [$endpoint_name]; + + # Add a matcher that matches all notifications, set our + # newly created target as a target. + $notification_config->add_matcher( + "", + $endpoints, + ); + + PVE::Notify::notify( + $severity, + $subject_template, + $body_template, + $notification_props, + $fields, + $notification_config + ); + } + } else { + # We use the 'new' system, or we are set to 'auto' and + # no email addresses were configured. + PVE::Notify::notify( + $severity, + $subject_template, + $body_template, + $notification_props, + $fields, ); } - - my $severity = $failed ? "error" : "info"; - - PVE::Notify::notify( - $severity, - $subject_template, - $body_template, - $notification_props, - $fields, - $notification_config - ); }; sub new { From 66b2086269b383e962ebbb6b90a39a368172f60f Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 21 Nov 2023 13:52:38 +0100 Subject: [PATCH 357/398] ui: backup jobs: add 'notification-mode' selector for backup jobs This selector allows one to selected between the 'old' (send email directly via sendmail) or the 'new' notification system. The default is 'auto', which sends and email if one is configured, and uses the notification system if no email address is set. Signed-off-by: Lukas Wagner --- www/manager6/dc/Backup.js | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js index 9aae4090a..1258772bd 100644 --- a/www/manager6/dc/Backup.js +++ b/www/manager6/dc/Backup.js @@ -206,12 +206,14 @@ Ext.define('PVE.dc.BackupEdit', { viewModel: { data: { selMode: 'include', + notificationMode: '__default__', }, formulas: { poolMode: (get) => get('selMode') === 'pool', disableVMSelection: (get) => get('selMode') !== 'include' && get('selMode') !== 'exclude', - mailNotificationSelected: (get) => get('notificationMode') === 'mailto', + showMailtoFields: (get) => + ['auto', 'legacy-sendmail', '__default__'].includes(get('notificationMode')), }, }, @@ -301,6 +303,28 @@ Ext.define('PVE.dc.BackupEdit', { }, ], column2: [ + { + xtype: 'proxmoxKVComboBox', + comboItems: [ + [ + '__default__', + Ext.String.format( + gettext('{0} (Auto)'), Proxmox.Utils.defaultText, + ), + ], + ['auto', gettext('Auto')], + ['legacy-sendmail', gettext('Email (legacy)')], + ['notification-system', gettext('Notification system')], + ], + fieldLabel: gettext('Notification mode'), + name: 'notification-mode', + cbind: { + deleteEmpty: '{!isCreate}', + }, + bind: { + value: '{notificationMode}', + }, + }, { xtype: 'pveEmailNotificationSelector', fieldLabel: gettext('Notify'), @@ -309,11 +333,17 @@ Ext.define('PVE.dc.BackupEdit', { value: (get) => get('isCreate') ? 'always' : '', deleteEmpty: '{!isCreate}', }, + bind: { + disabled: '{!showMailtoFields}', + }, }, { xtype: 'textfield', fieldLabel: gettext('Send email to'), name: 'mailto', + bind: { + disabled: '{!showMailtoFields}', + }, }, { xtype: 'pveBackupCompressionSelector', From c202b169f5b0294bb22d05a5385182098e18b3fd Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 21 Nov 2023 13:52:39 +0100 Subject: [PATCH 358/398] ui: backup: add 'notification-mode' param for one-shot backup jobs. This selector allows one to selected between the 'old' (send email directly via sendmail) or the 'new' notification system. The default is 'auto', which sends and email if one is configured, and uses the notification system if no email address is set. Signed-off-by: Lukas Wagner --- www/manager6/window/Backup.js | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/www/manager6/window/Backup.js b/www/manager6/window/Backup.js index 8d8c9ff01..4418a9c7e 100644 --- a/www/manager6/window/Backup.js +++ b/www/manager6/window/Backup.js @@ -36,6 +36,23 @@ Ext.define('PVE.window.Backup', { emptyText: Proxmox.Utils.noneText, }); + let notificationModeSelector = Ext.create({ + xtype: 'proxmoxKVComboBox', + comboItems: [ + ['auto', gettext('Auto')], + ['legacy-sendmail', gettext('Email (legacy)')], + ['notification-system', gettext('Notification system')], + ], + fieldLabel: gettext('Notification mode'), + name: 'notification-mode', + value: 'auto', + listeners: { + change: function(field, value) { + mailtoField.setDisabled(value === 'notification-system'); + }, + }, + }); + const keepNames = [ ['keep-last', gettext('Keep Last')], ['keep-hourly', gettext('Keep Hourly')], @@ -110,6 +127,9 @@ Ext.define('PVE.window.Backup', { if (!initialDefaults && data.mailto !== undefined) { mailtoField.setValue(data.mailto); } + if (!initialDefaults && data['notification-mode'] !== undefined) { + notificationModeSelector.setValue(data['notification-mode']); + } if (!initialDefaults && data.mode !== undefined) { modeSelector.setValue(data.mode); } @@ -176,6 +196,7 @@ Ext.define('PVE.window.Backup', { ], column2: [ compressionSelector, + notificationModeSelector, mailtoField, removeCheckbox, ], @@ -256,6 +277,10 @@ Ext.define('PVE.window.Backup', { params.mailto = values.mailto; } + if (values['notification-mode']) { + params['notification-mode'] = values['notification-mode']; + } + if (values.compress) { params.compress = values.compress; } From fb5b81796f42a21341689861a32216ddd674b671 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 21 Nov 2023 13:52:40 +0100 Subject: [PATCH 359/398] ui: backup job: change field text for 'mailnotification' field ... to highlight that this setting only affects the 'legacy-sendmail' mail notifications. Signed-off-by: Lukas Wagner --- www/manager6/dc/Backup.js | 2 +- www/manager6/form/NotificationPolicySelector.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/dc/Backup.js b/www/manager6/dc/Backup.js index 1258772bd..70903bdc2 100644 --- a/www/manager6/dc/Backup.js +++ b/www/manager6/dc/Backup.js @@ -327,7 +327,7 @@ Ext.define('PVE.dc.BackupEdit', { }, { xtype: 'pveEmailNotificationSelector', - fieldLabel: gettext('Notify'), + fieldLabel: gettext('Send email'), name: 'mailnotification', cbind: { value: (get) => get('isCreate') ? 'always' : '', diff --git a/www/manager6/form/NotificationPolicySelector.js b/www/manager6/form/NotificationPolicySelector.js index f318ea18d..d2a513862 100644 --- a/www/manager6/form/NotificationPolicySelector.js +++ b/www/manager6/form/NotificationPolicySelector.js @@ -2,7 +2,7 @@ Ext.define('PVE.form.EmailNotificationSelector', { extend: 'Proxmox.form.KVComboBox', alias: ['widget.pveEmailNotificationSelector'], comboItems: [ - ['always', gettext('Notify always')], + ['always', gettext('Always')], ['failure', gettext('On failure only')], ], }); From 464ef9f6c4fe62d1ee766d33cd39319222b0bb8b Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Tue, 21 Nov 2023 15:47:57 +0100 Subject: [PATCH 360/398] ui: dc: config: remove onlineHelp for notification config view The 'onlineHelp' is now set in the component definition in 'proxmox-widget-toolkit'. Signed-off-by: Lukas Wagner --- www/manager6/dc/Config.js | 1 - 1 file changed, 1 deletion(-) diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index 74a84e911..f22688f8f 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -324,7 +324,6 @@ Ext.define('PVE.dc.Config', { { xtype: 'pmxNotificationConfigView', title: gettext('Notifications'), - onlineHelp: 'notification_targets', itemId: 'notification-targets', iconCls: 'fa fa-bell-o', baseUrl: '/cluster/notifications', From c216d1d288ec998524e574d6798036c1f455c259 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Fri, 17 Nov 2023 12:39:55 +0100 Subject: [PATCH 361/398] sdn: regenerate DHCP config on reload Signed-off-by: Stefan Hanreich --- PVE/API2/Network.pm | 1 + 1 file changed, 1 insertion(+) diff --git a/PVE/API2/Network.pm b/PVE/API2/Network.pm index 00d964a79..f39f04f52 100644 --- a/PVE/API2/Network.pm +++ b/PVE/API2/Network.pm @@ -660,6 +660,7 @@ __PACKAGE__->register_method({ if ($have_sdn) { PVE::Network::SDN::generate_zone_config(); + PVE::Network::SDN::generate_dhcp_config(); } my $err = sub { From 4977ff7f50a1e78c532309c9836fa0610126b21a Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Fri, 17 Nov 2023 12:39:56 +0100 Subject: [PATCH 362/398] sdn: add DHCP option to Zone dialogue Co-Authored-by: Stefan Lendl Signed-off-by: Stefan Hanreich --- www/manager6/sdn/zones/Base.js | 6 ++++-- www/manager6/sdn/zones/SimpleEdit.js | 10 ++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/www/manager6/sdn/zones/Base.js b/www/manager6/sdn/zones/Base.js index 602e4c16b..db9b47b18 100644 --- a/www/manager6/sdn/zones/Base.js +++ b/www/manager6/sdn/zones/Base.js @@ -55,7 +55,9 @@ Ext.define('PVE.panel.SDNZoneBase', { }, ); - me.advancedItems = [ + me.advancedItems = me.advancedItems ?? []; + + me.advancedItems.unshift( { xtype: 'pveSDNDnsSelector', fieldLabel: gettext('DNS Server'), @@ -77,7 +79,7 @@ Ext.define('PVE.panel.SDNZoneBase', { fieldLabel: gettext('DNS Zone'), allowBlank: true, }, - ]; + ); me.callParent(); }, diff --git a/www/manager6/sdn/zones/SimpleEdit.js b/www/manager6/sdn/zones/SimpleEdit.js index cb7c34035..7a6f1d0d9 100644 --- a/www/manager6/sdn/zones/SimpleEdit.js +++ b/www/manager6/sdn/zones/SimpleEdit.js @@ -19,6 +19,16 @@ Ext.define('PVE.sdn.zones.SimpleInputPanel', { var me = this; me.items = []; + me.advancedItems = [ + { + xtype: 'proxmoxcheckbox', + name: 'dhcp', + inputValue: 'dnsmasq', + uncheckedValue: undefined, + checked: false, + fieldLabel: gettext('automatic DHCP'), + }, + ]; me.callParent(); }, From 1d2ee4a255949160005f00db3ad5eece5ccba6e6 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Fri, 17 Nov 2023 12:39:57 +0100 Subject: [PATCH 363/398] sdn: subnet: add panel for editing dhcp ranges Signed-off-by: Stefan Hanreich --- www/manager6/Makefile | 1 + www/manager6/sdn/SubnetEdit.js | 160 ++++++++++++++++++++++++++++++++- 2 files changed, 160 insertions(+), 1 deletion(-) diff --git a/www/manager6/Makefile b/www/manager6/Makefile index c63361c30..01afc8c4d 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -274,6 +274,7 @@ JSSRC= \ sdn/ZoneContentView.js \ sdn/ZoneContentPanel.js \ sdn/ZoneView.js \ + sdn/IpamEdit.js \ sdn/OptionsPanel.js \ sdn/controllers/Base.js \ sdn/controllers/EvpnEdit.js \ diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js index b9825d2a3..4fe16ab92 100644 --- a/www/manager6/sdn/SubnetEdit.js +++ b/www/manager6/sdn/SubnetEdit.js @@ -56,6 +56,147 @@ Ext.define('PVE.sdn.SubnetInputPanel', { ], }); +Ext.define('PVE.sdn.SubnetDhcpRangePanel', { + extend: 'Ext.form.FieldContainer', + mixins: ['Ext.form.field.Field'], + + initComponent: function() { + let me = this; + + me.callParent(); + me.initField(); + }, + + getValue: function() { + let me = this; + let store = me.lookup('grid').getStore(); + + let data = []; + + store.getData() + .each((item) => + data.push(`start-address=${item.data['start-address']},end-address=${item.data['end-address']}`), + ); + + return data; + }, + + getSubmitData: function() { + let me = this; + + let data = {}; + let value = me.getValue(); + + if (value.length) { + data[me.getName()] = value; + } + + return data; + }, + + setValue: function(dhcpRanges) { + let me = this; + let store = me.lookup('grid').getStore(); + store.setData(dhcpRanges); + }, + + getErrors: function() { + let me = this; + let errors = []; + + return errors; + }, + + controller: { + xclass: 'Ext.app.ViewController', + + addRange: function() { + let me = this; + me.lookup('grid').getStore().add({}); + }, + + removeRange: function(field) { + let me = this; + let record = field.getWidgetRecord(); + + me.lookup('grid').getStore().remove(record); + }, + + onValueChange: function(field, value) { + let me = this; + let record = field.getWidgetRecord(); + let column = field.getWidgetColumn(); + + record.set(column.dataIndex, value); + record.commit(); + }, + + control: { + 'grid button': { + click: 'removeRange', + }, + 'field': { + change: 'onValueChange', + }, + }, + }, + + items: [ + { + xtype: 'grid', + reference: 'grid', + scrollable: true, + store: { + fields: ['start-address', 'end-address'], + }, + columns: [ + { + text: gettext('Start Address'), + xtype: 'widgetcolumn', + dataIndex: 'start-address', + flex: 1, + widget: { + xtype: 'textfield', + vtype: 'IP64Address', + }, + }, + { + text: gettext('End Address'), + xtype: 'widgetcolumn', + dataIndex: 'end-address', + flex: 1, + widget: { + xtype: 'textfield', + vtype: 'IP64Address', + }, + }, + { + xtype: 'widgetcolumn', + width: 40, + widget: { + xtype: 'button', + iconCls: 'fa fa-trash-o', + }, + }, + ], + }, + { + xtype: 'container', + layout: { + type: 'hbox', + }, + items: [ + { + xtype: 'button', + text: gettext('Add'), + iconCls: 'fa fa-plus-circle', + handler: 'addRange', + }, + ], + }, + ], +}); + Ext.define('PVE.sdn.SubnetEdit', { extend: 'Proxmox.window.Edit', @@ -67,6 +208,8 @@ Ext.define('PVE.sdn.SubnetEdit', { base_url: undefined, + bodyPadding: 0, + initComponent: function() { var me = this; @@ -82,11 +225,22 @@ Ext.define('PVE.sdn.SubnetEdit', { let ipanel = Ext.create('PVE.sdn.SubnetInputPanel', { isCreate: me.isCreate, + title: gettext('General'), + }); + + let dhcpPanel = Ext.create('PVE.sdn.SubnetDhcpRangePanel', { + isCreate: me.isCreate, + title: gettext('DHCP Ranges'), + name: 'dhcp-range', }); Ext.apply(me, { items: [ - ipanel, + { + xtype: 'tabpanel', + bodyPadding: 10, + items: [ipanel, dhcpPanel], + }, ], }); @@ -97,6 +251,10 @@ Ext.define('PVE.sdn.SubnetEdit', { success: function(response, options) { let values = response.result.data; ipanel.setValues(values); + + if (values['dhcp-range']) { + dhcpPanel.setValue(values['dhcp-range']); + } }, }); } From a3c059c559d39a2f612d760b65b9ef622cbea827 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Fri, 17 Nov 2023 12:39:58 +0100 Subject: [PATCH 364/398] sdn: ipam: add ipam panel Signed-off-by: Stefan Hanreich --- www/css/ext6-pve.css | 22 ++- www/manager6/Makefile | 1 + www/manager6/dc/Config.js | 12 +- www/manager6/sdn/IpamEdit.js | 78 ++++++++++ www/manager6/tree/DhcpTree.js | 267 ++++++++++++++++++++++++++++++++++ 5 files changed, 372 insertions(+), 8 deletions(-) create mode 100644 www/manager6/sdn/IpamEdit.js create mode 100644 www/manager6/tree/DhcpTree.js diff --git a/www/css/ext6-pve.css b/www/css/ext6-pve.css index e18b173f5..091855356 100644 --- a/www/css/ext6-pve.css +++ b/www/css/ext6-pve.css @@ -510,28 +510,38 @@ div.right-aligned { content: ' '; } -.fa-sdn:before { +.x-fa-sdn-treelist:before { width: 14px; height: 14px; position: absolute; left: 1px; top: 4px; +} + +.fa-sdn:before { background-image:url(../images/icon-sdn.svg); background-size: 14px 14px; content: ' '; } .fa-network-wired:before { - width: 14px; - height: 14px; - position: absolute; - left: 1px; - top: 4px; background-image:url(../images/icon-fa-network-wired.svg); background-size: 14px 14px; content: ' '; } +.x-fa-treepanel:before { + width: 16px; + height: 24px; + display: block; + background-repeat: no-repeat; + background-position: center; +} + +.x-tree-icon-none { + display: none; +} + .x-treelist-row-over > * > .x-treelist-item-icon, .x-treelist-row-over > * > .x-treelist-item-text{ color: #000; diff --git a/www/manager6/Makefile b/www/manager6/Makefile index 01afc8c4d..dc3c85b10 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -109,6 +109,7 @@ JSSRC= \ tree/ResourceTree.js \ tree/SnapshotTree.js \ tree/ResourceMapTree.js \ + tree/DhcpTree.js \ window/Backup.js \ window/BackupConfig.js \ window/BulkAction.js \ diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js index f22688f8f..ddbb58b12 100644 --- a/www/manager6/dc/Config.js +++ b/www/manager6/dc/Config.js @@ -185,7 +185,7 @@ Ext.define('PVE.dc.Config', { me.items.push({ xtype: 'pveSDNStatus', title: gettext('SDN'), - iconCls: 'fa fa-sdn', + iconCls: 'fa fa-sdn x-fa-sdn-treelist', hidden: true, itemId: 'sdn', expandedOnInit: true, @@ -203,7 +203,7 @@ Ext.define('PVE.dc.Config', { groups: ['sdn'], title: 'VNets', hidden: true, - iconCls: 'fa fa-network-wired', + iconCls: 'fa fa-network-wired x-fa-sdn-treelist', itemId: 'sdnvnet', }, { @@ -213,6 +213,14 @@ Ext.define('PVE.dc.Config', { hidden: true, iconCls: 'fa fa-gear', itemId: 'sdnoptions', + }, + { + xtype: 'pveDhcpTree', + groups: ['sdn'], + title: gettext('IPAM'), + hidden: true, + iconCls: 'fa fa-map-signs', + itemId: 'sdnmappings', }); } diff --git a/www/manager6/sdn/IpamEdit.js b/www/manager6/sdn/IpamEdit.js new file mode 100644 index 000000000..18e22c592 --- /dev/null +++ b/www/manager6/sdn/IpamEdit.js @@ -0,0 +1,78 @@ +Ext.define('PVE.sdn.IpamEditInputPanel', { + extend: 'Proxmox.panel.InputPanel', + mixins: ['Proxmox.Mixin.CBind'], + + isCreate: false, + + onGetValues: function(values) { + let me = this; + + if (!values.vmid) { + delete values.vmid; + } + + return values; + }, + + items: [ + { + xtype: 'pmxDisplayEditField', + name: 'vmid', + fieldLabel: gettext('VMID'), + allowBlank: false, + editable: false, + cbind: { + hidden: '{isCreate}', + }, + }, + { + xtype: 'pmxDisplayEditField', + name: 'mac', + fieldLabel: gettext('MAC'), + allowBlank: false, + cbind: { + editable: '{isCreate}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'ip', + fieldLabel: gettext('IP'), + allowBlank: false, + }, + ], +}); + +Ext.define('PVE.sdn.IpamEdit', { + extend: 'Proxmox.window.Edit', + + subject: gettext('DHCP Mapping'), + width: 350, + + isCreate: false, + mapping: {}, + + submitUrl: function(url, values) { + return `${url}/${values.zone}/${values.vnet}/${values.mac}`; + }, + + initComponent: function() { + var me = this; + + me.method = me.isCreate ? 'POST' : 'PUT'; + + let ipanel = Ext.create('PVE.sdn.IpamEditInputPanel', { + isCreate: me.isCreate, + }); + + Ext.apply(me, { + items: [ + ipanel, + ], + }); + + me.callParent(); + + ipanel.setValues(me.mapping); + }, +}); diff --git a/www/manager6/tree/DhcpTree.js b/www/manager6/tree/DhcpTree.js new file mode 100644 index 000000000..ca279c29a --- /dev/null +++ b/www/manager6/tree/DhcpTree.js @@ -0,0 +1,267 @@ +Ext.define('PVE.sdn.DhcpTree', { + extend: 'Ext.tree.Panel', + xtype: 'pveDhcpTree', + + layout: 'fit', + rootVisible: false, + animate: false, + + store: { + sorters: ['ip', 'name'], + }, + + controller: { + xclass: 'Ext.app.ViewController', + + reload: function() { + let me = this; + + Proxmox.Utils.API2Request({ + url: `/cluster/sdn/ipam`, + method: 'GET', + success: function(response, opts) { + let root = { + name: '__root', + expanded: true, + children: [], + }; + + let zones = {}; + let vnets = {}; + let subnets = {}; + + response.result.data.forEach((element) => { + element.leaf = true; + + if (!(element.zone in zones)) { + let zone = { + name: element.zone, + type: 'zone', + iconCls: 'fa fa-th', + expanded: true, + children: [], + }; + + zones[element.zone] = zone; + root.children.push(zone); + } + + if (!(element.vnet in vnets)) { + let vnet = { + name: element.vnet, + zone: element.zone, + type: 'vnet', + iconCls: 'fa fa-network-wired x-fa-treepanel', + expanded: true, + children: [], + }; + + vnets[element.vnet] = vnet; + zones[element.zone].children.push(vnet); + } + + if (!(element.subnet in subnets)) { + let subnet = { + name: element.subnet, + zone: element.zone, + vnet: element.vnet, + type: 'subnet', + iconCls: 'x-tree-icon-none', + expanded: true, + children: [], + }; + + subnets[element.subnet] = subnet; + vnets[element.vnet].children.push(subnet); + } + + element.type = 'mapping'; + element.iconCls = 'x-tree-icon-none'; + subnets[element.subnet].children.push(element); + }); + + me.getView().setRootNode(root); + }, + }); + }, + + init: function(view) { + let me = this; + me.reload(); + }, + + onDelete: function(table, rI, cI, item, e, { data }) { + let me = this; + let view = me.getView(); + + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + message: Ext.String.format(gettext('Are you sure you want to remove DHCP mapping {0}'), `${data.mac} / ${data.ip}`), + buttons: Ext.Msg.YESNO, + defaultFocus: 'no', + callback: function(btn) { + if (btn !== 'yes') { + return; + } + + Proxmox.Utils.API2Request({ + url: `/cluster/sdn/ipam/${data.zone}/${data.vnet}/${data.mac}`, + method: 'DELETE', + waitMsgTarget: view, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + callback: me.reload.bind(me), + }); + }, + }); + }, + + editAction: function(_grid, _rI, _cI, _item, _e, rec) { + this.edit(rec); + }, + + editDblClick: function() { + let me = this; + + let view = me.getView(); + let selection = view.getSelection(); + + if (!selection || selection.length < 1) { + return; + } + + me.edit(selection[0]); + }, + + edit: function(rec) { + let me = this; + + if (rec.data.type === 'mapping' && !rec.data.gateway) { + me.openEditWindow(rec.data); + } + }, + + openEditWindow: function(data) { + let me = this; + + Ext.create('PVE.sdn.IpamEdit', { + autoShow: true, + mapping: data, + url: `/cluster/sdn/ipam`, + extraRequestParams: { + vmid: data.vmid, + mac: data.mac, + zone: data.zone, + vnet: data.vnet, + }, + listeners: { + destroy: () => me.reload(), + }, + }); + }, + }, + + listeners: { + itemdblclick: 'editDblClick', + }, + + tbar: [ + { + xtype: 'proxmoxButton', + text: gettext('Reload'), + handler: 'reload', + }, + ], + + columns: [ + { + xtype: 'treecolumn', + text: gettext('Name / VMID'), + dataIndex: 'name', + width: 200, + renderer: function(value, meta, record) { + if (record.get('gateway')) { + return gettext('Gateway'); + } + + return record.get('name') ?? record.get('vmid') ?? ' '; + }, + }, + { + text: gettext('IP'), + dataIndex: 'ip', + width: 200, + }, + { + text: gettext('MAC'), + dataIndex: 'mac', + width: 200, + }, + { + text: gettext('Gateway'), + dataIndex: 'gateway', + width: 200, + }, + { + header: gettext('Actions'), + xtype: 'actioncolumn', + dataIndex: 'text', + width: 150, + items: [ + { + handler: function(table, rI, cI, item, e, { data }) { + let me = this; + + Ext.create('PVE.sdn.IpamEdit', { + autoShow: true, + mapping: {}, + url: `/cluster/sdn/ipam`, + isCreate: true, + extraRequestParams: { + vnet: data.name, + zone: data.zone, + }, + listeners: { + destroy: () => { + me.up('pveDhcpTree').controller.reload(); + }, + }, + }); + }, + getTip: (v, m, rec) => gettext('Add'), + getClass: (v, m, { data }) => { + if (data.type === 'vnet') { + return 'fa fa-plus-square'; + } + + return 'pmx-hidden'; + }, + }, + { + handler: 'editAction', + getTip: (v, m, rec) => gettext('Edit'), + getClass: (v, m, { data }) => { + if (data.type === 'mapping' && !data.gateway) { + return 'fa fa-pencil fa-fw'; + } + + return 'pmx-hidden'; + }, + }, + { + handler: 'onDelete', + getTip: (v, m, rec) => gettext('Delete'), + getClass: (v, m, { data }) => { + if (data.type === 'mapping' && !data.gateway) { + return 'fa critical fa-trash-o'; + } + + return 'pmx-hidden'; + }, + }, + ], + }, + ], +}); From 14f46dbb97fec967eec6145cacab44858d683c33 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Mon, 20 Nov 2023 17:28:33 +0100 Subject: [PATCH 365/398] sdn: Update IPAM API endpoints The IPAM-related API endpoints were moved, reflect those changes in the UI as well. Signed-off-by: Stefan Hanreich --- www/manager6/sdn/IpamEdit.js | 4 +++- www/manager6/tree/DhcpTree.js | 15 +++++++++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/www/manager6/sdn/IpamEdit.js b/www/manager6/sdn/IpamEdit.js index 18e22c592..73e5d2e1a 100644 --- a/www/manager6/sdn/IpamEdit.js +++ b/www/manager6/sdn/IpamEdit.js @@ -52,8 +52,10 @@ Ext.define('PVE.sdn.IpamEdit', { isCreate: false, mapping: {}, + url: '/cluster/sdn/vnets', + submitUrl: function(url, values) { - return `${url}/${values.zone}/${values.vnet}/${values.mac}`; + return `${url}/${values.vnet}/ips`; }, initComponent: function() { diff --git a/www/manager6/tree/DhcpTree.js b/www/manager6/tree/DhcpTree.js index ca279c29a..b7baba606 100644 --- a/www/manager6/tree/DhcpTree.js +++ b/www/manager6/tree/DhcpTree.js @@ -17,7 +17,7 @@ Ext.define('PVE.sdn.DhcpTree', { let me = this; Proxmox.Utils.API2Request({ - url: `/cluster/sdn/ipam`, + url: `/cluster/sdn/ipams/pve/status`, method: 'GET', success: function(response, opts) { let root = { @@ -105,8 +105,17 @@ Ext.define('PVE.sdn.DhcpTree', { return; } + let params = { + zone: data.zone, + mac: data.mac, + }; + + let encodedParams = Ext.Object.toQueryString(params); + + let url = `/cluster/sdn/vnets/${data.vnet}/ips?${encodedParams}`; + Proxmox.Utils.API2Request({ - url: `/cluster/sdn/ipam/${data.zone}/${data.vnet}/${data.mac}`, + url, method: 'DELETE', waitMsgTarget: view, failure: function(response, opts) { @@ -149,7 +158,6 @@ Ext.define('PVE.sdn.DhcpTree', { Ext.create('PVE.sdn.IpamEdit', { autoShow: true, mapping: data, - url: `/cluster/sdn/ipam`, extraRequestParams: { vmid: data.vmid, mac: data.mac, @@ -217,7 +225,6 @@ Ext.define('PVE.sdn.DhcpTree', { Ext.create('PVE.sdn.IpamEdit', { autoShow: true, mapping: {}, - url: `/cluster/sdn/ipam`, isCreate: true, extraRequestParams: { vnet: data.name, From ad1278fae8e6e678219a702eea960c746551c635 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Tue, 21 Nov 2023 20:46:32 +0100 Subject: [PATCH 366/398] sdn: subnet: proper change detect for dhcp range panel Signed-off-by: Stefan Hanreich --- www/manager6/sdn/SubnetEdit.js | 51 +++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js index 4fe16ab92..8851b013a 100644 --- a/www/manager6/sdn/SubnetEdit.js +++ b/www/manager6/sdn/SubnetEdit.js @@ -67,25 +67,37 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', { me.initField(); }, + // since value is an array of objects we need to override isEquals here + isEqual: function(value1, value2) { + return JSON.stringify(value1) === JSON.stringify(value2); + }, + getValue: function() { let me = this; let store = me.lookup('grid').getStore(); - let data = []; + let value = []; store.getData() - .each((item) => - data.push(`start-address=${item.data['start-address']},end-address=${item.data['end-address']}`), - ); + .each((item) => { + // needs a deep copy otherwise we run in to ExtJS reference + // shenaningans + value.push({ + 'start-address': item.data['start-address'], + 'end-address': item.data['end-address'], + }); + }); - return data; + return value; }, getSubmitData: function() { let me = this; let data = {}; - let value = me.getValue(); + + let value = me.getValue() + .map((item) => `start-address=${item['start-address']},end-address=${item['end-address']}`); if (value.length) { data[me.getName()] = value; @@ -97,7 +109,19 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', { setValue: function(dhcpRanges) { let me = this; let store = me.lookup('grid').getStore(); - store.setData(dhcpRanges); + + let data = []; + + dhcpRanges.forEach((item) => { + // needs a deep copy otherwise we run in to ExtJS reference + // shenaningans + data.push({ + 'start-address': item['start-address'], + 'end-address': item['end-address'], + }); + }); + + store.setData(data); }, getErrors: function() { @@ -113,6 +137,8 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', { addRange: function() { let me = this; me.lookup('grid').getStore().add({}); + + me.getView().checkChange(); }, removeRange: function(field) { @@ -120,6 +146,8 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', { let record = field.getWidgetRecord(); me.lookup('grid').getStore().remove(record); + + me.getView().checkChange(); }, onValueChange: function(field, value) { @@ -129,6 +157,8 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', { record.set(column.dataIndex, value); record.commit(); + + me.getView().checkChange(); }, control: { @@ -249,12 +279,7 @@ Ext.define('PVE.sdn.SubnetEdit', { if (!me.isCreate) { me.load({ success: function(response, options) { - let values = response.result.data; - ipanel.setValues(values); - - if (values['dhcp-range']) { - dhcpPanel.setValue(values['dhcp-range']); - } + me.setValues(response.result.data); }, }); } From fd6fb858703c1fdba4063d162d9b57892bf0fd69 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 13:44:34 +0100 Subject: [PATCH 367/398] ui: node summary: drop PVE prefix for manager version the value on the right already includes pve-manager and the user has already loaded the PVE gui, so this is a bit superfluous Signed-off-by: Thomas Lamprecht --- www/manager6/node/StatusView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/node/StatusView.js b/www/manager6/node/StatusView.js index d34724f79..ef4403523 100644 --- a/www/manager6/node/StatusView.js +++ b/www/manager6/node/StatusView.js @@ -109,7 +109,7 @@ Ext.define('PVE.node.StatusView', { itemId: 'version', colspan: 2, printBar: false, - title: gettext('PVE Manager Version'), + title: gettext('Manager Version'), textField: 'pveversion', value: '', }, From 20ad4e0e01da68cf83f9f4ed0f2572359483fb47 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 13:19:34 +0100 Subject: [PATCH 368/398] api: nodes: add full info about current kernel from uname call Signed-off-by: Thomas Lamprecht --- PVE/API2/Nodes.pm | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index be000d945..f4b70b3d9 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -342,6 +342,19 @@ __PACKAGE__->register_method ({ return PVE::pvecfg::version_info(); }}); +my sub get_current_kernel_info { + my ($sysname, $nodename, $release, $version, $machine) = POSIX::uname(); + + my $kernel_version_string = "$sysname $release $version"; # for legacy compat + my $current_kernel = { + sysname => $sysname, + release => $release, + version => $version, + machine => $machine, + }; + return ($current_kernel, $kernel_version_string); +} + __PACKAGE__->register_method({ name => 'status', path => 'status', @@ -377,9 +390,9 @@ __PACKAGE__->register_method({ my ($avg1, $avg5, $avg15) = PVE::ProcFSTools::read_loadavg(); $res->{loadavg} = [ $avg1, $avg5, $avg15]; - my ($sysname, $nodename, $release, $version, $machine) = POSIX::uname(); - - $res->{kversion} = "$sysname $release $version"; + my ($current_kernel_info, $kversion_string) = get_current_kernel_info(); + $res->{kversion} = $kversion_string; + $res->{'current-kernel'} = $current_kernel_info; $res->{cpuinfo} = PVE::ProcFSTools::read_cpuinfo(); From be04f8ee8af61eaa3730ae74e6e3c7d4266474ca Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 13:24:43 +0100 Subject: [PATCH 369/398] ui: node summary: reduce noise in current kernel version use the new 'current-kernel' object returned by the node status API to render a more useable (less noise) version information. Keep fallback for old one to better work with upgrades (major and minor) to this version in a cluster, where the web UI one uses might be the new one, but a node one looks at still have the old manager. Signed-off-by: Thomas Lamprecht --- www/manager6/node/StatusView.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/www/manager6/node/StatusView.js b/www/manager6/node/StatusView.js index ef4403523..41fb51353 100644 --- a/www/manager6/node/StatusView.js +++ b/www/manager6/node/StatusView.js @@ -98,11 +98,20 @@ Ext.define('PVE.node.StatusView', { value: '', }, { - itemId: 'kversion', colspan: 2, title: gettext('Kernel Version'), printBar: false, - textField: 'kversion', + // TODO: remove with next major and only use newish current-kernel textfield + multiField: true, + //textField: 'current-kernel', + renderer: ({ data }) => { + if (!data['current-kernel']) { + return data.kversion; + } + let kernel = data['current-kernel']; + let buildDate = kernel.version.match(/\((.+)\)\s*$/)[1] ?? 'unknown'; + return `${kernel.sysname} ${kernel.release} (${buildDate})`; + }, value: '', }, { From 81fd95cf63a3f028cd1ff4aeb589d7ee48bf1bbd Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 13:19:49 +0100 Subject: [PATCH 370/398] api: nodes: add info about current boot mode report if the node is booted in EFI or Legacy BIOS mode, for the former also pass along the secure boot state. Signed-off-by: Thomas Lamprecht --- PVE/API2/Nodes.pm | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index f4b70b3d9..c1d5f5ea7 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -35,7 +35,7 @@ use PVE::RRD; use PVE::Report; use PVE::SafeSyslog; use PVE::Storage; -use PVE::Tools; +use PVE::Tools qw(file_get_contents); use PVE::pvecfg; use PVE::API2::APT; @@ -355,6 +355,25 @@ my sub get_current_kernel_info { return ($current_kernel, $kernel_version_string); } +my sub get_boot_mode_info { + my $is_efi_booted = -d "/sys/firmware/efi"; + + my $info = { + mode => $is_efi_booted ? 'efi' : 'legacy-bios', + }; + + if ($is_efi_booted) { + my $efi_var_sec_boot_entry = eval { file_get_contents("/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c") }; + if ($@) { + warn "Failed to read secure boot state: $@\n"; + } else { + my @secureboot = unpack("CCCCC", $efi_var_sec_boot_entry); + $info->{secureboot} = $secureboot[4] == 1 ? 1 : 0; + } + } + return $info; +} + __PACKAGE__->register_method({ name => 'status', path => 'status', @@ -394,6 +413,8 @@ __PACKAGE__->register_method({ $res->{kversion} = $kversion_string; $res->{'current-kernel'} = $current_kernel_info; + $res->{'boot-info'} = get_boot_mode_info(); + $res->{cpuinfo} = PVE::ProcFSTools::read_cpuinfo(); my $stat = PVE::ProcFSTools::read_proc_stat(); From 1f1d8bf3889f9995abce51a7606890ffcfe38fad Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 13:45:18 +0100 Subject: [PATCH 371/398] ui: node summary: add boot-mode information Add a extra row for showing the current boot mode, for that we need to grow the height of the status panel and graphs to have enough space again. Signed-off-by: Thomas Lamprecht --- www/manager6/node/StatusView.js | 17 ++++++++++++++++- www/manager6/node/Summary.js | 2 +- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/www/manager6/node/StatusView.js b/www/manager6/node/StatusView.js index 41fb51353..24b2c8d8d 100644 --- a/www/manager6/node/StatusView.js +++ b/www/manager6/node/StatusView.js @@ -2,7 +2,7 @@ Ext.define('PVE.node.StatusView', { extend: 'Proxmox.panel.StatusView', alias: 'widget.pveNodeStatus', - height: 300, + height: 350, bodyPadding: '15 5 15 5', layout: { @@ -114,6 +114,21 @@ Ext.define('PVE.node.StatusView', { }, value: '', }, + { + colspan: 2, + title: gettext('Boot Mode'), + printBar: false, + textField: 'boot-info', + renderer: boot => { + if (boot.mode === 'legacy-bios') { + return 'Legacy BIOS'; + } else if (boot.mode === 'efi') { + return `EFI${boot.secureboot ? ' (Secure Boot)' : ''}`; + } + return Proxmox.Utils.unknownText; + }, + value: '', + }, { itemId: 'version', colspan: 2, diff --git a/www/manager6/node/Summary.js b/www/manager6/node/Summary.js index 03512d708..c2dca0df1 100644 --- a/www/manager6/node/Summary.js +++ b/www/manager6/node/Summary.js @@ -150,7 +150,7 @@ Ext.define('PVE.node.Summary', { layout: 'column', minWidth: 700, defaults: { - minHeight: 325, + minHeight: 350, padding: 5, columnWidth: 1, }, From 024017cffac3a36d6e50af887d021efb319bae20 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Wed, 22 Nov 2023 13:29:51 +0100 Subject: [PATCH 372/398] ipam: send ip to delete endpoint The ip parameter has been added to the delete endpoint, so only a specific mapping gets deleted instead of all mappings for that mac address. Reflect this change in the UI. Signed-off-by: Stefan Hanreich --- www/manager6/tree/DhcpTree.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/tree/DhcpTree.js b/www/manager6/tree/DhcpTree.js index b7baba606..b5fbafe03 100644 --- a/www/manager6/tree/DhcpTree.js +++ b/www/manager6/tree/DhcpTree.js @@ -108,6 +108,7 @@ Ext.define('PVE.sdn.DhcpTree', { let params = { zone: data.zone, mac: data.mac, + ip: data.ip, }; let encodedParams = Ext.Object.toQueryString(params); From c6a1e4e8f0e4084ceaa23f41a928e503e184a7bd Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 14:55:46 +0100 Subject: [PATCH 373/398] d/control: upgrade libpve-network-perl dependency to recommendation could actually be a hard dependency too Signed-off-by: Thomas Lamprecht --- debian/control | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/debian/control b/debian/control index 7771bd9e3..78d1d6f42 100644 --- a/debian/control +++ b/debian/control @@ -98,8 +98,7 @@ Depends: apt (>= 1.5~), ${misc:Depends}, ${perl:Depends}, ${shlibs:Depends} -Recommends: proxmox-offline-mirror-helper -Suggests: libpve-network-perl (>= 0.8.2) +Recommends: proxmox-offline-mirror-helper, libpve-network-perl (>= 0.9~) Conflicts: vlan, vzdump, Replaces: vlan, From 4898513c4e09b028ae551d0a1e5cbebb5b9a971f Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 14:56:12 +0100 Subject: [PATCH 374/398] bump version to 8.1.0 Signed-off-by: Thomas Lamprecht --- debian/changelog | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/debian/changelog b/debian/changelog index 15b0e77f7..ceb3a266e 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,42 @@ +pve-manager (8.1.0) bookworm; urgency=medium + + * update shipped appliance info index + + * ui: fix zero-sized panels on fresh chrome start + + * fix #1148: api: pools: support nested pools + + * ui: vm create wizard: allow adding an extra iso drive for the VirtIO + drivers and set the defaults of the disk and network device-types + accordingly. + + * api: ceph: add endpoint to fetch config keys + + * fix #2515: ui: ceph pool create: use configured defaults for 'size' and + 'min_size' + + * vzdump: support 'notification-mode' parameter to allow deciding if one + wants to use the legacy sendmail-only system or the new centrally managed + notification system. + + * sdn: regenerate DHCP config on reload + + * sdn: add DHCP option to Zone dialogue + + * sdn: subnet: add panel for editing DHCP ranges + + * ui: node summary: drop PVE prefix for manager version + + * api: nodes: add full info about current kernel from uname call + + * api: nodes: add info about current boot mode + + * ui: node summary: reduce noise in current kernel version + + * ui: node summary: add boot-mode information + + -- Proxmox Support Team Wed, 22 Nov 2023 14:50:05 +0100 + pve-manager (8.0.9) bookworm; urgency=medium * move default link config to drop-in snippet to avoid that the all default From 01df797d0351b430a9dc3599e42705f5cb0e1e80 Mon Sep 17 00:00:00 2001 From: Christian Ebner Date: Wed, 22 Nov 2023 15:22:07 +0100 Subject: [PATCH 375/398] ui: sdn: use all upper case for acronyms Signed-off-by: Christian Ebner --- www/manager6/sdn/ZoneView.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/manager6/sdn/ZoneView.js b/www/manager6/sdn/ZoneView.js index 71890dd35..67e86e166 100644 --- a/www/manager6/sdn/ZoneView.js +++ b/www/manager6/sdn/ZoneView.js @@ -137,7 +137,7 @@ Ext.define('PVE.sdn.ZoneView', { }, }, { - header: 'Ipam', + header: 'IPAM', flex: 3, dataIndex: 'ipam', renderer: function(value, metaData, rec) { @@ -153,7 +153,7 @@ Ext.define('PVE.sdn.ZoneView', { }, }, { - header: gettext('Dns'), + header: gettext('DNS'), flex: 3, dataIndex: 'dns', renderer: function(value, metaData, rec) { @@ -161,7 +161,7 @@ Ext.define('PVE.sdn.ZoneView', { }, }, { - header: gettext('Reverse dns'), + header: gettext('Reverse DNS'), flex: 3, dataIndex: 'reversedns', renderer: function(value, metaData, rec) { From 92759ae1b451a41183f5bf32b181e387199c23f6 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 16:07:09 +0100 Subject: [PATCH 376/398] api: node status: cache boot mode info it's not that expensive but we call the endpoint that returns the boot mode info very frequently, and EFI vars are provided by the firmware, and there are lots of known cases where firmware was just a plain mess. So, don't risk that overly frequent reads will cause some weird side effect and rather just cache the whole info, it cannot change without a reboot anyway. Signed-off-by: Thomas Lamprecht --- PVE/API2/Nodes.pm | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index c1d5f5ea7..94b201726 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -355,10 +355,13 @@ my sub get_current_kernel_info { return ($current_kernel, $kernel_version_string); } +my $boot_mode_info_cache; my sub get_boot_mode_info { + return $boot_mode_info_cache if defined($boot_mode_info_cache); + my $is_efi_booted = -d "/sys/firmware/efi"; - my $info = { + $boot_mode_info_cache = { mode => $is_efi_booted ? 'efi' : 'legacy-bios', }; @@ -368,10 +371,10 @@ my sub get_boot_mode_info { warn "Failed to read secure boot state: $@\n"; } else { my @secureboot = unpack("CCCCC", $efi_var_sec_boot_entry); - $info->{secureboot} = $secureboot[4] == 1 ? 1 : 0; + $boot_mode_info_cache->{secureboot} = $secureboot[4] == 1 ? 1 : 0; } } - return $info; + return $boot_mode_info_cache; } __PACKAGE__->register_method({ From 9029252357822824bfc63861a8b4a7c56e8c8059 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Wed, 22 Nov 2023 18:41:05 +0100 Subject: [PATCH 377/398] sdn: adapt edit panels for new API endpoints PUT endpoints have changed so they work like the other SectionConfig endpoints. Reflect those changes in the UI. Signed-off-by: Stefan Hanreich --- www/manager6/sdn/SubnetEdit.js | 23 ++++++++++------- www/manager6/sdn/VnetEdit.js | 18 +++++++++----- www/manager6/sdn/zones/Base.js | 2 +- www/manager6/sdn/zones/EvpnEdit.js | 37 ++++++++-------------------- www/manager6/sdn/zones/SimpleEdit.js | 3 ++- 5 files changed, 39 insertions(+), 44 deletions(-) diff --git a/www/manager6/sdn/SubnetEdit.js b/www/manager6/sdn/SubnetEdit.js index 8851b013a..8fc3f52b0 100644 --- a/www/manager6/sdn/SubnetEdit.js +++ b/www/manager6/sdn/SubnetEdit.js @@ -11,13 +11,6 @@ Ext.define('PVE.sdn.SubnetInputPanel', { delete values.cidr; } - if (!values.gateway) { - delete values.gateway; - } - if (!values.snat) { - delete values.snat; - } - return values; }, @@ -33,18 +26,25 @@ Ext.define('PVE.sdn.SubnetInputPanel', { fieldLabel: gettext('Subnet'), }, { - xtype: 'textfield', + xtype: 'proxmoxtextfield', name: 'gateway', vtype: 'IP64Address', fieldLabel: gettext('Gateway'), allowBlank: true, + skipEmptyText: true, + cbind: { + deleteEmpty: "{!isCreate}", + }, }, { xtype: 'proxmoxcheckbox', name: 'snat', - uncheckedValue: 0, + uncheckedValue: null, checked: false, fieldLabel: 'SNAT', + cbind: { + deleteEmpty: "{!isCreate}", + }, }, { xtype: 'proxmoxtextfield', @@ -52,6 +52,9 @@ Ext.define('PVE.sdn.SubnetInputPanel', { skipEmptyText: true, fieldLabel: gettext('DNS Zone Prefix'), allowBlank: true, + cbind: { + deleteEmpty: "{!isCreate}", + }, }, ], }); @@ -101,6 +104,8 @@ Ext.define('PVE.sdn.SubnetDhcpRangePanel', { if (value.length) { data[me.getName()] = value; + } else if (!me.isCreate) { + data.delete = me.getName(); } return data; diff --git a/www/manager6/sdn/VnetEdit.js b/www/manager6/sdn/VnetEdit.js index 0f55595f8..cdd83ed40 100644 --- a/www/manager6/sdn/VnetEdit.js +++ b/www/manager6/sdn/VnetEdit.js @@ -9,10 +9,6 @@ Ext.define('PVE.sdn.VnetInputPanel', { values.type = 'vnet'; } - if (!values.vlanaware) { - delete values.vlanaware; - } - return values; }, @@ -29,10 +25,14 @@ Ext.define('PVE.sdn.VnetInputPanel', { fieldLabel: gettext('Name'), }, { - xtype: 'textfield', + xtype: 'proxmoxtextfield', name: 'alias', fieldLabel: gettext('Alias'), allowBlank: true, + skipEmptyText: true, + cbind: { + deleteEmpty: "{!isCreate}", + }, }, { xtype: 'pveSDNZoneSelector', @@ -48,13 +48,19 @@ Ext.define('PVE.sdn.VnetInputPanel', { maxValue: 16777216, fieldLabel: gettext('Tag'), allowBlank: true, + cbind: { + deleteEmpty: "{!isCreate}", + }, }, { xtype: 'proxmoxcheckbox', name: 'vlanaware', - uncheckedValue: 0, + uncheckedValue: null, checked: false, fieldLabel: gettext('VLAN Aware'), + cbind: { + deleteEmpty: "{!isCreate}", + }, }, ], }); diff --git a/www/manager6/sdn/zones/Base.js b/www/manager6/sdn/zones/Base.js index db9b47b18..2eecb101a 100644 --- a/www/manager6/sdn/zones/Base.js +++ b/www/manager6/sdn/zones/Base.js @@ -34,9 +34,9 @@ Ext.define('PVE.panel.SDNZoneBase', { minValue: 100, maxValue: 65000, fieldLabel: 'MTU', - skipEmptyText: true, allowBlank: true, emptyText: 'auto', + deleteEmpty: !me.isCreate, }, { xtype: 'pveNodeSelector', diff --git a/www/manager6/sdn/zones/EvpnEdit.js b/www/manager6/sdn/zones/EvpnEdit.js index cac1ef4d5..a08faef2d 100644 --- a/www/manager6/sdn/zones/EvpnEdit.js +++ b/www/manager6/sdn/zones/EvpnEdit.js @@ -8,30 +8,8 @@ Ext.define('PVE.sdn.zones.EvpnInputPanel', { if (me.isCreate) { values.type = me.type; - } else { - delete values.zone; } - if (!values.mac) { - delete values.mac; - } - - if (values['advertise-subnets'] === 0) { - delete values['advertise-subnets']; - } - - if (values['exitnodes-local-routing'] === 0) { - delete values['exitnodes-local-routing']; - } - - if (values['disable-arp-nd-suppression'] === 0) { - delete values['disable-arp-nd-suppression']; - } - - if (values['exitnodes-primary'] === '') { - delete values['exitnodes-primary']; - } - return values; }, @@ -55,12 +33,13 @@ Ext.define('PVE.sdn.zones.EvpnInputPanel', { allowBlank: false, }, { - xtype: 'textfield', + xtype: 'proxmoxtextfield', name: 'mac', fieldLabel: gettext('VNet MAC Address'), vtype: 'MacAddress', allowBlank: true, emptyText: 'auto', + deleteEmpty: !me.isCreate, }, { xtype: 'pveNodeSelector', @@ -79,29 +58,33 @@ Ext.define('PVE.sdn.zones.EvpnInputPanel', { { xtype: 'proxmoxcheckbox', name: 'exitnodes-local-routing', - uncheckedValue: 0, + uncheckedValue: null, checked: false, fieldLabel: gettext('Exit Nodes Local Routing'), + deleteEmpty: !me.isCreate, }, { xtype: 'proxmoxcheckbox', name: 'advertise-subnets', - uncheckedValue: 0, + uncheckedValue: null, checked: false, fieldLabel: gettext('Advertise Subnets'), + deleteEmpty: !me.isCreate, }, { xtype: 'proxmoxcheckbox', name: 'disable-arp-nd-suppression', - uncheckedValue: 0, + uncheckedValue: null, checked: false, fieldLabel: gettext('Disable ARP-nd Suppression'), + deleteEmpty: !me.isCreate, }, { - xtype: 'textfield', + xtype: 'proxmoxtextfield', name: 'rt-import', fieldLabel: gettext('Route Target Import'), allowBlank: true, + deleteEmpty: !me.isCreate, }, ]; diff --git a/www/manager6/sdn/zones/SimpleEdit.js b/www/manager6/sdn/zones/SimpleEdit.js index 7a6f1d0d9..89bd0031f 100644 --- a/www/manager6/sdn/zones/SimpleEdit.js +++ b/www/manager6/sdn/zones/SimpleEdit.js @@ -24,9 +24,10 @@ Ext.define('PVE.sdn.zones.SimpleInputPanel', { xtype: 'proxmoxcheckbox', name: 'dhcp', inputValue: 'dnsmasq', - uncheckedValue: undefined, + uncheckedValue: null, checked: false, fieldLabel: gettext('automatic DHCP'), + deleteEmpty: !me.isCreate, }, ]; From 58d4207bd37941c5c84d25e3ca2f806ee5ec7273 Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Wed, 22 Nov 2023 19:05:36 +0100 Subject: [PATCH 378/398] sdn: zone: fix edit for dns zone Signed-off-by: Stefan Hanreich --- www/manager6/sdn/zones/Base.js | 1 + 1 file changed, 1 insertion(+) diff --git a/www/manager6/sdn/zones/Base.js b/www/manager6/sdn/zones/Base.js index 2eecb101a..17693cf3c 100644 --- a/www/manager6/sdn/zones/Base.js +++ b/www/manager6/sdn/zones/Base.js @@ -78,6 +78,7 @@ Ext.define('PVE.panel.SDNZoneBase', { skipEmptyText: true, fieldLabel: gettext('DNS Zone'), allowBlank: true, + deleteEmpty: !me.isCreate, }, ); From 88dde360d05e126dd0cbddbc40ac3a104a857ef8 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 19:35:41 +0100 Subject: [PATCH 379/398] ui: sdn zone base: fix indentation Signed-off-by: Thomas Lamprecht --- www/manager6/sdn/zones/Base.js | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/www/manager6/sdn/zones/Base.js b/www/manager6/sdn/zones/Base.js index 17693cf3c..709129e68 100644 --- a/www/manager6/sdn/zones/Base.js +++ b/www/manager6/sdn/zones/Base.js @@ -28,24 +28,24 @@ Ext.define('PVE.panel.SDNZoneBase', { }); me.items.push( - { - xtype: 'proxmoxintegerfield', - name: 'mtu', - minValue: 100, - maxValue: 65000, - fieldLabel: 'MTU', - allowBlank: true, - emptyText: 'auto', + { + xtype: 'proxmoxintegerfield', + name: 'mtu', + minValue: 100, + maxValue: 65000, + fieldLabel: 'MTU', + allowBlank: true, + emptyText: 'auto', deleteEmpty: !me.isCreate, - }, - { - xtype: 'pveNodeSelector', - name: 'nodes', - fieldLabel: gettext('Nodes'), - emptyText: gettext('All') + ' (' + gettext('No restrictions') +')', - multiSelect: true, - autoSelect: false, - }, + }, + { + xtype: 'pveNodeSelector', + name: 'nodes', + fieldLabel: gettext('Nodes'), + emptyText: gettext('All') + ' (' + gettext('No restrictions') +')', + multiSelect: true, + autoSelect: false, + }, { xtype: 'pveSDNIpamSelector', fieldLabel: gettext('IPAM'), From 3f088b4a5020911776c5a2d7a3beeafab14caf1e Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 19:37:14 +0100 Subject: [PATCH 380/398] bump version to 8.1.1 Signed-off-by: Thomas Lamprecht --- debian/changelog | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/debian/changelog b/debian/changelog index ceb3a266e..023f2cb00 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +pve-manager (8.1.1) bookworm; urgency=medium + + * ui: sdn: use all upper case for acronyms + + * api: node status: cache boot mode info + + * sdn: adapt edit panels for new API endpoints + + -- Proxmox Support Team Wed, 22 Nov 2023 19:37:00 +0100 + pve-manager (8.1.0) bookworm; urgency=medium * update shipped appliance info index From f635d067442017dac6121947aaa605dc7368b81a Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Wed, 22 Nov 2023 20:40:29 +0100 Subject: [PATCH 381/398] ui: iso selector: fix layout, stretch items again to full space Signed-off-by: Thomas Lamprecht --- www/manager6/form/IsoSelector.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/www/manager6/form/IsoSelector.js b/www/manager6/form/IsoSelector.js index 632ee7f0a..0bc6346c1 100644 --- a/www/manager6/form/IsoSelector.js +++ b/www/manager6/form/IsoSelector.js @@ -6,6 +6,11 @@ Ext.define('PVE.form.IsoSelector', { 'Proxmox.Mixin.CBind', ], + layout: { + type: 'vbox', + align: 'stretch', + }, + nodename: undefined, insideWizard: false, From 4a8846554681d750c4c7e7c9f60e6a8fd48d4d9c Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 23 Nov 2023 09:23:23 +0100 Subject: [PATCH 382/398] ui: drop various translations of fixed strings Signed-off-by: Thomas Lamprecht --- www/manager6/dc/BackupJobDetail.js | 2 +- www/manager6/grid/BackupView.js | 2 +- www/manager6/node/Config.js | 2 +- www/manager6/sdn/IpamEdit.js | 6 +++--- www/manager6/storage/BackupView.js | 2 +- www/manager6/tree/DhcpTree.js | 4 ++-- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/www/manager6/dc/BackupJobDetail.js b/www/manager6/dc/BackupJobDetail.js index e154fec14..d00006b5b 100644 --- a/www/manager6/dc/BackupJobDetail.js +++ b/www/manager6/dc/BackupJobDetail.js @@ -434,7 +434,7 @@ Ext.define('PVE.dc.BackedGuests', { sortable: true, }, { - header: gettext('VMID'), + header: 'VMID', dataIndex: 'vmid', flex: 1, sortable: true, diff --git a/www/manager6/grid/BackupView.js b/www/manager6/grid/BackupView.js index 65340dcb1..e71d1c88a 100644 --- a/www/manager6/grid/BackupView.js +++ b/www/manager6/grid/BackupView.js @@ -364,7 +364,7 @@ Ext.define('PVE.grid.BackupView', { dataIndex: 'size', }, { - header: gettext('VMID'), + header: 'VMID', dataIndex: 'vmid', hidden: true, }, diff --git a/www/manager6/node/Config.js b/www/manager6/node/Config.js index f1a82e6e4..4d9062f38 100644 --- a/www/manager6/node/Config.js +++ b/www/manager6/node/Config.js @@ -437,7 +437,7 @@ Ext.define('PVE.node.Config', { extraFilter: [ { xtype: 'pveGuestIDSelector', - fieldLabel: gettext('VMID'), + fieldLabel: 'VMID', allowBlank: true, name: 'vmid', }, diff --git a/www/manager6/sdn/IpamEdit.js b/www/manager6/sdn/IpamEdit.js index 73e5d2e1a..4db8a4569 100644 --- a/www/manager6/sdn/IpamEdit.js +++ b/www/manager6/sdn/IpamEdit.js @@ -18,7 +18,7 @@ Ext.define('PVE.sdn.IpamEditInputPanel', { { xtype: 'pmxDisplayEditField', name: 'vmid', - fieldLabel: gettext('VMID'), + fieldLabel: 'VMID', allowBlank: false, editable: false, cbind: { @@ -28,7 +28,7 @@ Ext.define('PVE.sdn.IpamEditInputPanel', { { xtype: 'pmxDisplayEditField', name: 'mac', - fieldLabel: gettext('MAC'), + fieldLabel: 'MAC', allowBlank: false, cbind: { editable: '{isCreate}', @@ -37,7 +37,7 @@ Ext.define('PVE.sdn.IpamEditInputPanel', { { xtype: 'proxmoxtextfield', name: 'ip', - fieldLabel: gettext('IP'), + fieldLabel: gettext('IP Address'), allowBlank: false, }, ], diff --git a/www/manager6/storage/BackupView.js b/www/manager6/storage/BackupView.js index bdaf85c8d..878e1c8fb 100644 --- a/www/manager6/storage/BackupView.js +++ b/www/manager6/storage/BackupView.js @@ -223,7 +223,7 @@ Ext.define('PVE.storage.BackupView', { } me.extraColumns.vmid = { - header: gettext('VMID'), + header: 'VMID', dataIndex: 'vmid', hidden: true, sorter: (a, b) => (a.data.vmid ?? 0) - (b.data.vmid ?? 0), diff --git a/www/manager6/tree/DhcpTree.js b/www/manager6/tree/DhcpTree.js index b5fbafe03..d0b80803d 100644 --- a/www/manager6/tree/DhcpTree.js +++ b/www/manager6/tree/DhcpTree.js @@ -199,12 +199,12 @@ Ext.define('PVE.sdn.DhcpTree', { }, }, { - text: gettext('IP'), + text: gettext('IP Address'), dataIndex: 'ip', width: 200, }, { - text: gettext('MAC'), + text: 'MAC', dataIndex: 'mac', width: 200, }, From fa17629b5fe742d7a636089d3cdab7c730fefa0c Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 23 Nov 2023 09:25:40 +0100 Subject: [PATCH 383/398] ui: resource tree: fix extra space inconsistency in some node elements When a node element was updated, it was put through the 'setText' method which accidentally added a space before it's name. Fix this by putting the space into the status variable. Without this patch one could observe a vertical misalignment when some nodes had guests on them but others had none. Fixes: 2f414c50 ("ui: resource tree: limit tooltip to icon and text") Signed-off-by: Dominik Csapak Signed-off-by: Thomas Lamprecht --- www/manager6/tree/ResourceTree.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/manager6/tree/ResourceTree.js b/www/manager6/tree/ResourceTree.js index acfa545ae..7b2934ae9 100644 --- a/www/manager6/tree/ResourceTree.js +++ b/www/manager6/tree/ResourceTree.js @@ -122,7 +122,7 @@ Ext.define('PVE.tree.ResourceTree', { status = '
'; status += `
`; status += `
`; - status += '
'; + status += ' '; } } if (Ext.isNumeric(info.vmid) && info.vmid > 0) { @@ -130,7 +130,7 @@ Ext.define('PVE.tree.ResourceTree', { info.text = `${info.name} (${String(info.vmid)})`; } } - info.text = `${status} ${info.text}`; + info.text = `${status}${info.text}`; info.text += PVE.Utils.renderTags(info.tags, PVE.UIOptions.tagOverrides); }, From b877366adeea22e937cf720923d54cc477d5f8d1 Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 23 Nov 2023 09:25:42 +0100 Subject: [PATCH 384/398] ui: resource tree: remove wrong comment that function is not only there for the storage indicators, but generally for adding additional information, such as tags, and for wrapping in a span for making tooltip selection easier. Signed-off-by: Dominik Csapak --- www/manager6/tree/ResourceTree.js | 1 - 1 file changed, 1 deletion(-) diff --git a/www/manager6/tree/ResourceTree.js b/www/manager6/tree/ResourceTree.js index 7b2934ae9..d3c15aeca 100644 --- a/www/manager6/tree/ResourceTree.js +++ b/www/manager6/tree/ResourceTree.js @@ -109,7 +109,6 @@ Ext.define('PVE.tree.ResourceTree', { } }, - // add additional elements to text. Currently only the usage indicator for storages setText: function(info) { let me = this; From 95ece724d541b00d803b6f12f2a4be953dfa4ebe Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 23 Nov 2023 10:40:13 +0100 Subject: [PATCH 385/398] d/control: bump versioned dependency for libpve-guest-common-perl Ensure new notification-mode property is recognized for backup jobs. Signed-off-by: Thomas Lamprecht --- debian/control | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/control b/debian/control index 78d1d6f42..8add0a3d0 100644 --- a/debian/control +++ b/debian/control @@ -61,7 +61,7 @@ Depends: apt (>= 1.5~), libpve-cluster-api-perl (>= 7.0-5), libpve-cluster-perl (>= 7.2-3), libpve-common-perl (>= 7.2-7), - libpve-guest-common-perl (>= 5.0.5), + libpve-guest-common-perl (>= 5.0.6), libpve-http-server-perl (>= 4.1-1), libpve-notify-perl (>= 8.0.5), libpve-rs-perl (>= 0.7.1), From d31e7f13c62dd870c13b597af5d629c403187da5 Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Thu, 23 Nov 2023 10:54:03 +0100 Subject: [PATCH 386/398] ui: perm paths: change /mapping/notification to /mapping/notifications The ACL path was changed during the notification system rework. This change adapts the list of predefined ACL paths in the 'Add {User,Group,API Token} Permission' dialog window to reflect this change. Signed-off-by: Lukas Wagner --- www/manager6/data/PermPathStore.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/data/PermPathStore.js b/www/manager6/data/PermPathStore.js index 64ab2f03e..8785a1d78 100644 --- a/www/manager6/data/PermPathStore.js +++ b/www/manager6/data/PermPathStore.js @@ -9,7 +9,7 @@ Ext.define('PVE.data.PermPathStore', { { 'value': '/access/groups' }, { 'value': '/access/realm' }, { 'value': '/mapping' }, - { 'value': '/mapping/notification' }, + { 'value': '/mapping/notifications' }, { 'value': '/mapping/pci' }, { 'value': '/mapping/usb' }, { 'value': '/nodes' }, From 38d153d6c751a69959030e3f37fccd773e34ba42 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 23 Nov 2023 10:23:11 +0100 Subject: [PATCH 387/398] bump version to 8.1.2 Signed-off-by: Thomas Lamprecht --- debian/changelog | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/debian/changelog b/debian/changelog index 023f2cb00..14b894b69 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,19 @@ +pve-manager (8.1.2) bookworm; urgency=medium + + * ui: iso selector: fix layout, stretch items again to full space + + * ui: drop various translations of fixed strings like 'VMID' + + * ui: resource tree: fix inconsistency with extra-space causing some + potential vertical misalignment with nodes that had no virtual-guest + + * ui: resource tree: consistently apply the tooltip and 'setText' method + + * ui: perm paths: use adapted plural form for /mapping/notifications ACL + path + + -- Proxmox Support Team Thu, 23 Nov 2023 10:23:05 +0100 + pve-manager (8.1.1) bookworm; urgency=medium * ui: sdn: use all upper case for acronyms From cb92b114f37285148a0939128c662ac7b82262ac Mon Sep 17 00:00:00 2001 From: Dominik Csapak Date: Thu, 23 Nov 2023 11:07:19 +0100 Subject: [PATCH 388/398] ui: pool view: fix editing nested pools for nested pools we have to provide the pool id via a get parameter instead of in the path, and also we have to extract the data from the returned array. To do this, changet the cbind url handler, remove the autoLoad one, and handle the load ourselves. Signed-off-by: Dominik Csapak --- www/manager6/dc/PoolEdit.js | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/www/manager6/dc/PoolEdit.js b/www/manager6/dc/PoolEdit.js index d55756e72..db3d23cde 100644 --- a/www/manager6/dc/PoolEdit.js +++ b/www/manager6/dc/PoolEdit.js @@ -11,8 +11,7 @@ Ext.define('PVE.dc.PoolEdit', { }, cbind: { - autoLoad: get => !get('isCreate'), - url: get => `/api2/extjs/pools/${get('poolid')}`, + url: get => `/api2/extjs/pools/${!get('isCreate') ? '?poolid=' + get('poolid') : ''}`, method: get => get('isCreate') ? 'POST' : 'PUT', }, @@ -34,4 +33,21 @@ Ext.define('PVE.dc.PoolEdit', { allowBlank: true, }, ], + + initComponent: function() { + let me = this; + me.callParent(); + if (me.poolid) { + me.load({ + success: function(response) { + let data = response.result.data; + if (Ext.isArray(data)) { + me.setValues(data[0]); + } else { + me.setValues(data); + } + }, + }); + } + }, }); From b46aac3b42da5d157bab72b416ee17cfaae053d2 Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 23 Nov 2023 11:20:06 +0100 Subject: [PATCH 389/398] bump version to 8.1.3 Signed-off-by: Thomas Lamprecht --- debian/changelog | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/debian/changelog b/debian/changelog index 14b894b69..1baef29f8 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,9 @@ +pve-manager (8.1.3) bookworm; urgency=medium + + * ui: pool view: fix editing nested pools + + -- Proxmox Support Team Thu, 23 Nov 2023 11:20:00 +0100 + pve-manager (8.1.2) bookworm; urgency=medium * ui: iso selector: fix layout, stretch items again to full space From b81057a92627b4dd44ccb8969ebdcedfd408da2e Mon Sep 17 00:00:00 2001 From: Thomas Lamprecht Date: Thu, 23 Nov 2023 11:33:58 +0100 Subject: [PATCH 390/398] api: apt versions: track dnsmasq and frr-pythontools as optional packages Signed-off-by: Thomas Lamprecht --- PVE/API2/APT.pm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/PVE/API2/APT.pm b/PVE/API2/APT.pm index 2ea003e2a..f50a5347f 100644 --- a/PVE/API2/APT.pm +++ b/PVE/API2/APT.pm @@ -761,6 +761,8 @@ __PACKAGE__->register_method({ my @opt_pack = qw( ceph criu + dnsmasq + frr-pythontools gfs2-utils ifupdown ifupdown2 From 8113229eb7ef0f85d3277209304984304eceaf54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabian=20Gr=C3=BCnbichler?= Date: Tue, 28 Nov 2023 07:58:49 +0100 Subject: [PATCH 391/398] node: add guard for missing secure-boot efi var MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit some (old) systems might have efivars, but don't have the SecureBoot one. Signed-off-by: Fabian Grünbichler --- PVE/API2/Nodes.pm | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index 94b201726..3619190de 100644 --- a/PVE/API2/Nodes.pm +++ b/PVE/API2/Nodes.pm @@ -365,8 +365,10 @@ my sub get_boot_mode_info { mode => $is_efi_booted ? 'efi' : 'legacy-bios', }; - if ($is_efi_booted) { - my $efi_var_sec_boot_entry = eval { file_get_contents("/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c") }; + my $efi_var = "/sys/firmware/efi/efivars/SecureBoot-8be4df61-93ca-11d2-aa0d-00e098032b8c"; + + if ($is_efi_booted && -e $efi_var) { + my $efi_var_sec_boot_entry = eval { file_get_contents($efi_var) }; if ($@) { warn "Failed to read secure boot state: $@\n"; } else { From ac7127388f1a0d8b1a254a618a53f71f15cb2125 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Mon, 4 Dec 2023 10:29:56 +0100 Subject: [PATCH 392/398] ui: iso selector: disable all fields to avoid bogus validation The validation logic of the inner fields from the ISO selector was not disabled, so a user would need to select a valid storage and ISO file before being able to make any other choice for the general CD-ROM drive source (no-media or physical-drive). Call the parent method to ensure all the inner fields get actually disabled so that their validators also get disarmed if not relevant. Reported in the communiy forum: https://forum.proxmox.com/threads/136960/post-611704 Fixes: fc7b556d ("ui: refactor iso-selector out of the cd input panel") Signed-off-by: Fiona Ebner [ TL: add more background to commit message/subject ] Signed-off-by: Thomas Lamprecht --- www/manager6/form/IsoSelector.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/form/IsoSelector.js b/www/manager6/form/IsoSelector.js index 0bc6346c1..0f1bf744d 100644 --- a/www/manager6/form/IsoSelector.js +++ b/www/manager6/form/IsoSelector.js @@ -61,7 +61,7 @@ Ext.define('PVE.form.IsoSelector', { let me = this; me.lookup('storage').setDisabled(disabled); me.lookup('file').setDisabled(disabled); - me.callParent(); + return me.callParent([disabled]); }, referenceHolder: true, From 4f0d58394fa9c96af17298a52488a84bf72e173d Mon Sep 17 00:00:00 2001 From: Lukas Wagner Date: Fri, 1 Dec 2023 14:24:09 +0100 Subject: [PATCH 393/398] api: replication: allow users to enumerate accessible replication jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the /cluster/replication API handler would fail completely with a HTTP 403 if a user does have VM.Audit permissions for a single VM/CT. That was due to the 'noerr' parameter not set for $rpcenv->check() Signed-off-by: Lukas Wagner Reviewed-by: Fabian Grünbichler --- PVE/API2/ReplicationConfig.pm | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/PVE/API2/ReplicationConfig.pm b/PVE/API2/ReplicationConfig.pm index 8af626214..d0e8a49ee 100644 --- a/PVE/API2/ReplicationConfig.pm +++ b/PVE/API2/ReplicationConfig.pm @@ -20,7 +20,8 @@ __PACKAGE__->register_method ({ method => 'GET', description => "List replication jobs.", permissions => { - description => "Requires the VM.Audit permission on /vms/.", + description => "Will only return replication jobs for which the calling user has" + . " VM.Audit permission on /vms/.", user => 'all', }, parameters => { @@ -47,7 +48,7 @@ __PACKAGE__->register_method ({ foreach my $id (sort keys %{$cfg->{ids}}) { my $d = $cfg->{ids}->{$id}; my $vmid = $d->{guest}; - next if !$rpcenv->check($authuser, "/vms/$vmid", [ 'VM.Audit' ]); + next if !$rpcenv->check($authuser, "/vms/$vmid", [ 'VM.Audit' ], 1); $d->{id} = $id; push @$res, $d; } From 4fb7e9e434471adedcf35832a226ee84bd1cb566 Mon Sep 17 00:00:00 2001 From: Fiona Ebner Date: Thu, 14 Dec 2023 11:34:44 +0100 Subject: [PATCH 394/398] fix #5121: ui: node status: avoid invalid array access for certain foreign kernels with custom build date format, which would prevent the panel from loading. Signed-off-by: Fiona Ebner --- www/manager6/node/StatusView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/manager6/node/StatusView.js b/www/manager6/node/StatusView.js index 24b2c8d8d..0ac200c75 100644 --- a/www/manager6/node/StatusView.js +++ b/www/manager6/node/StatusView.js @@ -109,7 +109,7 @@ Ext.define('PVE.node.StatusView', { return data.kversion; } let kernel = data['current-kernel']; - let buildDate = kernel.version.match(/\((.+)\)\s*$/)[1] ?? 'unknown'; + let buildDate = kernel.version.match(/\((.+)\)\s*$/)?.[1] ?? 'unknown'; return `${kernel.sysname} ${kernel.release} (${buildDate})`; }, value: '', From 9a9b908a415e2d8c2d60f2532b27df40e42c8451 Mon Sep 17 00:00:00 2001 From: Hannes Duerr Date: Wed, 3 Jan 2024 09:53:30 +0100 Subject: [PATCH 395/398] report: add packet counter to iptables output Signed-off-by: Hannes Duerr --- PVE/Report.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/PVE/Report.pm b/PVE/Report.pm index 2024285e6..10b28c790 100644 --- a/PVE/Report.pm +++ b/PVE/Report.pm @@ -85,7 +85,7 @@ my $init_report_cmds = sub { cmds => [ sub { dir2text('/etc/pve/firewall/', '.*fw') }, 'cat /etc/pve/local/host.fw', - 'iptables-save', + 'iptables-save -c', ], }, cluster => { From c75ee959c5bc7f48ea1619bd61fb903f91dcea03 Mon Sep 17 00:00:00 2001 From: Aaron Lauterer Date: Tue, 10 Oct 2023 15:24:52 +0200 Subject: [PATCH 396/398] ui: DirEdit: LVMEdit: add hint when to enable shared Signed-off-by: Aaron Lauterer Reviewed-by: Fiona Ebner --- www/manager6/storage/DirEdit.js | 4 ++++ www/manager6/storage/LVMEdit.js | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/www/manager6/storage/DirEdit.js b/www/manager6/storage/DirEdit.js index 7e9ec44dd..3e2025fc8 100644 --- a/www/manager6/storage/DirEdit.js +++ b/www/manager6/storage/DirEdit.js @@ -30,6 +30,10 @@ Ext.define('PVE.storage.DirInputPanel', { name: 'shared', uncheckedValue: 0, fieldLabel: gettext('Shared'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Enable if the underlying file system is already shared between nodes.'), + }, }, ]; diff --git a/www/manager6/storage/LVMEdit.js b/www/manager6/storage/LVMEdit.js index fde302fce..b5a2d8127 100644 --- a/www/manager6/storage/LVMEdit.js +++ b/www/manager6/storage/LVMEdit.js @@ -227,6 +227,10 @@ Ext.define('PVE.storage.LVMInputPanel', { name: 'shared', uncheckedValue: 0, fieldLabel: gettext('Shared'), + autoEl: { + tag: 'div', + 'data-qtip': gettext('Enable if the LVM is located on a shared LUN.'), + }, }, ], }); From 250d7b07f12fd737cc349e70218a01e88b7d1e2c Mon Sep 17 00:00:00 2001 From: Stefan Hanreich Date: Fri, 22 Dec 2023 10:58:06 +0100 Subject: [PATCH 397/398] postinst: filter rbds in lvm Since LVM 2.03.15 RBD devices are also scanned by default [1]. This can lead to guest volumes being recognized and displayed on the host when using KRBD for RBD-backed disks. In order to prevent this we add an additional filter to the LVM config to avoid scanning rbds. This also prevents a bug where LVM created a very high amount of archive entries when there were logical volumes with the same path available. This could happen when two guests with RBD disks had the same LVM layout or a guest and host had the same layout. previous behavior: If there is no marker in the LVM conf and global_filter does not contain '/dev/zd.*': replace the global_filter with our version new behavior: Replace the global_filter iff: - There is no marker and global_filter is empty - The global_filter is exactly the old default If we don't replace the filter and it is a non-default value: We print a warning. Addtionally we force this function to run once when upgrading from older versions. The previous versions could replace custom global_filters where the comment had been removed and the zvol directive removed. The new behavior is slightly more conservative, but works the same in other cases. [1] https://gitlab.com/lvmteam/lvm2/-/commit/6a431eb24241caf2277d3e5b4718782d92650a2a Signed-off-by: Stefan Hanreich --- debian/postinst | 51 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/debian/postinst b/debian/postinst index 4c9a1f250..8028e39ee 100755 --- a/debian/postinst +++ b/debian/postinst @@ -9,23 +9,33 @@ set -e # installed and configured. set_lvm_conf() { + local FORCE="$1" LVM_CONF_MARKER="# added by pve-manager to avoid scanning" # keep user changes afterwards provided marker is still there.. - if grep -qLF "$LVM_CONF_MARKER" /etc/lvm/lvm.conf; then + if grep -qLF "$LVM_CONF_MARKER" /etc/lvm/lvm.conf && test -z "$FORCE"; then return 0 # only do these changes once fi - OLD_VALUE="$(lvmconfig --typeconfig full devices/global_filter)" - NEW_VALUE='global_filter=["r|/dev/zd.*|"]' - export LVM_SUPPRESS_FD_WARNINGS=1 - # check global_filter - # keep previous setting from our custom packaging if it is still there - if echo "$OLD_VALUE" | grep -qvF 'r|/dev/zd.*|'; then + OLD_VALUE="$(lvmconfig --typeconfig diff devices/global_filter || true)" + NEW_VALUE='global_filter=["r|/dev/zd.*|","r|/dev/rbd.*|"]' + + # update global_filter if: + # it is empty and there is no marker OR exactly the one we set before 8.1.4 + if (! grep -qF "$LVM_CONF_MARKER" /etc/lvm/lvm.conf && test -z "$OLD_VALUE")\ + || (echo "$OLD_VALUE" | grep -qF '="r|/dev/zd.*|"'); + then SET_FILTER=1 BACKUP=1 + # print warning if global_filter is set but not our old/new default + elif test -n "$OLD_VALUE"\ + && ! echo "$OLD_VALUE" | grep -qF '="r|/dev/zd.*|"'\ + && ! echo "$OLD_VALUE" | grep -qF "$NEW_VALUE"; + then + echo "non-default 'global_filter' value '$OLD_VALUE' in /etc/lvm/lvm.conf, not setting '$NEW_VALUE' automatically" + echo "consider adapting your 'global_filter' manually." fi # should be the default since bullseye if lvmconfig --typeconfig full devices/scan_lvs | grep -qv 'scan_lvs=0'; then @@ -37,17 +47,19 @@ set_lvm_conf() { cp -vb /etc/lvm/lvm.conf /etc/lvm/lvm.conf.bak fi if test -n "$SET_FILTER"; then - echo "Setting 'global_filter' in /etc/lvm/lvm.conf to prevent zvols from being scanned:" + echo "Setting 'global_filter' in /etc/lvm/lvm.conf to prevent zvols and rbds from being scanned:" echo "$OLD_VALUE => $NEW_VALUE" - # comment out existing setting - sed -i -e 's/^\([[:space:]]*global_filter[[:space:]]*=\)/#\1/' /etc/lvm/lvm.conf - # add new section with our setting - cat >> /etc/lvm/lvm.conf <> /etc/lvm/lvm.conf < Date: Thu, 14 Dec 2023 15:20:10 +0100 Subject: [PATCH 398/398] bump version to 8.1.4 Signed-off-by: Thomas Lamprecht --- debian/changelog | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/debian/changelog b/debian/changelog index 1baef29f8..51c5112fd 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,19 @@ +pve-manager (8.1.4) bookworm; urgency=medium + + * api: apt versions: track dnsmasq and frr-pythontools as optional packages + + * api: node status: add guard for missing secure-boot efi var + + * ui: iso selector: disable all fields to avoid bogus validation where one + cannot confirm the dialog even though the values visible are correct + + * api: replication: allow users to enumerate accessible replication jobs + + * fix #5121: ui: node status: avoid invalid array access for certain foreign + kernels + + -- Proxmox Support Team Thu, 14 Dec 2023 15:20:06 +0100 + pve-manager (8.1.3) bookworm; urgency=medium * ui: pool view: fix editing nested pools