From ab7ef9ac3a3bd1e87f7d94f1d1d267fef11b04e3 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 8 Jun 2026 21:03:41 +0200 Subject: [PATCH 01/13] Start patching --- .../patches/01-proxmoxlib-js-nagscreen.patch | 0 .../patches/01-nodes-pm-sensors.patch | 15 + .../patches/02-nodes-pm-GPU-RRD-history.patch | 0 .../patches/03-pvemanager-js-sensors.patch | 1013 +++++++++++++++++ 4 files changed, 1028 insertions(+) create mode 100644 src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch create mode 100644 src/PVENodeInfo/patches/01-nodes-pm-sensors.patch create mode 100644 src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch create mode 100644 src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch diff --git a/src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch b/src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch new file mode 100644 index 0000000..e69de29 diff --git a/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch b/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch new file mode 100644 index 0000000..272e6f8 --- /dev/null +++ b/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch @@ -0,0 +1,15 @@ +--- usr/share/perl5/PVE/API2/Nodes.pm 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/perl5/PVE/API2/Nodes.pm 2026-06-08 20:38:56.988900119 +0200 +@@ -536,6 +536,12 @@ + + $res->{pveversion} = PVE::pvecfg::package() . "/" . PVE::pvecfg::version_text(); + ++ # Collect sensor data from PveMod_SensorInfo ++ use PVE::API2::PVEMod_SensorInfo; ++ $res->{PveMod_JsonSensorInfo} = PVE::API2::PVEMod_SensorInfo::get_sensors_info(); ++ $res->{PveMod_Version} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version(); ++ $res->{PveMod_upsInfo} = PVE::API2::PVEMod_SensorInfo::get_ups_info(); ++ $res->{PveMod_systemInfo} = PVE::API2::PVEMod_SensorInfo::get_system_info(); + my $dinfo = df('/', 1); # output is bytes + + $res->{rootfs} = { diff --git a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch new file mode 100644 index 0000000..e69de29 diff --git a/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch b/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch new file mode 100644 index 0000000..0252331 --- /dev/null +++ b/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch @@ -0,0 +1,1013 @@ +--- usr/share/pve-manager/js/pvemanagerlib.js 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/pve-manager/js/pvemanagerlib.js 2026-06-07 15:44:28.581927600 +0200 +@@ -50737,175 +50737,181 @@ + me.reload(); + }, + }); +-Ext.define('PVE.node.StatusView', { +- extend: 'Proxmox.panel.StatusView', +- alias: 'widget.pveNodeStatus', +- +- height: 350, +- bodyPadding: '15 5 15 5', +- +- layout: { +- type: 'table', +- columns: 2, +- tableAttrs: { +- style: { +- width: '100%', +- }, +- }, +- }, +- +- defaults: { +- xtype: 'pmxInfoWidget', +- padding: '0 10 5 10', +- }, +- +- items: [ +- { +- itemId: 'cpu', +- iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', +- title: gettext('CPU usage'), +- valueField: 'cpu', +- maxField: 'cpuinfo', +- renderer: Proxmox.Utils.render_node_cpu_usage, +- }, +- { +- itemId: 'wait', +- iconCls: 'fa fa-fw fa-clock-o', +- title: gettext('IO delay'), +- valueField: 'wait', +- rowspan: 2, +- }, +- { +- itemId: 'load', +- iconCls: 'fa fa-fw fa-tasks', +- title: gettext('Load average'), +- printBar: false, +- textField: 'loadavg', +- }, +- { +- xtype: 'box', +- colspan: 2, +- padding: '0 0 20 0', +- }, +- { +- iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', +- itemId: 'memory', +- title: gettext('RAM usage'), +- valueField: 'memory', +- maxField: 'memory', +- warningThreshold: 0.9, +- criticalThreshold: 0.975, +- // TODO: split out ARC usage +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- itemId: 'ksm', +- printBar: false, +- title: gettext('KSM sharing'), +- textField: 'ksm', +- renderer: (record) => Proxmox.Utils.render_size(record.shared), +- padding: '0 10 10 10', +- }, +- { +- iconCls: 'fa fa-fw fa-hdd-o', +- itemId: 'rootfs', +- title: '/ ' + gettext('HD space'), +- valueField: 'rootfs', +- maxField: 'rootfs', +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- iconCls: 'fa fa-fw fa-refresh', +- itemId: 'swap', +- printSize: true, +- title: gettext('SWAP usage'), +- valueField: 'swap', +- maxField: 'swap', +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- xtype: 'box', +- colspan: 2, +- padding: '0 0 20 0', +- }, +- { +- itemId: 'cpus', +- colspan: 2, +- printBar: false, +- title: gettext('CPU(s)'), +- textField: 'cpuinfo', +- renderer: Proxmox.Utils.render_cpu_model, +- value: '', +- }, +- { +- colspan: 2, +- title: gettext('Kernel Version'), +- printBar: false, +- // 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: '', +- }, +- { +- 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, +- printBar: false, +- title: gettext('Manager Version'), +- textField: 'pveversion', +- value: '', +- }, +- ], +- +- updateTitle: function () { +- var me = this; +- var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); +- me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); +- }, +- +- initComponent: function () { +- let me = this; +- +- let stateProvider = Ext.state.Manager.getProvider(); +- let repoLink = stateProvider.encodeHToken({ +- view: 'server', +- rid: `node/${me.pveSelNode.data.node}`, +- ltab: 'tasks', +- nodetab: 'aptrepositories', +- }); +- +- me.items.push({ +- xtype: 'pmxNodeInfoRepoStatus', +- itemId: 'repositoryStatus', +- product: 'Proxmox VE', +- repoLink: `#${repoLink}`, +- }); +- +- me.callParent(); +- }, ++// Load custom PVE.node.StatusView from external module ++Ext.Loader.loadScript({ ++ url: '/pve2/js/PveMod_PveNodeStatusView.js', ++ onLoad: function() { }, ++ onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); } + }); ++// Ext.define('PVE.node.StatusView', { ++// extend: 'Proxmox.panel.StatusView', ++// alias: 'widget.pveNodeStatus', ++// ++// height: 350, ++// bodyPadding: '15 5 15 5', ++// ++// layout: { ++// type: 'table', ++// columns: 2, ++// tableAttrs: { ++// style: { ++// width: '100%', ++// }, ++// }, ++// }, ++// ++// defaults: { ++// xtype: 'pmxInfoWidget', ++// padding: '0 10 5 10', ++// }, ++// ++// items: [ ++// { ++// itemId: 'cpu', ++// iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', ++// title: gettext('CPU usage'), ++// valueField: 'cpu', ++// maxField: 'cpuinfo', ++// renderer: Proxmox.Utils.render_node_cpu_usage, ++// }, ++// { ++// itemId: 'wait', ++// iconCls: 'fa fa-fw fa-clock-o', ++// title: gettext('IO delay'), ++// valueField: 'wait', ++// rowspan: 2, ++// }, ++// { ++// itemId: 'load', ++// iconCls: 'fa fa-fw fa-tasks', ++// title: gettext('Load average'), ++// printBar: false, ++// textField: 'loadavg', ++// }, ++// { ++// xtype: 'box', ++// colspan: 2, ++// padding: '0 0 20 0', ++// }, ++// { ++// iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', ++// itemId: 'memory', ++// title: gettext('RAM usage'), ++// valueField: 'memory', ++// maxField: 'memory', ++// warningThreshold: 0.9, ++// criticalThreshold: 0.975, ++// // TODO: split out ARC usage ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// itemId: 'ksm', ++// printBar: false, ++// title: gettext('KSM sharing'), ++// textField: 'ksm', ++// renderer: (record) => Proxmox.Utils.render_size(record.shared), ++// padding: '0 10 10 10', ++// }, ++// { ++// iconCls: 'fa fa-fw fa-hdd-o', ++// itemId: 'rootfs', ++// title: '/ ' + gettext('HD space'), ++// valueField: 'rootfs', ++// maxField: 'rootfs', ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// iconCls: 'fa fa-fw fa-refresh', ++// itemId: 'swap', ++// printSize: true, ++// title: gettext('SWAP usage'), ++// valueField: 'swap', ++// maxField: 'swap', ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// xtype: 'box', ++// colspan: 2, ++// padding: '0 0 20 0', ++// }, ++// { ++// itemId: 'cpus', ++// colspan: 2, ++// printBar: false, ++// title: gettext('CPU(s)'), ++// textField: 'cpuinfo', ++// renderer: Proxmox.Utils.render_cpu_model, ++// value: '', ++// }, ++// { ++// colspan: 2, ++// title: gettext('Kernel Version'), ++// printBar: false, ++// // 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: '', ++// }, ++// { ++// 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, ++// printBar: false, ++// title: gettext('Manager Version'), ++// textField: 'pveversion', ++// value: '', ++// }, ++// ], ++// ++// updateTitle: function () { ++// var me = this; ++// var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); ++// me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); ++// }, ++// ++// initComponent: function () { ++// let me = this; ++// ++// let stateProvider = Ext.state.Manager.getProvider(); ++// let repoLink = stateProvider.encodeHToken({ ++// view: 'server', ++// rid: `node/${me.pveSelNode.data.node}`, ++// ltab: 'tasks', ++// nodetab: 'aptrepositories', ++// }); ++// ++// me.items.push({ ++// xtype: 'pmxNodeInfoRepoStatus', ++// itemId: 'repositoryStatus', ++// product: 'Proxmox VE', ++// repoLink: `#${repoLink}`, ++// }); ++// ++// me.callParent(); ++// }, ++// }); + Ext.define('PVE.node.SubscriptionKeyEdit', { + extend: 'Proxmox.window.Edit', + +@@ -51112,333 +51118,333 @@ + me.callParent(); + }, + }); +-Ext.define('PVE.node.Summary', { +- extend: 'Ext.panel.Panel', +- alias: 'widget.pveNodeSummary', +- +- scrollable: true, +- bodyPadding: 5, +- +- showVersions: function () { +- var me = this; +- +- // Note: we use simply text/html here, because ExtJS grid has problems +- // with cut&paste +- +- var nodename = me.pveSelNode.data.node; +- +- var view = Ext.createWidget('component', { +- autoScroll: true, +- id: 'pkgversions', +- padding: 5, +- style: { +- 'white-space': 'pre', +- 'font-family': 'monospace', +- }, +- }); +- +- var win = Ext.create('Ext.window.Window', { +- title: gettext('Package versions'), +- width: 600, +- height: 600, +- layout: 'fit', +- modal: true, +- items: [view], +- buttons: [ +- { +- xtype: 'button', +- iconCls: 'fa fa-clipboard', +- handler: function (button) { +- window +- .getSelection() +- .selectAllChildren(document.getElementById('pkgversions')); +- document.execCommand('copy'); +- }, +- text: gettext('Copy'), +- }, +- { +- text: gettext('Ok'), +- handler: function () { +- this.up('window').close(); +- }, +- }, +- ], +- }); +- +- Proxmox.Utils.API2Request({ +- waitMsgTarget: me, +- url: `/nodes/${nodename}/apt/versions`, +- method: 'GET', +- failure: function (response, opts) { +- win.close(); +- Ext.Msg.alert(gettext('Error'), response.htmlStatus); +- }, +- success: function (response, opts) { +- win.show(); +- let text = ''; +- Ext.Array.each(response.result.data, function (rec) { +- let version = 'not correctly installed'; +- let pkg = rec.Package; +- if (rec.OldVersion && rec.CurrentState === 'Installed') { +- version = rec.OldVersion; +- } +- if (rec.RunningKernel) { +- text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; +- } else if (rec.ManagerVersion) { +- text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; +- } else { +- text += `${pkg}: ${version}\n`; +- } +- }); +- +- view.update(Ext.htmlEncode(text)); +- }, +- }); +- }, +- +- updateRepositoryStatus: function () { +- let me = this; +- let repoStatus = me.nodeStatus.down('#repositoryStatus'); +- +- let nodename = me.pveSelNode.data.node; +- +- Proxmox.Utils.API2Request({ +- url: `/nodes/${nodename}/apt/repositories`, +- method: 'GET', +- failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), +- success: (response) => +- repoStatus.setRepositoryInfo(response.result.data['standard-repos']), +- }); +- +- Proxmox.Utils.API2Request({ +- url: `/nodes/${nodename}/subscription`, +- method: 'GET', +- failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), +- success: function (response, opts) { +- const res = response.result; +- const subscription = res?.data?.status.toLowerCase() === 'active'; +- repoStatus.setSubscriptionStatus(subscription); +- }, +- }); +- }, +- +- initComponent: function () { +- var me = this; +- +- var nodename = me.pveSelNode.data.node; +- if (!nodename) { +- throw 'no node name specified'; +- } +- +- if (!me.statusStore) { +- throw 'no status storage specified'; +- } +- +- var rstore = me.statusStore; +- +- var version_btn = new Ext.Button({ +- text: gettext('Package versions'), +- handler: function () { +- Proxmox.Utils.checked_command(function () { +- me.showVersions(); +- }); +- }, +- }); +- +- var rrdstore = Ext.create('Proxmox.data.RRDStore', { +- rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', +- model: 'pve-rrd-node', +- }); +- +- let nodeStatus = Ext.create('PVE.node.StatusView', { +- xtype: 'pveNodeStatus', +- rstore: rstore, +- width: 770, +- pveSelNode: me.pveSelNode, +- }); +- +- Ext.apply(me, { +- tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], +- nodeStatus: nodeStatus, +- items: [ +- { +- xtype: 'container', +- itemId: 'itemcontainer', +- layout: 'column', +- minWidth: 700, +- defaults: { +- minHeight: 360, +- padding: 5, +- columnWidth: 1, +- }, +- items: [ +- nodeStatus, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('CPU Usage'), +- fields: ['cpu', 'iowait'], +- fieldTitles: [gettext('CPU usage'), gettext('IO delay')], +- unit: 'percent', +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Server Load'), +- fields: ['loadavg'], +- fieldTitles: [gettext('Load average')], +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Memory usage'), +- fields: [ +- { +- yField: 'memtotal', +- title: gettext('Total'), +- tooltip: { +- trackMouse: true, +- renderer: function (toolTip, record, item) { +- let value = record.get('memtotal'); +- +- if (value === null) { +- toolTip.setHtml(gettext('No Data')); +- } else { +- let total = Proxmox.Utils.format_size(value); +- let time = new Date(record.get('time')); +- +- let avail = record.get('memavailable'); +- let availText = ''; +- if (Ext.isNumeric(avail)) { +- let v = Proxmox.Utils.format_size(avail); +- availText = ` (${gettext('Available')}: ${v})`; +- } +- +- toolTip.setHtml( +- `${gettext('Total')}: ${total}${availText}
${time}`, +- ); +- } +- }, +- }, +- }, +- { +- yField: 'memused', +- title: gettext('Used'), +- tooltip: { +- trackMouse: true, +- renderer: function (toolTip, record, item) { +- let value = record.get('memused'); +- +- if (value === null) { +- toolTip.setHtml(gettext('No Data')); +- } else { +- let total = Proxmox.Utils.format_size(value); +- let time = new Date(record.get('time')); +- +- let arc = record.get('arcsize'); +- let arcText = ''; +- if (Ext.isNumeric(arc) && arc > 1024 * 1024) { +- let v = Proxmox.Utils.format_size(value - arc); +- arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; +- } +- +- toolTip.setHtml( +- `${gettext('Used')}: ${total}${arcText}
${time}`, +- ); +- } +- }, +- }, +- }, +- 'arcsize', +- { +- type: 'line', +- fill: false, +- yField: 'memavailable', +- title: gettext('Available'), +- style: { +- lineWidth: 2.5, +- opacity: 1, +- }, +- }, +- ], +- fieldTitles: [ +- gettext('Total'), +- gettext('Used'), +- gettext('ZFS ARC'), +- gettext('Available'), +- ], +- colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], +- unit: 'bytes', +- powerOfTwo: true, +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Network Traffic'), +- fields: ['netin', 'netout'], +- fieldTitles: [gettext('Incoming'), gettext('Outgoing')], +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('CPU Pressure Stall'), +- fieldTitles: ['Some'], +- fields: ['pressurecpusome'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('IO Pressure Stall'), +- fieldTitles: ['Some', 'Full'], +- fields: ['pressureiosome', 'pressureiofull'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Memory Pressure Stall'), +- fieldTitles: ['Some', 'Full'], +- fields: ['pressurememorysome', 'pressurememoryfull'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- ], +- listeners: { +- resize: function (panel) { +- Proxmox.Utils.updateColumns(panel); +- }, +- }, +- }, +- ], +- listeners: { +- activate: function () { +- rstore.setInterval(1000); +- rstore.startUpdate(); // just to be sure +- rrdstore.startUpdate(); +- }, +- destroy: function () { +- rstore.setInterval(5000); // don't stop it, it's not ours! +- rrdstore.stopUpdate(); +- }, +- }, +- }); +- +- me.updateRepositoryStatus(); +- +- me.callParent(); +- +- let sp = Ext.state.Manager.getProvider(); +- me.mon(sp, 'statechange', function (provider, key, value) { +- if (key !== 'summarycolumns') { +- return; +- } +- Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); +- }); +- }, +-}); ++// Ext.define('PVE.node.Summary', { ++// extend: 'Ext.panel.Panel', ++// alias: 'widget.pveNodeSummary', ++// ++// scrollable: true, ++// bodyPadding: 5, ++// ++// showVersions: function () { ++// var me = this; ++// ++// // Note: we use simply text/html here, because ExtJS grid has problems ++// // with cut&paste ++// ++// var nodename = me.pveSelNode.data.node; ++// ++// var view = Ext.createWidget('component', { ++// autoScroll: true, ++// id: 'pkgversions', ++// padding: 5, ++// style: { ++// 'white-space': 'pre', ++// 'font-family': 'monospace', ++// }, ++// }); ++// ++// var win = Ext.create('Ext.window.Window', { ++// title: gettext('Package versions'), ++// width: 600, ++// height: 600, ++// layout: 'fit', ++// modal: true, ++// items: [view], ++// buttons: [ ++// { ++// xtype: 'button', ++// iconCls: 'fa fa-clipboard', ++// handler: function (button) { ++// window ++// .getSelection() ++// .selectAllChildren(document.getElementById('pkgversions')); ++// document.execCommand('copy'); ++// }, ++// text: gettext('Copy'), ++// }, ++// { ++// text: gettext('Ok'), ++// handler: function () { ++// this.up('window').close(); ++// }, ++// }, ++// ], ++// }); ++// ++// Proxmox.Utils.API2Request({ ++// waitMsgTarget: me, ++// url: `/nodes/${nodename}/apt/versions`, ++// method: 'GET', ++// failure: function (response, opts) { ++// win.close(); ++// Ext.Msg.alert(gettext('Error'), response.htmlStatus); ++// }, ++// success: function (response, opts) { ++// win.show(); ++// let text = ''; ++// Ext.Array.each(response.result.data, function (rec) { ++// let version = 'not correctly installed'; ++// let pkg = rec.Package; ++// if (rec.OldVersion && rec.CurrentState === 'Installed') { ++// version = rec.OldVersion; ++// } ++// if (rec.RunningKernel) { ++// text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; ++// } else if (rec.ManagerVersion) { ++// text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; ++// } else { ++// text += `${pkg}: ${version}\n`; ++// } ++// }); ++// ++// view.update(Ext.htmlEncode(text)); ++// }, ++// }); ++// }, ++// ++// updateRepositoryStatus: function () { ++// let me = this; ++// let repoStatus = me.nodeStatus.down('#repositoryStatus'); ++// ++// let nodename = me.pveSelNode.data.node; ++// ++// Proxmox.Utils.API2Request({ ++// url: `/nodes/${nodename}/apt/repositories`, ++// method: 'GET', ++// failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), ++// success: (response) => ++// repoStatus.setRepositoryInfo(response.result.data['standard-repos']), ++// }); ++// ++// Proxmox.Utils.API2Request({ ++// url: `/nodes/${nodename}/subscription`, ++// method: 'GET', ++// failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), ++// success: function (response, opts) { ++// const res = response.result; ++// const subscription = res?.data?.status.toLowerCase() === 'active'; ++// repoStatus.setSubscriptionStatus(subscription); ++// }, ++// }); ++// }, ++// ++// initComponent: function () { ++// var me = this; ++// ++// var nodename = me.pveSelNode.data.node; ++// if (!nodename) { ++// throw 'no node name specified'; ++// } ++// ++// if (!me.statusStore) { ++// throw 'no status storage specified'; ++// } ++// ++// var rstore = me.statusStore; ++// ++// var version_btn = new Ext.Button({ ++// text: gettext('Package versions'), ++// handler: function () { ++// Proxmox.Utils.checked_command(function () { ++// me.showVersions(); ++// }); ++// }, ++// }); ++// ++// var rrdstore = Ext.create('Proxmox.data.RRDStore', { ++// rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', ++// model: 'pve-rrd-node', ++// }); ++// ++// let nodeStatus = Ext.create('PVE.node.StatusView', { ++// xtype: 'pveNodeStatus', ++// rstore: rstore, ++// width: 770, ++// pveSelNode: me.pveSelNode, ++// }); ++// ++// Ext.apply(me, { ++// tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], ++// nodeStatus: nodeStatus, ++// items: [ ++// { ++// xtype: 'container', ++// itemId: 'itemcontainer', ++// layout: 'column', ++// minWidth: 700, ++// defaults: { ++// minHeight: 360, ++// padding: 5, ++// columnWidth: 1, ++// }, ++// items: [ ++// nodeStatus, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('CPU Usage'), ++// fields: ['cpu', 'iowait'], ++// fieldTitles: [gettext('CPU usage'), gettext('IO delay')], ++// unit: 'percent', ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Server Load'), ++// fields: ['loadavg'], ++// fieldTitles: [gettext('Load average')], ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Memory usage'), ++// fields: [ ++// { ++// yField: 'memtotal', ++// title: gettext('Total'), ++// tooltip: { ++// trackMouse: true, ++// renderer: function (toolTip, record, item) { ++// let value = record.get('memtotal'); ++// ++// if (value === null) { ++// toolTip.setHtml(gettext('No Data')); ++// } else { ++// let total = Proxmox.Utils.format_size(value); ++// let time = new Date(record.get('time')); ++// ++// let avail = record.get('memavailable'); ++// let availText = ''; ++// if (Ext.isNumeric(avail)) { ++// let v = Proxmox.Utils.format_size(avail); ++// availText = ` (${gettext('Available')}: ${v})`; ++// } ++// ++// toolTip.setHtml( ++// `${gettext('Total')}: ${total}${availText}
${time}`, ++// ); ++// } ++// }, ++// }, ++// }, ++// { ++// yField: 'memused', ++// title: gettext('Used'), ++// tooltip: { ++// trackMouse: true, ++// renderer: function (toolTip, record, item) { ++// let value = record.get('memused'); ++// ++// if (value === null) { ++// toolTip.setHtml(gettext('No Data')); ++// } else { ++// let total = Proxmox.Utils.format_size(value); ++// let time = new Date(record.get('time')); ++// ++// let arc = record.get('arcsize'); ++// let arcText = ''; ++// if (Ext.isNumeric(arc) && arc > 1024 * 1024) { ++// let v = Proxmox.Utils.format_size(value - arc); ++// arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; ++// } ++// ++// toolTip.setHtml( ++// `${gettext('Used')}: ${total}${arcText}
${time}`, ++// ); ++// } ++// }, ++// }, ++// }, ++// 'arcsize', ++// { ++// type: 'line', ++// fill: false, ++// yField: 'memavailable', ++// title: gettext('Available'), ++// style: { ++// lineWidth: 2.5, ++// opacity: 1, ++// }, ++// }, ++// ], ++// fieldTitles: [ ++// gettext('Total'), ++// gettext('Used'), ++// gettext('ZFS ARC'), ++// gettext('Available'), ++// ], ++// colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], ++// unit: 'bytes', ++// powerOfTwo: true, ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Network Traffic'), ++// fields: ['netin', 'netout'], ++// fieldTitles: [gettext('Incoming'), gettext('Outgoing')], ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('CPU Pressure Stall'), ++// fieldTitles: ['Some'], ++// fields: ['pressurecpusome'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('IO Pressure Stall'), ++// fieldTitles: ['Some', 'Full'], ++// fields: ['pressureiosome', 'pressureiofull'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Memory Pressure Stall'), ++// fieldTitles: ['Some', 'Full'], ++// fields: ['pressurememorysome', 'pressurememoryfull'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// ], ++// listeners: { ++// resize: function (panel) { ++// Proxmox.Utils.updateColumns(panel); ++// }, ++// }, ++// }, ++// ], ++// listeners: { ++// activate: function () { ++// rstore.setInterval(1000); ++// rstore.startUpdate(); // just to be sure ++// rrdstore.startUpdate(); ++// }, ++// destroy: function () { ++// rstore.setInterval(5000); // don't stop it, it's not ours! ++// rrdstore.stopUpdate(); ++// }, ++// }, ++// }); ++// ++// me.updateRepositoryStatus(); ++// ++// me.callParent(); ++// ++// let sp = Ext.state.Manager.getProvider(); ++// me.mon(sp, 'statechange', function (provider, key, value) { ++// if (key !== 'summarycolumns') { ++// return; ++// } ++// Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); ++// }); ++// }, ++// }); + Ext.define('PVE.node.CreateZFS', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCreateZFS', From f64e8e21cf0f03deb31e97a85a949f96bda53086 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 8 Jun 2026 22:01:28 +0200 Subject: [PATCH 02/13] rework apply and revert patches to patch --- debian/control | 2 +- debian/pve-mod.conffiles | 2 + debian/pve-mod.postinst | 50 +++- debian/rules | 39 ++- src/NagScreen/nag_screen.conf | 3 + .../patches/01-proxmoxlib-js-nagscreen.patch | 1 + src/NagScreen/patches/patches.list | 4 + src/NagScreen/patches/post-apply.sh | 26 ++ src/NagScreen/patches/post-revert.sh | 23 ++ src/PVENodeInfo/apply-patches.sh | 261 ------------------ src/PVENodeInfo/node_info.conf | 45 +++ src/PVENodeInfo/patches/patches.list | 9 + src/PVENodeInfo/patches/post-apply.sh | 34 +++ src/PVENodeInfo/revert-patches.sh | 69 ----- src/Scripts/apply-patches.sh | 206 ++++++++++++++ src/Scripts/pve-mod-configure | 46 ++- src/Scripts/revert-patches.sh | 101 +++++++ src/pve-mod.conf | 52 +--- 18 files changed, 569 insertions(+), 404 deletions(-) create mode 100644 src/NagScreen/nag_screen.conf create mode 100644 src/NagScreen/patches/patches.list create mode 100644 src/NagScreen/patches/post-apply.sh create mode 100644 src/NagScreen/patches/post-revert.sh delete mode 100644 src/PVENodeInfo/apply-patches.sh create mode 100644 src/PVENodeInfo/node_info.conf create mode 100644 src/PVENodeInfo/patches/patches.list create mode 100644 src/PVENodeInfo/patches/post-apply.sh delete mode 100644 src/PVENodeInfo/revert-patches.sh create mode 100644 src/Scripts/apply-patches.sh create mode 100644 src/Scripts/revert-patches.sh diff --git a/debian/control b/debian/control index d2d26a3..21f48d8 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Homepage: https://github.com/Meliox/PVE-mods Package: pve-mod Architecture: all -Depends: ${misc:Depends}, perl, librrds-perl +Depends: ${misc:Depends}, perl, librrds-perl, patch Recommends: lm-sensors, nut-client Suggests: igt-gpu-tools Description: Proxmox VE UI modifications and sensor monitoring diff --git a/debian/pve-mod.conffiles b/debian/pve-mod.conffiles index 0c4d9d3..1db9445 100644 --- a/debian/pve-mod.conffiles +++ b/debian/pve-mod.conffiles @@ -1 +1,3 @@ /etc/pve-mod/pve-mod.conf +/etc/pve-mod/conf.d/node_info.conf +/etc/pve-mod/conf.d/nag_screen.conf diff --git a/debian/pve-mod.postinst b/debian/pve-mod.postinst index 7ca0c76..1140cb4 100644 --- a/debian/pve-mod.postinst +++ b/debian/pve-mod.postinst @@ -1,9 +1,15 @@ #!/bin/bash set -e -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -DEFAULT_CONF="/usr/share/pve-mod/pve-mod.conf.default" -USER_CONF="/etc/pve-mod/pve-mod.conf" +DEFAULT_DIR="/usr/share/pve-mod" +CONF_DIR="/etc/pve-mod" + +# Config file pairs to compare on upgrade: "|" +CONF_PAIRS=" +${DEFAULT_DIR}/pve-mod.conf.default|${CONF_DIR}/pve-mod.conf +${DEFAULT_DIR}/conf.d/node_info.conf.default|${CONF_DIR}/conf.d/node_info.conf +${DEFAULT_DIR}/conf.d/nag_screen.conf.default|${CONF_DIR}/conf.d/nag_screen.conf +" # Extracts "section.key" pairs from an INI file, one per line. _extract_conf_keys() { @@ -17,30 +23,48 @@ _extract_conf_keys() { done < "$file" } -# Warns about keys present in the default config but missing from the user's config. -_check_new_config_keys() { - [ -f "$DEFAULT_CONF" ] || return 0 - [ -f "$USER_CONF" ] || return 0 +# Warns about keys present in a default config but missing from the user's copy. +_check_one_pair() { + local default_conf="$1" user_conf="$2" + [ -f "$default_conf" ] || return 0 + [ -f "$user_conf" ] || return 0 - local new_keys="" section="" key default_val + local new_keys="" section="" key line while IFS= read -r line; do case "$line" in '#'*|'') continue ;; '['*']') section="${line#[}"; section="${section%]}"; continue ;; *'='*) key="${line%%=*}" - if ! _extract_conf_keys "$USER_CONF" | grep -qF "${section}.${key}"; then - new_keys="${new_keys} [${section}] ${line}\n" + if ! _extract_conf_keys "$user_conf" | grep -qF "${section}.${key}"; then + new_keys="${new_keys} [${section}] ${line} (${user_conf})\n" fi ;; esac - done < "$DEFAULT_CONF" + done < "$default_conf" if [ -n "$new_keys" ]; then + printf "%b" "$new_keys" + fi +} + +# Warns about any new config keys across the main config and all per-mod configs. +_check_new_config_keys() { + local all_new="" pair default_conf user_conf + while IFS= read -r pair; do + [ -n "$pair" ] || continue + default_conf="${pair%%|*}" + user_conf="${pair#*|}" + all_new="${all_new}$(_check_one_pair "$default_conf" "$user_conf")" + done < [section.key=value] + +01-proxmoxlib-js-nagscreen.patch diff --git a/src/NagScreen/patches/post-apply.sh b/src/NagScreen/patches/post-apply.sh new file mode 100644 index 0000000..c4f0b96 --- /dev/null +++ b/src/NagScreen/patches/post-apply.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# post-apply hook for the nag_screen mod. +# Runs after nag_screen patches are applied. Provided env: MOD_CONF, STASH_DIR, +# CONFD_DIR. Replaces the minified proxmoxlib with a symlink to the patched +# unminified copy so the browser serves the patched code. The original minified +# file is stashed (it is a non-patch binary replacement, so `patch -R` cannot +# restore it). Exit codes: 0 = no change, 100 = changed, other = error. + +set -u + +PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" +PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" +STASH_DIR="${STASH_DIR:-/var/lib/pve-mod/backup}" + +if [[ ! -L "$PROXMOXLIB_MIN_JS" ]]; then + mkdir -p "$STASH_DIR" + if [[ -f "$PROXMOXLIB_MIN_JS" ]]; then + mv "$PROXMOXLIB_MIN_JS" \ + "$STASH_DIR/proxmoxlib.min.js.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true + fi + ln -sf "$PROXMOXLIB_JS" "$PROXMOXLIB_MIN_JS" + echo "[pve-mod] Symlinked proxmoxlib.min.js -> proxmoxlib.js" + exit 100 +fi + +exit 0 diff --git a/src/NagScreen/patches/post-revert.sh b/src/NagScreen/patches/post-revert.sh new file mode 100644 index 0000000..45c9df3 --- /dev/null +++ b/src/NagScreen/patches/post-revert.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# post-revert hook for the nag_screen mod. +# Runs before nag_screen patches are reverted. Provided env: MOD_CONF, +# STASH_DIR, CONFD_DIR. Removes the min.js symlink and restores the original +# minified file from the stash. Exit codes: 0 = no change, 100 = changed. + +set -u + +PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" +STASH_DIR="${STASH_DIR:-/var/lib/pve-mod/backup}" + +if [[ -L "$PROXMOXLIB_MIN_JS" ]]; then + rm -f "$PROXMOXLIB_MIN_JS" + latest=$(find "$STASH_DIR" -name "proxmoxlib.min.js.*" -type f -printf '%T+ %p\n' 2>/dev/null \ + | sort -r | head -n1 | awk '{print $2}') + if [[ -n "$latest" ]]; then + cp "$latest" "$PROXMOXLIB_MIN_JS" + echo "[pve-mod] Restored proxmoxlib.min.js from stash" + fi + exit 100 +fi + +exit 0 diff --git a/src/PVENodeInfo/apply-patches.sh b/src/PVENodeInfo/apply-patches.sh deleted file mode 100644 index cba11d7..0000000 --- a/src/PVENodeInfo/apply-patches.sh +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env bash -# /usr/lib/pve-mod/apply-patches.sh -# -# Applies pve-mod patches to Proxmox VE system files. -# Reads /etc/pve-mod/pve-mod.conf to determine which modules are enabled. -# Idempotent: safe to call multiple times (e.g. from apt hook after PVE upgrade). - -CONF_FILE="/etc/pve-mod/pve-mod.conf" -BACKUP_DIR="/var/lib/pve-mod/backup" -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -PVE_MANAGER_JS="/usr/share/pve-manager/js/pvemanagerlib.js" -PVE_MOD_JS="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" -PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" -PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" -GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu" - -info() { echo "[pve-mod] $*"; } -warn() { echo "[pve-mod] WARNING: $*" >&2; } - -# Read one value from the INI config file; prints $default if not found. -read_conf() { - local section="$1" key="$2" default="${3:-0}" - if [[ ! -f "$CONF_FILE" ]]; then - echo "$default" - return - fi - local val - val=$(awk -F= -v sec="[$section]" -v k="$key" ' - /^\[/ { in_sec = ($0 == sec) } - in_sec && /^[^#=]+=/ { - gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) - if ($1 == k) { - gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2) - print $2; exit - } - } - ' "$CONF_FILE") - echo "${val:-$default}" -} - -backup_file() { - local src="$1" - [[ -f "$src" ]] || return 0 - local name ts - name=$(basename "$src") - ts=$(date +%Y%m%d_%H%M%S) - mkdir -p "$BACKUP_DIR" - cp "$src" "$BACKUP_DIR/${name}.${ts}" - info "Backed up $(basename "$src") → $BACKUP_DIR/${name}.${ts}" -} - -# ── Read enabled modules ────────────────────────────────────────────────────── -NODE_INFO=$(read_conf modules node_info 0) -NAG_SCREEN=$(read_conf modules nag_screen 0) -GPU_HISTORY=$(read_conf gpu gpu_history 0) - -CHANGED=false - -# ── node-info: Nodes.pm ─────────────────────────────────────────────────────── -if [[ "$NODE_INFO" == "1" ]]; then - if ! grep -qF "use PVE::API2::PVEMod_SensorInfo" "$NODES_PM" 2>/dev/null; then - backup_file "$NODES_PM" - python3 - "$NODES_PM" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'use PVE::API2::PVEMod_SensorInfo' in content: - sys.exit(0) - -m = re.search(r'^([ \t]*)my \$dinfo = df\(\'\/\', 1\);', content, re.MULTILINE) -if not m: - print("ERROR: Anchor 'my $dinfo = df' not found in Nodes.pm", file=sys.stderr) - sys.exit(1) - -indent = m.group(1) -insertion = ( - f"{indent}# Collect sensor data from PveMod_SensorInfo\n" - f"{indent}use PVE::API2::PVEMod_SensorInfo;\n" - f"{indent}$res->{{PveMod_JsonSensorInfo}} = PVE::API2::PVEMod_SensorInfo::get_sensors_info();\n" - f"{indent}$res->{{PveMod_Version}} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version();\n" - f"{indent}$res->{{PveMod_graphicsInfo}} = PVE::API2::PVEMod_SensorInfo::get_graphics_info();\n" - f"{indent}$res->{{PveMod_upsInfo}} = PVE::API2::PVEMod_SensorInfo::get_ups_info();\n" - f"{indent}$res->{{PveMod_systemInfo}} = PVE::API2::PVEMod_SensorInfo::get_system_information();\n" -) -content = content[:m.start()] + insertion + content[m.start():] -open(path, 'w').write(content) -PYEOF - info "Patched Nodes.pm" - CHANGED=true - fi - - # ── node-info: pvemanagerlib.js ─────────────────────────────────────────── - if ! grep -qF "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_JS" 2>/dev/null; then - backup_file "$PVE_MANAGER_JS" - python3 - "$PVE_MANAGER_JS" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'PveMod_PveNodeStatusView.js' in content: - sys.exit(0) - -# Comment out original StatusView definition -content = re.sub( - r"(?m)^(Ext\.define\('PVE\.node\.StatusView',.*?^}\);)", - lambda m: '\n'.join('// ' + line for line in m.group(1).split('\n')), - content, flags=re.DOTALL -) - -# Comment out original Summary definition -content = re.sub( - r"(?m)^(Ext\.define\('PVE\.node\.Summary',.*?^}\);)", - lambda m: '\n'.join('// ' + line for line in m.group(1).split('\n')), - content, flags=re.DOTALL -) - -# Insert dynamic loader before the now-commented StatusView block -loader = ( - "// Load custom PVE.node.StatusView from external module\n" - "Ext.Loader.loadScript({\n" - " url: '/pve2/js/PveMod_PveNodeStatusView.js',\n" - " onLoad: function() { },\n" - " onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); }\n" - "});\n" -) -content = re.sub( - r"(// Ext\.define\('PVE\.node\.StatusView',)", - loader + r'\1', - content, count=1 -) -open(path, 'w').write(content) -PYEOF - info "Patched pvemanagerlib.js" - CHANGED=true - fi -fi - -# ── node-info: GPU RRD history ──────────────────────────────────────────────── -if [[ "$NODE_INFO" == "1" && "$GPU_HISTORY" == "1" ]]; then - if ! grep -qF "gpurrddata" "$NODES_PM" 2>/dev/null; then - # Register method in the node sub-path list - sed -i "s/{ name => 'rrddata' },/{ name => 'rrddata' },\n { name => 'gpurrddata' },/" "$NODES_PM" - - # Append gpurrddata method definition after the rrddata code block - python3 - "$NODES_PM" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'gpurrddata' in content: - sys.exit(0) - -method = r""" -__PACKAGE__->register_method({ - name => 'gpurrddata', - path => 'gpurrddata', - method => 'GET', - protected => 1, - proxyto => 'node', - permissions => { - check => ['perm', '/nodes/{node}', ['Sys.Audit']], - }, - description => "Read GPU RRD statistics", - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - card => { - description => "The GPU card identifier (e.g. card0, nvidia0).", - type => 'string', - pattern => '[a-zA-Z0-9]+', - }, - timeframe => { - description => "Specify the time frame you are interested in.", - type => 'string', - enum => ['hour', 'day', 'week', 'month', 'year', 'decade'], - }, - cf => { - description => "The RRD consolidation function", - type => 'string', - enum => ['AVERAGE', 'MAX'], - optional => 1, - }, - }, - }, - returns => { - type => "array", - items => { - type => "object", - properties => {}, - }, - }, - code => sub { - my ($param) = @_; - my $nodename = PVE::INotify::nodename(); - my $card = $param->{card}; - die "invalid card name\n" unless $card =~ /^[a-zA-Z0-9]+$/; - return PVE::RRD::create_rrd_data( - "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf}, - ); - }, -}); -""" - -# Insert before the final 1; at end of file -content = re.sub(r'\n1;\s*$', method + '\n1;\n', content) -open(path, 'w').write(content) -PYEOF - - mkdir -p "$GPU_RRD_DIR" - chown www-data:www-data "$GPU_RRD_DIR" 2>/dev/null || true - info "Installed gpurrddata API endpoint" - CHANGED=true - fi -fi - -# ── nag-screen: proxmoxlib.js ───────────────────────────────────────────────── -if [[ "$NAG_SCREEN" == "1" ]]; then - if ! grep -qF "// disable subscription nag screen" "$PROXMOXLIB_JS" 2>/dev/null; then - backup_file "$PROXMOXLIB_JS" - python3 - "$PROXMOXLIB_JS" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if '// disable subscription nag screen' in content: - sys.exit(0) - -m = re.search(r'(checked_command:\s*function\s*\(orig_cmd\)\s*\{)', content) -if not m: - print("ERROR: checked_command pattern not found in proxmoxlib.js", file=sys.stderr) - sys.exit(1) - -insert = "\n\t\t\t// disable subscription nag screen\n\t\t\torig_cmd();\n\t\t\treturn;" -pos = m.end() -content = content[:pos] + insert + content[pos:] -open(path, 'w').write(content) -PYEOF - info "Patched proxmoxlib.js (nag screen)" - CHANGED=true - fi - - if [[ ! -L "$PROXMOXLIB_MIN_JS" ]]; then - backup_file "$PROXMOXLIB_MIN_JS" - mv "$PROXMOXLIB_MIN_JS" "$BACKUP_DIR/proxmoxlib.min.js.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true - ln -sf "$PROXMOXLIB_JS" "$PROXMOXLIB_MIN_JS" - info "Symlinked proxmoxlib.min.js → proxmoxlib.js" - CHANGED=true - fi -fi - -# ── restart pveproxy if anything changed ────────────────────────────────────── -if [[ "$CHANGED" == "true" ]]; then - info "Restarting pveproxy..." - systemctl restart pveproxy 2>/dev/null || true -fi diff --git a/src/PVENodeInfo/node_info.conf b/src/PVENodeInfo/node_info.conf new file mode 100644 index 0000000..13c4ed4 --- /dev/null +++ b/src/PVENodeInfo/node_info.conf @@ -0,0 +1,45 @@ +# pve-mod :: node_info mod configuration +# Settings for the node-info / sensor-monitoring mod. +# Managed by pve-mod-configure. Re-run to update. + +[gpu] +intel_enabled=0 +nvidia_enabled=0 +amd_enabled=0 +gpu_history=0 + +[lm_sensors] +enabled=0 +enable_cpu=0 +cpu_temp_target=Core +enable_ram_temp=0 +enable_hdd_temp=0 +enable_nvme_temp=0 +enable_fan_speed=0 +display_zero_speed_fans=0 +temp_unit=C + +[ups] +enabled=0 +device_name=ups@localhost + +[system_info] +enabled=0 +type=1 + +# Debug mode: when a collector's mode is 1, the real tool is not required. +# Data is read from the file path instead. Useful for development/testing. +[debug] +lm_sensors_mode=0 +lm_sensors_output_file=/tmp/sensors-output.json +intel_mode=0 +intel_devices_file=/tmp/intel-gpu-devices.json +nvidia_mode=0 +nvidia_devices_file=/tmp/nvidia-smi-devices.csv +nvidia_output_file=/tmp/nvidia-smi-output.csv +amd_mode=0 +amd_devices_file=/tmp/amd-gpu-devices.json +ups_mode=0 +ups_output_file=/tmp/ups-output.json +log_enabled=0 +log_file=/tmp/pve-mod-debug.log diff --git a/src/PVENodeInfo/patches/patches.list b/src/PVENodeInfo/patches/patches.list new file mode 100644 index 0000000..9049189 --- /dev/null +++ b/src/PVENodeInfo/patches/patches.list @@ -0,0 +1,9 @@ +# pve-mod :: node_info patch manifest +# Format: [section.key=value] +# Patches are applied top-to-bottom. An optional condition (read from this mod's +# conf.d file, /etc/pve-mod/conf.d/node_info.conf) gates a patch; it is applied +# only when the key equals the given value. + +01-nodes-pm-sensors.patch +02-nodes-pm-GPU-RRD-history.patch gpu.gpu_history=1 +03-pvemanager-js-sensors.patch diff --git a/src/PVENodeInfo/patches/post-apply.sh b/src/PVENodeInfo/patches/post-apply.sh new file mode 100644 index 0000000..62b3b5e --- /dev/null +++ b/src/PVENodeInfo/patches/post-apply.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# post-apply hook for the node_info mod. +# Runs after node_info patches are applied. Provided env: MOD_CONF, STASH_DIR, +# CONFD_DIR. Creates the GPU RRD storage directory when GPU history is enabled. +# Exit codes: 0 = no change, 100 = changed (restart pveproxy), other = error. + +set -u + +GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu" + +read_conf() { + local file="$1" section="$2" key="$3" default="${4:-0}" + [[ -f "$file" ]] || { echo "$default"; return; } + local val + val=$(awk -F= -v sec="[$section]" -v k="$key" ' + /^\[/ { in_sec = ($0 == sec) } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + if ($1 == k) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit } + } + ' "$file") + echo "${val:-$default}" +} + +gpu_history="$(read_conf "${MOD_CONF:-/etc/pve-mod/conf.d/node_info.conf}" gpu gpu_history 0)" + +if [[ "$gpu_history" == "1" && ! -d "$GPU_RRD_DIR" ]]; then + mkdir -p "$GPU_RRD_DIR" + chown www-data:www-data "$GPU_RRD_DIR" 2>/dev/null || true + echo "[pve-mod] Created GPU RRD directory: $GPU_RRD_DIR" + exit 100 +fi + +exit 0 diff --git a/src/PVENodeInfo/revert-patches.sh b/src/PVENodeInfo/revert-patches.sh deleted file mode 100644 index d86097a..0000000 --- a/src/PVENodeInfo/revert-patches.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# /usr/lib/pve-mod/revert-patches.sh -# -# Reverts all patches applied by apply-patches.sh. -# Restores PVE system files from backups in /var/lib/pve-mod/backup/. -# Called by prerm before package files are removed. - -BACKUP_DIR="/var/lib/pve-mod/backup" -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -PVE_MANAGER_JS="/usr/share/pve-manager/js/pvemanagerlib.js" -PVE_MOD_JS="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" -PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" -PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" - -info() { echo "[pve-mod] $*"; } -warn() { echo "[pve-mod] WARNING: $*" >&2; } - -restore_latest() { - local name="$1" target="$2" - local latest - latest=$(find "$BACKUP_DIR" -name "${name}.*" -type f -printf '%T+ %p\n' 2>/dev/null \ - | sort -r | head -n1 | awk '{print $2}') - if [[ -n "$latest" ]]; then - cp "$latest" "$target" - info "Restored $(basename "$target") from backup" - else - warn "No backup found for ${name}; $target not restored" - fi -} - -CHANGED=false - -# ── node-info: Nodes.pm ─────────────────────────────────────────────────────── -if grep -qF "use PVE::API2::PVEMod_SensorInfo" "$NODES_PM" 2>/dev/null; then - restore_latest "Nodes.pm" "$NODES_PM" - CHANGED=true -fi - -# ── node-info: pvemanagerlib.js ─────────────────────────────────────────────── -if grep -qF "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_JS" 2>/dev/null; then - restore_latest "pvemanagerlib.js" "$PVE_MANAGER_JS" - CHANGED=true -fi - -# ── node-info: JS module file ───────────────────────────────────────────────── -if [[ -f "$PVE_MOD_JS" ]]; then - rm -f "$PVE_MOD_JS" - info "Removed PveMod_PveNodeStatusView.js" - CHANGED=true -fi - -# ── nag-screen: proxmoxlib.min.js symlink ──────────────────────────────────── -if [[ -L "$PROXMOXLIB_MIN_JS" ]]; then - rm -f "$PROXMOXLIB_MIN_JS" - restore_latest "proxmoxlib.min.js" "$PROXMOXLIB_MIN_JS" - CHANGED=true -fi - -# ── nag-screen: proxmoxlib.js ──────────────────────────────────────────────── -if grep -qF "// disable subscription nag screen" "$PROXMOXLIB_JS" 2>/dev/null; then - restore_latest "proxmoxlib.js" "$PROXMOXLIB_JS" - CHANGED=true -fi - -# ── restart pveproxy if anything changed ───────────────────────────────────── -if [[ "$CHANGED" == "true" ]]; then - info "Restarting pveproxy..." - systemctl restart pveproxy 2>/dev/null || true -fi diff --git a/src/Scripts/apply-patches.sh b/src/Scripts/apply-patches.sh new file mode 100644 index 0000000..e6dc887 --- /dev/null +++ b/src/Scripts/apply-patches.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# /usr/lib/pve-mod/apply-patches.sh +# +# Generic patch applier for pve-mod. +# +# Reads /etc/pve-mod/pve-mod.conf [modules] to learn which mods are enabled, +# then applies each enabled mod's patches from /usr/lib/pve-mod/patches//. +# +# Patch convention: every .patch uses a/ b/ headers and is applied +# with `patch -p1 -F0 -d /` (zero fuzz: line offsets tolerated, fuzzy context +# matching disabled). Each mod folder contains a 'patches.list' manifest and +# optional 'post-apply.sh' / 'post-revert.sh' hooks. +# +# Per-mod atomicity: before applying, every active patch of a mod is dry-run. +# If any one cannot be applied (or is already partially/broken-applied), the +# whole mod is reverted to a clean state and reported as failed - a mod is +# never left half-applied. +# +# Idempotent: a patch that is already applied is detected (reverse dry-run) and +# skipped, so this script is safe to run repeatedly (e.g. from the dpkg trigger +# after a pve-manager upgrade). +# +# Hook exit-code convention: a hook returns 0 (no change), 100 (made a change, +# triggers a pveproxy restart), or any other code (error). + +set -u + +# Root paths (overridable via environment, mainly for testing). +PVE_MOD_ROOT="${PVE_MOD_ROOT:-/}" +MAIN_CONF="${PVE_MOD_MAIN_CONF:-/etc/pve-mod/pve-mod.conf}" +CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" +PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" +# Storage for non-patch replaced files (e.g. nag-screen's minified proxmoxlib). +# Patched text files need no backups - `patch -R` reverts them. +STASH_DIR="${PVE_MOD_STASH_DIR:-/var/lib/pve-mod/backup}" + +info() { echo "[pve-mod] $*"; } +warn() { echo "[pve-mod] WARNING: $*" >&2; } + +# read_conf
[default] +# Prints one value from an INI file, or the default if absent. +read_conf() { + local file="$1" section="$2" key="$3" default="${4:-0}" + if [[ ! -f "$file" ]]; then + echo "$default" + return + fi + local val + val=$(awk -F= -v sec="[$section]" -v k="$key" ' + /^\[/ { in_sec = ($0 == sec) } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + if ($1 == k) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2) + print $2; exit + } + } + ' "$file") + echo "${val:-$default}" +} + +# List the keys of the [modules] section in the main config, one per line. +list_modules() { + [[ -f "$MAIN_CONF" ]] || return 0 + awk -F= ' + /^\[/ { in_sec = ($0 == "[modules]") } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + print $1 + } + ' "$MAIN_CONF" +} + +# Patch-state helpers (all use zero fuzz for deterministic detection). +_dry_forward() { patch -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$1" >/dev/null 2>&1; } +_dry_reverse() { patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$1" >/dev/null 2>&1; } +is_applied() { _dry_reverse "$1"; } +can_apply() { _dry_forward "$1"; } +do_apply() { patch -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$1"; } +do_revert() { patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$1"; } + +CHANGED=false +FAILED=false + +# Run a mod hook honouring the exit-code convention. Sets CHANGED / FAILED. +run_hook() { + local hook="$1" mod_conf="$2" + [[ -x "$hook" ]] || return 0 + STASH_DIR="$STASH_DIR" MOD_CONF="$mod_conf" CONFD_DIR="$CONFD_DIR" "$hook" + local rc=$? + case "$rc" in + 0) ;; + 100) CHANGED=true ;; + *) warn " hook $(basename "$hook") reported an error (exit $rc)"; FAILED=true ;; + esac +} + +# ── main ────────────────────────────────────────────────────────────────────── +if ! command -v patch >/dev/null 2>&1; then + warn "'patch' command not found; cannot apply mods. Install the 'patch' package." + exit 1 +fi + +for mod in $(list_modules); do + [[ "$(read_conf "$MAIN_CONF" modules "$mod" 0)" == "1" ]] || continue + + mod_dir="$PATCHES_DIR/$mod" + manifest="$mod_dir/patches.list" + mod_conf="$CONFD_DIR/$mod.conf" + + if [[ ! -f "$manifest" ]]; then + warn "No patch manifest for enabled mod '$mod' ($manifest); skipping." + FAILED=true + continue + fi + + info "Checking mod: $mod" + + # Build the list of active patch files (those whose condition is met). + active=() + preflight_ok=true + while IFS= read -r line; do + # Strip comments and surrounding whitespace; skip blanks. + line="${line%%#*}" + line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + [[ -z "$line" ]] && continue + + # Format: [section.key=value] + patch_name="${line%%[[:space:]]*}" + condition="" + if [[ "$line" == *[[:space:]]* ]]; then + condition="$(echo "${line#"$patch_name"}" | sed -E 's/^[[:space:]]+//')" + fi + + # Evaluate optional condition against the mod's conf.d file. + if [[ -n "$condition" ]]; then + local_key="${condition%%=*}" + want="${condition#*=}" + sect="${local_key%%.*}" + ckey="${local_key#*.}" + if [[ "$(read_conf "$mod_conf" "$sect" "$ckey" 0)" != "$want" ]]; then + info " skip $patch_name (condition $condition not met)" + continue + fi + fi + + patch_file="$mod_dir/$patch_name" + if [[ ! -f "$patch_file" ]]; then + warn " patch file missing: $patch_file" + preflight_ok=false + continue + fi + active+=("$patch_file") + done < "$manifest" + + # Preflight dry-run: every active patch must be already applied or cleanly + # appliable. Otherwise the mod cannot be installed atomically. + to_apply=() + if [[ "$preflight_ok" == "true" ]]; then + for pf in "${active[@]}"; do + if is_applied "$pf"; then + continue + elif can_apply "$pf"; then + to_apply+=("$pf") + else + warn " $(basename "$pf") does not apply cleanly" + preflight_ok=false + break + fi + done + fi + + # If preflight failed, revert the whole mod back to a clean state so it is + # never left half-applied. + if [[ "$preflight_ok" != "true" ]]; then + warn "Mod '$mod': preflight failed - reverting mod to clean state." + for (( i=${#active[@]}-1 ; i>=0 ; i-- )); do + pf="${active[$i]}" + if is_applied "$pf"; then + do_revert "$pf" && { info " reverted $(basename "$pf")"; CHANGED=true; } + fi + done + run_hook "$mod_dir/post-revert.sh" "$mod_conf" + FAILED=true + continue + fi + + # Apply the outstanding patches. + if [[ ${#to_apply[@]} -gt 0 ]]; then + for pf in "${to_apply[@]}"; do + do_apply "$pf" && { info " applied $(basename "$pf")"; CHANGED=true; } + done + else + info " already up to date" + fi + + run_hook "$mod_dir/post-apply.sh" "$mod_conf" +done + +if [[ "$CHANGED" == "true" ]]; then + info "Restarting pveproxy..." + systemctl restart pveproxy 2>/dev/null || true +fi + +[[ "$FAILED" == "true" ]] && exit 1 +exit 0 diff --git a/src/Scripts/pve-mod-configure b/src/Scripts/pve-mod-configure index 9911fb5..689c0db 100644 --- a/src/Scripts/pve-mod-configure +++ b/src/Scripts/pve-mod-configure @@ -7,6 +7,9 @@ set -euo pipefail CONF_FILE="/etc/pve-mod/pve-mod.conf" +CONFD_DIR="/etc/pve-mod/conf.d" +NODE_INFO_CONF="${CONFD_DIR}/node_info.conf" +NAG_SCREEN_CONF="${CONFD_DIR}/nag_screen.conf" APPLY_PATCHES="/usr/lib/pve-mod/apply-patches.sh" NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" @@ -25,7 +28,7 @@ ask() { bool() { [[ "$1" == true ]] && echo 1 || echo 0; } _load_conf_debug() { - [[ -f "$CONF_FILE" ]] || return 0 + [[ -f "$NODE_INFO_CONF" ]] || return 0 local in_debug=0 line key val while IFS= read -r line; do case "$line" in @@ -50,7 +53,7 @@ _load_conf_debug() { log_enabled) DEBUG_LOG="$val" ;; log_file) DEBUG_LOG_FILE="$val" ;; esac - done < "$CONF_FILE" + done < "$NODE_INFO_CONF" } #endregion helpers @@ -346,15 +349,33 @@ configure_node_info() { #region write config write_config() { - mkdir -p "$(dirname "$CONF_FILE")" + mkdir -p "$(dirname "$CONF_FILE")" "$CONFD_DIR" + + # Main config: which mods are enabled + global trigger/service settings. cat > "$CONF_FILE" <.conf [modules] node_info=${MOD_NODE_INFO} nag_screen=${MOD_NAG_SCREEN} +[pve_trigger] +enabled=${PVE_TRIGGER_ENABLED} + +[service] +mode=embedded +EOF + info "Main configuration saved to $CONF_FILE" + + # node_info mod config. + cat > "$NODE_INFO_CONF" < "$NAG_SCREEN_CONF" </ (regardless +# of whether the mod is currently enabled), using `patch -R -p1 -F0 -d /`. +# Patches are reverted in reverse manifest order. Patched text files need no +# backups - `patch -R` restores them exactly. Non-patch actions (e.g. the +# nag-screen min.js symlink) are undone by each mod's 'post-revert.sh' hook. +# +# Called by prerm before the package's files are removed. +# +# Hook exit-code convention: a hook returns 0 (no change), 100 (made a change, +# triggers a pveproxy restart), or any other code (error). + +set -u + +# Root paths (overridable via environment, mainly for testing). +PVE_MOD_ROOT="${PVE_MOD_ROOT:-/}" +CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" +PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" +# Storage for non-patch replaced files (e.g. nag-screen's minified proxmoxlib). +STASH_DIR="${PVE_MOD_STASH_DIR:-/var/lib/pve-mod/backup}" + +info() { echo "[pve-mod] $*"; } +warn() { echo "[pve-mod] WARNING: $*" >&2; } + +# Revert a single patch. Returns 0 if a change was made, 1 otherwise. +revert_one_patch() { + local patch="$1" + # Not applied? (a clean forward apply means the change is absent) + if patch -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$patch" >/dev/null 2>&1; then + return 1 + fi + if patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$patch" >/dev/null 2>&1; then + patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$patch" + return 0 + fi + warn " $(basename "$patch") could not be reverted cleanly; manual cleanup may be needed" + return 1 +} + +# ── main ────────────────────────────────────────────────────────────────────── +if ! command -v patch >/dev/null 2>&1; then + warn "'patch' command not found; cannot revert mods." + exit 0 +fi + +[[ -d "$PATCHES_DIR" ]] || exit 0 + +CHANGED=false + +for mod_dir in "$PATCHES_DIR"/*/; do + [[ -d "$mod_dir" ]] || continue + mod="$(basename "$mod_dir")" + manifest="$mod_dir/patches.list" + mod_conf="$CONFD_DIR/$mod.conf" + + # Run the post-revert hook first (undo non-patch actions such as symlinks). + hook="$mod_dir/post-revert.sh" + if [[ -x "$hook" ]]; then + info "Running post-revert hook for $mod" + STASH_DIR="$STASH_DIR" MOD_CONF="$mod_conf" CONFD_DIR="$CONFD_DIR" "$hook" + rc=$? + case "$rc" in + 0) ;; + 100) CHANGED=true ;; + *) warn "post-revert hook for $mod reported an error (exit $rc)" ;; + esac + fi + + [[ -f "$manifest" ]] || continue + info "Reverting mod: $mod" + + # Collect patch names (ignore conditions and comments), then reverse order. + mapfile -t patches < <( + while IFS= read -r line; do + line="${line%%#*}" + line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + [[ -z "$line" ]] && continue + echo "${line%%[[:space:]]*}" + done < "$manifest" + ) + + for (( i=${#patches[@]}-1 ; i>=0 ; i-- )); do + patch_name="${patches[$i]}" + patch_file="$mod_dir/$patch_name" + [[ -f "$patch_file" ]] || continue + if revert_one_patch "$patch_file"; then + info " reverted $patch_name" + CHANGED=true + fi + done +done + +if [[ "$CHANGED" == "true" ]]; then + info "Restarting pveproxy..." + systemctl restart pveproxy 2>/dev/null || true +fi +exit 0 diff --git a/src/pve-mod.conf b/src/pve-mod.conf index 40c3022..2757128 100644 --- a/src/pve-mod.conf +++ b/src/pve-mod.conf @@ -1,55 +1,19 @@ -# pve-mod configuration file +# pve-mod main configuration file # Run 'pve-mod-configure' to set values interactively. -# All flags are 0 (disabled) by default; pve-mod-configure enables them. +# +# This file only declares which mods are enabled. Each mod keeps its own +# settings in /etc/pve-mod/conf.d/.conf +# +# When a mod flag below is 1, its patches are (re)applied on install and, +# if [pve_trigger] enabled=1, after every pve-manager upgrade. [modules] node_info=0 nag_screen=0 -[gpu] -intel_enabled=0 -nvidia_enabled=0 -amd_enabled=0 -gpu_history=0 - -[lm_sensors] -enabled=0 -enable_cpu=0 -cpu_temp_target=Core -enable_ram_temp=0 -enable_hdd_temp=0 -enable_nvme_temp=0 -enable_fan_speed=0 -display_zero_speed_fans=0 -temp_unit=C - -[ups] -enabled=0 -device_name=ups@localhost - -[system_info] -enabled=0 -type=1 - +# Re-apply patches automatically after a pve-manager upgrade (dpkg trigger). [pve_trigger] enabled=0 [service] mode=embedded - -# Debug mode: when a mode is 1, the real tool is not required. -# Data is read from the file path instead. Useful for development/testing. -[debug] -lm_sensors_mode=0 -lm_sensors_output_file=/tmp/sensors-output.json -intel_mode=0 -intel_devices_file=/tmp/intel-gpu-devices.json -nvidia_mode=0 -nvidia_devices_file=/tmp/nvidia-smi-devices.csv -nvidia_output_file=/tmp/nvidia-smi-output.csv -amd_mode=0 -amd_devices_file=/tmp/amd-gpu-devices.json -ups_mode=0 -ups_output_file=/tmp/ups-output.json -log_enabled=0 -log_file=/tmp/pve-mod-debug.log From 0b616b0d0b2d5de43d3e0cbbc52767b14a6c9f37 Mon Sep 17 00:00:00 2001 From: Meliox Date: Tue, 9 Jun 2026 22:00:45 +0200 Subject: [PATCH 03/13] don't let a patch failure abort apt install --- debian/pve-mod.postinst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/debian/pve-mod.postinst b/debian/pve-mod.postinst index 1140cb4..2a33baa 100644 --- a/debian/pve-mod.postinst +++ b/debian/pve-mod.postinst @@ -71,8 +71,11 @@ EOF case "$1" in configure) - # Apply patches for any enabled modules. Fails loudly on error. - /usr/lib/pve-mod/apply-patches.sh + # Apply patches for any enabled modules. Don't abort the install if a + # patch fails — warn instead so dpkg doesn't end half-configured. + if ! /usr/lib/pve-mod/apply-patches.sh; then + echo "pve-mod: WARNING: Some patches failed to apply. Run 'pve-mod-configure' to retry." >&2 + fi if [ -z "$2" ]; then echo "" From 9fd039103cff30d9cdef22a799e7b7bf18773632 Mon Sep 17 00:00:00 2001 From: Meliox Date: Tue, 9 Jun 2026 22:07:24 +0200 Subject: [PATCH 04/13] roll the mod back if its post-apply hook errors --- src/Scripts/apply-patches.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Scripts/apply-patches.sh b/src/Scripts/apply-patches.sh index e6dc887..5224ae8 100644 --- a/src/Scripts/apply-patches.sh +++ b/src/Scripts/apply-patches.sh @@ -91,8 +91,9 @@ run_hook() { case "$rc" in 0) ;; 100) CHANGED=true ;; - *) warn " hook $(basename "$hook") reported an error (exit $rc)"; FAILED=true ;; + *) warn " hook $(basename "$hook") reported an error (exit $rc)"; FAILED=true; return 1 ;; esac + return 0 } # ── main ────────────────────────────────────────────────────────────────────── @@ -194,7 +195,20 @@ for mod in $(list_modules); do info " already up to date" fi - run_hook "$mod_dir/post-apply.sh" "$mod_conf" + # Run the post-apply hook. If it errors, roll the mod back so it is never + # left half-applied (matches the preflight atomicity contract). + if ! run_hook "$mod_dir/post-apply.sh" "$mod_conf"; then + warn "Mod '$mod': post-apply hook failed - reverting mod to clean state." + run_hook "$mod_dir/post-revert.sh" "$mod_conf" + for (( i=${#active[@]}-1 ; i>=0 ; i-- )); do + pf="${active[$i]}" + if is_applied "$pf"; then + do_revert "$pf" && { info " reverted $(basename "$pf")"; CHANGED=true; } + fi + done + FAILED=true + continue + fi done if [[ "$CHANGED" == "true" ]]; then From 02f4de3784e61c45c5f2d26a19a9bc1aa25d2668 Mon Sep 17 00:00:00 2001 From: Meliox Date: Tue, 9 Jun 2026 22:34:57 +0200 Subject: [PATCH 05/13] add patch for gpu history --- .../patches/02-nodes-pm-GPU-RRD-history.patch | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch index e69de29..cad2a6a 100644 --- a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch +++ b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch @@ -0,0 +1,119 @@ +--- usr/share/perl5/PVE/API2/Nodes.pm 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/perl5/PVE/API2/Nodes.pm 2026-06-09 22:30:12.187854456 +0200 +@@ -251,6 +251,7 @@ + { name => 'report' }, + { name => 'rrd' }, # fixme: remove? + { name => 'rrddata' }, ++ { name => 'gpurrddata' }, + { name => 'scan' }, + { name => 'sdn' }, + { name => 'services' }, +@@ -878,6 +879,108 @@ + "pve-node-9.0/$param->{node}", $param->{timeframe}, $param->{cf}, + ); + }, ++ ++__PACKAGE__->register_method({ ++ name => 'gpurrddata', ++ path => 'gpurrddata', ++ method => 'GET', ++ protected => 1, ++ proxyto => 'node', ++ permissions => { ++ check => ['perm', '/nodes/{node}', ['Sys.Audit']], ++ }, ++ description => "Read GPU RRD statistics", ++ parameters => { ++ additionalProperties => 0, ++ properties => { ++ node => get_standard_option('pve-node'), ++ card => { ++ description => "The GPU card identifier (e.g. card0, nvidia0).", ++ type => 'string', ++ pattern => '[a-zA-Z0-9]+', ++ }, ++ timeframe => { ++ description => "Specify the time frame you are interested in.", ++ type => 'string', ++ enum => ['hour', 'day', 'week', 'month', 'year', 'decade'], ++ }, ++ cf => { ++ description => "The RRD consolidation function", ++ type => 'string', ++ enum => ['AVERAGE', 'MAX'], ++ optional => 1, ++ }, ++ }, ++ }, ++ returns => { ++ type => "array", ++ items => { ++ type => "object", ++ properties => {}, ++ }, ++ }, ++ code => sub { ++ my ($param) = @_; ++ my $nodename = PVE::INotify::nodename(); ++ my $card = $param->{card}; ++ die "invalid card name ++" unless $card =~ /^[a-zA-Z0-9]+$/; ++ return PVE::RRD::create_rrd_data( ++ "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf}, ++ ); ++ }, ++}); ++ ++__PACKAGE__->register_method({ ++ name => 'gpurrddata', ++ path => 'gpurrddata', ++ method => 'GET', ++ protected => 1, ++ proxyto => 'node', ++ permissions => { ++ check => ['perm', '/nodes/{node}', ['Sys.Audit']], ++ }, ++ description => "Read GPU RRD statistics", ++ parameters => { ++ additionalProperties => 0, ++ properties => { ++ node => get_standard_option('pve-node'), ++ card => { ++ description => "The GPU card identifier (e.g. card0, nvidia0).", ++ type => 'string', ++ pattern => '[a-zA-Z0-9]+', ++ }, ++ timeframe => { ++ description => "Specify the time frame you are interested in.", ++ type => 'string', ++ enum => ['hour', 'day', 'week', 'month', 'year', 'decade'], ++ }, ++ cf => { ++ description => "The RRD consolidation function", ++ type => 'string', ++ enum => ['AVERAGE', 'MAX'], ++ optional => 1, ++ }, ++ }, ++ }, ++ returns => { ++ type => "array", ++ items => { ++ type => "object", ++ properties => {}, ++ }, ++ }, ++ code => sub { ++ my ($param) = @_; ++ my $nodename = PVE::INotify::nodename(); ++ my $card = $param->{card}; ++ die "invalid card name ++" unless $card =~ /^[a-zA-Z0-9]+$/; ++ return PVE::RRD::create_rrd_data( ++ "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf}, ++ ); ++ }, ++}); + }); + + __PACKAGE__->register_method({ From 2e1cd0e1f56a6bc60e59d216c30ee9704e9062a6 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 8 Jun 2026 21:03:41 +0200 Subject: [PATCH 06/13] Start patching --- .../patches/01-proxmoxlib-js-nagscreen.patch | 0 .../patches/01-nodes-pm-sensors.patch | 15 + .../patches/02-nodes-pm-GPU-RRD-history.patch | 0 .../patches/03-pvemanager-js-sensors.patch | 1013 +++++++++++++++++ 4 files changed, 1028 insertions(+) create mode 100644 src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch create mode 100644 src/PVENodeInfo/patches/01-nodes-pm-sensors.patch create mode 100644 src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch create mode 100644 src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch diff --git a/src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch b/src/NagScreen/patches/01-proxmoxlib-js-nagscreen.patch new file mode 100644 index 0000000..e69de29 diff --git a/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch b/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch new file mode 100644 index 0000000..272e6f8 --- /dev/null +++ b/src/PVENodeInfo/patches/01-nodes-pm-sensors.patch @@ -0,0 +1,15 @@ +--- usr/share/perl5/PVE/API2/Nodes.pm 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/perl5/PVE/API2/Nodes.pm 2026-06-08 20:38:56.988900119 +0200 +@@ -536,6 +536,12 @@ + + $res->{pveversion} = PVE::pvecfg::package() . "/" . PVE::pvecfg::version_text(); + ++ # Collect sensor data from PveMod_SensorInfo ++ use PVE::API2::PVEMod_SensorInfo; ++ $res->{PveMod_JsonSensorInfo} = PVE::API2::PVEMod_SensorInfo::get_sensors_info(); ++ $res->{PveMod_Version} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version(); ++ $res->{PveMod_upsInfo} = PVE::API2::PVEMod_SensorInfo::get_ups_info(); ++ $res->{PveMod_systemInfo} = PVE::API2::PVEMod_SensorInfo::get_system_info(); + my $dinfo = df('/', 1); # output is bytes + + $res->{rootfs} = { diff --git a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch new file mode 100644 index 0000000..e69de29 diff --git a/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch b/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch new file mode 100644 index 0000000..0252331 --- /dev/null +++ b/src/PVENodeInfo/patches/03-pvemanager-js-sensors.patch @@ -0,0 +1,1013 @@ +--- usr/share/pve-manager/js/pvemanagerlib.js 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/pve-manager/js/pvemanagerlib.js 2026-06-07 15:44:28.581927600 +0200 +@@ -50737,175 +50737,181 @@ + me.reload(); + }, + }); +-Ext.define('PVE.node.StatusView', { +- extend: 'Proxmox.panel.StatusView', +- alias: 'widget.pveNodeStatus', +- +- height: 350, +- bodyPadding: '15 5 15 5', +- +- layout: { +- type: 'table', +- columns: 2, +- tableAttrs: { +- style: { +- width: '100%', +- }, +- }, +- }, +- +- defaults: { +- xtype: 'pmxInfoWidget', +- padding: '0 10 5 10', +- }, +- +- items: [ +- { +- itemId: 'cpu', +- iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', +- title: gettext('CPU usage'), +- valueField: 'cpu', +- maxField: 'cpuinfo', +- renderer: Proxmox.Utils.render_node_cpu_usage, +- }, +- { +- itemId: 'wait', +- iconCls: 'fa fa-fw fa-clock-o', +- title: gettext('IO delay'), +- valueField: 'wait', +- rowspan: 2, +- }, +- { +- itemId: 'load', +- iconCls: 'fa fa-fw fa-tasks', +- title: gettext('Load average'), +- printBar: false, +- textField: 'loadavg', +- }, +- { +- xtype: 'box', +- colspan: 2, +- padding: '0 0 20 0', +- }, +- { +- iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', +- itemId: 'memory', +- title: gettext('RAM usage'), +- valueField: 'memory', +- maxField: 'memory', +- warningThreshold: 0.9, +- criticalThreshold: 0.975, +- // TODO: split out ARC usage +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- itemId: 'ksm', +- printBar: false, +- title: gettext('KSM sharing'), +- textField: 'ksm', +- renderer: (record) => Proxmox.Utils.render_size(record.shared), +- padding: '0 10 10 10', +- }, +- { +- iconCls: 'fa fa-fw fa-hdd-o', +- itemId: 'rootfs', +- title: '/ ' + gettext('HD space'), +- valueField: 'rootfs', +- maxField: 'rootfs', +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- iconCls: 'fa fa-fw fa-refresh', +- itemId: 'swap', +- printSize: true, +- title: gettext('SWAP usage'), +- valueField: 'swap', +- maxField: 'swap', +- renderer: Proxmox.Utils.render_node_size_usage, +- }, +- { +- xtype: 'box', +- colspan: 2, +- padding: '0 0 20 0', +- }, +- { +- itemId: 'cpus', +- colspan: 2, +- printBar: false, +- title: gettext('CPU(s)'), +- textField: 'cpuinfo', +- renderer: Proxmox.Utils.render_cpu_model, +- value: '', +- }, +- { +- colspan: 2, +- title: gettext('Kernel Version'), +- printBar: false, +- // 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: '', +- }, +- { +- 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, +- printBar: false, +- title: gettext('Manager Version'), +- textField: 'pveversion', +- value: '', +- }, +- ], +- +- updateTitle: function () { +- var me = this; +- var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); +- me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); +- }, +- +- initComponent: function () { +- let me = this; +- +- let stateProvider = Ext.state.Manager.getProvider(); +- let repoLink = stateProvider.encodeHToken({ +- view: 'server', +- rid: `node/${me.pveSelNode.data.node}`, +- ltab: 'tasks', +- nodetab: 'aptrepositories', +- }); +- +- me.items.push({ +- xtype: 'pmxNodeInfoRepoStatus', +- itemId: 'repositoryStatus', +- product: 'Proxmox VE', +- repoLink: `#${repoLink}`, +- }); +- +- me.callParent(); +- }, ++// Load custom PVE.node.StatusView from external module ++Ext.Loader.loadScript({ ++ url: '/pve2/js/PveMod_PveNodeStatusView.js', ++ onLoad: function() { }, ++ onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); } + }); ++// Ext.define('PVE.node.StatusView', { ++// extend: 'Proxmox.panel.StatusView', ++// alias: 'widget.pveNodeStatus', ++// ++// height: 350, ++// bodyPadding: '15 5 15 5', ++// ++// layout: { ++// type: 'table', ++// columns: 2, ++// tableAttrs: { ++// style: { ++// width: '100%', ++// }, ++// }, ++// }, ++// ++// defaults: { ++// xtype: 'pmxInfoWidget', ++// padding: '0 10 5 10', ++// }, ++// ++// items: [ ++// { ++// itemId: 'cpu', ++// iconCls: 'fa fa-fw pmx-itype-icon-processor pmx-icon', ++// title: gettext('CPU usage'), ++// valueField: 'cpu', ++// maxField: 'cpuinfo', ++// renderer: Proxmox.Utils.render_node_cpu_usage, ++// }, ++// { ++// itemId: 'wait', ++// iconCls: 'fa fa-fw fa-clock-o', ++// title: gettext('IO delay'), ++// valueField: 'wait', ++// rowspan: 2, ++// }, ++// { ++// itemId: 'load', ++// iconCls: 'fa fa-fw fa-tasks', ++// title: gettext('Load average'), ++// printBar: false, ++// textField: 'loadavg', ++// }, ++// { ++// xtype: 'box', ++// colspan: 2, ++// padding: '0 0 20 0', ++// }, ++// { ++// iconCls: 'fa fa-fw pmx-itype-icon-memory pmx-icon', ++// itemId: 'memory', ++// title: gettext('RAM usage'), ++// valueField: 'memory', ++// maxField: 'memory', ++// warningThreshold: 0.9, ++// criticalThreshold: 0.975, ++// // TODO: split out ARC usage ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// itemId: 'ksm', ++// printBar: false, ++// title: gettext('KSM sharing'), ++// textField: 'ksm', ++// renderer: (record) => Proxmox.Utils.render_size(record.shared), ++// padding: '0 10 10 10', ++// }, ++// { ++// iconCls: 'fa fa-fw fa-hdd-o', ++// itemId: 'rootfs', ++// title: '/ ' + gettext('HD space'), ++// valueField: 'rootfs', ++// maxField: 'rootfs', ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// iconCls: 'fa fa-fw fa-refresh', ++// itemId: 'swap', ++// printSize: true, ++// title: gettext('SWAP usage'), ++// valueField: 'swap', ++// maxField: 'swap', ++// renderer: Proxmox.Utils.render_node_size_usage, ++// }, ++// { ++// xtype: 'box', ++// colspan: 2, ++// padding: '0 0 20 0', ++// }, ++// { ++// itemId: 'cpus', ++// colspan: 2, ++// printBar: false, ++// title: gettext('CPU(s)'), ++// textField: 'cpuinfo', ++// renderer: Proxmox.Utils.render_cpu_model, ++// value: '', ++// }, ++// { ++// colspan: 2, ++// title: gettext('Kernel Version'), ++// printBar: false, ++// // 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: '', ++// }, ++// { ++// 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, ++// printBar: false, ++// title: gettext('Manager Version'), ++// textField: 'pveversion', ++// value: '', ++// }, ++// ], ++// ++// updateTitle: function () { ++// var me = this; ++// var uptime = Proxmox.Utils.render_uptime(me.getRecordValue('uptime')); ++// me.setTitle(me.pveSelNode.data.node + ' (' + gettext('Uptime') + ': ' + uptime + ')'); ++// }, ++// ++// initComponent: function () { ++// let me = this; ++// ++// let stateProvider = Ext.state.Manager.getProvider(); ++// let repoLink = stateProvider.encodeHToken({ ++// view: 'server', ++// rid: `node/${me.pveSelNode.data.node}`, ++// ltab: 'tasks', ++// nodetab: 'aptrepositories', ++// }); ++// ++// me.items.push({ ++// xtype: 'pmxNodeInfoRepoStatus', ++// itemId: 'repositoryStatus', ++// product: 'Proxmox VE', ++// repoLink: `#${repoLink}`, ++// }); ++// ++// me.callParent(); ++// }, ++// }); + Ext.define('PVE.node.SubscriptionKeyEdit', { + extend: 'Proxmox.window.Edit', + +@@ -51112,333 +51118,333 @@ + me.callParent(); + }, + }); +-Ext.define('PVE.node.Summary', { +- extend: 'Ext.panel.Panel', +- alias: 'widget.pveNodeSummary', +- +- scrollable: true, +- bodyPadding: 5, +- +- showVersions: function () { +- var me = this; +- +- // Note: we use simply text/html here, because ExtJS grid has problems +- // with cut&paste +- +- var nodename = me.pveSelNode.data.node; +- +- var view = Ext.createWidget('component', { +- autoScroll: true, +- id: 'pkgversions', +- padding: 5, +- style: { +- 'white-space': 'pre', +- 'font-family': 'monospace', +- }, +- }); +- +- var win = Ext.create('Ext.window.Window', { +- title: gettext('Package versions'), +- width: 600, +- height: 600, +- layout: 'fit', +- modal: true, +- items: [view], +- buttons: [ +- { +- xtype: 'button', +- iconCls: 'fa fa-clipboard', +- handler: function (button) { +- window +- .getSelection() +- .selectAllChildren(document.getElementById('pkgversions')); +- document.execCommand('copy'); +- }, +- text: gettext('Copy'), +- }, +- { +- text: gettext('Ok'), +- handler: function () { +- this.up('window').close(); +- }, +- }, +- ], +- }); +- +- Proxmox.Utils.API2Request({ +- waitMsgTarget: me, +- url: `/nodes/${nodename}/apt/versions`, +- method: 'GET', +- failure: function (response, opts) { +- win.close(); +- Ext.Msg.alert(gettext('Error'), response.htmlStatus); +- }, +- success: function (response, opts) { +- win.show(); +- let text = ''; +- Ext.Array.each(response.result.data, function (rec) { +- let version = 'not correctly installed'; +- let pkg = rec.Package; +- if (rec.OldVersion && rec.CurrentState === 'Installed') { +- version = rec.OldVersion; +- } +- if (rec.RunningKernel) { +- text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; +- } else if (rec.ManagerVersion) { +- text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; +- } else { +- text += `${pkg}: ${version}\n`; +- } +- }); +- +- view.update(Ext.htmlEncode(text)); +- }, +- }); +- }, +- +- updateRepositoryStatus: function () { +- let me = this; +- let repoStatus = me.nodeStatus.down('#repositoryStatus'); +- +- let nodename = me.pveSelNode.data.node; +- +- Proxmox.Utils.API2Request({ +- url: `/nodes/${nodename}/apt/repositories`, +- method: 'GET', +- failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), +- success: (response) => +- repoStatus.setRepositoryInfo(response.result.data['standard-repos']), +- }); +- +- Proxmox.Utils.API2Request({ +- url: `/nodes/${nodename}/subscription`, +- method: 'GET', +- failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), +- success: function (response, opts) { +- const res = response.result; +- const subscription = res?.data?.status.toLowerCase() === 'active'; +- repoStatus.setSubscriptionStatus(subscription); +- }, +- }); +- }, +- +- initComponent: function () { +- var me = this; +- +- var nodename = me.pveSelNode.data.node; +- if (!nodename) { +- throw 'no node name specified'; +- } +- +- if (!me.statusStore) { +- throw 'no status storage specified'; +- } +- +- var rstore = me.statusStore; +- +- var version_btn = new Ext.Button({ +- text: gettext('Package versions'), +- handler: function () { +- Proxmox.Utils.checked_command(function () { +- me.showVersions(); +- }); +- }, +- }); +- +- var rrdstore = Ext.create('Proxmox.data.RRDStore', { +- rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', +- model: 'pve-rrd-node', +- }); +- +- let nodeStatus = Ext.create('PVE.node.StatusView', { +- xtype: 'pveNodeStatus', +- rstore: rstore, +- width: 770, +- pveSelNode: me.pveSelNode, +- }); +- +- Ext.apply(me, { +- tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], +- nodeStatus: nodeStatus, +- items: [ +- { +- xtype: 'container', +- itemId: 'itemcontainer', +- layout: 'column', +- minWidth: 700, +- defaults: { +- minHeight: 360, +- padding: 5, +- columnWidth: 1, +- }, +- items: [ +- nodeStatus, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('CPU Usage'), +- fields: ['cpu', 'iowait'], +- fieldTitles: [gettext('CPU usage'), gettext('IO delay')], +- unit: 'percent', +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Server Load'), +- fields: ['loadavg'], +- fieldTitles: [gettext('Load average')], +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Memory usage'), +- fields: [ +- { +- yField: 'memtotal', +- title: gettext('Total'), +- tooltip: { +- trackMouse: true, +- renderer: function (toolTip, record, item) { +- let value = record.get('memtotal'); +- +- if (value === null) { +- toolTip.setHtml(gettext('No Data')); +- } else { +- let total = Proxmox.Utils.format_size(value); +- let time = new Date(record.get('time')); +- +- let avail = record.get('memavailable'); +- let availText = ''; +- if (Ext.isNumeric(avail)) { +- let v = Proxmox.Utils.format_size(avail); +- availText = ` (${gettext('Available')}: ${v})`; +- } +- +- toolTip.setHtml( +- `${gettext('Total')}: ${total}${availText}
${time}`, +- ); +- } +- }, +- }, +- }, +- { +- yField: 'memused', +- title: gettext('Used'), +- tooltip: { +- trackMouse: true, +- renderer: function (toolTip, record, item) { +- let value = record.get('memused'); +- +- if (value === null) { +- toolTip.setHtml(gettext('No Data')); +- } else { +- let total = Proxmox.Utils.format_size(value); +- let time = new Date(record.get('time')); +- +- let arc = record.get('arcsize'); +- let arcText = ''; +- if (Ext.isNumeric(arc) && arc > 1024 * 1024) { +- let v = Proxmox.Utils.format_size(value - arc); +- arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; +- } +- +- toolTip.setHtml( +- `${gettext('Used')}: ${total}${arcText}
${time}`, +- ); +- } +- }, +- }, +- }, +- 'arcsize', +- { +- type: 'line', +- fill: false, +- yField: 'memavailable', +- title: gettext('Available'), +- style: { +- lineWidth: 2.5, +- opacity: 1, +- }, +- }, +- ], +- fieldTitles: [ +- gettext('Total'), +- gettext('Used'), +- gettext('ZFS ARC'), +- gettext('Available'), +- ], +- colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], +- unit: 'bytes', +- powerOfTwo: true, +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Network Traffic'), +- fields: ['netin', 'netout'], +- fieldTitles: [gettext('Incoming'), gettext('Outgoing')], +- store: rrdstore, +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('CPU Pressure Stall'), +- fieldTitles: ['Some'], +- fields: ['pressurecpusome'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('IO Pressure Stall'), +- fieldTitles: ['Some', 'Full'], +- fields: ['pressureiosome', 'pressureiofull'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- { +- xtype: 'proxmoxRRDChart', +- title: gettext('Memory Pressure Stall'), +- fieldTitles: ['Some', 'Full'], +- fields: ['pressurememorysome', 'pressurememoryfull'], +- colors: ['#FFD13E', '#A61120'], +- store: rrdstore, +- unit: 'percent', +- }, +- ], +- listeners: { +- resize: function (panel) { +- Proxmox.Utils.updateColumns(panel); +- }, +- }, +- }, +- ], +- listeners: { +- activate: function () { +- rstore.setInterval(1000); +- rstore.startUpdate(); // just to be sure +- rrdstore.startUpdate(); +- }, +- destroy: function () { +- rstore.setInterval(5000); // don't stop it, it's not ours! +- rrdstore.stopUpdate(); +- }, +- }, +- }); +- +- me.updateRepositoryStatus(); +- +- me.callParent(); +- +- let sp = Ext.state.Manager.getProvider(); +- me.mon(sp, 'statechange', function (provider, key, value) { +- if (key !== 'summarycolumns') { +- return; +- } +- Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); +- }); +- }, +-}); ++// Ext.define('PVE.node.Summary', { ++// extend: 'Ext.panel.Panel', ++// alias: 'widget.pveNodeSummary', ++// ++// scrollable: true, ++// bodyPadding: 5, ++// ++// showVersions: function () { ++// var me = this; ++// ++// // Note: we use simply text/html here, because ExtJS grid has problems ++// // with cut&paste ++// ++// var nodename = me.pveSelNode.data.node; ++// ++// var view = Ext.createWidget('component', { ++// autoScroll: true, ++// id: 'pkgversions', ++// padding: 5, ++// style: { ++// 'white-space': 'pre', ++// 'font-family': 'monospace', ++// }, ++// }); ++// ++// var win = Ext.create('Ext.window.Window', { ++// title: gettext('Package versions'), ++// width: 600, ++// height: 600, ++// layout: 'fit', ++// modal: true, ++// items: [view], ++// buttons: [ ++// { ++// xtype: 'button', ++// iconCls: 'fa fa-clipboard', ++// handler: function (button) { ++// window ++// .getSelection() ++// .selectAllChildren(document.getElementById('pkgversions')); ++// document.execCommand('copy'); ++// }, ++// text: gettext('Copy'), ++// }, ++// { ++// text: gettext('Ok'), ++// handler: function () { ++// this.up('window').close(); ++// }, ++// }, ++// ], ++// }); ++// ++// Proxmox.Utils.API2Request({ ++// waitMsgTarget: me, ++// url: `/nodes/${nodename}/apt/versions`, ++// method: 'GET', ++// failure: function (response, opts) { ++// win.close(); ++// Ext.Msg.alert(gettext('Error'), response.htmlStatus); ++// }, ++// success: function (response, opts) { ++// win.show(); ++// let text = ''; ++// Ext.Array.each(response.result.data, function (rec) { ++// let version = 'not correctly installed'; ++// let pkg = rec.Package; ++// if (rec.OldVersion && rec.CurrentState === 'Installed') { ++// version = rec.OldVersion; ++// } ++// if (rec.RunningKernel) { ++// text += `${pkg}: ${version} (running kernel: ${rec.RunningKernel})\n`; ++// } else if (rec.ManagerVersion) { ++// text += `${pkg}: ${version} (running version: ${rec.ManagerVersion})\n`; ++// } else { ++// text += `${pkg}: ${version}\n`; ++// } ++// }); ++// ++// view.update(Ext.htmlEncode(text)); ++// }, ++// }); ++// }, ++// ++// updateRepositoryStatus: function () { ++// let me = this; ++// let repoStatus = me.nodeStatus.down('#repositoryStatus'); ++// ++// let nodename = me.pveSelNode.data.node; ++// ++// Proxmox.Utils.API2Request({ ++// url: `/nodes/${nodename}/apt/repositories`, ++// method: 'GET', ++// failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), ++// success: (response) => ++// repoStatus.setRepositoryInfo(response.result.data['standard-repos']), ++// }); ++// ++// Proxmox.Utils.API2Request({ ++// url: `/nodes/${nodename}/subscription`, ++// method: 'GET', ++// failure: (response) => Ext.Msg.alert(gettext('Error'), response.htmlStatus), ++// success: function (response, opts) { ++// const res = response.result; ++// const subscription = res?.data?.status.toLowerCase() === 'active'; ++// repoStatus.setSubscriptionStatus(subscription); ++// }, ++// }); ++// }, ++// ++// initComponent: function () { ++// var me = this; ++// ++// var nodename = me.pveSelNode.data.node; ++// if (!nodename) { ++// throw 'no node name specified'; ++// } ++// ++// if (!me.statusStore) { ++// throw 'no status storage specified'; ++// } ++// ++// var rstore = me.statusStore; ++// ++// var version_btn = new Ext.Button({ ++// text: gettext('Package versions'), ++// handler: function () { ++// Proxmox.Utils.checked_command(function () { ++// me.showVersions(); ++// }); ++// }, ++// }); ++// ++// var rrdstore = Ext.create('Proxmox.data.RRDStore', { ++// rrdurl: '/api2/json/nodes/' + nodename + '/rrddata', ++// model: 'pve-rrd-node', ++// }); ++// ++// let nodeStatus = Ext.create('PVE.node.StatusView', { ++// xtype: 'pveNodeStatus', ++// rstore: rstore, ++// width: 770, ++// pveSelNode: me.pveSelNode, ++// }); ++// ++// Ext.apply(me, { ++// tbar: [version_btn, '->', { xtype: 'proxmoxRRDTypeSelector' }], ++// nodeStatus: nodeStatus, ++// items: [ ++// { ++// xtype: 'container', ++// itemId: 'itemcontainer', ++// layout: 'column', ++// minWidth: 700, ++// defaults: { ++// minHeight: 360, ++// padding: 5, ++// columnWidth: 1, ++// }, ++// items: [ ++// nodeStatus, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('CPU Usage'), ++// fields: ['cpu', 'iowait'], ++// fieldTitles: [gettext('CPU usage'), gettext('IO delay')], ++// unit: 'percent', ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Server Load'), ++// fields: ['loadavg'], ++// fieldTitles: [gettext('Load average')], ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Memory usage'), ++// fields: [ ++// { ++// yField: 'memtotal', ++// title: gettext('Total'), ++// tooltip: { ++// trackMouse: true, ++// renderer: function (toolTip, record, item) { ++// let value = record.get('memtotal'); ++// ++// if (value === null) { ++// toolTip.setHtml(gettext('No Data')); ++// } else { ++// let total = Proxmox.Utils.format_size(value); ++// let time = new Date(record.get('time')); ++// ++// let avail = record.get('memavailable'); ++// let availText = ''; ++// if (Ext.isNumeric(avail)) { ++// let v = Proxmox.Utils.format_size(avail); ++// availText = ` (${gettext('Available')}: ${v})`; ++// } ++// ++// toolTip.setHtml( ++// `${gettext('Total')}: ${total}${availText}
${time}`, ++// ); ++// } ++// }, ++// }, ++// }, ++// { ++// yField: 'memused', ++// title: gettext('Used'), ++// tooltip: { ++// trackMouse: true, ++// renderer: function (toolTip, record, item) { ++// let value = record.get('memused'); ++// ++// if (value === null) { ++// toolTip.setHtml(gettext('No Data')); ++// } else { ++// let total = Proxmox.Utils.format_size(value); ++// let time = new Date(record.get('time')); ++// ++// let arc = record.get('arcsize'); ++// let arcText = ''; ++// if (Ext.isNumeric(arc) && arc > 1024 * 1024) { ++// let v = Proxmox.Utils.format_size(value - arc); ++// arcText = ` (${gettext('Without ZFS ARC')}: ${v})`; ++// } ++// ++// toolTip.setHtml( ++// `${gettext('Used')}: ${total}${arcText}
${time}`, ++// ); ++// } ++// }, ++// }, ++// }, ++// 'arcsize', ++// { ++// type: 'line', ++// fill: false, ++// yField: 'memavailable', ++// title: gettext('Available'), ++// style: { ++// lineWidth: 2.5, ++// opacity: 1, ++// }, ++// }, ++// ], ++// fieldTitles: [ ++// gettext('Total'), ++// gettext('Used'), ++// gettext('ZFS ARC'), ++// gettext('Available'), ++// ], ++// colors: ['#94ae0a', '#115fa6', '#24AD9A', '#bbde0d'], ++// unit: 'bytes', ++// powerOfTwo: true, ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Network Traffic'), ++// fields: ['netin', 'netout'], ++// fieldTitles: [gettext('Incoming'), gettext('Outgoing')], ++// store: rrdstore, ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('CPU Pressure Stall'), ++// fieldTitles: ['Some'], ++// fields: ['pressurecpusome'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('IO Pressure Stall'), ++// fieldTitles: ['Some', 'Full'], ++// fields: ['pressureiosome', 'pressureiofull'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// { ++// xtype: 'proxmoxRRDChart', ++// title: gettext('Memory Pressure Stall'), ++// fieldTitles: ['Some', 'Full'], ++// fields: ['pressurememorysome', 'pressurememoryfull'], ++// colors: ['#FFD13E', '#A61120'], ++// store: rrdstore, ++// unit: 'percent', ++// }, ++// ], ++// listeners: { ++// resize: function (panel) { ++// Proxmox.Utils.updateColumns(panel); ++// }, ++// }, ++// }, ++// ], ++// listeners: { ++// activate: function () { ++// rstore.setInterval(1000); ++// rstore.startUpdate(); // just to be sure ++// rrdstore.startUpdate(); ++// }, ++// destroy: function () { ++// rstore.setInterval(5000); // don't stop it, it's not ours! ++// rrdstore.stopUpdate(); ++// }, ++// }, ++// }); ++// ++// me.updateRepositoryStatus(); ++// ++// me.callParent(); ++// ++// let sp = Ext.state.Manager.getProvider(); ++// me.mon(sp, 'statechange', function (provider, key, value) { ++// if (key !== 'summarycolumns') { ++// return; ++// } ++// Proxmox.Utils.updateColumns(me.getComponent('itemcontainer')); ++// }); ++// }, ++// }); + Ext.define('PVE.node.CreateZFS', { + extend: 'Proxmox.window.Edit', + xtype: 'pveCreateZFS', From 96bd5cab8b2b352b4a5ad87a090be4788609eb65 Mon Sep 17 00:00:00 2001 From: Meliox Date: Mon, 8 Jun 2026 22:01:28 +0200 Subject: [PATCH 07/13] rework apply and revert patches to patch --- debian/control | 2 +- debian/pve-mod.conffiles | 2 + debian/pve-mod.postinst | 50 +++- debian/rules | 39 ++- src/NagScreen/nag_screen.conf | 3 + .../patches/01-proxmoxlib-js-nagscreen.patch | 1 + src/NagScreen/patches/patches.list | 4 + src/NagScreen/patches/post-apply.sh | 26 ++ src/NagScreen/patches/post-revert.sh | 23 ++ src/PVENodeInfo/apply-patches.sh | 261 ------------------ src/PVENodeInfo/node_info.conf | 45 +++ src/PVENodeInfo/patches/patches.list | 9 + src/PVENodeInfo/patches/post-apply.sh | 34 +++ src/PVENodeInfo/revert-patches.sh | 69 ----- src/Scripts/apply-patches.sh | 206 ++++++++++++++ src/Scripts/pve-mod-configure | 46 ++- src/Scripts/revert-patches.sh | 101 +++++++ src/pve-mod.conf | 52 +--- 18 files changed, 569 insertions(+), 404 deletions(-) create mode 100644 src/NagScreen/nag_screen.conf create mode 100644 src/NagScreen/patches/patches.list create mode 100644 src/NagScreen/patches/post-apply.sh create mode 100644 src/NagScreen/patches/post-revert.sh delete mode 100644 src/PVENodeInfo/apply-patches.sh create mode 100644 src/PVENodeInfo/node_info.conf create mode 100644 src/PVENodeInfo/patches/patches.list create mode 100644 src/PVENodeInfo/patches/post-apply.sh delete mode 100644 src/PVENodeInfo/revert-patches.sh create mode 100644 src/Scripts/apply-patches.sh create mode 100644 src/Scripts/revert-patches.sh diff --git a/debian/control b/debian/control index d2d26a3..21f48d8 100644 --- a/debian/control +++ b/debian/control @@ -8,7 +8,7 @@ Homepage: https://github.com/Meliox/PVE-mods Package: pve-mod Architecture: all -Depends: ${misc:Depends}, perl, librrds-perl +Depends: ${misc:Depends}, perl, librrds-perl, patch Recommends: lm-sensors, nut-client Suggests: igt-gpu-tools Description: Proxmox VE UI modifications and sensor monitoring diff --git a/debian/pve-mod.conffiles b/debian/pve-mod.conffiles index 0c4d9d3..1db9445 100644 --- a/debian/pve-mod.conffiles +++ b/debian/pve-mod.conffiles @@ -1 +1,3 @@ /etc/pve-mod/pve-mod.conf +/etc/pve-mod/conf.d/node_info.conf +/etc/pve-mod/conf.d/nag_screen.conf diff --git a/debian/pve-mod.postinst b/debian/pve-mod.postinst index 7ca0c76..1140cb4 100644 --- a/debian/pve-mod.postinst +++ b/debian/pve-mod.postinst @@ -1,9 +1,15 @@ #!/bin/bash set -e -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -DEFAULT_CONF="/usr/share/pve-mod/pve-mod.conf.default" -USER_CONF="/etc/pve-mod/pve-mod.conf" +DEFAULT_DIR="/usr/share/pve-mod" +CONF_DIR="/etc/pve-mod" + +# Config file pairs to compare on upgrade: "|" +CONF_PAIRS=" +${DEFAULT_DIR}/pve-mod.conf.default|${CONF_DIR}/pve-mod.conf +${DEFAULT_DIR}/conf.d/node_info.conf.default|${CONF_DIR}/conf.d/node_info.conf +${DEFAULT_DIR}/conf.d/nag_screen.conf.default|${CONF_DIR}/conf.d/nag_screen.conf +" # Extracts "section.key" pairs from an INI file, one per line. _extract_conf_keys() { @@ -17,30 +23,48 @@ _extract_conf_keys() { done < "$file" } -# Warns about keys present in the default config but missing from the user's config. -_check_new_config_keys() { - [ -f "$DEFAULT_CONF" ] || return 0 - [ -f "$USER_CONF" ] || return 0 +# Warns about keys present in a default config but missing from the user's copy. +_check_one_pair() { + local default_conf="$1" user_conf="$2" + [ -f "$default_conf" ] || return 0 + [ -f "$user_conf" ] || return 0 - local new_keys="" section="" key default_val + local new_keys="" section="" key line while IFS= read -r line; do case "$line" in '#'*|'') continue ;; '['*']') section="${line#[}"; section="${section%]}"; continue ;; *'='*) key="${line%%=*}" - if ! _extract_conf_keys "$USER_CONF" | grep -qF "${section}.${key}"; then - new_keys="${new_keys} [${section}] ${line}\n" + if ! _extract_conf_keys "$user_conf" | grep -qF "${section}.${key}"; then + new_keys="${new_keys} [${section}] ${line} (${user_conf})\n" fi ;; esac - done < "$DEFAULT_CONF" + done < "$default_conf" if [ -n "$new_keys" ]; then + printf "%b" "$new_keys" + fi +} + +# Warns about any new config keys across the main config and all per-mod configs. +_check_new_config_keys() { + local all_new="" pair default_conf user_conf + while IFS= read -r pair; do + [ -n "$pair" ] || continue + default_conf="${pair%%|*}" + user_conf="${pair#*|}" + all_new="${all_new}$(_check_one_pair "$default_conf" "$user_conf")" + done < [section.key=value] + +01-proxmoxlib-js-nagscreen.patch diff --git a/src/NagScreen/patches/post-apply.sh b/src/NagScreen/patches/post-apply.sh new file mode 100644 index 0000000..c4f0b96 --- /dev/null +++ b/src/NagScreen/patches/post-apply.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# post-apply hook for the nag_screen mod. +# Runs after nag_screen patches are applied. Provided env: MOD_CONF, STASH_DIR, +# CONFD_DIR. Replaces the minified proxmoxlib with a symlink to the patched +# unminified copy so the browser serves the patched code. The original minified +# file is stashed (it is a non-patch binary replacement, so `patch -R` cannot +# restore it). Exit codes: 0 = no change, 100 = changed, other = error. + +set -u + +PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" +PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" +STASH_DIR="${STASH_DIR:-/var/lib/pve-mod/backup}" + +if [[ ! -L "$PROXMOXLIB_MIN_JS" ]]; then + mkdir -p "$STASH_DIR" + if [[ -f "$PROXMOXLIB_MIN_JS" ]]; then + mv "$PROXMOXLIB_MIN_JS" \ + "$STASH_DIR/proxmoxlib.min.js.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true + fi + ln -sf "$PROXMOXLIB_JS" "$PROXMOXLIB_MIN_JS" + echo "[pve-mod] Symlinked proxmoxlib.min.js -> proxmoxlib.js" + exit 100 +fi + +exit 0 diff --git a/src/NagScreen/patches/post-revert.sh b/src/NagScreen/patches/post-revert.sh new file mode 100644 index 0000000..45c9df3 --- /dev/null +++ b/src/NagScreen/patches/post-revert.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# post-revert hook for the nag_screen mod. +# Runs before nag_screen patches are reverted. Provided env: MOD_CONF, +# STASH_DIR, CONFD_DIR. Removes the min.js symlink and restores the original +# minified file from the stash. Exit codes: 0 = no change, 100 = changed. + +set -u + +PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" +STASH_DIR="${STASH_DIR:-/var/lib/pve-mod/backup}" + +if [[ -L "$PROXMOXLIB_MIN_JS" ]]; then + rm -f "$PROXMOXLIB_MIN_JS" + latest=$(find "$STASH_DIR" -name "proxmoxlib.min.js.*" -type f -printf '%T+ %p\n' 2>/dev/null \ + | sort -r | head -n1 | awk '{print $2}') + if [[ -n "$latest" ]]; then + cp "$latest" "$PROXMOXLIB_MIN_JS" + echo "[pve-mod] Restored proxmoxlib.min.js from stash" + fi + exit 100 +fi + +exit 0 diff --git a/src/PVENodeInfo/apply-patches.sh b/src/PVENodeInfo/apply-patches.sh deleted file mode 100644 index cba11d7..0000000 --- a/src/PVENodeInfo/apply-patches.sh +++ /dev/null @@ -1,261 +0,0 @@ -#!/usr/bin/env bash -# /usr/lib/pve-mod/apply-patches.sh -# -# Applies pve-mod patches to Proxmox VE system files. -# Reads /etc/pve-mod/pve-mod.conf to determine which modules are enabled. -# Idempotent: safe to call multiple times (e.g. from apt hook after PVE upgrade). - -CONF_FILE="/etc/pve-mod/pve-mod.conf" -BACKUP_DIR="/var/lib/pve-mod/backup" -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -PVE_MANAGER_JS="/usr/share/pve-manager/js/pvemanagerlib.js" -PVE_MOD_JS="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" -PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" -PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" -GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu" - -info() { echo "[pve-mod] $*"; } -warn() { echo "[pve-mod] WARNING: $*" >&2; } - -# Read one value from the INI config file; prints $default if not found. -read_conf() { - local section="$1" key="$2" default="${3:-0}" - if [[ ! -f "$CONF_FILE" ]]; then - echo "$default" - return - fi - local val - val=$(awk -F= -v sec="[$section]" -v k="$key" ' - /^\[/ { in_sec = ($0 == sec) } - in_sec && /^[^#=]+=/ { - gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) - if ($1 == k) { - gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2) - print $2; exit - } - } - ' "$CONF_FILE") - echo "${val:-$default}" -} - -backup_file() { - local src="$1" - [[ -f "$src" ]] || return 0 - local name ts - name=$(basename "$src") - ts=$(date +%Y%m%d_%H%M%S) - mkdir -p "$BACKUP_DIR" - cp "$src" "$BACKUP_DIR/${name}.${ts}" - info "Backed up $(basename "$src") → $BACKUP_DIR/${name}.${ts}" -} - -# ── Read enabled modules ────────────────────────────────────────────────────── -NODE_INFO=$(read_conf modules node_info 0) -NAG_SCREEN=$(read_conf modules nag_screen 0) -GPU_HISTORY=$(read_conf gpu gpu_history 0) - -CHANGED=false - -# ── node-info: Nodes.pm ─────────────────────────────────────────────────────── -if [[ "$NODE_INFO" == "1" ]]; then - if ! grep -qF "use PVE::API2::PVEMod_SensorInfo" "$NODES_PM" 2>/dev/null; then - backup_file "$NODES_PM" - python3 - "$NODES_PM" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'use PVE::API2::PVEMod_SensorInfo' in content: - sys.exit(0) - -m = re.search(r'^([ \t]*)my \$dinfo = df\(\'\/\', 1\);', content, re.MULTILINE) -if not m: - print("ERROR: Anchor 'my $dinfo = df' not found in Nodes.pm", file=sys.stderr) - sys.exit(1) - -indent = m.group(1) -insertion = ( - f"{indent}# Collect sensor data from PveMod_SensorInfo\n" - f"{indent}use PVE::API2::PVEMod_SensorInfo;\n" - f"{indent}$res->{{PveMod_JsonSensorInfo}} = PVE::API2::PVEMod_SensorInfo::get_sensors_info();\n" - f"{indent}$res->{{PveMod_Version}} = PVE::API2::PVEMod_SensorInfo::get_pve_mod_version();\n" - f"{indent}$res->{{PveMod_graphicsInfo}} = PVE::API2::PVEMod_SensorInfo::get_graphics_info();\n" - f"{indent}$res->{{PveMod_upsInfo}} = PVE::API2::PVEMod_SensorInfo::get_ups_info();\n" - f"{indent}$res->{{PveMod_systemInfo}} = PVE::API2::PVEMod_SensorInfo::get_system_information();\n" -) -content = content[:m.start()] + insertion + content[m.start():] -open(path, 'w').write(content) -PYEOF - info "Patched Nodes.pm" - CHANGED=true - fi - - # ── node-info: pvemanagerlib.js ─────────────────────────────────────────── - if ! grep -qF "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_JS" 2>/dev/null; then - backup_file "$PVE_MANAGER_JS" - python3 - "$PVE_MANAGER_JS" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'PveMod_PveNodeStatusView.js' in content: - sys.exit(0) - -# Comment out original StatusView definition -content = re.sub( - r"(?m)^(Ext\.define\('PVE\.node\.StatusView',.*?^}\);)", - lambda m: '\n'.join('// ' + line for line in m.group(1).split('\n')), - content, flags=re.DOTALL -) - -# Comment out original Summary definition -content = re.sub( - r"(?m)^(Ext\.define\('PVE\.node\.Summary',.*?^}\);)", - lambda m: '\n'.join('// ' + line for line in m.group(1).split('\n')), - content, flags=re.DOTALL -) - -# Insert dynamic loader before the now-commented StatusView block -loader = ( - "// Load custom PVE.node.StatusView from external module\n" - "Ext.Loader.loadScript({\n" - " url: '/pve2/js/PveMod_PveNodeStatusView.js',\n" - " onLoad: function() { },\n" - " onError: function() { console.error('Failed to load PveMod_PveNodeStatusView.js'); }\n" - "});\n" -) -content = re.sub( - r"(// Ext\.define\('PVE\.node\.StatusView',)", - loader + r'\1', - content, count=1 -) -open(path, 'w').write(content) -PYEOF - info "Patched pvemanagerlib.js" - CHANGED=true - fi -fi - -# ── node-info: GPU RRD history ──────────────────────────────────────────────── -if [[ "$NODE_INFO" == "1" && "$GPU_HISTORY" == "1" ]]; then - if ! grep -qF "gpurrddata" "$NODES_PM" 2>/dev/null; then - # Register method in the node sub-path list - sed -i "s/{ name => 'rrddata' },/{ name => 'rrddata' },\n { name => 'gpurrddata' },/" "$NODES_PM" - - # Append gpurrddata method definition after the rrddata code block - python3 - "$NODES_PM" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if 'gpurrddata' in content: - sys.exit(0) - -method = r""" -__PACKAGE__->register_method({ - name => 'gpurrddata', - path => 'gpurrddata', - method => 'GET', - protected => 1, - proxyto => 'node', - permissions => { - check => ['perm', '/nodes/{node}', ['Sys.Audit']], - }, - description => "Read GPU RRD statistics", - parameters => { - additionalProperties => 0, - properties => { - node => get_standard_option('pve-node'), - card => { - description => "The GPU card identifier (e.g. card0, nvidia0).", - type => 'string', - pattern => '[a-zA-Z0-9]+', - }, - timeframe => { - description => "Specify the time frame you are interested in.", - type => 'string', - enum => ['hour', 'day', 'week', 'month', 'year', 'decade'], - }, - cf => { - description => "The RRD consolidation function", - type => 'string', - enum => ['AVERAGE', 'MAX'], - optional => 1, - }, - }, - }, - returns => { - type => "array", - items => { - type => "object", - properties => {}, - }, - }, - code => sub { - my ($param) = @_; - my $nodename = PVE::INotify::nodename(); - my $card = $param->{card}; - die "invalid card name\n" unless $card =~ /^[a-zA-Z0-9]+$/; - return PVE::RRD::create_rrd_data( - "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf}, - ); - }, -}); -""" - -# Insert before the final 1; at end of file -content = re.sub(r'\n1;\s*$', method + '\n1;\n', content) -open(path, 'w').write(content) -PYEOF - - mkdir -p "$GPU_RRD_DIR" - chown www-data:www-data "$GPU_RRD_DIR" 2>/dev/null || true - info "Installed gpurrddata API endpoint" - CHANGED=true - fi -fi - -# ── nag-screen: proxmoxlib.js ───────────────────────────────────────────────── -if [[ "$NAG_SCREEN" == "1" ]]; then - if ! grep -qF "// disable subscription nag screen" "$PROXMOXLIB_JS" 2>/dev/null; then - backup_file "$PROXMOXLIB_JS" - python3 - "$PROXMOXLIB_JS" <<'PYEOF' -import sys, re - -path = sys.argv[1] -content = open(path).read() - -if '// disable subscription nag screen' in content: - sys.exit(0) - -m = re.search(r'(checked_command:\s*function\s*\(orig_cmd\)\s*\{)', content) -if not m: - print("ERROR: checked_command pattern not found in proxmoxlib.js", file=sys.stderr) - sys.exit(1) - -insert = "\n\t\t\t// disable subscription nag screen\n\t\t\torig_cmd();\n\t\t\treturn;" -pos = m.end() -content = content[:pos] + insert + content[pos:] -open(path, 'w').write(content) -PYEOF - info "Patched proxmoxlib.js (nag screen)" - CHANGED=true - fi - - if [[ ! -L "$PROXMOXLIB_MIN_JS" ]]; then - backup_file "$PROXMOXLIB_MIN_JS" - mv "$PROXMOXLIB_MIN_JS" "$BACKUP_DIR/proxmoxlib.min.js.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true - ln -sf "$PROXMOXLIB_JS" "$PROXMOXLIB_MIN_JS" - info "Symlinked proxmoxlib.min.js → proxmoxlib.js" - CHANGED=true - fi -fi - -# ── restart pveproxy if anything changed ────────────────────────────────────── -if [[ "$CHANGED" == "true" ]]; then - info "Restarting pveproxy..." - systemctl restart pveproxy 2>/dev/null || true -fi diff --git a/src/PVENodeInfo/node_info.conf b/src/PVENodeInfo/node_info.conf new file mode 100644 index 0000000..13c4ed4 --- /dev/null +++ b/src/PVENodeInfo/node_info.conf @@ -0,0 +1,45 @@ +# pve-mod :: node_info mod configuration +# Settings for the node-info / sensor-monitoring mod. +# Managed by pve-mod-configure. Re-run to update. + +[gpu] +intel_enabled=0 +nvidia_enabled=0 +amd_enabled=0 +gpu_history=0 + +[lm_sensors] +enabled=0 +enable_cpu=0 +cpu_temp_target=Core +enable_ram_temp=0 +enable_hdd_temp=0 +enable_nvme_temp=0 +enable_fan_speed=0 +display_zero_speed_fans=0 +temp_unit=C + +[ups] +enabled=0 +device_name=ups@localhost + +[system_info] +enabled=0 +type=1 + +# Debug mode: when a collector's mode is 1, the real tool is not required. +# Data is read from the file path instead. Useful for development/testing. +[debug] +lm_sensors_mode=0 +lm_sensors_output_file=/tmp/sensors-output.json +intel_mode=0 +intel_devices_file=/tmp/intel-gpu-devices.json +nvidia_mode=0 +nvidia_devices_file=/tmp/nvidia-smi-devices.csv +nvidia_output_file=/tmp/nvidia-smi-output.csv +amd_mode=0 +amd_devices_file=/tmp/amd-gpu-devices.json +ups_mode=0 +ups_output_file=/tmp/ups-output.json +log_enabled=0 +log_file=/tmp/pve-mod-debug.log diff --git a/src/PVENodeInfo/patches/patches.list b/src/PVENodeInfo/patches/patches.list new file mode 100644 index 0000000..9049189 --- /dev/null +++ b/src/PVENodeInfo/patches/patches.list @@ -0,0 +1,9 @@ +# pve-mod :: node_info patch manifest +# Format: [section.key=value] +# Patches are applied top-to-bottom. An optional condition (read from this mod's +# conf.d file, /etc/pve-mod/conf.d/node_info.conf) gates a patch; it is applied +# only when the key equals the given value. + +01-nodes-pm-sensors.patch +02-nodes-pm-GPU-RRD-history.patch gpu.gpu_history=1 +03-pvemanager-js-sensors.patch diff --git a/src/PVENodeInfo/patches/post-apply.sh b/src/PVENodeInfo/patches/post-apply.sh new file mode 100644 index 0000000..62b3b5e --- /dev/null +++ b/src/PVENodeInfo/patches/post-apply.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# post-apply hook for the node_info mod. +# Runs after node_info patches are applied. Provided env: MOD_CONF, STASH_DIR, +# CONFD_DIR. Creates the GPU RRD storage directory when GPU history is enabled. +# Exit codes: 0 = no change, 100 = changed (restart pveproxy), other = error. + +set -u + +GPU_RRD_DIR="/var/lib/rrdcached/db/pve-mod-gpu" + +read_conf() { + local file="$1" section="$2" key="$3" default="${4:-0}" + [[ -f "$file" ]] || { echo "$default"; return; } + local val + val=$(awk -F= -v sec="[$section]" -v k="$key" ' + /^\[/ { in_sec = ($0 == sec) } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + if ($1 == k) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit } + } + ' "$file") + echo "${val:-$default}" +} + +gpu_history="$(read_conf "${MOD_CONF:-/etc/pve-mod/conf.d/node_info.conf}" gpu gpu_history 0)" + +if [[ "$gpu_history" == "1" && ! -d "$GPU_RRD_DIR" ]]; then + mkdir -p "$GPU_RRD_DIR" + chown www-data:www-data "$GPU_RRD_DIR" 2>/dev/null || true + echo "[pve-mod] Created GPU RRD directory: $GPU_RRD_DIR" + exit 100 +fi + +exit 0 diff --git a/src/PVENodeInfo/revert-patches.sh b/src/PVENodeInfo/revert-patches.sh deleted file mode 100644 index d86097a..0000000 --- a/src/PVENodeInfo/revert-patches.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# /usr/lib/pve-mod/revert-patches.sh -# -# Reverts all patches applied by apply-patches.sh. -# Restores PVE system files from backups in /var/lib/pve-mod/backup/. -# Called by prerm before package files are removed. - -BACKUP_DIR="/var/lib/pve-mod/backup" -NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" -PVE_MANAGER_JS="/usr/share/pve-manager/js/pvemanagerlib.js" -PVE_MOD_JS="/usr/share/pve-manager/js/PveMod_PveNodeStatusView.js" -PROXMOXLIB_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js" -PROXMOXLIB_MIN_JS="/usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js" - -info() { echo "[pve-mod] $*"; } -warn() { echo "[pve-mod] WARNING: $*" >&2; } - -restore_latest() { - local name="$1" target="$2" - local latest - latest=$(find "$BACKUP_DIR" -name "${name}.*" -type f -printf '%T+ %p\n' 2>/dev/null \ - | sort -r | head -n1 | awk '{print $2}') - if [[ -n "$latest" ]]; then - cp "$latest" "$target" - info "Restored $(basename "$target") from backup" - else - warn "No backup found for ${name}; $target not restored" - fi -} - -CHANGED=false - -# ── node-info: Nodes.pm ─────────────────────────────────────────────────────── -if grep -qF "use PVE::API2::PVEMod_SensorInfo" "$NODES_PM" 2>/dev/null; then - restore_latest "Nodes.pm" "$NODES_PM" - CHANGED=true -fi - -# ── node-info: pvemanagerlib.js ─────────────────────────────────────────────── -if grep -qF "PveMod_PveNodeStatusView.js" "$PVE_MANAGER_JS" 2>/dev/null; then - restore_latest "pvemanagerlib.js" "$PVE_MANAGER_JS" - CHANGED=true -fi - -# ── node-info: JS module file ───────────────────────────────────────────────── -if [[ -f "$PVE_MOD_JS" ]]; then - rm -f "$PVE_MOD_JS" - info "Removed PveMod_PveNodeStatusView.js" - CHANGED=true -fi - -# ── nag-screen: proxmoxlib.min.js symlink ──────────────────────────────────── -if [[ -L "$PROXMOXLIB_MIN_JS" ]]; then - rm -f "$PROXMOXLIB_MIN_JS" - restore_latest "proxmoxlib.min.js" "$PROXMOXLIB_MIN_JS" - CHANGED=true -fi - -# ── nag-screen: proxmoxlib.js ──────────────────────────────────────────────── -if grep -qF "// disable subscription nag screen" "$PROXMOXLIB_JS" 2>/dev/null; then - restore_latest "proxmoxlib.js" "$PROXMOXLIB_JS" - CHANGED=true -fi - -# ── restart pveproxy if anything changed ───────────────────────────────────── -if [[ "$CHANGED" == "true" ]]; then - info "Restarting pveproxy..." - systemctl restart pveproxy 2>/dev/null || true -fi diff --git a/src/Scripts/apply-patches.sh b/src/Scripts/apply-patches.sh new file mode 100644 index 0000000..e6dc887 --- /dev/null +++ b/src/Scripts/apply-patches.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# /usr/lib/pve-mod/apply-patches.sh +# +# Generic patch applier for pve-mod. +# +# Reads /etc/pve-mod/pve-mod.conf [modules] to learn which mods are enabled, +# then applies each enabled mod's patches from /usr/lib/pve-mod/patches//. +# +# Patch convention: every .patch uses a/ b/ headers and is applied +# with `patch -p1 -F0 -d /` (zero fuzz: line offsets tolerated, fuzzy context +# matching disabled). Each mod folder contains a 'patches.list' manifest and +# optional 'post-apply.sh' / 'post-revert.sh' hooks. +# +# Per-mod atomicity: before applying, every active patch of a mod is dry-run. +# If any one cannot be applied (or is already partially/broken-applied), the +# whole mod is reverted to a clean state and reported as failed - a mod is +# never left half-applied. +# +# Idempotent: a patch that is already applied is detected (reverse dry-run) and +# skipped, so this script is safe to run repeatedly (e.g. from the dpkg trigger +# after a pve-manager upgrade). +# +# Hook exit-code convention: a hook returns 0 (no change), 100 (made a change, +# triggers a pveproxy restart), or any other code (error). + +set -u + +# Root paths (overridable via environment, mainly for testing). +PVE_MOD_ROOT="${PVE_MOD_ROOT:-/}" +MAIN_CONF="${PVE_MOD_MAIN_CONF:-/etc/pve-mod/pve-mod.conf}" +CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" +PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" +# Storage for non-patch replaced files (e.g. nag-screen's minified proxmoxlib). +# Patched text files need no backups - `patch -R` reverts them. +STASH_DIR="${PVE_MOD_STASH_DIR:-/var/lib/pve-mod/backup}" + +info() { echo "[pve-mod] $*"; } +warn() { echo "[pve-mod] WARNING: $*" >&2; } + +# read_conf
[default] +# Prints one value from an INI file, or the default if absent. +read_conf() { + local file="$1" section="$2" key="$3" default="${4:-0}" + if [[ ! -f "$file" ]]; then + echo "$default" + return + fi + local val + val=$(awk -F= -v sec="[$section]" -v k="$key" ' + /^\[/ { in_sec = ($0 == sec) } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + if ($1 == k) { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2) + print $2; exit + } + } + ' "$file") + echo "${val:-$default}" +} + +# List the keys of the [modules] section in the main config, one per line. +list_modules() { + [[ -f "$MAIN_CONF" ]] || return 0 + awk -F= ' + /^\[/ { in_sec = ($0 == "[modules]") } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + print $1 + } + ' "$MAIN_CONF" +} + +# Patch-state helpers (all use zero fuzz for deterministic detection). +_dry_forward() { patch -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$1" >/dev/null 2>&1; } +_dry_reverse() { patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$1" >/dev/null 2>&1; } +is_applied() { _dry_reverse "$1"; } +can_apply() { _dry_forward "$1"; } +do_apply() { patch -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$1"; } +do_revert() { patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$1"; } + +CHANGED=false +FAILED=false + +# Run a mod hook honouring the exit-code convention. Sets CHANGED / FAILED. +run_hook() { + local hook="$1" mod_conf="$2" + [[ -x "$hook" ]] || return 0 + STASH_DIR="$STASH_DIR" MOD_CONF="$mod_conf" CONFD_DIR="$CONFD_DIR" "$hook" + local rc=$? + case "$rc" in + 0) ;; + 100) CHANGED=true ;; + *) warn " hook $(basename "$hook") reported an error (exit $rc)"; FAILED=true ;; + esac +} + +# ── main ────────────────────────────────────────────────────────────────────── +if ! command -v patch >/dev/null 2>&1; then + warn "'patch' command not found; cannot apply mods. Install the 'patch' package." + exit 1 +fi + +for mod in $(list_modules); do + [[ "$(read_conf "$MAIN_CONF" modules "$mod" 0)" == "1" ]] || continue + + mod_dir="$PATCHES_DIR/$mod" + manifest="$mod_dir/patches.list" + mod_conf="$CONFD_DIR/$mod.conf" + + if [[ ! -f "$manifest" ]]; then + warn "No patch manifest for enabled mod '$mod' ($manifest); skipping." + FAILED=true + continue + fi + + info "Checking mod: $mod" + + # Build the list of active patch files (those whose condition is met). + active=() + preflight_ok=true + while IFS= read -r line; do + # Strip comments and surrounding whitespace; skip blanks. + line="${line%%#*}" + line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + [[ -z "$line" ]] && continue + + # Format: [section.key=value] + patch_name="${line%%[[:space:]]*}" + condition="" + if [[ "$line" == *[[:space:]]* ]]; then + condition="$(echo "${line#"$patch_name"}" | sed -E 's/^[[:space:]]+//')" + fi + + # Evaluate optional condition against the mod's conf.d file. + if [[ -n "$condition" ]]; then + local_key="${condition%%=*}" + want="${condition#*=}" + sect="${local_key%%.*}" + ckey="${local_key#*.}" + if [[ "$(read_conf "$mod_conf" "$sect" "$ckey" 0)" != "$want" ]]; then + info " skip $patch_name (condition $condition not met)" + continue + fi + fi + + patch_file="$mod_dir/$patch_name" + if [[ ! -f "$patch_file" ]]; then + warn " patch file missing: $patch_file" + preflight_ok=false + continue + fi + active+=("$patch_file") + done < "$manifest" + + # Preflight dry-run: every active patch must be already applied or cleanly + # appliable. Otherwise the mod cannot be installed atomically. + to_apply=() + if [[ "$preflight_ok" == "true" ]]; then + for pf in "${active[@]}"; do + if is_applied "$pf"; then + continue + elif can_apply "$pf"; then + to_apply+=("$pf") + else + warn " $(basename "$pf") does not apply cleanly" + preflight_ok=false + break + fi + done + fi + + # If preflight failed, revert the whole mod back to a clean state so it is + # never left half-applied. + if [[ "$preflight_ok" != "true" ]]; then + warn "Mod '$mod': preflight failed - reverting mod to clean state." + for (( i=${#active[@]}-1 ; i>=0 ; i-- )); do + pf="${active[$i]}" + if is_applied "$pf"; then + do_revert "$pf" && { info " reverted $(basename "$pf")"; CHANGED=true; } + fi + done + run_hook "$mod_dir/post-revert.sh" "$mod_conf" + FAILED=true + continue + fi + + # Apply the outstanding patches. + if [[ ${#to_apply[@]} -gt 0 ]]; then + for pf in "${to_apply[@]}"; do + do_apply "$pf" && { info " applied $(basename "$pf")"; CHANGED=true; } + done + else + info " already up to date" + fi + + run_hook "$mod_dir/post-apply.sh" "$mod_conf" +done + +if [[ "$CHANGED" == "true" ]]; then + info "Restarting pveproxy..." + systemctl restart pveproxy 2>/dev/null || true +fi + +[[ "$FAILED" == "true" ]] && exit 1 +exit 0 diff --git a/src/Scripts/pve-mod-configure b/src/Scripts/pve-mod-configure index 9911fb5..689c0db 100644 --- a/src/Scripts/pve-mod-configure +++ b/src/Scripts/pve-mod-configure @@ -7,6 +7,9 @@ set -euo pipefail CONF_FILE="/etc/pve-mod/pve-mod.conf" +CONFD_DIR="/etc/pve-mod/conf.d" +NODE_INFO_CONF="${CONFD_DIR}/node_info.conf" +NAG_SCREEN_CONF="${CONFD_DIR}/nag_screen.conf" APPLY_PATCHES="/usr/lib/pve-mod/apply-patches.sh" NODES_PM="/usr/share/perl5/PVE/API2/Nodes.pm" @@ -25,7 +28,7 @@ ask() { bool() { [[ "$1" == true ]] && echo 1 || echo 0; } _load_conf_debug() { - [[ -f "$CONF_FILE" ]] || return 0 + [[ -f "$NODE_INFO_CONF" ]] || return 0 local in_debug=0 line key val while IFS= read -r line; do case "$line" in @@ -50,7 +53,7 @@ _load_conf_debug() { log_enabled) DEBUG_LOG="$val" ;; log_file) DEBUG_LOG_FILE="$val" ;; esac - done < "$CONF_FILE" + done < "$NODE_INFO_CONF" } #endregion helpers @@ -346,15 +349,33 @@ configure_node_info() { #region write config write_config() { - mkdir -p "$(dirname "$CONF_FILE")" + mkdir -p "$(dirname "$CONF_FILE")" "$CONFD_DIR" + + # Main config: which mods are enabled + global trigger/service settings. cat > "$CONF_FILE" <.conf [modules] node_info=${MOD_NODE_INFO} nag_screen=${MOD_NAG_SCREEN} +[pve_trigger] +enabled=${PVE_TRIGGER_ENABLED} + +[service] +mode=embedded +EOF + info "Main configuration saved to $CONF_FILE" + + # node_info mod config. + cat > "$NODE_INFO_CONF" < "$NAG_SCREEN_CONF" </ (regardless +# of whether the mod is currently enabled), using `patch -R -p1 -F0 -d /`. +# Patches are reverted in reverse manifest order. Patched text files need no +# backups - `patch -R` restores them exactly. Non-patch actions (e.g. the +# nag-screen min.js symlink) are undone by each mod's 'post-revert.sh' hook. +# +# Called by prerm before the package's files are removed. +# +# Hook exit-code convention: a hook returns 0 (no change), 100 (made a change, +# triggers a pveproxy restart), or any other code (error). + +set -u + +# Root paths (overridable via environment, mainly for testing). +PVE_MOD_ROOT="${PVE_MOD_ROOT:-/}" +CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" +PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" +# Storage for non-patch replaced files (e.g. nag-screen's minified proxmoxlib). +STASH_DIR="${PVE_MOD_STASH_DIR:-/var/lib/pve-mod/backup}" + +info() { echo "[pve-mod] $*"; } +warn() { echo "[pve-mod] WARNING: $*" >&2; } + +# Revert a single patch. Returns 0 if a change was made, 1 otherwise. +revert_one_patch() { + local patch="$1" + # Not applied? (a clean forward apply means the change is absent) + if patch -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$patch" >/dev/null 2>&1; then + return 1 + fi + if patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f --dry-run -s < "$patch" >/dev/null 2>&1; then + patch -R -p1 -F0 -d "$PVE_MOD_ROOT" -f -s < "$patch" + return 0 + fi + warn " $(basename "$patch") could not be reverted cleanly; manual cleanup may be needed" + return 1 +} + +# ── main ────────────────────────────────────────────────────────────────────── +if ! command -v patch >/dev/null 2>&1; then + warn "'patch' command not found; cannot revert mods." + exit 0 +fi + +[[ -d "$PATCHES_DIR" ]] || exit 0 + +CHANGED=false + +for mod_dir in "$PATCHES_DIR"/*/; do + [[ -d "$mod_dir" ]] || continue + mod="$(basename "$mod_dir")" + manifest="$mod_dir/patches.list" + mod_conf="$CONFD_DIR/$mod.conf" + + # Run the post-revert hook first (undo non-patch actions such as symlinks). + hook="$mod_dir/post-revert.sh" + if [[ -x "$hook" ]]; then + info "Running post-revert hook for $mod" + STASH_DIR="$STASH_DIR" MOD_CONF="$mod_conf" CONFD_DIR="$CONFD_DIR" "$hook" + rc=$? + case "$rc" in + 0) ;; + 100) CHANGED=true ;; + *) warn "post-revert hook for $mod reported an error (exit $rc)" ;; + esac + fi + + [[ -f "$manifest" ]] || continue + info "Reverting mod: $mod" + + # Collect patch names (ignore conditions and comments), then reverse order. + mapfile -t patches < <( + while IFS= read -r line; do + line="${line%%#*}" + line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + [[ -z "$line" ]] && continue + echo "${line%%[[:space:]]*}" + done < "$manifest" + ) + + for (( i=${#patches[@]}-1 ; i>=0 ; i-- )); do + patch_name="${patches[$i]}" + patch_file="$mod_dir/$patch_name" + [[ -f "$patch_file" ]] || continue + if revert_one_patch "$patch_file"; then + info " reverted $patch_name" + CHANGED=true + fi + done +done + +if [[ "$CHANGED" == "true" ]]; then + info "Restarting pveproxy..." + systemctl restart pveproxy 2>/dev/null || true +fi +exit 0 diff --git a/src/pve-mod.conf b/src/pve-mod.conf index 40c3022..2757128 100644 --- a/src/pve-mod.conf +++ b/src/pve-mod.conf @@ -1,55 +1,19 @@ -# pve-mod configuration file +# pve-mod main configuration file # Run 'pve-mod-configure' to set values interactively. -# All flags are 0 (disabled) by default; pve-mod-configure enables them. +# +# This file only declares which mods are enabled. Each mod keeps its own +# settings in /etc/pve-mod/conf.d/.conf +# +# When a mod flag below is 1, its patches are (re)applied on install and, +# if [pve_trigger] enabled=1, after every pve-manager upgrade. [modules] node_info=0 nag_screen=0 -[gpu] -intel_enabled=0 -nvidia_enabled=0 -amd_enabled=0 -gpu_history=0 - -[lm_sensors] -enabled=0 -enable_cpu=0 -cpu_temp_target=Core -enable_ram_temp=0 -enable_hdd_temp=0 -enable_nvme_temp=0 -enable_fan_speed=0 -display_zero_speed_fans=0 -temp_unit=C - -[ups] -enabled=0 -device_name=ups@localhost - -[system_info] -enabled=0 -type=1 - +# Re-apply patches automatically after a pve-manager upgrade (dpkg trigger). [pve_trigger] enabled=0 [service] mode=embedded - -# Debug mode: when a mode is 1, the real tool is not required. -# Data is read from the file path instead. Useful for development/testing. -[debug] -lm_sensors_mode=0 -lm_sensors_output_file=/tmp/sensors-output.json -intel_mode=0 -intel_devices_file=/tmp/intel-gpu-devices.json -nvidia_mode=0 -nvidia_devices_file=/tmp/nvidia-smi-devices.csv -nvidia_output_file=/tmp/nvidia-smi-output.csv -amd_mode=0 -amd_devices_file=/tmp/amd-gpu-devices.json -ups_mode=0 -ups_output_file=/tmp/ups-output.json -log_enabled=0 -log_file=/tmp/pve-mod-debug.log From 57ef8387d38c77ac1ff29dac880e8353498a6990 Mon Sep 17 00:00:00 2001 From: Meliox Date: Tue, 9 Jun 2026 22:00:45 +0200 Subject: [PATCH 08/13] don't let a patch failure abort apt install --- debian/pve-mod.postinst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/debian/pve-mod.postinst b/debian/pve-mod.postinst index 1140cb4..2a33baa 100644 --- a/debian/pve-mod.postinst +++ b/debian/pve-mod.postinst @@ -71,8 +71,11 @@ EOF case "$1" in configure) - # Apply patches for any enabled modules. Fails loudly on error. - /usr/lib/pve-mod/apply-patches.sh + # Apply patches for any enabled modules. Don't abort the install if a + # patch fails — warn instead so dpkg doesn't end half-configured. + if ! /usr/lib/pve-mod/apply-patches.sh; then + echo "pve-mod: WARNING: Some patches failed to apply. Run 'pve-mod-configure' to retry." >&2 + fi if [ -z "$2" ]; then echo "" From 938be23a58e19e3d9e26d876f2c71b80c2c07215 Mon Sep 17 00:00:00 2001 From: Meliox Date: Tue, 9 Jun 2026 22:07:24 +0200 Subject: [PATCH 09/13] roll the mod back if its post-apply hook errors --- src/Scripts/apply-patches.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Scripts/apply-patches.sh b/src/Scripts/apply-patches.sh index e6dc887..5224ae8 100644 --- a/src/Scripts/apply-patches.sh +++ b/src/Scripts/apply-patches.sh @@ -91,8 +91,9 @@ run_hook() { case "$rc" in 0) ;; 100) CHANGED=true ;; - *) warn " hook $(basename "$hook") reported an error (exit $rc)"; FAILED=true ;; + *) warn " hook $(basename "$hook") reported an error (exit $rc)"; FAILED=true; return 1 ;; esac + return 0 } # ── main ────────────────────────────────────────────────────────────────────── @@ -194,7 +195,20 @@ for mod in $(list_modules); do info " already up to date" fi - run_hook "$mod_dir/post-apply.sh" "$mod_conf" + # Run the post-apply hook. If it errors, roll the mod back so it is never + # left half-applied (matches the preflight atomicity contract). + if ! run_hook "$mod_dir/post-apply.sh" "$mod_conf"; then + warn "Mod '$mod': post-apply hook failed - reverting mod to clean state." + run_hook "$mod_dir/post-revert.sh" "$mod_conf" + for (( i=${#active[@]}-1 ; i>=0 ; i-- )); do + pf="${active[$i]}" + if is_applied "$pf"; then + do_revert "$pf" && { info " reverted $(basename "$pf")"; CHANGED=true; } + fi + done + FAILED=true + continue + fi done if [[ "$CHANGED" == "true" ]]; then From 1ee8077f25527dc8aa871f22093df58d2d040a42 Mon Sep 17 00:00:00 2001 From: Meliox Date: Tue, 9 Jun 2026 22:34:57 +0200 Subject: [PATCH 10/13] add patch for gpu history --- .../patches/02-nodes-pm-GPU-RRD-history.patch | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch index e69de29..cad2a6a 100644 --- a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch +++ b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch @@ -0,0 +1,119 @@ +--- usr/share/perl5/PVE/API2/Nodes.pm 2026-05-24 02:05:58.000000000 +0200 ++++ /usr/share/perl5/PVE/API2/Nodes.pm 2026-06-09 22:30:12.187854456 +0200 +@@ -251,6 +251,7 @@ + { name => 'report' }, + { name => 'rrd' }, # fixme: remove? + { name => 'rrddata' }, ++ { name => 'gpurrddata' }, + { name => 'scan' }, + { name => 'sdn' }, + { name => 'services' }, +@@ -878,6 +879,108 @@ + "pve-node-9.0/$param->{node}", $param->{timeframe}, $param->{cf}, + ); + }, ++ ++__PACKAGE__->register_method({ ++ name => 'gpurrddata', ++ path => 'gpurrddata', ++ method => 'GET', ++ protected => 1, ++ proxyto => 'node', ++ permissions => { ++ check => ['perm', '/nodes/{node}', ['Sys.Audit']], ++ }, ++ description => "Read GPU RRD statistics", ++ parameters => { ++ additionalProperties => 0, ++ properties => { ++ node => get_standard_option('pve-node'), ++ card => { ++ description => "The GPU card identifier (e.g. card0, nvidia0).", ++ type => 'string', ++ pattern => '[a-zA-Z0-9]+', ++ }, ++ timeframe => { ++ description => "Specify the time frame you are interested in.", ++ type => 'string', ++ enum => ['hour', 'day', 'week', 'month', 'year', 'decade'], ++ }, ++ cf => { ++ description => "The RRD consolidation function", ++ type => 'string', ++ enum => ['AVERAGE', 'MAX'], ++ optional => 1, ++ }, ++ }, ++ }, ++ returns => { ++ type => "array", ++ items => { ++ type => "object", ++ properties => {}, ++ }, ++ }, ++ code => sub { ++ my ($param) = @_; ++ my $nodename = PVE::INotify::nodename(); ++ my $card = $param->{card}; ++ die "invalid card name ++" unless $card =~ /^[a-zA-Z0-9]+$/; ++ return PVE::RRD::create_rrd_data( ++ "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf}, ++ ); ++ }, ++}); ++ ++__PACKAGE__->register_method({ ++ name => 'gpurrddata', ++ path => 'gpurrddata', ++ method => 'GET', ++ protected => 1, ++ proxyto => 'node', ++ permissions => { ++ check => ['perm', '/nodes/{node}', ['Sys.Audit']], ++ }, ++ description => "Read GPU RRD statistics", ++ parameters => { ++ additionalProperties => 0, ++ properties => { ++ node => get_standard_option('pve-node'), ++ card => { ++ description => "The GPU card identifier (e.g. card0, nvidia0).", ++ type => 'string', ++ pattern => '[a-zA-Z0-9]+', ++ }, ++ timeframe => { ++ description => "Specify the time frame you are interested in.", ++ type => 'string', ++ enum => ['hour', 'day', 'week', 'month', 'year', 'decade'], ++ }, ++ cf => { ++ description => "The RRD consolidation function", ++ type => 'string', ++ enum => ['AVERAGE', 'MAX'], ++ optional => 1, ++ }, ++ }, ++ }, ++ returns => { ++ type => "array", ++ items => { ++ type => "object", ++ properties => {}, ++ }, ++ }, ++ code => sub { ++ my ($param) = @_; ++ my $nodename = PVE::INotify::nodename(); ++ my $card = $param->{card}; ++ die "invalid card name ++" unless $card =~ /^[a-zA-Z0-9]+$/; ++ return PVE::RRD::create_rrd_data( ++ "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf}, ++ ); ++ }, ++}); + }); + + __PACKAGE__->register_method({ From f5795bb6717d1ee4f8d183bfaf100a8ef38d99a0 Mon Sep 17 00:00:00 2001 From: Meliox Date: Tue, 9 Jun 2026 23:02:46 +0200 Subject: [PATCH 11/13] fix rrd history patch for compilation --- .../patches/02-nodes-pm-GPU-RRD-history.patch | 59 ++----------------- 1 file changed, 4 insertions(+), 55 deletions(-) diff --git a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch index cad2a6a..141ae72 100644 --- a/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch +++ b/src/PVENodeInfo/patches/02-nodes-pm-GPU-RRD-history.patch @@ -8,60 +8,11 @@ { name => 'scan' }, { name => 'sdn' }, { name => 'services' }, -@@ -878,6 +879,108 @@ +@@ -878,6 +879,57 @@ "pve-node-9.0/$param->{node}", $param->{timeframe}, $param->{cf}, ); }, -+ -+__PACKAGE__->register_method({ -+ name => 'gpurrddata', -+ path => 'gpurrddata', -+ method => 'GET', -+ protected => 1, -+ proxyto => 'node', -+ permissions => { -+ check => ['perm', '/nodes/{node}', ['Sys.Audit']], -+ }, -+ description => "Read GPU RRD statistics", -+ parameters => { -+ additionalProperties => 0, -+ properties => { -+ node => get_standard_option('pve-node'), -+ card => { -+ description => "The GPU card identifier (e.g. card0, nvidia0).", -+ type => 'string', -+ pattern => '[a-zA-Z0-9]+', -+ }, -+ timeframe => { -+ description => "Specify the time frame you are interested in.", -+ type => 'string', -+ enum => ['hour', 'day', 'week', 'month', 'year', 'decade'], -+ }, -+ cf => { -+ description => "The RRD consolidation function", -+ type => 'string', -+ enum => ['AVERAGE', 'MAX'], -+ optional => 1, -+ }, -+ }, -+ }, -+ returns => { -+ type => "array", -+ items => { -+ type => "object", -+ properties => {}, -+ }, -+ }, -+ code => sub { -+ my ($param) = @_; -+ my $nodename = PVE::INotify::nodename(); -+ my $card = $param->{card}; -+ die "invalid card name -+" unless $card =~ /^[a-zA-Z0-9]+$/; -+ return PVE::RRD::create_rrd_data( -+ "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf}, -+ ); -+ }, +-}); +}); + +__PACKAGE__->register_method({ @@ -107,13 +58,11 @@ + my ($param) = @_; + my $nodename = PVE::INotify::nodename(); + my $card = $param->{card}; -+ die "invalid card name -+" unless $card =~ /^[a-zA-Z0-9]+$/; ++ die "invalid card name\n" unless $card =~ /^[a-zA-Z0-9]+$/; + return PVE::RRD::create_rrd_data( + "pve-mod-gpu/$nodename/$card", $param->{timeframe}, $param->{cf}, + ); + }, +}); - }); - __PACKAGE__->register_method({ + __PACKAGE__->register_method({ \ No newline at end of file From bc4150c991105722a896851c31426446b96ffd4f Mon Sep 17 00:00:00 2001 From: Meliox Date: Wed, 10 Jun 2026 17:51:56 +0200 Subject: [PATCH 12/13] add test workflows --- .github/workflows/install-test.yml | 44 +++++ .github/workflows/weekly-patch-test.yml | 110 +++++++++++++ src/Scripts/test/fetch-proxmox-files.sh | 55 +++++++ src/Scripts/test/test-mods.sh | 206 ++++++++++++++++++++++++ 4 files changed, 415 insertions(+) create mode 100644 .github/workflows/install-test.yml create mode 100644 .github/workflows/weekly-patch-test.yml create mode 100644 src/Scripts/test/fetch-proxmox-files.sh create mode 100644 src/Scripts/test/test-mods.sh diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml new file mode 100644 index 0000000..9ba111e --- /dev/null +++ b/.github/workflows/install-test.yml @@ -0,0 +1,44 @@ +name: Test Patches - Post Build + +# Runs automatically after every "Test Release Build" run completes (that build +# is itself triggered by the 'make-test-build' label on a PR). Reuses the .deb +# that build produced — no rebuild — installs it on a runner seeded with the +# latest upstream Proxmox files, and verifies that every mod's patches (and +# post-apply hooks) apply and revert cleanly. Fails if any mod fails. +# +# Wired via workflow_run, so it fires for builds on ANY branch/PR. (GitHub always +# runs the default-branch copy of this file, so it activates once merged to main.) + +on: + workflow_run: + workflows: ["Test Release Build"] + types: [completed] + +permissions: + contents: read + actions: read # required to download the triggering run's artifact + +jobs: + patch-install-test: + runs-on: ubuntu-latest + if: github.event.workflow_run.conclusion == 'success' + steps: + - uses: actions/checkout@v4 + + - name: Download deb from the build run + uses: actions/download-artifact@v4 + with: + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + pattern: pve-mods-test-build-* + merge-multiple: true + path: deb + + - name: Fetch upstream Proxmox files + run: bash src/Scripts/test/fetch-proxmox-files.sh + + - name: Install built package + run: sudo apt-get install -y ./deb/*.deb + + - name: Test all mods + run: sudo bash src/Scripts/test/test-mods.sh all diff --git a/.github/workflows/weekly-patch-test.yml b/.github/workflows/weekly-patch-test.yml new file mode 100644 index 0000000..2b239dd --- /dev/null +++ b/.github/workflows/weekly-patch-test.yml @@ -0,0 +1,110 @@ +name: Test Patches - Weekly + +# Weekly regression test of pve-mod against the latest upstream Proxmox VE files. +# Mods are auto-discovered from the [modules] section of src/pve-mod.conf and +# tested in parallel - adding a mod needs no change here. If a mod fails, an +# issue is opened (or updated) per failed mod explaining why. + +on: + schedule: + - cron: '0 6 * * 1' # Mondays 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + discover: + runs-on: ubuntu-latest + outputs: + mods: ${{ steps.list.outputs.mods }} + steps: + - uses: actions/checkout@v4 + + - name: Discover mods from [modules] + id: list + run: | + MODS=$(awk -F= ' + /^\[/ { in_sec = ($0 == "[modules]") } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1 + }' src/pve-mod.conf | jq -R . | jq -cs .) + echo "Discovered mods: $MODS" + echo "mods=$MODS" >> "$GITHUB_OUTPUT" + + test: + needs: discover + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + mod: ${{ fromJSON(needs.discover.outputs.mods) }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── Build (mirrors test-release.yml) ─────────────────────────────────── + - name: Install build deps + run: sudo apt-get install -y devscripts debhelper build-essential + + - name: Inject version into PVEMod_Config.pm + run: | + VERSION=$(grep -m1 '(' debian/changelog | sed 's/.*(\(.*\)).*/\1/') + SHORT_SHA=$(git rev-parse --short HEAD) + export BUILD_VERSION="${VERSION}-test-${SHORT_SHA}" + perl -i -pe 'BEGIN { $v = $ENV{BUILD_VERSION} } + s/(our \$VERSION\s*=\s*'"'"')[^'"'"']*'"'"';/$1'"'"'$v'"'"';/ + ' src/PVENodeInfo/PVEMod_Config.pm + + - name: Build package + run: dpkg-buildpackage -us -uc -b + + # ── Test ─────────────────────────────────────────────────────────────── + - name: Fetch upstream Proxmox files + run: bash src/Scripts/test/fetch-proxmox-files.sh + + - name: Install built package + run: sudo apt-get install -y ../pve-mod_*.deb + + - name: Test mod + run: | + set -o pipefail + sudo bash src/Scripts/test/test-mods.sh "${{ matrix.mod }}" 2>&1 | tee test-output.log + + - name: Report failure as issue + if: failure() + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MOD: ${{ matrix.mod }} + run: | + TITLE="[patch-break] ${MOD}" + { + echo "The weekly patch test failed for mod \`${MOD}\` against the latest Proxmox VE files." + echo "" + echo "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + echo "" + echo "
Test log" + echo "" + echo '```' + tail -n 200 test-output.log 2>/dev/null || echo "(no log captured)" + echo '```' + echo "
" + } > issue-body.md + + # Ensure the label exists (no-op if it already does). + gh label create patch-break --color B60205 \ + --description "pve-mod patch failed against upstream Proxmox" 2>/dev/null || true + + EXISTING=$(gh issue list --state open --label patch-break \ + --search "$TITLE in:title" --json number,title \ + --jq ".[] | select(.title==\"$TITLE\") | .number" | head -n1) + + if [[ -n "$EXISTING" ]]; then + gh issue comment "$EXISTING" --body-file issue-body.md + echo "Commented on existing issue #$EXISTING" + else + gh issue create --title "$TITLE" --label patch-break --body-file issue-body.md + echo "Opened new issue for mod $MOD" + fi diff --git a/src/Scripts/test/fetch-proxmox-files.sh b/src/Scripts/test/fetch-proxmox-files.sh new file mode 100644 index 0000000..282cfa7 --- /dev/null +++ b/src/Scripts/test/fetch-proxmox-files.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# src/Scripts/test/fetch-proxmox-files.sh +# +# Downloads the upstream Proxmox VE files that pve-mod patches and installs them +# at their real system paths, so the patch engine can be exercised on a plain +# (non-Proxmox) CI runner. +# +# Pulls pve-manager and proxmox-widget-toolkit from the Proxmox no-subscription +# repository (default: PVE 9 / Debian trixie) and extracts the target files. +# +# CI-only helper. The repo is added with [trusted=yes]: we are only fetching +# public, unmodified UI files to patch against, not installing Proxmox, so +# managing the signing key would add nothing. + +set -euo pipefail + +SUITE="${PVE_SUITE:-trixie}" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +# Files provided by the two packages, relative to the filesystem root. These are +# exactly the paths the .patch files (and the nag_screen post-apply hook) touch. +FILES=( + usr/share/perl5/PVE/API2/Nodes.pm + usr/share/pve-manager/js/pvemanagerlib.js + usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js + usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js +) + +echo "[fetch] Configuring Proxmox no-subscription repo ($SUITE)..." +echo "deb [trusted=yes] http://download.proxmox.com/debian/pve $SUITE pve-no-subscription" \ + | sudo tee /etc/apt/sources.list.d/pve-test.list >/dev/null +sudo apt-get update -qq + +echo "[fetch] Downloading pve-manager and proxmox-widget-toolkit..." +cd "$WORKDIR" +apt-get download pve-manager proxmox-widget-toolkit + +for deb in *.deb; do + echo "[fetch] Extracting $deb" + dpkg-deb -x "$deb" extract +done + +for rel in "${FILES[@]}"; do + src="extract/$rel" + dst="/$rel" + if [[ ! -f "$src" ]]; then + echo "[fetch] ERROR: expected file not found in packages: $rel" >&2 + exit 1 + fi + sudo install -Dm644 "$src" "$dst" + echo "[fetch] Installed $dst" +done + +echo "[fetch] Done." diff --git a/src/Scripts/test/test-mods.sh b/src/Scripts/test/test-mods.sh new file mode 100644 index 0000000..86d3bf1 --- /dev/null +++ b/src/Scripts/test/test-mods.sh @@ -0,0 +1,206 @@ +#!/usr/bin/env bash +# src/Scripts/test/test-mods.sh +# +# Exercises pve-mod's patch engine against the installed Proxmox files. +# +# Usage: test-mods.sh | all +# +# Mods are auto-discovered from the [modules] section of the main config, so +# adding a new mod (a new [modules] key plus a patches// directory) is +# picked up automatically - no edits to this script are required. +# +# For each target mod it: +# 1. enables only that mod in the main config +# 2. turns on every conditional flag referenced by the mod's patches.list, +# so all of the mod's patches are exercised (e.g. node_info's gpu_history) +# 3. runs apply-patches.sh and asserts it exits 0 +# 4. runs revert-patches.sh and asserts it reports no unclean reversions +# 5. restores the pristine Proxmox files before moving to the next mod +# +# Failure detection relies on the apply exit code: the patch engine performs an +# atomic preflight dry-run and exits non-zero if any mod cannot apply cleanly. +# +# Must run as root (it edits /etc/pve-mod and the patched system files). + +set -u + +MAIN_CONF="${PVE_MOD_MAIN_CONF:-/etc/pve-mod/pve-mod.conf}" +CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" +PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" +APPLY="${PVE_MOD_APPLY:-/usr/lib/pve-mod/apply-patches.sh}" +REVERT="${PVE_MOD_REVERT:-/usr/lib/pve-mod/revert-patches.sh}" + +# Proxmox files touched by the patches; snapshotted before and restored after +# each mod so every mod is tested from a pristine baseline. +PROXMOX_FILES=( + /usr/share/perl5/PVE/API2/Nodes.pm + /usr/share/pve-manager/js/pvemanagerlib.js + /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js + /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js +) + +SNAP_DIR="$(mktemp -d)" +trap 'rm -rf "$SNAP_DIR"' EXIT + +info() { echo "[test] $*"; } +warn() { echo "[test] WARNING: $*" >&2; } + +# List the keys of the [modules] section in the main config, one per line. +list_modules() { + [[ -f "$MAIN_CONF" ]] || return 0 + awk -F= ' + /^\[/ { in_sec = ($0 == "[modules]") } + in_sec && /^[^#=]+=/ { + gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) + print $1 + } + ' "$MAIN_CONF" +} + +# set_conf
: set an existing key within a section. +set_conf() { + local file="$1" section="$2" key="$3" value="$4" + [[ -f "$file" ]] || { warn "config not found: $file"; return 1; } + awk -v sec="[$section]" -v k="$key" -v v="$value" ' + /^\[/ { in_sec = ($0 == sec) } + { + if (in_sec && $0 ~ "^[[:space:]]*"k"[[:space:]]*=") { + print k"="v + } else { + print + } + } + ' "$file" > "$file.tmp" && mv "$file.tmp" "$file" +} + +# Disable every discovered mod in the main config. +disable_all_modules() { + local m + for m in $(list_modules); do + set_conf "$MAIN_CONF" modules "$m" 0 + done +} + +# Turn on every conditional flag a mod's patches.list references, so all of its +# patches become active. Conditions look like: section.key=value +enable_conditions() { + local mod="$1" + local manifest="$PATCHES_DIR/$mod/patches.list" + local mod_conf="$CONFD_DIR/$mod.conf" + [[ -f "$manifest" ]] || return 0 + local line cond local_key want sect ckey + while IFS= read -r line; do + line="${line%%#*}" + line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" + [[ -z "$line" ]] && continue + # Skip lines without a condition (no whitespace after the patch name). + [[ "$line" == *[[:space:]]* ]] || continue + cond="$(echo "${line#*[[:space:]]}" | sed -E 's/^[[:space:]]+//')" + [[ "$cond" == *=* ]] || continue + local_key="${cond%%=*}" + want="${cond#*=}" + sect="${local_key%%.*}" + ckey="${local_key#*.}" + set_conf "$mod_conf" "$sect" "$ckey" "$want" \ + && info " enabled condition $local_key=$want in $(basename "$mod_conf")" + done < "$manifest" +} + +snapshot_files() { + rm -rf "$SNAP_DIR"/*; mkdir -p "$SNAP_DIR" + local i=0 f + for f in "${PROXMOX_FILES[@]}"; do + if [[ -e "$f" || -L "$f" ]]; then + cp -a "$f" "$SNAP_DIR/$i" + fi + i=$((i+1)) + done +} + +restore_files() { + local i=0 f + for f in "${PROXMOX_FILES[@]}"; do + if [[ -e "$SNAP_DIR/$i" || -L "$SNAP_DIR/$i" ]]; then + rm -f "$f" + cp -a "$SNAP_DIR/$i" "$f" + fi + i=$((i+1)) + done +} + +# Test a single mod end to end. Returns 0 on success, 1 on failure. +test_one_mod() { + local mod="$1" + echo "::group::Testing mod: $mod" + local ok=0 + + if [[ ! -f "$PATCHES_DIR/$mod/patches.list" ]]; then + warn "no patch manifest for mod '$mod' ($PATCHES_DIR/$mod/patches.list)" + echo "::endgroup::" + return 1 + fi + + disable_all_modules + set_conf "$MAIN_CONF" modules "$mod" 1 + enable_conditions "$mod" + snapshot_files + + info "Applying patches for '$mod'..." + local apply_log apply_rc + apply_log="$("$APPLY" 2>&1)"; apply_rc=$? + echo "$apply_log" + if [[ $apply_rc -ne 0 ]]; then + warn "apply-patches.sh failed for mod '$mod' (exit $apply_rc)" + ok=1 + fi + + if [[ $ok -eq 0 ]]; then + info "Reverting patches for '$mod'..." + local revert_log + revert_log="$("$REVERT" 2>&1)" + echo "$revert_log" + if echo "$revert_log" | grep -qE "could not be reverted cleanly|reported an error"; then + warn "revert-patches.sh reported an unclean revert for mod '$mod'" + ok=1 + fi + fi + + restore_files + + if [[ $ok -eq 0 ]]; then + info "PASS: $mod" + else + warn "FAIL: $mod" + fi + echo "::endgroup::" + return $ok +} + +# ── main ────────────────────────────────────────────────────────────────────── +[[ $# -eq 1 ]] || { echo "Usage: $0 |all" >&2; exit 2; } + +if ! command -v patch >/dev/null 2>&1; then + warn "'patch' command not found; install the 'patch' package." + exit 1 +fi + +targets=() +if [[ "$1" == "all" ]]; then + mapfile -t targets < <(list_modules) + [[ ${#targets[@]} -gt 0 ]] || { warn "no mods found in [modules] of $MAIN_CONF"; exit 1; } +else + targets=("$1") +fi + +failed=() +for mod in "${targets[@]}"; do + test_one_mod "$mod" || failed+=("$mod") +done + +echo "" +if [[ ${#failed[@]} -gt 0 ]]; then + warn "Failed mods: ${failed[*]}" + exit 1 +fi +info "All tested mods passed: ${targets[*]}" +exit 0 From 81ab384e481531daee8599449b014ab1f9a809d5 Mon Sep 17 00:00:00 2001 From: Meliox Date: Wed, 10 Jun 2026 19:26:37 +0200 Subject: [PATCH 13/13] Move test and CI to separate PR --- .github/workflows/install-test.yml | 44 ----- .github/workflows/test-release.yml | 71 -------- .github/workflows/weekly-patch-test.yml | 110 ------------- src/Scripts/test/fetch-proxmox-files.sh | 55 ------- src/Scripts/test/test-mods.sh | 206 ------------------------ 5 files changed, 486 deletions(-) delete mode 100644 .github/workflows/install-test.yml delete mode 100644 .github/workflows/test-release.yml delete mode 100644 .github/workflows/weekly-patch-test.yml delete mode 100644 src/Scripts/test/fetch-proxmox-files.sh delete mode 100644 src/Scripts/test/test-mods.sh diff --git a/.github/workflows/install-test.yml b/.github/workflows/install-test.yml deleted file mode 100644 index 9ba111e..0000000 --- a/.github/workflows/install-test.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Test Patches - Post Build - -# Runs automatically after every "Test Release Build" run completes (that build -# is itself triggered by the 'make-test-build' label on a PR). Reuses the .deb -# that build produced — no rebuild — installs it on a runner seeded with the -# latest upstream Proxmox files, and verifies that every mod's patches (and -# post-apply hooks) apply and revert cleanly. Fails if any mod fails. -# -# Wired via workflow_run, so it fires for builds on ANY branch/PR. (GitHub always -# runs the default-branch copy of this file, so it activates once merged to main.) - -on: - workflow_run: - workflows: ["Test Release Build"] - types: [completed] - -permissions: - contents: read - actions: read # required to download the triggering run's artifact - -jobs: - patch-install-test: - runs-on: ubuntu-latest - if: github.event.workflow_run.conclusion == 'success' - steps: - - uses: actions/checkout@v4 - - - name: Download deb from the build run - uses: actions/download-artifact@v4 - with: - run-id: ${{ github.event.workflow_run.id }} - github-token: ${{ secrets.GITHUB_TOKEN }} - pattern: pve-mods-test-build-* - merge-multiple: true - path: deb - - - name: Fetch upstream Proxmox files - run: bash src/Scripts/test/fetch-proxmox-files.sh - - - name: Install built package - run: sudo apt-get install -y ./deb/*.deb - - - name: Test all mods - run: sudo bash src/Scripts/test/test-mods.sh all diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml deleted file mode 100644 index 24d2947..0000000 --- a/.github/workflows/test-release.yml +++ /dev/null @@ -1,71 +0,0 @@ -name: Test Release Build - -# Runs when the 'make-test-build' label is applied to a pull request, -# or on any subsequent commit while that label is present. - -on: - pull_request: - types: [labeled, synchronize] - -jobs: - test-release-build: - runs-on: ubuntu-latest - if: contains(github.event.pull_request.labels.*.name, 'make-test-build') - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - - name: Install build deps - run: sudo apt-get install -y devscripts debhelper build-essential - - - name: Compute build version - id: version - run: | - BASE_VERSION=$(grep -m1 '(' debian/changelog | sed 's/.*(\(.*\)).*/\1/') - PR_NUMBER="${{ github.event.pull_request.number }}" - SHORT_SHA=$(git rev-parse --short HEAD) - BUILD_VERSION="${BASE_VERSION}+pr${PR_NUMBER}.${SHORT_SHA}" - echo "build_version=$BUILD_VERSION" >> "$GITHUB_OUTPUT" - echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" - echo "Building version: $BUILD_VERSION" - - - name: Inject version into PVEMod_Config.pm - run: | - export BUILD_VERSION="${{ steps.version.outputs.build_version }}" - perl -i -pe 'BEGIN { $v = $ENV{BUILD_VERSION} } - s/(our \$VERSION\s*=\s*'"'"')[^'"'"']*'"'"';/$1'"'"'$v'"'"';/ - ' src/PVENodeInfo/PVEMod_Config.pm - - - name: Set package version in changelog - run: | - BUILD_VERSION="${{ steps.version.outputs.build_version }}" - sed -i -E "0,/\(([^)]*)\)/s//(${BUILD_VERSION})/" debian/changelog - - - name: Build package - run: dpkg-buildpackage -us -uc -b - - - name: Verify package contents - run: | - echo "=== Built files ===" - ls -la ../*.deb - echo "" - echo "=== Package info ===" - dpkg-deb --info ../*.deb - echo "" - echo "=== Package contents ===" - dpkg-deb --contents ../*.deb - - - name: Collect deb artifacts - id: collect - run: | - mkdir -p artifacts - cp ../*.deb artifacts/ - - - name: Upload deb as artifact - uses: actions/upload-artifact@v4 - with: - name: pve-mods-test-build-pr${{ github.event.pull_request.number }}-${{ steps.version.outputs.short_sha }} - path: artifacts/*.deb - retention-days: 3 \ No newline at end of file diff --git a/.github/workflows/weekly-patch-test.yml b/.github/workflows/weekly-patch-test.yml deleted file mode 100644 index 2b239dd..0000000 --- a/.github/workflows/weekly-patch-test.yml +++ /dev/null @@ -1,110 +0,0 @@ -name: Test Patches - Weekly - -# Weekly regression test of pve-mod against the latest upstream Proxmox VE files. -# Mods are auto-discovered from the [modules] section of src/pve-mod.conf and -# tested in parallel - adding a mod needs no change here. If a mod fails, an -# issue is opened (or updated) per failed mod explaining why. - -on: - schedule: - - cron: '0 6 * * 1' # Mondays 06:00 UTC - workflow_dispatch: - -permissions: - contents: read - issues: write - -jobs: - discover: - runs-on: ubuntu-latest - outputs: - mods: ${{ steps.list.outputs.mods }} - steps: - - uses: actions/checkout@v4 - - - name: Discover mods from [modules] - id: list - run: | - MODS=$(awk -F= ' - /^\[/ { in_sec = ($0 == "[modules]") } - in_sec && /^[^#=]+=/ { - gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1); print $1 - }' src/pve-mod.conf | jq -R . | jq -cs .) - echo "Discovered mods: $MODS" - echo "mods=$MODS" >> "$GITHUB_OUTPUT" - - test: - needs: discover - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - mod: ${{ fromJSON(needs.discover.outputs.mods) }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # ── Build (mirrors test-release.yml) ─────────────────────────────────── - - name: Install build deps - run: sudo apt-get install -y devscripts debhelper build-essential - - - name: Inject version into PVEMod_Config.pm - run: | - VERSION=$(grep -m1 '(' debian/changelog | sed 's/.*(\(.*\)).*/\1/') - SHORT_SHA=$(git rev-parse --short HEAD) - export BUILD_VERSION="${VERSION}-test-${SHORT_SHA}" - perl -i -pe 'BEGIN { $v = $ENV{BUILD_VERSION} } - s/(our \$VERSION\s*=\s*'"'"')[^'"'"']*'"'"';/$1'"'"'$v'"'"';/ - ' src/PVENodeInfo/PVEMod_Config.pm - - - name: Build package - run: dpkg-buildpackage -us -uc -b - - # ── Test ─────────────────────────────────────────────────────────────── - - name: Fetch upstream Proxmox files - run: bash src/Scripts/test/fetch-proxmox-files.sh - - - name: Install built package - run: sudo apt-get install -y ../pve-mod_*.deb - - - name: Test mod - run: | - set -o pipefail - sudo bash src/Scripts/test/test-mods.sh "${{ matrix.mod }}" 2>&1 | tee test-output.log - - - name: Report failure as issue - if: failure() - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - MOD: ${{ matrix.mod }} - run: | - TITLE="[patch-break] ${MOD}" - { - echo "The weekly patch test failed for mod \`${MOD}\` against the latest Proxmox VE files." - echo "" - echo "Run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" - echo "" - echo "
Test log" - echo "" - echo '```' - tail -n 200 test-output.log 2>/dev/null || echo "(no log captured)" - echo '```' - echo "
" - } > issue-body.md - - # Ensure the label exists (no-op if it already does). - gh label create patch-break --color B60205 \ - --description "pve-mod patch failed against upstream Proxmox" 2>/dev/null || true - - EXISTING=$(gh issue list --state open --label patch-break \ - --search "$TITLE in:title" --json number,title \ - --jq ".[] | select(.title==\"$TITLE\") | .number" | head -n1) - - if [[ -n "$EXISTING" ]]; then - gh issue comment "$EXISTING" --body-file issue-body.md - echo "Commented on existing issue #$EXISTING" - else - gh issue create --title "$TITLE" --label patch-break --body-file issue-body.md - echo "Opened new issue for mod $MOD" - fi diff --git a/src/Scripts/test/fetch-proxmox-files.sh b/src/Scripts/test/fetch-proxmox-files.sh deleted file mode 100644 index 282cfa7..0000000 --- a/src/Scripts/test/fetch-proxmox-files.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# src/Scripts/test/fetch-proxmox-files.sh -# -# Downloads the upstream Proxmox VE files that pve-mod patches and installs them -# at their real system paths, so the patch engine can be exercised on a plain -# (non-Proxmox) CI runner. -# -# Pulls pve-manager and proxmox-widget-toolkit from the Proxmox no-subscription -# repository (default: PVE 9 / Debian trixie) and extracts the target files. -# -# CI-only helper. The repo is added with [trusted=yes]: we are only fetching -# public, unmodified UI files to patch against, not installing Proxmox, so -# managing the signing key would add nothing. - -set -euo pipefail - -SUITE="${PVE_SUITE:-trixie}" -WORKDIR="$(mktemp -d)" -trap 'rm -rf "$WORKDIR"' EXIT - -# Files provided by the two packages, relative to the filesystem root. These are -# exactly the paths the .patch files (and the nag_screen post-apply hook) touch. -FILES=( - usr/share/perl5/PVE/API2/Nodes.pm - usr/share/pve-manager/js/pvemanagerlib.js - usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js - usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js -) - -echo "[fetch] Configuring Proxmox no-subscription repo ($SUITE)..." -echo "deb [trusted=yes] http://download.proxmox.com/debian/pve $SUITE pve-no-subscription" \ - | sudo tee /etc/apt/sources.list.d/pve-test.list >/dev/null -sudo apt-get update -qq - -echo "[fetch] Downloading pve-manager and proxmox-widget-toolkit..." -cd "$WORKDIR" -apt-get download pve-manager proxmox-widget-toolkit - -for deb in *.deb; do - echo "[fetch] Extracting $deb" - dpkg-deb -x "$deb" extract -done - -for rel in "${FILES[@]}"; do - src="extract/$rel" - dst="/$rel" - if [[ ! -f "$src" ]]; then - echo "[fetch] ERROR: expected file not found in packages: $rel" >&2 - exit 1 - fi - sudo install -Dm644 "$src" "$dst" - echo "[fetch] Installed $dst" -done - -echo "[fetch] Done." diff --git a/src/Scripts/test/test-mods.sh b/src/Scripts/test/test-mods.sh deleted file mode 100644 index 86d3bf1..0000000 --- a/src/Scripts/test/test-mods.sh +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env bash -# src/Scripts/test/test-mods.sh -# -# Exercises pve-mod's patch engine against the installed Proxmox files. -# -# Usage: test-mods.sh | all -# -# Mods are auto-discovered from the [modules] section of the main config, so -# adding a new mod (a new [modules] key plus a patches// directory) is -# picked up automatically - no edits to this script are required. -# -# For each target mod it: -# 1. enables only that mod in the main config -# 2. turns on every conditional flag referenced by the mod's patches.list, -# so all of the mod's patches are exercised (e.g. node_info's gpu_history) -# 3. runs apply-patches.sh and asserts it exits 0 -# 4. runs revert-patches.sh and asserts it reports no unclean reversions -# 5. restores the pristine Proxmox files before moving to the next mod -# -# Failure detection relies on the apply exit code: the patch engine performs an -# atomic preflight dry-run and exits non-zero if any mod cannot apply cleanly. -# -# Must run as root (it edits /etc/pve-mod and the patched system files). - -set -u - -MAIN_CONF="${PVE_MOD_MAIN_CONF:-/etc/pve-mod/pve-mod.conf}" -CONFD_DIR="${PVE_MOD_CONFD_DIR:-/etc/pve-mod/conf.d}" -PATCHES_DIR="${PVE_MOD_PATCHES_DIR:-/usr/lib/pve-mod/patches}" -APPLY="${PVE_MOD_APPLY:-/usr/lib/pve-mod/apply-patches.sh}" -REVERT="${PVE_MOD_REVERT:-/usr/lib/pve-mod/revert-patches.sh}" - -# Proxmox files touched by the patches; snapshotted before and restored after -# each mod so every mod is tested from a pristine baseline. -PROXMOX_FILES=( - /usr/share/perl5/PVE/API2/Nodes.pm - /usr/share/pve-manager/js/pvemanagerlib.js - /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js - /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.min.js -) - -SNAP_DIR="$(mktemp -d)" -trap 'rm -rf "$SNAP_DIR"' EXIT - -info() { echo "[test] $*"; } -warn() { echo "[test] WARNING: $*" >&2; } - -# List the keys of the [modules] section in the main config, one per line. -list_modules() { - [[ -f "$MAIN_CONF" ]] || return 0 - awk -F= ' - /^\[/ { in_sec = ($0 == "[modules]") } - in_sec && /^[^#=]+=/ { - gsub(/^[[:space:]]+|[[:space:]]+$/, "", $1) - print $1 - } - ' "$MAIN_CONF" -} - -# set_conf
: set an existing key within a section. -set_conf() { - local file="$1" section="$2" key="$3" value="$4" - [[ -f "$file" ]] || { warn "config not found: $file"; return 1; } - awk -v sec="[$section]" -v k="$key" -v v="$value" ' - /^\[/ { in_sec = ($0 == sec) } - { - if (in_sec && $0 ~ "^[[:space:]]*"k"[[:space:]]*=") { - print k"="v - } else { - print - } - } - ' "$file" > "$file.tmp" && mv "$file.tmp" "$file" -} - -# Disable every discovered mod in the main config. -disable_all_modules() { - local m - for m in $(list_modules); do - set_conf "$MAIN_CONF" modules "$m" 0 - done -} - -# Turn on every conditional flag a mod's patches.list references, so all of its -# patches become active. Conditions look like: section.key=value -enable_conditions() { - local mod="$1" - local manifest="$PATCHES_DIR/$mod/patches.list" - local mod_conf="$CONFD_DIR/$mod.conf" - [[ -f "$manifest" ]] || return 0 - local line cond local_key want sect ckey - while IFS= read -r line; do - line="${line%%#*}" - line="$(echo "$line" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g')" - [[ -z "$line" ]] && continue - # Skip lines without a condition (no whitespace after the patch name). - [[ "$line" == *[[:space:]]* ]] || continue - cond="$(echo "${line#*[[:space:]]}" | sed -E 's/^[[:space:]]+//')" - [[ "$cond" == *=* ]] || continue - local_key="${cond%%=*}" - want="${cond#*=}" - sect="${local_key%%.*}" - ckey="${local_key#*.}" - set_conf "$mod_conf" "$sect" "$ckey" "$want" \ - && info " enabled condition $local_key=$want in $(basename "$mod_conf")" - done < "$manifest" -} - -snapshot_files() { - rm -rf "$SNAP_DIR"/*; mkdir -p "$SNAP_DIR" - local i=0 f - for f in "${PROXMOX_FILES[@]}"; do - if [[ -e "$f" || -L "$f" ]]; then - cp -a "$f" "$SNAP_DIR/$i" - fi - i=$((i+1)) - done -} - -restore_files() { - local i=0 f - for f in "${PROXMOX_FILES[@]}"; do - if [[ -e "$SNAP_DIR/$i" || -L "$SNAP_DIR/$i" ]]; then - rm -f "$f" - cp -a "$SNAP_DIR/$i" "$f" - fi - i=$((i+1)) - done -} - -# Test a single mod end to end. Returns 0 on success, 1 on failure. -test_one_mod() { - local mod="$1" - echo "::group::Testing mod: $mod" - local ok=0 - - if [[ ! -f "$PATCHES_DIR/$mod/patches.list" ]]; then - warn "no patch manifest for mod '$mod' ($PATCHES_DIR/$mod/patches.list)" - echo "::endgroup::" - return 1 - fi - - disable_all_modules - set_conf "$MAIN_CONF" modules "$mod" 1 - enable_conditions "$mod" - snapshot_files - - info "Applying patches for '$mod'..." - local apply_log apply_rc - apply_log="$("$APPLY" 2>&1)"; apply_rc=$? - echo "$apply_log" - if [[ $apply_rc -ne 0 ]]; then - warn "apply-patches.sh failed for mod '$mod' (exit $apply_rc)" - ok=1 - fi - - if [[ $ok -eq 0 ]]; then - info "Reverting patches for '$mod'..." - local revert_log - revert_log="$("$REVERT" 2>&1)" - echo "$revert_log" - if echo "$revert_log" | grep -qE "could not be reverted cleanly|reported an error"; then - warn "revert-patches.sh reported an unclean revert for mod '$mod'" - ok=1 - fi - fi - - restore_files - - if [[ $ok -eq 0 ]]; then - info "PASS: $mod" - else - warn "FAIL: $mod" - fi - echo "::endgroup::" - return $ok -} - -# ── main ────────────────────────────────────────────────────────────────────── -[[ $# -eq 1 ]] || { echo "Usage: $0 |all" >&2; exit 2; } - -if ! command -v patch >/dev/null 2>&1; then - warn "'patch' command not found; install the 'patch' package." - exit 1 -fi - -targets=() -if [[ "$1" == "all" ]]; then - mapfile -t targets < <(list_modules) - [[ ${#targets[@]} -gt 0 ]] || { warn "no mods found in [modules] of $MAIN_CONF"; exit 1; } -else - targets=("$1") -fi - -failed=() -for mod in "${targets[@]}"; do - test_one_mod "$mod" || failed+=("$mod") -done - -echo "" -if [[ ${#failed[@]} -gt 0 ]]; then - warn "Failed mods: ${failed[*]}" - exit 1 -fi -info "All tested mods passed: ${targets[*]}" -exit 0