Statistics
| Branch: | Tag: | Revision:

root / image_creator / os_type / windows.py @ 76f2ae77

History | View | Annotate | Download (22.5 kB)

1
# -*- coding: utf-8 -*-
2
#
3
# Copyright 2012 GRNET S.A. All rights reserved.
4
#
5
# Redistribution and use in source and binary forms, with or
6
# without modification, are permitted provided that the following
7
# conditions are met:
8
#
9
#   1. Redistributions of source code must retain the above
10
#      copyright notice, this list of conditions and the following
11
#      disclaimer.
12
#
13
#   2. Redistributions in binary form must reproduce the above
14
#      copyright notice, this list of conditions and the following
15
#      disclaimer in the documentation and/or other materials
16
#      provided with the distribution.
17
#
18
# THIS SOFTWARE IS PROVIDED BY GRNET S.A. ``AS IS'' AND ANY EXPRESS
19
# OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
21
# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GRNET S.A OR
22
# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
25
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED
26
# AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
27
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
28
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
# POSSIBILITY OF SUCH DAMAGE.
30
#
31
# The views and conclusions contained in the software and
32
# documentation are those of the authors and should not be
33
# interpreted as representing official policies, either expressed
34
# or implied, of GRNET S.A.
35

    
36
"""This module hosts OS-specific code common for the various Microsoft
37
Windows OSs."""
38

    
39
from image_creator.os_type import OSBase, sysprep
40
from image_creator.util import FatalError, check_guestfs_version, get_command
41

    
42
import hivex
43
import tempfile
44
import os
45
import time
46
import random
47
import string
48
import subprocess
49
import struct
50

    
51
kvm = get_command('kvm')
52

    
53
BOOT_TIMEOUT = 300
54

    
55

    
56
class Windows(OSBase):
57
    """OS class for Windows"""
58
    def __init__(self, image, **kargs):
59
        super(Windows, self).__init__(image, **kargs)
60

    
61
        device = self.g.part_to_dev(self.root)
62

    
63
        self.last_part_num = self.g.part_list(device)[-1]['part_num']
64
        self.last_drive = None
65
        self.system_drive = None
66

    
67
        for drive, partition in self.g.inspect_get_drive_mappings(self.root):
68
            if partition == "%s%d" % (device, self.last_part_num):
69
                self.last_drive = drive
70
            if partition == self.root:
71
                self.system_drive = drive
72

    
73
        assert self.system_drive
74

    
75
    def needed_sysprep_params(self):
76
        """Returns a list of needed sysprep parameters. Each element in the
77
        list is a SysprepParam object.
78
        """
79
        password = self.SysprepParam(
80
            'password', 'Image Administrator Password', 20, lambda x: True)
81

    
82
        return [password]
83

    
84
    @sysprep('Disabling IPv6 privacy extensions')
85
    def disable_ipv6_privacy_extensions(self):
86
        """Disable IPv6 privacy extensions"""
87

    
88
        self._guest_exec('netsh interface ipv6 set global '
89
                         'randomizeidentifiers=disabled store=persistent')
90

    
91
    @sysprep('Disabling Teredo interface')
92
    def disable_teredo(self):
93
        """Disable Teredo interface"""
94

    
95
        self._guest_exec('netsh interface teredo set state disabled')
96

    
97
    @sysprep('Disabling ISATAP Adapters')
98
    def disable_isatap(self):
99
        """Disable ISATAP Adapters"""
100

    
101
        self._guest_exec('netsh interface isa set state disabled')
102

    
103
    @sysprep('Enabling ping responses')
104
    def enable_pings(self):
105
        """Enable ping responces"""
106

    
107
        self._guest_exec('netsh firewall set icmpsetting 8')
108

    
109
    @sysprep('Disabling hibernation support')
110
    def disable_hibernation(self):
111
        """Disable hibernation support and remove the hibernation file"""
112

    
113
        self._guest_exec(r'powercfg.exe /hibernate off')
114

    
115
    @sysprep('Setting the system clock to UTC')
116
    def utc(self):
117
        """Set the hardware clock to UTC"""
118

    
119
        path = r'HKLM\SYSTEM\CurrentControlSet\Control\TimeZoneInformation'
120
        self._guest_exec(
121
            r'REG ADD %s /v RealTimeIsUniversal /t REG_DWORD /d 1 /f' % path)
122

    
123
    @sysprep('Clearing the event logs')
124
    def clear_logs(self):
125
        """Clear all the event logs"""
126

    
127
        self._guest_exec(
128
            r"cmd /q /c for /f %l in ('wevtutil el') do wevtutil cl %l")
129

    
130
    @sysprep('Executing sysprep on the image (may take more that 10 minutes)')
131
    def microsoft_sysprep(self):
132
        """Run the Microsoft System Preparation Tool. This will remove
133
        system-specific data and will make the image ready to be deployed.
134
        After this no other task may run.
135
        """
136

    
137
        self._guest_exec(r'C:\Windows\system32\sysprep\sysprep '
138
                         r'/quiet /generalize /oobe /shutdown')
139
        self.syspreped = True
140

    
141
    @sysprep('Shrinking the last filesystem')
142
    def shrink(self):
143
        """Shrink the last filesystem. Make sure the filesystem is defragged"""
144

    
145
        # Query for the maximum number of reclaimable bytes
146
        cmd = (
147
            r'cmd /Q /C "SET SCRIPT=%TEMP%\QUERYMAX_%RANDOM%.TXT & ' +
148
            r'ECHO SELECT DISK 0 > %SCRIPT% & ' +
149
            'ECHO SELECT PARTITION %d >> %%SCRIPT%% & ' % self.last_part_num +
150
            r'ECHO SHRINK QUERYMAX >> %SCRIPT% & ' +
151
            r'ECHO EXIT >> %SCRIPT% & ' +
152
            r'DISKPART /S %SCRIPT% & ' +
153
            r'IF ERRORLEVEL 1 EXIT /B 1 & ' +
154
            r'DEL /Q %SCRIPT%"')
155

    
156
        stdout, stderr, rc = self._guest_exec(cmd)
157

    
158
        querymax = None
159
        for line in stdout.splitlines():
160
            # diskpart will return something like this:
161
            #
162
            #   The maximum number of reclaimable bytes is: xxxx MB
163
            #
164
            if line.find('reclaimable') >= 0:
165
                querymax = line.split(':')[1].split()[0].strip()
166
                assert querymax.isdigit(), \
167
                    "Number of reclaimable bytes not a number"
168

    
169
        if querymax is None:
170
            FatalError("Error in shrinking! "
171
                       "Couldn't find the max number of reclaimable bytes!")
172

    
173
        querymax = int(querymax)
174
        # From ntfsresize:
175
        # Practically the smallest shrunken size generally is at around
176
        # "used space" + (20-200 MB). Please also take into account that
177
        # Windows might need about 50-100 MB free space left to boot safely.
178
        # I'll give 100MB extra space just to be sure
179
        querymax -= 100
180

    
181
        if querymax < 0:
182
            self.out.warn("Not enought available space to shrink the image!")
183
            return
184

    
185
        cmd = (
186
            r'cmd /Q /C "SET SCRIPT=%TEMP%\QUERYMAX_%RANDOM%.TXT & ' +
187
            r'ECHO SELECT DISK 0 > %SCRIPT% & ' +
188
            'ECHO SELECT PARTITION %d >> %%SCRIPT%% & ' % self.last_part_num +
189
            'ECHO SHRINK DESIRED=%d >> %%SCRIPT%% & ' % querymax +
190
            r'ECHO EXIT >> %SCRIPT% & ' +
191
            r'DISKPART /S %SCRIPT% & ' +
192
            r'IF ERRORLEVEL 1 EXIT /B 1 & ' +
193
            r'DEL /Q %SCRIPT%"')
194

    
195
        stdout, stderr, rc = self._guest_exec(cmd)
196

    
197
        for line in stdout.splitlines():
198
            if line.find('shrunk') >= 0:
199
                self.out.output(line)
200

    
201
    def do_sysprep(self):
202
        """Prepare system for image creation."""
203

    
204
        if getattr(self, 'syspreped', False):
205
            raise FatalError("Image is already syspreped!")
206

    
207
        txt = "System preparation parameter: `%s' is needed but missing!"
208
        for param in self.needed_sysprep_params():
209
            if param[0] not in self.sysprep_params:
210
                raise FatalError(txt % param[0])
211

    
212
        self.mount(readonly=False)
213
        try:
214
            disabled_uac = self._update_uac_remote_setting(1)
215
            token = self._enable_os_monitor()
216

    
217
            # disable the firewalls
218
            firewall_states = self._update_firewalls(0, 0, 0)
219

    
220
            # Delete the pagefile. It will be recreated when the system boots
221
            systemroot = self.g.inspect_get_windows_systemroot(self.root)
222
            pagefile = "%s/pagefile.sys" % systemroot
223
            self.g.rm_rf(self.g.case_sensitive_path(pagefile))
224

    
225
        finally:
226
            self.umount()
227

    
228
        self.out.output("Shutting down helper VM ...", False)
229
        self.g.sync()
230
        # guestfs_shutdown which is the prefered way to shutdown the backend
231
        # process was introduced in version 1.19.16
232
        if check_guestfs_version(self.g, 1, 19, 16) >= 0:
233
            ret = self.g.shutdown()
234
        else:
235
            ret = self.g.kill_subprocess()
236

    
237
        self.out.success('done')
238

    
239
        vm = None
240
        monitor = None
241
        try:
242
            self.out.output("Starting windows VM ...", False)
243
            monitorfd, monitor = tempfile.mkstemp()
244
            os.close(monitorfd)
245
            vm, display = self._create_vm(monitor)
246
            self.out.success("started (console on vnc display: %d)." % display)
247

    
248
            self.out.output("Waiting for OS to boot ...", False)
249
            if not self._wait_on_file(monitor, token):
250
                raise FatalError("Windows booting timed out.")
251
            else:
252
                time.sleep(10)  # Just to be sure everything is up
253
                self.out.success('done')
254

    
255
            self.out.output("Disabling automatic logon ...", False)
256
            self._disable_autologon()
257
            self.out.success('done')
258

    
259
            self.out.output('Preparing system from image creation:')
260

    
261
            tasks = self.list_syspreps()
262
            enabled = filter(lambda x: x.enabled, tasks)
263
            size = len(enabled)
264

    
265
            # Make sure shrink runs in the end, before ms sysprep
266
            enabled = filter(lambda x: self.sysprep_info(x).name != 'shrink',
267
                             enabled)
268

    
269
            shrink_enabled = False
270
            if len(enabled) != size:
271
                enabled.append(self.shrink)
272
                shrink_enabled = True
273

    
274
            # Make sure the ms sysprep is the last task to run if it is enabled
275
            enabled = filter(
276
                lambda x: self.sysprep_info(x).name != 'microsoft-sysprep',
277
                enabled)
278

    
279
            ms_sysprep_enabled = False
280
            if len(enabled) != size:
281
                enabled.append(self.microsoft_sysprep)
282
                ms_sysprep_enabled = True
283

    
284
            cnt = 0
285
            for task in enabled:
286
                cnt += 1
287
                self.out.output(('(%d/%d)' % (cnt, size)).ljust(7), False)
288
                task()
289
                setattr(task.im_func, 'executed', True)
290

    
291
            self.out.output("Sending shut down command ...", False)
292
            if not ms_sysprep_enabled:
293
                self._shutdown()
294
            self.out.success("done")
295

    
296
            self.out.output("Waiting for windows to shut down ...", False)
297
            vm.wait()
298
            self.out.success("done")
299
        finally:
300
            if monitor is not None:
301
                os.unlink(monitor)
302

    
303
            if vm is not None:
304
                self._destroy_vm(vm)
305

    
306
            self.out.output("Relaunching helper VM (may take a while) ...",
307
                            False)
308
            self.g.launch()
309
            self.out.success('done')
310

    
311
            self.mount(readonly=False)
312
            try:
313
                if disabled_uac:
314
                    self._update_uac_remote_setting(0)
315

    
316
                self._update_firewalls(*firewall_states)
317
            finally:
318
                self.umount()
319

    
320
    def _create_vm(self, monitor):
321
        """Create a VM with the image attached as the disk
322

323
            monitor: a file to be used to monitor when the OS is up
324
        """
325

    
326
        def random_mac():
327
            mac = [0x00, 0x16, 0x3e,
328
                   random.randint(0x00, 0x7f),
329
                   random.randint(0x00, 0xff),
330
                   random.randint(0x00, 0xff)]
331

    
332
            return ':'.join(map(lambda x: "%02x" % x, mac))
333

    
334
        # Use ganeti's VNC port range for a random vnc port
335
        vnc_port = random.randint(11000, 14999)
336
        display = vnc_port - 5900
337

    
338
        vm = kvm(
339
            '-smp', '1', '-m', '1024', '-drive',
340
            'file=%s,format=raw,cache=unsafe,if=virtio' % self.image.device,
341
            '-netdev', 'type=user,hostfwd=tcp::445-:445,id=netdev0',
342
            '-device', 'virtio-net-pci,mac=%s,netdev=netdev0' % random_mac(),
343
            '-vnc', ':%d' % display, '-serial', 'file:%s' % monitor, _bg=True)
344

    
345
        return vm, display
346

    
347
    def _destroy_vm(self, vm):
348
        """Destroy a VM previously created by _create_vm"""
349
        if vm.process.alive:
350
            vm.terminate()
351

    
352
    def _shutdown(self):
353
        """Shuts down the windows VM"""
354
        self._guest_exec(r'shutdown /s /t 5')
355

    
356
    def _wait_on_file(self, fname, msg):
357
        """Wait until a message appears on a file"""
358

    
359
        for i in range(BOOT_TIMEOUT):
360
            time.sleep(1)
361
            with open(fname) as f:
362
                for line in f:
363
                    if line.startswith(msg):
364
                        return True
365
        return False
366

    
367
    def _disable_autologon(self):
368
        """Disable automatic logon on the windows image"""
369

    
370
        winlogon = \
371
            r'"HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon"'
372

    
373
        self._guest_exec('REG DELETE %s /v DefaultUserName /f' % winlogon)
374
        self._guest_exec('REG DELETE %s /v DefaultPassword /f' % winlogon)
375
        self._guest_exec('REG DELETE %s /v AutoAdminLogon /f' % winlogon)
376

    
377
    def _registry_file_path(self, regfile):
378
        """Retrieves the case sensitive path to a registry file"""
379

    
380
        systemroot = self.g.inspect_get_windows_systemroot(self.root)
381
        path = "%s/system32/config/%s" % (systemroot, regfile)
382
        try:
383
            path = self.g.case_sensitive_path(path)
384
        except RuntimeError as e:
385
            raise FatalError("Unable to retrieve registry file: %s. Reason: %s"
386
                             % (regfile, str(e)))
387
        return path
388

    
389
    def _enable_os_monitor(self):
390
        """Add a script in the registry that will send a random string to the
391
        first serial port when the windows image finishes booting.
392
        """
393

    
394
        token = "".join(random.choice(string.ascii_letters) for x in range(16))
395

    
396
        path = self._registry_file_path('SOFTWARE')
397
        softwarefd, software = tempfile.mkstemp()
398
        try:
399
            os.close(softwarefd)
400
            self.g.download(path, software)
401

    
402
            h = hivex.Hivex(software, write=True)
403

    
404
            # Enable automatic logon.
405
            # This is needed because we need to execute a script that we add in
406
            # the RunOnce registry entry and those programs only get executed
407
            # when a user logs on. There is a RunServicesOnce registry entry
408
            # whose keys get executed in the background when the logon dialog
409
            # box first appears, but they seem to only work with services and
410
            # not arbitrary command line expressions :-(
411
            #
412
            # Instructions on how to turn on automatic logon in Windows can be
413
            # found here: http://support.microsoft.com/kb/324737
414
            #
415
            # Warning: Registry change will not work if the “Logon Banner” is
416
            # defined on the server either by a Group Policy object (GPO) or by
417
            # a local policy.
418

    
419
            winlogon = h.root()
420
            for child in ('Microsoft', 'Windows NT', 'CurrentVersion',
421
                          'Winlogon'):
422
                winlogon = h.node_get_child(winlogon, child)
423

    
424
            h.node_set_value(
425
                winlogon,
426
                {'key': 'DefaultUserName', 't': 1,
427
                 'value': "Administrator".encode('utf-16le')})
428
            h.node_set_value(
429
                winlogon,
430
                {'key': 'DefaultPassword', 't': 1,
431
                 'value':  self.sysprep_params['password'].encode('utf-16le')})
432
            h.node_set_value(
433
                winlogon,
434
                {'key': 'AutoAdminLogon', 't': 1,
435
                 'value': "1".encode('utf-16le')})
436

    
437
            key = h.root()
438
            for child in ('Microsoft', 'Windows', 'CurrentVersion'):
439
                key = h.node_get_child(key, child)
440

    
441
            runonce = h.node_get_child(key, "RunOnce")
442
            if runonce is None:
443
                runonce = h.node_add_child(key, "RunOnce")
444

    
445
            value = (
446
                r'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe '
447
                r'-ExecutionPolicy RemoteSigned '
448
                r'"&{$port=new-Object System.IO.Ports.SerialPort COM1,9600,'
449
                r'None,8,one;$port.open();$port.WriteLine(\"' + token + r'\");'
450
                r'$port.Close()}"').encode('utf-16le')
451

    
452
            h.node_set_value(runonce,
453
                             {'key': "BootMonitor", 't': 1, 'value': value})
454

    
455
            value = (
456
                r'REG ADD HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion'
457
                r'\policies\system /v LocalAccountTokenFilterPolicy'
458
                r' /t REG_DWORD /d 1 /f').encode('utf-16le')
459

    
460
            h.node_set_value(runonce,
461
                             {'key': "UpdateRegistry", 't': 1, 'value': value})
462

    
463
            h.commit(None)
464

    
465
            self.g.upload(software, path)
466
        finally:
467
            os.unlink(software)
468

    
469
        return token
470

    
471
    def _update_firewalls(self, domain, public, standard):
472
        """Enables or disables the firewall for the Domain, the Public and the
473
        Standard profile. Returns a triplete with the old values.
474

475
        1 will enable a firewall and 0 will disable it
476
        """
477

    
478
        if domain not in (0, 1):
479
            raise ValueError("Valid values for domain parameter are 0 and 1")
480

    
481
        if public not in (0, 1):
482
            raise ValueError("Valid values for public parameter are 0 and 1")
483

    
484
        if standard not in (0, 1):
485
            raise ValueError("Valid values for standard parameter are 0 and 1")
486

    
487
        path = self._registry_file_path("SYSTEM")
488
        systemfd, system = tempfile.mkstemp()
489
        try:
490
            os.close(systemfd)
491
            self.g.download(path, system)
492

    
493
            h = hivex.Hivex(system, write=True)
494

    
495
            select = h.node_get_child(h.root(), 'Select')
496
            current_value = h.node_get_value(select, 'Current')
497

    
498
            # expecting a little endian dword
499
            assert h.value_type(current_value)[1] == 4
500
            current = "%03d" % h.value_dword(current_value)
501

    
502
            firewall_policy = h.root()
503
            for child in ('ControlSet%s' % current, 'services', 'SharedAccess',
504
                          'Parameters', 'FirewallPolicy'):
505
                firewall_policy = h.node_get_child(firewall_policy, child)
506

    
507
            old_values = []
508
            new_values = [domain, public, standard]
509
            for profile in ('Domain', 'Public', 'Standard'):
510
                node = h.node_get_child(firewall_policy, '%sProfile' % profile)
511

    
512
                old_value = h.node_get_value(node, 'EnableFirewall')
513

    
514
                # expecting a little endian dword
515
                assert h.value_type(old_value)[1] == 4
516
                old_values.append(h.value_dword(old_value))
517

    
518
                h.node_set_value(
519
                    node, {'key': 'EnableFirewall', 't': 4L,
520
                           'value': struct.pack("<I", new_values.pop(0))})
521

    
522
            h.commit(None)
523
            self.g.upload(system, path)
524

    
525
        finally:
526
            os.unlink(system)
527

    
528
        return old_values
529

    
530
    def _update_uac_remote_setting(self, value):
531
        """Updates the registry key value:
532
        [HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies
533
        \System]"LocalAccountTokenFilterPolicy"
534

535
        value = 1 will disable the UAC remote restrictions
536
        value = 0 will enable the UAC remote restrictions
537

538
        For more info see here: http://support.microsoft.com/kb/951016
539

540
        Returns:
541
            True if the key is changed
542
            False if the key is unchanged
543
        """
544

    
545
        if value not in (0, 1):
546
            raise ValueError("Valid values for value parameter are 0 and 1")
547

    
548
        path = self._registry_file_path('SOFTWARE')
549
        softwarefd, software = tempfile.mkstemp()
550
        try:
551
            os.close(softwarefd)
552
            self.g.download(path, software)
553

    
554
            h = hivex.Hivex(software, write=True)
555

    
556
            key = h.root()
557
            for child in ('Microsoft', 'Windows', 'CurrentVersion', 'Policies',
558
                          'System'):
559
                key = h.node_get_child(key, child)
560

    
561
            policy = None
562
            for val in h.node_values(key):
563
                if h.value_key(val) == "LocalAccountTokenFilterPolicy":
564
                    policy = val
565

    
566
            if policy is not None:
567
                dword = h.value_dword(policy)
568
                if dword == value:
569
                    return False
570
            elif value == 0:
571
                return False
572

    
573
            new_value = {'key': "LocalAccountTokenFilterPolicy", 't': 4L,
574
                         'value': struct.pack("<I", value)}
575

    
576
            h.node_set_value(key, new_value)
577
            h.commit(None)
578

    
579
            self.g.upload(software, path)
580

    
581
        finally:
582
            os.unlink(software)
583

    
584
        return True
585

    
586
    def _do_collect_metadata(self):
587
        """Collect metadata about the OS"""
588
        super(Windows, self)._do_collect_metadata()
589
        self.meta["USERS"] = " ".join(self._get_users())
590

    
591
    def _get_users(self):
592
        """Returns a list of users found in the images"""
593
        path = self._registry_file_path('SAM')
594
        samfd, sam = tempfile.mkstemp()
595
        try:
596
            os.close(samfd)
597
            self.g.download(path, sam)
598

    
599
            h = hivex.Hivex(sam)
600

    
601
            key = h.root()
602
            # Navigate to /SAM/Domains/Account/Users/Names
603
            for child in ('SAM', 'Domains', 'Account', 'Users', 'Names'):
604
                key = h.node_get_child(key, child)
605

    
606
            users = [h.node_name(x) for x in h.node_children(key)]
607

    
608
        finally:
609
            os.unlink(sam)
610

    
611
        # Filter out the guest account
612
        return filter(lambda x: x != "Guest", users)
613

    
614
    def _guest_exec(self, command, fatal=True):
615
        """Execute a command on a windows VM"""
616

    
617
        user = "Administrator%" + self.sysprep_params['password']
618
        addr = 'localhost'
619
        runas = '--runas=%s' % user
620
        winexe = subprocess.Popen(
621
            ['winexe', '-U', user, runas, "//%s" % addr, command],
622
            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
623

    
624
        stdout, stderr = winexe.communicate()
625
        rc = winexe.poll()
626

    
627
        if rc != 0 and fatal:
628
            reason = stderr if len(stderr) else stdout
629
            self.out.output("Command: `%s' failed. Reason: %s" %
630
                            (command, reason))
631
            raise FatalError("Command: `%s' failed. Reason: %s" %
632
                             (command, reason))
633

    
634
        return (stdout, stderr, rc)
635

    
636
# vim: set sta sts=4 shiftwidth=4 sw=4 et ai :