diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b4ec5c1..91abf7f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -184,7 +184,7 @@ jobs: cp perl5/PVE/Storage/LunCmd/FreeNAS.pm "${STAGING}/usr/share/freenas-proxmox/FreeNAS.pm" cp perl5/REST/Client.pm "${STAGING}/usr/share/freenas-proxmox/REST-Client.pm" - # PVE 8 patches (primary supported version) + # PVE 8 patches (8.0.x – 8.3.x) cp stable-8/perl5/PVE/Storage/ZFSPlugin.pm.patch \ "${STAGING}/usr/share/freenas-proxmox/patches/ZFSPlugin/8.patch" cp stable-8/pve-manager/js/pvemanagerlib.js.patch \ @@ -192,6 +192,11 @@ jobs: cp stable-8/pve-docs/api-viewer/apidoc.js.patch \ "${STAGING}/usr/share/freenas-proxmox/patches/apidoc/8.patch" + # PVE 8.4.x pvemanagerlib patch — JS was reformatted in 8.4, incompatible with 8.patch + cp stable-8/pve-manager/js/pvemanagerlib-8.4.14_1.js.patch \ + "${STAGING}/usr/share/freenas-proxmox/patches/pvemanagerlib/8.4.patch" + echo "Bundled PVE 8.4.x pvemanagerlib patch" + # PVE 7 patches (best-effort — use latest versioned patch available) for type in ZFSPlugin pvemanagerlib apidoc; do case "$type" in diff --git a/packaging/DEBIAN/postinst b/packaging/DEBIAN/postinst index 04bf84b..ee21871 100644 --- a/packaging/DEBIAN/postinst +++ b/packaging/DEBIAN/postinst @@ -18,21 +18,38 @@ log() { echo "[freenas-proxmox] $*" | tee -a "$LOG_FILE" } -# Detect installed Proxmox VE major version +# Detect installed Proxmox VE major version (e.g. "8" from "8.4.19") detect_pve_major() { local ver ver=$(dpkg-query --showformat='${Version}' --show proxmox-ve 2>/dev/null || echo "0") echo "${ver%%.*}" } -# Find the best bundled patch for a given component and PVE major version. -# Walks down from the detected major version until a patch file is found. +# Detect pve-manager major.minor (e.g. "8.4" from "8.4.19"). +# Used to select minor-version-specific patches within a major series. +detect_pve_manager_minor() { + local ver + ver=$(dpkg-query -W -f '${Version}' pve-manager 2>/dev/null || echo "0.0.0") + echo "${ver%.*}" +} + +# Find the best bundled patch for a given component and PVE version. +# Tries {minor}.patch (e.g. 8.4.patch) first, then walks down {major}.patch, +# {major-1}.patch, ... until a match is found. find_patch() { local component="$1" local major="$2" + local minor="$3" # e.g. "8.4" — may be empty local patch_dir="${INSTALL_DIR}/patches/${component}" - local ver="$major" + # Minor-version-specific patch takes priority (e.g. pvemanagerlib/8.4.patch) + if [ -n "$minor" ] && [ -f "${patch_dir}/${minor}.patch" ]; then + echo "${patch_dir}/${minor}.patch" + return 0 + fi + + # Fall back to major-version patch, walking down until one is found + local ver="$major" while [ "$ver" -ge 5 ]; do if [ -f "${patch_dir}/${ver}.patch" ]; then echo "${patch_dir}/${ver}.patch" @@ -92,7 +109,8 @@ restart_pve_services() { # ── Entry point ────────────────────────────────────────────────────────────── major=$(detect_pve_major) -log "Proxmox VE major version detected: ${major}" +pve_mgr_minor=$(detect_pve_manager_minor) +log "Proxmox VE major version: ${major}, pve-manager: ${pve_mgr_minor}" case "$1" in @@ -105,15 +123,15 @@ case "$1" in filename="${filename%.*}" # strip extension case "$filename" in ZFSPlugin) - patch_file=$(find_patch "ZFSPlugin" "$major" || true) + patch_file=$(find_patch "ZFSPlugin" "$major" "$pve_mgr_minor" || true) apply_patch "$ZFSPLUGIN_PATH" "$patch_file" "ZFSPlugin.pm" ;; pvemanagerlib) - patch_file=$(find_patch "pvemanagerlib" "$major" || true) + patch_file=$(find_patch "pvemanagerlib" "$major" "$pve_mgr_minor" || true) apply_patch "$PVEMANAGER_PATH" "$patch_file" "pvemanagerlib.js" ;; apidoc) - patch_file=$(find_patch "apidoc" "$major" || true) + patch_file=$(find_patch "apidoc" "$major" "$pve_mgr_minor" || true) apply_patch "$APIDOC_PATH" "$patch_file" "apidoc.js" ;; esac @@ -127,13 +145,13 @@ case "$1" in log "Configuring freenas-proxmox (previous version: ${2:-none})" CHANGED="no" - patch_file=$(find_patch "ZFSPlugin" "$major" || true) + patch_file=$(find_patch "ZFSPlugin" "$major" "$pve_mgr_minor" || true) apply_patch "$ZFSPLUGIN_PATH" "$patch_file" "ZFSPlugin.pm" && CHANGED="yes" - patch_file=$(find_patch "pvemanagerlib" "$major" || true) + patch_file=$(find_patch "pvemanagerlib" "$major" "$pve_mgr_minor" || true) apply_patch "$PVEMANAGER_PATH" "$patch_file" "pvemanagerlib.js" && CHANGED="yes" - patch_file=$(find_patch "apidoc" "$major" || true) + patch_file=$(find_patch "apidoc" "$major" "$pve_mgr_minor" || true) apply_patch "$APIDOC_PATH" "$patch_file" "apidoc.js" && CHANGED="yes" install_files diff --git a/pve-manager/js/pvemanagerlib-8.4.14_1.js.patch b/pve-manager/js/pvemanagerlib-8.4.14_1.js.patch new file mode 100644 index 0000000..229cb75 --- /dev/null +++ b/pve-manager/js/pvemanagerlib-8.4.14_1.js.patch @@ -0,0 +1,241 @@ +--- /tmp/pvemanagerlib-8.4.14-stock.js 2026-05-19 17:46:16.553917259 -0400 ++++ /tmp/pvemanagerlib-8.4.14-patched.js 2026-05-19 17:49:36.037913599 -0400 +@@ -9745,6 +9745,7 @@ + alias: ['widget.pveiScsiProviderSelector'], + comboItems: [ + ['comstar', 'Comstar'], ++ ['freenas', 'FreeNAS/TrueNAS API'], + ['istgt', 'istgt'], + ['iet', 'IET'], + ['LIO', 'LIO'], +@@ -38511,6 +38512,47 @@ + initComponent: function () { + var me = this; + ++ var tnsecret = Ext.create('Ext.form.TextField', { ++ xtype: 'proxmoxtextfield', ++ name: 'truenas_secret', ++ reference: 'truenas_secret_field', ++ inputType: me.isCreate ? '' : 'password', ++ value: '', ++ editable: true, ++ emptyText: Proxmox.Utils.noneText, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ fieldLabel: gettext('API Password'), ++ change: function (f, value) { ++ if (f.rendered) { ++ f.up().down('field[name=truenas_confirm_secret]').validate(); ++ } ++ }, ++ }); ++ ++ var tnconfirmsecret = Ext.create('Ext.form.TextField', { ++ xtype: 'proxmoxtextfield', ++ name: 'truenas_confirm_secret', ++ reference: 'truenas_confirm_secret_field', ++ inputType: me.isCreate ? '' : 'password', ++ value: '', ++ editable: true, ++ submitValue: false, ++ emptyText: Proxmox.Utils.noneText, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ fieldLabel: gettext('Confirm API Password'), ++ validator: function (value) { ++ var pw = me.up().down('field[name=truenas_secret]').getValue(); ++ if (pw !== value) { ++ return 'Secrets do not match!'; ++ } ++ return true; ++ }, ++ }); ++ + me.column1 = [ + { + xtype: 'proxmoxintegerfield', +@@ -62673,10 +62715,17 @@ + viewModel: { + parent: null, + data: { +- isLIO: false, + isComstar: true, ++ isFreeNAS: false, ++ isLIO: false, ++ isToken: false, + hasWriteCacheOption: true, + }, ++ formulas: { ++ hideUsername: function(get) { ++ return (!get('isFreeNAS') || !(get('isFreeNAS') && !get('isToken'))); ++ }, ++ }, + }, + + controller: { +@@ -62685,12 +62734,41 @@ + 'field[name=iscsiprovider]': { + change: 'changeISCSIProvider', + }, ++ 'field[name=truenas_token_auth]': { ++ change: 'changeUsername', ++ }, + }, + changeISCSIProvider: function (f, newVal, oldVal) { ++ var me = this; + var vm = this.getViewModel(); + vm.set('isLIO', newVal === 'LIO'); + vm.set('isComstar', newVal === 'comstar'); +- vm.set('hasWriteCacheOption', newVal === 'comstar' || newVal === 'istgt'); ++ vm.set('isFreeNAS', newVal === 'freenas'); ++ vm.set('hasWriteCacheOption', newVal === 'comstar' || newVal === 'freenas' || newVal === 'istgt'); ++ if (newVal !== 'freenas') { ++ me.lookupReference('freenas_use_ssl_field').setValue(false); ++ me.lookupReference('truenas_token_auth_field').setValue(false); ++ me.lookupReference('freenas_apiv4_host_field').setValue(''); ++ me.lookupReference('freenas_user_field').setValue(''); ++ me.lookupReference('freenas_user_field').allowBlank = true; ++ me.lookupReference('truenas_secret_field').setValue(''); ++ me.lookupReference('truenas_secret_field').allowBlank = true; ++ me.lookupReference('truenas_confirm_secret_field').setValue(''); ++ me.lookupReference('truenas_confirm_secret_field').allowBlank = true; ++ } else { ++ me.lookupReference('freenas_user_field').allowBlank = false; ++ me.lookupReference('truenas_secret_field').allowBlank = false; ++ me.lookupReference('truenas_confirm_secret_field').allowBlank = false; ++ } ++ }, ++ changeUsername: function (f, newVal, oldVal) { ++ var me = this; ++ var vm = me.getViewModel(); ++ vm.set('isToken', newVal); ++ me.lookupReference('freenas_user_field').allowBlank = newVal; ++ if (newVal) { ++ me.lookupReference('freenas_user_field').setValue(''); ++ } + }, + }, + +@@ -62703,11 +62781,18 @@ + + values.nowritecache = values.writecache ? 0 : 1; + delete values.writecache; ++ if (values.freenas_password) { ++ values.truenas_secret = values.freenas_password; ++ } + + return me.callParent([values]); + }, + + setValues: function (values) { ++ if (values.freenas_password) { ++ values.truenas_secret = values.freenas_password; ++ } ++ values.truenas_confirm_secret = values.truenas_secret; + values.writecache = values.nowritecache ? 0 : 1; + this.callParent([values]); + }, +@@ -62749,9 +62834,60 @@ + name: 'comstar_tg', + value: '', + fieldLabel: gettext('Target group'), +- bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' }, ++ bind: { ++ hidden: '{!isComstar}', ++ }, + allowBlank: true, + }, ++ { ++ xtype: 'proxmoxcheckbox', ++ name: 'freenas_use_ssl', ++ reference: 'freenas_use_ssl_field', ++ inputId: 'freenas_use_ssl_field', ++ checked: false, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ uncheckedValue: 0, ++ fieldLabel: gettext('API use SSL'), ++ }, ++ { ++ xtype: 'proxmoxcheckbox', ++ name: 'truenas_token_auth', ++ reference: 'truenas_token_auth_field', ++ inputId: 'truenas_use_token_auth_field', ++ checked: false, ++ listeners: { ++ change: function (field, newValue) { ++ if (newValue === true) { ++ tnsecret.labelEl.update('API Token'); ++ tnconfirmsecret.labelEl.update('Confirm API Token'); ++ me.lookupReference('freenas_user_field').setValue(''); ++ me.lookupReference('freenas_user_field').allowBlank = true; ++ } else { ++ tnsecret.labelEl.update('API Password'); ++ tnconfirmsecret.labelEl.update('Confirm API Password'); ++ me.lookupReference('freenas_user_field').allowBlank = false; ++ } ++ }, ++ }, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ uncheckedValue: 0, ++ fieldLabel: gettext('API Token Auth'), ++ }, ++ { ++ xtype: 'textfield', ++ name: 'freenas_user', ++ reference: 'freenas_user_field', ++ inputId: 'freenas_user_field', ++ value: '', ++ fieldLabel: gettext('API Username'), ++ bind: { ++ hidden: '{hideUsername}', ++ }, ++ }, + ]; + + me.column2 = [ +@@ -62783,7 +62919,9 @@ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'comstar_hg', + value: '', +- bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' }, ++ bind: { ++ hidden: '{!isComstar}', ++ }, + fieldLabel: gettext('Host group'), + allowBlank: true, + }, +@@ -62791,10 +62929,26 @@ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'lio_tpg', + value: '', +- bind: me.isCreate ? { disabled: '{!isLIO}' } : { hidden: '{!isLIO}' }, +- allowBlank: false, ++ bind: { ++ hidden: '{!isLIO}', ++ }, ++ allowBlank: true, + fieldLabel: gettext('Target portal group'), + }, ++ { ++ xtype: 'proxmoxtextfield', ++ name: 'freenas_apiv4_host', ++ reference: 'freenas_apiv4_host_field', ++ value: '', ++ editable: true, ++ emptyText: Proxmox.Utils.noneText, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ fieldLabel: gettext('API IPv4 Host'), ++ }, ++ tnsecret, ++ tnconfirmsecret, + ]; + + me.callParent(); diff --git a/stable-8/pve-manager/js/pvemanagerlib-8.4.14_1.js.patch b/stable-8/pve-manager/js/pvemanagerlib-8.4.14_1.js.patch new file mode 100644 index 0000000..229cb75 --- /dev/null +++ b/stable-8/pve-manager/js/pvemanagerlib-8.4.14_1.js.patch @@ -0,0 +1,241 @@ +--- /tmp/pvemanagerlib-8.4.14-stock.js 2026-05-19 17:46:16.553917259 -0400 ++++ /tmp/pvemanagerlib-8.4.14-patched.js 2026-05-19 17:49:36.037913599 -0400 +@@ -9745,6 +9745,7 @@ + alias: ['widget.pveiScsiProviderSelector'], + comboItems: [ + ['comstar', 'Comstar'], ++ ['freenas', 'FreeNAS/TrueNAS API'], + ['istgt', 'istgt'], + ['iet', 'IET'], + ['LIO', 'LIO'], +@@ -38511,6 +38512,47 @@ + initComponent: function () { + var me = this; + ++ var tnsecret = Ext.create('Ext.form.TextField', { ++ xtype: 'proxmoxtextfield', ++ name: 'truenas_secret', ++ reference: 'truenas_secret_field', ++ inputType: me.isCreate ? '' : 'password', ++ value: '', ++ editable: true, ++ emptyText: Proxmox.Utils.noneText, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ fieldLabel: gettext('API Password'), ++ change: function (f, value) { ++ if (f.rendered) { ++ f.up().down('field[name=truenas_confirm_secret]').validate(); ++ } ++ }, ++ }); ++ ++ var tnconfirmsecret = Ext.create('Ext.form.TextField', { ++ xtype: 'proxmoxtextfield', ++ name: 'truenas_confirm_secret', ++ reference: 'truenas_confirm_secret_field', ++ inputType: me.isCreate ? '' : 'password', ++ value: '', ++ editable: true, ++ submitValue: false, ++ emptyText: Proxmox.Utils.noneText, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ fieldLabel: gettext('Confirm API Password'), ++ validator: function (value) { ++ var pw = me.up().down('field[name=truenas_secret]').getValue(); ++ if (pw !== value) { ++ return 'Secrets do not match!'; ++ } ++ return true; ++ }, ++ }); ++ + me.column1 = [ + { + xtype: 'proxmoxintegerfield', +@@ -62673,10 +62715,17 @@ + viewModel: { + parent: null, + data: { +- isLIO: false, + isComstar: true, ++ isFreeNAS: false, ++ isLIO: false, ++ isToken: false, + hasWriteCacheOption: true, + }, ++ formulas: { ++ hideUsername: function(get) { ++ return (!get('isFreeNAS') || !(get('isFreeNAS') && !get('isToken'))); ++ }, ++ }, + }, + + controller: { +@@ -62685,12 +62734,41 @@ + 'field[name=iscsiprovider]': { + change: 'changeISCSIProvider', + }, ++ 'field[name=truenas_token_auth]': { ++ change: 'changeUsername', ++ }, + }, + changeISCSIProvider: function (f, newVal, oldVal) { ++ var me = this; + var vm = this.getViewModel(); + vm.set('isLIO', newVal === 'LIO'); + vm.set('isComstar', newVal === 'comstar'); +- vm.set('hasWriteCacheOption', newVal === 'comstar' || newVal === 'istgt'); ++ vm.set('isFreeNAS', newVal === 'freenas'); ++ vm.set('hasWriteCacheOption', newVal === 'comstar' || newVal === 'freenas' || newVal === 'istgt'); ++ if (newVal !== 'freenas') { ++ me.lookupReference('freenas_use_ssl_field').setValue(false); ++ me.lookupReference('truenas_token_auth_field').setValue(false); ++ me.lookupReference('freenas_apiv4_host_field').setValue(''); ++ me.lookupReference('freenas_user_field').setValue(''); ++ me.lookupReference('freenas_user_field').allowBlank = true; ++ me.lookupReference('truenas_secret_field').setValue(''); ++ me.lookupReference('truenas_secret_field').allowBlank = true; ++ me.lookupReference('truenas_confirm_secret_field').setValue(''); ++ me.lookupReference('truenas_confirm_secret_field').allowBlank = true; ++ } else { ++ me.lookupReference('freenas_user_field').allowBlank = false; ++ me.lookupReference('truenas_secret_field').allowBlank = false; ++ me.lookupReference('truenas_confirm_secret_field').allowBlank = false; ++ } ++ }, ++ changeUsername: function (f, newVal, oldVal) { ++ var me = this; ++ var vm = me.getViewModel(); ++ vm.set('isToken', newVal); ++ me.lookupReference('freenas_user_field').allowBlank = newVal; ++ if (newVal) { ++ me.lookupReference('freenas_user_field').setValue(''); ++ } + }, + }, + +@@ -62703,11 +62781,18 @@ + + values.nowritecache = values.writecache ? 0 : 1; + delete values.writecache; ++ if (values.freenas_password) { ++ values.truenas_secret = values.freenas_password; ++ } + + return me.callParent([values]); + }, + + setValues: function (values) { ++ if (values.freenas_password) { ++ values.truenas_secret = values.freenas_password; ++ } ++ values.truenas_confirm_secret = values.truenas_secret; + values.writecache = values.nowritecache ? 0 : 1; + this.callParent([values]); + }, +@@ -62749,9 +62834,60 @@ + name: 'comstar_tg', + value: '', + fieldLabel: gettext('Target group'), +- bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' }, ++ bind: { ++ hidden: '{!isComstar}', ++ }, + allowBlank: true, + }, ++ { ++ xtype: 'proxmoxcheckbox', ++ name: 'freenas_use_ssl', ++ reference: 'freenas_use_ssl_field', ++ inputId: 'freenas_use_ssl_field', ++ checked: false, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ uncheckedValue: 0, ++ fieldLabel: gettext('API use SSL'), ++ }, ++ { ++ xtype: 'proxmoxcheckbox', ++ name: 'truenas_token_auth', ++ reference: 'truenas_token_auth_field', ++ inputId: 'truenas_use_token_auth_field', ++ checked: false, ++ listeners: { ++ change: function (field, newValue) { ++ if (newValue === true) { ++ tnsecret.labelEl.update('API Token'); ++ tnconfirmsecret.labelEl.update('Confirm API Token'); ++ me.lookupReference('freenas_user_field').setValue(''); ++ me.lookupReference('freenas_user_field').allowBlank = true; ++ } else { ++ tnsecret.labelEl.update('API Password'); ++ tnconfirmsecret.labelEl.update('Confirm API Password'); ++ me.lookupReference('freenas_user_field').allowBlank = false; ++ } ++ }, ++ }, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ uncheckedValue: 0, ++ fieldLabel: gettext('API Token Auth'), ++ }, ++ { ++ xtype: 'textfield', ++ name: 'freenas_user', ++ reference: 'freenas_user_field', ++ inputId: 'freenas_user_field', ++ value: '', ++ fieldLabel: gettext('API Username'), ++ bind: { ++ hidden: '{hideUsername}', ++ }, ++ }, + ]; + + me.column2 = [ +@@ -62783,7 +62919,9 @@ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'comstar_hg', + value: '', +- bind: me.isCreate ? { disabled: '{!isComstar}' } : { hidden: '{!isComstar}' }, ++ bind: { ++ hidden: '{!isComstar}', ++ }, + fieldLabel: gettext('Host group'), + allowBlank: true, + }, +@@ -62791,10 +62929,26 @@ + xtype: me.isCreate ? 'textfield' : 'displayfield', + name: 'lio_tpg', + value: '', +- bind: me.isCreate ? { disabled: '{!isLIO}' } : { hidden: '{!isLIO}' }, +- allowBlank: false, ++ bind: { ++ hidden: '{!isLIO}', ++ }, ++ allowBlank: true, + fieldLabel: gettext('Target portal group'), + }, ++ { ++ xtype: 'proxmoxtextfield', ++ name: 'freenas_apiv4_host', ++ reference: 'freenas_apiv4_host_field', ++ value: '', ++ editable: true, ++ emptyText: Proxmox.Utils.noneText, ++ bind: { ++ hidden: '{!isFreeNAS}', ++ }, ++ fieldLabel: gettext('API IPv4 Host'), ++ }, ++ tnsecret, ++ tnconfirmsecret, + ]; + + me.callParent();