public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Leo Nunner <l.nunner@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH WIP manager 1/2] api: endpoints for cluster-wide hosts config
Date: Thu, 14 Sep 2023 12:03:40 +0200	[thread overview]
Message-ID: <20230914100341.122329-4-l.nunner@proxmox.com> (raw)
In-Reply-To: <20230914100341.122329-1-l.nunner@proxmox.com>

Adds endpoints for a cluster-wide hosts configuration. The configuration
is stored at /etc/pve/hosts, and is synced into the local /etc/hosts on
each node. The entries are configured via endpoints that reside under
/cluster, and can then be synced to local nodes via an endpoint located
under /nodes/{node}.

The endpoints on the cluster are as follows:
    - GET	/cluster/hosts
	List all entries in the hosts config (ip, hosts, comment).
    - POST	/cluster/hosts
	Create a new entry in the config.
    - PUT	/cluster/hosts
	Update all cluster nodes with the new hosts config.
    - GET	/cluster/hosts/{ip}
	Get details for a specific IP in the hosts config.
    - PUT	/cluster/hosts/{ip}
	Update an existing entry in the hosts config.
    - DELETE	/cluster/hosts/{ip}
	Delete an existing entry from the hosts config.

On the node itself, there is only one new endpoint:
    - PUT	/nodes/{node}/hosts
	Writes the cluster hosts config into the local /etc/hosts.

Syncing the cluster config to /etc/hosts works via section markers,
where the PVE-managed section gets demarcated from the rest of the file.
Currently, this is always located at the bottom of the file, but this
may be configurable in the future (e.g. by making it configurable,
whether the cluster-wide entries should be an 'override' or a
'fallback').

Signed-off-by: Leo Nunner <l.nunner@proxmox.com>
---
 PVE/API2/Cluster.pm       |   6 ++
 PVE/API2/Cluster/Hosts.pm | 208 ++++++++++++++++++++++++++++++++++++++
 PVE/API2/Cluster/Makefile |   3 +-
 PVE/API2/Nodes.pm         |  39 +++++++
 PVE/Hosts.pm              | 152 ++++++++++++++++++++++++++++
 PVE/Makefile              |   1 +
 6 files changed, 408 insertions(+), 1 deletion(-)
 create mode 100644 PVE/API2/Cluster/Hosts.pm
 create mode 100644 PVE/Hosts.pm

diff --git a/PVE/API2/Cluster.pm b/PVE/API2/Cluster.pm
index 04387ab48..619ae654f 100644
--- a/PVE/API2/Cluster.pm
+++ b/PVE/API2/Cluster.pm
@@ -27,6 +27,7 @@ use PVE::API2::Backup;
 use PVE::API2::Cluster::BackupInfo;
 use PVE::API2::Cluster::Ceph;
 use PVE::API2::Cluster::Mapping;
+use PVE::API2::Cluster::Hosts;
 use PVE::API2::Cluster::Jobs;
 use PVE::API2::Cluster::MetricServer;
 use PVE::API2::Cluster::Notifications;
@@ -110,6 +111,11 @@ if ($have_sdn) {
     });
 }
 
+__PACKAGE__->register_method ({
+    subclass => "PVE::API2::Cluster::Hosts",
+    path => 'hosts',
+});
+
 my $dc_schema = PVE::DataCenterConfig::get_datacenter_schema();
 my $dc_properties = {
     delete => {
diff --git a/PVE/API2/Cluster/Hosts.pm b/PVE/API2/Cluster/Hosts.pm
new file mode 100644
index 000000000..3b722e763
--- /dev/null
+++ b/PVE/API2/Cluster/Hosts.pm
@@ -0,0 +1,208 @@
+package PVE::API2::Cluster::Hosts;
+
+use strict;
+use warnings;
+
+use base qw(PVE::RESTHandler);
+
+use PVE::Hosts;
+
+use PVE::Tools;
+
+__PACKAGE__->register_method ({
+    name => 'get_entries',
+    path => '',
+    method => 'GET',
+    description => "List entries in cluster-wide hosts configuration.",
+    permissions => { user => 'all' },
+    parameters => {
+	additionalProperties => 0,
+	properties => {},
+    },
+    returns => {
+	type => "array",
+	items => {
+	    type => "object",
+	    properties => {
+		ip => { type => 'string', format => 'ip' },
+		hosts => {
+		    type => "array",
+		    items => { type => 'string' },
+		},
+		comment => { type => 'string' },
+	    },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $conf = PVE::Hosts::cluster_config();
+	return [ values $conf->%* ];
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'get_entry',
+    path => '{ip}',
+    method => 'GET',
+    description => "Get entry from cluster-wide hosts configuration.",
+    permissions => { user => 'all' },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    ip => { type => 'string', format => 'ip' },
+	},
+    },
+    returns => {
+	type => "object",
+	properties => {
+	    ip => { type => 'string', format => 'ip' },
+	    hosts => {
+	        type => 'array',
+	        items => { type => 'string' },
+	    },
+	    comment => { type => 'string' },
+	},
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $conf = PVE::Hosts::cluster_config();
+	my $ip = $param->{ip};
+
+	return $conf->{$ip};
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'create_entry',
+    path => '',
+    method => 'POST',
+    protected => 1,
+    description => "Create new cluster-wide hosts entry.",
+    permissions => { user => 'all' },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    ip => { type => 'string', format => 'ip' },
+	    hosts => { type => 'string', optional => 1 },
+	    comment => { type => 'string', optional => 1 },
+	},
+    },
+    returns => {
+	type => 'object',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $conf = PVE::Hosts::cluster_config();
+	my $ip = $param->{ip};
+	my $hostname = $param->{hosts};
+	my $comment = $param->{comment};
+
+	die "Entry for $ip exists already" if $conf->{$ip};
+
+	$conf->{$ip} = {
+	    ip => $ip,
+	    hosts => $hostname,
+	    comment => $comment,
+	};
+
+	PVE::Hosts::write_cluster_config($conf);
+	return undef;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'update',
+    path => '{ip}',
+    method => 'PUT',
+    protected => 1,
+    description => "Cluster-wide hosts configuration.",
+    permissions => { user => 'all' },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    ip => { type => 'string', format => 'ip' },
+	    hosts => { type => 'string' },
+	    comment => { type => 'string', optional => 1 },
+	},
+    },
+    returns => {
+	type => 'object',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $conf = PVE::Hosts::cluster_config();
+	my $ip = $param->{ip};
+	my $hosts = $param->{hosts};
+	my $comment = $param->{comment};
+
+	return if !$conf->{$ip};
+
+	$conf->{$ip}->{comment} = $comment if $comment;
+	$conf->{$ip}->{hosts} = $hosts if $hosts;
+
+	PVE::Hosts::write_cluster_config($conf);
+
+	return;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'delete',
+    path => '{ip}',
+    method => 'DELETE',
+    protected => 1,
+    description => "Cluster-wide hosts configuration.",
+    permissions => { user => 'all' },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    ip => { type => 'string', format => 'ip' },
+	},
+    },
+    returns => {
+	type => 'object',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $conf = PVE::Hosts::cluster_config();
+	my $ip = $param->{ip};
+
+	return if !$conf->{$ip};
+
+	delete $conf->{$ip};
+	PVE::Hosts::write_cluster_config($conf);
+
+	return undef;
+    }
+});
+
+__PACKAGE__->register_method ({
+    name => 'apply',
+    path => '',
+    method => 'PUT',
+    protected => 1,
+    description => "Apply cluster-wide hosts configuration.",
+    permissions => { user => 'all' },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	},
+    },
+    returns => {
+	type => 'object',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	PVE::Hosts::update_configs();
+
+	return undef;
+    }
+});
+
+1;
diff --git a/PVE/API2/Cluster/Makefile b/PVE/API2/Cluster/Makefile
index b109e5cb6..f27644f4d 100644
--- a/PVE/API2/Cluster/Makefile
+++ b/PVE/API2/Cluster/Makefile
@@ -10,7 +10,8 @@ PERLSOURCE= 			\
 	Mapping.pm		\
 	Notifications.pm		\
 	Jobs.pm			\
-	Ceph.pm
+	Ceph.pm			\
+	Hosts.pm
 
 all:
 
diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index 5a148d1d0..36b71c4a6 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -2261,6 +2261,45 @@ __PACKAGE__->register_method ({
 	return;
     }});
 
+__PACKAGE__->register_method ({
+    name => 'update_etc_hosts',
+    path => 'hosts',
+    method => 'PUT',
+    proxyto => 'node',
+    protected => 1,
+    permissions => {
+	check => ['perm', '/nodes/{node}', [ 'Sys.Modify' ]],
+    },
+    description => "Update /etc/hosts.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	},
+    },
+    returns => {
+	type => 'string',
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+	my $worker = sub {
+	    my $conf = PVE::Hosts::cluster_config();
+
+	    my $data = "";
+	    for my $entry (sort { $a->{ip} cmp $b->{ip} } values $conf->%*) {
+		$data .= "$entry->{ip} $entry->{hosts}\n";
+		$data =~ s/,/ /g;
+	    }
+
+	    PVE::Hosts::write_local_config($data);
+	};
+	return $rpcenv->fork_worker('reloadhosts', undef, $authuser, $worker);
+    }});
+
 # bash completion helper
 
 sub complete_templet_repo {
diff --git a/PVE/Hosts.pm b/PVE/Hosts.pm
new file mode 100644
index 000000000..f83bd189a
--- /dev/null
+++ b/PVE/Hosts.pm
@@ -0,0 +1,152 @@
+package PVE::Hosts;
+
+use strict;
+use warnings;
+
+use PVE::Cluster qw(cfs_read_file cfs_write_file cfs_lock_file);
+use PVE::Tools;
+
+my $hosts_cluster = "/etc/pve/hosts";
+my $hosts_local = "/etc/hosts";
+
+# Cluster-wide configuration
+
+sub parse_cluster_hosts {
+    my ($filename, $raw) = @_;
+
+    $raw = '' if !defined($raw);
+
+    my $conf = {};
+
+    my $pos = 0;
+    for my $line (split(/\n/, $raw)) {
+	my ($content, $comment) = split(/ # /, $line);
+	my ($ip, $hosts) = split(/\s/, $content);
+
+	my $entry = {
+	    ip => $ip,
+	    hosts => $hosts,
+	    comment => $comment,
+	};
+
+	$conf->{$ip} = $entry;
+    }
+
+    return $conf;
+}
+
+sub write_cluster_hosts {
+    my ($filename, $cfg) = @_;
+    my $raw = '';
+
+    for my $entry (sort { $a->{ip} cmp $b->{ip} } values $cfg->%*) {
+	my $line = '';
+
+	$line .= "$entry->{ip} $entry->{hosts}";
+	$line .= " # $entry->{comment}" if $entry->{comment};
+	$line .= "\n";
+
+	$raw .= $line;
+    }
+
+    return $raw;
+}
+
+PVE::Cluster::cfs_register_file('hosts',
+				\&parse_cluster_hosts,
+				\&write_cluster_hosts);
+
+sub cluster_config {
+    return PVE::Cluster::cfs_read_file('hosts');
+}
+
+sub write_cluster_config {
+    my ($conf) = @_;
+
+    PVE::Cluster::cfs_lock_file('hosts', undef, sub {
+	PVE::Cluster::cfs_write_file('hosts', $conf);
+    });
+
+    die $@ if $@;
+}
+
+sub write_local_config {
+    my ($data) = @_;
+
+    my $head = "# --- BEGIN PVE ---\n";
+    my $tail = "# --- END PVE ---";
+    $data .= "\n" if $data && $data !~ /\n$/;
+
+    my $old = PVE::Tools::file_get_contents($hosts_local);
+    my @lines = split(/\n/, $old);
+
+    my ($beg, $end);
+    foreach my $i (0..(@lines-1)) {
+        my $line = $lines[$i];
+        $beg = $i if !defined($beg) &&
+            $line =~ /^#\s*---\s*BEGIN\s*PVE\s*/;
+        $end = $i if !defined($end) && defined($beg) &&
+            $line =~ /^#\s*---\s*END\s*PVE\s*/i;
+        last if defined($beg) && defined($end);
+    }
+
+    if (defined($beg) && defined($end)) {
+        # Found a section
+        if ($data) {
+            splice @lines, $beg, $end-$beg+1, $head.$data.$tail;
+        } else {
+            if ($beg == 0 && $end == (@lines-1)) {
+                return;
+            }
+            splice @lines, $beg, $end-$beg+1;
+        }
+        PVE::Tools::file_set_contents($hosts_local, join("\n", @lines) . "\n"); 
+    } elsif ($data) {
+        # No section found
+        my $content = join("\n", @lines);
+        chomp $content;
+        $content .= "\n";
+        $data = $head.$data.$tail;
+	#PVE::Tools::file_set_contents($hosts_local, $content.$data, $perms);
+	PVE::Tools::file_set_contents($hosts_local, $content.$data);
+    }
+
+}
+
+my $create_reload_hosts_worker = sub {
+    my ($nodename) = @_;
+
+    my $upid;
+    PVE::Tools::run_command(['pvesh', 'set', "/nodes/$nodename/hosts"], outfunc => sub {
+	my $line = shift;
+	if ($line =~ /^["']?(UPID:[^\s"']+)["']?$/) {
+	    $upid = $1;
+	}
+    });
+    my $res = PVE::Tools::upid_decode($upid);
+
+    return $res->{pid};
+};
+
+sub update_configs {
+    my () = @_;
+
+    my $rpcenv = PVE::RPCEnvironment::get();
+    my $authuser = $rpcenv->get_user();
+
+    my $code = sub {
+	$rpcenv->{type} = 'priv'; # to start tasks in background
+	PVE::Cluster::check_cfs_quorum();
+	my $nodelist = PVE::Cluster::get_nodelist();
+	for my $node (@$nodelist) {
+	    my $pid = eval { $create_reload_hosts_worker->($node) };
+	    warn $@ if $@;
+	}
+
+	return;
+    };
+
+    return $rpcenv->fork_worker('reloadhostsall', undef, $authuser, $code);
+}
+
+1;
diff --git a/PVE/Makefile b/PVE/Makefile
index 660de4d02..23b6dfe27 100644
--- a/PVE/Makefile
+++ b/PVE/Makefile
@@ -10,6 +10,7 @@ PERLSOURCE = 			\
 	CertCache.pm		\
 	CertHelpers.pm		\
 	ExtMetric.pm		\
+	Hosts.pm		\
 	HTTPServer.pm		\
 	Jobs.pm			\
 	NodeConfig.pm		\
-- 
2.39.2





  parent reply	other threads:[~2023-09-14 10:03 UTC|newest]

Thread overview: 5+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2023-09-14 10:03 [pve-devel] [PATCH WIP widget-toolkit/cluster/manager] Cluster-wide " Leo Nunner
2023-09-14 10:03 ` [pve-devel] [PATCH WIP widget-toolkit] introduce abstractions for /etc/hosts view Leo Nunner
2023-09-14 10:03 ` [pve-devel] [PATCH WIP cluster] hosts: add /etc/pve/hosts to watched files Leo Nunner
2023-09-14 10:03 ` Leo Nunner [this message]
2023-09-14 10:03 ` [pve-devel] [PATCH WIP manager 2/2] gui: configure cluster-wide hosts through gui Leo Nunner

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20230914100341.122329-4-l.nunner@proxmox.com \
    --to=l.nunner@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal