Skip to content

Commit 51ab39f

Browse files
authored
[hostcfgd]: Add Ability To Configure Feature During Run-time (#6700)
Features may be enabled/disabled for the same topology based on run-time configuration. This PR adds the ability to enable/disable feature based on config db data. signed-off-by: Tamer Ahmed <[email protected]>
1 parent 7f52abc commit 51ab39f

File tree

9 files changed

+494
-60
lines changed

9 files changed

+494
-60
lines changed

files/build_scripts/mask_disabled_services.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,5 @@
99
init_cfg = json.load(init_cfg_file)
1010
if 'FEATURE' in init_cfg:
1111
for feature_name, feature_props in init_cfg['FEATURE'].items():
12-
if 'state' in feature_props and feature_props['state'] == 'disabled':
12+
if 'state' in feature_props and feature_props['state'] != 'enabled' and feature_props['state'] != 'always_enabled':
1313
subprocess.run(['systemctl', 'mask', '{}.service'.format(feature_name)])

src/sonic-host-services/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ dist/
1212

1313
# Unit test coverage
1414
.coverage
15+
.pytest_cache/
1516
coverage.xml
1617
htmlcov/

src/sonic-host-services/__init__.py

Whitespace-only changes.

src/sonic-host-services/pytest.ini

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
[pytest]
2-
addopts = --cov=scripts --cov-report html --cov-report term --cov-report xml
2+
addopts = --cov=scripts --cov-report html --cov-report term --cov-report xml --ignore=tests/hostcfgd/test_vectors.py

src/sonic-host-services/scripts/hostcfgd

+94-58
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,10 @@ class HostConfigDaemon:
394394
self.config_db.connect(wait_for_init=True, retry_on=True)
395395
syslog.syslog(syslog.LOG_INFO, 'ConfigDB connect success')
396396

397+
# Load DEVICE metadata configurations
398+
self.device_config = {}
399+
self.device_config['DEVICE_METADATA'] = self.config_db.get_table('DEVICE_METADATA')
400+
397401
self.aaacfg = AaaCfg()
398402
self.iptables = Iptables()
399403
self.ntpcfg = NtpCfg(self.config_db)
@@ -421,75 +425,106 @@ class HostConfigDaemon:
421425
ntp_global = self.config_db.get_table('NTP')
422426
self.ntpcfg.load(ntp_global, ntp_server)
423427

424-
def update_feature_state(self, feature_name, state, feature_table):
425-
if self.cached_feature_states[feature_name] == "always_enabled":
426-
if state != "always_enabled":
427-
syslog.syslog(syslog.LOG_INFO, "Feature '{}' service is always enabled"
428-
.format(feature_name))
429-
entry = self.config_db.get_entry('FEATURE', feature_name)
430-
entry['state'] = 'always_enabled'
431-
self.config_db.set_entry('FEATURE', feature_name, entry)
432-
return
428+
def get_target_state(self, feature_name, state):
429+
template = jinja2.Template(state)
430+
target_state = template.render(self.device_config)
431+
entry = self.config_db.get_entry('FEATURE', feature_name)
432+
entry["state"] = target_state
433+
self.config_db.set_entry("FEATURE", feature_name, entry)
433434

434-
self.cached_feature_states[feature_name] = state
435+
return target_state
435436

437+
def get_feature_attribute(self, feature_name, feature_table):
436438
has_timer = ast.literal_eval(feature_table[feature_name].get('has_timer', 'False'))
437439
has_global_scope = ast.literal_eval(feature_table[feature_name].get('has_global_scope', 'True'))
438440
has_per_asic_scope = ast.literal_eval(feature_table[feature_name].get('has_per_asic_scope', 'False'))
439441

440442
# Create feature name suffix depending feature is running in host or namespace or in both
441-
feature_name_suffix_list = (([feature_name] if has_global_scope or not self.is_multi_npu else []) +
442-
([(feature_name + '@' + str(asic_inst)) for asic_inst in range(device_info.get_num_npus())
443-
if has_per_asic_scope and self.is_multi_npu]))
443+
feature_names = (
444+
([feature_name] if has_global_scope or not self.is_multi_npu else []) +
445+
([(feature_name + '@' + str(asic_inst)) for asic_inst in range(device_info.get_num_npus())
446+
if has_per_asic_scope and self.is_multi_npu])
447+
)
444448

445-
if not feature_name_suffix_list:
449+
if not feature_names:
446450
syslog.syslog(syslog.LOG_ERR, "Feature '{}' service not available"
447451
.format(feature_name))
448452

449453
feature_suffixes = ["service"] + (["timer"] if has_timer else [])
450454

451-
if state == "enabled":
452-
start_cmds = []
453-
for feature_name_suffix in feature_name_suffix_list:
454-
for suffix in feature_suffixes:
455-
start_cmds.append("sudo systemctl unmask {}.{}".format(feature_name_suffix, suffix))
456-
# If feature has timer associated with it, start/enable corresponding systemd .timer unit
457-
# otherwise, start/enable corresponding systemd .service unit
458-
start_cmds.append("sudo systemctl enable {}.{}".format(feature_name_suffix, feature_suffixes[-1]))
459-
start_cmds.append("sudo systemctl start {}.{}".format(feature_name_suffix, feature_suffixes[-1]))
460-
for cmd in start_cmds:
461-
syslog.syslog(syslog.LOG_INFO, "Running cmd: '{}'".format(cmd))
462-
try:
463-
subprocess.check_call(cmd, shell=True)
464-
except subprocess.CalledProcessError as err:
465-
syslog.syslog(syslog.LOG_ERR, "'{}' failed. RC: {}, output: {}"
466-
.format(err.cmd, err.returncode, err.output))
467-
syslog.syslog(syslog.LOG_ERR, "Feature '{}.{}' failed to be enabled and started"
468-
.format(feature_name, feature_suffixes[-1]))
469-
return
470-
syslog.syslog(syslog.LOG_INFO, "Feature '{}.{}' is enabled and started"
471-
.format(feature_name, feature_suffixes[-1]))
472-
elif state == "disabled":
473-
stop_cmds = []
474-
for feature_name_suffix in feature_name_suffix_list:
475-
for suffix in reversed(feature_suffixes):
476-
stop_cmds.append("sudo systemctl stop {}.{}".format(feature_name_suffix, suffix))
477-
stop_cmds.append("sudo systemctl disable {}.{}".format(feature_name_suffix, suffix))
478-
stop_cmds.append("sudo systemctl mask {}.{}".format(feature_name_suffix, suffix))
479-
for cmd in stop_cmds:
480-
syslog.syslog(syslog.LOG_INFO, "Running cmd: '{}'".format(cmd))
481-
try:
482-
subprocess.check_call(cmd, shell=True)
483-
except subprocess.CalledProcessError as err:
484-
syslog.syslog(syslog.LOG_ERR, "'{}' failed. RC: {}, output: {}"
485-
.format(err.cmd, err.returncode, err.output))
486-
syslog.syslog(syslog.LOG_ERR, "Feature '{}' failed to be stopped and disabled".format(feature_name))
487-
return
488-
syslog.syslog(syslog.LOG_INFO, "Feature '{}' is stopped and disabled".format(feature_name))
489-
else:
490-
syslog.syslog(syslog.LOG_ERR, "Unexpected state value '{}' for feature '{}'"
491-
.format(state, feature_name))
455+
return feature_names, feature_suffixes
456+
457+
def enable_feature(self, feature_names, feature_suffixes):
458+
start_cmds = []
459+
for feature_name in feature_names:
460+
for suffix in feature_suffixes:
461+
start_cmds.append("sudo systemctl unmask {}.{}".format(feature_name, suffix))
462+
# If feature has timer associated with it, start/enable corresponding systemd .timer unit
463+
# otherwise, start/enable corresponding systemd .service unit
464+
start_cmds.append("sudo systemctl enable {}.{}".format(feature_name, feature_suffixes[-1]))
465+
start_cmds.append("sudo systemctl start {}.{}".format(feature_name, feature_suffixes[-1]))
466+
for cmd in start_cmds:
467+
syslog.syslog(syslog.LOG_INFO, "Running cmd: '{}'".format(cmd))
468+
try:
469+
subprocess.check_call(cmd, shell=True)
470+
except subprocess.CalledProcessError as err:
471+
syslog.syslog(syslog.LOG_ERR, "'{}' failed. RC: {}, output: {}"
472+
.format(err.cmd, err.returncode, err.output))
473+
syslog.syslog(syslog.LOG_ERR, "Feature '{}.{}' failed to be enabled and started"
474+
.format(feature_name, feature_suffixes[-1]))
475+
return
476+
477+
def disable_feature(self, feature_names, feature_suffixes):
478+
stop_cmds = []
479+
for feature_name in feature_names:
480+
for suffix in reversed(feature_suffixes):
481+
stop_cmds.append("sudo systemctl stop {}.{}".format(feature_name, suffix))
482+
stop_cmds.append("sudo systemctl disable {}.{}".format(feature_name, suffix))
483+
stop_cmds.append("sudo systemctl mask {}.{}".format(feature_name, suffix))
484+
for cmd in stop_cmds:
485+
syslog.syslog(syslog.LOG_INFO, "Running cmd: '{}'".format(cmd))
486+
try:
487+
subprocess.check_call(cmd, shell=True)
488+
except subprocess.CalledProcessError as err:
489+
syslog.syslog(syslog.LOG_ERR, "'{}' failed. RC: {}, output: {}"
490+
.format(err.cmd, err.returncode, err.output))
491+
syslog.syslog(syslog.LOG_ERR, "Feature '{}' failed to be stopped and disabled".format(feature_name))
492+
return
493+
494+
def is_invariant_feature(self, feature_name, state, feature_table):
495+
invariant_feature = self.cached_feature_states[feature_name] == "always_enabled" or \
496+
self.cached_feature_states[feature_name] == "always_disabled"
497+
if invariant_feature:
498+
invariant_state = self.cached_feature_states[feature_name]
499+
if state != invariant_state:
500+
syslog.syslog(syslog.LOG_INFO, "Feature '{}' service is '{}'"
501+
.format(feature_name, invariant_state))
502+
entry = self.config_db.get_entry('FEATURE', feature_name)
503+
entry['state'] = invariant_state
504+
self.config_db.set_entry('FEATURE', feature_name, entry)
492505

506+
if state == "always_disabled":
507+
feature_names, feature_suffixes = self.get_feature_attribute(feature_name, feature_table)
508+
self.disable_feature(feature_names, feature_suffixes)
509+
syslog.syslog(syslog.LOG_INFO, "Feature '{}' is stopped and disabled".format(feature_name))
510+
511+
return invariant_feature
512+
513+
def update_feature_state(self, feature_name, state, feature_table):
514+
if not self.is_invariant_feature(feature_name, state, feature_table):
515+
self.cached_feature_states[feature_name] = state
516+
517+
feature_names, feature_suffixes = self.get_feature_attribute(feature_name, feature_table)
518+
if state == "enabled":
519+
self.enable_feature(feature_names, feature_suffixes)
520+
syslog.syslog(syslog.LOG_INFO, "Feature '{}.{}' is enabled and started"
521+
.format(feature_name, feature_suffixes[-1]))
522+
elif state == "disabled":
523+
self.disable_feature(feature_names, feature_suffixes)
524+
syslog.syslog(syslog.LOG_INFO, "Feature '{}' is stopped and disabled".format(feature_name))
525+
else:
526+
syslog.syslog(syslog.LOG_ERR, "Unexpected state value '{}' for feature '{}'"
527+
.format(state, feature_name))
493528

494529
def update_all_feature_states(self):
495530
feature_table = self.config_db.get_table('FEATURE')
@@ -500,13 +535,14 @@ class HostConfigDaemon:
500535

501536
state = feature_table[feature_name]['state']
502537
if not state:
503-
syslog.syslog(syslog.LOG_WARNING, "Eanble state of feature '{}' is None".format(feature_name))
538+
syslog.syslog(syslog.LOG_WARNING, "Enable state of feature '{}' is None".format(feature_name))
504539
continue
505540

541+
target_state = self.get_target_state(feature_name, state)
506542
# Store the initial value of 'state' field in 'FEATURE' table of a specific container
507-
self.cached_feature_states[feature_name] = state
543+
self.cached_feature_states[feature_name] = target_state
508544

509-
self.update_feature_state(feature_name, state, feature_table)
545+
self.update_feature_state(feature_name, target_state, feature_table)
510546

511547
def aaa_handler(self, key, data):
512548
self.aaacfg.aaa_update(key, data)

src/sonic-host-services/tests/hostcfgd/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import importlib.machinery
2+
import importlib.util
3+
import os
4+
import sys
5+
import swsssdk
6+
7+
from parameterized import parameterized
8+
from unittest import TestCase, mock
9+
from tests.hostcfgd.test_vectors import HOSTCFGD_TEST_VECTOR
10+
from tests.hostcfgd.mock_configdb import MockConfigDb
11+
12+
13+
swsssdk.ConfigDBConnector = MockConfigDb
14+
test_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15+
modules_path = os.path.dirname(test_path)
16+
scripts_path = os.path.join(modules_path, "scripts")
17+
sys.path.insert(0, modules_path)
18+
19+
# Load the file under test
20+
hostcfgd_path = os.path.join(scripts_path, 'hostcfgd')
21+
loader = importlib.machinery.SourceFileLoader('hostcfgd', hostcfgd_path)
22+
spec = importlib.util.spec_from_loader(loader.name, loader)
23+
hostcfgd = importlib.util.module_from_spec(spec)
24+
loader.exec_module(hostcfgd)
25+
sys.modules['hostcfgd'] = hostcfgd
26+
27+
28+
class TestHostcfgd(TestCase):
29+
"""
30+
Test hostcfd daemon - feature
31+
"""
32+
def __verify_table(self, table, expected_table):
33+
"""
34+
verify config db tables
35+
36+
Compares Config DB table (FEATURE) with expected output table
37+
38+
Args:
39+
table(dict): Current Config Db table
40+
expected_table(dict): Expected Config Db table
41+
42+
Returns:
43+
None
44+
"""
45+
is_equal = len(table) == len(expected_table)
46+
if is_equal:
47+
for key, fields in expected_table.items():
48+
is_equal = is_equal and key in table and len(fields) == len(table[key])
49+
if is_equal:
50+
for field, value in fields.items():
51+
is_equal = is_equal and value == table[key][field]
52+
if not is_equal:
53+
break;
54+
else:
55+
break
56+
return is_equal
57+
58+
@parameterized.expand(HOSTCFGD_TEST_VECTOR)
59+
def test_hostcfgd(self, test_name, test_data):
60+
"""
61+
Test hostcfd daemon initialization
62+
63+
Args:
64+
test_name(str): test name
65+
test_data(dict): test data which contains initial Config Db tables, and expected results
66+
67+
Returns:
68+
None
69+
"""
70+
MockConfigDb.set_config_db(test_data["config_db"])
71+
with mock.patch("hostcfgd.subprocess") as mocked_subprocess:
72+
host_config_daemon = hostcfgd.HostConfigDaemon()
73+
host_config_daemon.update_all_feature_states()
74+
assert self.__verify_table(
75+
MockConfigDb.get_config_db()["FEATURE"],
76+
test_data["expected_config_db"]["FEATURE"]
77+
), "Test failed for test data: {0}".format(test_data)
78+
mocked_subprocess.check_call.assert_has_calls(test_data["expected_subprocess_calls"], any_order=True)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
class MockConfigDb(object):
2+
"""
3+
Mock Config DB which responds to data tables requests and store updates to the data table
4+
"""
5+
STATE_DB = None
6+
CONFIG_DB = None
7+
8+
def __init__(self):
9+
pass
10+
11+
@staticmethod
12+
def set_config_db(test_config_db):
13+
MockConfigDb.CONFIG_DB = test_config_db
14+
15+
@staticmethod
16+
def get_config_db():
17+
return MockConfigDb.CONFIG_DB
18+
19+
def connect(self, wait_for_init=True, retry_on=True):
20+
pass
21+
22+
def get(self, db_id, key, field):
23+
return MockConfigDb.CONFIG_DB[key][field]
24+
25+
def get_entry(self, key, field):
26+
return MockConfigDb.CONFIG_DB[key][field]
27+
28+
def set_entry(self, key, field, data):
29+
MockConfigDb.CONFIG_DB[key][field] = data
30+
31+
def get_table(self, table_name):
32+
return MockConfigDb.CONFIG_DB[table_name]

0 commit comments

Comments
 (0)