tool.finish()   A
last analyzed

Complexity

Conditions 3

Size

Total Lines 12
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 7
nop 1
dl 0
loc 12
rs 10
c 0
b 0
f 0
1
#!/usr/bin/env python
2
# -*- coding: UTF-8 -*-
3
4
# Isomer - The distributed application framework
5
# ==============================================
6
# Copyright (C) 2011-2020 Heiko 'riot' Weinen <[email protected]> and others.
7
#
8
# This program is free software: you can redistribute it and/or modify
9
# it under the terms of the GNU Affero General Public License as published by
10
# the Free Software Foundation, either version 3 of the License, or
11
# (at your option) any later version.
12
#
13
# This program is distributed in the hope that it will be useful,
14
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
# GNU Affero General Public License for more details.
17
#
18
# You should have received a copy of the GNU Affero General Public License
19
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21
22
"""
23
24
Package: Tool
25
=============
26
27
Contains basic functionality for the isomer management tool.
28
29
30
Command groups
31
--------------
32
33
backup
34
configuration
35
create_module
36
database
37
defaults
38
dev
39
environment
40
etc
41
installer
42
instance
43
misc
44
objects
45
rbac
46
remote
47
system
48
user
49
50
General binding glue
51
--------------------
52
53
cli
54
templates
55
tool
56
57
58
"""
59
60
import getpass
61
import os
62
import signal
63
import time
64
65
import bcrypt
66
import distro
67
import spur
68
69
from typing import Tuple, Union
70
from hmac import compare_digest
71
from isomer.error import (
72
    abort,
73
    EXIT_ISOMER_URL_REQUIRED,
74
    EXIT_INVALID_CONFIGURATION,
75
    EXIT_INSTANCE_UNKNOWN,
76
    EXIT_ROOT_REQUIRED
77
)
78
from isomer.logger import isolog, verbose, debug, error, warn
79
from isomer.tool.defaults import platforms
80
from tomlkit.exceptions import NonExistentKey
81
82
83
def log(*args, **kwargs):
84
    """Log as Emitter:MANAGE"""
85
86
    kwargs.update({"emitter": "MANAGE", "frame_ref": 2})
87
    isolog(*args, **kwargs)
88
89
90
def finish(ctx):
91
    """
92
    Signalize the successful conclusion of an operation.
93
    """
94
    parent = ctx.parent
95
    commands = ctx.info_name
96
97
    while parent is not None and parent.info_name is not None:
98
        commands = parent.info_name + " " + commands
99
        parent = parent.parent
100
101
    log("Done:", commands)
102
103
104
def check_root():
105
    """Check if current user has root permissions"""
106
107
    if os.geteuid() != 0:
108
        log(
109
            "If you installed into a virtual environment, don't forget to "
110
            "specify the interpreter binary for sudo, e.g:\n"
111
            "$ sudo /home/user/.virtualenv/isomer/bin/python3 iso"
112
        )
113
        abort(EXIT_ROOT_REQUIRED)
114
115
116
def run_process(cwd: str, args: list, shell=None, sudo: Union[bool, str] = None,
117
                show: bool = False, stdout: str = None, stdin: str = None,
118
                timeout: int = 5) -> Tuple[bool, any]:
119
    """
120
    Executes an external process via subprocess.check_output
121
    :param cwd: Working directory
122
    :param args: List of command plus its arguments
123
    :param shell: Either a spur.LocalShell or a spur.SshShell
124
    :param sudo: Username (or True for root) to use with sudo, False for no sudo
125
    :param show: Log executed command at info priority before executing
126
    :param stdout: String to fill with std_out data
127
    :param stdin: String to supply as std_in data
128
    :param timeout: Timeout for the process in seconds
129
    :return: A boolean success flag and the whole output
130
    :rtype:
131
132
    """
133
134
    log("Running:", cwd, args, lvl=verbose)
135
136
    # if shell is None and sudo is None:
137
    #     check_root()
138
139
    def build_command(*things):
140
        """Construct a command adding sudo if necessary"""
141
142
        if sudo not in (None, False, "False"):
143
            if isinstance(sudo, bool) and sudo is True or sudo == "True":
144
                user = "root"
145
            elif isinstance(sudo, str):
146
                user = sudo
147
            else:
148
                log("Malformed run_process call:", things, lvl=error)
149
                return
150
151
            log("Using sudo with user:", user, lvl=verbose)
152
            cmd = ["sudo", "-H", "-u", user] + list(things)
153
        else:
154
            log("Not using sudo", lvl=verbose)
155
            cmd = []
156
            for thing in things:
157
                cmd += [thing]
158
159
        return cmd
160
161
    if shell is None:
162
        log("Running on local shell", lvl=verbose)
163
        shell = spur.LocalShell()
164
    else:
165
        log("Running on remote shell:", shell, lvl=debug)
166
167
    command = build_command(*args)
168
    log(command, lvl=verbose)
169
170
    try:
171
        if show:
172
            log("Executing:", command)
173
174
        if stdin is not None:
175
            process = shell.spawn(command, cwd=cwd, store_pid=True, stdout=stdout)
176
            process.stdin_write(stdin)
177
178
            try:
179
                process._process_stdin.close()  # Local
180
            except AttributeError:
181
                process._stdin.close()  # SSH
182
183
            begin = time.time()
184
            waiting = 0.0
185
            while waiting < timeout and process.is_running():
186
                waiting = time.time() - begin
187
            if waiting >= timeout:
188
                log("Sending SIGHUP", lvl=warn)
189
                process.send_signal(signal.SIGHUP)
190
191
                time.sleep(0.5)
192
                if process.is_running():
193
                    log("Sending SIGKILL", lvl=error)
194
                    process.send_signal(signal.SIGKILL)
195
196
            process = process.wait_for_result()
197
        else:
198
            process = shell.run(command, cwd=cwd, stdout=stdout)
199
200
        decoded = str(process.output, encoding="utf-8")
201
        log(decoded.replace("\\n", "\n"), lvl=verbose)
202
203
        return True, process
204
    except spur.RunProcessError as e:
205
        log(
206
            "Uh oh, the teapot broke again! Error:",
207
            e,
208
            type(e),
209
            lvl=verbose,
210
            pretty=True,
211
        )
212
        log(command, e.args, e.return_code, e.output, lvl=verbose)
213
        if e.stderr_output not in ("", None, False):
214
            log("Error output:", e.stderr_output, lvl=error)
215
        return False, e
216
    except spur.NoSuchCommandError as e:
217
        log("Command was not found:", e, type(e), lvl=verbose, pretty=True)
218
        log(args)
219
        return False, e
220
221
222
def ask_password():
223
    """Securely and interactively ask for a password"""
224
225
    # noinspection HardcodedPassword
226
    password = "Foo"
227
    password_trial = ""
228
229
    while not compare_digest(password, password_trial):
230
        password = getpass.getpass()
231
        password_trial = getpass.getpass(prompt="Repeat:")
232
        if not compare_digest(password, password_trial):
233
            print("\nPasswords do not match!")
234
235
    return password
236
237
238
def _get_credentials(username=None, password=None, dbhost=None):
239
    """Obtain user credentials by arguments or asking the user"""
240
241
    # Database salt
242
    system_config = dbhost.objectmodels["systemconfig"].find_one({"active": True})
243
244
    try:
245
        salt = system_config.salt.encode("ascii")
246
    except (KeyError, AttributeError):
247
        log(
248
            "No systemconfig or it is without a salt! "
249
            "Reinstall the system provisioning with"
250
            "iso install provisions -p system"
251
        )
252
        abort(3)
253
        return
254
255
    if username is None:
256
        username = ask("Please enter username: ")
257
258
    if password is None:
259
        password = ask_password()
260
261
    try:
262
        password = password.encode("utf-8")
263
    except UnicodeDecodeError:
264
        password = password
265
266
    password_hash = bcrypt.hashpw(password, salt).decode('ascii')
267
268
    return username, password_hash
269
270
271
def _get_system_configuration(dbhost, dbname):
272
    from isomer import database
273
274
    database.initialize(dbhost, dbname)
275
    systemconfig = database.objectmodels["systemconfig"].find_one({"active": True})
276
277
    return systemconfig
278
279
280
def ask(question, default=None, data_type="str", show_hint=False):
281
    """Interactively ask the user for data"""
282
283
    data = default
284
285
    if data_type == "bool":
286
        data = None
287
        default_string = "Y" if default else "N"
288
289
        while data not in ("Y", "J", "N", "1", "0"):
290
            data = input("%s? [%s]: " % (question, default_string)).upper()
291
292
            if data == "":
293
                return default
294
295
        return data in ("Y", "J", "1")
296
    elif data_type in ("str", "unicode"):
297
        if show_hint:
298
            msg = "%s? [%s] (%s): " % (question, default, data_type)
299
        else:
300
            msg = question
301
302
        data = input(msg)
303
304
        if len(data) == 0:
305
            data = default
306
    elif data_type == "int":
307
        if show_hint:
308
            msg = "%s? [%s] (%s): " % (question, default, data_type)
309
        else:
310
            msg = question
311
312
        data = input(msg)
313
314
        if len(data) == 0:
315
            data = int(default)
316
        else:
317
            data = int(data)
318
    else:
319
        print("Programming error! Datatype invalid!")
320
321
    return data
322
323
324
def format_result(result):
325
    """Format child instance output"""
326
    return str(result.output, encoding="ascii").replace("\\n", "\n")
327
328
329
def get_isomer(source, url, destination, upgrade=False, release=None,
330
               shell=None, sudo=None):
331
    """Grab a copy of Isomer somehow"""
332
    success = False
333
    log("Beginning get_isomer:",
334
        source, url, destination, upgrade, release, shell, sudo, lvl=debug)
335
336
    if url in ("", None) and source == "git" and not upgrade:
337
        abort(EXIT_ISOMER_URL_REQUIRED)
338
339
    if source in ("git", "github"):
340
        if not upgrade or not os.path.exists(os.path.join(destination, "repository")):
341
            log("Cloning repository from", url)
342
            success, result = run_process(
343
                destination, ["git", "clone", url, "repository"], shell, sudo
344
            )
345
            if not success:
346
                log(result, lvl=error)
347
                abort(50000)
348
349
        if upgrade:
350
            log("Updating repository from", url)
351
352
            if release is not None:
353
                log("Checking out release:", release)
354
                success, result = run_process(
355
                    os.path.join(destination, "repository"),
356
                    ["git", "checkout", "tags/" + release],
357
                    shell,
358
                    sudo,
359
                )
360
                if not success:
361
                    log(result, lvl=error)
362
                    abort(50000)
363
            else:
364
                log("Pulling latest")
365
                success, result = run_process(
366
                    os.path.join(destination, "repository"),
367
                    ["git", "pull", "origin", "master"],
368
                    shell,
369
                    sudo,
370
                )
371
                if not success:
372
                    log(result, lvl=error)
373
                    abort(50000)
374
375
        repository = os.path.join(destination, "repository")
376
        log("Initializing submodules")
377
        success, result = run_process(
378
            repository, ["git", "submodule", "init"], shell, sudo
379
        )
380
        if not success:
381
            log(result, lvl=error)
382
            abort(50000)
383
384
        #log("Pulling frontend")
385
        #success, result = run_process(
386
        #    os.path.join(repository, "frontend"),
387
        #    ["git", "pull", "origin", "master"],
388
        #    shell,
389
        #    sudo,
390
        #)
391
        #if not success:
392
        #    log(result, lvl=error)
393
        #    abort(50000)
394
395
        log("Updating frontend")
396
        success, result = run_process(
397
            repository, ["git", "submodule", "update"], shell, sudo
398
        )
399
        if not success:
400
            log(result, lvl=error)
401
            abort(50000)
402
    elif source == "link":
403
        if shell is not None:
404
            log(
405
                "Remote Linking? Are you sure? Links will be local, "
406
                "they cannot span over any network.",
407
                lvl=warn,
408
            )
409
410
        path = os.path.abspath(url)
411
412
        if not os.path.exists(os.path.join(destination, "repository")):
413
            log("Linking repository from", path)
414
            success, result = run_process(
415
                destination, ["ln", "-s", path, "repository"], shell, sudo
416
            )
417
            if not success:
418
                log(result, lvl=error)
419
                abort(50000)
420
        else:
421
            log("Repository already exists!", lvl=warn)
422
423
        if not os.path.exists(
424
            os.path.join(destination, "repository", "frontend", "src")
425
        ):
426
            log("Linking frontend")
427
            success, result = run_process(
428
                destination,
429
                ["ln", "-s", os.path.join(path, "frontend"), "repository/frontend"],
430
                shell,
431
                sudo,
432
            )
433
            if not success:
434
                log(result, lvl=error)
435
                abort(50000)
436
        else:
437
            log("Frontend already present")
438
    elif source == "copy":
439
        log("Copying local repository")
440
441
        path = os.path.realpath(os.path.expanduser(url))
442
        target = os.path.join(destination, "repository")
443
444
        if shell is None:
445
            shell = spur.LocalShell()
446
        else:
447
            log("Copying to remote")
448
449
        log("Copying %s to %s" % (path, target), lvl=verbose)
450
451
        shell.upload_dir(path, target, [".tox*", "node_modules*"])
452
453
        if sudo is not None:
454
            success, result = run_process("/", ["chown", sudo, "-R", target])
455
            if not success:
456
                log("Could not change ownership to", sudo, lvl=warn)
457
                abort(50000)
458
        return True
459
    else:
460
        log("Invalid source selected. "
461
            "Currently, only git, github, copy, link are supported ")
462
463
    return success
464
465
466
def install_isomer(
467
    platform_name=None,
468
    use_sudo=False,
469
    shell=None,
470
    cwd=".",
471
    show=False,
472
    omit_common=False,
473
    omit_platform=False,
474
):
475
    """Installs all dependencies"""
476
477
    if platform_name is None:
478
        platform_name = distro.linux_distribution()[0]
479
        log("Platform detected as %s" % platform_name)
480
481
    if platform_name not in platforms and not omit_platform:
482
        log(
483
            "Your platform is not yet officially supported!\n\n"
484
            "Please check the documentation for more information:\n"
485
            "https://isomer.readthedocs.io/en/latest/start/platforms/support.html",
486
            lvl=error,
487
        )
488
        abort(50000)
489
490
    if isinstance(platforms[platform_name], str):
491
        platform_name = platforms[platform_name]
492
        log("This platform is a link to another:", platform_name, lvl=verbose)
493
494
    def handle_command(command):
495
        if command.get("action", None) == "create_file":
496
            with open(command["filename"], "w") as f:
497
                f.write(command["content"])
498
499
    def platform():
500
        """In a platform specific way, install all dependencies"""
501
502
        if platform_name not in platforms:
503
            log("Unknown platform specified, proceeding anyway", lvl=warn)
504
            return
505
506
        dependency_handling = platforms[platform_name].get('dependencies', None)
507
        pre_install_commands = platforms[platform_name].get("pre_install", [])
508
        post_install_commands = platforms[platform_name].get("post_install", [])
509
510
        for command in pre_install_commands:
511
            log("Running pre install command", " ".join(command))
512
            if isinstance(command, dict):
513
                handle_command(command)
514
            else:
515
516
                success, output = run_process(cwd, command, shell, sudo=use_sudo)
517
                if not success:
518
                    log("Could not run command %s!" % command, lvl=error)
519
                    log(output, pretty=True)
520
521
        if dependency_handling is not None:
522
            log("Installing platform dependencies")
523
            tool = platforms[platform_name]["tool"]
524
            packages = platforms[platform_name]["packages"]
525
526
            success, output = run_process(cwd, tool + packages, shell, sudo=use_sudo)
527
            if not success:
528
                log("Could not install %s dependencies!" % platform_name, lvl=error)
529
                log(output, pretty=True)
530
531
        for command in post_install_commands:
532
            log("Running post install command", " ".join(command))
533
            success, output = run_process(cwd, command, shell, sudo=use_sudo)
534
            if not success:
535
                log("Could not run command %s!" % command, lvl=error)
536
                log(output, pretty=True)
537
538
    def common():
539
        """Perform platform independent setup"""
540
541
        log("Installing Isomer")
542
        success, output = run_process(
543
            cwd, ["python3", "setup.py", "develop"], shell, sudo=use_sudo
544
        )
545
        if not success:
546
            log("Could not install Isomer package!", lvl=error)
547
            log(output, pretty=True)
548
549
        log("Installing Isomer requirements")
550
551
        success, output = run_process(
552
            cwd, ["pip3", "install", "-r", "requirements.txt"], shell, sudo=use_sudo
553
        )
554
        if not success:
555
            log("Could not install Python dependencies!", lvl=error)
556
            log(output, pretty=True)
557
558
    if not omit_platform:
559
        platform()
560
561
    if not omit_common:
562
        common()
563
564
565
def _get_configuration(ctx):
566
    try:
567
        log("Configuration:", ctx.obj["config"], lvl=verbose, pretty=True)
568
        log("Instance:", ctx.obj["instance"], lvl=debug)
569
    except KeyError:
570
        log("Invalid configuration, stopping.", lvl=error)
571
        abort(EXIT_INVALID_CONFIGURATION)
572
573
    try:
574
        instance_configuration = ctx.obj["instances"][ctx.obj["instance"]]
575
        log("Instance Configuration:", instance_configuration, lvl=verbose, pretty=True)
576
    except NonExistentKey:
577
        log("Instance %s does not exist" % ctx.obj["instance"], lvl=warn)
578
        abort(EXIT_INSTANCE_UNKNOWN)
579
        return
580
581
    environment_name = instance_configuration["environment"]
582
    environment_config = instance_configuration["environments"][environment_name]
583
584
    ctx.obj["environment"] = environment_config
585
586
    ctx.obj["instance_configuration"] = instance_configuration
587
588
589
def get_next_environment(ctx):
590
    """Return the next environment"""
591
592
    if ctx.obj["acting_environment"] is not None:
593
        next_environment = ctx.obj["acting_environment"]
594
    else:
595
        current_environment = ctx.obj["instance_configuration"]["environment"]
596
        next_environment = "blue" if current_environment == "green" else "green"
597
598
    log("Acting on environment:", next_environment, lvl=debug)
599
600
    return next_environment
601