Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions COMPATIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion app/utils/unallocated-volumes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
32 changes: 32 additions & 0 deletions docs/releases/web-console-1.6.169.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions scripts/check-modernization-blockers
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion scripts/check-ui-console-workspace
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion scripts/check-ui-critical-high-dependencies
Original file line number Diff line number Diff line change
Expand Up @@ -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')}")
Expand Down
75 changes: 74 additions & 1 deletion tests/unit/utils/unallocated-volumes-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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();
Expand Down
Loading