From 7fb4b45c96a5f484e05162a86194ea9737a401d7 Mon Sep 17 00:00:00 2001 From: SANRAJ RAJENDRA BANDRE Date: Fri, 17 Jul 2026 09:05:35 -0700 Subject: [PATCH] Capacity Scheduler View: tolerant node label response parsing getNodeLabels assumed the ResourceManager node-label response was always a JSON string and chose the response shape from the stack version. That check (stackVersion >= 2.5) is unreliable: Number('3.2.0') is NaN, so 3-part versions silently fell through to the legacy branch. Extract normalization into QueueAdapter#parseNodeLabels, which: - parses only when the response is a string; accepts an already-parsed object - unwraps an optional nodeLabelsInfo wrapper - supports nodeLabelInfo (array or single object) and legacy nodeLabels - coerces exclusivity to a Boolean (true / "true" -> true) - derives the shape from the payload, not the stack version isNodeLabelsConfiguredByRM semantics are unchanged. Adds unit tests covering wrapper, JSON string, array, single-object, legacy, boolean/string exclusivity, malformed JSON, and empty responses. --- .../src/main/resources/ui/app/adapters.js | 97 +++++++++------- .../ui/test/unit/adapters/adapters_test.js | 107 ++++++++++++++++++ 2 files changed, 162 insertions(+), 42 deletions(-) create mode 100644 contrib/views/capacity-scheduler/src/main/resources/ui/test/unit/adapters/adapters_test.js diff --git a/contrib/views/capacity-scheduler/src/main/resources/ui/app/adapters.js b/contrib/views/capacity-scheduler/src/main/resources/ui/app/adapters.js index 4c7da129370..edc51f6eb7e 100644 --- a/contrib/views/capacity-scheduler/src/main/resources/ui/app/adapters.js +++ b/contrib/views/capacity-scheduler/src/main/resources/ui/app/adapters.js @@ -243,55 +243,68 @@ App.QueueAdapter = DS.Adapter.extend({ },'App: QueueAdapter#findAllTagged ' + tag); }, - getNodeLabels:function (store) { - var uri = [_getCapacitySchedulerViewUri(this),'nodeLabels'].join('/'); - var stackId = store.get('stackId'), - stackVersion = stackId.substr(stackId.indexOf('-') + 1); + getNodeLabels: function (store) { + var uri = [_getCapacitySchedulerViewUri(this), 'nodeLabels'].join('/'), + adapter = this; if (App.testMode) uri = uri + ".json"; - return new Ember.RSVP.Promise(function(resolve, reject) { - _ajax(uri,'GET').then(function(data) { - var parsedData; - - try { - parsedData = JSON.parse(data); - } catch(e) { - console.warn('Failed to parse node labels data:', e); - parsedData = null; - } - - if (parsedData !== null) { - store.set('isNodeLabelsConfiguredByRM', true); - } else { - store.set('isNodeLabelsConfiguredByRM', false); - } - - if (stackVersion >= 2.5) { - if (parsedData && Em.isArray(parsedData.nodeLabelInfo)) { - labels = parsedData.nodeLabelInfo; - } else { - labels = (parsedData && parsedData.nodeLabelInfo)?[parsedData.nodeLabelInfo]:[]; - } - Ember.run(null, resolve, labels.map(function (label) { - return {name:label.name,exclusivity:label.exclusivity}; - })); - } else { - if (parsedData && Em.isArray(parsedData.nodeLabels)) { - labels = parsedData.nodeLabels; - } else { - labels = (parsedData && parsedData.nodeLabels)?[parsedData.nodeLabels]:[]; - } - Ember.run(null, resolve, labels.map(function (label) { - return {name:label}; - })); - } - }, function(jqXHR) { + return new Ember.RSVP.Promise(function (resolve, reject) { + _ajax(uri, 'GET').then(function (data) { + var result = adapter.parseNodeLabels(data); + store.set('isNodeLabelsConfiguredByRM', result.configured); + Ember.run(null, resolve, result.labels); + }, function (jqXHR) { jqXHR.then = null; Ember.run(null, reject, jqXHR); }); - }.bind(this),'App: QueueAdapter#getNodeLabels'); + }.bind(this), 'App: QueueAdapter#getNodeLabels'); + }, + + /** + * Normalizes the ResourceManager node-label response into a flat list. + * + * Tolerates a JSON string or an already-parsed object, the optional + * `nodeLabelsInfo` wrapper, both the current `nodeLabelInfo` and the legacy + * `nodeLabels` shapes, and a single label object instead of an array. + * `exclusivity` is coerced to a real Boolean (Boolean or the strings + * "true"/"false"). The response format is derived from the payload shape, + * not from the stack version. + * + * @param {String|Object} data raw ResourceManager response + * @return {{configured: Boolean, labels: Array}} + */ + parseNodeLabels: function (data) { + var parsedData; + + try { + parsedData = (typeof data === 'string') ? JSON.parse(data) : data; + } catch (e) { + console.warn('Failed to parse node labels data:', e); + parsedData = null; + } + + var configured = (parsedData !== null && parsedData !== undefined), + labels = []; + + if (parsedData && parsedData.nodeLabelsInfo) { + parsedData = parsedData.nodeLabelsInfo; + } + + if (parsedData && parsedData.nodeLabelInfo) { + labels = Em.isArray(parsedData.nodeLabelInfo) ? parsedData.nodeLabelInfo : [parsedData.nodeLabelInfo]; + labels = labels.map(function (label) { + return { name: label.name, exclusivity: label.exclusivity === true || label.exclusivity === 'true' }; + }); + } else if (parsedData && parsedData.nodeLabels) { + labels = Em.isArray(parsedData.nodeLabels) ? parsedData.nodeLabels : [parsedData.nodeLabels]; + labels = labels.map(function (label) { + return { name: (label && label.name !== undefined) ? label.name : label }; + }); + } + + return { configured: configured, labels: labels }; }, getPrivilege:function () { diff --git a/contrib/views/capacity-scheduler/src/main/resources/ui/test/unit/adapters/adapters_test.js b/contrib/views/capacity-scheduler/src/main/resources/ui/test/unit/adapters/adapters_test.js new file mode 100644 index 00000000000..aec980e1e57 --- /dev/null +++ b/contrib/views/capacity-scheduler/src/main/resources/ui/test/unit/adapters/adapters_test.js @@ -0,0 +1,107 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var adapter; + +QUnit.module('unit/adapters - QueueAdapter#parseNodeLabels', { + setup: function () { + adapter = App.QueueAdapter.create(); + }, + teardown: function () { + adapter = null; + } +}); + +test('parsed object with nodeLabelsInfo wrapper and mixed exclusivity', function () { + var result = adapter.parseNodeLabels({ + nodeLabelsInfo: { + nodeLabelInfo: [ + { name: 'label-a', exclusivity: true }, + { name: 'label-b', exclusivity: 'false' } + ] + } + }); + equal(result.configured, true, 'configured is true'); + deepEqual(result.labels, [ + { name: 'label-a', exclusivity: true }, + { name: 'label-b', exclusivity: false } + ], 'wrapper unwrapped, exclusivity normalized'); +}); + +test('JSON string response with single nodeLabelInfo object and string "true"', function () { + var result = adapter.parseNodeLabels(JSON.stringify({ + nodeLabelInfo: { name: 'label-a', exclusivity: 'true' } + })); + equal(result.configured, true); + deepEqual(result.labels, [{ name: 'label-a', exclusivity: true }], + 'string parsed, single object wrapped, "true" -> true'); +}); + +test('nodeLabelInfo as array', function () { + var result = adapter.parseNodeLabels({ + nodeLabelInfo: [{ name: 'label-a', exclusivity: false }] + }); + deepEqual(result.labels, [{ name: 'label-a', exclusivity: false }]); +}); + +test('exclusivity string "false" and boolean false both normalize to false', function () { + var result = adapter.parseNodeLabels({ + nodeLabelInfo: [ + { name: 'label-a', exclusivity: 'false' }, + { name: 'label-b', exclusivity: false } + ] + }); + deepEqual(result.labels, [ + { name: 'label-a', exclusivity: false }, + { name: 'label-b', exclusivity: false } + ]); +}); + +test('legacy nodeLabels with mixed string and object entries', function () { + var result = adapter.parseNodeLabels({ + nodeLabels: ['label-a', { name: 'label-b' }] + }); + equal(result.configured, true); + deepEqual(result.labels, [{ name: 'label-a' }, { name: 'label-b' }], + 'string and object legacy entries both normalize to {name}'); +}); + +test('legacy nodeLabels as a single string', function () { + var result = adapter.parseNodeLabels({ nodeLabels: 'label-a' }); + deepEqual(result.labels, [{ name: 'label-a' }]); +}); + +test('malformed JSON string logs a warning and returns empty labels', function () { + var warned = false, + originalWarn = console.warn; + console.warn = function () { warned = true; }; + try { + var result = adapter.parseNodeLabels('{ this is not json'); + ok(warned, 'a parse warning was logged'); + equal(result.configured, false, 'configured is false on parse failure'); + deepEqual(result.labels, [], 'empty label list on parse failure'); + } finally { + console.warn = originalWarn; + } +}); + +test('empty valid object returns empty labels and configured true', function () { + var result = adapter.parseNodeLabels({}); + equal(result.configured, true, 'a valid empty object still counts as configured'); + deepEqual(result.labels, []); +});