1 | # Licensed to the StackStorm, Inc ('StackStorm') under one or more |
||
2 | # contributor license agreements. See the NOTICE file distributed with |
||
3 | # this work for additional information regarding copyright ownership. |
||
4 | # The ASF licenses this file to You under the Apache License, Version 2.0 |
||
5 | # (the "License"); you may not use this file except in compliance with |
||
6 | # the License. You may obtain a copy of the License at |
||
7 | # |
||
8 | # http://www.apache.org/licenses/LICENSE-2.0 |
||
9 | # |
||
10 | # Unless required by applicable law or agreed to in writing, software |
||
11 | # distributed under the License is distributed on an "AS IS" BASIS, |
||
12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||
13 | # See the License for the specific language governing permissions and |
||
14 | # limitations under the License. |
||
15 | |||
16 | from __future__ import absolute_import |
||
17 | import os |
||
18 | import re |
||
19 | import uuid |
||
20 | |||
21 | import six |
||
22 | |||
23 | from eventlet.green import subprocess |
||
24 | |||
25 | from st2common import log as logging |
||
26 | from st2common.util.green.shell import run_command |
||
27 | from st2common.util.shell import quote_windows |
||
28 | from st2common.constants.action import LIVEACTION_STATUS_SUCCEEDED |
||
29 | from st2common.constants.action import LIVEACTION_STATUS_FAILED |
||
30 | from st2common.constants.action import LIVEACTION_STATUS_TIMED_OUT |
||
31 | from st2common.constants.runners import WINDOWS_RUNNER_DEFAULT_ACTION_TIMEOUT |
||
32 | from st2common.runners.base import ShellRunnerMixin |
||
33 | from st2common.runners.base import get_metadata as get_runner_metadata |
||
34 | |||
35 | from windows_runner.base import BaseWindowsRunner |
||
36 | |||
37 | __all__ = [ |
||
38 | 'WindowsScriptRunner', |
||
39 | |||
40 | 'get_runner', |
||
41 | 'get_metadata', |
||
42 | ] |
||
43 | |||
44 | LOG = logging.getLogger(__name__) |
||
45 | |||
46 | PATH_SEPARATOR = '\\' |
||
47 | |||
48 | # constants to lookup in runner_parameters |
||
49 | RUNNER_HOST = 'host' |
||
50 | RUNNER_USERNAME = 'username' |
||
51 | RUNNER_PASSWORD = 'password' |
||
52 | RUNNER_COMMAND = 'cmd' |
||
53 | RUNNER_TIMEOUT = 'timeout' |
||
54 | RUNNER_SHARE_NAME = 'share' |
||
55 | |||
56 | # Timeouts for different steps |
||
57 | UPLOAD_FILE_TIMEOUT = 30 |
||
58 | CREATE_DIRECTORY_TIMEOUT = 10 |
||
59 | DELETE_FILE_TIMEOUT = 10 |
||
60 | DELETE_DIRECTORY_TIMEOUT = 10 |
||
61 | |||
62 | POWERSHELL_COMMAND = 'powershell.exe -InputFormat None' |
||
63 | |||
64 | |||
65 | class WindowsScriptRunner(BaseWindowsRunner, ShellRunnerMixin): |
||
66 | """ |
||
67 | Runner which executes PowerShell scripts on a remote Windows machine. |
||
68 | """ |
||
69 | |||
70 | def __init__(self, runner_id, timeout=WINDOWS_RUNNER_DEFAULT_ACTION_TIMEOUT): |
||
71 | """ |
||
72 | :param timeout: Action execution timeout in seconds. |
||
73 | :type timeout: ``int`` |
||
74 | """ |
||
75 | super(WindowsScriptRunner, self).__init__(runner_id=runner_id) |
||
76 | self._timeout = timeout |
||
77 | |||
78 | def pre_run(self): |
||
79 | super(WindowsScriptRunner, self).pre_run() |
||
80 | |||
81 | # TODO :This is awful, but the way "runner_parameters" and other variables get |
||
82 | # assigned on the runner instance is even worse. Those arguments should |
||
83 | # be passed to the constructor. |
||
84 | self._host = self.runner_parameters.get(RUNNER_HOST, None) |
||
85 | self._username = self.runner_parameters.get(RUNNER_USERNAME, None) |
||
86 | self._password = self.runner_parameters.get(RUNNER_PASSWORD, None) |
||
87 | self._command = self.runner_parameters.get(RUNNER_COMMAND, None) |
||
88 | self._timeout = self.runner_parameters.get(RUNNER_TIMEOUT, self._timeout) |
||
89 | |||
90 | self._share = self.runner_parameters.get(RUNNER_SHARE_NAME, 'C$') |
||
91 | |||
92 | def run(self, action_parameters): |
||
93 | # Make sure the dependencies are available |
||
94 | self._verify_winexe_exists() |
||
95 | self._verify_smbclient_exists() |
||
96 | |||
97 | # Parse arguments, if any |
||
98 | pos_args, named_args = self._get_script_args(action_parameters) |
||
99 | args = self._get_script_arguments(named_args=named_args, positional_args=pos_args) |
||
100 | |||
101 | # 1. Retrieve full absolute path for the share name |
||
102 | # TODO: Cache resolved paths |
||
103 | base_path = self._get_share_absolute_path(share=self._share) |
||
104 | |||
105 | # 2. Upload script file to a temporary location |
||
106 | local_path = self.entry_point |
||
107 | script_path, temporary_directory_path = self._upload_file(local_path=local_path, |
||
108 | base_path=base_path) |
||
109 | |||
110 | # 3. Execute the script |
||
111 | exit_code, stdout, stderr, timed_out = self._run_script(script_path=script_path, |
||
112 | arguments=args) |
||
113 | |||
114 | # 4. Delete temporary directory |
||
115 | self._delete_directory(directory_path=temporary_directory_path) |
||
116 | |||
117 | succeeded = (exit_code == 0) |
||
118 | |||
119 | if timed_out: |
||
120 | succeeded = False |
||
121 | error = 'Action failed to complete in %s seconds' % (self._timeout) |
||
122 | |||
123 | winexe_error = self._parse_winexe_error(stdout=stdout, stderr=stderr) |
||
124 | |||
125 | if winexe_error: |
||
126 | error += ': %s' % (winexe_error) |
||
127 | else: |
||
128 | error = None |
||
129 | |||
130 | if not succeeded and not timed_out: |
||
131 | error = self._parse_winexe_error() |
||
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
132 | |||
133 | output = { |
||
134 | 'stdout': stdout, |
||
135 | 'stderr': stderr, |
||
136 | 'return_code': exit_code, |
||
137 | 'succeeded': succeeded, |
||
138 | 'failed': not succeeded |
||
139 | } |
||
140 | |||
141 | if error: |
||
142 | output['error'] = error |
||
143 | |||
144 | if timed_out: |
||
145 | status = LIVEACTION_STATUS_TIMED_OUT |
||
146 | else: |
||
147 | status = LIVEACTION_STATUS_SUCCEEDED if exit_code == 0 else LIVEACTION_STATUS_FAILED |
||
148 | |||
149 | return (status, output, None) |
||
150 | |||
151 | def _run_script(self, script_path, arguments=None): |
||
152 | """ |
||
153 | :param script_path: Full path to the script on the remote server. |
||
154 | :type script_path: ``str`` |
||
155 | |||
156 | :param arguments: The arguments to pass to the script. |
||
157 | :type arguments: ``str`` |
||
158 | """ |
||
159 | if arguments is not None: |
||
160 | command = '%s %s %s' % (POWERSHELL_COMMAND, quote_windows(script_path), arguments) |
||
161 | else: |
||
162 | command = '%s %s' % (POWERSHELL_COMMAND, quote_windows(script_path)) |
||
163 | args = self._get_winexe_command_args(host=self._host, username=self._username, |
||
164 | password=self._password, |
||
165 | command=command) |
||
166 | |||
167 | LOG.debug('Running script "%s"' % (script_path)) |
||
168 | |||
169 | # Note: We don't send anything over stdin, we just create an unused pipe |
||
170 | # to avoid some obscure failures |
||
171 | exit_code, stdout, stderr, timed_out = run_command(cmd=args, |
||
172 | stdin=subprocess.PIPE, |
||
173 | stdout=subprocess.PIPE, |
||
174 | stderr=subprocess.PIPE, |
||
175 | shell=False, |
||
176 | timeout=self._timeout) |
||
177 | |||
178 | extra = {'exit_code': exit_code, 'stdout': stdout, 'stderr': stderr} |
||
179 | LOG.debug('Command returned', extra=extra) |
||
180 | |||
181 | return exit_code, stdout, stderr, timed_out |
||
182 | |||
183 | def _get_script_arguments(self, named_args=None, positional_args=None): |
||
184 | """ |
||
185 | Builds a string of named and positional arguments in PowerShell format, |
||
186 | which are passed to the script. |
||
187 | |||
188 | :param named_args: Dictionary with named arguments |
||
189 | :type named_args: ``dict``. |
||
190 | |||
191 | :param positional_args: List of positional arguments |
||
192 | :type positional_args: ``str`` |
||
193 | |||
194 | :rtype: ``str`` |
||
195 | """ |
||
196 | cmd_parts = [] |
||
197 | if positional_args: |
||
198 | cmd_parts.append(positional_args) |
||
199 | if named_args: |
||
200 | for (arg, value) in six.iteritems(named_args): |
||
201 | arg = quote_windows(arg) |
||
202 | if value is None or (isinstance(value, six.string_types) and len(value) < 1): |
||
203 | LOG.debug('Ignoring arg %s as its value is %s.', arg, value) |
||
204 | continue |
||
205 | if isinstance(value, bool): |
||
206 | if value: |
||
207 | cmd_parts.append('-%s' % (arg)) |
||
208 | else: |
||
209 | cmd_parts.append('-%s:$false' % (arg)) |
||
210 | elif isinstance(value, (list, tuple)): |
||
211 | # Array support, pass parameters to shell script |
||
212 | cmd_parts.append('-%s %s' % (arg, ','.join(value))) |
||
213 | else: |
||
214 | cmd_parts.append('-%s %s' % (arg, quote_windows(str(value)))) |
||
215 | return ' '.join(cmd_parts) |
||
216 | |||
217 | def _upload_file(self, local_path, base_path): |
||
218 | """ |
||
219 | Upload provided file to the remote server in a temporary directory. |
||
220 | |||
221 | :param local_path: Local path to the file to upload. |
||
222 | :type local_path: ``str`` |
||
223 | |||
224 | :param base_path: Absolute base path for the share. |
||
225 | :type base_path: ``str`` |
||
226 | """ |
||
227 | file_name = os.path.basename(local_path) |
||
228 | |||
229 | temporary_directory_name = str(uuid.uuid4()) |
||
230 | command = 'mkdir %s' % (quote_windows(temporary_directory_name)) |
||
231 | |||
232 | # 1. Create a temporary dir for out scripts (ignore errors if it already exists) |
||
233 | # Note: We don't necessary have access to $TEMP so we create a temporary directory for our |
||
234 | # us in the root of the share we are using and have access to |
||
235 | args = self._get_smbclient_command_args(host=self._host, username=self._username, |
||
236 | password=self._password, command=command, |
||
237 | share=self._share) |
||
238 | |||
239 | LOG.debug('Creating temp directory "%s"' % (temporary_directory_name)) |
||
240 | |||
241 | exit_code, stdout, stderr, timed_out = run_command(cmd=args, stdout=subprocess.PIPE, |
||
242 | stderr=subprocess.PIPE, shell=False, |
||
243 | timeout=CREATE_DIRECTORY_TIMEOUT) |
||
244 | |||
245 | extra = {'exit_code': exit_code, 'stdout': stdout, 'stderr': stderr} |
||
246 | LOG.debug('Directory created', extra=extra) |
||
247 | |||
248 | # 2. Upload file to temporary directory |
||
249 | remote_path = PATH_SEPARATOR.join([temporary_directory_name, file_name]) |
||
250 | |||
251 | values = { |
||
252 | 'local_path': quote_windows(local_path), |
||
253 | 'remote_path': quote_windows(remote_path) |
||
254 | } |
||
255 | command = 'put %(local_path)s %(remote_path)s' % values |
||
256 | args = self._get_smbclient_command_args(host=self._host, username=self._username, |
||
257 | password=self._password, command=command, |
||
258 | share=self._share) |
||
259 | |||
260 | extra = {'local_path': local_path, 'remote_path': remote_path} |
||
261 | LOG.debug('Uploading file to "%s"' % (remote_path)) |
||
262 | |||
263 | exit_code, stdout, stderr, timed_out = run_command(cmd=args, stdout=subprocess.PIPE, |
||
264 | stderr=subprocess.PIPE, shell=False, |
||
265 | timeout=UPLOAD_FILE_TIMEOUT) |
||
266 | |||
267 | extra = {'exit_code': exit_code, 'stdout': stdout, 'stderr': stderr} |
||
268 | LOG.debug('File uploaded to "%s"' % (remote_path), extra=extra) |
||
269 | |||
270 | full_remote_file_path = base_path + '\\' + remote_path |
||
271 | full_temporary_directory_path = base_path + '\\' + temporary_directory_name |
||
272 | |||
273 | return full_remote_file_path, full_temporary_directory_path |
||
274 | |||
275 | def _get_share_absolute_path(self, share): |
||
276 | """ |
||
277 | Retrieve full absolute path for a share with the provided name. |
||
278 | |||
279 | :param share: Share name. |
||
280 | :type share: ``str`` |
||
281 | """ |
||
282 | command = 'net share %s' % (quote_windows(share)) |
||
283 | args = self._get_winexe_command_args(host=self._host, username=self._username, |
||
284 | password=self._password, |
||
285 | command=command) |
||
286 | |||
287 | LOG.debug('Retrieving full absolute path for share "%s"' % (share)) |
||
288 | exit_code, stdout, stderr, timed_out = run_command(cmd=args, stdout=subprocess.PIPE, |
||
289 | stderr=subprocess.PIPE, shell=False, |
||
290 | timeout=self._timeout) |
||
291 | |||
292 | if exit_code != 0: |
||
293 | msg = 'Failed to retrieve absolute path for share "%s"' % (share) |
||
294 | raise Exception(msg) |
||
295 | |||
296 | share_info = self._parse_share_information(stdout=stdout) |
||
297 | share_path = share_info.get('path', None) |
||
298 | |||
299 | if not share_path: |
||
300 | msg = 'Failed to retrieve absolute path for share "%s"' % (share) |
||
301 | raise Exception(msg) |
||
302 | |||
303 | return share_path |
||
304 | |||
305 | def _parse_share_information(self, stdout): |
||
306 | """ |
||
307 | Parse share information retrieved using "net share <share name>". |
||
308 | |||
309 | :rtype: ``dict`` |
||
310 | """ |
||
311 | lines = stdout.split('\n') |
||
312 | |||
313 | result = {} |
||
314 | |||
315 | for line in lines: |
||
316 | line = line.strip() |
||
317 | split = re.split('\s{3,}', line) |
||
0 ignored issues
–
show
A suspicious escape sequence
\s was found. Did you maybe forget to add an r prefix?
Escape sequences in Python are generally interpreted according to rules similar
to standard C. Only if strings are prefixed with The escape sequence that was used indicates that you might have intended to write a regular expression. Learn more about the available escape sequences. in the Python documentation.
Loading history...
|
|||
318 | |||
319 | if len(split) not in [1, 2]: |
||
320 | # Invalid line, skip it |
||
321 | continue |
||
322 | |||
323 | key = split[0] |
||
324 | key = key.lower().replace(' ', '_') |
||
325 | |||
326 | if len(split) == 2: |
||
327 | value = split[1].strip() |
||
328 | else: |
||
329 | value = None |
||
330 | |||
331 | result[key] = value |
||
332 | |||
333 | return result |
||
334 | |||
335 | def _delete_file(self, file_path): |
||
336 | command = 'rm %(file_path)s' % {'file_path': quote_windows(file_path)} |
||
337 | args = self._get_smbclient_command_args(host=self._host, username=self._username, |
||
338 | password=self._password, command=command, |
||
339 | share=self._share) |
||
340 | |||
341 | exit_code, _, _, _ = run_command(cmd=args, stdout=subprocess.PIPE, |
||
342 | stderr=subprocess.PIPE, shell=False, |
||
343 | timeout=DELETE_FILE_TIMEOUT) |
||
344 | |||
345 | return exit_code == 0 |
||
346 | |||
347 | def _delete_directory(self, directory_path): |
||
348 | command = 'rmdir %(directory_path)s' % {'directory_path': quote_windows(directory_path)} |
||
349 | args = self._get_smbclient_command_args(host=self._host, username=self._username, |
||
350 | password=self._password, command=command, |
||
351 | share=self._share) |
||
352 | |||
353 | LOG.debug('Removing directory "%s"' % (directory_path)) |
||
354 | exit_code, _, _, _ = run_command(cmd=args, stdout=subprocess.PIPE, |
||
355 | stderr=subprocess.PIPE, shell=False, |
||
356 | timeout=DELETE_DIRECTORY_TIMEOUT) |
||
357 | |||
358 | return exit_code == 0 |
||
359 | |||
360 | |||
361 | def get_runner(): |
||
362 | return WindowsScriptRunner(str(uuid.uuid4())) |
||
363 | |||
364 | |||
365 | def get_metadata(): |
||
366 | return get_runner_metadata('windows_script_runner') |
||
367 |