building_manager.py 70.4 KB
Newer Older
1
#!/usr/bin/env python3
2
""" Python module to assist creating and maintaining docker openHab stacks."""
3
import crypt
dobli's avatar
dobli committed
4
from enum import Enum
dobli's avatar
dobli committed
5
from typing import NamedTuple
6
import logging
7
import os
Dobli's avatar
Dobli committed
8
import sys
dobli's avatar
dobli committed
9
import json as pyjson
dobli's avatar
dobli committed
10
from hashlib import md5
11
from shutil import copy2
12
from subprocess import PIPE, run
dobli's avatar
dobli committed
13
from time import sleep
14
15
16

import bcrypt
import docker
17
import questionary as qust
18
from ruamel.yaml import YAML
19
from prompt_toolkit.styles import Style
20
21
22
23

# Configure YAML
yaml = YAML()
yaml.indent(mapping=4, sequence=4, offset=2)
24
25
26

# Log level during development is info
logging.basicConfig(level=logging.WARNING)
27

28
29
30
31
32
33
34
35
36
37
38
# Prompt style
st = Style([
    ('qmark', 'fg:#00c4b4 bold'),     # token in front of question
    ('question', 'bold'),             # question text
    ('answer', 'fg:#00c4b4 bold'),    # submitted answer question
    ('pointer', 'fg:#00c4b4 bold'),   # pointer for select and checkbox
    ('selected', 'fg:#00c4b4'),       # selected item checkbox
    ('separator', 'fg:#00c4b4'),      # separator in lists
    ('instruction', '')               # user instructions for selections
])

dobli's avatar
dobli committed
39

40
41
42
43
# ******************************
# Constants <<<
# ******************************

44
# Directories for config generation
45
46
CUSTOM_DIR = 'custom_configs'
TEMPLATE_DIR = 'template_configs'
47
48
49
COMPOSE_NAME = 'docker-stack.yml'
SKELETON_NAME = 'docker-skeleton.yml'
TEMPLATES_NAME = 'docker-templates.yml'
Dobli's avatar
Dobli committed
50
CONFIG_DIRS = ['mosquitto', 'nodered', 'ssh', 'filebrowser',
dobli's avatar
dobli committed
51
               'traefik', 'volumerize', 'postgres', 'pb-framr']
Dobli's avatar
Dobli committed
52
53
TEMPLATE_FILES = [
    'mosquitto/mosquitto.conf', 'nodered/nodered_package.json',
dobli's avatar
dobli committed
54
55
    'pb-framr/logo.svg', 'nodered/nodered_settings.js',
    'ssh/sshd_config', 'traefik/traefik.toml'
56
]
57
58
EDIT_FILES = {
    "mosquitto_passwords": "mosquitto/mosquitto_passwords",
59
    "sftp_users": "ssh/sftp_users.conf",
60
61
62
    "traefik_users": "traefik/traefik_users",
    "id_rsa": "ssh/id_rsa",
    "host_key": "ssh/ssh_host_ed25519_key",
63
    "known_hosts": "ssh/known_hosts",
dobli's avatar
dobli committed
64
    "backup_config": "volumerize/backup_config",
dobli's avatar
dobli committed
65
    "postgres_user": "postgres/user",
dobli's avatar
dobli committed
66
    "postgres_passwd": "postgres/passwd",
Dobli's avatar
Dobli committed
67
68
    "pb_framr_pages": "pb-framr/pages.json",
    "filebrowser_conf": "filebrowser/filebrowser.json"
69
}
Dobli's avatar
Dobli committed
70
CONSTRAINTS = {"building": "node.labels.building"}
71

72
# Default Swarm port
dobli's avatar
dobli committed
73
SWARM_PORT = 2377
74
75
# UID for admin
UID = 9001
Dobli's avatar
Dobli committed
76
77
# Username for admin
ADMIN_USER = 'ohadmin'
dobli's avatar
dobli committed
78

dobli's avatar
dobli committed
79
80
81
82
83
84
# USB DEVICES (e.g. Zwave stick)
USB_DEVICES = [{
    "name": "Aeotec Z-Stick Gen5 (ttyACM0)",
    "value": "zwave_stick"
}]

dobli's avatar
dobli committed
85

dobli's avatar
dobli committed
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
class ServiceBody(NamedTuple):
    fullname: str
    prefix: str
    additional: bool
    frontend: bool
    sftp: bool = False
    icon: str = None


class Service(ServiceBody, Enum):
    SFTP = ServiceBody("SFTP", "sftp", False, False)
    OPENHAB = ServiceBody("OpenHAB", "openhab", True,
                          True, icon='dashboard', sftp=True)
    NODERED = ServiceBody("Node-RED", "nodered", False,
                          True, icon='ballot', sftp=True)
    POSTGRES = ServiceBody("Postgre SQL", "postgres", True, False)
    MQTT = ServiceBody("Mosquitto MQTT Broker", "mqtt", True, False)
    FILES = ServiceBody("File Manager", "files", False, True, icon='folder')
dobli's avatar
dobli committed
104
105
    BACKUP = ServiceBody("Volumerize Backups", "backup",
                         False, False, sftp=True)
dobli's avatar
dobli committed
106
107
108
109
110

    @classmethod
    def service_by_prefix(cls, prefix):
        # cls here is the enumeration
        return next(service for service in cls if service.prefix == prefix)
111
112
# >>>

113

dobli's avatar
dobli committed
114
115
116
117
118
# ******************************
# State Variables <<<
# ******************************
base_dir = sys.path[0]
template_path = f'{base_dir}/{TEMPLATE_DIR}'
dobli's avatar
dobli committed
119
custom_path = f'{base_dir}/{CUSTOM_DIR}'
dobli's avatar
dobli committed
120
121
122
# >>>


123
# ******************************
124
# Compose file functions <<<
125
# ******************************
Dobli's avatar
Dobli committed
126
127

# Functions to generate initial file
dobli's avatar
dobli committed
128
def generate_initial_compose():
129
130
131
    """Creates the initial compose using the skeleton
    """
    # compose file
dobli's avatar
dobli committed
132
    compose = custom_path + '/' + COMPOSE_NAME
133
134
135
136
137
138
139
140
    # skeleton file
    skeleton = template_path + '/' + SKELETON_NAME

    with open(skeleton, 'r') as skeleton_f, open(compose, 'w+') as compose_f:
        init_content = yaml.load(skeleton_f)
        yaml.dump(init_content, compose_f)


dobli's avatar
dobli committed
141
def add_sftp_service(building, number=0):
142
143
    """Generates an sftp entry and adds it to the compose file

dobli's avatar
dobli committed
144
    :building: names of building that the services is added to
145
146
147
    :number: increment of exposed port to prevent overlaps
    """
    # compose file
dobli's avatar
dobli committed
148
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
149
    # service name
dobli's avatar
dobli committed
150
    service_name = f'sftp_{building}'
151
    # template
dobli's avatar
dobli committed
152
    template = get_service_template(Service.SFTP.prefix)
153
154
    # only label contraint is building
    template['deploy']['placement']['constraints'][0] = (
dobli's avatar
dobli committed
155
        f"{CONSTRAINTS['building']} == {building}")
156
    template['ports'] = [f'{2222 + number}:22']
157

dobli's avatar
dobli committed
158
159
    # attach volumes
    volume_base = '/home/ohadmin/'
dobli's avatar
dobli committed
160
    template['volumes'] = get_attachable_volume_list(volume_base, building)
dobli's avatar
dobli committed
161

162
    add_or_update_compose_service(compose_path, service_name, template)
163
164


dobli's avatar
dobli committed
165
def add_openhab_service(building, host):
166
167
    """Generates an openhab entry and adds it to the compose file

dobli's avatar
dobli committed
168
169
    :building: name of building that the services is added to
    :host: host the building is added to, used for routing
170
171
    """
    # compose file
dobli's avatar
dobli committed
172
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
173
    # service name
dobli's avatar
dobli committed
174
    service_name = f'openhab_{building}'
175
    # template
dobli's avatar
dobli committed
176
    template = get_service_template(Service.OPENHAB.prefix)
177
178
    # only label contraint is building
    template['deploy']['placement']['constraints'][0] = (
dobli's avatar
dobli committed
179
        f"{CONSTRAINTS['building']} == {building}")
180
    # include in backups of this building
dobli's avatar
dobli committed
181
    template['deploy']['labels'].append(f'backup={building}')
182
183
184
185
    # traefik backend
    template['deploy']['labels'].append(f'traefik.backend={service_name}')
    # traefik frontend domain->openhab
    template['deploy']['labels'].extend(
dobli's avatar
dobli committed
186
        generate_traefik_host_labels(host, segment='main'))
187
188
189
190
191
192
    # traefik frontend subdomain openhab_hostname.* -> openhab
    template['deploy']['labels'].append(
        f'traefik.sub.frontend.rule=HostRegexp:'
        f'{service_name}.{{domain:[a-zA-z0-9-]+}}')
    template['deploy']['labels'].append('traefik.sub.frontend.priority=2')

dobli's avatar
dobli committed
193
194
195
196
    # replace volumes with named entries in template
    template['volumes'] = generate_named_volumes(
        template['volumes'], service_name, compose_path)

197
    add_or_update_compose_service(compose_path, service_name, template)
198
199


200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def move_openhab_service(building, new_host):
    """Updates an openhab entry to be accessible on another host

    :building: name of building that the services is uses
    :host: host the building service is moved to, used for routing
    """
    # compose file
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
    # service name
    service_name = f'openhab_{building}'
    # template
    entry = get_service_entry(service_name)
    # traefik remove old domain by filtering
    old_labels = entry['deploy']['labels']
    filtered_labels = [
        l for l in old_labels
        if not l.startswith('traefik.main.frontend')]
    # traefik frontend new_domain->openhab
    filtered_labels.extend(
        generate_traefik_host_labels(new_host, segment='main'))

    entry['deploy']['labels'] = filtered_labels

    add_or_update_compose_service(compose_path, service_name, entry)


dobli's avatar
dobli committed
226
def add_nodered_service(building):
227
228
    """Generates an nodered entry and adds it to the compose file

dobli's avatar
dobli committed
229
    :building: name of building that the services is added to
230
231
    """
    # compose file
dobli's avatar
dobli committed
232
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
233
    # service name
dobli's avatar
dobli committed
234
    service_name = f'nodered_{building}'
235
    # template
dobli's avatar
dobli committed
236
    template = get_service_template(Service.NODERED.prefix)
237
238
    # only label contraint is building
    template['deploy']['placement']['constraints'][0] = (
dobli's avatar
dobli committed
239
        f"{CONSTRAINTS['building']} == {building}")
240
    template['deploy']['labels'].append(f'traefik.backend={service_name}')
dobli's avatar
dobli committed
241
    template['deploy']['labels'].append(f'backup={building}')
242
243
    template['deploy']['labels'].extend(
        generate_traefik_path_labels(service_name, segment='main'))
Dobli's avatar
Dobli committed
244
245
    template['deploy']['labels'].extend(
        generate_traefik_subdomain_labels(service_name, segment='sub'))
246

dobli's avatar
dobli committed
247
248
249
250
    # replace volumes with named entries in template
    template['volumes'] = generate_named_volumes(
        template['volumes'], service_name, compose_path)

251
    add_or_update_compose_service(compose_path, service_name, template)
252
253


dobli's avatar
dobli committed
254
def add_mqtt_service(building, number=0):
255
256
    """Generates an mqtt entry and adds it to the compose file

dobli's avatar
dobli committed
257
    :building: name of building that the services is added to
258
259
260
    :number: increment of exposed port to prevent overlaps
    """
    # compose file
dobli's avatar
dobli committed
261
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
262
    # service name
dobli's avatar
dobli committed
263
    service_name = f'mqtt_{building}'
264
    # template
dobli's avatar
dobli committed
265
    template = get_service_template(Service.MQTT.prefix)
266
267
    # only label contraint is building
    template['deploy']['placement']['constraints'][0] = (
dobli's avatar
dobli committed
268
        f"{CONSTRAINTS['building']} == {building}")
269
270
    # ports incremented by number of services
    template['ports'] = [f'{1883 + number}:1883', f'{9001 + number}:9001']
271

dobli's avatar
dobli committed
272
273
274
275
    # replace volumes with named entries in template
    template['volumes'] = generate_named_volumes(
        template['volumes'], service_name, compose_path)

276
    add_or_update_compose_service(compose_path, service_name, template)
277
278


dobli's avatar
dobli committed
279
def add_postgres_service(building, postfix=None):
dobli's avatar
dobli committed
280
281
    """Generates an postgres entry and adds it to the compose file

dobli's avatar
dobli committed
282
    :building: name of building that the services is added to
283
    :postfix: an identifier for this service
dobli's avatar
dobli committed
284
285
    """
    # compose file
dobli's avatar
dobli committed
286
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
dobli's avatar
dobli committed
287
    # use building as postfix when empty
dobli's avatar
dobli committed
288
    if postfix is None:
dobli's avatar
dobli committed
289
        service_name = f'postgres_{building}'
dobli's avatar
dobli committed
290
291
292
    else:
        service_name = f'postgres_{postfix}'

dobli's avatar
dobli committed
293
    # template
dobli's avatar
dobli committed
294
    template = get_service_template(Service.POSTGRES.prefix)
Dobli's avatar
Dobli committed
295
    # only label constraint is building
dobli's avatar
dobli committed
296
    template['deploy']['placement']['constraints'][0] = (
dobli's avatar
dobli committed
297
        f"{CONSTRAINTS['building']} == {building}")
dobli's avatar
dobli committed
298

dobli's avatar
dobli committed
299
300
301
302
    # replace volumes with named entries in template
    template['volumes'] = generate_named_volumes(
        template['volumes'], service_name, compose_path)

dobli's avatar
dobli committed
303
304
305
    add_or_update_compose_service(compose_path, service_name, template)


dobli's avatar
dobli committed
306
def add_file_service(building):
dobli's avatar
dobli committed
307
    """Generates a file manager entry and adds it to the compose file
308

dobli's avatar
dobli committed
309
    :building: names of host that the services is added to
310
311
    """
    # compose file
dobli's avatar
dobli committed
312
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
313
    # service name
dobli's avatar
dobli committed
314
    service_name = f'{Service.FILES.prefix}_{building}'
315
    # template
dobli's avatar
dobli committed
316
    template = get_service_template(Service.FILES.prefix)
317
318
319
320
    # add command that sets base url
    template['command'] = f'-b /{service_name}'
    # only label contraint is building
    template['deploy']['placement']['constraints'][0] = (
dobli's avatar
dobli committed
321
        f"{CONSTRAINTS['building']} == {building}")
322
323
324
325
326
    template['deploy']['labels'].append(f'traefik.backend={service_name}')
    template['deploy']['labels'].extend(
        generate_traefik_path_labels(service_name, segment='main',
                                     redirect=False))

dobli's avatar
dobli committed
327
328
    # attach volumes
    volume_base = '/srv/'
dobli's avatar
dobli committed
329
    template['volumes'] = get_attachable_volume_list(volume_base, building)
dobli's avatar
dobli committed
330

331
332
333
    add_or_update_compose_service(compose_path, service_name, template)


dobli's avatar
dobli committed
334
def add_volumerize_service(building):
dobli's avatar
dobli committed
335
336
    """Generates a volumerize backup entry and adds it to the compose file

dobli's avatar
dobli committed
337
    :building: names of host that the services is added to
dobli's avatar
dobli committed
338
339
    """
    # compose file
dobli's avatar
dobli committed
340
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
dobli's avatar
dobli committed
341
    # service name
dobli's avatar
dobli committed
342
    service_name = f'{Service.BACKUP.prefix}_{building}'
dobli's avatar
dobli committed
343
    # template
dobli's avatar
dobli committed
344
    template = get_service_template(Service.BACKUP.prefix)
dobli's avatar
dobli committed
345
346
347

    # only label contraint is building
    template['deploy']['placement']['constraints'][0] = (
dobli's avatar
dobli committed
348
        f"{CONSTRAINTS['building']} == {building}")
dobli's avatar
dobli committed
349
350
351

    # attach volumes
    volume_base = '/source/'
dobli's avatar
dobli committed
352
    template['volumes'].extend(
dobli's avatar
dobli committed
353
        get_attachable_volume_list(volume_base, building))
dobli's avatar
dobli committed
354

dobli's avatar
dobli committed
355
356
357
358
359
    # adjust config
    config_list = template['configs']
    # get backup entry from configs
    index, entry = next((i, c) for i, c in enumerate(config_list)
                        if c['source'] == 'backup_config')
dobli's avatar
dobli committed
360
    entry['source'] = f'backup_config_{building}'
dobli's avatar
dobli committed
361
362
    template['configs'][index] = entry

dobli's avatar
dobli committed
363
364
365
    add_or_update_compose_service(compose_path, service_name, template)


Dobli's avatar
Dobli committed
366
# Functions to delete services
dobli's avatar
dobli committed
367
def delete_service(service_name):
Dobli's avatar
Dobli committed
368
369
370
371
372
    """Deletes a service from the compose file

    :returns: list of current services
    """
    # compose file
dobli's avatar
dobli committed
373
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
Dobli's avatar
Dobli committed
374
375
376
377
378
379
380
381
382
383
384
385
386
387
    with open(compose_path, 'r+') as compose_f:
        # load compose file
        compose = yaml.load(compose_f)
        # generate list of names
        compose['services'].pop(service_name, None)
        # start writing from file start
        compose_f.seek(0)
        # write new compose content
        yaml.dump(compose, compose_f)
        # reduce file to new size
        compose_f.truncate()


# Functions to extract information
dobli's avatar
dobli committed
388
def get_current_services(placement=None):
dobli's avatar
dobli committed
389
    """Gets a list of currently used services may be restricted to a placement
Dobli's avatar
Dobli committed
390

dobli's avatar
dobli committed
391
    :placement: placement contraint the service shall match
Dobli's avatar
Dobli committed
392
393
394
    :returns: list of current services
    """
    # compose file
dobli's avatar
dobli committed
395
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
Dobli's avatar
Dobli committed
396
397
398
399
    with open(compose_path, 'r') as compose_f:
        # load compose file
        compose = yaml.load(compose_f)
        # generate list of names
dobli's avatar
dobli committed
400
401
402
403
404
        service_names = []
        for (name, entry) in compose['services'].items():
            if placement is None or get_building_of_entry(entry) == placement:
                service_names.append(name)

Dobli's avatar
Dobli committed
405
406
407
        return service_names


dobli's avatar
dobli committed
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
def get_current_building_constraints():
    """Gets a list of currently used building constraints

    :returns: set of current buildings
    """
    # compose file
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
    with open(compose_path, 'r') as compose_f:
        # load compose file
        compose = yaml.load(compose_f)
        # generate list of buildings
        building_names = set()
        for (name, entry) in compose['services'].items():
            building = get_building_of_entry(entry)
            if building:
                building_names.add(building)

        return building_names


dobli's avatar
dobli committed
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
def get_building_of_entry(service_dict):
    """Extract the configured building constraint from an yaml service entry

    :service_dict: service dict from yaml
    :returns: building that is set
    """
    # get constraints
    constraint_list = service_dict['deploy']['placement']['constraints']
    # convert them to dicts
    label_dict = {i.split("==")[0].strip(): i.split("==")[1].strip()
                  for i in constraint_list}
    return label_dict.get('node.labels.building')


def get_service_entry_info(service_entry):
    """Gets service name and instance of a service entry

    :service_entry: service entry name
    :return: tuple with service_name and instance name
    """
    entry_split = service_entry.split("_")
    name = entry_split[0]
    instance = entry_split[1]
    return name, instance


dobli's avatar
dobli committed
454
def get_service_volumes(service_name):
dobli's avatar
dobli committed
455
456
457
458
459
    """Gets a list of volumes of a service

    :returns: list of volumes
    """
    # compose file
dobli's avatar
dobli committed
460
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
dobli's avatar
dobli committed
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
    with open(compose_path, 'r') as compose_f:
        # load compose file
        compose = yaml.load(compose_f)
        # load service
        service = compose['services'].get(service_name)

        # extract volume names
        volume_dict = yaml_list_to_dict(service['volumes'])
        volumes = list(volume_dict.keys())
        # filter only named volumes
        named_volumes = [v for v in volumes if '/' not in v]

        return named_volumes


476
# Helper functions
dobli's avatar
dobli committed
477
478
def get_attachable_volume_list(volume_base, building):
    """Get a list of volumes from a building that can be attatched for file acccess
dobli's avatar
dobli committed
479
480

    :volume_base: Base path of volumes
dobli's avatar
dobli committed
481
    :building: building to consider
dobli's avatar
dobli committed
482
483
484
    :returns: list of attachable volume entries
    """
    volume_list = []
dobli's avatar
dobli committed
485
    host_services = get_current_services(building)
dobli's avatar
dobli committed
486
487
488
    for host_service in host_services:
        name, instance = get_service_entry_info(host_service)
        volume_service = Service.service_by_prefix(name)
dobli's avatar
dobli committed
489
        # only apply to services that want their volumes attatched
dobli's avatar
dobli committed
490
        if volume_service.sftp:
dobli's avatar
dobli committed
491
            volumes = get_service_volumes(host_service)
dobli's avatar
dobli committed
492
493
494
495
            # collect volumes not already in list
            vlist = [
                f'{v}:{volume_base}{v}' for v in volumes
                if f'{v}:{volume_base}{v}' not in volume_list]
dobli's avatar
dobli committed
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
            volume_list.extend(vlist)
    return volume_list


def generate_named_volumes(template_volume_list, service_name, compose_path):
    """Generates volumes including name of services and ads them to
    the compose file

    :template_volume_list: List of volume entries from template
    :service_name: Name of the service instance
    :compose_path: path to compose file
    :returns: list of named entries

    """
    volume_entries = yaml_list_to_dict(template_volume_list)
    # add name to entries (that are named volumes
    named_volume_entries = {}
    for (volume, target) in volume_entries.items():
        if "/" not in volume:
            named_volume_entries[f"{service_name}_{volume}"] = target
        else:
            named_volume_entries[f"{volume}"] = target

    for (volume, target) in named_volume_entries.items():
        # declare volume if it is a named one
        if "/" not in volume:
            add_volume_entry(compose_path, volume)

    return dict_to_yaml_list(named_volume_entries)


def yaml_list_to_dict(yaml_list):
    """Converts a yaml list (volumes, configs etc) into a python dict

    :yaml_list: list of a yaml containing colon separated entries
    :return: python dict
    """
    return {i.split(":")[0]: i.split(":")[1] for i in yaml_list}


def dict_to_yaml_list(pdict):
    """Converts a python dict into a yaml list (volumes, configs etc)

    :pdict: python dict
    :return: list of a yaml containing colon separated entries
    """
    return [f'{k}:{v}' for (k, v) in pdict.items()]


545
546
547
548
549
550
551
552
553
554
555
556
557
558
def get_service_entry(service_name):
    """Gets a service entry from the compose yaml

    :return: yaml entry of a service
    """
    # compose file
    compose_path = f'{custom_path}/{COMPOSE_NAME}'

    with open(compose_path, 'r') as templates_file:
        compose_content = yaml.load(templates_file)

    return compose_content['services'][service_name]


dobli's avatar
dobli committed
559
def get_service_template(service_name):
560
561
562
563
564
565
566
567
568
569
    """Gets a service template entry from the template yaml

    :return: yaml entry of a service
    """
    templates = template_path + '/' + TEMPLATES_NAME

    with open(templates, 'r') as templates_file:
        template_content = yaml.load(templates_file)

    return template_content['services'][service_name]
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589


def generate_traefik_host_labels(hostname, segment=None, priority=1):
    """Generates a traefik path url with necessary redirects

    :hostname: Hostname that gets assigned by the label
    :segment: Optional traefik segment when using multiple rules
    :priority: Priority of frontend rule
    :returns: list of labels for traefik
    """
    label_list = []
    # check segment
    segment = f'.{segment}' if segment is not None else ''
    # fill list
    label_list.append(
        f'traefik{segment}.frontend.rule=HostRegexp:{{domain:{hostname}}}')
    label_list.append(f'traefik{segment}.frontend.priority={priority}')
    return label_list


Dobli's avatar
Dobli committed
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
def generate_traefik_subdomain_labels(subdomain, segment=None, priority=2):
    """Generates a traefik subdomain with necessary redirects

    :subdomain: subdomain that will be assigned to a service
    :segment: Optional traefik segment when using multiple rules
    :priority: Priority of frontend rule
    :returns: list of labels for traefik
    """
    label_list = []
    # check segment
    segment = f'.{segment}' if segment is not None else ''
    # fill list
    label_list.append(
        f'traefik{segment}.frontend.rule='
        f'HostRegexp:{subdomain}.{{domain:[a-zA-z0-9-]+}}')
    label_list.append(f'traefik{segment}.frontend.priority={priority}')
    return label_list


609
610
def generate_traefik_path_labels(url_path, segment=None, priority=2,
                                 redirect=True):
611
612
613
614
615
    """Generates a traefik path url with necessary redirects

    :url_path: path that should be used for the site
    :segment: Optional traefik segment when using multiple rules
    :priority: Priority of frontend rule
616
    :redirect: Redirect to path with trailing slash
617
618
619
620
621
622
623
    :returns: list of labels for traefik
    """
    label_list = []
    # check segment
    segment = f'.{segment}' if segment is not None else ''
    # fill list
    label_list.append(f'traefik{segment}.frontend.priority={priority}')
624
625
626
627
628
629
630
631
632
633
634
    if redirect:
        label_list.append(
            f'traefik{segment}.frontend.redirect.regex=^(.*)/{url_path}$$')
        label_list.append(
            f'traefik{segment}.frontend.redirect.replacement=$$1/{url_path}/')
        label_list.append(
            f'traefik{segment}.frontend.rule=PathPrefix:/{url_path};'
            f'ReplacePathRegex:^/{url_path}/(.*) /$$1')
    else:
        label_list.append(
            f'traefik{segment}.frontend.rule=PathPrefix:/{url_path}')
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
    return label_list


def add_or_update_compose_service(compose_path, service_name, service_content):
    """Adds or replaces a service in a compose file

    :compose_path: path of the compose file to change
    :service_name: name of the service to add/replace
    :service_content: service definition to add
    """
    with open(compose_path, 'r+') as compose_f:
        # load compose file
        compose = yaml.load(compose_f)
        # add / update service with template
        compose['services'][service_name] = service_content
        # write content starting from first line
        compose_f.seek(0)
        # write new compose content
        yaml.dump(compose, compose_f)
        # reduce file to new size
        compose_f.truncate()
dobli's avatar
dobli committed
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674


def add_volume_entry(compose_path, volume_name):
    """Creates an additional volume entry in the stack file

    :compose_path: path of the compose file to change
    :volume_name: name of the additional volume
    """
    with open(compose_path, 'r+') as compose_f:
        # load compose file
        compose = yaml.load(compose_f)
        # add volume
        compose['volumes'][volume_name] = None
        # write content starting from first line
        compose_f.seek(0)
        # write new compose content
        yaml.dump(compose, compose_f)
        # reduce file to new size
        compose_f.truncate()
dobli's avatar
dobli committed
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694


def add_config_entry(compose_path, config_name, config_path):
    """Creates an additional config entry in the stack file or updates it

    :compose_path: path of the compose file to change
    :config_name: name of the additional config
    :config_path: path of the additional config
    """
    with open(compose_path, 'r+') as compose_f:
        # load compose file
        compose = yaml.load(compose_f)
        # add config
        compose['configs'][config_name] = {"file": config_path}
        # write content starting from first line
        compose_f.seek(0)
        # write new compose content
        yaml.dump(compose, compose_f)
        # reduce file to new size
        compose_f.truncate()
695
# >>>
dobli's avatar
dobli committed
696

697

698
# ******************************
699
# Config file functions <<<
700
# ******************************
dobli's avatar
dobli committed
701
def generate_config_folders():
702
703
    """Generate folders for configuration files
    """
dobli's avatar
dobli committed
704
705
    if not os.path.exists(custom_path):
        os.makedirs(custom_path)
706

dobli's avatar
dobli committed
707
    print(f'Initialize configuration in {custom_path}')
708

709
    # generate empty config dirs
710
    for d in CONFIG_DIRS:
dobli's avatar
dobli committed
711
        new_dir = f'{custom_path}/{d}'
712
713
714
        if not os.path.exists(new_dir):
            os.makedirs(new_dir)

715
716
    # copy template configs
    for template_file in TEMPLATE_FILES:
dobli's avatar
dobli committed
717
        copy_template_config(template_file)
718

719

dobli's avatar
dobli committed
720
def copy_template_config(config_path):
721
722
723
724
    """Copies template configuration files into custom folder

    :config_path: relative path of config to copy from template
    """
dobli's avatar
dobli committed
725
    custom_config_path = f'{custom_path}/{config_path}'
dobli's avatar
dobli committed
726
    template_config = f"{template_path}/{config_path}"
Dobli's avatar
Dobli committed
727

dobli's avatar
dobli committed
728
729
730
    logging.info(
        f'Copy {config_path} from {template_config} to {custom_path}')
    copy2(template_config, custom_config_path)
731
732


733
734
735
736
737
738
def generate_mosquitto_user_line(username, password):
    """Generates a line for a mosquitto user with a crypt hashed password

    :username: username to use
    :password: password that will be hashed (SHA512)

739
    :returns: a line as expected by mosquitto
740
741
742
743
744
745
    """
    password_hash = crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512))
    line = f"{username}:{password_hash}"
    return line


746
747
748
749
def generate_sftp_user_line(username, password, directories=None):
    """Generates a line for a sftp user with a hashed password

    :username: username to use
750
    :password: password that will be hashed (SHA512)
751
752
    :directories: list of directories which the user should have

753
    :returns: a line as expected by sshd
754
755
756
    """
    # generate user line with hashed password
    password_hash = crypt.crypt(password, crypt.mksalt(crypt.METHOD_SHA512))
757
    line = f"{username}:{password_hash}:e:{UID}:{UID}"
758
759
760
761
762
763
764
765
    # add directory entries when available
    if directories:
        # create comma separated string from list
        dir_line = ','.join(d for d in directories)
        line = f"{line}:{dir_line}"
    return line


766
767
768
769
770
771
772
773
def generate_traefik_user_line(username, password):
    """Generates a line for a traefik user with a bcrypt hashed password

    :username: username to use
    :password: password that will be hashed (bcrypt)

    :returns: a line as expected by traefik
    """
Dobli's avatar
Dobli committed
774
775
    password_hash = get_bcrypt_hash(password)
    line = f"{username}:{password_hash}"
776
777
778
    return line


dobli's avatar
dobli committed
779
def generate_pb_framr_entry(building, host, service):
dobli's avatar
dobli committed
780
781
    """Generates a single entry of the framr file

dobli's avatar
dobli committed
782
    :building: building this entry is intended for
dobli's avatar
dobli committed
783
784
785
786
787
788
789
    :host: host this entry is intended for
    :service: entry from service enum
    :returns: a dict fitting the asked entry

    """
    entry = {}
    entry['title'] = service.fullname
790
    if service == Service.OPENHAB:
dobli's avatar
dobli committed
791
792
793
        entry['url'] = f'http://{host}/'
        pass
    else:
dobli's avatar
dobli committed
794
        entry['url'] = f'/{service.prefix}_{building}/'
dobli's avatar
dobli committed
795
796
797
798
    entry['icon'] = service.icon
    return entry


dobli's avatar
dobli committed
799
def generate_mosquitto_file(username, password):
800
801
802
803
804
    """Generates a mosquitto password file using mosquitto_passwd system tool

    :username: username to use
    :password: password that will be used
    """
dobli's avatar
dobli committed
805
    passwd_path = f"{custom_path}/{EDIT_FILES['mosquitto_passwords']}"
806
807
808
809
810
811
812
813

    # ensure file exists
    if not os.path.exists(passwd_path):
        open(passwd_path, 'a').close()

    # execute mosquitto passwd
    mos_result = run(
        ['mosquitto_passwd', '-b', passwd_path, username, password],
814
        universal_newlines=True)
815
816
817
    return mos_result.returncode == 0


dobli's avatar
dobli committed
818
def generate_sftp_file(username, password, direcories=None):
819
    """Generates a sftp password file
820
821
822
823
824
825
826

    :username: username to use
    :password: password that will be used
    :directories: list of directories which the user should have
    """
    # generate line and save it into a file
    file_content = generate_sftp_user_line(username, password, direcories)
dobli's avatar
dobli committed
827
    create_or_replace_config_file(EDIT_FILES['sftp_users'], file_content)
828
829


dobli's avatar
dobli committed
830
def generate_postgres_files(username, password):
dobli's avatar
dobli committed
831
832
833
834
835
836
    """Generates postgres user and password files

    :username: username to use
    :password: password that will be used
    """
    # content is purely username and (hashed) password
dobli's avatar
dobli committed
837
838
839
840
    hashed_pass = (
        f'md5{md5(username.encode() + password.encode()).hexdigest()}')
    create_or_replace_config_file(EDIT_FILES['postgres_user'], username)
    create_or_replace_config_file(EDIT_FILES['postgres_passwd'], hashed_pass)
dobli's avatar
dobli committed
841
842


dobli's avatar
dobli committed
843
def generate_id_rsa_files():
844
845
    """Generates id_rsa and id_rsa.pub private/public keys using ssh-keygen
    """
dobli's avatar
dobli committed
846
    id_path = f"{custom_path}/{EDIT_FILES['id_rsa']}"
847
848
849

    # execute ssh-keygen
    id_result = run(
dobli's avatar
dobli committed
850
851
        ['ssh-keygen', '-m', 'PEM', '-t', 'rsa',
            '-b', '4096', '-f', id_path, '-N', ''],
852
        universal_newlines=True, stdout=PIPE)
853
854
855
    return id_result.returncode == 0


dobli's avatar
dobli committed
856
def generate_host_key_files(hosts):
857
858
    """Generates ssh host keys and matching known_hosts using ssh-keygen
    """
dobli's avatar
dobli committed
859
    key_path = f"{custom_path}/{EDIT_FILES['host_key']}"
860
861
    # ssh-keygen generates public key with .pub postfix
    pub_path = key_path + '.pub'
862
863
    # host_names with sftp_ postfix
    sftp_hosts = [f'sftp_{host}' for host in hosts]
864
865
866

    # execute ssh-keygen
    id_result = run(['ssh-keygen', '-t', 'ed25519', '-f', key_path, '-N', ''],
867
                    universal_newlines=True, stdout=PIPE)
868
869
870
871
872
873
874
875

    # read content of public key as known line
    known_line = ""
    with open(pub_path, 'r') as pub_file:
        pub_line = pub_file.readline()
        split_line = pub_line.split()
        # delete last list element
        del split_line[-1]
876
877
        # collect sftp hosts as comma separated string
        hosts_line = ','.join(h for h in sftp_hosts)
878
879
880
881
882
        split_line.insert(0, hosts_line)
        # collect parts as space separated string
        known_line = ' '.join(sp for sp in split_line)

    # write new known_line file
dobli's avatar
dobli committed
883
    create_or_replace_config_file(EDIT_FILES['known_hosts'], known_line)
884
885
886
887

    return id_result.returncode == 0


dobli's avatar
dobli committed
888
def generate_filebrowser_file(username, password):
Dobli's avatar
Dobli committed
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
    """Generates a configuration for the filebrowser web app

    :username: username to use
    :password: password that will be used
    """
    # generate line and save it into a file
    file_content = {
        "port": "80",
        "address": "",
        "username": f"{username}",
        "password": f"{get_bcrypt_hash(password)}",
        "log": "stdout",
        "root": "/srv"
    }

dobli's avatar
dobli committed
904
    create_or_replace_config_file(EDIT_FILES['filebrowser_conf'],
Dobli's avatar
Dobli committed
905
906
907
                                  file_content, json=True)


dobli's avatar
dobli committed
908
def generate_traefik_file(username, password):
909
910
911
912
913
914
915
    """Generates a traefik password file

    :username: username to use
    :password: password that will be used
    """
    # generate line and save it into a file
    file_content = generate_traefik_user_line(username, password)
dobli's avatar
dobli committed
916
    create_or_replace_config_file(EDIT_FILES['traefik_users'], file_content)
917
918


dobli's avatar
dobli committed
919
def generate_volumerize_files(host_entries):
920
921
    """Generates config for volumerize backups

dobli's avatar
dobli committed
922
    :host_entries: dickt of host entries
923
    """
dobli's avatar
dobli committed
924
    compose_path = f'{custom_path}/{COMPOSE_NAME}'
dobli's avatar
dobli committed
925
    # create one config per host
dobli's avatar
dobli committed
926
    for h in host_entries:
dobli's avatar
dobli committed
927
928
        configs = []
        # Each host knows other hosts
dobli's avatar
dobli committed
929
        for t in host_entries:
dobli's avatar
dobli committed
930
            host_config = {
dobli's avatar
dobli committed
931
932
933
                'description': f"'Backup Server on {t['building_name']}",
                'url': f"sftp://ohadmin@sftp_{t['building_id']}:"
                f"//home/ohadmin/backup_data/backup/{h['building_id']}"
dobli's avatar
dobli committed
934
935
936
            }
            configs.append(host_config)

dobli's avatar
dobli committed
937
        config_file = f"{EDIT_FILES['backup_config']}_{h['building_id']}.json"
dobli's avatar
dobli committed
938
        create_or_replace_config_file(config_file, configs, json=True)
dobli's avatar
dobli committed
939
        add_config_entry(
dobli's avatar
dobli committed
940
941
942
            compose_path,
            f"backup_config_{h['building_id']}",
            f"./{config_file}")
943
944


dobli's avatar
dobli committed
945
def generate_pb_framr_file(frames):
dobli's avatar
dobli committed
946
947
948
949
950
951
952
953
    """Generates config for pb framr landing page

    :frames: a dict that contains hosts with matching name and services
    """
    configs = []

    for f in frames:
        building = {
dobli's avatar
dobli committed
954
955
            'instance': f['building_name'],
            'entries': [generate_pb_framr_entry(f['building_id'], f['host'], s)
dobli's avatar
dobli committed
956
957
958
959
960
                        for s in f['services'] if s.frontend]
        }
        configs.append(building)

    create_or_replace_config_file(
dobli's avatar
dobli committed
961
        EDIT_FILES['pb_framr_pages'], configs, json=True)
dobli's avatar
dobli committed
962
963


dobli's avatar
dobli committed
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
def update_pb_framr_host(old_host, new_host):
    """Updates framr config to use changed host name

    :old_host: old host that shall be replaced
    :new_host: host that will be the new target
    """
    configs = []

    config_path = EDIT_FILES['pb_framr_pages']
    custom_config_path = f'{custom_path}/{config_path}'
    with open(custom_config_path, 'r') as file:
        configs = pyjson.load(file)
        for c in configs:
            for e in c['entries']:
                if e['url'] == f"http://{old_host}/":
                    e['url'] = f"http://{new_host}/"

    if configs:
        create_or_replace_config_file(
            EDIT_FILES['pb_framr_pages'], configs, json=True)


dobli's avatar
dobli committed
986
def create_or_replace_config_file(config_path, content, json=False):
987
988
989
990
991
    """Creates or replaces a config file with new content

    :config_path: relative path of config
    :content: content of the file as a string
    """
dobli's avatar
dobli committed
992
993
    custom_config_path = f'{custom_path}/{config_path}'
    with open(custom_config_path, 'w+') as file:
994
995
996
997
998
        if json:
            import json
            json.dump(content, file, indent=2)
        else:
            file.write(content)
Dobli's avatar
Dobli committed
999
1000
1001


# Functions to modify existing files
dobli's avatar
dobli committed
1002
def add_user_to_traefik_file(username, password):
Dobli's avatar
Dobli committed
1003
1004
1005
1006
1007
    """Adds or modifies user in traefik file

    :username: username to use
    :password: password that will be used
    """
Dobli's avatar
Dobli committed
1008
    # get current users
dobli's avatar
dobli committed
1009
    current_users = get_traefik_users()
Dobli's avatar
Dobli committed
1010
    # ensure to delete old entry if user exists
Dobli's avatar
Dobli committed
1011
    users = [u for u in current_users if u['username'] != username]
Dobli's avatar
Dobli committed
1012
1013
1014
1015
1016
1017
1018
1019
    # collect existing users lines
    user_lines = []
    for u in users:
        user_lines.append(f"{u['username']}:{u['password']}")
    # add new/modified user
    user_lines.append(generate_traefik_user_line(username, password))
    # generate content
    file_content = "\n".join(user_lines)
dobli's avatar
dobli committed
1020
    create_or_replace_config_file(EDIT_FILES['traefik_users'], file_content)
Dobli's avatar
Dobli committed
1021
1022


dobli's avatar
dobli committed
1023
def remove_user_from_traefik_file(username):
Dobli's avatar
Dobli committed
1024
1025
1026
1027
1028
    """Removes user from traefik file

    :username: username to delete
    """
    # get current users
dobli's avatar
dobli committed
1029
    current_users = get_traefik_users()
Dobli's avatar
Dobli committed
1030
1031
1032
1033
1034
1035
1036
1037
    # ensure to delete entry if user exists
    users = [u for u in current_users if u['username'] != username]
    # collect other user lines
    user_lines = []
    for u in users:
        user_lines.append(f"{u['username']}:{u['password']}")
    # generate content and write file
    file_content = "\n".join(user_lines)
dobli's avatar
dobli committed
1038
    create_or_replace_config_file(EDIT_FILES['traefik_users'], file_content)
Dobli's avatar
Dobli committed
1039
1040


Dobli's avatar
Dobli committed
1041
# Functions to get content from files
dobli's avatar
dobli committed
1042
def get_users_from_files():
Dobli's avatar
Dobli committed
1043
1044
1045
1046
1047
1048
1049
    """Gets a list of users in files

    :returns: list of users
    """
    users = []

    # add treafik users
dobli's avatar
dobli committed
1050
    users.extend([u['username'] for u in get_traefik_users()])
Dobli's avatar
Dobli committed
1051
1052
1053
1054

    return users


dobli's avatar
dobli committed
1055
def get_traefik_users():
Dobli's avatar
Dobli committed
1056
1057
1058
1059
1060
1061
1062
    """Gets a list of dicts containing users and password hashes

    :returns: list of users / password dicts
    """
    users = []

    # get treafik users
dobli's avatar
dobli committed
1063
    traefik_file = f"{custom_path}/{EDIT_FILES['traefik_users']}"
Dobli's avatar
Dobli committed
1064
1065
1066
1067
1068
1069
1070
1071
    with open(traefik_file, 'r') as file:
        lines = file.read().splitlines()
        for line in lines:
            # username in traefik file is first entry unitl colon
            username = line.split(':')[0]
            password = line.split(':')[1]
            users.append({"username": username, "password": password})
    return users
Dobli's avatar
Dobli committed
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083


# Additional helper functions
def get_bcrypt_hash(password):
    """Returns bcrypt hash for a password

    :password: password to hash
    :returns: bcrypt hash of password

    """
    return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()

1084
# >>>
dobli's avatar
dobli committed
1085
1086


1087
# ******************************
1088
# Docker machine functions <<<
1089
# ******************************
1090
1091
1092
1093
1094
1095
def get_machine_list():
    """Get a list of docker machine names using the docker-machine system command

    :returns: a list of machine names managed by docker-machine
    """
    machine_result = run(['docker-machine', 'ls', '-q'],
1096
1097
                         universal_newlines=True,
                         stdout=PIPE)
1098
1099
1100
1101
1102
1103
1104
    return machine_result.stdout.splitlines()


def check_machine_exists(machine_name):
    """Checks weather a docker machine exists and is available

    :machine_name: Name of the machine to check
dobli's avatar
dobli committed
1105
    :returns: True when machine is available
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
    """
    machines = get_machine_list()

    return machine_name in machines


def get_machine_env(machine_name):
    """Gets dict of env settings from a machine

    :machine_name: Name of the machine to check
    :returns: Dict of env variables for this machine
    """
    env_result = run(['docker-machine', 'env', machine_name],
1119
1120
                     universal_newlines=True,
                     stdout=PIPE)
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132

    machine_envs = {}

    lines = env_result.stdout.splitlines()
    for line in lines:
        if 'export' in line:
            assign = line.split('export ', 1)[1]
            env_entry = [a.strip('"') for a in assign.split('=', 1)]
            machine_envs[env_entry[0]] = env_entry[1]
    return machine_envs


dobli's avatar
dobli committed
1133
1134
1135
1136
1137
1138
def get_machine_ip(machine_name):
    """Asks for the ip of the docker machine

    :machine_name: Name of the machine to use for init
    """
    machine_result = run(['docker-machine', 'ip', machine_name],
1139
1140
                         universal_newlines=True,
                         stdout=PIPE)
1141
    return machine_result.stdout.strip()
dobli's avatar
dobli committed
1142
1143
1144
1145
1146
1147


def init_swarm_machine(machine_name):
    """Creates a new swarm with the specified machine as leader

    :machine_name: Name of the machine to use for init
1148
    :return: True if swarm init was successful
dobli's avatar
dobli committed
1149
1150
1151
    """
    machine_ip = get_machine_ip(machine_name)
    init_command = 'docker swarm init --advertise-addr ' + machine_ip
1152
    init_result = run(['docker-machine', 'ssh', machine_name, init_command],
1153
                      universal_newlines=True)
1154
    return init_result.returncode == 0
dobli's avatar
dobli committed
1155
1156
1157
1158
1159
1160
1161


def join_swarm_machine(machine_name, leader_name):
    """Joins the swarm of the specified leader

    :machine_name: Name of the machine to join a swarm
    :leader_name: Name of the swarm leader machine
1162
    :return: True if join to swarm was successful
dobli's avatar
dobli committed
1163
1164
1165
    """
    token_command = 'docker swarm join-token manager -q'
    token_result = run(['docker-machine', 'ssh', leader_name, token_command],
1166
1167
                       universal_newlines=True,
                       stdout=PIPE)
1168
    token = token_result.stdout.strip()
dobli's avatar
dobli committed
1169
    leader_ip = get_machine_ip(leader_name)
1170
    logging.info(f"Swarm leader with ip {leader_ip} uses token {token}")
dobli's avatar
dobli committed
1171

1172
1173
1174
    join_cmd = f'docker swarm join --token {token} {leader_ip}:{SWARM_PORT}'
    logging.info(f'Machine {machine_name} joins using command {join_cmd}')
    join_result = run(['docker-machine', 'ssh', machine_name, join_cmd],
1175
                      universal_newlines=True)
dobli's avatar
dobli committed
1176

1177
    return join_result.returncode == 0
dobli's avatar
dobli committed
1178
1179


1180
1181
1182
1183
1184
1185
def generate_swarm(machines):
    """Generates a swarm, the first machine will be the initial leader

    :machines: list of machines in the swarm
    """
    leader = None
1186
    for machine in machines:
1187
1188
1189
1190
1191
1192
        # init swarm with first machine
        if leader is None:
            leader = machine
            print(f'Create initial swarm with leader {leader}')
            if init_swarm_machine(leader):
                print('Swarm init successful\n')
1193
1194
                assign_label_to_node(leader, 'building',
                                     leader, manager=leader)
1195
1196
1197
1198
        else:
            print(f'Machine {machine} joins swarm of leader {leader}')
            if (join_swarm_machine(machine, leader)):
                print('Joining swarm successful\n')
1199
1200
                assign_label_to_node(machine, 'building',
                                     machine, manager=leader)
Dobli's avatar
Dobli committed
1201
1202


1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
def check_dir_on_machine(dirpath, machine):
    """Checks weather a dir exists on a machine

    :dirpath: Directory to check
    :machine: Machine to check
    :returns: True when dir exists false otherwise
    """
    check_command = f"[ -d {dirpath} ]"
    check_result = run(['docker-machine', 'ssh', machine, check_command])
    return check_result.returncode == 0


def check_file_on_machine(filepath, machine):
    """Checks weather a file exists on a machine

    :filepath: File to check
    :machine: Machine to check
    :returns: True when file exists false otherwise
    """
    check_command = f"[ -f {filepath} ]"
    check_result = run(['docker-machine', 'ssh', machine, check_command])
    return check_result.returncode == 0


def copy_files_to_machine(filepath, machine):
    """Copyies a directory and its content or a file to a machine

    :filepath: Direcotry or file to copy
    :machine: Machine to copy to
    """
    run(['docker-machine', 'scp', '-r', filepath, f'{machine}:'])


def execute_command_on_machine(command, machine):
    """Executes a command on a docker machine

    :command: Command to execute
    :machine: Machine to execute command
    """
    run([f'docker-machine ssh {machine} {command}'], shell=True)
1243
# >>>
dobli's avatar
dobli committed
1244

dobli's avatar
dobli committed
1245

dobli's avatar
dobli committed
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
# ******************************
# Systemd functions <<<
# ******************************
def list_enabled_devices():
    """Presents a list of enabled devices (systemd services)
    :returns: list of enabled devices

    """
    list_result = run(['systemctl', 'list-units'],
                      stdout=PIPE, universal_newlines=True)
    device_list = list_result.stdout.splitlines()
    # Filter out only swarm-device services
    device_list = [d.strip() for d in device_list if 'swarm-device' in d]
    # Extract service name
    device_list = [d.split()[0] for d in device_list]
    return device_list
# >>>

dobli's avatar
dobli committed
1264

1265
# ******************************
1266
# Docker client commands <<<
1267
# ******************************
dobli's avatar
dobli committed
1268
1269
def deploy_docker_stack(machine):
    """Deploys the custom stack in the custom_path
dobli's avatar
dobli committed
1270
1271
1272
1273
1274
1275
1276
1277
1278

    :machine: Docker machine to execute command
    """
    # Set CLI environment to target docker machine
    machine_env = get_machine_env(machine)
    os_env = os.environ.copy()
    os_env.update(machine_env)

    # Get compose file and start stack
dobli's avatar
dobli committed
1279
    compose_file = f'{custom_path}/{COMPOSE_NAME}'
dobli's avatar
dobli committed
1280
1281
1282
1283
1284
    deploy_command = f'docker stack deploy -c {compose_file} ohpb'
    run([f'{deploy_command}'], shell=True, env=os_env)


def remove_docker_stack(machine):
dobli's avatar
dobli committed
1285
    """Removes the custom stack in the custom_path
dobli's avatar
dobli committed
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297

    :machine: Docker machine to execute command
    """
    # Set CLI environment to target docker machine
    machine_env = get_machine_env(machine)
    os_env = os.environ.copy()
    os_env.update(machine_env)

    remove_command = f'docker stack rm ohpb'
    run([f'{remove_command}'], shell=True, env=os_env)


Dobli's avatar
Dobli committed
1298
def resolve_service_nodes(service):
dobli's avatar
dobli committed
1299
    """Returnes nodes running a specified service
Dobli's avatar
Dobli committed
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331

    :service: name or id of a service
    :returns: list of nodes running the service
    """
    node_result = run(['docker', 'service', 'ps', service,
                       '--format', '{{.Node}}',
                       '-f', 'desired-state=running'],
                      universal_newlines=True,
                      stdout=PIPE)
    return node_result.stdout.splitlines()


def get_container_list(manager=None):
    """Return a list of containers running on a machine

    :manager: Docker machine to use for command, otherwise local
    :returns: list of containers
    """
    client = get_docker_client(manager)
    return [c.name for c in client.containers.list()]


def get_service_list(manager=None):
    """Return a list of services managed by a machine

    :manager: Docker machine to use for command, otherwise local
    :returns: list of services
    """
    client = get_docker_client(manager)
    return [s.name for s in client.services.list()]


dobli's avatar
dobli committed
1332
1333
1334
1335
1336
1337
def remove_label_from_nodes(label, value, manager=None):
    """Removes label with matching value from all nodes

    :label: Label you want to remove
    :value: The value to match before removing
    :manager: Docker machine to use for command, otherwise local
dobli's avatar
dobli committed
1338
    :return: Nodes with removed label
dobli's avatar
dobli committed
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
    """
    client = get_docker_client(manager)

    nodes = client.nodes.list()
    matching_nodes = [n for n in nodes
                      if label in n.attrs['Spec']['Labels']
                      and n.attrs['Spec']['Labels'][label] == value]
    print(f'Matches {matching_nodes}')
    for m in matching_nodes:
        spec = m.attrs['Spec']
        spec['Labels'].pop(label)
        m.update(spec)
        logging.info(f'Remove label {label} with value {value} from {m}')

    client.close()
dobli's avatar
dobli committed
1354
    return [n.attrs['Description']['Hostname'] for n in matching_nodes]
dobli's avatar
dobli committed
1355
1356


1357
def assign_label_to_node(nodeid, label, value, manager=None):
1358
1359
1360
1361
1362
    """Assigns a label to a node (e.g. building)

    :nodeid: Id or name of the node
    :label: Label you want to add
    :value: The value to assign to the label
Dobli's avatar
Dobli committed
1363
    :manager: Docker machine to use for command, otherwise local
1364
    """
Dobli's avatar
Dobli committed
1365
    client = get_docker_client(manager)
1366
1367
1368
1369
1370

    node = client.nodes.get(nodeid)
    spec = node.attrs['Spec']
    spec['Labels'][label] = value
    node.update(spec)
1371
    logging.info(f'Assign label {label} with value {value} to {nodeid}')
1372
1373
1374
1375

    client.close()


1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
def run_command_in_service(service, command, building=None):
    """Runs a command in a service based on its name.
    When no matching container is found or the service name is ambigous
    an error will be displayed and the function exits

    :param service: Name of the service to execute command
    :param command: Command to execute
    :param building: Optional building, make service unambigous (Default: None)
    """

Dobli's avatar
Dobli committed
1386
    client = get_docker_client(building)
1387
1388
1389
1390
1391
1392
1393

    # Find containers matching name
    service_name_filter = {"name": service}
    containers = client.containers.list(filters=service_name_filter)

    # Ensure match is unambigous
    if (len(containers) > 1):
1394
        print(f'Found multiple containers matching service name {service}, '
1395
1396
              'ensure service is unambigous')
    elif (len(containers) < 1):
1397
        print(f'Found no matching container for service name {service}')
1398
1399
    else:
        service_container = containers[0]
1400
        print(f'Executing {command} in container {service_container.name}'
Dobli's avatar
Dobli committed
1401
              f'({service_container.id}) on building {building}\n')
dobli's avatar
dobli committed
1402
1403
        command_exec = service_container.exec_run(command)
        print(command_exec.output.decode())
1404
    client.close()
1405
1406


Dobli's avatar
Dobli committed
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
def get_docker_client(manager=None):
    """Returns docker client instance

    :manager: Optional machine to use, local otherwise
    :returns: Docker client instance
    """
    if manager:
        machine_env = get_machine_env(manager)
        client = docker.from_env(environment=machine_env)
    else:
        client = docker.from_env()
    return client
dobli's avatar
dobli committed
1419
1420


dobli's avatar
dobli committed
1421
def restore_building_backup(manager, building, new_machine=None):
dobli's avatar
dobli committed
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
    client = get_docker_client(manager)
    # get backup services of the building
    services = client.services.list(filters={'label': f'backup={building}'})

    # scale down services (to prevent writes during restore)
    for s in services:
        s.scale(0)

    # Give services 10 seconds to shutdown
    print("Wait for services to shutdown...")
    sleep(10)

dobli's avatar
dobli committed
1434
1435
    # When a new machine is used, (un-)assign labels
    if new_machine:
1436
        # Remove old node labels and add new
dobli's avatar
dobli committed
1437
        old_nodes = remove_label_from_nodes('building', building, manager)
dobli's avatar
dobli committed
1438
1439
        assign_label_to_node(new_machine, 'building', building, manager)
        print("Wait for services to start on new machine")
dobli's avatar
dobli committed
1440
1441
        if wait_for_containers(new_machine, 'backup|sftp', expected_count=2):
            run_command_in_service('backup', 'restore', new_machine)
1442
1443
            # When building was moved update host entry of openhab in compose
            move_openhab_service(building, new_machine)
dobli's avatar
dobli committed
1444
            update_pb_framr_host(old_nodes[0], new_machine)
dobli's avatar
dobli committed
1445
        else:
1446
1447
1448
            logging.error(
                f"Failed to start services on {new_machine}, "
                " rolling back changes")
dobli's avatar
dobli committed
1449
1450
1451
1452
            # restore labels to old nodes
            remove_label_from_nodes('building', building, manager)
            for on in old_nodes:
                assign_label_to_node(on, 'building', building, manager)
dobli's avatar
dobli committed
1453
                update_pb_framr_host(new_machine, on)
dobli's avatar
dobli committed
1454
1455
1456
    else:
        # execute restore command in backup service
        run_command_in_service('backup', 'restore', manager)
dobli's avatar
dobli committed
1457
1458
1459
1460
1461

    # reload and scale up services again
    for s in services:
        s.reload()
        s.scale(1)
dobli's avatar
dobli committed
1462
1463
1464

    # close client
    client.close()
dobli's avatar
dobli committed
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488


def wait_for_containers(machine, name_filter, expected_count=1, timeout=60):
    """Waits until containers matching filters are available

    :machine: machine to check for container
    :name_filter: regexp to filter names by
    :expected_count: number of services that are expected to match
    :timeout: Time to at least wait for before abborting check
    :returns: true if found, false when timed out
    """
    client = get_docker_client(machine)
    for t in range(timeout):
        cl = client.containers.list(filters={'name': name_filter})
        if len(cl) >= expected_count:
            logging.info("Let serivces boot up")
            sleep(3)
            return True
        else:
            sleep(1)
    logging.error(f"Timed out wait for containers matching {name_filter}.")
    return False


1489
# >>>
dobli's avatar
dobli committed
1490
1491


1492
# ******************************
1493
# CLI base commands <<<
1494
# ******************************
1495
1496
1497
1498
1499
def init_config_dirs_command(args):
    """Initialize config directories

    :args: parsed commandline arguments
    """
Dobli's avatar
Dobli committed
1500
    # generate basic config folder
dobli's avatar
dobli committed
1501
    generate_config_folders()
1502
1503


1504
1505
1506
1507
1508
1509
1510
1511
def assign_building_command(args):
    """Assigns the role of a building to a node

    :args: parsed commandline arguments
    """
    node = args.node
    building = args.building

1512
    print(f'Assign role of building {building} to node {node}')
1513
1514
1515
1516

    assign_label_to_node(node, 'building', building)


1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
def execute_command(args):
    """Top level function to manage command executions from CLI

    :args: parsed commandline arguments
    """
    service = args.service
    command = " ".join(str(x) for x in args.command)  # list to string
    building = args.building

    run_command_in_service(service, command, building)


def restore_command(args):
    """Top level function to manage command executions from CLI

    :args: parsed commandline arguments
    """
1534
1535
1536
1537
    building = args.building
    target = args.target

    if not check_machine_exists(target):
1538
        print(f'Machine with name {target} not found')
1539
1540
        return

1541
    print(f'Restoring building {building} on machine {target}')
1542
1543

    get_machine_env(target)
1544
1545


1546
1547
1548
def interactive_command(args):
    """Top level function to start the interactive mode

1549
    :args: parsed command line arguments
1550
    """
Dobli's avatar
Dobli committed
1551
    main_menu(args)
1552
1553


1554
# >>>
dobli's avatar
dobli committed
1555
1556


1557
# ******************************
1558
# Interactive menu entries <<<
1559
# ******************************
1560
def main_menu(args):
1561
1562
    """ Display main menu
    """
Dobli's avatar
Dobli committed
1563
    # Main menu prompts selection contains function
1564
    choice = qust.select('Public Building Manager - Main Menu',
dobli's avatar
dobli committed
1565
                         choices=load_main_entires(), style=st).ask()
1566

Dobli's avatar
Dobli committed
1567
    # Call funtion of menu entry
1568
1569
    if choice:
        choice(args)
1570
1571


dobli's avatar
dobli committed
1572
def load_main_entires():
Dobli's avatar
Dobli committed
1573
1574
1575
1576
1577
1578
1579
    """Loads entries for main menu depending on available files

    :returns: entries of main menu
    """

    entries = []
    if not os.path.exists(custom_path):
Dobli's avatar
Dobli committed
1580
1581
        entries.append({'name': 'Create initial structure',
                        'value': init_menu})
Dobli's avatar
Dobli committed
1582
    else:
Dobli's avatar
Dobli committed
1583
1584
1585
1586
        entries.append({'name': 'Manage Services',
                        'value': service_menu})
        entries.append({'name': 'Manage Users',
                        'value': user_menu})
1587
1588
        entries.append({'name': 'Manage Devices',
                        'value': device_menu})
dobli's avatar
dobli committed
1589
1590
        entries.append({'name': 'Manage Backups',
                        'value': backup_menu})
Dobli's avatar
Dobli committed
1591
1592
        entries.append({'name': 'Execute a command in a service container',
                        'value': exec_menu})
Dobli's avatar
Dobli committed
1593

Dobli's avatar
Dobli committed
1594
    entries.append({'name': 'Exit', 'value': sys.exit})
Dobli's avatar
Dobli committed
1595
1596
1597
1598

    return entries


Dobli's avatar
Dobli committed
1599
1600
1601
1602
1603
1604
def exit_menu(args):
    """Exits the programm
    """
    sys.exit()


Dobli's avatar
Dobli committed
1605
# *** Init Menu Entries ***
1606
def init_menu(args):
1607
    """Menu entry for initial setup and file generation
Dobli's avatar
Dobli committed
1608
1609

    :args: Passed commandline arguments
1610
    """
1611
    # Prompts
1612
    stack_name = qust.text('Choose a name for your setup', style=st).ask()
dobli's avatar
dobli committed
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
    hosts = (qust.checkbox(
        'What docker machines will be used?',
        choices=generate_cb_choices(get_machine_list()),
        style=st)
        .skip_if(not stack_name)
        .ask())

    # Cancel init if no hosts selected
    if not hosts:
        return
1623
1624
1625
    # Ensure passwords match
    password_match = False
    while not password_match:
1626
1627
1628
1629
1630
        password = qust.password(
            'Choose a password for the ohadmin user:', style=st).ask()
        confirm = qust.password(
            'Repeat password for the ohadmin user:', style=st).ask()
        if password == confirm:
1631
            password_match = True
dobli's avatar
dobli committed
1632
        else:
1633
            print("Passwords did not match, try again")
1634

1635
    # Initialize custom configuration dirs and templates
dobli's avatar
dobli committed
1636
1637
    generate_config_folders()
    generate_initial_compose()
1638

dobli's avatar
dobli committed
1639
    frames = []
1640
    for i, host in enumerate(hosts):
dobli's avatar
dobli committed
1641
        building_id, building_name, services = init_machine_menu(host, i)
dobli's avatar
dobli committed
1642
1643
1644
1645
1646
1647
1648
        if building_id and building_name and services:
            frames.append({'host': host,
                           'building_id': building_id,
                           'building_name': building_name,
                           'services': services})
        else:
            return
dobli's avatar
dobli committed
1649
1650
1651

    # When frames is not empty generate frame config
    if frames:
dobli's avatar
dobli committed
1652
        generate_pb_framr_file(frames)
dobli's avatar
dobli committed
1653
1654
1655
        generate_volumerize_files(frames)
        building_ids = [f['building_id'] for f in frames]
        generate_host_key_files(building_ids)
dobli's avatar
dobli committed
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
        # Generate config files based on input
        username = ADMIN_USER
        generate_sftp_file(username, password, ['backup_data/backup'])
        generate_postgres_files(username, password)
        generate_mosquitto_file(username, password)
        generate_traefik_file(username, password)
        generate_filebrowser_file(username, password)
        generate_id_rsa_files()

        # print(answers)
        print(f"Configuration files for {stack_name} created in {custom_path}")

        # Check if changes shall be applied to docker environment
        generate = (qust.confirm(
            'Apply changes to docker environment?',
            default=True,
            style=st)
            .ask())
1674

dobli's avatar
dobli committed
1675
1676
        if generate:
            generate_swarm(hosts)
1677
1678


dobli's avatar
dobli committed
1679
def init_machine_menu(host, increment):
1680
1681
1682
1683
    """Prompts to select server services

    :host: docker-machine host
    :increment: incrementing number to ensure ports are unique
dobli's avatar
dobli committed
1684
    :return: choosen building id, name and services or None if canceld
1685
    """
dobli's avatar
dobli committed
1686
1687
    # Print divider
    print('----------')
1688
    # Prompt for services
dobli's avatar
dobli committed
1689
    building_id = (qust.text(
dobli's avatar
dobli committed
1690
1691
        f'Choose an identifier for the building on server {host} '
        '(lowercase no space)',
dobli's avatar
dobli committed
1692
1693
1694
1695
1696
        default=f'{host}', style=st)
        .skip_if(not host)
        .ask())

    building = (qust.text(
dobli's avatar
dobli committed
1697
        f'Choose a display name for building on server {host}',
dobli's avatar
dobli committed
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
        default=f'{host.capitalize()}', style=st)
        .skip_if(not building_id)
        .ask())

    services = (qust.checkbox(
        f'What services shall {host} provide?',
        choices=generate_cb_service_choices(checked=True),
        style=st)
        .skip_if(not building)
        .ask())

    if services is None:
        return None, None, None
dobli's avatar
dobli committed
1711
    if Service.OPENHAB in services:
dobli's avatar
dobli committed
1712
        add_openhab_service(building_id, host)
dobli's avatar
dobli committed
1713
    if Service.NODERED in services:
dobli's avatar
dobli committed
1714
        add_nodered_service(building_id)
dobli's avatar
dobli committed
1715
    if Service.MQTT in services:
dobli's avatar
dobli committed
1716
        add_mqtt_service(building_id, increment)
dobli's avatar
dobli committed
1717
    if Service.POSTGRES in services:
dobli's avatar
dobli committed
1718
        add_postgres_service(building_id)
dobli's avatar
dobli committed
1719
    if Service.BACKUP in services:
dobli's avatar
dobli committed
1720
        add_volumerize_service(building_id)
1721
    if Service.FILES in services:
dobli's avatar
dobli committed
1722
        add_file_service(building_id)
dobli's avatar
dobli committed
1723
    if Service.SFTP in services:
dobli's avatar
dobli committed
1724
1725
        add_sftp_service(building_id, increment)
    return building_id, building, services
1726
1727


Dobli's avatar
Dobli committed
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
# *** Exec Menu Entries ***
def exec_menu(args):
    """Menu entry for executing commands in services

    :args: Passed commandline arguments
    """
    machine = docker_client_prompt(" to execute command at")
    service_name = qust.select(
        'Which service container shall execute the command?',
        choices=get_container_list(machine), style=st).ask()
    command = qust.text('What command should be executed?', style=st).ask()

    run_command_in_service(service_name, command, machine)


# *** User Menu Entries ***
def user_menu(args):
    """Menu entry for user managment

    :args: Passed commandline arguments
    """
Dobli's avatar
Dobli committed
1749
    # Ask for action
Dobli's avatar
Dobli committed
1750
    choice = qust.select("What do you want to do?", choices=[
1751
1752
        'Add a new user', 'Modify existing user', 'Exit'],
        style=st).ask()
Dobli's avatar
Dobli committed
1753
    if "Add" in choice:
dobli's avatar
dobli committed
1754
        new_user_menu()
Dobli's avatar
Dobli committed
1755
    elif "Modify" in choice:
dobli's avatar
dobli committed
1756
        modify_user_menu()
Dobli's avatar
Dobli committed
1757
1758


dobli's avatar
dobli committed
1759
def new_user_menu():
Dobli's avatar
Dobli committed
1760
1761
    """Menu entry for new users
    """
dobli's avatar
dobli committed
1762
    current_users = get_users_from_files()
Dobli's avatar
Dobli committed
1763
1764
1765
1766
1767
1768
1769
1770
    new_user = False
    while not new_user:
        username = qust.text("Choose a new username:", style=st).ask()
        if username not in current_users:
            new_user = True
        else:
            print(f"User with name {username} already exists, try again")

1771
    # Ensure passwords match (only if username was selected)
Dobli's avatar
Dobli committed
1772
    password_match = False
1773
1774
    password = None
    while username and not password_match:
Dobli's avatar
Dobli committed
1775
1776
        password = qust.password(
            f'Choose a password for the user {username}:', style=st).ask()
1777
1778
1779
        confirm = (qust.password(
            f'Repeat password for the user {username}:',
            style=st)
dobli's avatar
dobli committed
1780
            .skip_if(not password)
1781
            .ask())
Dobli's avatar
Dobli committed
1782
1783
1784
1785
1786
        if password == confirm:
            password_match = True
        else:
            print("Passwords did not match, try again")

1787
1788
    if password and username:
        add_user_to_traefik_file(username, password)
Dobli's avatar
Dobli committed
1789
1790


dobli's avatar
dobli committed
1791
def modify_user_menu():
Dobli's avatar
Dobli committed
1792
    """Menu entry to remove users or change passwords
Dobli's avatar
Dobli committed
1793
    """
dobli's avatar
dobli committed
1794
    current_users = get_users_from_files()
Dobli's avatar
Dobli committed
1795
1796
    user = qust.select("Choose user to modify:",
                       choices=current_users, style=st).ask()
Dobli's avatar
Dobli committed
1797

1798
1799
1800
    if user is None:
        return
    elif user == 'ohadmin':
Dobli's avatar
Dobli committed
1801
1802
1803
1804
1805
1806
1807
1808
        choices = [{'name': 'Delete user',
                    'disabled': 'Disabled: cannot delete admin user'},
                   'Change password', 'Exit']
    else:
        choices = ['Delete user', 'Change password', 'Exit']

    action = qust.select(
        f"What should we do with {user}?", choices=choices, style=st).ask()
Dobli's avatar
Dobli committed
1809

1810
1811
    if action is None:
        return
Dobli's avatar
Dobli committed
1812
    if 'Delete' in action:
Dobli's avatar
Dobli committed
1813
1814
1815
        is_sure = qust.confirm(
            f"Are you sure you want to delete user {user}?", style=st).ask()
        if is_sure:
dobli's avatar
dobli committed
1816
            remove_user_from_traefik_file(user)
Dobli's avatar
Dobli committed
1817
1818
1819
1820
1821
    elif 'Change' in action:
        password_match = False
        while not password_match:
            password = qust.password(
                f'Choose a password for the user {user}:', style=st).ask()
1822
1823
            confirm = (qust.password(
                f'Repeat password for the user {user}:', style=st)
dobli's avatar
dobli committed
1824
                .skip_if(password is None)
1825
                .ask())
Dobli's avatar
Dobli committed
1826
1827
1828
1829
            if password == confirm:
                password_match = True
            else:
                print("Passwords did not match, try again")
1830
1831
        if password:
            add_user_to_traefik_file(user, password)
Dobli's avatar
Dobli committed
1832
1833


Dobli's avatar
Dobli committed
1834
1835
1836
1837
1838
1839
1840
1841
# *** Service Menu Entries ***
def service_menu(args):
    """Menu entry for service managment

    :args: Passed commandline arguments
    """
    # Ask for action
    choice = qust.select("What do you want to do?", choices=[
dobli's avatar
dobli committed
1842
        'Re-/Start docker stack', 'Stop docker stack',
1843
1844
        'Modify existing services', 'Add additional service',
        'Exit'], style=st).ask()
Dobli's avatar
Dobli committed
1845
    if "Add" in choice:
dobli's avatar
dobli committed
1846
        service_add_menu()
Dobli's avatar
Dobli committed
1847
    elif "Modify" in choice:
dobli's avatar
dobli committed
1848
        service_modify_menu()
dobli's avatar
dobli committed
1849
1850
    elif "Start" in choice:
        machine = docker_client_prompt(" to execute deploy")
1851
1852
        if machine:
            deploy_docker_stack(machine)
dobli's avatar
dobli committed
1853
1854
    elif "Stop" in choice:
        machine = docker_client_prompt(" to execute remove")
1855
1856
        if machine:
            remove_docker_stack(machine)
Dobli's avatar
Dobli committed
1857
1858


dobli's avatar
dobli committed
1859
def service_add_menu():
1860
1861
1862
1863
1864
1865
1866
    """Menu to add additional services
    """
    services = [s for s in Service if s.additional]
    service = qust.select(
        'What service do you want to add?', style=st,
        choices=generate_cb_service_choices(service_list=services)).ask()

1867
1868
1869
    host = (qust.select('Where should the service be located?',
                        choices=generate_cb_choices(
                            get_machine_list()), style=st)
dobli's avatar
dobli committed
1870
            .skip_if(not service)
1871
1872
1873
1874
            .ask())
    identifier = (qust.text(
        'Input an all lower case identifier:',
        style=st)
dobli's avatar
dobli committed
1875
        .skip_if(not host)
1876
        .ask())
1877
1878

    if service and host and identifier:
1879
        if service == Service.POSTGRES:
dobli's avatar
dobli committed
1880
            add_postgres_service(host, postfix=identifier)
1881
1882


dobli's avatar
dobli committed
1883
def service_modify_menu():
Dobli's avatar
Dobli committed
1884
1885
    """Menu to modify services
    """
dobli's avatar
dobli committed
1886
    services = get_current_services()
Dobli's avatar
Dobli committed
1887
1888
1889
    service = qust.select(
        'What service do you want to modify?', choices=services).ask()

1890
1891
1892
    if service is None:
        return
    elif service in ['proxy', 'landing']:
Dobli's avatar
Dobli committed
1893
1894
1895
1896
1897
1898
        choices = [{'name': 'Remove service',
                    'disabled': 'Disabled: cannot remove framework services'},
                   'Exit']
    else:
        choices = ['Remove service', 'Exit']

1899
1900
    action = (qust.select(
        f"What should we do with {service}?", choices=choices, style=st)
dobli's avatar
dobli committed
1901
        .skip_if(not service)
1902
        .ask())
Dobli's avatar
Dobli committed
1903

1904
1905
1906
    if action is None:
        return
    elif 'Remove' in action:
dobli's avatar
dobli committed
1907
        delete_service(service)
Dobli's avatar
Dobli committed
1908
1909


1910
1911
1912
1913
1914
1915
1916
# *** Device Menu Functions ***
def device_menu(args):
    """Menu to manage devices

    :args: Arguments form commandline
    """
    # Check if device scripts are installed
dobli's avatar
dobli committed
1917
    bin_path = '/usr/bin/enable-swarm-device'
1918
1919

    choices = ['Install device scripts']
dobli's avatar
dobli committed
1920
    if os.path.exists(bin_path):
1921
        choices.append('Link device to service')
dobli's avatar
dobli committed
1922
        choices.append('Unlink device')
1923
1924
1925
1926

    choices.append('Exit')

    # Ask for action
1927
1928
    choice = qust.select("What do you want to do? (root required)",
                         choices=choices, style=st).ask()
1929
    if "Install" in choice:
dobli's avatar
dobli committed
1930
        print("Installing device scripts (needs root)")
dobli's avatar
dobli committed
1931
        device_install_menu()
1932
    elif "Link" in choice:
dobli's avatar
dobli committed
1933
        device_link_menu()
dobli's avatar
dobli committed
1934
    elif "Unlink" in choice:
dobli's avatar
dobli committed
1935
        device_unlink_menu()
1936
1937


dobli's avatar
dobli committed
1938
def device_install_menu():
1939
1940
    """Install scripts to link devices
    """
1941
1942
    machine = docker_client_prompt(" to install usb support")

1943
1944
1945
    if machine:
        # Name of base dir on machines
        external_base_dir = os.path.basename(base_dir)
1946

1947
1948
1949
1950
1951
1952
1953
1954
        # Check if files are available on targeted machine
        machine_dir = f"{external_base_dir}/install-usb-support.sh"
        print(machine_dir)
        if not check_file_on_machine(machine_dir, machine):
            print("Scripts missing on machine, will be copied")
            copy_files_to_machine(base_dir, machine)
        else:
            print("Scripts available on machine")
1955

1956
1957
1958
        execute_command_on_machine(f'sudo {machine_dir}', machine)
    else:
        print("Cancelled device script installation")
1959
1960


dobli's avatar
dobli committed
1961
def device_link_menu():
dobli's avatar
dobli committed
1962
1963
    """Link device to a service
    """
1964
    machine = docker_client_prompt(" to link device on")
1965
1966
1967
    device = (qust.select("What device should be linked?",
                          choices=USB_DEVICES,
                          style=st)
dobli's avatar
dobli committed
1968
              .skip_if(not machine)
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
              .ask())

    if machine and device:
        # Start systemd service that ensures link (escapes of backslash needed)
        link_cmd = f"sudo systemctl enable --now swarm-device@" + \
            f"{device}\\\\\\\\x20openhab.service"

        # Needs enable to keep after reboot
        execute_command_on_machine(link_cmd, machine)
        print(f"Linked device {device} to openHAB service on {machine}")
    else:
        print("Cancelled device linking")
dobli's avatar
dobli committed
1981
1982


dobli's avatar
dobli committed
1983
def device_unlink_menu():
dobli's avatar
dobli committed
1984
1985
    """Unlink a device from a service
    """
dobli's avatar
dobli committed
1986
    machine = docker_client_prompt(" to unlink device from")
1987
1988
    device = (qust.select("What device should be unlinked?",
                          choices=USB_DEVICES, style=st)
dobli's avatar
dobli committed
1989
              .skip_if(not machine)
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
              .ask())

    if machine and device:
        # Stop systemd service that ensures link (escapes of backslash needed)
        link_cmd = f"sudo systemctl disable --now swarm-device@" + \
            f"{device}\\\\\\\\x20openhab.service"

        execute_command_on_machine(link_cmd, machine)
        print(f"Unlinked device {device} on machine {machine}")
    else:
        print("Cancelled device unlinking")
dobli's avatar
dobli committed
2001
2002


dobli's avatar
dobli committed
2003
2004
2005
2006
2007
2008
2009
2010
# *** Backup Menu Entries ***
def backup_menu(args):
    """Menu entry for backup managment

    :args: Passed commandline arguments
    """
    # Ask for action
    choice = qust.select("What do you want to do?", choices=[
dobli's avatar
dobli committed
2011
        'Execute backup', 'Restore backup', 'Move building', 'Exit'],
dobli's avatar
dobli committed
2012
2013
        style=st).ask()
    if "Execute" in choice:
dobli's avatar
dobli committed
2014
        execute_backup_menu()
dobli's avatar
dobli committed
2015
    elif "Restore" in choice:
dobli's avatar
dobli committed
2016
        restore_backup_menu()
dobli's avatar
dobli committed
2017
2018
    elif "Move" in choice:
        restore_new_building_menu()
dobli's avatar
dobli committed
2019
2020


dobli's avatar
dobli committed
2021
def execute_backup_menu():
dobli's avatar
dobli committed
2022
2023
2024
2025
    """Submenu for backup execution
    """
    machine = docker_client_prompt(" to backup")

2026
2027
    full = (qust.confirm("Execute full backup (otherwise partial)?",
                         default=False, style=st)
dobli's avatar
dobli committed
2028
            .skip_if(not machine)
2029
2030
2031
2032
2033
            .ask())

    if full is None:
        return
    elif full:
dobli's avatar
dobli committed
2034
2035
2036
2037
2038
2039
2040
        run_command_in_service('backup', 'backupFull', machine)
        print("Full backup completed")
    else:
        run_command_in_service('backup', 'backup', machine)
        print("Partial backup completed")


dobli's avatar
dobli committed
2041
def restore_backup_menu():
dobli's avatar
dobli committed
2042
2043
2044
2045
    """Submenu for backup execution
    """
    machine = docker_client_prompt(" to restore")

2046
    confirm = (qust.confirm(
dobli's avatar
dobli committed
2047
2048
2049
        f'Restore services from last backup on machine {machine} '
        '(current data will be lost)?',
        default=False,
2050
        style=st)
dobli's avatar
dobli committed
2051
        .skip_if(not machine)
2052
        .ask())
dobli's avatar
dobli committed
2053
2054
2055
2056
2057
2058
2059
2060

    if confirm:
        restore_building_backup(machine, machine)
        print("Restore completed")
    else:
        print("Restore canceled")


dobli's avatar
dobli committed
2061
2062
2063
2064
def restore_new_building_menu():
    """Submenu for backup execution on a new building
    """
    machine = docker_client_prompt(" to execute restores with.")
2065
2066
2067
2068
    current_building = compose_building_prompt(" to move", skip_if=not machine)
    new_machine = docker_client_prompt(" to move building to",
                                       skip_if=not current_building)
    confirm = (qust.confirm(
dobli's avatar
dobli committed
2069
2070
2071
        f'Recreate {current_building} from last backup'
        f' on machine {new_machine}',
        default=False,
2072
2073
2074
        style=st)
        .skip_if(not new_machine, default=False)
        .ask())
dobli's avatar
dobli committed
2075
2076
2077
2078
2079
2080
2081

    if confirm:
        restore_building_backup(machine, current_building, new_machine)
    else:
        print("Restore canceled")


Dobli's avatar
Dobli committed
2082
# *** Menu Helper Functions ***
2083
def generate_cb_choices(list, checked=False):
dobli's avatar
dobli committed
2084
2085
    """Generates checkbox entries for lists of strings

2086
2087
    :list: pyhton list that shall be converted
    :checked: if true, selections will be checked by default
dobli's avatar
dobli committed
2088
2089
    :returns: A list of dicts with name keys
    """
2090
    return [{'name': m, 'checked': checked} for m in list]
Dobli's avatar
Dobli committed
2091
2092


2093
def generate_cb_service_choices(checked=False, service_list=None):
dobli's avatar
dobli committed
2094
2095
2096
    """Generates checkbox entries for the sevice enum

    :checked: if true, selections will be checked by default
2097
    :service_list: optional list of services, use all if empty
dobli's avatar
dobli committed
2098
2099
    :returns: A list of dicts with name keys
    """
2100
    services = service_list if service_list is not None else Service
dobli's avatar
dobli committed
2101
    return [
2102
        {'name': s.fullname, 'value': s, 'checked': checked} for s in services
dobli's avatar
dobli committed
2103
2104
2105
    ]


2106
def docker_client_prompt(message_details='', skip_if=False):
Dobli's avatar
Dobli committed
2107
2108
2109
2110
2111
    """Show list of docker machines and return selection

    :manager: Optional machine to use, prompt otherwise
    :returns: Docker client instance
    """
2112
2113
    machine = (qust.select(f'Choose manager machine{message_details}',
                           choices=get_machine_list(), style=st)
dobli's avatar
dobli committed
2114
               .skip_if(skip_if)
2115
               .ask())
2116
    return machine
dobli's avatar
dobli committed
2117
2118


2119
def compose_building_prompt(message_details='', skip_if=False):
dobli's avatar
dobli committed
2120
2121
2122
2123
2124
2125
    """Show list of building contraints used in compose

    :returns: Docker client instance
    """
    building = qust.select(f'Choose building{message_details}:',
                           choices=get_current_building_constraints(),
dobli's avatar
dobli committed
2126
                           style=st).skip_if(skip_if).ask()
dobli's avatar
dobli committed
2127
    return building
2128
# >>>
dobli's avatar
dobli committed
2129

2130

2131
# ******************************
2132
# Script main (entry) <<<
2133
# ******************************
2134
2135
2136
if __name__ == '__main__':
    import argparse
    parser = argparse.ArgumentParser(
2137
        prog='building_manager',
2138
2139
        description='Generate and manage multi'
        'building configurations of openHAB with docker swarm')
2140
    parser.add_argument(
dobli's avatar
dobli committed
2141
        '--config_dir',
2142
2143
        '-d',
        help='Directory to creat config folders in, default is current dir')
2144
2145
    subparsers = parser.add_subparsers()

2146
2147
2148
2149
2150
2151
    # Interactive mode
    parser_interactive = subparsers.add_parser(
        'interactive',
        help='Starts the interactive mode of the building manager')
    parser_interactive.set_defaults(func=interactive_command)

2152
2153
2154
    # Restore command
    parser_restore = subparsers.add_parser('restore', help='Restore backups')
    parser_restore.add_argument(
2155
        'building', help='Name (label) of the building that shall be restored')
2156
2157
2158
2159
    parser_restore.add_argument(
        'target', help='Name of the machine to restore to')
    parser_restore.set_defaults(func=restore_command)

2160
2161
2162
2163
2164
2165
2166
2167
2168
    # Assign building command
    parser_assign_building = subparsers.add_parser(
        'assign_building', help='Assign the role of a building to a node')
    parser_assign_building.add_argument(
        'node', help='Name (or ID) of the node that gets the role assigned')
    parser_assign_building.add_argument(
        'building', help='Name of the building that will be assigned')
    parser_assign_building.set_defaults(func=assign_building_command)

2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
    # Execute command
    parser_exec = subparsers.add_parser(
        'exec', help='Execute commands in a service container')
    parser_exec.add_argument(
        'service', help='Name of the service that will run the command')
    parser_exec.add_argument(
        'command', help='Command to be executed', nargs=argparse.REMAINDER)
    parser_exec.add_argument(
        '--building',
        '-b',
        help='Building name (label) of the service if '
        'service location is ambiguous')
    parser_exec.set_defaults(func=execute_command)

2183
2184
2185
2186
2187
2188
2189
2190
2191
    # Config commands
    parser_config = subparsers.add_parser(
        'config', help='Manage configuration files')
    parser_config_subs = parser_config.add_subparsers()
    # - Config init
    parser_config_init = parser_config_subs.add_parser(
        'init', help='Initialize config file directories')
    parser_config_init.set_defaults(func=init_config_dirs_command)

2192
    # Parse arguments into args dict
2193
    args = parser.parse_args()
2194

2195
2196
2197
2198
    # Check if custom config dir is used
    if args.config_dir:
        custom_path = args.config_dir

2199
2200
2201
2202
2203
    # when no subcommand is defined show interactive menu
    try:
        args.func(args)
    except AttributeError:
        interactive_command(args)
2204
# >>>
dobli's avatar
dobli committed
2205
2206

# --- vim settings ---
2207
# vim:foldmethod=marker:foldlevel=0:foldmarker=<<<,>>>