From 09df1480c5f4b58c6a9a9060ff94d980792f7015 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 08:29:05 +0800 Subject: [PATCH] Fix null expanded mounts projection without weakening allocation proof --- COMPATIBILITY.md | 15 +++++-- README.md | 13 +++++- app/utils/unallocated-volumes.js | 10 ++++- ...ass-replacement.node24-ignore-scripts.json | 4 +- docs/releases/web-console-1.6.170.md | 45 +++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- scripts/check-modernization-blockers | 4 +- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 2 +- tests/unit/utils/unallocated-volumes-test.js | 44 ++++++++++++++++++ 11 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 docs/releases/web-console-1.6.170.md diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index fe4aa759cb..5598b690aa 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -4,6 +4,14 @@ Web Console preserves compatible API paths, schema and resource names, action na Visible branding, product-owned assets, icon identifiers, package metadata, and operator documentation use PastureStack. Historical identifiers remain only where they are server data or protocol contracts and must not be mechanically replaced. +Source candidate `1.6.170` accepts null only for the optional expanded `mounts` +projection while retaining the real complete empty pool relationship and full +scoped mount-cache proof. It preserves nonempty raw ID binding, current-project +ownership, stale-proof invalidation and backend authorization. Null raw IDs and +malformed array-like objects remain excluded. No API or authentication contract +changes. Formal publication and packaged native lifecycle acceptance remain +pending; see the [release note](docs/releases/web-console-1.6.170.md). + Published Web Console `1.6.169` treats Volume `externalId` as an identifier rather than an allocation reference, matching the existing engine pre-create contract. The field remains part of relationship-proof invalidation. Host, image, instance, @@ -16,9 +24,10 @@ produced two byte-identical production archives. The signed immutable numeric release pins source `5962f57fccb4062a65b5921646c06b4663713b9b`; anonymous public readback matches archive SHA-256 `e2bcb97b0da810f2ff216f9738739235e3c6f29ef46f1d99b623cf9c9f7258e2` -and size 2,981,057 bytes. Server `v1.6.506` / PR231 is still a source -candidate, not a formally published Server artifact. Packaged native -existing-volume terminal and fresh lifecycle acceptance remain pending. +and size 2,981,057 bytes. Server `v1.6.506` is published and deployed on QA 8080; +initial/restart checks passed with configuration and persistent volumes retained. +Its native existing-volume terminal exposed the null-projection defect above; +fresh lifecycle acceptance remains pending. Historical HOLDs remain HOLD; the complete permission/resource/locale matrix remains INCOMPLETE. See the [release note](docs/releases/web-console-1.6.169.md). diff --git a/README.md b/README.md index 6cd1322abd..10e2b4a5ab 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,14 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Source candidate `1.6.170` corrects an optional `mounts: null` projection being +mistaken for a real allocation in the shared local-volume list. It preserves +the complete advertised pool relationship, full scoped mount cache, exact-volume +binding checks and backend permissions. Raw IDs and malformed values do not +become empty evidence. Formal publication and packaged native lifecycle +acceptance remain pending; historical HOLDs and the complete matrix are not +promoted. See the [release note](docs/releases/web-console-1.6.170.md). + Published `1.6.169` corrects the shared unallocated-local-volume classifier. The engine may generate `externalId` from a volume's name; that identifier does not allocate the volume to a host, workload or storage pool. Classification @@ -23,8 +31,9 @@ production archives. The signed immutable numeric pins source `5962f57fccb4062a65b5921646c06b4663713b9b`; archive SHA-256 is `e2bcb97b0da810f2ff216f9738739235e3c6f29ef46f1d99b623cf9c9f7258e2` (2,981,057 bytes). Anonymous public downloads match the formal artifact. -Server `v1.6.506` remains a source candidate in PR231, not a formally -published Server artifact. Packaged native existing-volume terminal and fresh +Server `v1.6.506` is now published and deployed on the QA 8080 host, with initial +and restart HTTP 200 checks and preserved configuration/volumes. The packaged +native existing-volume terminal found the null-projection defect above; fresh create/cancel/refresh/readonly-denial/remove acceptance remain pending. Historical HOLDs are not promoted; the complete permission/resource/locale matrix remains INCOMPLETE. See the diff --git a/app/utils/unallocated-volumes.js b/app/utils/unallocated-volumes.js index 242ce2b7a8..4b6da2aefc 100644 --- a/app/utils/unallocated-volumes.js +++ b/app/utils/unallocated-volumes.js @@ -21,7 +21,10 @@ function isCandidate(volume, projectId) { } function emptyArray(value) { - return isArray(value) && get(value, 'length') === 0; + // Ember's isArray also accepts arbitrary objects with a length property. + // Require a native array or actual Ember Array API, not an API object impostor. + return isArray(value) && (Array.isArray(value) || typeof get(value, 'objectAt') === 'function') && + get(value, 'length') === 0; } function completeCollection(value) { @@ -42,7 +45,10 @@ export function isUnallocatedLocalVolume(volume, projectId) { } for (let field of ['storagePoolIds', 'mountIds', 'mounts']) { let value = get(volume, field); - if (value !== undefined && !emptyArray(value)) { + // API can explicitly serialize the optional expanded mounts projection as null. + // This is not absence proof: the complete pool read and scoped mount cache + // above/below are still mandatory, and nonempty raw IDs remain binding. + if (value !== undefined && !(field === 'mounts' && value === null) && !emptyArray(value)) { return false; } } diff --git a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json index 312083f0ce..3f22097502 100644 --- a/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json +++ b/docs/baselines/npm-package-lock.sass-replacement.node24-ignore-scripts.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.169", + "version": "1.6.170", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.169", + "version": "1.6.170", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/docs/releases/web-console-1.6.170.md b/docs/releases/web-console-1.6.170.md new file mode 100644 index 0000000000..85c9a06cdf --- /dev/null +++ b/docs/releases/web-console-1.6.170.md @@ -0,0 +1,45 @@ +# Web Console 1.6.170 + +Source candidate; formal component publication and packaged native acceptance +remain separate gates. + +## Root cause and scope + +QA Server v1.6.506 supplied the exact inactive local Volume in the current +environment, with a complete empty storage-pool Collection and complete scoped +mount cache. The API also supplied `mounts: null`. The resource relationship +setter correctly retained that optional expanded projection, but the shared +unallocated-volume classifier rejected it as an allocation. The Store held the +resource while the native table incorrectly hid it. + +The classifier accepts null only for the optional expanded `mounts` projection. +It does not replace it with an empty array or treat it as absence proof. The +real complete empty storage-pool relationship, full current-environment mount +cache, absence of exact-volume mounts (including inactive workloads), allocation +fingerprint and explicit local/non-native fields remain required. Raw nonempty +mount/pool IDs remain binding; null or malformed raw ID fields do not qualify. +An arbitrary object with `length: 0` is not an empty native/Ember array. + +No API method, authorization, authentication, resource transition, backend, +HAProxy or production configuration changes. No migration is required. + +## Verification boundary + +The new real Store/Volume/Collection regression deserializes the null projection +through the actual computed setter. Positive classification still requires +real pool and mount evidence; incomplete/partial/nonempty pool data, incomplete +mount cache, inactive mounts, nonempty IDs and malformed values remain excluded. +Focused headless Chrome 153 validation passed 13/13 directly affected classifier +and storage-route cases, with zero failures, skips or todo. Independent scoped +review found no blocker. This is source regression evidence, not packaged QA. +Formal immutable component/archive results and packaged browser lifecycle +results will be recorded after they are actually complete. Earlier failed +native row receipts remain HOLD; the complete resource/permission/locale matrix +is not claimed as PASS. + +## Upgrade and rollback + +Use the separately published Server patch containing this exact component. +Do not overwrite Web Console 1.6.169 or Server v1.6.506. Existing named volumes, +runtime environment and the previous immutable Server image remain the rollback +boundary. This repair does not require or authorize company deployment. diff --git a/package-lock.json b/package-lock.json index 312083f0ce..3f22097502 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.169", + "version": "1.6.170", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.169", + "version": "1.6.170", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 26bae12815..1902dab79e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.169", + "version": "1.6.170", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index b19b9f459b..5c58ee80b0 100755 --- a/scripts/check-modernization-blockers +++ b/scripts/check-modernization-blockers @@ -41,8 +41,8 @@ with open('package.json', encoding='utf-8') as f: print(json.load(f).get('version', '')) PY ) -if [[ "$version" != "1.6.169" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.169" +if [[ "$version" != "1.6.170" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.170" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index d4dad18005..3d3469751a 100755 --- a/scripts/check-ui-console-workspace +++ b/scripts/check-ui-console-workspace @@ -141,4 +141,4 @@ if [[ -n ${PASTURESTACK_PRIVATE_MARKER:-} ]] && grep -RInF -- "$PASTURESTACK_PRI fi printf 'UI_CONSOLE_WORKSPACE_OK version=%s persistence=%s cross_tab=%s\n' \ - 1.6.169 browser-session broker-broadcast + 1.6.170 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 068b836033..894541c21c 100755 --- a/scripts/check-ui-critical-high-dependencies +++ b/scripts/check-ui-critical-high-dependencies @@ -66,7 +66,7 @@ if lock_bytes != baseline_bytes: lock = json.loads(lock_bytes) packages = lock.get("packages", {}) root = packages.get("", {}) -if package.get("version") != "1.6.169": +if package.get("version") != "1.6.170": fail(f"unexpected Web Console version: {package.get('version')}") if root.get("version") != package.get("version"): fail(f"lock root version differs: {root.get('version')}") diff --git a/tests/unit/utils/unallocated-volumes-test.js b/tests/unit/utils/unallocated-volumes-test.js index ec43108ed8..444206a0cd 100644 --- a/tests/unit/utils/unallocated-volumes-test.js +++ b/tests/unit/utils/unallocated-volumes-test.js @@ -120,6 +120,50 @@ module('Unit | Utils | unallocated volumes', function() { } finally { f.dispose(); } }); + test('null API mounts projection needs real pool and full mount-cache proof', async function(assert) { + const f = volumeFixture(); + // _typeify exercises the computed relationship setter used by actual API + // deserialization; missing mountIds alone otherwise computes an empty array. + const volume = f.volume({state: 'inactive', externalId: 'local-volume', mounts: null}); + try { + assert.strictEqual(volume.get('mounts'), null, 'API null is preserved by the expanded relationship setter'); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'null projection alone proves nothing'); + await refreshUnallocatedVolumeRelations([volume], '1a2540'); + assert.true(isUnallocatedLocalVolume(volume, '1a2540'), 'complete empty pool and mount evidence admits null projection'); + f.store._state.foundAll.mount = false; + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'null projection cannot bypass incomplete mount cache'); + f.store._state.foundAll.mount = true; + const mount = f.store._typeify({type: 'mount', id: '1m-nullable', volumeId: volume.get('id'), + instanceId: 'not-in-cache', state: 'inactive'}); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'actual inactive mount beats null expanded relationship'); + f.store._remove('mount', mount); + assert.true(isUnallocatedLocalVolume(volume, '1a2540')); + for (const field of ['storagePoolIds', 'mountIds', 'mounts']) { + for (const value of [['not-in-cache'], false, 0, '', {}, {length: 0}]) { + volume.set(field, value); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), `${field} malformed or nonempty cannot qualify`); + } + if (field === 'mounts') { + volume.set(field, null); + } else { + volume.set(field, null); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'null raw ID field is not valid array input'); + volume.set(field, undefined); + } + } + f.store.incrementProperty('generation'); + f.store.rawRequest = () => resolve({status: 200, body: {type: 'collection', resourceType: 'storagePool', + data: [], pagination: {partial: true}}}); + await assert.rejects(refreshUnallocatedVolumeRelations([volume], '1a2540')); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'null projection cannot bypass partial pool response'); + f.store.incrementProperty('generation'); + f.store.rawRequest = () => resolve({status: 200, body: {type: 'collection', resourceType: 'storagePool', + data: [{type: 'storagePool', id: '1sp-nullable'}], pagination: {partial: false}}}); + await refreshUnallocatedVolumeRelations([volume], '1a2540'); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'actual pool allocation beats null projection'); + } finally { f.dispose(); } + }); + test('external ID metadata cannot bypass typed input, pool allocation or inactive mounts', async function(assert) { const f = volumeFixture(); try {