tool.remote   F
last analyzed

Complexity

Total Complexity 60

Size/Duplication

Total Lines 586
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 386
dl 0
loc 586
rs 3.6
c 0
b 0
f 0
wmc 60

12 Functions

Rating   Name   Duplication   Size   Complexity  
A test() 0 12 1
C add() 0 97 8
C upload_key() 0 80 11
A upgrade() 0 9 1
A get_remote_home() 0 7 2
C remote() 0 78 9
B backup() 0 54 6
A command() 0 23 2
B set_parameter() 0 49 6
A info() 0 9 2
A list_remotes() 0 6 1
C install_remote() 0 70 11

How to fix   Complexity   

Complexity

Complex classes like tool.remote often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

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
Module: remote
24
==============
25
26
Remote instance management functionality.
27
28
This module allows deploying and maintaining instances on remote systems via SSH.
29
30
"""
31
32
import os
33
import spur
34
import tomlkit
35
import click
36
import getpass
37
38
from typing import Optional
39
from binascii import hexlify
40
from click_didyoumean import DYMGroup
41
42
from isomer.logger import warn, error, debug, verbose
43
from isomer.misc.std import std_now
44
from isomer.tool import (
45
    log,
46
    run_process,
47
    install_isomer,
48
    get_isomer,
49
    format_result,
50
)  # , ask_password
51
from isomer.misc.path import get_etc_remote_keys_path
52
from isomer.tool.defaults import platforms, key_defaults
53
from isomer.error import abort, EXIT_INVALID_PARAMETER, EXIT_INVALID_VALUE
54
from isomer.tool.cli import cli
55
from isomer.tool.etc import load_remotes, remote_template, write_remote
56
57
key_dispatch_table: Optional[dict]
58
59
try:
60
    # noinspection PyPackageRequirements
61
    from paramiko import DSSKey, RSAKey
62
63
    key_dispatch_table = {"dsa": DSSKey, "rsa": RSAKey}
64
except ImportError:
65
    key_dispatch_table = DSSKey = RSAKey = None
66
    log("Could not load paramiko. Remote operations disabled.", lvl=warn)
67
68
69
def get_remote_home(username):
70
    """Expands a username into a correct home directory"""
71
72
    if username == "root":
73
        return "/root/"
74
    else:
75
        return "/home/" + username
76
77
78
@cli.group(
79
    cls=DYMGroup,
80
    short_help="Remote Isomer Management"
81
)
82
@click.option("--name", "-n", default="default")
83
@click.option("--install", "-i", is_flag=True, default=False)
84
@click.option(
85
    "--platform",
86
    "-p",
87
    default=list(platforms.keys())[0],
88
    type=click.Choice(platforms.keys()),
89
)
90
@click.option(
91
    "--source", "-s", default="git", type=click.Choice(["link", "copy", "git"])
92
)
93
@click.option("--url", "-u", default=None)
94
@click.option("--existing", "-e", default=None)
95
@click.pass_context
96
def remote(ctx, name, install, platform, source, url, existing):
97
    """Remote instance control (Work in Progress!)"""
98
99
    ctx.obj["remote"] = name
100
    ctx.obj["platform"] = platform
101
    ctx.obj["source"] = source
102
    ctx.obj["url"] = url
103
    ctx.obj["existing"] = existing
104
105
    if ctx.invoked_subcommand == "add":
106
        return
107
108
    remotes = ctx.obj["remotes"] = load_remotes()
109
110
    if ctx.invoked_subcommand == "list":
111
        return
112
113
    # log('Remote configurations:', remotes, pretty=True)
114
115
    host_config = remotes.get(name, None)
116
117
    if host_config is None:
118
        log("Cannot proceed, remote unknown", lvl=error)
119
        abort(5000)
120
121
    ctx.obj["host_config"] = host_config
122
123
    if platform is None:
124
        platform = ctx.obj["host_config"].get("platform", "debian")
125
    ctx.obj["platform"] = platform
126
127
    spur_config = dict(host_config["login"])
128
129
    if spur_config["private_key_file"] == "":
130
        spur_config.pop("private_key_file")
131
132
    if spur_config["port"] != 22:
133
        log(
134
            "Warning! Using any port other than 22 is not supported right now.",
135
            lvl=warn,
136
        )
137
138
    spur_config.pop("port")
139
140
    shell = spur.SshShell(**spur_config)
141
142
    if install:
143
        success, result = run_process("/", ["iso", "info"], shell)
144
        if success:
145
            log("Isomer version on remote:", format_result(result))
146
        else:
147
            log('Use "remote install" for now')
148
            # if existing is None:
149
            #     get_isomer(source, url, '/root', shell=shell)
150
            #     destination = '/' + host_config['login']['username'] + '/repository'
151
            # else:
152
            #     destination = existing
153
            # install_isomer(platform, host_config.get('use_sudo', True), shell, cwd=destination)
154
155
    ctx.obj["shell"] = shell
156
157
158
@remote.command()
159
@click.argument("hostname")
160
@click.option(
161
    "--username",
162
    "-u",
163
    prompt=True,
164
    default=lambda: os.environ.get("USER", ""),
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable os does not seem to be defined.
Loading history...
165
    show_default="current user",
166
)
167
@click.option("--password", "-pw", default="")
168
@click.option("--port", "-p", default=22)
169
@click.option("--use-sudo", "-s", default=False, is_flag=True)
170
@click.option("--make-key", "-m", default=False, is_flag=True)
171
@click.option("--key-file", "-k", default="")
172
@click.option(
173
    "--key-type",
174
    "-t",
175
    default=key_defaults["type"],
176
    help="Key type (%s)" % key_defaults["type"],
177
    type=click.Choice(["dsa", "rsa"]),
178
)
179
@click.option(
180
    "--key-bits",
181
    "-b",
182
    type=int,
183
    default=int(key_defaults["bits"]),
184
    help="Key bits (%i)" % int(key_defaults["bits"]),
185
)
186
@click.pass_context
187
def add(
188
    ctx,
189
    hostname,
190
    username,
191
    password,
192
    port,
193
    use_sudo,
194
    make_key,
195
    key_file,
196
    key_type,
197
    key_bits,
198
):
199
    """Adds a new remote"""
200
201
    if make_key:
202
        if key_dispatch_table is None:
203
            log(
204
                "You'll need to install paramiko to generate remote keys. Use pip3 install paramiko",
205
                lvl=error,
206
            )
207
            abort(5000)
208
209
        if key_file == "":
210
            key_file = os.path.join(get_etc_remote_keys_path(), ctx.obj["remote"])
211
212
        phrase = None
213
214
        if key_type == "dsa" and key_bits > 1024:
215
            log("DSA Keys must be 1024 bits", lvl=error)
216
            abort(5000)
217
218
        # generating private key
219
        prv = key_dispatch_table[key_type].generate(bits=key_bits, progress_func=log)
220
        prv.write_private_key_file(key_file, password=phrase)
221
        # generating public key
222
        pub = key_dispatch_table[key_type](filename=key_file, password=phrase)
223
        with open("%s.pub" % key_file, "w") as f:
224
            f.write("%s %s" % (pub.get_name(), pub.get_base64()))
225
            f.write(" %s" % key_defaults["comment"])
226
227
        key_hash = hexlify(pub.get_fingerprint())
228
        log(
229
            "Fingerprint: %d %s %s.pub (%s)"
230
            % (
231
                key_bits,
232
                ":".join(
233
                    [str(key_hash)[i: 2 + i] for i in range(0, len(key_hash), 2)]
234
                ),
235
                key_file,
236
                key_type.upper(),
237
            )
238
        )
239
240
    new_remote = remote_template
241
    new_remote["name"] = ctx.obj["remote"]
242
    new_remote["platform"] = ctx.obj["platform"]
243
    new_remote["use_sudo"] = use_sudo
244
    new_remote["source"] = ctx.obj["source"]
245
    new_remote["url"] = ctx.obj["url"]
246
247
    new_remote["login"]["hostname"] = hostname
248
    new_remote["login"]["username"] = username
249
    new_remote["login"]["password"] = password
250
    new_remote["login"]["port"] = port
251
    new_remote["login"]["private_key_file"] = key_file
252
253
    log("New remote:", new_remote, pretty=True)
254
    write_remote(new_remote)
255
256
257
@remote.command()
258
@click.option(
259
    "--accept",
260
    "-a",
261
    help="Accept missing host key and add it to known_hosts",
262
    is_flag=True,
263
    default=False,
264
)
265
@click.pass_context
266
def upload_key(ctx, accept):
267
    """Upload a remote key to a user account on a remote machine"""
268
269
    login_config = dict(ctx.obj["host_config"]["login"])
270
271
    if login_config["password"] == "":
272
        login_config["password"] = getpass.getpass()
273
274
    with open(login_config["private_key_file"] + ".pub") as f:
275
        key = f.read()
276
277
    username = login_config["username"]
278
279
    if accept:
280
        host_key_flag = spur.ssh.MissingHostKey.warn
281
    else:
282
        host_key_flag = spur.ssh.MissingHostKey.raise_error
283
284
    shell = spur.SshShell(
285
        hostname=login_config["hostname"],
286
        username=login_config["username"],
287
        password=login_config["password"],
288
        missing_host_key=host_key_flag,
289
    )
290
291
    try:
292
        with shell.open("/home/" + username + "/.ssh/authorized_keys", "r") as f:
293
            result = f.read()
294
    except spur.ssh.ConnectionError as e:
295
        log("SSH Connection error:\n", e, lvl=error)
296
        log(
297
            "Host not in known hosts or other problem. Use --accept to add to known_hosts."
298
        )
299
        abort(50071)
300
    except FileNotFoundError as e:
301
        log("No authorized key file yet, creating")
302
        success, result = run_process(
303
            "/home/" + username,
304
            ["/bin/mkdir", "/home/" + username + "/.ssh"],
305
            shell=shell,
306
        )
307
        if not success:
308
            log(
309
                "Error creating .ssh directory:",
310
                e,
311
                format_result(result),
312
                pretty=True,
313
                lvl=error,
314
            )
315
        success, result = run_process(
316
            "/home/" + login_config["username"],
317
            ["/usr/bin/touch", "/home/" + username + "/.ssh/authorized_keys"],
318
            shell=shell,
319
        )
320
        if not success:
321
            log(
322
                "Error creating authorized hosts file:",
323
                e,
324
                format_result(result).output,
325
                lvl=error,
326
            )
327
        result = ""
328
329
    if key not in result:
0 ignored issues
show
introduced by
The variable result does not seem to be defined for all execution paths.
Loading history...
330
        log("Key not yet authorized - adding")
331
        with shell.open("/home/" + username + "/.ssh/authorized_keys", "a") as f:
332
            f.write(key)
333
    else:
334
        log("Key is already authorized.", lvl=warn)
335
336
    log("Uploaded key")
337
338
339
@remote.command(name="set", short_help="Set a parameter of a remote")
340
@click.option(
341
    "--login", "-l", help="Modify login settings", is_flag=True, default=False
342
)
343
@click.argument("parameter")
344
@click.argument("value")
345
@click.pass_context
346
def set_parameter(ctx, login, parameter, value):
347
    """Set a configuration parameter of an instance"""
348
349
    log("Setting %s to %s" % (parameter, value))
350
    remote_config = ctx.obj["host_config"]
351
    defaults = remote_template
352
353
    converted_value = None
354
355
    try:
356
        if login:
357
            parameter_type = type(defaults["login"][parameter])
358
        else:
359
            parameter_type = type(defaults[parameter])
360
361
        log(parameter_type, pretty=True, lvl=verbose)
362
363
        if parameter_type == tomlkit.api.Integer:
364
            converted_value = int(value)
365
        else:
366
            converted_value = value
367
    except KeyError:
368
        log(
369
            "Invalid parameter specified. Available parameters:",
370
            sorted(list(defaults.keys())),
371
            lvl=warn,
372
        )
373
        abort(EXIT_INVALID_PARAMETER)
374
375
    if converted_value is None:
376
        abort(EXIT_INVALID_VALUE)
377
378
    if login:
379
        remote_config["login"][parameter] = converted_value
380
    else:
381
        remote_config[parameter] = converted_value
382
383
    log("New config:", remote_config, pretty=True, lvl=debug)
384
385
    ctx.obj["remotes"][ctx.obj["remote"]] = remote_config
386
387
    write_remote(remote_config)
388
389
390
@remote.command(name="install")
391
@click.option(
392
    "--archive", "-a", is_flag=True, default=False, help="Archive existing Isomer first"
393
)
394
@click.option(
395
    "--setup",
396
    "-s",
397
    is_flag=True,
398
    default=False,
399
    help="Setup basic Isomer user/directories",
400
)
401
@click.pass_context
402
def install_remote(ctx, archive, setup):
403
    """Installs Isomer (Management) on a remote host"""
404
405
    shell = ctx.obj["shell"]
406
    platform = ctx.obj["platform"]
407
    host_config = ctx.obj["host_config"]
408
    use_sudo = host_config["use_sudo"]
409
    username = host_config["login"]["username"]
410
    existing = ctx.obj["existing"]
411
    remote_home = get_remote_home(username)
412
    target = os.path.join(remote_home, "isomer")
413
414
    log(remote_home)
415
416
    if shell is None:
417
        log("Remote was not configured properly.", lvl=warn)
418
        abort(5000)
419
420
    if archive:
421
        log("Renaming remote isomer copy")
422
        success, result = run_process(
423
            remote_home,
424
            ["mv", target, os.path.join(remote_home, "isomer_" + std_now())],
425
            shell=shell,
426
        )
427
        if not success:
428
            log("Could not rename remote copy:", result, pretty=True, lvl=error)
429
            abort(5000)
430
431
    if existing is None:
432
        url = ctx.obj["url"]
433
        if url is None:
434
            url = host_config.get("url", None)
435
436
        source = ctx.obj["source"]
437
        if source is None:
438
            source = host_config.get("source", None)
439
440
        if url is None or source is None:
441
            log('Need a source and url to install. Try "iso remote --help".')
442
            abort(5000)
443
444
        get_isomer(source, url, target, upgrade=ctx.obj["upgrade"], shell=shell)
445
        destination = os.path.join(remote_home, "isomer")
446
    else:
447
        destination = existing
448
449
    install_isomer(platform, use_sudo, shell=shell, cwd=destination)
450
451
    if setup:
452
        log("Setting up system user and paths")
453
        success, result = run_process(remote_home, ["iso", "system", "all"])
454
        if not success:
455
            log(
456
                "Setting up system failed:",
457
                format_result(result),
458
                pretty=True,
459
                lvl=error,
460
            )
461
462
463
@remote.command()
464
@click.pass_context
465
def upgrade(ctx):
466
    """Upgrade an existing remote"""
467
468
    ctx.obj["archive"] = True
469
    ctx.obj["setup"] = False
470
    ctx.obj["upgrade"] = True
471
    ctx.forward(install_remote)
472
473
474
@remote.command()
475
@click.pass_context
476
def info(ctx):
477
    """Shows information about the selected remote"""
478
479
    if ctx.obj["host_config"]["login"]["password"] != "":
480
        ctx.obj["host_config"]["login"]["password"] = "__OMITTED__"
481
482
    log("Remote %s:" % ctx.obj["remote"], ctx.obj["host_config"], pretty=True)
483
484
485
@remote.command(name="list")
486
@click.pass_context
487
def list_remotes(ctx):
488
    """Shows all configured remotes"""
489
490
    log("Remotes:", list(ctx.obj["remotes"].keys()), pretty=True)
491
492
493
@remote.command(name="test")
494
@click.pass_context
495
def test(ctx):
496
    """Run and return info command on a remote"""
497
498
    shell = ctx.obj["shell"]
499
    username = ctx.obj["host_config"]["login"]["username"]
500
501
    success, result = run_process(
502
        get_remote_home(username), ["iso", "-nc", "version"], shell=shell
503
    )
504
    log(success, "\n", format_result(result), pretty=True)
505
506
507
@remote.command(name="command")
508
@click.argument("commands", nargs=-1)
509
@click.pass_context
510
def command(ctx, commands):
511
    """Execute a remote command"""
512
513
    log("Executing commands %s on remote %s" % (commands, ctx.obj["remote"]))
514
515
    shell = ctx.obj["shell"]
516
517
    args = ["iso"] + list(commands)
518
519
    log(args)
520
521
    success, result = run_process(
522
        get_remote_home(ctx.obj["host_config"]["login"]["username"]), args, shell=shell
523
    )
524
525
    if not success:
526
        log("Execution error:", format_result(result), pretty=True, lvl=error)
527
    else:
528
        log("Success:")
529
        log(format_result(result))
530
531
532
@remote.command(name="backup")
533
@click.argument("backup-instance")
534
@click.option(
535
    "--fetch",
536
    "-f",
537
    help="Fetch remote backup for local storage",
538
    is_flag=True,
539
    default=False,
540
)
541
@click.option(
542
    "--target", "-t", help="Fetch to specified target directory", metavar="target"
543
)
544
@click.pass_context
545
def backup(ctx, backup_instance, fetch, target):
546
    """Backup a remote"""
547
548
    log("Backing up %s on remote %s" % (backup_instance, ctx.obj["remote"]))
549
550
    shell = ctx.obj["shell"]
551
552
    args = [
553
        "iso",
554
        "-nc",
555
        "--clog",
556
        "10",
557
        "-i",
558
        backup_instance,
559
        "-e",
560
        "current",
561
        "environment",
562
        "archive",
563
    ]
564
565
    log(args)
566
567
    success, result = run_process(
568
        get_remote_home(ctx.obj["host_config"]["login"]["username"]), args, shell=shell
569
    )
570
571
    if not success or b"Archived to" not in result.output:
572
        log("Execution error:", format_result(result), pretty=True, lvl=error)
573
    else:
574
        log("Local backup created")
575
576
        if fetch:
577
            full_path = result.split("'")[1]
578
            filename = os.path.basename(full_path)
579
580
            with shell.open(full_path, "r") as input_file:
581
                with open(os.path.join(target, filename), "w") as output_file:
582
                    output = input_file.read()
583
                    output_file.write(output)
584
585
            log("Backup downloaded. Size:", len(output))
586