public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH WIP widget-toolkit/cluster/manager] Cluster-wide hosts config
@ 2023-09-14 10:03 Leo Nunner
  2023-09-14 10:03 ` [pve-devel] [PATCH WIP widget-toolkit] introduce abstractions for /etc/hosts view Leo Nunner
                   ` (3 more replies)
  0 siblings, 4 replies; 5+ messages in thread
From: Leo Nunner @ 2023-09-14 10:03 UTC (permalink / raw)
  To: pve-devel

This series introduces the ability to sync entries to the /etc/hosts
file on all nodes in a cluster. The cluster-wide configuration is stored
in /etc/pve/hosts. Synchronization is triggered manually. Information on
the corresponding endpoints and functionality can be found in the
corresponding commits.

This series is still WIP, and probably needs some changes here and
there. Especially the syncing mechanism is still a bit rough, and I
didn't get around to testing it thoroughly. Most of it is derived from
the SDN synchronization code, and might need some more adaptions. We
might also want to change the paths of the API endpoints, since
/cluster/hosts could be a bit confusing.

proxmox-widget-toolkit:

Leo Nunner (1):
  introduce abstractions for /etc/hosts view

 src/Makefile                |   1 +
 src/node/HostsView.js       | 329 ++++++++++++++++++++++++++++--------
 src/panel/HostsEditPanel.js | 137 +++++++++++++++
 3 files changed, 401 insertions(+), 66 deletions(-)
 create mode 100644 src/panel/HostsEditPanel.js

pve-cluster:

Leo Nunner (1):
  hosts: add /etc/pve/hosts to watched files

 src/PVE/Cluster.pm  | 1 +
 src/pmxcfs/status.c | 1 +
 2 files changed, 2 insertions(+)

pve-manager:

Leo Nunner (2):
  api: endpoints for cluster-wide hosts config
  gui: configure cluster-wide hosts through gui

 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 +
 www/manager6/Makefile     |   1 +
 www/manager6/dc/Config.js |   6 +
 www/manager6/dc/Hosts.js  | 257 ++++++++++++++++++++++++++++++++++++++
 9 files changed, 672 insertions(+), 1 deletion(-)
 create mode 100644 PVE/API2/Cluster/Hosts.pm
 create mode 100644 PVE/Hosts.pm
 create mode 100644 www/manager6/dc/Hosts.js

-- 
2.39.2





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

* [pve-devel] [PATCH WIP widget-toolkit] introduce abstractions for /etc/hosts view
  2023-09-14 10:03 [pve-devel] [PATCH WIP widget-toolkit/cluster/manager] Cluster-wide hosts config Leo Nunner
@ 2023-09-14 10:03 ` Leo Nunner
  2023-09-14 10:03 ` [pve-devel] [PATCH WIP cluster] hosts: add /etc/pve/hosts to watched files Leo Nunner
                   ` (2 subsequent siblings)
  3 siblings, 0 replies; 5+ messages in thread
From: Leo Nunner @ 2023-09-14 10:03 UTC (permalink / raw)
  To: pve-devel

Remove the textarea and instead introduce a gridview. It shows all the
values that are being returned by the API endpoint:
    - Line
	The actual line number inside the file.
    - Enabled
	Whether the line is commented out or not.
    - IP
	The IP address which is being mapped.
    - Hosts
	The list of hostnames for the IP.
    - Value
	The raw line value as it is stored in /etc/hosts.

Entries can be added/edited/removed, and their 'Enabeld' status can be
toggled via the 'Toggle' button.

Signed-off-by: Leo Nunner <l.nunner@proxmox.com>
---
 src/Makefile                |   1 +
 src/node/HostsView.js       | 329 ++++++++++++++++++++++++++++--------
 src/panel/HostsEditPanel.js | 137 +++++++++++++++
 3 files changed, 401 insertions(+), 66 deletions(-)
 create mode 100644 src/panel/HostsEditPanel.js

diff --git a/src/Makefile b/src/Makefile
index 21fbe76..637bde4 100644
--- a/src/Makefile
+++ b/src/Makefile
@@ -77,6 +77,7 @@ JSSRC=					\
 	panel/StatusView.js		\
 	panel/TfaView.js		\
 	panel/NotesView.js		\
+	panel/HostsEditPanel.js		\
 	window/Edit.js			\
 	window/PasswordEdit.js		\
 	window/SafeDestroy.js		\
diff --git a/src/node/HostsView.js b/src/node/HostsView.js
index 9adb6b2..8415d4a 100644
--- a/src/node/HostsView.js
+++ b/src/node/HostsView.js
@@ -1,66 +1,102 @@
+Ext.define('pve-etc-hosts-entry', {
+    extend: 'Ext.data.Model',
+    fields: [
+	{ name: 'enabled', type: 'boolean' },
+	{ name: 'ip', type: 'string' },
+	{ name: 'hosts', type: 'string' },
+	{ name: 'value', type: 'string' },
+	{ name: 'line', type: 'int' },
+    ],
+});
+
+Ext.define('Proxmox.node.HostsEditWindow', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 600,
+
+    line: undefined,
+
+    initComponent: function() {
+	var me = this;
+
+	Ext.apply(me, {
+	    subject: "Hosts entry",
+	    defaultFocus: 'textfield[name=ip]',
+	});
+
+	Ext.apply(me, {
+	    items: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'enabled',
+		    dataIndex: 'enabled',
+		    fieldLabel: 'Enable',
+		    uncheckedValue: 0,
+		},
+		{
+		    xtype: me.isCreate ? 'numberfield' : 'hiddenfield',
+		    fieldLabel: 'Line',
+		    name: 'line',
+		    dataIndex: 'line',
+		    allowBlank: false,
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('IP'),
+		    name: 'ip',
+		    dataIndex: 'ip',
+		    allowBlank: false,
+		    vtype: 'IP64Address',
+		},
+		{
+		    xtype: 'fieldcontainer',
+		    fieldLabel: gettext('Hostnames'),
+		    items: [
+			{
+			    xtype: 'proxmoxHostsEditPanel', name: 'hosts',
+			},
+		    ],
+		},
+	    ],
+	});
+
+	let base_url = `/api2/extjs/nodes/${me.nodename}/hosts`;
+	if (me.isCreate) {
+            me.url = base_url;
+            me.method = 'POST';
+        } else {
+            me.url = base_url + `/${me.line}`;
+            me.method = 'PUT';
+        }
+
+	me.callParent();
+
+	let hostsPanel = me.down("proxmoxHostsEditPanel");
+	let hostsController = hostsPanel.getController();
+
+	me.setValues({ line: me.line });
+
+	if (!me.isCreate) { // do we already have data?
+	    me.load();
+	} else { // if not, we create a single empty host entry
+	    hostsController.addHost();
+	}
+    },
+});
+
 Ext.define('Proxmox.node.HostsView', {
-    extend: 'Ext.panel.Panel',
+    extend: 'Ext.grid.Panel',
     xtype: 'proxmoxNodeHostsView',
 
     reload: function() {
 	let me = this;
-	me.store.load();
+	let view = me.getView();
+	view.store.load();
     },
 
-    tbar: [
-	{
-	    text: gettext('Save'),
-	    disabled: true,
-	    itemId: 'savebtn',
-	    handler: function() {
-		let view = this.up('panel');
-		Proxmox.Utils.API2Request({
-		    params: {
-			digest: view.digest,
-			data: view.down('#hostsfield').getValue(),
-		    },
-		    method: 'POST',
-		    url: '/nodes/' + view.nodename + '/hosts',
-		    waitMsgTarget: view,
-		    success: function(response, opts) {
-			view.reload();
-		    },
-		    failure: function(response, opts) {
-			Ext.Msg.alert('Error', response.htmlStatus);
-		    },
-		});
-	    },
-	},
-	{
-	    text: gettext('Revert'),
-	    disabled: true,
-	    itemId: 'resetbtn',
-	    handler: function() {
-		let view = this.up('panel');
-		view.down('#hostsfield').reset();
-	    },
-	},
-    ],
+    layout: 'fit',
 
-	    layout: 'fit',
-
-    items: [
-	{
-	    xtype: 'textarea',
-	    itemId: 'hostsfield',
-	    fieldStyle: {
-		'font-family': 'monospace',
-		'white-space': 'pre',
-	    },
-	    listeners: {
-		dirtychange: function(ta, dirty) {
-		    let view = this.up('panel');
-		    view.down('#savebtn').setDisabled(!dirty);
-		    view.down('#resetbtn').setDisabled(!dirty);
-		},
-	    },
-	},
-    ],
+    nodename: undefined,
 
     initComponent: function() {
 	let me = this;
@@ -70,26 +106,187 @@ Ext.define('Proxmox.node.HostsView', {
 	}
 
 	me.store = Ext.create('Ext.data.Store', {
+	    model: 'pve-etc-hosts-entry',
 	    proxy: {
-		type: 'proxmox',
-		url: "/api2/json/nodes/" + me.nodename + "/hosts",
+                type: 'proxmox',
+                url: `/api2/json/nodes/${me.nodename}/hosts`,
 	    },
+	    sorters: [
+		{
+		    property: 'line',
+		    direction: 'ASC',
+		},
+	    ],
 	});
 
-	me.callParent();
+	var sm = Ext.create('Ext.selection.RowModel', {});
 
-	Proxmox.Utils.monStoreErrors(me, me.store);
-
-	me.mon(me.store, 'load', function(store, records, success) {
-	    if (!success || records.length < 1) {
+	var run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec || !(rec.data.ip || rec.data.hosts)) {
 		return;
 	    }
-	    me.digest = records[0].data.digest;
-	    let data = records[0].data.data;
-	    me.down('#hostsfield').setValue(data);
-	    me.down('#hostsfield').resetOriginalValue();
+
+	    let win = Ext.create('Proxmox.node.HostsEditWindow', {
+		isCreate: false,
+		nodename: me.nodename,
+		line: rec.data.line,
+	    });
+	    win.on('destroy', me.reload, me);
+	    win.show();
+	};
+
+	Ext.apply(me, {
+	    tbar: [
+		{
+		    text: gettext('Add'),
+		    itemId: 'addbtn',
+		    handler: function() {
+			let items = me.store.getData().items;
+			let maxLine = items.reduce((a, v) => Math.max(a, v.data.line), -Infinity);
+			let win = Ext.create('Proxmox.node.HostsEditWindow', {
+			    isCreate: true,
+			    nodename: me.nodename,
+			    line: maxLine + 1,
+			});
+			win.on('destroy', me.reload, me);
+			win.show();
+		    },
+		},
+		{
+		    text: gettext('Edit'),
+		    itemId: 'editbtn',
+		    handler: run_editor,
+		},
+		{
+		    text: gettext('Delete'),
+		    itemId: 'deletebtn',
+		    handler: function() {
+			let rec = sm.getSelection()[0];
+
+			Proxmox.Utils.API2Request({
+			    method: 'DELETE',
+			    url: `/nodes/${me.nodename}/hosts/${rec.data.line}?move=1`,
+			    waitMsgTarget: me,
+			    success: function(response, opts) {
+				me.reload();
+			    },
+			    failure: function(response, opts) {
+				Ext.Msg.alert('Error', response.htmlStatus);
+			    },
+			});
+		    },
+		},
+		{
+		    text: gettext('Toggle'),
+		    itemId: 'togglebtn',
+		    handler: function() {
+			let rec = sm.getSelection()[0];
+			let params = rec.data;
+			params.enabled = params.enabled ? 0 : 1;
+
+			Proxmox.Utils.API2Request({
+			    method: 'PUT',
+			    url: `/nodes/${me.nodename}/hosts/${rec.data.line}`,
+			    params: params,
+			    waitMsgTarget: me,
+			    success: function(response, opts) {
+				me.reload();
+			    },
+			    failure: function(response, opts) {
+				Ext.Msg.alert('Error', response.htmlStatus);
+			    },
+			});
+		    },
+		},
+		'->',
+		gettext('Search') + ':',
+		' ',
+		{
+		    xtype: 'textfield',
+		    width: 200,
+		    enableKeyEvents: true,
+		    emptyText: gettext("IP, FQDN"),
+		    listeners: {
+			keyup: {
+			    buffer: 500,
+			    fn: function(field) {
+				let needle = field.getValue().toLocaleLowerCase();
+				me.store.clearFilter(true);
+				me.store.filter([
+				    {
+					filterFn: ({ data }) =>
+					data.ip?.toLocaleLowerCase().includes(needle) ||
+					data.hosts?.toLocaleLowerCase().includes(needle),
+				    },
+				]);
+			    },
+			},
+			change: function(field, newValue, oldValue) {
+			    if (newValue !== this.originalValue) {
+				this.triggers.clear.setVisible(true);
+			    }
+			},
+		    },
+		    triggers: {
+			clear: {
+			    cls: 'pmx-clear-trigger',
+			    weight: -1,
+			    hidden: true,
+			    handler: function() {
+				this.triggers.clear.setVisible(false);
+				this.setValue(this.originalValue);
+				me.store.clearFilter();
+			    },
+			},
+		    },
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    bodystyle: {
+		width: '100% !important',
+	    },
+	    columns: [
+		{
+		    text: gettext('Line'),
+		    dataIndex: 'line',
+		    width: 70,
+		},
+		{
+		    header: gettext('Enabled'),
+		    dataIndex: 'enabled',
+		    align: 'center',
+		    renderer: Proxmox.Utils.renderEnabledIcon,
+		    width: 90,
+		},
+		{
+		    text: gettext('IP'),
+		    dataIndex: 'ip',
+		    width: 150,
+		},
+		{
+		    text: gettext('Hosts'),
+		    dataIndex: 'hosts',
+		    flex: 1,
+		},
+		{
+		    text: gettext('Value'),
+		    dataIndex: 'value',
+		    flex: 1,
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    selModel: sm,
+	    listeners: {
+		itemdblclick: run_editor,
+	    },
 	});
 
+	me.callParent();
 	me.reload();
     },
 });
diff --git a/src/panel/HostsEditPanel.js b/src/panel/HostsEditPanel.js
new file mode 100644
index 0000000..34e99e1
--- /dev/null
+++ b/src/panel/HostsEditPanel.js
@@ -0,0 +1,137 @@
+Ext.define('Proxmox.node.HostsEntryEditor', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'proxmoxHostsEntryEditor',
+
+    layout: 'hbox',
+    border: false,
+
+    margin: '5 5 5 5',
+
+    initComponent: function() {
+	let me = this;
+
+	Ext.apply(me, {
+	    items: [
+		{
+		    xtype: 'proxmoxtextfield',
+		    width: 200,
+		    margin: '0 5 0 0 ',
+		    value: me.value,
+		    allowBlank: false,
+		    isFormField: false,
+		},
+		{
+		    xtype: 'button',
+		    iconCls: 'fa fa-trash-o',
+		    cls: 'removeLinkBtn',
+		    handler: function() {
+			let parent = this.up('proxmoxHostsEntryEditor');
+			if (parent.removeBtnHandler !== undefined) {
+			    parent.removeBtnHandler();
+			}
+		    },
+		},
+	    ],
+	});
+
+	me.callParent();
+    },
+});
+
+Ext.define('Proxmox.node.HostsEdit', {
+    extend: 'Ext.panel.Panel',
+    xtype: 'proxmoxHostsEditPanel',
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	loadData: function(data) {
+	    let me = this;
+	    let hosts = data.split(",");
+
+	    let view = me.getView();
+	    view.query("proxmoxHostsEntryEditor").forEach((e) => view.remove(e.id));
+
+	    hosts.forEach((host) => {
+		me.addHost(host);
+	    });
+	},
+
+	addHost: function(value) {
+	    let me = this;
+	    let view = me.getView();
+	    let hostEdit = Ext.create('Proxmox.node.HostsEntryEditor', {
+		value: value,
+		removeBtnHandler: function() {
+		    view.remove(this);
+		    view.getController().setMarkerValue();
+		},
+	    });
+	    view.add(hostEdit);
+	},
+
+	addBtnHandler: function() {
+	    let me = this;
+	    me.addHost("");
+	    me.setMarkerValue();
+	},
+
+	setMarkerValue() {
+	    let me = this;
+	    let view = me.getView();
+	    view.inUpdate = true;
+	    me.lookup('marker').setValue(me.calculateValue());
+	    view.inUpdate = false;
+	},
+
+	calculateValue: function() {
+	    let me = this;
+	    let view = me.getView();
+
+	    let hosts = [];
+	    Ext.Array.each(view.query('proxmoxtextfield'), function(field) {
+		hosts.push(field.value);
+	    });
+	    return hosts.join(",");
+	},
+
+	control: {
+	    "proxmoxtextfield": {
+		change: "setMarkerValue",
+	    },
+	},
+    },
+
+    items: [
+	{
+	    xtype: 'hiddenfield',
+	    reference: 'marker',
+	    name: 'hosts',
+	    setValue: function(value) {
+		let me = this;
+		let panel = me.up('proxmoxHostsEditPanel');
+
+		if (!panel.inUpdate) {
+		    panel.getController().loadData(value);
+		}
+
+		return Ext.form.field.Hidden.prototype.setValue.call(this, value);
+	    },
+	},
+    ],
+
+    dockedItems: [{
+	xtype: 'toolbar',
+	dock: 'bottom',
+	defaultButtonUI: 'default',
+	border: false,
+	padding: '6 0 6 0',
+	items: [
+	    {
+		xtype: 'button',
+		text: gettext('Add'),
+		handler: 'addBtnHandler',
+	    },
+	],
+    }],
+});
-- 
2.39.2





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

* [pve-devel] [PATCH WIP cluster] hosts: add /etc/pve/hosts to watched files
  2023-09-14 10:03 [pve-devel] [PATCH WIP widget-toolkit/cluster/manager] Cluster-wide hosts config 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 ` Leo Nunner
  2023-09-14 10:03 ` [pve-devel] [PATCH WIP manager 1/2] api: endpoints for cluster-wide hosts config Leo Nunner
  2023-09-14 10:03 ` [pve-devel] [PATCH WIP manager 2/2] gui: configure cluster-wide hosts through gui Leo Nunner
  3 siblings, 0 replies; 5+ messages in thread
From: Leo Nunner @ 2023-09-14 10:03 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Leo Nunner <l.nunner@proxmox.com>
---
 src/PVE/Cluster.pm  | 1 +
 src/pmxcfs/status.c | 1 +
 2 files changed, 2 insertions(+)

diff --git a/src/PVE/Cluster.pm b/src/PVE/Cluster.pm
index cfa2583..919215d 100644
--- a/src/PVE/Cluster.pm
+++ b/src/PVE/Cluster.pm
@@ -57,6 +57,7 @@ my $observed = {
     'domains.cfg' => 1,
     'notifications.cfg' => 1,
     'priv/notifications.cfg' => 1,
+    'hosts' => 1,
     'priv/shadow.cfg' => 1,
     'priv/tfa.cfg' => 1,
     'priv/token.cfg' => 1,
diff --git a/src/pmxcfs/status.c b/src/pmxcfs/status.c
index c8094ac..9159872 100644
--- a/src/pmxcfs/status.c
+++ b/src/pmxcfs/status.c
@@ -84,6 +84,7 @@ static memdb_change_t memdb_change_array[] = {
 	{ .path = "domains.cfg" },
 	{ .path = "notifications.cfg"},
 	{ .path = "priv/notifications.cfg"},
+	{ .path = "hosts" },
 	{ .path = "priv/shadow.cfg" },
 	{ .path = "priv/acme/plugins.cfg" },
 	{ .path = "priv/tfa.cfg" },
-- 
2.39.2





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

* [pve-devel] [PATCH WIP manager 1/2] api: endpoints for cluster-wide hosts config
  2023-09-14 10:03 [pve-devel] [PATCH WIP widget-toolkit/cluster/manager] Cluster-wide hosts config 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
  2023-09-14 10:03 ` [pve-devel] [PATCH WIP manager 2/2] gui: configure cluster-wide hosts through gui Leo Nunner
  3 siblings, 0 replies; 5+ messages in thread
From: Leo Nunner @ 2023-09-14 10:03 UTC (permalink / raw)
  To: pve-devel

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





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

* [pve-devel] [PATCH WIP manager 2/2] gui: configure cluster-wide hosts through gui
  2023-09-14 10:03 [pve-devel] [PATCH WIP widget-toolkit/cluster/manager] Cluster-wide hosts config Leo Nunner
                   ` (2 preceding siblings ...)
  2023-09-14 10:03 ` [pve-devel] [PATCH WIP manager 1/2] api: endpoints for cluster-wide hosts config Leo Nunner
@ 2023-09-14 10:03 ` Leo Nunner
  3 siblings, 0 replies; 5+ messages in thread
From: Leo Nunner @ 2023-09-14 10:03 UTC (permalink / raw)
  To: pve-devel

GUI components for the cluster-wide hosts configuration. It is located
under Datacenter > Hosts, and allows editing, adding and deleting
entries. They are searchable, and the edit window allows adding a
variable number of hostnames to each IP address. There is a separate
"apply" button, which starts a task to sync the hosts config to each
node in the cluster.

Signed-off-by: Leo Nunner <l.nunner@proxmox.com>
---
 www/manager6/Makefile     |   1 +
 www/manager6/dc/Config.js |   6 +
 www/manager6/dc/Hosts.js  | 257 ++++++++++++++++++++++++++++++++++++++
 3 files changed, 264 insertions(+)
 create mode 100644 www/manager6/dc/Hosts.js

diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 59a5d8a7f..f34d5b2f2 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -157,6 +157,7 @@ JSSRC= 							\
 	dc/GroupView.js					\
 	dc/Guests.js					\
 	dc/Health.js					\
+	dc/Hosts.js					\
 	dc/Log.js					\
 	dc/NodeView.js					\
 	dc/NotificationEvents.js			\
diff --git a/www/manager6/dc/Config.js b/www/manager6/dc/Config.js
index 9ba7b301f..332662516 100644
--- a/www/manager6/dc/Config.js
+++ b/www/manager6/dc/Config.js
@@ -178,6 +178,12 @@ Ext.define('PVE.dc.Config', {
 		iconCls: 'fa fa-bolt',
 		xtype: 'pveFencingView',
 		itemId: 'ha-fencing',
+	    },
+	    {
+		xtype: 'pveClusterHosts',
+		title: gettext('Hosts'),
+		iconCls: 'fa fa-globe',
+		itemId: 'hosts',
 	    });
 	    // always show on initial load, will be hiddea later if the SDN API calls don't exist,
 	    // else it won't be shown at first if the user initially loads with DC selected
diff --git a/www/manager6/dc/Hosts.js b/www/manager6/dc/Hosts.js
new file mode 100644
index 000000000..57bf29054
--- /dev/null
+++ b/www/manager6/dc/Hosts.js
@@ -0,0 +1,257 @@
+Ext.define('pve-etc-hosts-entry', {
+    extend: 'Ext.data.Model',
+    fields: [
+	{ name: 'ip', type: 'string' },
+	{ name: 'hosts', type: 'string' },
+	{ name: 'comment', type: 'string' },
+    ],
+});
+
+Ext.define('PVE.dc.HostsEditWindow', {
+    extend: 'Proxmox.window.Edit',
+
+    width: 600,
+
+    ip: undefined,
+
+    initComponent: function() {
+	var me = this;
+
+	Ext.apply(me, {
+	    subject: "Hosts entry",
+	    defaultFocus: 'textfield[name=ip]',
+	});
+
+	Ext.apply(me, {
+	    items: [
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('IP'),
+		    name: 'ip',
+		    dataIndex: 'ip',
+		    allowBlank: false,
+		    vtype: 'IP64Address',
+		},
+		{
+		    xtype: 'textfield',
+		    fieldLabel: gettext('Comment'),
+		    name: 'comment',
+		    dataIndex: 'comment',
+		    allowBlank: true,
+		},
+		{
+		    xtype: 'fieldcontainer',
+		    fieldLabel: gettext('Hostnames'),
+		    items: [
+			{
+			    xtype: 'proxmoxHostsEditPanel', name: 'hosts',
+			},
+		    ],
+		},
+	    ],
+	});
+
+	let base_url = `/api2/extjs/cluster/hosts`;
+	if (me.isCreate) {
+            me.url = base_url;
+            me.method = 'POST';
+        } else {
+            me.url = base_url + `/${me.ip}`;
+            me.method = 'PUT';
+        }
+
+	me.callParent();
+
+	let hostsPanel = me.down("proxmoxHostsEditPanel");
+	let hostsController = hostsPanel.getController();
+
+	me.setValues({ ip: me.ip });
+
+	if (!me.isCreate) { // do we already have data?
+	    me.load();
+	} else { // if not, we create a single empty host entry
+	    hostsController.addHost();
+	}
+    },
+});
+
+Ext.define('PVE.dc.ClusterHosts', {
+    extend: 'Ext.grid.Panel',
+    xtype: 'pveClusterHosts',
+
+    reload: function() {
+	let me = this;
+	let view = me.getView();
+	view.store.load();
+    },
+
+    layout: 'fit',
+
+    initComponent: function() {
+	let me = this;
+
+	me.store = Ext.create('Ext.data.Store', {
+	    model: 'pve-etc-hosts-entry',
+	    proxy: {
+                type: 'proxmox',
+                url: `/api2/json/cluster/hosts`,
+	    },
+	    sorters: [
+		{
+		    property: 'ip',
+		    direction: 'ASC',
+		},
+	    ],
+	});
+
+	var sm = Ext.create('Ext.selection.RowModel', {});
+
+	var run_editor = function() {
+	    var rec = sm.getSelection()[0];
+	    if (!rec || !(rec.data.ip || rec.data.hosts)) {
+		return;
+	    }
+
+	    let win = Ext.create('PVE.dc.HostsEditWindow', {
+		isCreate: false,
+		ip: rec.data.ip,
+	    });
+	    win.on('destroy', me.reload, me);
+	    win.show();
+	};
+
+	Ext.apply(me, {
+	    tbar: [
+		{
+		    text: gettext('Apply'),
+		    itemId: 'applybtn',
+		    handler: function() {
+			Proxmox.Utils.API2Request({
+			    method: 'PUT',
+			    url: `/cluster/hosts`,
+			    waitMsgTarget: me,
+			    success: function(response, opts) {
+				me.reload();
+			    },
+			    failure: function(response, opts) {
+				Ext.Msg.alert('Error', response.htmlStatus);
+			    },
+			});
+		    },
+		},
+		'|',
+		{
+		    text: gettext('Add'),
+		    itemId: 'addbtn',
+		    handler: function() {
+			let win = Ext.create('PVE.dc.HostsEditWindow', {
+			    isCreate: true,
+			    ip: undefined,
+			});
+			win.on('destroy', me.reload, me);
+			win.show();
+		    },
+		},
+		{
+		    text: gettext('Edit'),
+		    itemId: 'editbtn',
+		    handler: run_editor,
+		},
+		{
+		    text: gettext('Delete'),
+		    itemId: 'deletebtn',
+		    handler: function() {
+			let rec = sm.getSelection()[0];
+
+			Proxmox.Utils.API2Request({
+			    method: 'DELETE',
+			    url: `/cluster/hosts/${rec.data.ip}`,
+			    waitMsgTarget: me,
+			    success: function(response, opts) {
+				me.reload();
+			    },
+			    failure: function(response, opts) {
+				Ext.Msg.alert('Error', response.htmlStatus);
+			    },
+			});
+		    },
+		},
+		'->',
+		gettext('Search') + ':',
+		' ',
+		{
+		    xtype: 'textfield',
+		    width: 200,
+		    enableKeyEvents: true,
+		    emptyText: gettext("IP, FQDN"),
+		    listeners: {
+			keyup: {
+			    buffer: 500,
+			    fn: function(field) {
+				let needle = field.getValue().toLocaleLowerCase();
+				me.store.clearFilter(true);
+				me.store.filter([
+				    {
+					filterFn: ({ data }) =>
+					data.ip?.toLocaleLowerCase().includes(needle) ||
+					data.hosts?.toLocaleLowerCase().includes(needle),
+				    },
+				]);
+			    },
+			},
+			change: function(field, newValue, oldValue) {
+			    if (newValue !== this.originalValue) {
+				this.triggers.clear.setVisible(true);
+			    }
+			},
+		    },
+		    triggers: {
+			clear: {
+			    cls: 'pmx-clear-trigger',
+			    weight: -1,
+			    hidden: true,
+			    handler: function() {
+				this.triggers.clear.setVisible(false);
+				this.setValue(this.originalValue);
+				me.store.clearFilter();
+			    },
+			},
+		    },
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    bodystyle: {
+		width: '100% !important',
+	    },
+	    columns: [
+		{
+		    text: gettext('IP'),
+		    dataIndex: 'ip',
+		    width: 150,
+		},
+		{
+		    text: gettext('Hosts'),
+		    dataIndex: 'hosts',
+		    flex: 1,
+		},
+		{
+		    text: gettext('Comment'),
+		    dataIndex: 'comment',
+		    flex: 1,
+		},
+	    ],
+	});
+
+	Ext.apply(me, {
+	    selModel: sm,
+	    listeners: {
+		itemdblclick: run_editor,
+	    },
+	});
+
+	me.callParent();
+	me.reload();
+    },
+});
-- 
2.39.2





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

end of thread, other threads:[~2023-09-14 10:04 UTC | newest]

Thread overview: 5+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2023-09-14 10:03 [pve-devel] [PATCH WIP widget-toolkit/cluster/manager] Cluster-wide hosts config 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 ` [pve-devel] [PATCH WIP manager 1/2] api: endpoints for cluster-wide hosts config Leo Nunner
2023-09-14 10:03 ` [pve-devel] [PATCH WIP manager 2/2] gui: configure cluster-wide hosts through gui Leo Nunner

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