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() |
||
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
|
|||
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 |
Escape sequences in Python are generally interpreted according to rules similar to standard C. Only if strings are prefixed with
r
orR
are they interpreted as regular expressions.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.