diff --git a/api-ref/source/api-ref-sysinv-v1-config.rst b/api-ref/source/api-ref-sysinv-v1-config.rst index 1718d3ed0f..b2957d6cc6 100644 --- a/api-ref/source/api-ref-sysinv-v1-config.rst +++ b/api-ref/source/api-ref-sysinv-v1-config.rst @@ -6931,6 +6931,146 @@ Deletes a device label This operation does not accept a request body. +-------------- +Host labels +-------------- + +************************ +List all the host labels +************************ + +.. rest_method:: GET /v1/ihosts/{ihost_uuid}/labels + +**Normal response codes** + +200 + +**Error response codes** + +computeFault (400, 500, ...), serviceUnavailable (503), badRequest (400), +unauthorized (401), forbidden (403), badMethod (405), overLimit (413), +itemNotFound (404) + +**Response parameters** + +.. csv-table:: + :header: "Parameter", "Style", "Type", "Description" + :widths: 20, 20, 20, 60 + + "labels ", "plain", "xsd:list", "The list of host labels." + "uuid ", "plain", "csapi:UUID", "The universally unique identifier for this object." + "label_key ", "plain", "xsd:string", "The key of the device label." + "label_value ", "plain", "xsd:string", "The value of the device label." + "host_uuid ", "plain", "csapi:UUID", "The universally unique identifier for the host object." + +:: + + { + "labels": [ + { + "uuid": "71caa220-390f-4403-86a3-8061dba35d06", + "label_key": "key1", + "label_value": "value1", + "host_uuid": "960c759f-fc00-42a1-b67e-a796bf709258" + }, + { + "uuid": "4512b32f-943a-48d0-9449-9119205302c2", + "label_key": "key5", + "label_value": "value5", + "host_uuid": "960c759f-fc00-42a1-b67e-a796bf709258" + } + ] + } + +****************************** +Assign host labels to a host +****************************** + +.. rest_method:: POST /v1/labels/{ihost_uuid}?overwrite={overwrite_parameter} + +**Normal response codes** + +200 + +**Error response codes** + +computeFault (400, 500, ...), serviceUnavailable (503), badRequest (400), +unauthorized (401), forbidden (403), badMethod (405), overLimit (413), +itemNotFound (404) + +**Request parameters** + +.. csv-table:: + :header: "Parameter", "Style", "Type", "Description" + :widths: 20, 20, 20, 60 + + "ihost_uuid ", "plain", "csapi:UUID", "The universally unique identifier for the host object." + "overwrite_parameter (Optional)", "plain", "xsd:boolean", "Overwrite label if it already exists." + "host_labels", "URI", "xsd:list", "List of key-value paired of device labels." + +:: + + { + "key1": "value1", + "key2": "value2", + "key3": "value3" + } + +**Response parameters** + +.. csv-table:: + :header: "Parameter", "Style", "Type", "Description" + :widths: 20, 20, 20, 60 + + "labels ", "plain", "xsd:list", "The list of host labels." + "uuid ", "plain", "csapi:UUID", "The universally unique identifier for this object." + "label_key ", "plain", "xsd:string", "The key of the device label." + "label_value ", "plain", "xsd:string", "The value of the device label." + "host_uuid ", "plain", "csapi:UUID", "The universally unique identifier for the host object." + +:: + + { + "labels": [ + { + "uuid": "bfb37f67-d231-4bf2-836b-677c9cd04dd6", + "label_key": "key1", + "label_value": "value1", + "host_uuid": "960c759f-fc00-42a1-b67e-a796bf709258" + }, + { + "uuid": "85acec16-a163-4ed3-9e24-005602979cd6", + "label_key": "key2", + "label_value": "value2", + "host_uuid": "960c759f-fc00-42a1-b67e-a796bf709258" + }, + { + "uuid": "45fb9fff-5b32-4088-ad65-acc191fcd8b2", + "label_key": "key3", + "label_value": "value3", + "host_uuid": "960c759f-fc00-42a1-b67e-a796bf709258" + } + ] + } + +************************ +Delete a host label +************************ + +.. rest_method:: DELETE /v1/labels/{host_label_uuid} + +**Normal response codes** + +204 + +**Request parameters** + +.. csv-table:: + :header: "Parameter", "Style", "Type", "Description" + :widths: 20, 20, 20, 60 + + "host_label_uuid", "URI", "csapi:UUID", "The unique identifier of the host label." + ------------------ Service Parameter ------------------ diff --git a/sysinv/cgts-client/cgts-client/cgtsclient/tests/v1/test_host_label.py b/sysinv/cgts-client/cgts-client/cgtsclient/tests/v1/test_host_label.py new file mode 100644 index 0000000000..8cec585ee3 --- /dev/null +++ b/sysinv/cgts-client/cgts-client/cgtsclient/tests/v1/test_host_label.py @@ -0,0 +1,216 @@ +# +# Copyright (c) 2025 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import testtools +import uuid + +from cgtsclient.tests import utils +import cgtsclient.v1.label + +CONTROLLER_0 = { + 'uuid': str(uuid.uuid4()), + 'hostname': 'controller-0', + 'id': '0', +} + +LABEL_1 = { + 'uuid': str(uuid.uuid4()), + 'host_uuid': CONTROLLER_0['uuid'], + 'label_key': 'key1', + 'label_value': 'value1', +} + +LABEL_2 = { + 'uuid': str(uuid.uuid4()), + 'host_uuid': CONTROLLER_0['uuid'], + 'label_key': 'key2', + 'label_value': 'value2', +} + +LABEL_3 = { + 'uuid': str(uuid.uuid4()), + 'host_uuid': CONTROLLER_0['uuid'], + 'label_key': 'key3', + 'label_value': 'value3', +} + +LABEL_4 = { + 'uuid': str(uuid.uuid4()), + 'host_uuid': CONTROLLER_0['uuid'], + 'label_key': 'key4', + 'label_value': 'value4', +} + +LABELS = { + 'labels': [LABEL_1, LABEL_2] +} + +NEW_LABELS = { + 'labels': [LABEL_3, LABEL_4] +} + +OVERWRITE_PARAMETER = "overwrite=" + str(True) + +fixtures_default = { + '/v1/labels': + { + 'GET': ( + {}, + LABELS, + ), + }, + f'/v1/labels/{LABEL_1["uuid"]}': + { + 'GET': ( + {}, + LABEL_1, + ), + 'DELETE': ( + {}, + None, + ) + }, + f'/v1/labels/{LABEL_2["uuid"]}': + { + 'GET': ( + {}, + LABEL_2, + ), + 'DELETE': ( + {}, + None, + ) + }, + f'/v1/labels/{LABEL_3["uuid"]}': + { + 'GET': ( + {}, + LABEL_3, + ), + 'DELETE': ( + {}, + None, + ) + }, + f'/v1/labels/{LABEL_4["uuid"]}': + { + 'GET': ( + {}, + LABEL_4, + ), + 'DELETE': ( + {}, + None, + ) + }, + f'/v1/ihosts/{CONTROLLER_0["uuid"]}/labels': + { + 'GET': ( + {}, + LABELS + ), + }, + f'/v1/labels/{CONTROLLER_0["uuid"]}?{OVERWRITE_PARAMETER}': + { + 'POST': ( + {}, + NEW_LABELS, + ) + } + +} + + +class KubernetesLabelManagerTest(testtools.TestCase): + + def setUp(self): + super(KubernetesLabelManagerTest, self).setUp() + self.api = utils.FakeAPI(fixtures_default) + self.mgr = \ + cgtsclient.v1.label.KubernetesLabelManager(self.api) + + def test_host_label_list(self): + host_uuid = CONTROLLER_0['uuid'] + labels = self.mgr.list(host_uuid) + expect = [ + # (method, url, headers, body ) + ( + 'GET', + f'/v1/ihosts/{host_uuid}/labels', + {}, + None + ) + ] + self.assertEqual(self.api.calls, expect) + self.assertEqual(len(labels), 2) + + def test_host_label_get(self): + label_id = LABEL_1['uuid'] + label = self.mgr.get(label_id) + expect = [ + # (method, url, headers, body ) + ( + 'GET', + f'/v1/labels/{label_id}', + {}, + None, + ) + ] + self.assertEqual(self.api.calls, expect) + self.assertTrue( + isinstance(label, + cgtsclient.v1.label.KubernetesLabel)) + + self.assertEqual(label.uuid, LABEL_1['uuid']) + self.assertEqual(label.host_uuid, LABEL_1['host_uuid']) + self.assertEqual(label.label_key, LABEL_1['label_key']) + self.assertEqual(label.label_value, LABEL_1['label_value']) + + def test_host_label_assign(self): + keyvaluepairs = { + LABEL_3['label_key']: LABEL_3['label_value'], + LABEL_4['label_key']: LABEL_4['label_value'], + } + host_uuid = CONTROLLER_0['uuid'] + assigned_labels = self.mgr.assign(host_uuid, + keyvaluepairs, + [OVERWRITE_PARAMETER]) + expect = [ + # (method, url, headers, body ) + ( + 'POST', + f'/v1/labels/{host_uuid}?{OVERWRITE_PARAMETER}', + {}, + keyvaluepairs, + ) + ] + self.assertEqual(self.api.calls, expect) + for label in assigned_labels: + self.assertTrue( + isinstance(label, cgtsclient.v1.label.KubernetesLabel) + ) + + expected_labels = [ + cgtsclient.v1.label.KubernetesLabel(None, LABEL_3, True), + cgtsclient.v1.label.KubernetesLabel(None, LABEL_4, True), + ] + + self.assertEqual(expected_labels, assigned_labels) + + def test_host_label_remove(self): + label_uuid = LABEL_4['uuid'] + label = self.mgr.remove(label_uuid) + expect = [ + # (method, url, headers, body ) + ( + 'DELETE', + f'/v1/labels/{label_uuid}', + {}, + None, + ) + ] + self.assertEqual(self.api.calls, expect) + self.assertTrue(label is None) diff --git a/sysinv/cgts-client/cgts-client/cgtsclient/tests/v1/test_host_label_shell.py b/sysinv/cgts-client/cgts-client/cgtsclient/tests/v1/test_host_label_shell.py new file mode 100644 index 0000000000..6b624f6cd1 --- /dev/null +++ b/sysinv/cgts-client/cgts-client/cgtsclient/tests/v1/test_host_label_shell.py @@ -0,0 +1,171 @@ +# +# Copyright (c) 2025 Wind River Systems, Inc. +# +# SPDX-License-Identifier: Apache-2.0 +# + +import mock +import uuid +import yaml + +from cgtsclient import exc +from cgtsclient.tests import test_shell +from cgtsclient.v1.ihost import ihost +from cgtsclient.v1.label import KubernetesLabel + +from testtools import ExpectedException + + +FAKE_HOST_CONTROLLER_0 = { + 'uuid': str(uuid.uuid4()), + 'hostname': 'controller-0', + 'id': '0', +} + +FAKE_HOST_LABEL_1 = { + 'uuid': str(uuid.uuid4()), + 'host_uuid': FAKE_HOST_CONTROLLER_0['uuid'], + 'hostname': FAKE_HOST_CONTROLLER_0['hostname'], + 'label_key': 'testkey1', + 'label_value': 'testvalue1', +} + +FAKE_HOST_LABEL_2 = { + 'uuid': str(uuid.uuid4()), + 'host_uuid': FAKE_HOST_CONTROLLER_0['uuid'], + 'hostname': FAKE_HOST_CONTROLLER_0['hostname'], + 'label_key': 'testkey2', + 'label_value': 'testvalue2', +} + + +class KubernetesLabelTest(test_shell.ShellTest): + + def setUp(self): + super(KubernetesLabelTest, self).setUp() + self.make_env() + + def tearDown(self): + super(KubernetesLabelTest, self).tearDown() + + @mock.patch('cgtsclient.v1.label.KubernetesLabelManager.list') + @mock.patch('cgtsclient.v1.ihost._find_ihost') + def test_host_label_list(self, mock_find_ihost, mock_label_list): + mock_find_ihost.return_value = ihost(None, FAKE_HOST_CONTROLLER_0, True) + mock_label_list.return_value = [ + KubernetesLabel(None, FAKE_HOST_LABEL_1, True), + KubernetesLabel(None, FAKE_HOST_LABEL_2, True), + ] + results_str = self.shell("host-label-list --format yaml controller-0") + results_list = yaml.safe_load(results_str) + self.assertTrue(isinstance(results_list, list), + "host-label-list should return a list") + + returned_keys = [ + 'hostname', + 'label_key', + 'label_value', + ] + lbl_1 = { + k: v for k, v in FAKE_HOST_LABEL_1.items() if k in returned_keys + } + lbl_2 = { + k: v for k, v in FAKE_HOST_LABEL_2.items() if k in returned_keys + } + expected_labels = [lbl_1, lbl_2] + self.assertListEqual(expected_labels, results_list) + + @mock.patch('cgtsclient.v1.ihost.ihostManager.list') + def test_host_label_list_host_not_found(self, mock_ihost_list,): + mock_ihost_list.return_value = [ + ihost(None, FAKE_HOST_CONTROLLER_0, True) + ] + hostname = "controller-1" + exception_str = f"host not found: {hostname}" + with ExpectedException(exc.CommandError, exception_str): + self.shell(f"host-label-list {hostname}") + + @mock.patch('cgtsclient.v1.label.KubernetesLabelManager.get') + @mock.patch('cgtsclient.v1.label.KubernetesLabelManager.assign') + @mock.patch('cgtsclient.v1.ihost._find_ihost') + def test_host_label_assign(self, mock_find_ihost, mock_label_assign, mock_label_get): + mock_find_ihost.return_value = ihost(None, FAKE_HOST_CONTROLLER_0, True) + mock_label_assign.return_value = [ + KubernetesLabel(None, FAKE_HOST_LABEL_1, True), + KubernetesLabel(None, FAKE_HOST_LABEL_2, True), + ] + mock_label_get.side_effect = [ + KubernetesLabel(None, FAKE_HOST_LABEL_1, True), + KubernetesLabel(None, FAKE_HOST_LABEL_2, True), + ] + hostname = "controller-0" + parameters = \ + f" {FAKE_HOST_LABEL_1['label_key']}"\ + f"={FAKE_HOST_LABEL_1['label_value']}"\ + f" {FAKE_HOST_LABEL_2['label_key']}"\ + f"={FAKE_HOST_LABEL_2['label_value']}" + + self.shell(f"host-label-assign {hostname} {parameters}") + + @mock.patch('cgtsclient.v1.ihost.ihostManager.list') + def test_host_label_assign_host_not_found(self, mock_ihost_list): + mock_ihost_list.return_value = [ + ihost(None, FAKE_HOST_CONTROLLER_0, True) + ] + hostname = "controller-1" + exception_str = f'host not found: {hostname}' + with ExpectedException(exc.CommandError, exception_str): + self.shell(f"host-label-assign {hostname} newkey=newvalue") + + @mock.patch('cgtsclient.v1.label.KubernetesLabelManager.remove') + @mock.patch('cgtsclient.v1.label.KubernetesLabelManager.list') + @mock.patch('cgtsclient.v1.ihost._find_ihost') + def test_host_label_remove(self, + mock_find_ihost, + mock_label_list, + mock_label_remove): + mock_find_ihost.return_value = ihost(None, FAKE_HOST_CONTROLLER_0, True) + mock_label_list.return_value = [ + KubernetesLabel(None, FAKE_HOST_LABEL_1, True), + KubernetesLabel(None, FAKE_HOST_LABEL_2, True), + ] + hostname = 'controller-0' + key1 = FAKE_HOST_LABEL_1['label_key'] + key2 = FAKE_HOST_LABEL_2['label_key'] + self.shell(f"host-label-remove {hostname} {key1} {key2}") + + label1_uuid = FAKE_HOST_LABEL_1['uuid'] + label2_uuid = FAKE_HOST_LABEL_2['uuid'] + mock_label_remove.assert_has_calls( + [ + mock.call(label1_uuid), + mock.call(label2_uuid), + ] + ) + + @mock.patch('cgtsclient.v1.ihost.ihostManager.list') + def test_host_label_remove_host_not_found(self, mock_ihost_list): + mock_ihost_list.return_value = [ + ihost(None, FAKE_HOST_CONTROLLER_0, True) + ] + hostname = "controller-1" + exception_str = f'host not found: {hostname}' + with ExpectedException(exc.CommandError, exception_str): + self.shell(f"host-label-remove {hostname} key1") + + @mock.patch('cgtsclient.v1.label.KubernetesLabelManager.remove') + @mock.patch('cgtsclient.v1.label.KubernetesLabelManager.list') + @mock.patch('cgtsclient.v1.ihost._find_ihost') + def test_host_label_remove_label_not_found(self, + mock_find_ihost, + mock_label_list, + mock_label_remove): + mock_find_ihost.return_value = ihost(None, FAKE_HOST_CONTROLLER_0, True) + mock_label_list.return_value = [ + KubernetesLabel(None, FAKE_HOST_LABEL_1, True), + KubernetesLabel(None, FAKE_HOST_LABEL_2, True), + ] + hostname = "controller-0" + self.shell(f"host-label-remove {hostname} unknownkey") + + mock_label_remove.assert_not_called() diff --git a/sysinv/cgts-client/cgts-client/cgtsclient/v1/label.py b/sysinv/cgts-client/cgts-client/cgtsclient/v1/label.py index d45f46d16c..f0b23a054a 100644 --- a/sysinv/cgts-client/cgts-client/cgtsclient/v1/label.py +++ b/sysinv/cgts-client/cgts-client/cgtsclient/v1/label.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2018 Wind River Systems, Inc. +# Copyright (c) 2018,2025 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # @@ -35,8 +35,28 @@ class KubernetesLabelManager(base.Manager): except IndexError: return None + def _assign(self, url, body): + _, body = self.api.json_request('POST', url, body=body) + + if not body: + return None + + body = body.get('labels', body) + if not isinstance(body, list): + return self.resource_class(self, body) # noqa pylint: disable=not-callable + + resources = [] + for item in body: + resources.append(self.resource_class(self, item)) # noqa pylint: disable=not-callable + + return resources + def assign(self, host_uuid, label, parameters=None): - return self._create(options.build_url(self._path(host_uuid), q=None, params=parameters), label) + url = options.build_url( + self._path(host_uuid), + q=None, + params=parameters) + return self._assign(url, label) def remove(self, uuid): return self._delete(self._path(uuid)) diff --git a/sysinv/cgts-client/cgts-client/cgtsclient/v1/label_shell.py b/sysinv/cgts-client/cgts-client/cgtsclient/v1/label_shell.py index 7571aaef99..28e6367d8d 100644 --- a/sysinv/cgts-client/cgts-client/cgtsclient/v1/label_shell.py +++ b/sysinv/cgts-client/cgts-client/cgtsclient/v1/label_shell.py @@ -1,5 +1,5 @@ # -# Copyright (c) 2018 Wind River Systems, Inc. +# Copyright (c) 2018,2025 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # @@ -23,15 +23,20 @@ def _print_label_show(obj): @utils.arg('hostnameorid', metavar='', help="Name or ID of host [REQUIRED]") +@utils.arg('--format', + choices=['table', 'yaml', 'value'], + help="specify the output format, defaults to table") def do_host_label_list(cc, args): - """List kubernetes labels assigned to a host.""" + """List labels assigned to a host.""" ihost = ihost_utils._find_ihost(cc, args.hostnameorid) host_label = cc.label.list(ihost.uuid) for i in host_label[:]: setattr(i, 'hostname', ihost.hostname) field_labels = ['hostname', 'label key', 'label value'] fields = ['hostname', 'label_key', 'label_value'] - utils.print_list(host_label, fields, field_labels, sortby=1) + utils.print_list(host_label, fields, + field_labels, sortby=1, + output_format=args.format) @utils.arg('hostnameorid', @@ -52,13 +57,13 @@ def do_host_label_assign(cc, args): ihost = ihost_utils._find_ihost(cc, args.hostnameorid) parameters = ["overwrite=" + str(args.overwrite)] new_labels = cc.label.assign(ihost.uuid, attributes, parameters) - for p in new_labels.labels: - uuid = p['uuid'] - if uuid is not None: + + for lbl in new_labels: + if lbl.uuid is not None: try: - label_obj = cc.label.get(uuid) + label_obj = cc.label.get(lbl.uuid) except exc.HTTPNotFound: - raise exc.CommandError('Host label not found: %s' % uuid) + raise exc.CommandError(f'Host label not found: {lbl.uuid}') _print_label_show(label_obj) diff --git a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/label.py b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/label.py index 38fca711b0..3a11c7032f 100644 --- a/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/label.py +++ b/sysinv/sysinv/sysinv/sysinv/api/controllers/v1/label.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018-2024 Wind River Systems, Inc. +# Copyright (c) 2018-2024,2025 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # @@ -7,10 +7,12 @@ import json import os import pecan from pecan import rest +import re import wsme import wsmeext.pecan as wsme_pecan from wsme import types as wtypes +from contextlib import suppress from oslo_log import log from oslo_utils import excutils from sysinv._i18n import _ @@ -152,16 +154,20 @@ class LabelController(rest.RestController): sort_key=sort_key, sort_dir=sort_dir) - def _apply_manifest_after_label_operation(self, uuid, keys): + def _apply_manifest_after_label_operation(self, host_uuid, keys): if (common.LABEL_DISABLE_NOHZ_FULL in keys or constants.KUBE_POWER_MANAGER_LABEL in keys): pecan.request.rpcapi.update_grub_config( - pecan.request.context, uuid) + pecan.request.context, host_uuid) if constants.KUBE_POWER_MANAGER_LABEL in keys: pecan.request.rpcapi.configure_power_manager( pecan.request.context) + if _check_if_stalld_label_modified(keys): + pecan.request.rpcapi.configure_stalld( + pecan.request.context, host_uuid) + @wsme_pecan.wsexpose(LabelCollection, types.uuid, types.uuid, int, wtypes.text, wtypes.text) def get_all(self, uuid=None, marker=None, limit=None, @@ -203,14 +209,14 @@ class LabelController(rest.RestController): @cutils.synchronized(LOCK_NAME) @wsme_pecan.wsexpose(LabelCollection, types.uuid, types.boolean, body=types.apidict) - def post(self, uuid, overwrite=False, body=None): + def post(self, host_uuid, overwrite=False, body=None): """Assign label(s) to a host. """ if self._from_ihosts: raise exception.OperationNotPermitted LOG.info("patch_data: %s" % body) - host = objects.host.get_by_uuid(pecan.request.context, uuid) + host = objects.host.get_by_uuid(pecan.request.context, host_uuid) # Probably will never happen, but add an extra guard to be absolutely # sure no null values make it into the database. @@ -226,13 +232,13 @@ class LabelController(rest.RestController): _semantic_check_k8s_plugins_labels(host, body) + _semantic_check_stalld_labels(host, body) + existing_labels = {} for label_key in body.keys(): label = None - try: + with suppress(exception.HostLabelNotFoundByKey): label = pecan.request.dbapi.label_query(host.id, label_key) - except exception.HostLabelNotFoundByKey: - pass if label: if overwrite: existing_labels.update({label_key: label.uuid}) @@ -268,7 +274,7 @@ class LabelController(rest.RestController): label_uuid = existing_labels.get(key) new_label = pecan.request.dbapi.label_update(label_uuid, {'label_value': value}) else: - new_label = pecan.request.dbapi.label_create(uuid, values) + new_label = pecan.request.dbapi.label_create(host_uuid, values) new_records.append(new_label) except exception.HostLabelAlreadyExists: # We should not be here @@ -277,11 +283,11 @@ class LabelController(rest.RestController): try: vim_api.vim_host_update( None, - uuid, + host_uuid, host.hostname, constants.VIM_DEFAULT_TIMEOUT_IN_SECS) self._apply_manifest_after_label_operation( - uuid, body.keys()) + host_uuid, body.keys()) except Exception as e: LOG.warn(_("No response vim_api host:%s e=%s" % (host.hostname, e))) @@ -390,6 +396,154 @@ def evaluate_case_agnostic_policy_name(policy_name, policy_value): _case_agnostic_check(policy_value, constants.KUBE_CPU_MEMORY_MANAGER_VALUES, policy_name) +def _check_if_stalld_label_modified(keys: list[str]) -> bool: + # check for starlingx.io/stalld substring in any of the labels + return any(constants.LABEL_STALLD in label_key for label_key in keys) + + +def _is_stalld_enabled(host, body: dict) -> bool: + + def _is_enabled_in_db(host) -> tuple[bool, bool]: + # default value if missing from db + found_in_db, stalld_enabled = False, constants.LABEL_VALUE_STALLD_DISABLED + with suppress(exception.HostLabelNotFoundByKey): + label_key = constants.LABEL_STALLD + label = pecan.request.dbapi.label_query(host.id, label_key) + stalld_enabled = label.label_value + found_in_db = True + return found_in_db, (stalld_enabled == constants.LABEL_VALUE_STALLD_ENABLED) + + def _is_enabled_in_body(body: dict) -> tuple[bool, bool]: + label_key = constants.LABEL_STALLD + found = label_key in body.keys() + stalld_enabled = body.get(label_key, constants.LABEL_VALUE_STALLD_DISABLED) + return found, (stalld_enabled == constants.LABEL_VALUE_STALLD_ENABLED) + + found_in_body, stalld_enabled_in_body = _is_enabled_in_body(body) + found_in_db, stalld_enabled_in_db = _is_enabled_in_db(host) + + if found_in_body: + # user input highest priority + return stalld_enabled_in_body + elif found_in_db: + # check database + return stalld_enabled_in_db + + # otherwise use the default value 'disabled' + return False + + +def _semantic_check_stalld_cpu_list_is_not_empty(host, body: dict) -> None: + """ Check that stalld will not be started with an empty cpu list. + + Args: + host: host + body (dict): dictionary of label key values being updated + """ + keys = body.keys() + if not _check_if_stalld_label_modified(keys): + return None + + if not _is_stalld_enabled(host, body): + return None + + label_key = constants.LABEL_STALLD_CPU_FUNCTIONS + supported_values = constants.VALID_STALLD_CPU_FUNCTION_VALUES + + cpu_function = body.get(label_key, None) + if not cpu_function: + # default value if missing from db + cpu_function = constants.LABEL_VALUE_CPU_DEFAULT + with suppress(exception.HostLabelNotFoundByKey): + label_key = constants.LABEL_STALLD_CPU_FUNCTIONS + label = pecan.request.dbapi.label_query(host.id, label_key) + cpu_function = label.label_value + + if cpu_function.lower() == constants.LABEL_VALUE_CPU_ALL: + # all cpus - assume there will be at least 1 cpu on the node + return None + + if cpu_function.lower() == constants.LABEL_VALUE_CPU_APPLICATION_ISOLATED: + functions = { + constants.ISOLATED_FUNCTION + } + elif cpu_function.lower() == constants.LABEL_VALUE_CPU_APPLICATION: + functions = { + constants.APPLICATION_FUNCTION, + constants.ISOLATED_FUNCTION + } + else: + # should not get here + msg = \ + f"{host.hostname} invalid value {label_key}={cpu_function}. "\ + f"Supported values are {supported_values}" + LOG.error(msg) + raise wsme.exc.ClientSideError(_(msg)) + + cpu_count = 0 + for cpu in pecan.request.dbapi.icpu_get_by_ihost(host.uuid): + if cpu.allocated_function in functions or not functions: + cpu_count += 1 + if cpu_count == 0: + msg = \ + f"{host.hostname} stalld cpu list empty,"\ + f" update cpu core assignments for {functions}" + raise wsme.exc.ClientSideError(_(msg)) + + +def _semantic_check_custom_stalld_label_format(host, body: dict) -> None: + """Check that custom stalld labels have the correct format + starlingx.io/stalld.= + """ + keys = body.keys() + stalld_labels = [label for label in keys if constants.LABEL_STALLD in label] + for label_key in stalld_labels: + # skip supported labels they are validated elsewhere + if label_key in constants.SUPPORTED_STALLD_LABELS: + continue + if not re.match(constants.REGEX_STALLD_CUSTOM_LABEL, label_key): + msg = \ + f"{host.hostname} '{label_key}' invalid format,"\ + f" format example 'starlingx.io/stalld.'" + raise wsme.exc.ClientSideError(_(msg)) + + +def _semantic_check_stalld_labels(host, body: dict) -> None: + """ + Perform semantic checks to ensure the stalld labels have supported values + """ + keys = body.keys() + stalld_labels = [label for label in keys if constants.LABEL_STALLD in label] + if not stalld_labels: + return None + + if constants.WORKER not in host.subfunctions: + msg = f"{stalld_labels} can only be modified on worker nodes." + raise wsme.exc.ClientSideError(_(msg)) + + # tuple format ( label_key, supported_values) + label_tuples = [ + # starlingx.io/stalld=[enabled|disabled] + (constants.LABEL_STALLD, + constants.VALID_STALLD_VALUES), + # starlingx.io/stalld_cpu_functions=[all|application|application_isolated] + (constants.LABEL_STALLD_CPU_FUNCTIONS, + constants.VALID_STALLD_CPU_FUNCTION_VALUES)] + + for (label_key, supported_values) in label_tuples: + label_value = body.get(label_key, None) + if label_value: + label_value = label_value.lower() + if label_value and label_value not in supported_values: + msg = \ + f"{host.hostname} invalid value {label_key}={label_value}. "\ + f"Supported values are {supported_values}" + raise wsme.exc.ClientSideError(_(msg)) + + _semantic_check_stalld_cpu_list_is_not_empty(host, body) + _semantic_check_custom_stalld_label_format(host, body) + + def _semantic_check_worker_labels(body): """ Perform semantic checks to ensure the worker labels are valid. diff --git a/sysinv/sysinv/sysinv/sysinv/common/constants.py b/sysinv/sysinv/sysinv/sysinv/common/constants.py index d0a4f63bae..53091755ae 100644 --- a/sysinv/sysinv/sysinv/sysinv/common/constants.py +++ b/sysinv/sysinv/sysinv/sysinv/common/constants.py @@ -2809,3 +2809,40 @@ DEPLOYED = "deployed" DEPLOYING = "deploying" REMOVING = "removing" UNAVAILABLE = "unavailable" + +# stalld labels +LABEL_STALLD = 'starlingx.io/stalld' +LABEL_VALUE_STALLD_ENABLED = 'enabled' +LABEL_VALUE_STALLD_DISABLED = 'disabled' +# Default is 'disabled' +VALID_STALLD_VALUES = [ + LABEL_VALUE_STALLD_DISABLED, + LABEL_VALUE_STALLD_ENABLED +] + +# stalld cpu functions values +LABEL_STALLD_CPU_FUNCTIONS = 'starlingx.io/stalld_cpu_functions' +LABEL_VALUE_CPU_ALL = 'all' +LABEL_VALUE_CPU_APPLICATION = 'application' +LABEL_VALUE_CPU_APPLICATION_ISOLATED = 'application-isolated' +# Default is 'application' +LABEL_VALUE_CPU_DEFAULT = LABEL_VALUE_CPU_APPLICATION +VALID_STALLD_CPU_FUNCTION_VALUES = [ + LABEL_VALUE_CPU_APPLICATION, + LABEL_VALUE_CPU_APPLICATION_ISOLATED, + LABEL_VALUE_CPU_ALL +] + +SUPPORTED_STALLD_LABELS = [ + LABEL_STALLD, + LABEL_STALLD_CPU_FUNCTIONS +] + +# Custom arguments follow starlingx.io/stalld.xxxx pattern +# Examples +# 'starlingx.io/stalld.boost_period' +# 'starlingx.io/stalld.boost_runtime' +# 'starlingx.io/stalld.boost_duration' +# 'starlingx.io/stalld.starving_threshold' +REGEX_STALLD_CUSTOM_LABEL = r"starlingx\.io\/stalld\.(\w+)" +CUSTOM_STALLD_LABEL_STRING = "starlingx.io/stalld." diff --git a/sysinv/sysinv/sysinv/sysinv/conductor/manager.py b/sysinv/sysinv/sysinv/sysinv/conductor/manager.py index 0ca7aadbe0..5306f47641 100644 --- a/sysinv/sysinv/sysinv/sysinv/conductor/manager.py +++ b/sysinv/sysinv/sysinv/sysinv/conductor/manager.py @@ -20024,6 +20024,40 @@ class ConductorManager(service.PeriodicService): """ return cutils.get_secrets_info() + def configure_stalld(self, context, host_uuid): + """ Configure and restart the stalld daemon + + :param context: admin context + :param ihost_uuid: host uuid + """ + host_uuid = host_uuid.strip() + try: + host = self.dbapi.ihost_get(host_uuid) + except exception.ServerNotFound: + LOG.info(f'Host not found {host_uuid}') + return None + + hostname = host['hostname'] + LOG.info(f'Attempting to configure stalld for host={hostname}') + + personalities = [host['personality']] + host_uuids = [host['uuid']] + config_uuid = self._config_update_hosts( + context=context, + personalities=personalities, + host_uuids=host_uuids, + reboot=False) + config_dict = { + "personalities": personalities, + "host_uuids": host_uuids, + "classes": [ + 'platform::stalld::runtime' + ], + } + self._config_apply_runtime_manifest(context, + config_uuid, + config_dict) + def device_image_state_sort_key(dev_img_state): if dev_img_state.bitstream_type == dconstants.BITSTREAM_TYPE_ROOT_KEY: diff --git a/sysinv/sysinv/sysinv/sysinv/conductor/rpcapi.py b/sysinv/sysinv/sysinv/sysinv/conductor/rpcapi.py index 75d9f8a8e0..9bead3a59b 100644 --- a/sysinv/sysinv/sysinv/sysinv/conductor/rpcapi.py +++ b/sysinv/sysinv/sysinv/sysinv/conductor/rpcapi.py @@ -16,7 +16,7 @@ # License for the specific language governing permissions and limitations # under the License. # -# Copyright (c) 2013-2024 Wind River Systems, Inc. +# Copyright (c) 2013-2025 Wind River Systems, Inc. # """ @@ -2309,3 +2309,13 @@ class ConductorAPI(sysinv.openstack.common.rpc.proxy.RpcProxy): :param context: request context. """ return self.call(context, self.make_msg('get_all_k8s_certs')) + + def configure_stalld(self, context, host_uuid): + """Synchronously, have the conductor reconfigure stalld + for the specified host. + + :param context: request context + :param host_uuid: the uuid of the host + """ + return self.call(context, self.make_msg('configure_stalld', + host_uuid=host_uuid)) diff --git a/sysinv/sysinv/sysinv/sysinv/db/sqlalchemy/api.py b/sysinv/sysinv/sysinv/sysinv/db/sqlalchemy/api.py index 06543d04a3..616f99798a 100644 --- a/sysinv/sysinv/sysinv/sysinv/db/sqlalchemy/api.py +++ b/sysinv/sysinv/sysinv/sysinv/db/sqlalchemy/api.py @@ -8118,6 +8118,14 @@ class Connection(api.Connection): query = query.filter_by(host_id=hostid) return query.all() + @db_objects.objectify(objects.label) + def label_get_all_like(self, pattern, hostid=None): + query = model_query(models.Label, read_deleted="no") + if hostid: + query = query.filter_by(host_id=hostid) + query = query.filter(models.Label.label_key.like(pattern)) + return query.all() + @db_objects.objectify(objects.label) def label_update(self, uuid, values): with _session_for_write() as session: diff --git a/sysinv/sysinv/sysinv/sysinv/puppet/platform.py b/sysinv/sysinv/sysinv/sysinv/puppet/platform.py index d2cb821676..93e4d2579a 100644 --- a/sysinv/sysinv/sysinv/sysinv/puppet/platform.py +++ b/sysinv/sysinv/sysinv/sysinv/puppet/platform.py @@ -1,4 +1,4 @@ -# Copyright (c) 2017-2024 Wind River Systems, Inc. +# Copyright (c) 2017-2025 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # @@ -9,6 +9,7 @@ import ruamel.yaml as yaml import re import numpy as np +from contextlib import suppress from oslo_log import log as logging from oslo_serialization import base64 from sysinv.common import constants @@ -79,6 +80,7 @@ class PlatformPuppet(base.BasePuppet): config.update(self._get_host_lldp_config(host)) config.update(self._get_ttys_dcd_config(host)) config.update(self._get_host_tuned_devices(host)) + config.update(self._get_stalld_config(host)) return config def get_host_config_upgrade(self, host): @@ -1156,3 +1158,102 @@ class PlatformPuppet(base.BasePuppet): "platform::tty::params::active_device": host.console.split(',')[0] } + + def _get_stalld_config(self, host): + LOG.debug(f"{host.hostname} _get_stalld_config()") + + def _get_label_value(_label_key, + _supported_values, + _default_value): + label_value = _default_value + with suppress(exception.HostLabelNotFoundByKey): + label = self.dbapi.label_query(host.id, _label_key) + label_value = label.label_value.lower() + # unsupported value in database + if label_value not in _supported_values: + LOG.error(f"Unexpected {_label_key}='{label.label_value}' " + "using default values of " + f"'{_default_value}' instead") + label_value = _default_value + return label_value + + stalld_enabled = _get_label_value( + constants.LABEL_STALLD, + constants.VALID_STALLD_VALUES, + constants.LABEL_VALUE_STALLD_DISABLED) + + stalld_cpu_functions = _get_label_value( + constants.LABEL_STALLD_CPU_FUNCTIONS, + constants.VALID_STALLD_CPU_FUNCTION_VALUES, + constants.LABEL_VALUE_CPU_DEFAULT) + + cpus = [] + if stalld_cpu_functions == constants.LABEL_VALUE_CPU_ALL: + cpus = self._get_host_cpu_list(host, + threads=True) + elif stalld_cpu_functions == constants.LABEL_VALUE_CPU_APPLICATION_ISOLATED: + cpus = self._get_host_cpu_list(host, + constants.ISOLATED_FUNCTION, + threads=True) + else: + # constants.LABEL_VALUE_CPU_APPLICATION = 'application' + cpus_app = self._get_host_cpu_list( + host, + constants.APPLICATION_FUNCTION, + threads=True) + cpus_iso = self._get_host_cpu_list( + host, + constants.ISOLATED_FUNCTION, + threads=True) + cpus = cpus_app + cpus_iso + + if not cpus: + LOG.error(f"{host.hostname} - {stalld_cpu_functions} " + "stalld cpu_list is empty") + LOG.error("stalld auto-disabled update cpu core assignment") + stalld_enabled = constants.LABEL_VALUE_STALLD_DISABLED + + # generate cpu_list + cpus = sorted(cpus, key=lambda c: c.cpu) + cpu_set = set([c.cpu for c in cpus]) + stalld_cpu_list = utils.format_range_set(cpu_set) + + config = { + "platform::stalld::params::enable": + stalld_enabled == constants.LABEL_VALUE_STALLD_ENABLED, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + } + + # custom stalld labels + config.update(self._get_custom_stalld_config(host)) + + LOG.debug(f"{host.hostname} updating stalld config\n{config}") + return config + + def _get_custom_stalld_config(self, host): + config = {} + + stalld_label_list = self.dbapi.label_get_all_like( + pattern=f"{constants.CUSTOM_STALLD_LABEL_STRING}%", + hostid=host.id + ) + + for label in stalld_label_list: + label_key = label.label_key + label_value = label.label_value + + # skip supported stalld label keys which are also case insensitive + if label_key.lower() in constants.SUPPORTED_STALLD_LABELS: + continue + + custom_parameter = label_key.replace( + constants.CUSTOM_STALLD_LABEL_STRING, + '' + ) + config.update({ + f"platform::stalld::params::{custom_parameter}": + f"'{label_value}'" + }) + + return config diff --git a/sysinv/sysinv/sysinv/sysinv/tests/api/test_label.py b/sysinv/sysinv/sysinv/sysinv/tests/api/test_label.py index 7a5e5e9894..6001cb4ebd 100644 --- a/sysinv/sysinv/sysinv/sysinv/tests/api/test_label.py +++ b/sysinv/sysinv/sysinv/sysinv/tests/api/test_label.py @@ -1,9 +1,11 @@ -# Copyright (c) 2019-2024 Wind River Systems, Inc. +# Copyright (c) 2019-2025 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # +import itertools import mock +import random from six.moves import http_client from six.moves.urllib.parse import urlencode @@ -12,6 +14,7 @@ from sysinv.db import api as dbapi from sysinv.tests.api import base from sysinv.api.controllers.v1 import label as policylabel from sysinv.tests.db import utils as dbutils +from sysinv.tests.db import base as dbbase import wsme @@ -39,10 +42,9 @@ def mock_get_system_enabled_k8s_plugins_return_none(): class LabelTestCase(base.FunctionalTest): + def setUp(self): super(LabelTestCase, self).setUp() - self.dbapi = dbapi.get_instance() - self.system = dbutils.create_test_isystem() def _get_path(self, host=None, params=None): if host: @@ -79,8 +81,11 @@ class LabelTestCase(base.FunctionalTest): class LabelAssignTestCase(LabelTestCase): + def setUp(self): super(LabelAssignTestCase, self).setUp() + self.dbapi = dbapi.get_instance() + self.system = dbutils.create_test_isystem() self.controller = dbutils.create_test_ihost( id='1', uuid=None, @@ -321,3 +326,411 @@ class LabelAssignTestCase(LabelTestCase): response_data = self.get_host_labels(self.worker.uuid) self.validate_labels(test_plugin_label, response_data) + + +class FakeConductorAPI(object): + + def __init__(self): + self.update_kubernetes_label = mock.MagicMock() + self.update_grub_config = mock.MagicMock() + self.configure_power_manager = mock.MagicMock() + self.configure_stalld = mock.MagicMock() + + +class StalldLabelTestCase(LabelTestCase, dbbase.BaseHostTestCase): + + def _create_host(self, personality, subfunction=None, + mgmt_mac=None, mgmt_ip=None, + admin=None, + invprovision=constants.PROVISIONED, **kw): + host = self._create_test_host(personality=personality, + subfunction=subfunction, + administrative=(admin or + constants.ADMIN_UNLOCKED), + invprovision=invprovision, + **kw) + return host + + def _setup_context(self): + self.fake_conductor_api = FakeConductorAPI() + p = mock.patch('sysinv.conductor.rpcapiproxy.ConductorAPI') + self.mock_conductor_api = p.start() + self.mock_conductor_api.return_value = self.fake_conductor_api + self.addCleanup(p.stop) + + def _create_standard_system(self): + self.controller = self._create_host(constants.CONTROLLER) + self.worker = self._create_host(constants.WORKER) + self._create_test_host_cpus(self.worker, + application=8) + self.storage = self._create_host(constants.STORAGE) + + def _create_aio_system(self): + self.controller = self._create_host(constants.CONTROLLER, + subfunction=constants.WORKER) + self._create_test_host_cpus(self.controller, + platform=2, + application=6) + + def setUp(self): + super(StalldLabelTestCase, self).setUp() + self._setup_context() + + def test_stalld_enable_successful_on_aio_controller(self): + self._create_aio_system() + host_uuid = self.controller.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + response_data = self.get_host_labels(host_uuid) + self.validate_labels(input_data, response_data) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_disable_successful_on_aio_controller(self): + self._create_aio_system() + host_uuid = self.controller.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_DISABLED + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + response_data = self.get_host_labels(host_uuid) + self.validate_labels(input_data, response_data) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_enable_successful_on_worker_node(self): + self._create_standard_system() + host_uuid = self.worker.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + response_data = self.get_host_labels(host_uuid) + self.validate_labels(input_data, response_data) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_disable_successful_on_worker_node(self): + self._create_standard_system() + host_uuid = self.worker.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_DISABLED + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + response_data = self.get_host_labels(host_uuid) + self.validate_labels(input_data, response_data) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_enable_fails_on_standard_controller(self): + self._create_standard_system() + host_uuid = self.controller.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED + } + parameters = {'overwrite': True} + self.assign_labels_failure(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is not called + self.fake_conductor_api.configure_stalld.assert_not_called() + + def test_stalld_enable_fails_on_storage_node(self): + self._create_standard_system() + host_uuid = self.storage.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED + } + parameters = {'overwrite': True} + self.assign_labels_failure(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is not called + self.fake_conductor_api.configure_stalld.assert_not_called() + + def test_stalld_assign_application_cpus_successful(self): + """Labels assigned together + """ + self._create_standard_system() + host_uuid = self.worker.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED, + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_APPLICATION + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_assign_all_cpus_successful(self): + """Labels assigned together + """ + self._create_standard_system() + host_uuid = self.worker.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED, + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_ALL + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_assign_application_isolated_cpus_fails(self): + """Fails because no cpus are assigned to application isolated function + on the worker node. + """ + self._create_standard_system() + host_uuid = self.worker.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED, + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_APPLICATION_ISOLATED + } + parameters = {'overwrite': True} + self.assign_labels_failure(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is not called + self.fake_conductor_api.configure_stalld.assert_not_called() + + def _generate_case_insensite_permutations(self, + label_values: list[str], + sample_size=5) -> list: + all_permutations = [] + for label_value in label_values: + character_tuples = ((c.lower(), c.upper()) for c in label_value) + label_permutations = [ + ''.join(x) for x in itertools.product(*character_tuples) + ] + # randomly sample 'n' permutation because the list could be very long + sample_size = min(sample_size, len(label_permutations)) + all_permutations.extend(random.sample(label_permutations, + k=sample_size)) + return all_permutations + + def test_stalld_assign_case_insensitive(self): + """Labels assigned together + """ + self._create_standard_system() + host_uuid = self.worker.uuid + label_values = self._generate_case_insensite_permutations([ + 'enabled', + 'disabled' + ]) + parameters = {'overwrite': True} + for label_value in label_values: + input_data = { + constants.LABEL_STALLD: label_value + } + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + self.fake_conductor_api.configure_stalld.reset_mock() + + def test_stalld_assign_cpu_function_case_insensitive(self): + """Labels assigned together + """ + self._create_standard_system() + host_uuid = self.worker.uuid + label_values = self._generate_case_insensite_permutations([ + 'all', + 'Application', + 'Application-isolated' + ]) + parameters = {'overwrite': True} + for label_value in label_values: + input_data = { + constants.LABEL_STALLD_CPU_FUNCTIONS: label_value + } + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + self.fake_conductor_api.configure_stalld.reset_mock() + + def test_stalld_enable_fails_if_assigned_app_iso_cpus_prior(self): + """Fails because no cpus are assigned to application isolated function + on the worker node. + While stalld is disabled we can assign the isolated cpu functions + but if we try to enable stalld it will fail + """ + self._create_standard_system() + host_uuid = self.worker.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_DISABLED, + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_APPLICATION_ISOLATED + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + # after cpu functions are assigned attempt to enable stalld + self.fake_conductor_api.configure_stalld.reset_mock() + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED + } + parameters = {'overwrite': True} + self.assign_labels_failure(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is not called + self.fake_conductor_api.configure_stalld.assert_not_called() + + def test_stalld_assign_cpus_before_enable_successful(self): + """Labels assigned in sequence + 1. starlingx.io/stalld_cpu_functions=application + 2. starlingx.io/stalld_cpu_functions=all + 3. starlingx.io/stalld=enabled + """ + self._create_standard_system() + host_uuid = self.worker.uuid + input_data = { + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_APPLICATION + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + self.fake_conductor_api.configure_stalld.reset_mock() + + input_data = { + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_ALL + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + self.fake_conductor_api.configure_stalld.reset_mock() + + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_assign_different_cpu_functions(self): + """Labels assigned in sequence + 1. starlingx.io/stalld=enabled + 2. starlingx.io/stalld_cpu_functions=all + 3. starlingx.io/stalld_cpu_functions=application + 4. starlingx.io/stalld_cpu_functions=application-isolated <- fails + 5. assign application-isolated function to 1 cpu of the worker node + 6. starlingx.io/stalld_cpu_functions=application-isolated <- success + """ + self._create_standard_system() + host_uuid = self.worker.uuid + input_data = { + constants.LABEL_STALLD: constants.LABEL_VALUE_STALLD_ENABLED + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + self.fake_conductor_api.configure_stalld.reset_mock() + + input_data = { + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_ALL + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + self.fake_conductor_api.configure_stalld.reset_mock() + + input_data = { + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_APPLICATION + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + self.fake_conductor_api.configure_stalld.reset_mock() + + input_data = { + constants.LABEL_STALLD_CPU_FUNCTIONS: constants.LABEL_VALUE_CPU_APPLICATION_ISOLATED + } + parameters = {'overwrite': True} + self.assign_labels_failure(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_not_called() + self.fake_conductor_api.configure_stalld.reset_mock() + + # Change the last cpu to application-isolated + last_cpu = self.dbapi.icpu_get_by_ihost(host_uuid)[-1] + values = {"allocated_function": constants.ISOLATED_FUNCTION} + self.dbapi.icpu_update(last_cpu.uuid, values) + + # try again + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_assign_custom_label_to_worker_successful(self): + """Custom stalld label on worker node + 1. starlingx.io/stalld.custom_label=custom_value + """ + self._create_standard_system() + host_uuid = self.worker.uuid + custom_stalld_label = f"{constants.LABEL_STALLD}.customlabel" + input_data = { + custom_stalld_label: "custom_value" + } + parameters = {'overwrite': True} + self.assign_labels(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is called + self.fake_conductor_api.configure_stalld.assert_called_once() + + def test_stalld_assign_custom_label_to_storage_fails(self): + """Custom stalld label on worker node + 1. starlingx.io/stalld.custom_label=custom_value + """ + self._create_standard_system() + host_uuid = self.storage.uuid + custom_stalld_label = f"{constants.LABEL_STALLD}.customlabel" + input_data = { + custom_stalld_label: "custom_value" + } + parameters = {'overwrite': True} + self.assign_labels_failure(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is not called + self.fake_conductor_api.configure_stalld.assert_not_called() + + def test_stalld_assign_custom_label_invalid_format(self): + """Custom stalld label on worker node + 1. starlingx.io/stalld_custom_label=custom_value + should be a '.' not an '_' character + """ + self._create_standard_system() + host_uuid = self.worker.uuid + custom_stalld_label = f"{constants.LABEL_STALLD}_customlabel" + input_data = { + custom_stalld_label: "custom_value" + } + parameters = {'overwrite': True} + self.assign_labels_failure(host_uuid, input_data, parameters) + + # Verify that the method configure_stalld() is not called + self.fake_conductor_api.configure_stalld.assert_not_called() diff --git a/sysinv/sysinv/sysinv/sysinv/tests/conductor/test_manager.py b/sysinv/sysinv/sysinv/sysinv/tests/conductor/test_manager.py index 5068ac30a1..b1631dd64b 100644 --- a/sysinv/sysinv/sysinv/sysinv/tests/conductor/test_manager.py +++ b/sysinv/sysinv/sysinv/sysinv/tests/conductor/test_manager.py @@ -5849,6 +5849,51 @@ class ManagerTestCase(base.DbTestCase): # update only 1 time mock_update_cached_app_bundles_set.assert_called_once() + @mock.patch('sysinv.conductor.manager.' + 'ConductorManager._config_apply_runtime_manifest') + @mock.patch('sysinv.conductor.manager.' + 'ConductorManager._config_update_hosts') + def test_configure_stalld(self, + mock_config_update_hosts, + mock_config_apply_runtime_manifest): + self._create_test_ihosts() + hostname = 'compute-0' + host = self.service.get_ihost_by_hostname(self.context, hostname) + host_uuid = host['uuid'] + personalities = [host['personality']] + host_uuids = [host_uuid] + config_dict = { + "personalities": personalities, + "host_uuids": host_uuids, + "classes": [ + 'platform::stalld::runtime' + ], + } + config_uuid = '1234' + mock_config_update_hosts.return_value = config_uuid + self.service.configure_stalld(context=self.context, + host_uuid=host_uuid) + + mock_config_update_hosts.assert_called_once() + mock_config_apply_runtime_manifest.assert_called_once_with( + mock.ANY, + config_uuid, + config_dict) + + @mock.patch('sysinv.conductor.manager.' + 'ConductorManager._config_apply_runtime_manifest') + @mock.patch('sysinv.conductor.manager.' + 'ConductorManager._config_update_hosts') + def test_configure_stalld_host_not_found(self, + mock_config_update_hosts, + mock_config_apply_runtime_manifest): + host_uuid = str(uuid.uuid4()) + self.service.configure_stalld(context=self.context, + host_uuid=host_uuid) + + mock_config_update_hosts.assert_not_called() + mock_config_apply_runtime_manifest.assert_not_called() + class ManagerTestCaseInternal(base.BaseHostTestCase): def setUp(self): diff --git a/sysinv/sysinv/sysinv/sysinv/tests/conductor/test_rpcapi.py b/sysinv/sysinv/sysinv/sysinv/tests/conductor/test_rpcapi.py index ac71780ae4..7892ea2ccd 100644 --- a/sysinv/sysinv/sysinv/sysinv/tests/conductor/test_rpcapi.py +++ b/sysinv/sysinv/sysinv/sysinv/tests/conductor/test_rpcapi.py @@ -116,3 +116,8 @@ class RPCAPITestCase(base.DbTestCase): 'cast', ihost_uuid=self.fake_ihost['uuid'], kernel_running=constants.KERNEL_LOWLATENCY) + + def test_configure_stalld(self): + self._test_rpcapi('configure_stalld', + 'call', + host_uuid=self.fake_ihost['uuid']) diff --git a/sysinv/sysinv/sysinv/sysinv/tests/puppet/test_platform.py b/sysinv/sysinv/sysinv/sysinv/tests/puppet/test_platform.py index 2d2553a84b..0ba69da29f 100644 --- a/sysinv/sysinv/sysinv/sysinv/tests/puppet/test_platform.py +++ b/sysinv/sysinv/sysinv/sysinv/tests/puppet/test_platform.py @@ -1,10 +1,11 @@ -# Copyright (c) 2019-2024 Wind River Systems, Inc. +# Copyright (c) 2019-2025 Wind River Systems, Inc. # # SPDX-License-Identifier: Apache-2.0 # import mock from sysinv.tests.db import base as dbbase +from sysinv.tests.db import utils as dbutils from sysinv.tests.puppet import base from sysinv.common import constants @@ -193,3 +194,334 @@ class PlatformTestCaseKubernetesReservedMemory(base.PuppetTestCaseMixin, 'platform::kubernetes::params::k8s_reserved_memory': expected_k8s_reserved_memory} ) self.assertEqual(k8s_reserved_memory_config, config) + + +class PlatformTestCaseStalldConfig(base.PuppetTestCaseMixin, + dbbase.BaseHostTestCase): + + def _create_test_host_cpus(self, host, + platform=0, application=0, isolated=0, + threads=1): + counts = [platform, application, isolated] + functions = [constants.PLATFORM_FUNCTION, + constants.APPLICATION_FUNCTION, + constants.ISOLATED_FUNCTION] + + nodes = self.dbapi.inode_get_by_ihost(host.id) + for node in nodes: + cpu = 0 + for count, function in zip(counts, functions): + for _ in range(0, count): + for thread in range(0, threads): + self.dbapi.icpu_create(host.id, + dbutils.get_test_icpu( + forinodeid=node.id, + cpu=cpu, thread=thread, + allocated_function=function)) + cpu = cpu + 1 + + def _configure_cpus(self, platform=0, application=0, isolated=0): + self._create_test_host_cpus(self.host, + platform=platform, + application=application, + isolated=isolated) + self._create_test_host_addresses(self.host.hostname) + + def _create_test_host_cpus_using_spec(self, + host, + cpu_assignment_spec: dict): + node = self.dbapi.inode_get_by_ihost(host.id)[0] + for function, _d in cpu_assignment_spec.items(): + for thread, cpu_ids in _d.items(): + for cpu_id in cpu_ids: + icpu = dbutils.get_test_icpu(forinodeid=node.id, + cpu=cpu_id, + thread=thread, + allocated_function=function) + self.dbapi.icpu_create(host.id, icpu) + + def _configure_cpus_using_assignment_spec(self, cpu_assignment_spec): + self._create_test_host_cpus_using_spec(self.host, cpu_assignment_spec) + self._create_test_host_addresses(self.host.hostname) + + def _create_host_labels_in_db(self, labels): + for label_str in labels: + k, v = label_str.split('=') + self.dbapi.label_create(self.host.id, + {'host_id': self.host.id, + 'label_key': k, + 'label_value': v}) + + def setUp(self): + super(PlatformTestCaseStalldConfig, self).setUp() + self.host = self._create_test_host(constants.WORKER) + + def test_get_stalld_config_defaults(self): + """ stalld disabled with application default + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + stalld_cpu_list = "2-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + False, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_enabled_cpus_default(self): + """ stalld enabled with cpu default=application + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + labels = [ + # starlingx.io/stalld=enabled + f"{constants.LABEL_STALLD}={constants.LABEL_VALUE_STALLD_ENABLED}" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "2-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + True, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_enabled_cpus_all(self): + """ stalld enabled with all cpus + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + labels = [ + # starlingx.io/stalld=enabled + f"{constants.LABEL_STALLD}={constants.LABEL_VALUE_STALLD_ENABLED}", + # starlingx.io/stalld_cpu_functions=all + f"{constants.LABEL_STALLD_CPU_FUNCTIONS}={constants.LABEL_VALUE_CPU_ALL}" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "0-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + True, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_enabled_cpus_application(self): + """ stalld enabled with application cpus + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + labels = [ + # starlingx.io/stalld=enabled + f"{constants.LABEL_STALLD}={constants.LABEL_VALUE_STALLD_ENABLED}", + # starlingx.io/stalld_cpu_functions=application + f"{constants.LABEL_STALLD_CPU_FUNCTIONS}={constants.LABEL_VALUE_CPU_APPLICATION}" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "2-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + True, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_enabled_cpus_isolated(self): + """ stalld enabled with application-isolated cpus + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + labels = [ + # starlingx.io/stalld=enabled + f"{constants.LABEL_STALLD}={constants.LABEL_VALUE_STALLD_ENABLED}", + # starlingx.io/stalld_cpu_functions=application-isolated + f"{constants.LABEL_STALLD_CPU_FUNCTIONS}={constants.LABEL_VALUE_CPU_APPLICATION_ISOLATED}" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "8-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + True, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_case_insensitive(self): + """ stalld enabled with application-isolated cpus + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + labels = [ + # starlingx.io/stalld=enabled + f"{constants.LABEL_STALLD}=EnablED", + # starlingx.io/stalld_cpu_functions=application-isolated + f"{constants.LABEL_STALLD_CPU_FUNCTIONS}=APPlicaTion-iSOLAted" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "8-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + True, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_enabled_cpus_isolated_not_configured(self): + """ stalld enabled with application-isolated cpus + but cpu list is empty so stalld will be disabled in config + 0-1 platform cpus + 2-9 application cpus + n/a isolated cpus + """ + self._configure_cpus(platform=2, application=8, isolated=0) + labels = [ + # starlingx.io/stalld=enabled + f"{constants.LABEL_STALLD}={constants.LABEL_VALUE_STALLD_ENABLED}", + # starlingx.io/stalld_cpu_functions=application-isolated + f"{constants.LABEL_STALLD_CPU_FUNCTIONS}={constants.LABEL_VALUE_CPU_APPLICATION_ISOLATED}" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + False, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_bad_data_in_db(self): + """ stalld enabled with bad value + stalld defaults to disabled + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + labels = [ + # starlingx.io/stalld=%#@#%#@ + f"{constants.LABEL_STALLD}=%#@#%#@", + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "2-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + False, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_cpus_bad_data_in_db(self): + """ stalld enabled with bad cpu function value + stalld cpu function defaults to application + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + labels = [ + # starlingx.io/stalld=enabled + f"{constants.LABEL_STALLD}={constants.LABEL_VALUE_STALLD_ENABLED}", + # starlingx.io/stalld_cpu_functions=%#@#%#@ + f"{constants.LABEL_STALLD_CPU_FUNCTIONS}=%#@#%#@" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "2-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + True, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_enabled_cpus_application_with_threads(self): + """ stalld enabled with application cpus (with 2 threads) + and discontinous cpu ids + platform: 0-1 ... thread 0 + 10-11 ... thread 1 + application: 2-7 ... thread 0 + 12-17 ... thread 1 + isolated: 8-9 ... thread 0 + 18-19 ... thread 1 + """ + _THREAD_0, _THREAD_1 = 0, 1 + cpu_assignment_spec = { + constants.PLATFORM_FUNCTION: { + _THREAD_0: [0, 1], + _THREAD_1: [10, 11] + }, + constants.APPLICATION_FUNCTION: { + _THREAD_0: [2, 3, 4, 5, 6, 7], + _THREAD_1: [12, 13, 14, 15, 16, 17] + }, + constants.ISOLATED_FUNCTION: { + _THREAD_0: [8, 9], + _THREAD_1: [18, 19] + } + } + # Add application CPUs + self._configure_cpus_using_assignment_spec(cpu_assignment_spec) + labels = [ + f"{constants.LABEL_STALLD}={constants.LABEL_VALUE_STALLD_ENABLED}", + f"{constants.LABEL_STALLD_CPU_FUNCTIONS}={constants.LABEL_VALUE_CPU_APPLICATION_ISOLATED}" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "8-9,18-19" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": True, + "platform::stalld::params::cpu_list": f"'{stalld_cpu_list}'" + }) + + def test_get_stalld_config_enabled_cpus_all_with_custom_parameters(self): + """ stalld enabled with all cpus + 0-1 platform cpus + 2-7 application cpus + 8-9 isolated cpus + """ + self._configure_cpus(platform=2, application=6, isolated=2) + custom_parameter1, value1 = "string_parameter", "string_value1" + custom_parameter2, value2 = "numeric_parameter", 1792992 + + labels = [ + # starlingx.io/stalld=enabled + f"{constants.LABEL_STALLD}={constants.LABEL_VALUE_STALLD_ENABLED}", + # starlingx.io/stalld_cpu_functions=all + f"{constants.LABEL_STALLD_CPU_FUNCTIONS}={constants.LABEL_VALUE_CPU_ALL}", + f"{constants.CUSTOM_STALLD_LABEL_STRING}{custom_parameter1}={value1}", + f"{constants.CUSTOM_STALLD_LABEL_STRING}{custom_parameter2}={value2}" + ] + self._create_host_labels_in_db(labels) + stalld_cpu_list = "0-9" + self.operator.update_host_config(self.host) + self.assertConfigParameters(self.mock_write_config, { + "platform::stalld::params::enable": + True, + "platform::stalld::params::cpu_list": + f"'{stalld_cpu_list}'", + f"platform::stalld::params::{custom_parameter1}": + f"'{value1}'", + f"platform::stalld::params::{custom_parameter2}": + f"'{value2}'" + })