Skip to content

Commit

Permalink
Add remote syslog configuration (#53)
Browse files Browse the repository at this point in the history
Adding the following functionality:

- Configure remote syslog servers: protocol, filter, severity level
- Update global syslog configuration: severity level, message format
  • Loading branch information
iavraham authored Jun 5, 2023
1 parent 7e6b5a4 commit 16a6fa6
Show file tree
Hide file tree
Showing 6 changed files with 255 additions and 124 deletions.
132 changes: 65 additions & 67 deletions scripts/hostcfgd
Original file line number Diff line number Diff line change
Expand Up @@ -1610,75 +1610,55 @@ class MgmtIfaceCfg(object):
self.mgmt_vrf_enabled = enabled


class SyslogCfg:
SYSLOG_RATE_LIMIT_INTERVAL = 'rate_limit_interval'
SYSLOG_RATE_LIMIT_BURST = 'rate_limit_burst'
HOST_KEY = 'GLOBAL'
class RSyslogCfg(object):
"""Remote syslog config daemon
# syslog conf file path in docker
SYSLOG_CONF_PATH = '/etc/rsyslog.conf'

# Regular expressions to extract value from rsyslog.conf
INTERVAL_PATTERN = '.*SystemLogRateLimitInterval\s+(\d+).*'
BURST_PATTERN = '.*SystemLogRateLimitBurst\s+(\d+).*'
Handles changes in syslog configuration tables:
1) SYSLOG_CONFIG
2) SYSLOG_SERVER
"""

def __init__(self):
self.current_interval, self.current_burst = self.parse_syslog_conf()

def syslog_update(self, data):
"""Update syslog related configuration
Args:
data (dict): CONFIG DB data: {<field_name>: <field_value>}
"""
new_interval = '0'
new_burst = '0'
if data:
new_interval = data.get(self.SYSLOG_RATE_LIMIT_INTERVAL, '0')
new_burst = data.get(self.SYSLOG_RATE_LIMIT_BURST, '0')

if new_interval == self.current_interval and new_burst == self.current_burst:
return

syslog.syslog(syslog.LOG_INFO, f'Configure syslog rate limit interval={new_interval} (old:{self.current_interval}), burst={new_burst} (old:{self.current_burst})')

try:
run_cmd(['systemctl', 'reset-failed', 'rsyslog-config', 'rsyslog'], raise_exception=True)
run_cmd(['systemctl', 'restart', 'rsyslog-config'], raise_exception=True)
self.current_interval = new_interval
self.current_burst = new_burst
except Exception as e:
syslog.syslog(syslog.LOG_ERR, f'Failed to configure syslog rate limit for host - {e}')
self.cache = {}

def load(self, data):
if self.HOST_KEY in data:
self.syslog_update(data[self.HOST_KEY])
def load(self, rsyslog_config={}, rsyslog_servers={}):
# Get initial remote syslog configuration
self.cache = {
'config': rsyslog_config,
'servers': rsyslog_servers
}
syslog.syslog(syslog.LOG_INFO,
f'RSyslogCfg: Initial config: {self.cache}')

def parse_syslog_conf(self):
"""Parse existing syslog conf and extract config values
def update_rsyslog_config(self, rsyslog_config, rsyslog_servers):
"""Apply remote syslog configuration
Returns:
tuple: interval,burst,target_ip
The daemon restarts rsyslog-config which will regenerate rsyslog.conf
file based on Jinja2 template and will restart rsyslogd
Args:
rsyslog_config: Remote syslog global config table
rsyslog_servers: Remote syslog servers
"""
interval = '0'
burst = '0'
syslog.syslog(syslog.LOG_DEBUG, 'RSyslogCfg: Configuration update')
if (self.cache.get('config', {}) != rsyslog_config or
self.cache.get('servers', {}) != rsyslog_servers):
syslog.syslog(syslog.LOG_INFO, f'RSyslogCfg: Set config '
f'{rsyslog_config}, servers: {rsyslog_servers}')

try:
with open(self.SYSLOG_CONF_PATH, 'r') as f:
content = f.read()
pattern = re.compile(self.INTERVAL_PATTERN)
for match in pattern.finditer(content):
interval = match.group(1)
break
# Restarting the service
try:
run_cmd(['systemctl', 'reset-failed', 'rsyslog-config',
'rsyslog'], log_err=True, raise_exception=True)
run_cmd(['systemctl', 'restart', 'rsyslog-config'],
log_err=True, raise_exception=True)
except Exception:
syslog.syslog(syslog.LOG_ERR,
f'RSyslogCfg: Failed to restart rsyslog service')
return

pattern = re.compile(self.BURST_PATTERN)
for match in pattern.finditer(content):
burst = match.group(1)
break
except OSError:
syslog.syslog(syslog.LOG_ERR, f'Failed to read file {self.SYSLOG_CONF_PATH}')
return interval, burst
return interval, burst
# Updating the cache
self.cache['config'] = rsyslog_config
self.cache['servers'] = rsyslog_servers

class HostConfigDaemon:
def __init__(self):
Expand Down Expand Up @@ -1730,8 +1710,8 @@ class HostConfigDaemon:
# Initialize MgmtIfaceCfg
self.mgmtifacecfg = MgmtIfaceCfg()

# Initialize SyslogCfg
self.syslogcfg = SyslogCfg()
# Initialize RSyslogCfg
self.rsyslogcfg = RSyslogCfg()

def load(self, init_data):
features = init_data['FEATURE']
Expand All @@ -1748,7 +1728,9 @@ class HostConfigDaemon:
dev_meta = init_data.get(swsscommon.CFG_DEVICE_METADATA_TABLE_NAME, {})
mgmt_ifc = init_data.get(swsscommon.CFG_MGMT_INTERFACE_TABLE_NAME, {})
mgmt_vrf = init_data.get(swsscommon.CFG_MGMT_VRF_CONFIG_TABLE_NAME, {})
syslog = init_data.get('SYSLOG_CONFIG', {})
syslog_cfg = init_data.get(swsscommon.CFG_SYSLOG_CONFIG_TABLE_NAME, {})
syslog_srv = init_data.get(swsscommon.CFG_SYSLOG_SERVER_TABLE_NAME, {})


self.feature_handler.sync_state_field(features)
self.aaacfg.load(aaa, tacacs_global, tacacs_server, radius_global, radius_server)
Expand All @@ -1758,7 +1740,7 @@ class HostConfigDaemon:
self.passwcfg.load(passwh)
self.devmetacfg.load(dev_meta)
self.mgmtifacecfg.load(mgmt_ifc, mgmt_vrf)
self.syslogcfg.load(syslog)
self.rsyslogcfg.load(syslog_cfg, syslog_srv)

# Update AAA with the hostname
self.aaacfg.hostname_update(self.devmetacfg.hostname)
Expand Down Expand Up @@ -1859,8 +1841,20 @@ class HostConfigDaemon:
self.devmetacfg.hostname_update(data)
self.devmetacfg.timezone_update(data)

def syslog_handler(self, key, op, data):
self.syslogcfg.syslog_update(data)
def rsyslog_handler(self):
rsyslog_config = self.config_db.get_table(
swsscommon.CFG_SYSLOG_CONFIG_TABLE_NAME)
rsyslog_servers = self.config_db.get_table(
swsscommon.CFG_SYSLOG_SERVER_TABLE_NAME)
self.rsyslogcfg.update_rsyslog_config(rsyslog_config, rsyslog_servers)

def rsyslog_server_handler(self, key, op, data):
syslog.syslog(syslog.LOG_INFO, 'SYSLOG_SERVER table handler...')
self.rsyslog_handler()

def rsyslog_config_handler(self, key, op, data):
syslog.syslog(syslog.LOG_INFO, 'SYSLOG_CONFIG table handler...')
self.rsyslog_handler()

def wait_till_system_init_done(self):
# No need to print the output in the log file so using the "--quiet"
Expand Down Expand Up @@ -1909,7 +1903,11 @@ class HostConfigDaemon:
self.config_db.subscribe(swsscommon.CFG_MGMT_VRF_CONFIG_TABLE_NAME,
make_callback(self.mgmt_vrf_handler))

self.config_db.subscribe('SYSLOG_CONFIG', make_callback(self.syslog_handler))
# Handle SYSLOG_CONFIG and SYSLOG_SERVER changes
self.config_db.subscribe(swsscommon.CFG_SYSLOG_CONFIG_TABLE_NAME,
make_callback(self.rsyslog_config_handler))
self.config_db.subscribe(swsscommon.CFG_SYSLOG_SERVER_TABLE_NAME,
make_callback(self.rsyslog_server_handler))

syslog.syslog(syslog.LOG_INFO,
"Waiting for systemctl to finish initialization")
Expand Down
4 changes: 4 additions & 0 deletions tests/common/mock_configdb.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ def __init__(self, **kwargs):
def set_config_db(test_config_db):
MockConfigDb.CONFIG_DB = test_config_db

@staticmethod
def mod_config_db(test_config_db):
MockConfigDb.CONFIG_DB.update(test_config_db)

@staticmethod
def deserialize_key(key, separator="|"):
tokens = key.split(separator)
Expand Down
113 changes: 113 additions & 0 deletions tests/hostcfgd/hostcfgd_rsyslog_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import importlib.machinery
import importlib.util
import os
import sys

from copy import copy
from parameterized import parameterized
from swsscommon import swsscommon
from syslog import syslog, LOG_ERR
from tests.hostcfgd.test_rsyslog_vectors \
import HOSTCFGD_TEST_RSYSLOG_VECTOR as rsyslog_test_data
from tests.common.mock_configdb import MockConfigDb, MockDBConnector
from unittest import TestCase, mock

test_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
modules_path = os.path.dirname(test_path)
scripts_path = os.path.join(modules_path, "scripts")
src_path = os.path.dirname(modules_path)
templates_path = os.path.join(src_path, "sonic-host-services-data/templates")
output_path = os.path.join(test_path, "hostcfgd/output")
sample_output_path = os.path.join(test_path, "hostcfgd/sample_output")
sys.path.insert(0, modules_path)

# Load the file under test
hostcfgd_path = os.path.join(scripts_path, 'hostcfgd')
loader = importlib.machinery.SourceFileLoader('hostcfgd', hostcfgd_path)
spec = importlib.util.spec_from_loader(loader.name, loader)
hostcfgd = importlib.util.module_from_spec(spec)
loader.exec_module(hostcfgd)
sys.modules['hostcfgd'] = hostcfgd

# Mock swsscommon classes
hostcfgd.ConfigDBConnector = MockConfigDb
hostcfgd.DBConnector = MockDBConnector
hostcfgd.Table = mock.Mock()
hostcfgd.run_cmd = mock.Mock()


class TestHostcfgdRSyslog(TestCase):
"""
Test hostcfgd daemon - RSyslog
"""

def __init__(self, *args, **kwargs):
super(TestHostcfgdRSyslog, self).__init__(*args, **kwargs)
self.host_config_daemon = None

def setUp(self):
MockConfigDb.set_config_db(rsyslog_test_data['initial'])
self.host_config_daemon = hostcfgd.HostConfigDaemon()

syslog_config = self.host_config_daemon.config_db.get_table(
swsscommon.CFG_SYSLOG_CONFIG_TABLE_NAME)
syslog_server = self.host_config_daemon.config_db.get_table(
swsscommon.CFG_SYSLOG_SERVER_TABLE_NAME)

assert self.host_config_daemon.rsyslogcfg.cache == {}
self.host_config_daemon.rsyslogcfg.load(syslog_config, syslog_server)
assert self.host_config_daemon.rsyslogcfg.cache != {}

# Reset run_cmd mock
hostcfgd.run_cmd.reset_mock()

def tearDown(self):
self.host_config_daemon = None
MockConfigDb.set_config_db({})

def update_config(self, config_name):
MockConfigDb.mod_config_db(rsyslog_test_data[config_name])
self.host_config_daemon.rsyslog_config_handler(None, None, None)

def assert_applied(self, config_name):
"""Assert that updated config triggered appropriate services
Args:
config_name: str: Test vectors config name
Assert:
Assert when config wasn't used
"""
orig_cache = copy(self.host_config_daemon.rsyslogcfg.cache)
self.update_config(config_name)
assert self.host_config_daemon.rsyslogcfg.cache != orig_cache
hostcfgd.run_cmd.assert_called()

def assert_not_applied(self, config_name):
"""Assert that the same config does not affect on services
Args:
config_name: str: Test vectors config name
Assert:
Assert when config was used
"""
orig_cache = copy(self.host_config_daemon.rsyslogcfg.cache)
self.update_config(config_name)
assert self.host_config_daemon.rsyslogcfg.cache == orig_cache
hostcfgd.run_cmd.assert_not_called()

def test_rsyslog_handle_change_global(self):
self.assert_applied('change_global')

def test_rsyslog_handle_change_server(self):
self.assert_applied('change_server')

def test_rsyslog_handle_add_server(self):
self.assert_applied('add_server')

def test_rsyslog_handle_empty(self):
self.assert_applied('empty_config')

def test_rsyslog_handle_the_same_config(self):
self.assert_not_applied('initial')
56 changes: 0 additions & 56 deletions tests/hostcfgd/hostcfgd_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -670,59 +670,3 @@ def test_mgmtiface_event(self):
call(['cat', '/proc/net/route'], ['grep', '-E', r"eth0\s+00000000\s+[0-9A-Z]+\s+[0-9]+\s+[0-9]+\s+[0-9]+\s+202"], ['wc', '-l'])
]
mocked_check_output.assert_has_calls(expected)

class TestSyslogHandler:
@mock.patch('hostcfgd.run_cmd')
@mock.patch('hostcfgd.SyslogCfg.parse_syslog_conf', mock.MagicMock(return_value=('100', '200')))
def test_syslog_update(self, mock_run_cmd):
syslog_cfg = hostcfgd.SyslogCfg()
data = {
'rate_limit_interval': '100',
'rate_limit_burst': '200'
}
syslog_cfg.syslog_update(data)
mock_run_cmd.assert_not_called()

data = {
'rate_limit_interval': '200',
'rate_limit_burst': '200'
}
syslog_cfg.syslog_update(data)
expected = [call(['systemctl', 'reset-failed', 'rsyslog-config', 'rsyslog'], raise_exception=True),
call(['systemctl', 'restart', 'rsyslog-config'], raise_exception=True)]
mock_run_cmd.assert_has_calls(expected)

data = {
'rate_limit_interval': '100',
'rate_limit_burst': '100'
}
mock_run_cmd.side_effect = Exception()
syslog_cfg.syslog_update(data)
# when exception occurs, interval and burst should not be updated
assert syslog_cfg.current_interval == '200'
assert syslog_cfg.current_burst == '200'

def test_load(self):
syslog_cfg = hostcfgd.SyslogCfg()
syslog_cfg.syslog_update = mock.MagicMock()

data = {}
syslog_cfg.load(data)
syslog_cfg.syslog_update.assert_not_called()

data = {syslog_cfg.HOST_KEY: {}}
syslog_cfg.load(data)
syslog_cfg.syslog_update.assert_called_once()

def test_parse_syslog_conf(self):
syslog_cfg = hostcfgd.SyslogCfg()

syslog_cfg.SYSLOG_CONF_PATH = os.path.join(test_path, 'hostcfgd', 'mock_rsyslog.conf')
interval, burst = syslog_cfg.parse_syslog_conf()
assert interval == '50'
assert burst == '10002'

syslog_cfg.SYSLOG_CONF_PATH = os.path.join(test_path, 'hostcfgd', 'mock_empty_rsyslog.conf')
interval, burst = syslog_cfg.parse_syslog_conf()
assert interval == '0'
assert burst == '0'
Loading

0 comments on commit 16a6fa6

Please sign in to comment.