"Koka Micro-PaaS"

# from fcntl import fcntl, F_SETFL, F_GETFL

# import os
# import re
# import pwd
# import grp
# import sys
# import glob
# import json
# import stat
# import time 
# import click
# import shutil
# import socket
# import tempfile
# import traceback
# import collections
# import multiprocessing
# try:
#     from urllib.request import urlopen
# except Exception as e:
#     from urllib import urlopen
# from os.path import abspath, basename, dirname, exists, getmtime, join, realpath, splitext
# from subprocess import call, check_output, Popen, STDOUT, PIPE 

# from os import chmod, stat, O_NONBLOCK
# from socket import socket, AF_INET, SOCK_STREAM
# from sys import argv, stdin, stdout, stderr, version_info, exit

print("Package name typo. Should be 'bokku'")
print("pip install bokku")
exit()

# === Make sure we can access all system binaries ===

if 'sbin' not in os.environ['PATH']:
    os.environ['PATH'] = "/usr/local/sbin:/usr/sbin:/sbin:" + os.environ['PATH']

# === Globals - all tweakable settings are here ===
CWD = os.getcwd()

#os.environ["HOME"] = join(CWD, 'devlab', 'HOME')

os.environ["HOME"] = "/home"

KOKA_CMD = "koka"
USER = "koka"
KOKA_ROOT = os.environ.get('KOKA_ROOT', join(os.environ['HOME'],'koka'))
APP_ROOT = abspath(KOKA_ROOT)
DOT_ROOT = abspath(join(KOKA_ROOT, ".koka"))
ENV_ROOT = abspath(join(DOT_ROOT, "envs"))
GIT_ROOT = abspath(join(DOT_ROOT, "repos"))
LOG_ROOT = abspath(join(DOT_ROOT, "logs"))
UWSGI_ROOT = abspath(join(DOT_ROOT, "uwsgi"))
NGINX_ROOT = abspath(join(DOT_ROOT, "nginx"))
CONFIG_FILE_NAME = "app.json"
UWSGI_AVAILABLE = abspath(join(DOT_ROOT, "uwsgi-available"))
UWSGI_ENABLED = abspath(join(DOT_ROOT, "uwsgi-enabled"))
UWSGI_LOG_MAXSIZE = '1048576'
ACME_ROOT = os.environ.get('ACME_ROOT', join(os.environ['HOME'],'.acme.sh'))
ACME_WWW = abspath(join(DOT_ROOT, "acme"))

VALID_RUNTIME = ["python", "node", "static", "php"]
DEFAULT_PACKAGES = []

# pylint: disable=anomalous-backslash-in-string
NGINX_TEMPLATE = """
upstream $APP {
  server $NGINX_SOCKET;
}
server {
  listen              $NGINX_IPV6_ADDRESS:80;
  listen              $NGINX_IPV4_ADDRESS:80;

  location ^~ /.well-known/acme-challenge {
    allow all;
    root ${ACME_WWW};
  }

$INTERNAL_NGINX_COMMON
}
"""

NGINX_HTTPS_ONLY_TEMPLATE = """
upstream $APP {
  server $NGINX_SOCKET;
}
server {
  listen              $NGINX_IPV6_ADDRESS:80;
  listen              $NGINX_IPV4_ADDRESS:80;
  server_name         $NGINX_SERVER_NAME;

  location ^~ /.well-known/acme-challenge {
    allow all;
    root ${ACME_WWW};
  }

  return 301 https://$server_name$request_uri;
}

server {
$INTERNAL_NGINX_COMMON
}
"""
# pylint: enable=anomalous-backslash-in-string

NGINX_COMMON_FRAGMENT = """
  listen              $NGINX_IPV6_ADDRESS:$NGINX_SSL;
  listen              $NGINX_IPV4_ADDRESS:$NGINX_SSL;
  ssl                 on;
  ssl_certificate     $NGINX_ROOT/$APP.crt;
  ssl_certificate_key $NGINX_ROOT/$APP.key;
  server_name         $NGINX_SERVER_NAME;

  # These are not required under systemd - enable for debugging only
  # access_log        $LOG_ROOT/$APP/access.log;
  # error_log         $LOG_ROOT/$APP/error.log;
  
  # Enable gzip compression
  gzip on;
  gzip_proxied any;
  gzip_types text/plain text/xml text/css application/x-javascript text/javascript application/xml+rss application/atom+xml;
  gzip_comp_level 7;
  gzip_min_length 2048;
  gzip_vary on;
  gzip_disable "MSIE [1-6]\.(?!.*SV1)";
  
  # set a custom header for requests
  add_header X-Deployed-By Koka;

  $INTERNAL_NGINX_CUSTOM_CLAUSES

  $INTERNAL_NGINX_STATIC_MAPPINGS

  $INTERNAL_NGINX_PORTMAP
"""

NGINX_PORTMAP_FRAGMENT = """
  location    / {
    $INTERNAL_NGINX_UWSGI_SETTINGS
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_set_header X-Request-Start $msec;
    $NGINX_ACL
  }
"""

NGINX_ACME_FIRSTRUN_TEMPLATE = """
server {
  listen              $NGINX_IPV6_ADDRESS:80;
  listen              $NGINX_IPV4_ADDRESS:80;
  server_name         $NGINX_SERVER_NAME;
  location ^~ /.well-known/acme-challenge {
    allow all;
    root ${ACME_WWW};
  }
}
"""

INTERNAL_NGINX_STATIC_MAPPING = """
  location $static_url {
      sendfile on;
      sendfile_max_chunk 1m;
      tcp_nopush on;
      directio 8m;
      aio threads;
      alias $static_path;
  }
"""

INTERNAL_NGINX_UWSGI_SETTINGS = """
    uwsgi_pass $APP;
    uwsgi_param QUERY_STRING $query_string;
    uwsgi_param REQUEST_METHOD $request_method;
    uwsgi_param CONTENT_TYPE $content_type;
    uwsgi_param CONTENT_LENGTH $content_length;
    uwsgi_param REQUEST_URI $request_uri;
    uwsgi_param PATH_print $document_uri;
    uwsgi_param DOCUMENT_ROOT $document_root;
    uwsgi_param SERVER_PROTOCOL $server_protocol;
    uwsgi_param REMOTE_ADDR $remote_addr;
    uwsgi_param REMOTE_PORT $remote_port;
    uwsgi_param SERVER_ADDR $server_addr;
    uwsgi_param SERVER_PORT $server_port;
    uwsgi_param SERVER_NAME $server_name;
"""

# === Utility functions ===

def _print(message, type="info", arrow=False):
    color = "white"
    types = {
        "default": "white",
        "info": "white",
        "warning": "yellow",
        "error": "red",
        "success": "green"
    }
    color = types[type] if type in types else types["default"]
    msg = "-----> %s" % message if arrow else message
    click.secho(msg, fg=color)

def _call(cmd, cwd=None, env=None, shell=True):
    return call(cmd, cwd=cwd, env=env, shell=shell)

def which(pgm):
    path = os.getenv('PATH')
    for p in path.split(os.path.pathsep):
        p = join(p,pgm)
        if os.path.exists(p) and os.access(p,os.X_OK):
            return p

def sanitize_app_name(app):
    """Sanitize the app name and build matching path"""   
    return "".join(c for c in app if c.isalnum() or c in ('.','_')).rstrip().lstrip('/')

def exit_if_invalid(app):
    """Utility function for error checking upon command startup."""
    app = sanitize_app_name(app)
    if not exists(join(APP_ROOT, app)):
        _print("Error: app '{}' not found.".format(app), type="error")
        exit(1)
    return app

def generate_random_port():
    s = socket(AF_INET, SOCK_STREAM)
    s.bind(("",0))
    port = s.getsockname()[1]
    s.close()
    return port

def write_config(filename, bag, separator='='):
    """Helper for writing out config files"""
    with open(filename, 'w') as h:
        for k, v in bag.items():
            h.write('{k:s}{separator:s}{v}\n'.format(**locals()))

def apt_update():
    _print("apt-get UPDATE", arrow=True)
    #_call("sudo apt-get -y update")

def apt_install_packages():
    pkgs = ['bc', 'git', 'build-essential', 'libpcre3-dev', 'zlib1g-dev', 'python-dev', 'python3', 'python3-pip',
            'python3-dev', 'python-pip', 'python-setuptools', 'python3-setuptools', 'nginx', 'incron', 'acl']
    pkgs = " ".join(pkgs)
    _print("apt-get INSTALL '%s'" % pkgs, arrow=True)
    #_call("sudo apt-get -y install %s" % pkgs)

def install_acme_sh():
    """ Install acme.sh for letsencrypt """
    if os.path.isdir(ACME_ROOT):
        return
    try:
        _print("Installing acme.sh", type="success", arrow=True)
        dest = join(KOKA_ROOT, "acme.sh")
        url = "https://raw.githubusercontent.com/Neilpang/acme.sh/master/acme.sh"
        content = urlopen(url).read().decode("utf-8")
        with open(dest, "w") as f:
            f.write(content)
        os.chmod(dest, 0755)
        _call("./acme.sh --install", cwd=KOKA_ROOT, shell=True)
        os.remove(dest)
    except Exception as e:
        _print('Unable to download acme.sh: %s' % e, type="error") 


def setup_authorized_keys(ssh_fingerprint, script_path, pubkey):
    """Sets up an authorized_keys file to redirect SSH commands"""
    
    authorized_keys = join(os.environ['HOME'],'.ssh','authorized_keys')
    if not exists(dirname(authorized_keys)):
        os.makedirs(dirname(authorized_keys))
    # Restrict features and force all SSH commands to go through our script 
    with open(authorized_keys, 'a') as h:
        h.write("""command="FINGERPRINT={ssh_fingerprint:s} NAME=default {script_path:s} $SSH_ORIGINAL_COMMAND",no-agent-forwarding,no-user-rc,no-X11-forwarding,no-port-forwarding {pubkey:s}\n""".format(**locals()))
    os.chmod(dirname(authorized_keys), stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
    os.chmod(authorized_keys, stat.S_IRUSR | stat.S_IWUSR)

def parse_procfile(filename):
    """Parses a Procfile and returns the worker types. Only one worker of each type is allowed."""
    if not os.path.isfile(filename):
        return None
    workers = {}
    with open(filename, 'r') as procfile:
        for line in procfile:
            try:
                kind, command = map(lambda x: x.strip(), line.split(":", 1))
                workers[kind] = command
            except:
                _print("Warning: unrecognized Procfile entry '{}'".format(line), type="warning")
    return workers 


def expand_vars(buffer, env, default=None, skip_escaped=False):
    """expand shell-style environment variables in a buffer"""
    def replace_var(match):
        return env.get(match.group(2) or match.group(1), match.group(0) if default is None else default)
    pattern = (r'(?<!\\)' if skip_escaped else '') + r'\$(\w+|\{([^}]*)\})'
    return re.sub(pattern, replace_var, buffer)


def get_cmd_output(cmd):
    """executes a command and grabs its output, if any"""
    try:
        env = environ
        return str(check_output(cmd, stderr=STDOUT, env=env, shell=True))
    except:
        return ""


def read_settings_file(filename, env={}):
    """Parses a settings file and returns a dict with environment variables"""
    if not os.path.isfile(filename):
        return {}
    with open(filename, 'r') as settings:
        for line in settings:
            try:
                if '#' == line[0] or len(line.strip()) == 0: # ignore comments and newlines
                    continue                
                k, v = map(lambda x: x.strip(), line.split("=", 1))
                env[k] = expand_vars(v, env)
            except:
                _print("Error: malformed setting '{}', ignoring file.".format(line), type="error")
                return {}
    return env


def has_bin_dependencies(binaries):
    """Checks if all the binaries exist and are executable"""
    _print("Checking requirements: {}".format(binaries), type='success', arrow=True)
    requirements = list(map(which, binaries))
    _print(str(requirements))
    return False if None in requirements else True

def get_app_config(app):
    config_file = join(APP_ROOT, app, CONFIG_FILE_NAME)
    with open(config_file) as f:
        return json.load(f)["koka"]
    return None

def get_app_proc(app):
    return get_app_config(app)["run"]

def get_app_env(app):
    env = {}
    config = get_app_config(app)
    for k, v in config.items():
        if k not in ["run", "scripts"]:
            if isinstance(v, dict):
                env.update({("%s_%s" % (k, vk)).upper(): vv for vk, vv in v.items() })
            else:
                env[k.upper()] = v
    return env

def to_proc_env(config):
    env = {}
    for k in ["nginx", "uswgi"]:
        if config.get(k):
            for l in config.get(k):
                m = ("%s_%s" % (k, l)).upper()
                env[m] = config.get(k).get(l)
    return env


def get_app_runtime(app):
    app_path = join(APP_ROOT, app)
    config = get_app_config(app);
    app_runtime = config.get("type")
    
    if app_runtime.lower() in VALID_RUNTIME:
        return app_runtime.lower()

    if exists(join(app_path, 'requirements.txt')):
        return "python"
    elif exists(join(app_path, 'package.json')) and has_bin_dependencies(['nodejs', 'npm']):
        return "node"        
    elif exists(join(app_path, 'composer.json')):
        return "php"        
    return None
    
def do_deploy(app, deltas={}, newrev=None):
    """Deploy an app by resetting the work directory"""
    
    app_path = join(APP_ROOT, app)
    cwd = join(APP_ROOT, app)
    log_path = join(LOG_ROOT, app)
    config = get_app_config(app)

    env = {'GIT_WORK_DIR': app_path}
    if exists(app_path):
        _print("Deploying app '{}'".format(app), type="success", arrow=True)
        _call('git fetch --quiet', cwd=app_path, env=env, shell=True)
        if newrev:
            _call('git reset --hard {}'.format(newrev), cwd=app_path, env=env, shell=True)
        _call('git submodule init', cwd=app_path, env=env, shell=True)
        _call('git submodule update', cwd=app_path, env=env, shell=True)

        if not config:
            _print("Error: Invalid app.json for app '{}'.".format(app), type="error")
        else:
            if not os.path.isdir(log_path):
                os.makedirs(log_path)

            app_runtime = get_app_runtime(app)
            if not app_runtime:
                _print("Could not detect runtime!", arrow=True, type="error")
            else:
                _print("[%s] app detected." % app_runtime, arrow=True, type="success")

                env_path = join(ENV_ROOT, app)
                if not exists(env_path):
                    os.makedirs(env_path)

                # python
                if app_runtime == "python":
                    deploy_python(app, deltas)
                # node
                elif app_runtime == "node":
                    deploy_node(app, deltas)
                # php
                elif app_runtime == "php":
                    deploy_php(app, deltas)                    
                # static
                elif app_runtime == "static":
                    deploy_static(app, deltas)

    else:
        _print("Error: app '%s' not found." % app, type="error")


def run_app_scripts(app, script_type, env=None):
    cwd = join(APP_ROOT, app)
    config = get_app_config(app)
    if "scripts" in config and script_type in config["scripts"]:
        _print("Running scripts: %s ..." % script_type, arrow=True)
        for cmd in config["scripts"][script_type]:
            _call(cmd, cwd, env=env)

def deploy_python(app, deltas={}):
    """Deploy a Python application"""
    
    cwd = join(APP_ROOT, app)
    virtualenv_path = join(ENV_ROOT, app)
    requirements = join(APP_ROOT, app, 'requirements.txt')
    env_file = join(APP_ROOT, app, 'ENV')
    # Peek at environment variables shipped with repo (if any) to determine version
    env = {}
    if exists(env_file):
        env.update(parse_procfile(env_file, env))

    version = int(env.get("PYTHON_VERSION", "3"))

    first_time = False
    if not exists(virtualenv_path):
        _print("Creating virtualenv for '{}'".format(app), type="success")
        os.makedirs(virtualenv_path)
        _call('virtualenv --python=python{version:d} {app:s}'.format(**locals()), cwd=ENV_ROOT)
        first_time = True

    activation_script = join(virtualenv_path,'bin','activate_this.py')
    #exec(open(activation_script).read(), dict(__file__=activation_script))

    if first_time or getmtime(requirements) > getmtime(virtualenv_path):
        _print("Running pip install...", type="success", arrow=True)
        _call('pip install -r %s' % requirements, cwd=cwd)

    run_app_scripts(app, "before_deploy")
    spawn_app(app, deltas)
    run_app_scripts(app, "after_deploy")

def deploy_node(app, deltas={}):
    """Deploy a Node  application"""

    virtualenv_path = join(ENV_ROOT, app)
    node_path = join(ENV_ROOT, app, "node_modules")
    node_path_tmp = join(APP_ROOT, app, "node_modules")
    env_file = join(APP_ROOT, app, CONFIG_FILE_NAME)
    deps = join(APP_ROOT, app, 'package.json')

    first_time = False
    if not exists(node_path):
        _print("Creating node_modules for '{}'".format(app),  arrow=True)
        os.makedirs(node_path)
        first_time = True

    env = {
        'VIRTUAL_ENV': virtualenv_path,
        'NODE_PATH': node_path,
        'NPM_CONFIG_PREFIX': abspath(join(node_path, "..")),
        "PATH": ':'.join([join(virtualenv_path, "bin"), join(node_path, ".bin"),os.environ['PATH']])
    }
    if exists(env_file):
        env.update(read_settings_file(env_file))

    version = env.get("NODE_VERSION")
    node_binary = join(virtualenv_path, "bin", "node")
    installed = check_output("{} -v".format(node_binary), cwd=join(APP_ROOT, app), env=env, shell=True).decode("utf8").rstrip("\n") if exists(node_binary) else ""

    if version and has_bin_dependencies(['nodeenv']):
        if not installed.endswith(version):
            started = glob.glob(join(UWSGI_ENABLED, '{}*.ini'.format(app)))
            if installed and len(started):
                _print("Warning: Can't update node with app running. Stop the app & retry.", type="warning")
            else:
                _print("Installing node version '{NODE_VERSION:s}' using nodeenv".format(**env), type="success", arrow=TreeBuilder(element_factory=None))
                _call("nodeenv --prebuilt --node={NODE_VERSION:s} --clean-src --force {VIRTUAL_ENV:s}".format(**env), cwd=virtualenv_path, env=env, shell=True)
        else:
            _print("Node is installed at {}.".format(version), arrow=True)

    if exists(deps) and has_bin_dependencies(['npm']):
        if first_time or getmtime(deps) > getmtime(node_path):
            _print("Running npm for '{}'".format(app), type="success", arrow=True)
            os.symlink(node_path, node_path_tmp)
            _call('npm install', cwd=join(APP_ROOT, app), env=env, shell=True)
            os.unlink(node_path_tmp)
    
    run_app_scripts(app, "before_deploy")
    spawn_app(app, deltas)
    run_app_scripts(app, "after_deploy")

def deploy_php(app, deltas={}):
    pass

def deploy_static(app, deltas={}):
    return spawn_app(app, deltas)


def spawn_app(app, deltas={}):
    """Create all workers for an app"""
    
    cwd = join(APP_ROOT, app)
    config = get_app_config(app)
    proc = get_app_proc(app) 
    
    ordinals = collections.defaultdict(lambda:1)
    worker_count = {k:1 for k in proc.keys()}

    # the Python virtualenv
    virtualenv_path = join(ENV_ROOT, app)
    # Settings shipped with the app
    env_file = join(APP_ROOT, app, CONFIG_FILE_NAME)
    # Custom overrides
    settings = join(ENV_ROOT, app, 'ENV')
    # Live settings
    live = join(ENV_ROOT, app, 'LIVE_ENV')
    # Scaling
    scaling = join(ENV_ROOT, app, 'SCALING')

    env = {
        'APP': app,
        'LOG_ROOT': LOG_ROOT,
        'HOME': os.environ['HOME'],
        'USER': os.environ['USER'],
        'PATH': ':'.join([join(virtualenv_path,'bin'),os.environ['PATH']]),
        'PWD': cwd,
        'VIRTUAL_ENV': virtualenv_path,
    }

    safe_defaults = {
        'NGINX_IPV4_ADDRESS': '0.0.0.0',
        'NGINX_IPV6_ADDRESS': '[::]',
        'BIND_ADDRESS': '127.0.0.1',
    }

    # add node path if present
    node_path = join(virtualenv_path, "node_modules")
    if os.path.isdir(node_path):
        env["NODE_PATH"] = node_path
        env["PATH"] = ':'.join([join(node_path, ".bin"),env['PATH']])

    if "web" in proc:
        env.update(get_app_env(app))

        # Pick a port if none defined
        if 'PORT' not in env:
            env['PORT'] = str(generate_random_port())
            _print("picked free port {PORT}".format(**env),  arrow=True)

        # Safe defaults for addressing     
        for k, v in safe_defaults.items():
            if k not in env:
                _print("nginx {k:s} set to {v}".format(**locals()),  arrow=True)
                env[k] = v
                
        # Set up nginx if we have NGINX_SERVER_NAME set
        if 'NGINX_SERVER_NAME' in env:
            nginx = get_cmd_output("nginx -V")
            nginx_ssl = "443 ssl"
            if "--with-http_v2_module" in nginx:
                nginx_ssl += " http2"
            nginx_conf = join(NGINX_ROOT,"{}.conf".format(app))
        
            env.update({ 
                'NGINX_SSL': nginx_ssl,
                'NGINX_ROOT': NGINX_ROOT,
                'ACME_WWW': ACME_WWW,
            })
            
            proxy_pass = '{BIND_ADDRESS:s}:{PORT:s}'.format(**env)
            env['INTERNAL_NGINX_UWSGI_SETTINGS'] = 'proxy_pass http://%s;' % proxy_pass
            env['NGINX_SOCKET'] = proxy_pass 
            _print("nginx will look for app '{}' on {}".format(app, env['NGINX_SOCKET']), arrow=True)

            domain = env['NGINX_SERVER_NAME'].split()[0]       
            key, crt = [join(NGINX_ROOT, "{}.{}".format(app,x)) for x in ['key','crt']]
            if exists(join(ACME_ROOT, "acme.sh")):
                acme = ACME_ROOT
                www = ACME_WWW
                # if this is the first run there will be no nginx conf yet
                # create a basic conf stub just to serve the acme auth
                if not exists(nginx_conf):
                    _print("writing temporary nginx conf", True)
                    buffer = expand_vars(NGINX_ACME_FIRSTRUN_TEMPLATE, env)
                    with open(nginx_conf, "w") as h:
                        h.write(buffer)
                if not exists(key) or not exists(join(ACME_ROOT, domain, domain + ".key")):
                    _print("getting letsencrypt certificate", True)
                    _call('{acme:s}/acme.sh --issue -d {domain:s} -w {www:s}'.format(**locals()), shell=True)
                    _call('{acme:s}/acme.sh --install-cert -d {domain:s} --key-file {key:s} --fullchain-file {crt:s}'.format(**locals()), shell=True)
                    if exists(join(ACME_ROOT, domain)) and not exists(join(ACME_WWW, app)):
                        os.symlink(join(ACME_ROOT, domain), join(ACME_WWW, app))
                else:
                    _print("letsencrypt certificate already installed")

            # fall back to creating self-signed certificate if acme failed
            if not os.path.isfile(key) or os.stat(crt).st_size == 0:
                _print("generating self-signed certificate")
                _call('openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=NY/L=New York/O=Koka/OU=Self-Signed/CN={domain:s}" -keyout {key:s} -out {crt:s}'.format(**locals()), shell=True)
            
            # restrict access to server from CloudFlare IP addresses
            acl = []
            if env.get('NGINX_CLOUDFLARE_ACL') is True:
                try:
                    cf = json.loads(urlopen('https://api.cloudflare.com/client/v4/ips').read().decode("utf-8"))
                    if cf['success'] is True:
                        for i in cf['result']['ipv4_cidrs']:
                            acl.append("allow {};".format(i))
                        for i in cf['result']['ipv6_cidrs']:
                            acl.append("allow {};".format(i))
                        # allow access from controlling machine
                        if 'SSH_CLIENT' in environ:
                            remote_ip = os.environ['SSH_CLIENT'].split()[0]
                            _print("Adding your IP ({}) to nginx ACL".format(remote_ip),  arrow=True)
                            acl.append("allow {};".format(remote_ip))
                        acl.extend(["allow 127.0.0.1;","deny all;"])
                except Exception:
                    cf = collections.defaultdict()
                    _print("Could not retrieve CloudFlare IP ranges: {}".format(traceback.format_exc()),  arrow=True)

            env['NGINX_ACL'] = " ".join(acl)
            env['INTERNAL_NGINX_STATIC_MAPPINGS'] = ''

            # Static url
            # Get a mapping of [/url:path1 , /url2:path2]
            static_paths = env.get('NGINX_STATIC_PATHS', [])
            if len(static_paths):
                try:
                    for item in static_paths:
                        static_url, static_path = item.split(':', 2)
                        if static_path[0] != '/':
                            static_path = join(cwd, static_path)
                        env['INTERNAL_NGINX_STATIC_MAPPINGS'] = env['INTERNAL_NGINX_STATIC_MAPPINGS'] + expand_vars(INTERNAL_NGINX_STATIC_MAPPING, locals())
                except Exception as e:
                    _print("Error {} in static path spec: should be [/url1:path1, /url2:path2, ...], ignoring.".format(e), type="error")
                    env['INTERNAL_NGINX_STATIC_MAPPINGS'] = ''

            env['INTERNAL_NGINX_CUSTOM_CLAUSES'] = expand_vars(open(join(cwd, env["NGINX_INCLUDE_FILE"])).read(), env) if env.get("NGINX_INCLUDE_FILE") else ""
            env['INTERNAL_NGINX_PORTMAP'] = expand_vars(NGINX_PORTMAP_FRAGMENT, env)
            env['INTERNAL_NGINX_COMMON'] = expand_vars(NGINX_COMMON_FRAGMENT, env)

            _print("nginx will map app '{}' to hostname '{}'".format(app, env['NGINX_SERVER_NAME']))
            if 'NGINX_HTTPS_ONLY' in env or 'HTTPS_ONLY' in env:
                buffer = expand_vars(NGINX_HTTPS_ONLY_TEMPLATE, env)
                _print("nginx will redirect all requests to hostname '{}' to HTTPS".format(env['NGINX_SERVER_NAME']))
            else:
                buffer = expand_vars(NGINX_TEMPLATE, env)
            with open(nginx_conf, "w") as h:
                h.write(buffer)
            # prevent broken config from breaking other deploys
            try:
                nginx_config_test = str(check_output("nginx -t 2>&1 | grep {}".format(app), env=environ, shell=True))
            except Exception as e:
                nginx_config_test = None
            if nginx_config_test:
                _print("Error: [nginx config] {}".format(nginx_config_test), type="error")
                _print("Warning: removing broken nginx config.", type="warning")
                os.remove(nginx_conf)

    # Configured worker count
    if exists(scaling):
        worker_count.update({k: int(v) for k,v in parse_procfile(scaling).items() if k in proc})
    
    to_create = {}
    to_destroy = {}    
    for k, v in worker_count.items():
        to_create[k] = range(1,worker_count[k] + 1)
        if k in deltas and deltas[k]:
            to_create[k] = range(1, worker_count[k] + deltas[k] + 1)
            if deltas[k] < 0:
                to_destroy[k] = range(worker_count[k], worker_count[k] + deltas[k], -1)
            worker_count[k] = worker_count[k]+deltas[k]

    # Cleanup env
    for k, v in list(env.items()):
        if k.startswith('INTERNAL_'):
            del env[k]

    # Save current settings
    write_config(live, env)
    write_config(scaling, worker_count, ':')
    
    if env.get("AUTO_RESTART") is True:
        config = glob.glob(join(UWSGI_ENABLED, '{}*.ini'.format(app)))
        if len(config):
            _print("Removing uwsgi configs to trigger auto-restart.", True)
            for c in config:
                os.remove(c)

    # Create new proc
    for k, v in to_create.items():
        for w in v:
            enabled = join(UWSGI_ENABLED, '{app:s}_{k:s}.{w:d}.ini'.format(**locals()))
            if not exists(enabled):
                _print("spawning '{app:s}:{k:s}.{w:d}'".format(**locals()), True)
                spawn_worker(app=app, kind=k, command=proc[k], env=env, ordinal=w)
        
    # Remove unnecessary proc (leave logfiles)
    for k, v in to_destroy.items():
        for w in v:
            enabled = join(UWSGI_ENABLED, '{app:s}_{k:s}.{w:d}.ini'.format(**locals()))
            if exists(enabled):
                _print("terminating '{app:s}:{k:s}.{w:d}'".format(**locals()), arrow=True, type="warning")
                os.remove(enabled)

    return env
    

def spawn_worker(app, kind, command, env, ordinal=1):
    """Set up and deploy a single worker of a given kind"""

    app_runtime = get_app_runtime(app)
    if not app_runtime:
        _print("Could not detect runtime!", arrow=True, type="error")
        return

    cwd = join(APP_ROOT, app)
    config = get_app_config(app)

    if app_runtime == "python":
        kind = "wsgi"
    elif app_runtime == "static":
        kind = "static"
    env['PROC_TYPE'] = kind

    env_path = join(ENV_ROOT, app)
    available = join(UWSGI_AVAILABLE, '{app:s}_{kind:s}.{ordinal:d}.ini'.format(**locals()))
    enabled = join(UWSGI_ENABLED, '{app:s}_{kind:s}.{ordinal:d}.ini'.format(**locals()))
    log_file = join(LOG_ROOT, app, kind)



    settings = [
        ('chdir',               cwd),
        ('master',              'true'),
        ('project',             app),
        ('max-requests',        env.get('UWSGI_MAX_REQUESTS', '1024')),
        ('listen',              env.get('UWSGI_LISTEN', '16')),
        ('processes',           env.get('UWSGI_PROCESSES', '1')),
        ('procname-prefix',     '{app:s}:{kind:s}'.format(**locals())),
        ('enable-threads',      env.get('UWSGI_ENABLE_THREADS', 'true').lower()),
        ('log-x-forwarded-for', env.get('UWSGI_LOG_X_FORWARDED_FOR', 'false').lower()),
        ('log-maxsize',         env.get('UWSGI_LOG_MAXSIZE', UWSGI_LOG_MAXSIZE)),
        ('logto',               '{log_file:s}.{ordinal:d}.log'.format(**locals())),
        ('log-backupname',      '{log_file:s}.{ordinal:d}.log.old'.format(**locals())),
    ]

    # only add virtualenv to uwsgi if it's a real virtualenv
    if exists(join(env_path, "bin", "activate_this.py")):
        settings.append(('virtualenv', env_path))

    if kind == 'wsgi':
        settings.extend([('module', command), ('threads', env.get('UWSGI_THREADS','4'))])

        python_version = int(env.get('PYTHON_VERSION','3'))
        if python_version == 2:
            settings.extend([('plugin','python')])
            if 'UWSGI_GEVENT' in env:
                settings.extend([('plugin','gevent_python'),('gevent',env['UWSGI_GEVENT'])])
            elif 'UWSGI_ASYNCIO' in env:
                settings.extend([('plugin','asyncio_python')])
        elif python_version == 3:
            settings.extend([('plugin','python3'),])
            if 'UWSGI_ASYNCIO' in env:
                settings.extend([('plugin','asyncio_python3'),])
            
        # If running under nginx, don't expose a port at all
        if 'NGINX_SERVER_NAME' in env:
            sock = join(NGINX_ROOT, "%s.sock" % app)
            _print("nginx will talk to uWSGI via {}".format(sock), type="warning", arrow=True)
            settings.extend([('socket', sock), ('chmod-socket', '664')])
        else:
            _ = '{BIND_ADDRESS:s}:{PORT:s}'.format(**env)
            _print("nginx will talk to uWSGI via: %s " % _, type="warning", arrow=True)
            settings.extend([ ('http', _), ('http-socket', _)])
    elif kind == 'web':
        _print("nginx will talk to the 'web' process via {BIND_ADDRESS:s}:{PORT:s}".format(**env), type="waring", arrow=True)
        settings.append(('attach-daemon', command))
    elif kind == 'static':
        _print("nginx serving static files only".format(**env), type="waring", arrow=True)
    else:
        settings.append(('attach-daemon', command))

    if kind in ['wsgi', 'web']:
        settings.append(('log-format','%%(addr) - %%(user) [%%(ltime)] "%%(method) %%(uri) %%(proto)" %%(status) %%(size) "%%(referer)" "%%(uagent)" %%(msecs)ms'))
        
    # remove unnecessary variables from the env in nginx.ini
    for k in ['NGINX_ACL']:
        if k in env:
            del env[k]

    # insert user defined uwsgi settings if set
    settings += read_settings_file(join(APP_ROOT, app, env.get("UWSGI_INCLUDE_FILE"))).items() if env.get("UWSGI_INCLUDE_FILE") else []

    for k, v in env.items():
        settings.append(('env', '{k:s}={v}'.format(**locals())))

    if kind != 'static':
        with open(available, 'w') as h:
            h.write('[uwsgi]\n')
            for k, v in settings:
                h.write("{k:s} = {v}\n".format(**locals()))

        shutil.copyfile(available, enabled)

def do_restart(app):
    config = glob.glob(join(UWSGI_ENABLED, '{}*.ini'.format(app)))

    if len(config):
        _print("Restarting app '{}'...".format(app), type="warning")
        for c in config:
            os.remove(c)
        spawn_app(app)
    else:
        _print("Error: app '%s' not deployed" % app, type="error")


def multi_tail(app, filenames, catch_up=20):
    """Tails multiple log files"""
    
    # Seek helper
    def peek(handle):
        where = handle.tell()
        line = handle.readline()
        if not line:
            handle.seek(where)
            return None
        return line

    inodes = {}
    files = {}
    prefixes = {}
    
    # Set up current state for each log file
    for f in filenames:
        prefixes[f] = splitext(basename(f))[0]
        files[f] = open(f)
        inodes[f] = os.stat(f).st_ino
        files[f].seek(0, 2)
        
    longest = max(map(len, prefixes.values()))
    
    # Grab a little history (if any) 
    for f in filenames:
        for line in collections.deque(open(f), catch_up):
            yield "{} | {}".format(prefixes[f].ljust(longest), line)

    while True:
        updated = False
        # Check for updates on every file
        for f in filenames:
            line = peek(files[f])
            if not line:
                continue
            else:
                updated = True
                yield "{} | {}".format(prefixes[f].ljust(longest), line)
                
        if not updated:
            time.sleep(1)
            # Check if logs rotated
            for f in filenames:
                if exists(f):
                    if os.stat(f).st_ino != inodes[f]:
                        files[f] = open(f)
                        inodes[f] = os.stat(f).st_ino
                else:
                    filenames.remove(f)

# -------------------------------------------------------------------------------------
# === CLI commands ===    
    
@click.group()
def cli():
    """Koka, the smallest PaaS you've ever seen"""
    pass

    
@cli.resultcallback()
def cleanup(ctx):
    """Callback from command execution -- add debugging to taste"""
    pass


# --- User commands ---

@cli.command("apps")
def list_apps():
    """List all apps"""
    
    for a in os.listdir(APP_ROOT):
        if not a.startswith((".", "_")):
            _print(a, type="success")


@cli.command("config")
@click.argument('app')
def cmd_config(app):
    """<app>: Show an app config"""
    
    exit_if_invalid(app)

    app = sanitize_app_name(app)
    config_file = join(ENV_ROOT, app, CONFIG_FILE_NAME)
    if exists(config_file):
        _print(open(config_file).read().strip(), fg='white')
    else:
        _print("Warning: app '{}' not deployed, no config found.".format(app), type="warning")


@cli.command("config-get")
@click.argument('app')
@click.argument('setting')
def cmd_config_get(app, setting):
    """e.g.: koka config:get <app> FOO"""
    
    exit_if_invalid(app)
    app = sanitize_app_name(app)
    
    config_file = join(ENV_ROOT, app, 'ENV')
    if exists(config_file):
        env = read_settings_file(config_file)
        if setting in env:
            _print("{}".format(env[setting]))
    else:
        _print("Warning: no active configuration for '{}'".format(app), type="warning")


@cli.command("config-set")
@click.argument('app')
@click.argument('settings', nargs=-1)
def cmd_config_set(app, settings):
    """<app> [KEY1=VAL1, ...] - Set config"""
    
    exit_if_invalid(app)
    app  = sanitize_app_name(app)
    
    config_file = join(ENV_ROOT, app, 'ENV')
    env = read_settings_file(config_file)
    for s in settings:
        try:
            k, v = map(lambda x: x.strip(), s.split("=", 1))
            env[k] = v
            _print("Setting {k:s}={v} for '{app:s}'".format(**locals()))
        except:
            _print("Error: malformed setting %s " %s , type="error")
            return
    write_config(config_file, env)
    do_deploy(app)


@cli.command("config-unset")
@click.argument('app')
@click.argument('settings', nargs=-1)
def cmd_config_unset(app, settings):
    """e.g.: koka config:unset <app> FOO"""
    
    exit_if_invalid(app)
    app = sanitize_app_name(app)
    
    config_file = join(ENV_ROOT, app, 'ENV')
    env = read_settings_file(config_file)
    for s in settings:
        if s in env:
            del env[s]
            _print("Unsetting {} for '{}'".format(s, app))
    write_config(config_file, env)
    do_deploy(app)


@cli.command("config:live")
@click.argument('app')
def cmd_config_live(app):
    """e.g.: koka config:live <app>"""
    
    exit_if_invalid(app)
    app = sanitize_app_name(app)

    live_config = join(ENV_ROOT, app, 'LIVE_ENV')
    if exists(live_config):
        _print(open(live_config).read().strip())
    else:
        _print("Warning: app '{}' not deployed, no config found.".format(app), type="warning")


@cli.command("deploy")
@click.argument('app')
def cmd_deploy(app):
    """<app>: Deploy an app"""
    
    exit_if_invalid(app)
    app = sanitize_app_name(app)
    do_deploy(app)


@cli.command("destroy")
@click.argument('app')
def cmd_destroy(app):
    """<app>: Permanently destroy an app"""
    exit_if_invalid(app)
    app = sanitize_app_name(app)
    _print("**** WARNING ****", type="warning")
    _print("**** YOU ARE ABOUT TO DESTROY AN APP ****", type="warning")
    if not click.confirm("Do you want to destroy this app? It will delete everything"):
        exit(1)
    if not click.confirm("Are you really sure?"):
        exit(1)

    for p in [join(x, app) for x in [APP_ROOT, GIT_ROOT, ENV_ROOT, LOG_ROOT]]:
        if exists(p):
            _print("Removing folder '%s'" % p, type="warning")
            shutil.rmtree(p)

    for p in [join(x, '%s*.ini' % app) for x in [UWSGI_AVAILABLE, UWSGI_ENABLED]]:
        g = glob.glob(p)
        for f in g:
            _print("Removing file '{}'".format(f), type="warning")
            os.remove(f)
                
    nginx_files = [join(NGINX_ROOT, "{}.{}".format(app,x)) for x in ['conf','sock','key','crt']]
    for f in nginx_files:
        if exists(f):
            _print("Removing file '{}'".format(f), type="warning")
            os.remove(f)

    acme_link = join(ACME_WWW, app)
    acme_certs = realpath(acme_link)
    if exists(acme_certs):
        _print("Removing folder '{}'".format(acme_certs), type="warning")
        shutil.rmtree(acme_certs)
        _print("Removing file '{}'".format(acme_link), type="warning")
        os.unlink(acme_link)

    
@cli.command("logs")
@click.argument('app')
def cmd_logs(app):
    """<app>: Run tail logs"""
    
    exit_if_invalid(app)
    app = sanitize_app_name(app)

    logfiles = glob.glob(join(LOG_ROOT, app, '*.log'))
    if len(logfiles):
        for line in multi_tail(app, logfiles):
            _print(line.strip())
    else:
        _print("No logs found for app '{}'.".format(app), type="error")


@cli.command("ps")
@click.argument('app')
def cmd_ps(app):
    """<app>: Show process"""
    
    exit_if_invalid(app)
    app = sanitize_app_name(app)

    config_file = join(ENV_ROOT, app, 'SCALING')
    if exists(config_file):
        _print(open(config_file).read().strip())
    else:
        _print("Error: no workers found for app '%s'." % app, type="error")


@cli.command("scale")
@click.argument('app')
@click.argument('settings', nargs=-1)
def cmd_ps_scale(app, settings):
    """<app> [<proc>=<count> ...]: Scale process"""
    
    exit_if_invalid(app)
    app = sanitize_app_name(app)

    config_file = join(ENV_ROOT, app, 'SCALING')
    worker_count = {k:int(v) for k, v in parse_procfile(config_file).items()}
    deltas = {}
    for s in settings:
        try:
            k, v = map(lambda x: x.strip(), s.split("=", 1))
            c = int(v) # check for integer value
            if c < 0:
                _print("Error: cannot scale type '%s' below 0" % k, type="error")
                return
            if k not in worker_count:
                _print("Error: worker type '%s' not present in '%s'" % (k, app), type="error")
                return
            deltas[k] = c - worker_count[k]
        except:
            _print("Error: malformed setting '%s'" % s, type="error")
            return
    do_deploy(app, deltas)


@cli.command("restart")
@click.argument('app')
def cmd_restart(app):
    """<app>: Restart an app"""
    
    exit_if_invalid(app)
    app = sanitize_app_name(app)
    do_restart(app)


@cli.command("stop")
@click.argument('app')
def cmd_stop(app):
    """<app>: Stop an app"""

    exit_if_invalid(app)
    app = sanitize_app_name(app)
    config = glob.glob(join(UWSGI_ENABLED, '{}*.ini'.format(app)))

    if len(config):
        _print("Stopping app '{}'...".format(app), type="warning")
        for c in config:
            os.remove(c)
    else:
        _print("Error: app '{}' not deployed!".format(app), type="error")
       

@cli.command("init")
def cmd_setup():
    """Initialize environment"""

    _print("Running in Python {}".format(".".join(map(str,version_info))))
    
    _print("creating new user: %s" % USER, type="success", arrow=True)
    _call("adduser --disabled-password --gecos 'PaaS access' --ingroup www-data %s" % USER)
        
    # Create required paths
    for p in [APP_ROOT, GIT_ROOT, ACME_WWW, ENV_ROOT, UWSGI_ROOT, UWSGI_AVAILABLE, UWSGI_ENABLED, LOG_ROOT, NGINX_ROOT]:
        if not os.path.isdir(p):
            _print("Creating %s" % p)
            os.makedirs(p)

    # Set up the uWSGI emperor config
    settings = [
        ('chdir',           UWSGI_ROOT),
        ('emperor',         UWSGI_ENABLED),
        ('log-maxsize',     UWSGI_LOG_MAXSIZE),
        ('logto',           join(UWSGI_ROOT, 'uwsgi.log')),
        ('log-backupname',  join(UWSGI_ROOT, 'uwsgi.old.log')),
        ('socket',          join(UWSGI_ROOT, 'uwsgi.sock')),
        ('uid',             pwd.getpwuid(os.getuid()).pw_name),
        ('gid',             grp.getgrgid(os.getgid()).gr_name),
        ('enable-threads',  'true'),
        ('threads',         '{}'.format(multiprocessing.cpu_count() * 2)),
    ]
    with open(join(UWSGI_ROOT,'uwsgi.ini'), 'w') as h:
        h.write('[uwsgi]\n')
        for k, v in settings:
            h.write("{k:s} = {v}\n".format(**locals()))

    # acme
    install_acme_sh()

    # 



@cli.command("setup-ssh")
@click.argument('public_key_file')
def cmd_setup_ssh(public_key_file):
    """Set up a new SSH key (use - for stdin)"""

    def add_helper(key_file):
        if exists(key_file):
            try:
                fingerprint = str(check_output('ssh-keygen -lf ' + key_file, shell=True)).split(' ', 4)[1]
                with open(key_file) as f:
                    key = f.read().strip()
                _print("Adding key '{}'.".format(fingerprint))
                setup_authorized_keys(fingerprint, KOKA_CMD, key)
            except Exception:
                _print("Error: invalid public key file '{}': {}".format(key_file, traceback.format_exc()), type="error")
        elif '-' == public_key_file:
            buffer = "".join(stdin.readlines())
            with tempfile.NamedTemporaryFile(mode="w") as f:
                f.write(buffer)
                f.flush()
                add_helper(f.name)
        else:
            _print("Error: public key file '{}' not found.".format(key_file), type="error")

    add_helper(public_key_file) 
        
# --- Internal commands ---


def git_hook(app):
    """INTERNAL: Post-receive git hook"""
    
    app = sanitize_app_name(app)
    repo_path = join(GIT_ROOT, app)
    app_path = join(APP_ROOT, app)


    #--- Run setup scripts
    # run_app_scripts(app, "setup")


    # for line in stdin:
    #     # pylint: disable=unused-variable
    #     oldrev, newrev, refname = line.strip().split(" ")
    #     # Handle pushes
    #     if not exists(app_path):
    #         _print("-----> Creating app '{}'".format(app))
    #         os.makedirs(app_path)
    #         _call('git clone --quiet {} {}'.format(repo_path, app), cwd=APP_ROOT, shell=True)
    #     do_deploy(app, newrev=newrev)



def git_receive_pack(app):
    """INTERNAL: Handle git pushes for an app"""

    app = sanitize_app_name(app)

    hook_path = join(GIT_ROOT, app, 'hooks', 'post-receive')
    env = globals()
    env.update(locals())

    if not exists(hook_path):
        os.makedirs(dirname(hook_path))
        # Initialize the repository with a hook to this script
        _call("git init --quiet --bare " + app, cwd=GIT_ROOT, shell=True)
        with open(hook_path, 'w') as h:
            h.write("""#!/usr/bin/env bash
set -e; set -o pipefail;
cat | KOKA_ROOT="{KOKA_ROOT:s}" {KOKA_CMD:s} git-hook {app:s}""".format(**env))
        # Make the hook executable by our user
        os.chmod(hook_path, os.stat(hook_path).st_mode | stat.S_IXUSR)
    # Handle the actual receive. We'll be called with 'git-hook' after it happens
    #_call('git-shell -c "{}" '.format(argv[1] + " '{}'".format(app)), cwd=GIT_ROOT, shell=True)


def git_upload_pack(app):
    """INTERNAL: Handle git upload pack for an app"""

    env = globals()
    env.update(locals())
    # Handle the actual receive. We'll be called with 'git-hook' after it happens
    #_call('git-shell -c "{}" '.format(argv[1] + " '{}'".format(app)), cwd=GIT_ROOT, shell=True)


def main():
    _argvs = sys.argv
    script_name = sys.argv[0].split('/')[-1]

    # Internal GIT command
    if _argvs and len(_argvs) >= 3 and _argvs[1] in ["git-hook", "git-upload-pack", "git-receive-pack"]:
        cmd = sys.argv[1]
        app = sys.argv[2]
        if cmd == "git-hook":
            git_hook(app)
        elif cmd == "git-upload-pack":
            git_upload_pack(app)
        elif cmd == "git-receive-pack":
            git_receive_pack(app)
    else:
        cli()
