From 8dc5a60294972d8e4296a2cfaabcb9151abc2b35 Mon Sep 17 00:00:00 2001 From: Val V Date: Mon, 2 Oct 2023 21:37:46 -0700 Subject: [PATCH] Support for simpleinit-msb init system (#6618) * Support for simpleinit-msb init system * Drop unused imports * Correct regex * Fix documentation * Address BOTMETA * PEP8 compliance * Drop irrelevant snippet * Add missing option type in docs * PEP8 compliance * Update plugins/modules/simpleinit_msb.py Co-authored-by: Felix Fontein * Update plugins/modules/simpleinit_msb.py Co-authored-by: Felix Fontein * Update plugins/modules/simpleinit_msb.py Co-authored-by: Felix Fontein * Daemonize commands in service control to handle telinit broken behavior * Update plugins/modules/simpleinit_msb.py Co-authored-by: Felix Fontein * Update plugins/modules/simpleinit_msb.py Co-authored-by: Felix Fontein * Update plugins/modules/simpleinit_msb.py Co-authored-by: Felix Fontein * Update plugins/modules/simpleinit_msb.py Co-authored-by: Felix Fontein * Unify examples section * Add unit tests for service state detection * Drop unused import * Add service enable/disable tests * Test get_service_tools() * Do not shadow fail_json() * Reuse module init * Implement service_enabled() and associated tests * Update plugins/modules/simpleinit_msb.py Co-authored-by: Felix Fontein * Indent * Bump version_added * Bump requirements * Reword and move to notes --------- Co-authored-by: Felix Fontein --- .github/BOTMETA.yml | 2 + plugins/modules/simpleinit_msb.py | 322 ++++++++++++++++++ .../plugins/modules/test_simpleinit_msb.py | 200 +++++++++++ 3 files changed, 524 insertions(+) create mode 100644 plugins/modules/simpleinit_msb.py create mode 100644 tests/unit/plugins/modules/test_simpleinit_msb.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index 5c21e0b556..a6470f4950 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -1213,6 +1213,8 @@ files: ignore: ryansb $modules/shutdown.py: maintainers: nitzmahone samdoran aminvakil + $modules/simpleinit_msb.py: + maintainers: vaygr $modules/sl_vm.py: maintainers: mcltn $modules/slack.py: diff --git a/plugins/modules/simpleinit_msb.py b/plugins/modules/simpleinit_msb.py new file mode 100644 index 0000000000..92738471c2 --- /dev/null +++ b/plugins/modules/simpleinit_msb.py @@ -0,0 +1,322 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2016-2023, Vlad Glagolev +# +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: simpleinit_msb +short_description: Manage services on Source Mage GNU/Linux +version_added: 7.5.0 +description: + - Controls services on remote hosts using C(simpleinit-msb). +notes: + - This module needs ansible-core 2.15.5 or newer. Older versions have a broken and insufficient daemonize functionality. +author: "Vlad Glagolev (@vaygr)" +extends_documentation_fragment: + - community.general.attributes +attributes: + check_mode: + support: full + diff_mode: + support: none +options: + name: + type: str + description: + - Name of the service. + required: true + aliases: ['service'] + state: + type: str + required: false + choices: [ running, started, stopped, restarted, reloaded ] + description: + - V(started)/V(stopped) are idempotent actions that will not run + commands unless necessary. V(restarted) will always bounce the + service. V(reloaded) will always reload. + - At least one of O(state) and O(enabled) are required. + - Note that V(reloaded) will start the + service if it is not already started, even if your chosen init + system would not normally. + enabled: + type: bool + required: false + description: + - Whether the service should start on boot. + - At least one of O(state) and O(enabled) are required. +''' + +EXAMPLES = ''' +- name: Example action to start service httpd, if not running + community.general.simpleinit_msb: + name: httpd + state: started + +- name: Example action to stop service httpd, if running + community.general.simpleinit_msb: + name: httpd + state: stopped + +- name: Example action to restart service httpd, in all cases + community.general.simpleinit_msb: + name: httpd + state: restarted + +- name: Example action to reload service httpd, in all cases + community.general.simpleinit_msb: + name: httpd + state: reloaded + +- name: Example action to enable service httpd, and not touch the running state + community.general.simpleinit_msb: + name: httpd + enabled: true +''' + +import os +import re + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.service import daemonize + + +class SimpleinitMSB(object): + """ + Main simpleinit-msb service manipulation class + """ + + def __init__(self, module): + self.module = module + self.name = module.params['name'] + self.state = module.params['state'] + self.enable = module.params['enabled'] + self.changed = False + self.running = None + self.action = None + self.telinit_cmd = None + self.svc_change = False + + def execute_command(self, cmd, daemon=False): + if not daemon: + return self.module.run_command(cmd) + else: + return daemonize(self.module, cmd) + + def check_service_changed(self): + if self.state and self.running is None: + self.module.fail_json(msg="failed determining service state, possible typo of service name?") + # Find out if state has changed + if not self.running and self.state in ["started", "running", "reloaded"]: + self.svc_change = True + elif self.running and self.state in ["stopped", "reloaded"]: + self.svc_change = True + elif self.state == "restarted": + self.svc_change = True + if self.module.check_mode and self.svc_change: + self.module.exit_json(changed=True, msg='service state changed') + + def modify_service_state(self): + # Only do something if state will change + if self.svc_change: + # Control service + if self.state in ['started', 'running']: + self.action = "start" + elif not self.running and self.state == 'reloaded': + self.action = "start" + elif self.state == 'stopped': + self.action = "stop" + elif self.state == 'reloaded': + self.action = "reload" + elif self.state == 'restarted': + self.action = "restart" + + if self.module.check_mode: + self.module.exit_json(changed=True, msg='changing service state') + + return self.service_control() + else: + # If nothing needs to change just say all is well + rc = 0 + err = '' + out = '' + return rc, out, err + + def get_service_tools(self): + paths = ['/sbin', '/usr/sbin', '/bin', '/usr/bin'] + binaries = ['telinit'] + location = dict() + + for binary in binaries: + location[binary] = self.module.get_bin_path(binary, opt_dirs=paths) + + if location.get('telinit', False) and os.path.exists("/etc/init.d/smgl_init"): + self.telinit_cmd = location['telinit'] + + if self.telinit_cmd is None: + self.module.fail_json(msg='cannot find telinit script for simpleinit-msb, aborting...') + + def get_service_status(self): + self.action = "status" + rc, status_stdout, status_stderr = self.service_control() + + if self.running is None and status_stdout.count('\n') <= 1: + cleanout = status_stdout.lower().replace(self.name.lower(), '') + + if "is not running" in cleanout: + self.running = False + elif "is running" in cleanout: + self.running = True + + return self.running + + def service_enable(self): + # Check if the service is already enabled/disabled + if not self.enable ^ self.service_enabled(): + return + + action = "boot" + ("enable" if self.enable else "disable") + + (rc, out, err) = self.execute_command("%s %s %s" % (self.telinit_cmd, action, self.name)) + + self.changed = True + + for line in err.splitlines(): + if self.enable and line.find('already enabled') != -1: + self.changed = False + break + if not self.enable and line.find('already disabled') != -1: + self.changed = False + break + + if not self.changed: + return + + return (rc, out, err) + + def service_enabled(self): + self.service_exists() + + (rc, out, err) = self.execute_command("%s %sd" % (self.telinit_cmd, self.enable)) + + service_enabled = False if self.enable else True + + rex = re.compile(r'^%s$' % self.name) + + for line in out.splitlines(): + if rex.match(line): + service_enabled = True if self.enable else False + break + + return service_enabled + + def service_exists(self): + (rc, out, err) = self.execute_command("%s list" % self.telinit_cmd) + + service_exists = False + + rex = re.compile(r'^\w+\s+%s$' % self.name) + + for line in out.splitlines(): + if rex.match(line): + service_exists = True + break + + if not service_exists: + self.module.fail_json(msg='telinit could not find the requested service: %s' % self.name) + + def service_control(self): + self.service_exists() + + svc_cmd = "%s run %s" % (self.telinit_cmd, self.name) + + rc_state, stdout, stderr = self.execute_command("%s %s" % (svc_cmd, self.action), daemon=True) + + return (rc_state, stdout, stderr) + + +def build_module(): + return AnsibleModule( + argument_spec=dict( + name=dict(required=True, aliases=['service']), + state=dict(choices=['running', 'started', 'stopped', 'restarted', 'reloaded']), + enabled=dict(type='bool'), + ), + supports_check_mode=True, + required_one_of=[['state', 'enabled']], + ) + + +def main(): + module = build_module() + + service = SimpleinitMSB(module) + + rc = 0 + out = '' + err = '' + result = {} + result['name'] = service.name + + # Find service management tools + service.get_service_tools() + + # Enable/disable service startup at boot if requested + if service.module.params['enabled'] is not None: + service.service_enable() + result['enabled'] = service.enable + + if module.params['state'] is None: + # Not changing the running state, so bail out now. + result['changed'] = service.changed + module.exit_json(**result) + + result['state'] = service.state + + service.get_service_status() + + # Calculate if request will change service state + service.check_service_changed() + + # Modify service state if necessary + (rc, out, err) = service.modify_service_state() + + if rc != 0: + if err: + module.fail_json(msg=err) + else: + module.fail_json(msg=out) + + result['changed'] = service.changed | service.svc_change + if service.module.params['enabled'] is not None: + result['enabled'] = service.module.params['enabled'] + + if not service.module.params['state']: + status = service.get_service_status() + if status is None: + result['state'] = 'absent' + elif status is False: + result['state'] = 'started' + else: + result['state'] = 'stopped' + else: + # as we may have just bounced the service the service command may not + # report accurate state at this moment so just show what we ran + if service.module.params['state'] in ['started', 'restarted', 'running', 'reloaded']: + result['state'] = 'started' + else: + result['state'] = 'stopped' + + module.exit_json(**result) + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/modules/test_simpleinit_msb.py b/tests/unit/plugins/modules/test_simpleinit_msb.py new file mode 100644 index 0000000000..d97e9b5f29 --- /dev/null +++ b/tests/unit/plugins/modules/test_simpleinit_msb.py @@ -0,0 +1,200 @@ +# Copyright (c) 2023 Vlad Glagolev +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.general.tests.unit.compat.mock import patch +from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleFailJson, ModuleTestCase, set_module_args + +from ansible_collections.community.general.plugins.modules.simpleinit_msb import SimpleinitMSB, build_module + + +_TELINIT_LIST = """ +RUNLEVEL SCRIPT +2 smgl-suspend-single +3 crond +3 fuse +3 network +3 nscd +3 smgl-default-remote-fs +3 smgl-misc +3 sshd +DEV coldplug +DEV devices +DEV udevd +S hostname.sh +S hwclock.sh +S keymap.sh +S modutils +S mountall.sh +S mountroot.sh +S single +S smgl-default-crypt-fs +S smgl-metalog +S smgl-sysctl +S sysstat +""" + +_TELINIT_LIST_ENABLED = """ +smgl-suspend-single +crond +fuse +network +nscd +smgl-default-remote-fs +smgl-misc +sshd +coldplug +devices +udevd +hostname.sh +hwclock.sh +keymap.sh +modutils +mountall.sh +mountroot.sh +single +smgl-default-crypt-fs +smgl-metalog +smgl-sysctl +""" + +_TELINIT_LIST_DISABLED = """ +sysstat +""" + +_TELINIT_ALREADY_ENABLED = """ +Service smgl-suspend-single already enabled. +""" + +_TELINIT_ALREADY_DISABLED = """ +Service smgl-suspend-single already disabled. +""" + +_TELINIT_STATUS_RUNNING = """ +sshd is running with Process ID(s) 8510 8508 2195 +""" + +_TELINIT_STATUS_RUNNING_NOT = """ +/sbin/metalog is not running +""" + + +class TestSimpleinitMSB(ModuleTestCase): + + def setUp(self): + super(TestSimpleinitMSB, self).setUp() + + def tearDown(self): + super(TestSimpleinitMSB, self).tearDown() + + def init_module(self, args): + set_module_args(args) + + return SimpleinitMSB(build_module()) + + @patch('os.path.exists', return_value=True) + @patch('ansible.module_utils.basic.AnsibleModule.get_bin_path', return_value="/sbin/telinit") + def test_get_service_tools(self, *args, **kwargs): + simpleinit_msb = self.init_module({ + 'name': 'smgl-suspend-single', + 'state': 'running', + }) + + simpleinit_msb.get_service_tools() + + self.assertEqual(simpleinit_msb.telinit_cmd, "/sbin/telinit") + + @patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.execute_command') + def test_service_exists(self, execute_command): + simpleinit_msb = self.init_module({ + 'name': 'smgl-suspend-single', + 'state': 'running', + }) + + execute_command.return_value = (0, _TELINIT_LIST, "") + + simpleinit_msb.service_exists() + + @patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.execute_command') + def test_service_exists_not(self, execute_command): + simpleinit_msb = self.init_module({ + 'name': 'ntp', + 'state': 'running', + }) + + execute_command.return_value = (0, _TELINIT_LIST, "") + + with self.assertRaises(AnsibleFailJson) as context: + simpleinit_msb.service_exists() + + self.assertEqual("telinit could not find the requested service: ntp", context.exception.args[0]["msg"]) + + @patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.service_exists') + @patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.execute_command') + def test_check_service_enabled(self, execute_command, service_exists): + simpleinit_msb = self.init_module({ + 'name': 'nscd', + 'state': 'running', + 'enabled': 'true', + }) + + service_exists.return_value = True + execute_command.return_value = (0, _TELINIT_LIST_ENABLED, "") + + self.assertTrue(simpleinit_msb.service_enabled()) + + # Race condition check + with patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.service_enabled', return_value=False): + execute_command.return_value = (0, "", _TELINIT_ALREADY_ENABLED) + + simpleinit_msb.service_enable() + + self.assertFalse(simpleinit_msb.changed) + + @patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.service_exists') + @patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.execute_command') + def test_check_service_disabled(self, execute_command, service_exists): + simpleinit_msb = self.init_module({ + 'name': 'sysstat', + 'state': 'stopped', + 'enabled': 'false', + }) + + service_exists.return_value = True + execute_command.return_value = (0, _TELINIT_LIST_DISABLED, "") + + self.assertFalse(simpleinit_msb.service_enabled()) + + # Race condition check + with patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.service_enabled', return_value=True): + execute_command.return_value = (0, "", _TELINIT_ALREADY_DISABLED) + + simpleinit_msb.service_enable() + + self.assertFalse(simpleinit_msb.changed) + + @patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.service_control') + def test_check_service_running(self, service_control): + simpleinit_msb = self.init_module({ + 'name': 'sshd', + 'state': 'running', + }) + + service_control.return_value = (0, _TELINIT_STATUS_RUNNING, "") + + self.assertFalse(simpleinit_msb.get_service_status()) + + @patch('ansible_collections.community.general.plugins.modules.simpleinit_msb.SimpleinitMSB.service_control') + def test_check_service_running_not(self, service_control): + simpleinit_msb = self.init_module({ + 'name': 'smgl-metalog', + 'state': 'running', + }) + + service_control.return_value = (0, _TELINIT_STATUS_RUNNING_NOT, "") + + self.assertFalse(simpleinit_msb.get_service_status())