From 7f794a2e3315b87e17970843a4c5fab4dcbcb250 Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 07:27:38 +0800 Subject: [PATCH 1/3] Fix generated local volume identifiers in Web Console 1.6.169 --- COMPATIBILITY.md | 8 +++ README.md | 10 +++ app/utils/unallocated-volumes.js | 5 +- docs/releases/web-console-1.6.169.md | 32 +++++++++ package-lock.json | 4 +- package.json | 2 +- tests/unit/utils/unallocated-volumes-test.js | 75 +++++++++++++++++++- 7 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 docs/releases/web-console-1.6.169.md diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 3d7e5b3195..8267c91c9b 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. +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, +storage-pool and mount checks, current-project schema ownership, permission +notices and backend authorization are unchanged. No forced activation or +deactivation is added: an inactive unallocated volume uses its advertised remove +action. Publication and packaged lifecycle acceptance remain pending. + Published Web Console `1.6.167` normalizes only schema-cache lookup IDs, matching the existing `_bulkAdd` producer. Mixed-case API types resolve the same cached schema through `Resource.schema`, `canCreate`, `canList` and schema-based update diff --git a/README.md b/README.md index 2aa3126562..160a30c2f4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,16 @@ PastureStack is an independent community effort to preserve, audit, and moderniz ## Project status +Web Console `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 +still requires explicit local/non-native fields, no host/image/instance binding, +the complete advertised storage-pool relationship and the full scoped mount +cache. Identifier changes invalidate stale relationship proof. No API permission, +authentication or lifecycle request is changed. Publication and packaged +create/refresh/remove acceptance are pending; see the +[release note](docs/releases/web-console-1.6.169.md). + Published `1.6.168` makes the Volume Add control and direct create route use the current environment's actual schema capability. The shared create/upgrade route guard rejects missing or stale environment schemas; upgrades still require diff --git a/app/utils/unallocated-volumes.js b/app/utils/unallocated-volumes.js index e5a29e4c10..242ce2b7a8 100644 --- a/app/utils/unallocated-volumes.js +++ b/app/utils/unallocated-volumes.js @@ -8,13 +8,16 @@ const allocationFields = ['id', 'accountId', 'driver', 'state', 'removed', 'isNa 'hostId', 'imageId', 'instanceId', 'externalId', 'links.storagePools', 'store.generation', 'store.baseUrl']; function isCandidate(volume, projectId) { + // Engine may generate this identifier from the name; it is not allocation. + const externalId = volume && get(volume, 'externalId'); return Boolean(volume && typeof projectId === 'string' && projectId && typeof get(volume, 'id') === 'string' && get(volume, 'id') && get(volume, 'type') === 'volume' && get(volume, 'accountId') === projectId && get(volume, 'driver') === 'local' && get(volume, 'isNative') === false && get(volume, 'isHostPath') === false && get(volume, 'removed') === null && typeof get(volume, 'state') === 'string' && !C.REMOVEDISH_STATES.includes(get(volume, 'state')) && - ['hostId', 'imageId', 'instanceId', 'externalId'].every((field) => get(volume, field) === null)); + (externalId === null || typeof externalId === 'string') && + ['hostId', 'imageId', 'instanceId'].every((field) => get(volume, field) === null)); } function emptyArray(value) { diff --git a/docs/releases/web-console-1.6.169.md b/docs/releases/web-console-1.6.169.md new file mode 100644 index 0000000000..5dadf09726 --- /dev/null +++ b/docs/releases/web-console-1.6.169.md @@ -0,0 +1,32 @@ +# Web Console 1.6.169 + +## Scope and root cause + +The shared unallocated-local-volume classifier incorrectly required `externalId` +to be null. The engine's existing `VolumeExternalIdPreCreate` handler generates +that identifier from a volume name when no image is attached. Consequently a +successfully created, inactive local volume could disappear from the independent +local-volume list despite having no host, workload or storage-pool allocation. + +The classifier no longer treats a string identifier as allocation. It retains +the identifier in the relation-proof identity so stale asynchronous reads cannot +restore capability after metadata changes. Explicit resource classification, +current environment, null host/image/instance references, complete pool reads +and full scoped mount-cache checks remain mandatory. Missing relationships never +mean unused. No backend permission, authentication, request method or lifecycle +transition changes; inactive volumes use their existing advertised remove action. + +## Verification and publication + +Focused real Store/Volume/Collection regression tests and formal build validation +are pending. Packaged browser create, cancel, refresh, readonly same-ID denial and +remove acceptance are also pending. Historical failed acceptance receipts remain +failed and are not retrospectively promoted. The full permission matrix remains +INCOMPLETE. No company deployment is part of this repair. + +## Upgrade and rollback + +Use a new immutable component tag and a new Server patch image after publication; +do not overwrite `1.6.168` or Server `v1.6.505`. The change requires no migration. +Existing persistent volumes, runtime configuration and the previous immutable +Server image remain the rollback boundary. diff --git a/package-lock.json b/package-lock.json index a0ec81266b..312083f0ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pasturestack/web-console", - "version": "1.6.168", + "version": "1.6.169", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.168", + "version": "1.6.169", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1" diff --git a/package.json b/package.json index 20f7bba89a..26bae12815 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@pasturestack/web-console", - "version": "1.6.168", + "version": "1.6.169", "private": true, "description": "PastureStack browser console for the compatible control platform.", "repository": { diff --git a/tests/unit/utils/unallocated-volumes-test.js b/tests/unit/utils/unallocated-volumes-test.js index b02a3331be..ec43108ed8 100644 --- a/tests/unit/utils/unallocated-volumes-test.js +++ b/tests/unit/utils/unallocated-volumes-test.js @@ -79,7 +79,7 @@ module('Unit | Utils | unallocated volumes', function() { driver: ['docker', null, 'external'], accountId: ['1a2541', null], isNative: [true, undefined], isHostPath: [true, undefined], hostId: ['1h1', undefined], imageId: ['1i1', undefined], instanceId: ['1i2', undefined], - removed: ['2026-10-03T00:00:00Z', undefined], externalId: ['docker-id', undefined, false, 0], + removed: ['2026-10-03T00:00:00Z', undefined], externalId: [undefined, false, 0, {}], state: ['removed', 'purging', 'purged', undefined], }; for (let field of Object.keys(exclusions)) { @@ -96,6 +96,79 @@ module('Unit | Utils | unallocated volumes', function() { } finally { f.dispose(); } }); + test('inactive local volumes with generated, hashed or custom external IDs remain unallocated', async function(assert) { + const f = volumeFixture(); + const variants = [ + {name: 'local-volume', externalId: 'local-volume'}, + // Engine VolumeUtils.externalId produces this for the 128-character name. + {name: 'a'.repeat(128), externalId: 'e510683b3f5ffe4093d021808bc6ff70'}, + {name: 'custom-volume', externalId: 'explicit-custom-id'}, + {name: 'nullable-volume', externalId: null}, + ]; + try { + for (let i = 0; i < variants.length; i++) { + const id = `1v-external-${i}`; + const volume = f.volume({id, state: 'inactive', ...variants[i], + links: {storagePools: `/v2-beta/projects/1a2540/volumes/${id}/storagepools`}}); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'metadata does not replace relation proof'); + await refreshUnallocatedVolumeRelations([volume], '1a2540'); + assert.true(isUnallocatedLocalVolume(volume, '1a2540'), 'external ID is not a host, pool or mount'); + assert.strictEqual(volume.get('externalId'), variants[i].externalId, 'never rewrite or derive the ID in UI'); + assert.strictEqual(volume.get('storagePools.type'), 'collection', 'real Store relationship is preserved'); + } + assert.strictEqual(f.requests.length, variants.length, 'each exact volume relationship is read once'); + } finally { f.dispose(); } + }); + + test('external ID metadata cannot bypass typed input, pool allocation or inactive mounts', async function(assert) { + const f = volumeFixture(); + try { + for (let i = 0; i < 5; i++) { + const value = [undefined, false, 0, {}, []][i]; + const malformed = f.volume({id: `1v-malformed-${i}`, state: 'inactive', externalId: value}); + await refreshUnallocatedVolumeRelations([malformed], '1a2540'); + assert.false(isUnallocatedLocalVolume(malformed, '1a2540'), 'only schema nullable string is accepted'); + } + assert.strictEqual(f.requests.length, 0, 'invalid metadata never starts a relation read'); + const volume = f.volume({state: 'inactive', externalId: 'custom-id'}); + f.store.rawRequest = () => resolve({status: 200, body: {type: 'collection', resourceType: 'storagePool', + data: [{type: 'storagePool', id: '1sp-mapped'}], pagination: {partial: false}}}); + await refreshUnallocatedVolumeRelations([volume], '1a2540'); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'actual nonempty pool relationship stays allocated'); + f.store.rawRequest = () => resolve({status: 200, body: {type: 'collection', resourceType: 'storagePool', data: []}}); + f.store.incrementProperty('generation'); + await refreshUnallocatedVolumeRelations([volume], '1a2540'); + assert.true(isUnallocatedLocalVolume(volume, '1a2540')); + f.store._typeify({type: 'mount', id: '1m-external', volumeId: volume.get('id'), + instanceId: 'not-in-cache', state: 'inactive'}); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'inactive mount remains binding regardless of external ID'); + } finally { f.dispose(); } + }); + + test('external ID changes invalidate completed and late relationship identity proofs', async function(assert) { + const f = volumeFixture(); + const volume = f.volume({state: 'inactive', externalId: 'generated-id'}); + try { + await refreshUnallocatedVolumeRelations([volume], '1a2540'); + assert.true(isUnallocatedLocalVolume(volume, '1a2540')); + volume.set('externalId', 'custom-id'); + assert.false(isUnallocatedLocalVolume(volume, '1a2540'), 'old complete relationship cannot certify changed metadata'); + await refreshUnallocatedVolumeRelations([volume], '1a2540'); + assert.strictEqual(f.requests.length, 2, 'changed identity requires its own read'); + assert.true(isUnallocatedLocalVolume(volume, '1a2540')); + const response = defer(); + f.store.rawRequest = () => response.promise; + volume.set('externalId', 'dispatch-id'); + const loading = refreshUnallocatedVolumeRelations([volume], '1a2540'); + await resolve(); + volume.set('externalId', 'later-id'); + response.resolve({status: 200, body: {type: 'collection', resourceType: 'storagePool', data: []}}); + await loading; + assert.strictEqual(volume.get('storagePools'), undefined, 'late prior-ID relation cannot bind'); + assert.false(isUnallocatedLocalVolume(volume, '1a2540')); + } finally { f.dispose(); } + }); + test('full mount cache excludes inactive and unresolved workload references by exact volume ID', async function(assert) { const f = volumeFixture(); const volume = f.volume(); From ab93577df5e5041a28a25588eee1762c1c8c66ed Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 07:34:17 +0800 Subject: [PATCH 2/3] Align Web Console 1.6.169 artifact validation gates --- scripts/check-modernization-blockers | 4 ++-- scripts/check-ui-console-workspace | 2 +- scripts/check-ui-critical-high-dependencies | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/check-modernization-blockers b/scripts/check-modernization-blockers index 60d9c93612..b19b9f459b 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.168" ]]; then - echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.168" +if [[ "$version" != "1.6.169" ]]; then + echo "UNEXPECTED_UI_ARTIFACT_VERSION version=$version expected=1.6.169" failures=$((failures + 1)) fi diff --git a/scripts/check-ui-console-workspace b/scripts/check-ui-console-workspace index 294b9fc762..d4dad18005 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.168 browser-session broker-broadcast + 1.6.169 browser-session broker-broadcast diff --git a/scripts/check-ui-critical-high-dependencies b/scripts/check-ui-critical-high-dependencies index 0aa4d5e031..068b836033 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.168": +if package.get("version") != "1.6.169": 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')}") From 5962f57fccb4062a65b5921646c06b4663713b9b Mon Sep 17 00:00:00 2001 From: "Cheng-Chen, Chen" Date: Sat, 3 Oct 2026 07:34:50 +0800 Subject: [PATCH 3/3] Align reviewed lock baseline root version with Web Console 1.6.169 --- ...m-package-lock.sass-replacement.node24-ignore-scripts.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a0ec81266b..312083f0ce 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.168", + "version": "1.6.169", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pasturestack/web-console", - "version": "1.6.168", + "version": "1.6.169", "license": "Apache-2.0", "dependencies": { "sass": "1.103.1"