Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* 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.
*/


import { describe, it, expect } from 'vitest';
import type { NodeToLabelMapping } from '~/types';
import { getNodeIdsToClearOnUnassign } from '../nodeLabelHostWildcard';

describe('nodeLabelHostWildcard', () => {
describe('getNodeIdsToClearOnUnassign', () => {
it('clears host:0 when it mirrors the only labeled NM on the host', () => {
const nodeToLabels: NodeToLabelMapping[] = [
{ nodeId: 'localhost:8041', nodeLabels: ['label3'] },
{ nodeId: 'localhost:0', nodeLabels: ['label3'] },
];

expect(getNodeIdsToClearOnUnassign('localhost:8041', nodeToLabels)).toEqual([
'localhost:8041',
'localhost:0',
]);
});

it('clears host:0 and sibling NMs when unassigning a host-level label', () => {
const nodeToLabels: NodeToLabelMapping[] = [
{ nodeId: 'ccycloud-2.example.com:8041', nodeLabels: ['label4'] },
{ nodeId: 'ccycloud-2.example.com:8042', nodeLabels: ['label4'] },
{ nodeId: 'ccycloud-2.example.com:0', nodeLabels: ['label4'] },
];

expect(getNodeIdsToClearOnUnassign('ccycloud-2.example.com:8041', nodeToLabels)).toEqual([
'ccycloud-2.example.com:8041',
'ccycloud-2.example.com:0',
'ccycloud-2.example.com:8042',
]);
});

it('only clears the NM when it has a different label than the host-level mapping', () => {
const nodeToLabels: NodeToLabelMapping[] = [
{ nodeId: 'localhost:8041', nodeLabels: ['p2'] },
{ nodeId: 'localhost:8042', nodeLabels: ['p1'] },
{ nodeId: 'localhost:0', nodeLabels: ['p1'] },
];

expect(getNodeIdsToClearOnUnassign('localhost:8041', nodeToLabels)).toEqual([
'localhost:8041',
]);
});

it('clears host:0 but not NMs with a different label on the same host', () => {
const nodeToLabels: NodeToLabelMapping[] = [
{ nodeId: 'localhost:8041', nodeLabels: ['p2'] },
{ nodeId: 'localhost:8042', nodeLabels: ['p1'] },
{ nodeId: 'localhost:0', nodeLabels: ['p1'] },
];

expect(getNodeIdsToClearOnUnassign('localhost:8042', nodeToLabels)).toEqual([
'localhost:8042',
'localhost:0',
]);
});

it('only clears the NM when only that NM is labeled via the UI', () => {
const nodeToLabels: NodeToLabelMapping[] = [
{ nodeId: 'localhost:8041', nodeLabels: ['label3'] },
];

expect(getNodeIdsToClearOnUnassign('localhost:8041', nodeToLabels)).toEqual([
'localhost:8041',
]);
});

it('only clears the NM when it has no labels', () => {
const nodeToLabels: NodeToLabelMapping[] = [
{ nodeId: 'localhost:8041', nodeLabels: [] },
{ nodeId: 'localhost:0', nodeLabels: ['label3'] },
];

expect(getNodeIdsToClearOnUnassign('localhost:8041', nodeToLabels)).toEqual([
'localhost:8041',
]);
});

it('supports bracketed IPv6 node ids', () => {
const nodeToLabels: NodeToLabelMapping[] = [
{ nodeId: '[2001:db8::1]:8041', nodeLabels: ['label3'] },
{ nodeId: '[2001:db8::1]:8042', nodeLabels: ['label3'] },
{ nodeId: '[2001:db8::1]:0', nodeLabels: ['label3'] },
];

expect(getNodeIdsToClearOnUnassign('[2001:db8::1]:8041', nodeToLabels)).toEqual([
'[2001:db8::1]:8041',
'[2001:db8::1]:0',
'[2001:db8::1]:8042',
]);
});

it('supports unbracketed IPv6 node ids', () => {
const nodeToLabels: NodeToLabelMapping[] = [
{ nodeId: '2001:db8::1:8041', nodeLabels: ['label3'] },
{ nodeId: '2001:db8::1:8042', nodeLabels: ['label3'] },
{ nodeId: '2001:db8::1:0', nodeLabels: ['label3'] },
];

expect(getNodeIdsToClearOnUnassign('2001:db8::1:8041', nodeToLabels)).toEqual([
'2001:db8::1:8041',
'2001:db8::1:0',
'2001:db8::1:8042',
]);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* 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.
*/


/**
* Host-level node label mapping helpers.
*
* YARN represents a host-wide label assignment as a synthetic node id with
* wildcard port 0 (for example, `worker1.example.com:0`) via
* CommonNodeLabelsManager.WILDCARD_PORT. That entry applies the label to every
* NodeManager on the host and is exposed alongside per-NM mappings in
* node-to-labels API responses.
*
* Host-wide assignments (for example via `yarn rmadmin -replaceLabelsOnNode
* "hostname,label"`) create host:0 plus per-NM entries. When unassigning from
* the UI, matching host-level mappings and sibling NMs with the same label must
* be cleared together so the label can be removed from the cluster.
*/

import type { NodeToLabelMapping } from '~/types';

/** Wildcard port used for host-level node label mappings in YARN. */
const HOST_LEVEL_WILDCARD_PORT = '0';

function getHostFromNodeId(nodeId: string): string | null {
const lastColonIndex = nodeId.lastIndexOf(':');
if (lastColonIndex <= 0) {
return null;
}
return nodeId.slice(0, lastColonIndex);
}

function getPortFromNodeId(nodeId: string): string | null {
const lastColonIndex = nodeId.lastIndexOf(':');
if (lastColonIndex <= 0) {
return null;
}
return nodeId.slice(lastColonIndex + 1);
}

function findHostLevelNodeId(
nodeId: string,
nodeToLabels: ReadonlyArray<NodeToLabelMapping>,
): string | null {
const host = getHostFromNodeId(nodeId);
const port = getPortFromNodeId(nodeId);
if (!host || !port || port === HOST_LEVEL_WILDCARD_PORT) {
return null;
}

const computedHostLevelNodeId = `${host}:${HOST_LEVEL_WILDCARD_PORT}`;
if (nodeToLabels.some((mapping) => mapping.nodeId === computedHostLevelNodeId)) {
return computedHostLevelNodeId;
}

return (
nodeToLabels.find((mapping) => {
return (
getPortFromNodeId(mapping.nodeId) === HOST_LEVEL_WILDCARD_PORT &&
getHostFromNodeId(mapping.nodeId) === host
);
})?.nodeId ?? null
);
}

/**
* Returns every node id that should be cleared when unassigning a label from
* nodeId in the UI.
*
* When the NM label matches a host-level mapping on the same host, also clear
* host:0 and any other NMs on that host carrying the same label. This mirrors
* host-wide rmadmin assignments and avoids leaving orphan mappings (for
* example host:8042) that block label removal.
*
* @param nodeId NodeManager node id being unassigned
* @param nodeToLabels Current node-to-label mappings from the cluster
* @returns Node ids to replace with an empty label list
*/
export function getNodeIdsToClearOnUnassign(
nodeId: string,
nodeToLabels: ReadonlyArray<NodeToLabelMapping>,
): string[] {
const nodeIdsToClear = new Set<string>([nodeId]);
const host = getHostFromNodeId(nodeId);
const hostLevelNodeId = findHostLevelNodeId(nodeId, nodeToLabels);

if (!host || !hostLevelNodeId) {
return [...nodeIdsToClear];
}

const nmLabel = nodeToLabels.find((mapping) => mapping.nodeId === nodeId)?.nodeLabels[0];
const hostLevelLabel = nodeToLabels.find((mapping) => mapping.nodeId === hostLevelNodeId)
?.nodeLabels[0];

if (!hostLevelLabel || nmLabel !== hostLevelLabel) {
return [...nodeIdsToClear];
}

nodeIdsToClear.add(hostLevelNodeId);

for (const mapping of nodeToLabels) {
if (mapping.nodeId === nodeId || mapping.nodeId === hostLevelNodeId) {
continue;
}
if (mapping.nodeLabels.length === 0 || mapping.nodeLabels[0] !== hostLevelLabel) {
continue;
}

const port = getPortFromNodeId(mapping.nodeId);
if (!port || port === HOST_LEVEL_WILDCARD_PORT) {
continue;
}
if (getHostFromNodeId(mapping.nodeId) !== host) {
continue;
}

nodeIdsToClear.add(mapping.nodeId);
}

return [...nodeIdsToClear];
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ describe('nodeLabelsSlice', () => {
refreshSchedulerData: vi.fn(async () => {}),
stagedChanges: [],
applyError: null,
...initialState,
// Slice properties and methods
...createNodeLabelsSlice(set, get, api),
...initialState,
// Stub implementations for methods we don't test
...({} as any),
})),
Expand Down Expand Up @@ -439,6 +439,104 @@ describe('nodeLabelsSlice', () => {
expect(store.getState().nodeToLabels).toEqual([{ nodeId: 'node1', nodeLabels: [] }]);
});

it('should also clear host:0 when unassigning the last NM with the host-level label', async () => {
store = createTestStore({
nodeToLabels: [
{ nodeId: 'localhost:8041', nodeLabels: ['p2'] },
{ nodeId: 'localhost:8042', nodeLabels: ['p1'] },
{ nodeId: 'localhost:0', nodeLabels: ['p1'] },
],
});

vi.mocked(store.getState().apiClient.replaceNodeToLabels).mockResolvedValue(undefined);
vi.mocked(store.getState().apiClient.getNodeToLabels).mockResolvedValue({
nodeToLabels: {
entry: [
{ key: 'localhost:8041', value: { nodeLabelInfo: { name: 'p2' } } },
],
},
});

await store.getState().assignNodeToLabel('localhost:8042', null);

expect(store.getState().apiClient.replaceNodeToLabels).toHaveBeenCalledWith([
{ nodeId: 'localhost:8042', labels: [] },
{ nodeId: 'localhost:0', labels: [] },
]);
});

it('should clear host:0 and sibling NMs for host-wide rmadmin assignments', async () => {
store = createTestStore({
nodeToLabels: [
{ nodeId: 'ccycloud-2.example.com:8041', nodeLabels: ['label4'] },
{ nodeId: 'ccycloud-2.example.com:8042', nodeLabels: ['label4'] },
{ nodeId: 'ccycloud-2.example.com:0', nodeLabels: ['label4'] },
],
});

vi.mocked(store.getState().apiClient.replaceNodeToLabels).mockResolvedValue(undefined);
vi.mocked(store.getState().apiClient.getNodeToLabels).mockResolvedValue({
nodeToLabels: { entry: [] },
});

await store.getState().assignNodeToLabel('ccycloud-2.example.com:8041', null);

expect(store.getState().apiClient.replaceNodeToLabels).toHaveBeenCalledWith([
{ nodeId: 'ccycloud-2.example.com:8041', labels: [] },
{ nodeId: 'ccycloud-2.example.com:0', labels: [] },
{ nodeId: 'ccycloud-2.example.com:8042', labels: [] },
]);
});

it('should not clear host:0 when unassigning an NM with a different label', async () => {
store = createTestStore({
nodeToLabels: [
{ nodeId: 'localhost:8041', nodeLabels: ['p2'] },
{ nodeId: 'localhost:8042', nodeLabels: ['p1'] },
{ nodeId: 'localhost:0', nodeLabels: ['p1'] },
],
});

vi.mocked(store.getState().apiClient.replaceNodeToLabels).mockResolvedValue(undefined);
vi.mocked(store.getState().apiClient.getNodeToLabels).mockResolvedValue({
nodeToLabels: {
entry: [
{ key: 'localhost:8042', value: { nodeLabelInfo: { name: 'p1' } } },
{ key: 'localhost:0', value: { nodeLabelInfo: { name: 'p1' } } },
],
},
});

await store.getState().assignNodeToLabel('localhost:8041', null);

expect(store.getState().apiClient.replaceNodeToLabels).toHaveBeenCalledWith([
{ nodeId: 'localhost:8041', labels: [] },
]);
});

it('should clear host:0 and all matching sibling NMs for host-wide labels', async () => {
store = createTestStore({
nodeToLabels: [
{ nodeId: 'localhost:8041', nodeLabels: ['p1'] },
{ nodeId: 'localhost:8042', nodeLabels: ['p1'] },
{ nodeId: 'localhost:0', nodeLabels: ['p1'] },
],
});

vi.mocked(store.getState().apiClient.replaceNodeToLabels).mockResolvedValue(undefined);
vi.mocked(store.getState().apiClient.getNodeToLabels).mockResolvedValue({
nodeToLabels: { entry: [] },
});

await store.getState().assignNodeToLabel('localhost:8041', null);

expect(store.getState().apiClient.replaceNodeToLabels).toHaveBeenCalledWith([
{ nodeId: 'localhost:8041', labels: [] },
{ nodeId: 'localhost:0', labels: [] },
{ nodeId: 'localhost:8042', labels: [] },
]);
});

it('should throw error in read-only mode', async () => {
store = createTestStore({ isReadOnly: true });

Expand Down
Loading
Loading