From 08d627d993442a90fc9d0f7e5bab09017d4bf4a3 Mon Sep 17 00:00:00 2001
From: dobli <dobler.alex@gmail.com>
Date: Thu, 21 Mar 2019 16:11:42 +0100
Subject: [PATCH] refactored volume management

---
 building_manager.py                           | 221 ++++++++++++++++--
 {template_configs => legacy}/docker-stack.yml |   0
 template_configs/docker-skeleton.yml          |   7 -
 template_configs/docker-templates.yml         |  37 +--
 4 files changed, 205 insertions(+), 60 deletions(-)
 rename {template_configs => legacy}/docker-stack.yml (100%)

diff --git a/building_manager.py b/building_manager.py
index 1713269..53e240d 100755
--- a/building_manager.py
+++ b/building_manager.py
@@ -2,6 +2,7 @@
 """ Python module to assist creating and maintaining docker openHab stacks."""
 import crypt
 from enum import Enum
+from typing import NamedTuple
 import logging
 import os
 import sys
@@ -79,20 +80,29 @@ USB_DEVICES = [{
 }]
 
 
-class Service(Enum):
-    SFTP = ("SFTP", "sftp", False, False)
-    OPENHAB = ("OpenHAB", "openhab", True, True, 'dashboard')
-    NODERED = ("Node-RED", "nodered", False, True, 'ballot')
-    POSTGRES = ("Postgre SQL", "postgres", True, False)
-    MQTT = ("Mosquitto MQTT Broker", "mqtt", True, False)
-    FILES = ("File Manager", "files", False, True, 'folder')
-
-    def __init__(self, fullname, prefix, additional, frontend, icon=None):
-        self.fullname = fullname
-        self.prefix = prefix
-        self.additional = additional
-        self.frontend = frontend
-        self.icon = icon
+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')
+
+    @classmethod
+    def service_by_prefix(cls, prefix):
+        # cls here is the enumeration
+        return next(service for service in cls if service.prefix == prefix)
 # >>>
 
 
@@ -137,6 +147,11 @@ def add_sftp_service(base_dir, hostname, number=0):
         f"{CONSTRAINTS['building']} == {hostname}")
     template['ports'] = [f'{2222 + number}:22']
 
+    # attach volumes
+    volume_base = '/home/ohadmin/'
+    template['volumes'] = get_attachable_volume_list(
+        base_dir, volume_base, hostname)
+
     add_or_update_compose_service(compose_path, service_name, template)
 
 
@@ -169,6 +184,10 @@ def add_openhab_service(base_dir, hostname):
         f'{service_name}.{{domain:[a-zA-z0-9-]+}}')
     template['deploy']['labels'].append('traefik.sub.frontend.priority=2')
 
+    # replace volumes with named entries in template
+    template['volumes'] = generate_named_volumes(
+        template['volumes'], service_name, compose_path)
+
     add_or_update_compose_service(compose_path, service_name, template)
 
 
@@ -195,6 +214,10 @@ def add_nodered_service(base_dir, hostname):
     template['deploy']['labels'].extend(
         generate_traefik_subdomain_labels(service_name, segment='sub'))
 
+    # replace volumes with named entries in template
+    template['volumes'] = generate_named_volumes(
+        template['volumes'], service_name, compose_path)
+
     add_or_update_compose_service(compose_path, service_name, template)
 
 
@@ -218,6 +241,10 @@ def add_mqtt_service(base_dir, hostname, number=0):
     # ports incremented by number of services
     template['ports'] = [f'{1883 + number}:1883', f'{9001 + number}:9001']
 
+    # replace volumes with named entries in template
+    template['volumes'] = generate_named_volumes(
+        template['volumes'], service_name, compose_path)
+
     add_or_update_compose_service(compose_path, service_name, template)
 
 
@@ -232,15 +259,21 @@ def add_postgres_service(base_dir, hostname, postfix=None):
     # compose file
     compose_path = base_path + '/' + COMPOSE_NAME
     # use hostname as postfix when empty
-    postfix = hostname if postfix is None else postfix
-    # service name
-    service_name = f'postgres_{postfix}'
+    if postfix is None:
+        service_name = f'postgres_{hostname}'
+    else:
+        service_name = f'postgres_{postfix}'
+
     # template
     template = get_service_template(base_dir, Service.POSTGRES.prefix)
     # only label constraint is building
     template['deploy']['placement']['constraints'][0] = (
         f"{CONSTRAINTS['building']} == {hostname}")
 
+    # replace volumes with named entries in template
+    template['volumes'] = generate_named_volumes(
+        template['volumes'], service_name, compose_path)
+
     add_or_update_compose_service(compose_path, service_name, template)
 
 
@@ -267,6 +300,11 @@ def add_file_service(base_dir, hostname):
         generate_traefik_path_labels(service_name, segment='main',
                                      redirect=False))
 
+    # attach volumes
+    volume_base = '/srv/'
+    template['volumes'] = get_attachable_volume_list(
+        base_dir, volume_base, hostname)
+
     add_or_update_compose_service(compose_path, service_name, template)
 
 
@@ -294,7 +332,7 @@ def delete_service(base_dir, service_name):
 
 
 # Functions to extract information
-def get_current_services(base_dir):
+def get_current_services(base_dir, placement=None):
     """Gets a list of currently used services
 
     :base_dir: dir to find files in
@@ -307,11 +345,130 @@ def get_current_services(base_dir):
         # load compose file
         compose = yaml.load(compose_f)
         # generate list of names
-        service_names = [n for n in compose['services']]
+        service_names = []
+        for (name, entry) in compose['services'].items():
+            if placement is None or get_building_of_entry(entry) == placement:
+                service_names.append(name)
+
         return service_names
 
 
+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
+
+
+def get_service_volumes(base_dir, service_name):
+    """Gets a list of volumes of a service
+
+    :base_dir: dir to find files in
+    :returns: list of volumes
+    """
+    base_path = base_dir + '/' + CUSTOM_DIR
+    # compose file
+    compose_path = base_path + '/' + COMPOSE_NAME
+    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
+
+
 # Helper functions
+def get_attachable_volume_list(base_dir, volume_base, host):
+    """Get a list of volumes from a host that can be attatched for file acccess
+
+    :base_dir: Base config dir
+    :volume_base: Base path of volumes
+    :host: host to consider
+    :returns: list of attachable volume entries
+    """
+    volume_list = []
+    host_services = get_current_services(base_dir, host)
+    for host_service in host_services:
+        name, instance = get_service_entry_info(host_service)
+        volume_service = Service.service_by_prefix(name)
+        if volume_service.sftp:
+            volumes = get_service_volumes(base_dir, host_service)
+            vlist = [f'{v}:{volume_base}{v}' for v in volumes]
+            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()]
+
+
 def get_service_template(base_dir, service_name):
     """Gets a service template entry from the template yaml
 
@@ -410,6 +567,25 @@ def add_or_update_compose_service(compose_path, service_name, service_content):
         yaml.dump(compose, compose_f)
         # reduce file to new size
         compose_f.truncate()
+
+
+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()
 # >>>
 
 
@@ -955,11 +1131,10 @@ def execute_command_on_machine(command, machine):
     run([f'docker-machine ssh {machine} {command}'], shell=True)
 # >>>
 
+
 # ******************************
 # Systemd functions <<<
 # ******************************
-
-
 def list_enabled_devices():
     """Presents a list of enabled devices (systemd services)
     :returns: list of enabled devices
@@ -1279,8 +1454,6 @@ def init_machine_menu(base_dir, host, increment):
     services = qust.checkbox(f'What services shall {host} provide?',
                              choices=generate_cb_service_choices(checked=True),
                              style=st).ask()
-    if Service.SFTP in services:
-        add_sftp_service(base_dir, host, increment)
     if Service.OPENHAB in services:
         add_openhab_service(base_dir, host)
     if Service.NODERED in services:
@@ -1291,6 +1464,8 @@ def init_machine_menu(base_dir, host, increment):
         add_postgres_service(base_dir, host)
     if Service.FILES in services:
         add_file_service(base_dir, host)
+    if Service.SFTP in services:
+        add_sftp_service(base_dir, host, increment)
     return building, services
 
 
diff --git a/template_configs/docker-stack.yml b/legacy/docker-stack.yml
similarity index 100%
rename from template_configs/docker-stack.yml
rename to legacy/docker-stack.yml
diff --git a/template_configs/docker-skeleton.yml b/template_configs/docker-skeleton.yml
index 9bcb344..2443080 100644
--- a/template_configs/docker-skeleton.yml
+++ b/template_configs/docker-skeleton.yml
@@ -45,13 +45,6 @@ configs:
         file: ./filebrowser/filebrowser.json
 
 volumes:
-    openhab_addons:
-    openhab_conf:
-    openhab_userdata:
-    nodered_data:
-    mosquitto_data:
-    influxdb_data:
-    postgres_data:
     backup_data:
     backup_cache:
 
diff --git a/template_configs/docker-templates.yml b/template_configs/docker-templates.yml
index d240177..db32575 100644
--- a/template_configs/docker-templates.yml
+++ b/template_configs/docker-templates.yml
@@ -45,13 +45,6 @@ configs:
         file: ./filebrowser/filebrowser.json
 
 volumes:
-    openhab_addons:
-    openhab_conf:
-    openhab_userdata:
-    nodered_data:
-    mosquitto_data:
-    influxdb_data:
-    postgres_data:
     backup_data:
     backup_cache:
 
@@ -88,9 +81,6 @@ services:
     sftp:
         image: "atmoz/sftp"
         volumes:
-            - "openhab_userdata:/home/ohadmin/openhab_userdata"
-            - "openhab_conf:/home/ohadmin/openhab_conf"
-            - "nodered_data:/home/ohadmin/nodered_data"
             - "backup_data:/home/ohadmin/backup_data"
         configs:
             - source: sftp_config
@@ -117,9 +107,9 @@ services:
         volumes:
             - "/etc/localtime:/etc/localtime:ro"
             - "/etc/timezone:/etc/timezone:ro"
-            - "openhab_addons:/openhab/addons"
-            - "openhab_conf:/openhab/conf"
-            - "openhab_userdata:/openhab/userdata"
+            - "addons:/openhab/addons"
+            - "conf:/openhab/conf"
+            - "userdata:/openhab/userdata"
         environment:
             OPENHAB_HTTP_PORT: "8181"
             OPENHAB_HTTPS_PORT: "8443"
@@ -136,7 +126,7 @@ services:
     nodered:
         image: "nodered/node-red-docker"
         volumes:
-            - "nodered_data:/data"
+            - "data:/data"
         networks:
             - habnet
         configs:
@@ -154,7 +144,7 @@ services:
     mqtt:
         image: "eclipse-mosquitto"
         volumes:
-            - "mosquitto_data:/mosquitto/data"
+            - "data:/mosquitto/data"
         ports:
         configs:
             - source: mosquitto_passwords
@@ -170,7 +160,7 @@ services:
     db:
         image: "influxdb"
         volumes:
-            - "influxdb_data:/var/lib/influxdb"
+            - "data:/var/lib/influxdb"
         configs:
             - source: influx_init
               target: /init-influxdb.sh
@@ -193,7 +183,7 @@ services:
     postgres:
         image: "postgres"
         volumes:
-            - "postgres_data:/var/lib/postgresql/data/pgdata"
+            - "data:/var/lib/postgresql/data/pgdata"
         configs:
             - source: postgres_user
               target: /run/secrets/postgres_user
@@ -233,8 +223,6 @@ services:
     files:
         image: filebrowser/filebrowser
         volumes:
-            - openhab_conf:/srv/openHAB
-            - nodered_data:/srv/Node-RED
         configs:
             - source: filebrowser
               target: /.filebrowser.json
@@ -247,14 +235,3 @@ services:
             placement:
                 constraints:
                     - node.labels.building == X
-    zwave_oh:
-        image: docker
-        command: "docker run --rm --name device_oh --network habnet -v /etc/localtime:/etc/localtime:ro -v /etc/timezone:/etc/timezone:ro -v openhab_zwave_conf:/openhab/conf -v openhab_zwave_userdata:/openhab/userdata -p 9898:8080 openhab/openhab:2.4.0"
-        volumes:
-            - "/var/run/docker.sock:/var/run/docker.sock"
-        networks:
-            - habnet
-        deploy:
-            placement:
-                constraints:
-                    - node.labels.device == zwave
-- 
GitLab