tool.environment._migrate()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 3
nop 1
dl 0
loc 5
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
Module: Environment
24
===================
25
26
Environment management functionality.
27
28
    environment clear
29
    environment archive
30
    environment install-frontend
31
    environment install-module
32
    environment install-provisions
33
    environment install
34
35
"""
36
import os
37
import glob
38
import shutil
39
import tarfile
40
from copy import copy
41
from tempfile import mkdtemp
42
43
import grp
44
import pwd
45
import click
46
import pymongo
47
import tomlkit
48
from click_didyoumean import DYMGroup
49
from git import Repo, exc
50
51
from isomer.database.backup import dump, load
52
from isomer.error import abort, EXIT_INVALID_SOURCE, EXIT_STORE_PACKAGE_NOT_FOUND, \
53
    EXIT_INVALID_PARAMETER, EXIT_INVALID_CONFIGURATION
54
from isomer.logger import error, verbose, warn, critical, debug, hilight
55
from isomer.misc.std import std_now, std_uuid
56
from isomer.tool.etc import valid_configuration
57
from isomer.misc.path import (
58
    set_instance,
59
    get_path,
60
    get_etc_path,
61
    get_prefix_path,
62
    locations,
63
    get_log_path,
64
    get_etc_instance_path,
65
)
66
from isomer.scm_version import version
67
from isomer.tool import (
68
    log,
69
    finish,
70
    run_process,
71
    format_result,
72
    get_isomer,
73
    _get_configuration,
74
    get_next_environment,
75
)
76
from isomer.tool.database import delete_database
77
from isomer.tool.defaults import source_url
78
from isomer.tool.etc import write_instance, environment_template
79
from isomer.ui.builder import get_frontend_locations
80
from isomer.ui.store.inventory import get_store
81
from isomer.ui.store import DEFAULT_STORE_URL
82
83
84
@click.group(
85
    cls=DYMGroup,
86
    short_help="Environment handling"
87
)
88
@click.pass_context
89
def environment(ctx):
90
    """[GROUP] Various aspects of Isomer environment handling"""
91
92
    _get_configuration(ctx)
93
94
95
@environment.command(name="check", short_help="Health check an environment")
96
@click.option("--dev", "-d", is_flag=True, default=False,
97
              help="Use development locations")
98
@click.pass_context
99
def check_environment(ctx, dev):
100
    """General fitness tests of the built environment"""
101
102
    if _check_environment(ctx, dev=dev):
103
        log("Environment seems healthy")
104
105
    finish(ctx)
106
107
108
def _check_environment(ctx, env=None, dev=False):
109
    """General fitness tests of the built environment"""
110
111
    if env is None:
112
        env = get_next_environment(ctx)
113
114
    log("Health checking the environment '%s'" % env)
115
116
    # Frontend
117
118
    not_enough_files = False
119
    html_missing = False
120
    loader_missing = False
121
    size_too_small = False
122
123
    # Backend
124
125
    repository_missing = False
126
    modules_missing = False
127
    venv_missing = False
128
    local_missing = False
129
    cache_missing = False
130
131
    # Backend
132
133
    if not os.path.exists(os.path.join(get_path('lib', 'repository'))):
134
        log("Repository is missing", lvl=warn)
135
        repository_missing = True
136
137
    if not os.path.exists(os.path.join(get_path('lib', 'modules'))):
138
        log("Modules folder is missing", lvl=warn)
139
        modules_missing = True
140
141
    if not os.path.exists(os.path.join(get_path('lib', 'venv'))):
142
        log("Virtual environment is missing", lvl=warn)
143
        venv_missing = True
144
145
    if not os.path.exists(os.path.join(get_path('local', ''))):
146
        log("Local data folder is missing", lvl=warn)
147
        local_missing = True
148
149
    if not os.path.exists(os.path.join(get_path('cache', ''))):
150
        log("Cache folder is missing", lvl=warn)
151
        cache_missing = True
152
153
    # Frontend
154
155
    _, frontend_target = get_frontend_locations(dev)
156
157
    if not os.path.exists(os.path.join(frontend_target, 'index.html')):
158
        log("A compiled frontend html seems to be missing", lvl=warn)
159
        html_missing = True
160
161
    if not glob.glob(frontend_target + '/main.*.js'):
162
        log("A compiled frontend loader seems to be missing", lvl=warn)
163
        loader_missing = True
164
165
    size_sum = 0
166
    amount_files = 0
167
    for file in glob.glob(os.path.join(frontend_target, '*.gz')):
168
        size_sum += os.stat(file).st_size
169
        amount_files += 1
170
171
    if amount_files < 4:
172
        log("The frontend probably did not compile completely", lvl=warn)
173
        not_enough_files = True
174
    if size_sum < 2 * 1024 * 1024:
175
        log("The compiled frontend seems exceptionally small",
176
            lvl=warn)
177
        size_too_small = True
178
179
    frontend = (repository_missing or modules_missing or venv_missing or
180
                local_missing or cache_missing)
181
    backend = (not_enough_files or loader_missing or size_too_small or
182
               html_missing)
183
184
    result = not (frontend or backend)
185
186
    if result is False:
187
        log("Health check failed", lvl=error)
188
189
    return result
190
191
192
@environment.command(name="clear", short_help="Clear an environment")
193
@click.option("--force", "-f", is_flag=True, default=False)
194
@click.option("--no-archive", "-n", is_flag=True, default=False)
195
@click.pass_context
196
def clear_environment(ctx, force, no_archive):
197
    """Clear the non-active environment"""
198
199
    _clear_environment(ctx, force, no_archive=no_archive)
200
201
202
def _clear_environment(ctx, force=False, clear_env=None, clear=False, no_archive=False):
203
    """Tests an environment for usage, then clears it
204
205
    :param ctx: Click Context
206
    :param force: Irrefutably destroy environment content
207
    :param clear_env: Environment to clear (Green/Blue)
208
    :param clear: Also destroy generated folders
209
    :param no_archive: Don't attempt to archive instance
210
    """
211
212
    instance_name = ctx.obj["instance"]
213
214
    if clear_env is None:
215
        next_environment = get_next_environment(ctx)
216
    else:
217
        next_environment = clear_env
218
219
    log("Clearing environment:", next_environment)
220
    set_instance(instance_name, next_environment)
221
222
    # log('Testing', environment, 'for usage')
223
224
    env = ctx.obj["instance_configuration"]["environments"][next_environment]
225
226
    if not no_archive:
227
        if not (_archive(ctx, force) or force):
228
            log("Archival failed, stopping.")
229
            abort(5000)
230
231
    log("Clearing env:", env, lvl=debug)
232
233
    for item in locations:
234
        path = get_path(item, "")
235
        log("Clearing [%s]: %s" % (item, path), lvl=debug)
236
        try:
237
            shutil.rmtree(path)
238
        except FileNotFoundError:
239
            log("Path not found:", path, lvl=debug)
240
        except PermissionError:
241
            log("No permission to clear environment", lvl=error)
242
            return False
243
244
    if not clear:
245
        _create_folders(ctx)
246
247
    try:
248
        delete_database(
249
            ctx.obj["dbhost"], "%s_%s" % (instance_name, next_environment), force=True
250
        )
251
    except pymongo.errors.ServerSelectionTimeoutError:
252
        log("No database available")
253
    except Exception as e:
254
        log("Could not delete database:", e, lvl=warn, exc=True)
255
256
    ctx.obj["instance_configuration"]["environments"][
257
        next_environment
258
    ] = environment_template
259
    write_instance(ctx.obj["instance_configuration"])
260
    return True
261
262
263
def _create_folders(ctx):
264
    """Generate required folders for an instance"""
265
266
    log("Generating instance directories", emitter="MANAGE")
267
268
    instance_configuration = ctx.obj["instance_configuration"]
269
270
    try:
271
        uid = pwd.getpwnam(instance_configuration["user"]).pw_uid
272
        gid = grp.getgrnam(instance_configuration["group"]).gr_gid
273
    except KeyError:
274
        log("User account for instance not found!", lvl=warn)
275
        uid = gid = None
276
277
    logfile = os.path.join(get_log_path(), "isomer." + ctx.obj["instance"] + ".log")
278
279
    for item in locations:
280
        path = get_path(item, "", ensure=True)
281
282
        log("Created path: " + path, lvl=debug)
283
        try:
284
            os.chown(path, uid, gid)
285
        except PermissionError:
286
            log("Could not change ownership:", path, lvl=warn, exc=True)
287
288
    module_path = get_path("lib", "modules", ensure=True)
289
290
    try:
291
        os.chown(module_path, uid, gid)
292
    except PermissionError:
293
        log("Could not change ownership:", module_path, lvl=warn, exc=True)
294
295
    log("Module storage created:", module_path, lvl=debug)
296
297
    if not os.path.exists(logfile):
298
        open(logfile, "w").close()
299
300
    try:
301
        os.chown(logfile, uid, gid)
302
    except PermissionError:
303
        log("Could not change ownership:", logfile, lvl=warn, exc=True)
304
305
    finish(ctx)
306
307
308
@environment.command(name="set", short_help="Set a parameter of an environment")
309
@click.argument("parameter")
310
@click.argument("value")
311
@click.option(
312
    "--force",
313
    "-f",
314
    is_flag=True,
315
    help="Ignore configuration validation errors"
316
)
317
@click.pass_context
318
def set_parameter(ctx, parameter, value, force):
319
    """Set a configuration parameter of an environment"""
320
321
    # TODO: Generalize and improve this.
322
    #  - To get less code redundancy (this is also in instance.py)
323
    #  - To be able to set lists, dicts and other types correctly
324
325
    log("Setting %s to %s" % (parameter, value))
326
327
    next_environment = get_next_environment(ctx)
328
    environment_configuration = ctx.obj["instance_configuration"]['environments'][next_environment]
329
    defaults = environment_template
330
    converted_value = None
331
332
    try:
333
        parameter_type = type(defaults[parameter])
334
        log(parameter_type, pretty=True, lvl=debug)
335
336
        if parameter_type == tomlkit.items.Integer:
337
            converted_value = int(value)
338
        elif parameter_type == bool:
339
            converted_value = value.upper() == "TRUE"
340
        else:
341
            converted_value = value
342
    except KeyError:
343
        log("Available parameters:", sorted(list(defaults.keys())))
344
        abort(EXIT_INVALID_PARAMETER)
345
346
    if converted_value is None:
347
        log("Converted value was None! Recheck the new config!", lvl=warn)
348
349
    environment_configuration[parameter] = converted_value
350
    log("New config:", environment_configuration, pretty=True, lvl=debug)
351
352
    ctx.obj["instances"][ctx.obj["instance"]]['environments'][next_environment] = environment_configuration
353
354
    if valid_configuration(ctx) or force:
355
        write_instance(ctx.obj["instances"][ctx.obj["instance"]])
356
        finish(ctx)
357
    else:
358
        log("New configuration would not be valid", lvl=critical)
359
        abort(EXIT_INVALID_CONFIGURATION)
360
361
362
@environment.command(short_help="Archive an environment")
363
@click.option("--force", "-f", is_flag=True, default=False)
364
@click.option(
365
    "--dynamic",
366
    "-d",
367
    is_flag=True,
368
    default=False,
369
    help="Archive only dynamic data: database, configuration",
370
)
371
@click.pass_context
372
def archive(ctx, force, dynamic):
373
    """Archive the specified or non-active environment"""
374
375
    result = _archive(ctx, force, dynamic)
376
    if result:
377
        log("Archived to '%s'" % result)
378
        finish(ctx)
379
    else:
380
        log("Could not archive.", lvl=error)
381
        abort(50060)
382
383
384
def _archive(ctx, force=False, dynamic=False):
385
    instance_configuration = ctx.obj["instance_configuration"]
386
387
    next_environment = get_next_environment(ctx)
388
389
    env = instance_configuration["environments"][next_environment]
390
391
    log("Instance info:", instance_configuration, next_environment, pretty=True,
392
        lvl=debug)
393
    log("Installed:", env["installed"], "Tested:", env["tested"], lvl=debug)
394
395
    if (not env["installed"] or not env["tested"]) and not force:
396
        log("Environment has not been installed - not archiving.", lvl=warn)
397
        return False
398
399
    log("Archiving environment:", next_environment)
400
    set_instance(ctx.obj["instance"], next_environment)
401
402
    timestamp = std_now().replace(":", "-").replace(".", "-")
403
404
    temp_path = mkdtemp(prefix="isomer_backup")
405
406
    log("Archiving database")
407
    if not dump(
408
            instance_configuration["database_host"],
409
            instance_configuration["database_port"],
410
            env["database"],
411
            os.path.join(temp_path, "db_" + timestamp + ".json"),
412
    ):
413
        if not force:
414
            log("Could not archive database.")
415
            return False
416
417
    archive_filename = os.path.join(
418
        "/var/backups/isomer/",
419
        "%s_%s_%s.tgz" % (ctx.obj["instance"], next_environment, timestamp),
420
    )
421
422
    try:
423
        shutil.copy(
424
            os.path.join(get_etc_instance_path(), ctx.obj["instance"] + ".conf"),
425
            temp_path,
426
        )
427
428
        with tarfile.open(archive_filename, "w:gz") as f:
429
            if not dynamic:
430
                for item in locations:
431
                    path = get_path(item, "")
432
                    log("Archiving [%s]: %s" % (item, path))
433
                    f.add(path)
434
            f.add(temp_path, "db_etc")
435
    except (PermissionError, FileNotFoundError) as e:
436
        log("Could not archive environment:", e, lvl=error)
437
        if not force:
438
            return False
439
    finally:
440
        log("Clearing temporary backup target")
441
        shutil.rmtree(temp_path)
442
443
    ctx.obj["instance_configuration"]["environments"]["archive"][timestamp] = env
444
445
    log(ctx.obj["instance_configuration"])
446
447
    return archive_filename
448
449
450
@environment.command(short_help="Install frontend")
451
@click.pass_context
452
def install_frontend(ctx):
453
    """Install frontend into an environment"""
454
455
    next_environment = get_next_environment(ctx)
456
457
    set_instance(ctx.obj["instance"], next_environment)
458
    _install_frontend(ctx)
459
    finish(ctx)
460
461
462
def _install_frontend(ctx):
463
    """Install and build the frontend"""
464
465
    env = get_next_environment(ctx)
466
    env_path = get_path("lib", "")
467
468
    instance_configuration = ctx.obj["instance_configuration"]
469
470
    user = instance_configuration["user"]
471
472
    log("Building frontend")
473
474
    success, result = run_process(
475
        os.path.join(env_path, "repository"),
476
        [
477
            os.path.join(env_path, "venv", "bin", "python3"),
478
            "./iso",
479
            "-nc",
480
            "--config-path",
481
            get_etc_path(),
482
            "--prefix-path",
483
            get_prefix_path(),
484
            "-i",
485
            instance_configuration["name"],
486
            "-e",
487
            env,
488
            "--clog",
489
            "10",
490
            "install",
491
            "frontend",
492
            "--rebuild",
493
        ],
494
        sudo=user,
495
    )
496
    if not success:
497
        log(format_result(result), lvl=error)
498
        return False
499
500
    return True
501
502
503
@environment.command(
504
    "install-env-modules", short_help="Install a module into an environment"
505
)
506
@click.option(
507
    "--source", "-s", default="git", type=click.Choice(["link", "copy", "git", "store"])
508
)
509
@click.option(
510
    "--store-url",
511
    default=DEFAULT_STORE_URL,
512
    help="Specify alternative store url",
513
)
514
@click.option(
515
    "--force",
516
    "-f",
517
    default=False,
518
    is_flag=True,
519
    help="Force installation (overwrites old modules)",
520
)
521
@click.argument("urls", nargs=-1)
522
@click.pass_context
523
def install_environment_modules(ctx, source, force, urls, store_url):
524
    """Add and install a module only to a single environment
525
526
    Note: This does not modify the instance configuration, so this will not
527
    be permanent during upgrades etc.
528
    """
529
530
    instance_name = ctx.obj["instance"]
531
    instance_configuration = ctx.obj["instances"][instance_name]
532
533
    next_environment = get_next_environment(ctx)
534
    user = instance_configuration["user"]
535
    installed = instance_configuration["environments"][next_environment]["installed"]
536
537
    if not installed:
538
        log("Please install the '%s' environment first." % next_environment, lvl=error)
539
        abort(50000)
540
541
    set_instance(instance_name, next_environment)
542
543
    for url in urls:
544
        result = _install_module(
545
            source, url, force=force, user=user, store_url=store_url
546
        )
547
548
        if result is False:
549
            log("Installation failed!", lvl=error)
550
            abort(50000)
551
552
        package_name, package_version = result
553
554
        descriptor = {"version": package_version, "source": source, "url": url}
555
        if store_url != DEFAULT_STORE_URL:
556
            descriptor["store_url"] = store_url
557
        instance_configuration["environments"][next_environment]["modules"][
558
            package_name
559
        ] = descriptor
560
561
    write_instance(instance_configuration)
562
563
    finish(ctx)
564
565
566
def _install_module(source, url, store_url=DEFAULT_STORE_URL, auth=None, force=False,
567
                    user=None):
568
    """Actually installs a module into an environment"""
569
570
    package_name = package_version = success = output = ""
571
572
    def get_module_info(directory):
573
        log("Getting name")
574
        success, result = run_process(
575
            directory, ["python3", "setup.py", "--name"], sudo=user
576
        )
577
        if not success:
578
            log(format_result(result), pretty=True, lvl=error)
579
            return False
580
581
        package_name = str(result.output, encoding="utf8").rstrip("\n")
582
583
        log("Getting version")
584
        success, result = run_process(
585
            directory, ["python3", "setup.py", "--version"], sudo=user
586
        )
587
        if not success:
588
            log(format_result(result), pretty=True, lvl=error)
589
            return False
590
591
        package_version = str(result.output, encoding="utf8").rstrip("\n")
592
593
        log("Package name:", package_name, "version:", package_version)
594
        return package_name, package_version
595
596
    if source == "develop":
597
        log("Installing module for development")
598
        success, output = run_process(
599
            url,
600
            [
601
                os.path.join(get_path("lib", "venv"), "bin", "python3"),
602
                "setup.py",
603
                "develop",
604
            ],
605
            sudo=user,
606
        )
607
        if not success:
608
            log(output, lvl=verbose)
609
            return False
610
        else:
611
            return get_module_info(url)
612
613
    module_path = get_path("lib", "modules", ensure=True)
614
    module_info = False
615
616
    if source not in ("git", "link", "copy", "store"):
617
        abort(EXIT_INVALID_SOURCE)
618
619
    uuid = std_uuid()
620
    temporary_path = os.path.join(module_path, "%s" % uuid)
621
622
    log("Installing module: %s [%s]" % (url, source))
623
624
    if source in ("link", "copy") and url.startswith("/"):
625
        absolute_path = url
626
    else:
627
        absolute_path = os.path.abspath(url)
628
629
    if source == "git":
630
        log("Cloning repository from", url)
631
        success, output = run_process(
632
            module_path, ["git", "clone", url, temporary_path], sudo=user
633
        )
634
        if not success:
635
            log("Error:", output, lvl=error)
636
    elif source == "link":
637
        log("Linking repository from", absolute_path)
638
        success, output = run_process(
639
            module_path, ["ln", "-s", absolute_path, temporary_path], sudo=user
640
        )
641
        if not success:
642
            log("Error:", output, lvl=error)
643
    elif source == "copy":
644
        log("Copying repository from", absolute_path)
645
        success, output = run_process(
646
            module_path, ["cp", "-a", absolute_path, temporary_path], sudo=user
647
        )
648
        if not success:
649
            log("Error:", output, lvl=error)
650
    elif source == "store":
651
        log("Installing wheel from store", absolute_path)
652
653
        log(store_url, auth)
654
        store = get_store(store_url, auth)
655
656
        if url not in store["packages"]:
657
            abort(EXIT_STORE_PACKAGE_NOT_FOUND)
658
659
        meta = store["packages"][url]
660
661
        package_name = meta['name']
662
        package_version = meta['version']
663
664
        venv_path = os.path.join(get_path("lib", "venv"), "bin")
665
666
        success, output = run_process(venv_path, [
667
            "pip3", "install", "--extra-index-url", store_url, package_name
668
        ])
669
670
    if source != "store":
671
        module_info = get_module_info(temporary_path)
672
673
        if module_info is False:
674
            log("Could not get name and version information from module.", lvl=error)
675
            return False
676
677
        package_name, package_version = module_info
678
679
        final_path = os.path.join(module_path, package_name)
680
681
        if os.path.exists(final_path):
682
            log("Module exists.", lvl=warn)
683
            if force:
684
                log("Removing previous version.")
685
                success, result = run_process(
686
                    module_path, ["rm", "-rf", final_path], sudo=user
687
                )
688
                if not success:
689
                    log("Could not remove previous version!", lvl=error)
690
                    abort(50000)
691
            else:
692
                log("Not overwriting previous version without --force", lvl=error)
693
                abort(50000)
694
695
        log("Renaming to", final_path)
696
        os.rename(temporary_path, final_path)
697
698
        log("Installing module")
699
        success, output = run_process(
700
            final_path,
701
            [
702
                os.path.join(get_path("lib", "venv"), "bin", "python3"),
703
                "setup.py",
704
                "develop",
705
            ],
706
            sudo=user,
707
        )
708
709
    if not success:
710
        log(output, lvl=verbose)
711
        return False
712
    else:
713
        return package_name, package_version
714
715
716
@environment.command()
717
@click.pass_context
718
def install_modules(ctx):
719
    """Installs all instance configured modules
720
721
    To configure (and install) modules for an instance, use
722
723
        iso instance install-modules -s <SOURCE> [URLS]
724
725
    To immediately install them, add --install
726
    """
727
728
    _install_modules(ctx)
729
730
    finish(ctx)
731
732
733
def _install_modules(ctx):
734
    """Internal function to install modules"""
735
736
    env = get_next_environment(ctx)
737
    log("Installing modules into", env, pretty=True)
738
739
    instance_configuration = ctx.obj["instance_configuration"]
740
741
    modules = instance_configuration["modules"]
742
    user = instance_configuration["user"]
743
744
    if len(modules) == 0:
745
        log("No modules defined for instance")
746
        return True
747
748
    for module in modules:
749
        log("Installing:", module, pretty=True)
750
        store_url = module[2] if module[0] == "store" else DEFAULT_STORE_URL
751
        result = _install_module(module[0], module[1], user=user, store_url=store_url)
752
        if result is False:
753
            log("Installation of module failed!", lvl=warn)
754
        else:
755
            module_name, module_version = result
756
            descriptor = {"name": module_name, "source": module[0], "url": module[1]}
757
            if store_url != DEFAULT_STORE_URL:
758
                descriptor["store_url"] = store_url
759
            instance_configuration["environments"][env]["modules"][
760
                module_name
761
            ] = descriptor
762
763
    write_instance(instance_configuration)
764
    return True
765
766
767
@environment.command(short_help="Install provisions and/or a database backup")
768
@click.option(
769
    "--import-file", "--import", default=None, help="Import the specified backup"
770
)
771
@click.option("--skip-provisions", is_flag=True, default=False)
772
@click.pass_context
773
def install_provisions(ctx, import_file, skip_provisions):
774
    """Install provisions and/or a database dump"""
775
    _install_provisions(ctx, import_file, skip_provisions)
776
777
    finish(ctx)
778
779
780
def _install_provisions(ctx, import_file=None, skip_provisions=False):
781
    """Load provisions into database"""
782
783
    instance_configuration = ctx.obj["instance_configuration"]
784
    env = get_next_environment(ctx)
785
    env_path = get_path("lib", "")
786
787
    log("Installing provisioning data")
788
789
    if not skip_provisions:
790
        success, result = run_process(
791
            os.path.join(env_path, "repository"),
792
            [
793
                os.path.join(env_path, "venv", "bin", "python3"),
794
                "./iso",
795
                "-nc",
796
                "--flog",
797
                "5",
798
                "--config-path",
799
                get_etc_path(),
800
                "-i",
801
                instance_configuration["name"],
802
                "-e",
803
                env,
804
                "install",
805
                "provisions",
806
            ],
807
            # Note: no sudo necessary as long as we do not enforce
808
            # authentication on databases
809
        )
810
        if not success:
811
            log("Could not provision data:", lvl=error)
812
            log(format_result(result), lvl=error)
813
            return False
814
815
    if import_file is not None:
816
        log("Importing backup")
817
        log(ctx.obj, pretty=True)
818
        host, port = ctx.obj["dbhost"].split(":")
819
        load(host, int(port), ctx.obj["dbname"], import_file)
820
821
    return True
822
823
824
def _migrate(ctx):
825
    """Migrate all data objects"""
826
    # TODO: Implement migration
827
    log("Would now migrate (Not implemented, yet)")
828
    return True
829
830
831
@environment.command(name="install", short_help="Install the other environment")
832
@click.option("--force", "-f", is_flag=True, default=False)
833
@click.option(
834
    "--source", "-s", default="git", type=click.Choice(["link", "copy", "git"])
835
)
836
@click.option(
837
    "--url", "-u", default=None,
838
    type=click.Path(
839
        exists=True,
840
        file_okay=False,
841
        resolve_path=True
842
    )
843
)
844
@click.option(
845
    "--import-file", "--import", default=None, help="Import the specified backup"
846
)
847
@click.option(
848
    "--no-sudo",
849
    is_flag=True,
850
    default=False,
851
    help="Do not use sudo to install (Mostly for tests)",
852
)
853
@click.option(
854
    "--release", "-r", default=None, help="Override installed release version"
855
)
856
@click.option("--skip-modules", is_flag=True, default=False)
857
@click.option("--skip-data", is_flag=True, default=False)
858
@click.option("--skip-frontend", is_flag=True, default=False)
859
@click.option("--skip-test", is_flag=True, default=False)
860
@click.option("--skip-provisions", is_flag=True, default=False)
861
@click.pass_context
862
def install_environment(ctx, **kwargs):
863
    """Install an environment"""
864
865
    _install_environment(ctx, **kwargs)
866
867
    finish(ctx)
868
869
870
def _install_environment(
871
        ctx,
872
        source=None,
873
        url=None,
874
        import_file=None,
875
        no_sudo=False,
876
        force=False,
877
        release=None,
878
        upgrade=False,
879
        skip_modules=False,
880
        skip_data=False,
881
        skip_frontend=False,
882
        skip_test=False,
883
        skip_provisions=False,
884
):
885
    """Internal function to perform environment installation"""
886
887
    if url is None:
888
        url = source_url
889
    elif url[0] == '.':
890
        url = url.replace(".", os.getcwd(), 1)
891
892
    if url[0] == '/':
893
        url = os.path.abspath(url)
894
895
    instance_name = ctx.obj["instance"]
896
    instance_configuration = ctx.obj["instance_configuration"]
897
898
    next_environment = get_next_environment(ctx)
899
900
    set_instance(instance_name, next_environment)
901
902
    env = copy(instance_configuration["environments"][next_environment])
903
904
    env["database"] = instance_name + "_" + next_environment
905
906
    env_path = get_path("lib", "")
907
908
    user = instance_configuration["user"]
909
910
    if no_sudo:
911
        user = None
912
913
    log(
914
        "Installing new other environment for %s on %s from %s in %s"
915
        % (instance_name, next_environment, source, env_path)
916
    )
917
918
    try:
919
        result = get_isomer(
920
            source, url, env_path, upgrade=upgrade, sudo=user, release=release
921
        )
922
        if result is False:
923
            log("Getting Isomer failed", lvl=critical)
924
            abort(50011, ctx)
925
    except FileExistsError:
926
        if not force:
927
            log(
928
                "Isomer already present, please safely clear or "
929
                "inspect the environment before continuing! Use --force to ignore.",
930
                lvl=warn,
931
            )
932
            abort(50012, ctx)
933
        else:
934
            log("Isomer already present, forcing through anyway.")
935
936
    try:
937
        repository = Repo(os.path.join(env_path, "repository"))
938
939
        log("Repo:", repository, lvl=debug)
940
        env["version"] = str(repository.git.describe())
941
    except (exc.InvalidGitRepositoryError, exc.NoSuchPathError, exc.GitCommandError):
942
        env["version"] = version
943
        log(
944
            "Not running from a git repository; Using isomer.version:",
945
            version,
946
            lvl=warn,
947
        )
948
949
    ctx.obj["instance_configuration"]["environments"][next_environment] = env
950
951
    # TODO: Does it make sense to early-write the configuration and then again later?
952
    write_instance(ctx.obj["instance_configuration"])
953
954
    log("Creating virtual environment")
955
    success, result = run_process(
956
        env_path,
957
        ["virtualenv", "-p", "/usr/bin/python3", "--system-site-packages", "venv"],
958
        sudo=user,
959
    )
960
    if not success:
961
        log(format_result(result), lvl=error)
962
963
    try:
964
        if _install_backend(ctx):
965
            log("Backend installed")
966
            env["installed"] = True
967
        if not skip_modules and _install_modules(ctx):
968
            log("Modules installed")
969
            # env['installed_modules'] = True
970
        if not skip_provisions and _install_provisions(ctx, import_file=import_file):
971
            log("Provisions installed")
972
            env["provisioned"] = True
973
        if not skip_data and _migrate(ctx):
974
            log("Data migrated")
975
            env["migrated"] = True
976
        if not skip_frontend and _install_frontend(ctx):
977
            log("Frontend installed")
978
            env["frontend"] = True
979
        if not skip_test and _check_environment(ctx):
980
            log("Environment tested")
981
            env["tested"] = True
982
    except Exception:
983
        log("Error during installation:", exc=True, lvl=critical)
984
985
    log("Environment status now:", env)
986
987
    ctx.obj["instance_configuration"]["environments"][next_environment] = env
988
989
    write_instance(ctx.obj["instance_configuration"])
990
991
992
def _install_backend(ctx):
993
    """Installs the backend into an environment"""
994
995
    instance_name = ctx.obj["instance"]
996
    env = get_next_environment(ctx)
997
998
    set_instance(instance_name, env)
999
1000
    log("Installing backend on", env, lvl=debug)
1001
1002
    env_path = get_path("lib", "")
1003
    user = ctx.obj["instance_configuration"]["user"]
1004
1005
    success, result = run_process(
1006
        os.path.join(env_path, "repository"),
1007
        [os.path.join(env_path, "venv", "bin", "python3"), "setup.py", "develop"],
1008
        sudo=user,
1009
    )
1010
    if not success:
1011
        output = str(result)
1012
1013
        if "was unable to detect version" in output:
1014
            log(
1015
                "Installing from dirty repository. This might result in dependency "
1016
                "version problems!",
1017
                lvl=hilight,
1018
            )
1019
        else:
1020
            log(
1021
                "Something unexpected happened during backend installation:\n",
1022
                result,
1023
                lvl=hilight,
1024
            )
1025
1026
        # TODO: Another fault might be an unclean package path.
1027
        #  But i forgot the log message to check for.
1028
        # log('This might be a problem due to unclean installations of Python'
1029
        #     ' libraries. Please check your path.')
1030
1031
    log("Installing requirements")
1032
    success, result = run_process(
1033
        os.path.join(env_path, "repository"),
1034
        [
1035
            os.path.join(env_path, "venv", "bin", "pip3"),
1036
            "install",
1037
            "-r",
1038
            "requirements.txt",
1039
        ],
1040
        sudo=user,
1041
    )
1042
    if not success:
1043
        log(format_result(result), lvl=error)
1044
1045
    return True
1046