Skip to content

Commit 910dc86

Browse files
Jan Zmeskallukas-bednar
authored andcommitted
Add PlaybookRunner service (#117)
* Add PlaybookRunner service * Add common.CommandReader * Add unit tests for common.CommandReader * Add unit tests for PlaybookRunner service * Docstrings for PlaybookRunner and its tests * Add ssh_common_args param to PlaybookRunner.run
1 parent 4b4d950 commit 910dc86

File tree

8 files changed

+626
-5
lines changed

8 files changed

+626
-5
lines changed

rrmngmnt/common.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,59 @@ def normalize_string(data):
3737
if isinstance(data, six.text_type):
3838
data = data.encode('utf-8', errors='replace')
3939
return data
40+
41+
42+
class CommandReader(object):
43+
"""
44+
This class is for gradual reading of commands output lines as they come in.
45+
Each instance of CommandReader is tied to one command and one executor.
46+
The executor calls the command only once the method read_lines is called.
47+
After the execution of command finishes, CommandReader object may be
48+
queried for return code, stdout and stderr of the command.
49+
50+
Example usage:
51+
my_host = Host("1.2.3.4")
52+
my_host.users.append(RootUser("1234"))
53+
my_executor = my_host.executor()
54+
cr = CommandReader(my_executor, ['ansible-playbook', 'long_task.yml']
55+
for line in cr.read_lines():
56+
print(line)
57+
"""
58+
59+
def __init__(self, executor, cmd, cmd_input=None):
60+
"""
61+
Args:
62+
executor (rrmngmnt.Executor): instance of rrmngmnt.Executor class
63+
or one of its subclasses that executes provided command
64+
cmd (list): Command to be executed
65+
cmd_input(str): Input for the command
66+
"""
67+
self.executor = executor
68+
self.cmd = cmd
69+
self.cmd_input = cmd_input
70+
self.rc = None
71+
self.out = ''
72+
self.err = ''
73+
74+
def read_lines(self):
75+
"""
76+
Generator that yields lines of command output as they come to
77+
underlying file handler.
78+
79+
Yields:
80+
str: Line of command's output stripped of newline character
81+
"""
82+
with self.executor.session() as ss:
83+
command = ss.command(self.cmd)
84+
with command.execute() as (in_, out, err):
85+
if self.cmd_input:
86+
in_.write(self.cmd_input)
87+
in_.close()
88+
while True:
89+
line = out.readline()
90+
self.out += line
91+
if not line:
92+
break
93+
yield line.strip('\n')
94+
self.rc = command.rc
95+
self.err = err.read()

rrmngmnt/host.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from rrmngmnt.network import Network
2020
from rrmngmnt.operatingsystem import OperatingSystem
2121
from rrmngmnt.package_manager import PackageManagerProxy
22+
from rrmngmnt.playbook_runner import PlaybookRunner
2223
from rrmngmnt.resource import Resource
2324
from rrmngmnt.service import Systemd, SysVinit, InitCtl
2425
from rrmngmnt.storage import NFSService, LVMService
@@ -460,6 +461,10 @@ def lvm(self):
460461
def fs(self):
461462
return FileSystem(self)
462463

464+
@property
465+
def playbook(self):
466+
return PlaybookRunner(self)
467+
463468
@property
464469
def ssh_public_key(self):
465470
return self.get_ssh_public_key()

rrmngmnt/playbook_runner.py

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import contextlib
2+
import json
3+
import os.path
4+
import uuid
5+
6+
from rrmngmnt.common import CommandReader
7+
from rrmngmnt.resource import Resource
8+
from rrmngmnt.service import Service
9+
10+
11+
class PlaybookRunner(Service):
12+
"""
13+
Class for working with and especially executing Ansible playbooks on hosts.
14+
On your Host instance, it might be accessed (similar to other services) by
15+
playbook property.
16+
17+
Example:
18+
rc,out, err = my_host.playbook.run('long_task.yml')
19+
20+
In such case, the default logger of this class (called PlaybookRunner) will
21+
be used to log playbook's output. It will propagate events to handlers of
22+
ancestor loggers. However, PlaybookRunner might also be directly
23+
instantiated with instance of logging.Logger passed to logger parameter.
24+
25+
Example:
26+
my_runner = PlaybookRunner(my_host, logging.getLogger('playbook'))
27+
rc, out, err = my_runner.run('long_task.yml')
28+
29+
In that case, custom provided logger will be used instead. In both cases,
30+
each log record will be prefixed with UUID generated specifically for that
31+
one playbook execution.
32+
"""
33+
class LoggerAdapter(Resource.LoggerAdapter):
34+
35+
def process(self, msg, kwargs):
36+
return "[%s] %s" % (self.extra['self'].short_run_uuid, msg), kwargs
37+
38+
tmp_dir = "/tmp"
39+
binary = "ansible-playbook"
40+
extra_vars_file = "extra_vars.json"
41+
default_inventory_name = "inventory"
42+
default_inventory_content = "localhost ansible_connection=local"
43+
ssh_common_args_param = "--ssh-common-args"
44+
check_mode_param = "--check"
45+
46+
def __init__(self, host, logger=None):
47+
"""
48+
Args:
49+
host (rrmngmnt.Host): Underlying host for this service
50+
logger (logging.Logger): Alternate logger for Ansible output
51+
"""
52+
super(PlaybookRunner, self).__init__(host)
53+
if logger:
54+
self.set_logger(logger)
55+
self.run_uuid = uuid.uuid4()
56+
self.short_run_uuid = str(self.run_uuid).split('-')[0]
57+
self.tmp_exec_dir = None
58+
self.cmd = [self.binary]
59+
self.rc = None
60+
self.out = None
61+
self.err = None
62+
63+
@contextlib.contextmanager
64+
def _exec_dir(self):
65+
"""
66+
Context manager that makes sure that for each execution of playbook,
67+
temporary directory (whose name is the same as run's UUID) is created
68+
on the host and removed afterwards.
69+
"""
70+
exec_dir_path = os.path.join(self.tmp_dir, self.short_run_uuid)
71+
self.host.fs.rmdir(exec_dir_path)
72+
self.host.fs.mkdir(exec_dir_path)
73+
self.tmp_exec_dir = exec_dir_path
74+
try:
75+
yield
76+
finally:
77+
self.tmp_exec_dir = None
78+
self.host.fs.rmdir(exec_dir_path)
79+
80+
def _upload_file(self, file_):
81+
file_path_on_host = os.path.join(
82+
self.tmp_exec_dir, os.path.basename(file_)
83+
)
84+
self.host.fs.put(path_src=file_, path_dst=file_path_on_host)
85+
return file_path_on_host
86+
87+
def _dump_vars_to_json_file(self, vars_):
88+
file_path_on_host = os.path.join(
89+
self.tmp_exec_dir, self.extra_vars_file
90+
)
91+
self.host.fs.create_file(
92+
content=json.dumps(vars_), path=file_path_on_host
93+
)
94+
return file_path_on_host
95+
96+
def _generate_default_inventory(self):
97+
file_path_on_host = os.path.join(
98+
self.tmp_exec_dir, self.default_inventory_name
99+
)
100+
self.host.fs.create_file(
101+
content=self.default_inventory_content,
102+
path=file_path_on_host
103+
)
104+
return file_path_on_host
105+
106+
def run(
107+
self, playbook, extra_vars=None, vars_files=None, inventory=None,
108+
verbose_level=1, run_in_check_mode=False, ssh_common_args=None,
109+
):
110+
"""
111+
Run Ansible playbook on host
112+
113+
Args:
114+
playbook (str): Path to playbook you want to execute (on your
115+
machine)
116+
extra_vars (dict): Dictionary of extra variables that are to be
117+
passed to playbook execution. They will be dumped to JSON file
118+
and included using -e@ parameter
119+
vars_files (list): List of additional variable files to be included
120+
using -e@ parameter. If one variable is specified both in
121+
extra_vars and in one of the vars_files, the one in vars_files
122+
takes precedence.
123+
inventory (str): Path to an inventory file (on your machine) to be
124+
used for playbook execution. If none is provided, default
125+
inventory including only localhost will be generated and used
126+
verbose_level (int): How much should playbook be verbose. Possible
127+
values are 1 through 5 with 1 being the most quiet and 5 being
128+
the most verbose
129+
run_in_check_mode (bool): If True, playbook will not actually be
130+
executed, but instead run with --check parameter
131+
ssh_common_args (list): List of options that will extend (not
132+
replace) the list of default options that Ansible uses when
133+
calling ssh/sftp/scp. Example: ["-o StrictHostKeyChecking=no",
134+
"-o UserKnownHostsFile=/dev/null"]
135+
136+
Returns:
137+
tuple: tuple of (rc, out, err)
138+
"""
139+
self.logger.info(
140+
"Running playbook {} on {}".format(
141+
os.path.basename(playbook),
142+
self.host.fqdn
143+
)
144+
)
145+
146+
with self._exec_dir():
147+
148+
if extra_vars:
149+
self.cmd.append(
150+
"-e@{}".format(self._dump_vars_to_json_file(extra_vars))
151+
)
152+
153+
if vars_files:
154+
for f in vars_files:
155+
self.cmd.append("-e@{}".format(self._upload_file(f)))
156+
157+
self.cmd.append("-i")
158+
if inventory:
159+
self.cmd.append(self._upload_file(inventory))
160+
else:
161+
self.cmd.append(self._generate_default_inventory())
162+
163+
self.cmd.append("-{}".format("v" * verbose_level))
164+
165+
if run_in_check_mode:
166+
self.cmd.append(self.check_mode_param)
167+
168+
if ssh_common_args:
169+
self.cmd.append(
170+
"{}={}".format(
171+
self.ssh_common_args_param, " ".join(ssh_common_args)
172+
)
173+
)
174+
175+
self.cmd.append(self._upload_file(playbook))
176+
177+
self.logger.debug("Executing: {}".format(" ".join(self.cmd)))
178+
179+
playbook_reader = CommandReader(self.host.executor(), self.cmd)
180+
for line in playbook_reader.read_lines():
181+
self.logger.debug(line)
182+
self.rc, self.out, self.err = (
183+
playbook_reader.rc,
184+
playbook_reader.out,
185+
playbook_reader.err
186+
)
187+
188+
self.logger.debug(
189+
"Ansible playbook finished with RC: {}".format(self.rc)
190+
)
191+
192+
return self.rc, self.out, self.err

rrmngmnt/resource.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,14 @@ def warn(self, *args, **kwargs):
1616
def __init__(self):
1717
super(Resource, self).__init__()
1818
logger = logging.getLogger(self.__class__.__name__)
19-
self._logger_adapter = self.LoggerAdapter(logger, {'self': self})
19+
self.set_logger(logger)
2020

2121
@property
2222
def logger(self):
2323
return self._logger_adapter
24+
25+
def set_logger(self, logger):
26+
if isinstance(logger, logging.Logger):
27+
self._logger_adapter = self.LoggerAdapter(logger, {'self': self})
28+
elif isinstance(logger, logging.LoggerAdapter):
29+
self._logger_adapter = logger

rrmngmnt/ssh.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ class RemoteExecutor(Executor):
2121
Any resource which provides SSH service.
2222
2323
This class is meant to replace our current utilities.machine.LinuxMachine
24-
classs. This allows you to lower access to communicate with ssh.
24+
class. This allows you to lower access to communicate with ssh.
2525
Like a live interaction, getting rid of True/False results, and
2626
mixing stdout with stderr.
2727
2828
You can still use use 'run_cmd' method if you don't care.
29-
But I would recommed you to work like this:
29+
But I would recommend you to work like this:
3030
"""
3131

3232
TCP_TIMEOUT = 10.0

tests/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,8 @@ def run(self, input_, timeout=None):
106106
@contextlib.contextmanager
107107
def execute(self, bufsize=-1, timeout=None):
108108
rc, out, err = self._ss.get_data(self.cmd)
109-
yield six.StringIO(), six.StringIO(out), six.StringIO(err)
110109
self._rc = rc
110+
yield six.StringIO(), six.StringIO(out), six.StringIO(err)
111111

112112
def __init__(self, user, address):
113113
super(FakeExecutor, self).__init__(user)

0 commit comments

Comments
 (0)