#!/usr/bin/env python3 "Piku Micro-PaaS" try: from sys import version_info assert version_info >= (3, 7) except AssertionError: exit("Piku requires Python 3.7 or above") from importlib import import_module from collections import defaultdict, deque from fcntl import fcntl, F_SETFL, F_GETFL from glob import glob from json import loads from multiprocessing import cpu_count from os import chmod, getgid, getuid, symlink, unlink, remove, stat, listdir, environ, makedirs, O_NONBLOCK from os.path import abspath, basename, dirname, exists, getmtime, join, realpath, splitext, isdir from pwd import getpwuid from grp import getgrgid from re import sub, match from shutil import copyfile, rmtree, which from socket import socket, AF_INET, SOCK_STREAM from stat import S_IRUSR, S_IWUSR, S_IXUSR from subprocess import call, check_output, Popen, STDOUT from sys import argv, stdin, stdout, stderr, version_info, exit, path as sys_path from tempfile import NamedTemporaryFile from time import sleep from traceback import format_exc from urllib.request import urlopen from click import argument, group, secho as echo, pass_context, CommandCollection # === Make sure we can access all system binaries === if 'sbin' not in environ['PATH']: environ['PATH'] = "/usr/local/sbin:/usr/sbin:/sbin:" + environ['PATH'] # === Globals - all tweakable settings are here === PIKU_RAW_SOURCE_URL = "https://raw.githubusercontent.com/piku/piku/master/piku.py" PIKU_ROOT = environ.get('PIKU_ROOT', join(environ['HOME'], '.piku')) PIKU_BIN = join(environ['HOME'], 'bin') PIKU_SCRIPT = realpath(__file__) PIKU_PLUGIN_ROOT = abspath(join(PIKU_ROOT, "plugins")) APP_ROOT = abspath(join(PIKU_ROOT, "apps")) DATA_ROOT = abspath(join(PIKU_ROOT, "data")) ENV_ROOT = abspath(join(PIKU_ROOT, "envs")) GIT_ROOT = abspath(join(PIKU_ROOT, "repos")) LOG_ROOT = abspath(join(PIKU_ROOT, "logs")) NGINX_ROOT = abspath(join(PIKU_ROOT, "nginx")) CACHE_ROOT = abspath(join(PIKU_ROOT, "cache")) UWSGI_AVAILABLE = abspath(join(PIKU_ROOT, "uwsgi-available")) UWSGI_ENABLED = abspath(join(PIKU_ROOT, "uwsgi-enabled")) UWSGI_ROOT = abspath(join(PIKU_ROOT, "uwsgi")) UWSGI_LOG_MAXSIZE = '1048576' ACME_ROOT = environ.get('ACME_ROOT', join(environ['HOME'], '.acme.sh')) ACME_WWW = abspath(join(PIKU_ROOT, "acme")) ACME_ROOT_CA = environ.get('ACME_ROOT_CA', 'letsencrypt.org') # === Make sure we can access piku user-installed binaries === # if PIKU_BIN not in environ['PATH']: environ['PATH'] = PIKU_BIN + ":" + environ['PATH'] # pylint: disable=anomalous-backslash-in-string NGINX_TEMPLATE = """ $PIKU_INTERNAL_PROXY_CACHE_PATH 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}; } $PIKU_INTERNAL_NGINX_COMMON } """ NGINX_HTTPS_ONLY_TEMPLATE = """ $PIKU_INTERNAL_PROXY_CACHE_PATH 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}; } location / { return 301 https://$server_name$request_uri; } } server { $PIKU_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_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 text/javascript text/js application/x-javascript application/javascript application/json application/xml+rss application/atom+xml image/svg+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 Piku; $PIKU_INTERNAL_NGINX_CUSTOM_CLAUSES $PIKU_INTERNAL_NGINX_STATIC_MAPPINGS $PIKU_INTERNAL_NGINX_CACHE_MAPPINGS $PIKU_INTERNAL_NGINX_BLOCK_GIT $PIKU_INTERNAL_NGINX_PORTMAP """ NGINX_PORTMAP_FRAGMENT = """ location / { $PIKU_INTERNAL_NGINX_UWSGI_SETTINGS proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Remote-Address $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}; } } """ PIKU_INTERNAL_NGINX_STATIC_MAPPING = """ location $static_url { sendfile on; sendfile_max_chunk 1m; tcp_nopush on; directio 8m; aio threads; alias $static_path; try_files $uri $uri.html $uri/ =404; } """ PIKU_INTERNAL_PROXY_CACHE_PATH = """ uwsgi_cache_path $cache_path levels=1:2 keys_zone=$app:20m inactive=$cache_time_expiry max_size=$cache_size use_temp_path=off; """ PIKU_INTERNAL_NGINX_CACHE_MAPPING = """ location ~* ^/($cache_prefixes) { uwsgi_cache $APP; uwsgi_cache_min_uses 1; uwsgi_cache_key $host$uri; uwsgi_cache_valid 200 304 $cache_time_content; uwsgi_cache_valid 301 307 $cache_time_redirects; uwsgi_cache_valid 500 502 503 504 0s; uwsgi_cache_valid any $cache_time_any; uwsgi_hide_header Cache-Control; add_header Cache-Control "public, max-age=$cache_time_control"; add_header X-Cache $upstream_cache_status; $PIKU_INTERNAL_NGINX_UWSGI_SETTINGS } """ PIKU_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_INFO $document_uri; uwsgi_param DOCUMENT_ROOT $document_root; uwsgi_param SERVER_PROTOCOL $server_protocol; uwsgi_param X_FORWARDED_FOR $proxy_add_x_forwarded_for; 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; """ CRON_REGEXP = "^((?:(?:\*\/)?\d+)|\*) ((?:(?:\*\/)?\d+)|\*) ((?:(?:\*\/)?\d+)|\*) ((?:(?:\*\/)?\d+)|\*) ((?:(?:\*\/)?\d+)|\*) (.*)$" # === Utility functions === def sanitize_app_name(app): """Sanitize the app name and build matching path""" app = "".join(c for c in app if c.isalnum() or c in ('.', '_', '-')).rstrip().lstrip('/') return app 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)): echo("Error: app '{}' not found.".format(app), fg='red') exit(1) return app def get_free_port(address=""): """Find a free TCP port (entirely at random)""" s = socket(AF_INET, SOCK_STREAM) s.bind((address, 0)) # lgtm [py/bind-socket-all-network-interfaces] port = s.getsockname()[1] s.close() return port def get_boolean(value): """Convert a boolean-ish string to a boolean.""" return value.lower() in ['1', 'on', 'true', 'enabled', 'yes', 'y'] def write_config(filename, bag, separator='='): """Helper for writing out config files""" with open(filename, 'w') as h: # pylint: disable=unused-variable for k, v in bag.items(): h.write('{k:s}{separator:s}{v}\n'.format(**locals())) def setup_authorized_keys(ssh_fingerprint, script_path, pubkey): """Sets up an authorized_keys file to redirect SSH commands""" authorized_keys = join(environ['HOME'], '.ssh', 'authorized_keys') if not exists(dirname(authorized_keys)): 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())) chmod(dirname(authorized_keys), S_IRUSR | S_IWUSR | S_IXUSR) chmod(authorized_keys, S_IRUSR | S_IWUSR) def parse_procfile(filename): """Parses a Procfile and returns the worker types. Only one worker of each type is allowed.""" workers = {} if not exists(filename): return None with open(filename, 'r') as procfile: for line_number, line in enumerate(procfile): line = line.strip() if line.startswith("#") or not line: continue try: kind, command = map(lambda x: x.strip(), line.split(":", 1)) # Check for cron patterns if kind == "cron": limits = [59, 24, 31, 12, 7] res = match(CRON_REGEXP, command) if res: matches = res.groups() for i in range(len(limits)): if int(matches[i].replace("*/", "").replace("*", "1")) > limits[i]: raise ValueError workers[kind] = command except Exception: echo("Warning: misformatted Procfile entry '{}' at line {}".format(line, line_number), fg='yellow') if len(workers) == 0: return {} # WSGI trumps regular web workers if 'wsgi' in workers or 'jwsgi' in workers or 'rwsgi' in workers: if 'web' in workers: echo("Warning: found both 'wsgi' and 'web' workers, disabling 'web'", fg='yellow') del workers['web'] return workers def expandvars(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'(? Checking requirements: {}".format(binaries), fg='green') requirements = list(map(which, binaries)) echo(str(requirements)) if None in requirements: return False return True def found_app(kind): """Helper function to output app detected""" echo("-----> {} app detected.".format(kind), fg='green') return True def do_deploy(app, deltas={}, newrev=None): """Deploy an app by resetting the work directory""" app_path = join(APP_ROOT, app) procfile = join(app_path, 'Procfile') log_path = join(LOG_ROOT, app) env = {'GIT_WORK_DIR': app_path} if exists(app_path): echo("-----> Deploying app '{}'".format(app), fg='green') 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 exists(log_path): makedirs(log_path) workers = parse_procfile(procfile) if workers and len(workers) > 0: settings = {} if "preflight" in workers: echo("-----> Running preflight.", fg='green') retval = call(workers["preflight"], cwd=app_path, env=settings, shell=True) if retval: echo("-----> Exiting due to preflight command error value: {}".format(retval)) exit(retval) workers.pop("preflight", None) if exists(join(app_path, 'requirements.txt')) and found_app("Python"): settings.update(deploy_python(app, deltas)) elif exists(join(app_path, 'Gemfile')) and found_app("Ruby Application") and check_requirements(['ruby', 'gem', 'bundle']): settings.update(deploy_ruby(app, deltas)) elif exists(join(app_path, 'package.json')) and found_app("Node") and ( check_requirements(['nodejs', 'npm']) or check_requirements(['node', 'npm']) or check_requirements(['nodeenv'])): settings.update(deploy_node(app, deltas)) elif exists(join(app_path, 'pom.xml')) and found_app("Java Maven") and check_requirements(['java', 'mvn']): settings.update(deploy_java_maven(app, deltas)) elif exists(join(app_path, 'build.gradle')) and found_app("Java Gradle") and check_requirements(['java', 'gradle']): settings.update(deploy_java_gradle(app, deltas)) elif (exists(join(app_path, 'Godeps')) or len(glob(join(app_path, '*.go')))) and found_app("Go") and check_requirements(['go']): settings.update(deploy_go(app, deltas)) elif exists(join(app_path, 'deps.edn')) and found_app("Clojure CLI") and check_requirements(['java', 'clojure']): settings.update(deploy_clojure_cli(app, deltas)) elif exists(join(app_path, 'project.clj')) and found_app("Clojure Lein") and check_requirements(['java', 'lein']): settings.update(deploy_clojure_leiningen(app, deltas)) elif 'release' in workers and 'web' in workers: echo("-----> Generic app detected.", fg='green') settings.update(deploy_identity(app, deltas)) elif 'static' in workers: echo("-----> Static app detected.", fg='green') settings.update(deploy_identity(app, deltas)) else: echo("-----> Could not detect runtime!", fg='red') # TODO: detect other runtimes if "release" in workers: echo("-----> Releasing", fg='green') retval = call(workers["release"], cwd=app_path, env=settings, shell=True) if retval: echo("-----> Exiting due to release command error value: {}".format(retval)) exit(retval) workers.pop("release", None) else: echo("Error: Invalid Procfile for app '{}'.".format(app), fg='red') else: echo("Error: app '{}' not found.".format(app), fg='red') def deploy_java_gradle(app, deltas={}): """Deploy a Java application using Gradle""" java_path = join(ENV_ROOT, app) build_path = join(APP_ROOT, app, 'build') env_file = join(APP_ROOT, app, 'ENV') env = { 'VIRTUAL_ENV': java_path, "PATH": ':'.join([join(java_path, "bin"), join(app, ".bin"), environ['PATH']]) } if exists(env_file): env.update(parse_settings(env_file, env)) if not exists(java_path): makedirs(java_path) if not exists(build_path): echo("-----> Building Java Application") call('gradle build', cwd=join(APP_ROOT, app), env=env, shell=True) else: echo("-----> Removing previous builds") echo("-----> Rebuilding Java Application") call('gradle clean build', cwd=join(APP_ROOT, app), env=env, shell=True) return spawn_app(app, deltas) def deploy_java_maven(app, deltas={}): """Deploy a Java application using Maven""" # TODO: Use jenv to isolate Java Application environments java_path = join(ENV_ROOT, app) target_path = join(APP_ROOT, app, 'target') env_file = join(APP_ROOT, app, 'ENV') env = { 'VIRTUAL_ENV': java_path, "PATH": ':'.join([join(java_path, "bin"), join(app, ".bin"), environ['PATH']]) } if exists(env_file): env.update(parse_settings(env_file, env)) if not exists(java_path): makedirs(java_path) if not exists(target_path): echo("-----> Building Java Application") call('mvn package', cwd=join(APP_ROOT, app), env=env, shell=True) else: echo("-----> Removing previous builds") echo("-----> Rebuilding Java Application") call('mvn clean package', cwd=join(APP_ROOT, app), env=env, shell=True) return spawn_app(app, deltas) def deploy_clojure_cli(app, deltas={}): """Deploy a Clojure Application""" virtual = join(ENV_ROOT, app) target_path = join(APP_ROOT, app, 'target') env_file = join(APP_ROOT, app, 'ENV') if not exists(target_path): makedirs(virtual) env = { 'VIRTUAL_ENV': virtual, "PATH": ':'.join([join(virtual, "bin"), join(app, ".bin"), environ['PATH']]), "CLJ_CONFIG": environ.get('CLJ_CONFIG', join(environ['HOME'], '.clojure')), } if exists(env_file): env.update(parse_settings(env_file, env)) echo("-----> Building Clojure Application") call('clojure -T:build release', cwd=join(APP_ROOT, app), env=env, shell=True) return spawn_app(app, deltas) def deploy_clojure_leiningen(app, deltas={}): """Deploy a Clojure Application""" virtual = join(ENV_ROOT, app) target_path = join(APP_ROOT, app, 'target') env_file = join(APP_ROOT, app, 'ENV') if not exists(target_path): makedirs(virtual) env = { 'VIRTUAL_ENV': virtual, "PATH": ':'.join([join(virtual, "bin"), join(app, ".bin"), environ['PATH']]), "LEIN_HOME": environ.get('LEIN_HOME', join(environ['HOME'], '.lein')), } if exists(env_file): env.update(parse_settings(env_file, env)) echo("-----> Building Clojure Application") call('lein clean', cwd=join(APP_ROOT, app), env=env, shell=True) call('lein uberjar', cwd=join(APP_ROOT, app), env=env, shell=True) return spawn_app(app, deltas) def deploy_ruby(app, deltas={}): """Deploy a Ruby Application""" virtual = join(ENV_ROOT, app) env_file = join(APP_ROOT, app, 'ENV') env = { 'VIRTUAL_ENV': virtual, "PATH": ':'.join([join(virtual, "bin"), join(app, ".bin"), environ['PATH']]), } if exists(env_file): env.update(parse_settings(env_file, env)) if not exists(virtual): echo("-----> Building Ruby Application") makedirs(virtual) call('bundle config set --local path $VIRTUAL_ENV', cwd=join(APP_ROOT, app), env=env, shell=True) else: echo("------> Rebuilding Ruby Application") call('bundle install', cwd=join(APP_ROOT, app), env=env, shell=True) return spawn_app(app, deltas) def deploy_go(app, deltas={}): """Deploy a Go application""" go_path = join(ENV_ROOT, app) deps = join(APP_ROOT, app, 'Godeps') first_time = False if not exists(go_path): echo("-----> Creating GOPATH for '{}'".format(app), fg='green') makedirs(go_path) # copy across a pre-built GOPATH to save provisioning time call('cp -a $HOME/gopath {}'.format(app), cwd=ENV_ROOT, shell=True) first_time = True if exists(deps): if first_time or getmtime(deps) > getmtime(go_path): echo("-----> Running godep for '{}'".format(app), fg='green') env = { 'GOPATH': '$HOME/gopath', 'GOROOT': '$HOME/go', 'PATH': '$PATH:$HOME/go/bin', 'GO15VENDOREXPERIMENT': '1' } call('godep update ...', cwd=join(APP_ROOT, app), env=env, shell=True) return spawn_app(app, deltas) def deploy_node(app, deltas={}): """Deploy a Node application""" virtualenv_path = join(ENV_ROOT, app) node_path = join(ENV_ROOT, app, "node_modules") node_modules_symlink = join(APP_ROOT, app, "node_modules") npm_prefix = abspath(join(node_path, "..")) env_file = join(APP_ROOT, app, 'ENV') deps = join(APP_ROOT, app, 'package.json') first_time = False if not exists(node_path): echo("-----> Creating node_modules for '{}'".format(app), fg='green') makedirs(node_path) first_time = True env = { 'VIRTUAL_ENV': virtualenv_path, 'NODE_PATH': node_path, 'NPM_CONFIG_PREFIX': npm_prefix, "PATH": ':'.join([join(virtualenv_path, "bin"), join(node_path, ".bin"), environ['PATH']]) } if exists(env_file): env.update(parse_settings(env_file, env)) # include node binaries on our path environ["PATH"] = env["PATH"] 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 check_requirements(['nodeenv']): if not installed.endswith(version): started = glob(join(UWSGI_ENABLED, '{}*.ini'.format(app))) if installed and len(started): echo("Warning: Can't update node with app running. Stop the app & retry.", fg='yellow') else: echo("-----> Installing node version '{NODE_VERSION:s}' using nodeenv".format(**env), fg='green') call("nodeenv --prebuilt --node={NODE_VERSION:s} --clean-src --force {VIRTUAL_ENV:s}".format(**env), cwd=virtualenv_path, env=env, shell=True) else: echo("-----> Node is installed at {}.".format(version)) if exists(deps) and check_requirements(['npm']): if first_time or getmtime(deps) > getmtime(node_path): copyfile(join(APP_ROOT, app, 'package.json'), join(ENV_ROOT, app, 'package.json')) if not exists(node_modules_symlink): symlink(node_path, node_modules_symlink) echo("-----> Running npm for '{}'".format(app), fg='green') call('npm install --prefix {} --package-lock=false'.format(npm_prefix), cwd=join(APP_ROOT, app), env=env, shell=True) return spawn_app(app, deltas) def deploy_python(app, deltas={}): """Deploy a Python application""" virtualenv_path = join(ENV_ROOT, app) requirements = join(APP_ROOT, app, 'requirements.txt') env_file = join(APP_ROOT, app, 'ENV') # Set unbuffered output and readable UTF-8 mapping env = { 'PYTHONUNBUFFERED': '1', 'PYTHONIOENCODING': 'UTF_8:replace' } if exists(env_file): env.update(parse_settings(env_file, env)) # TODO: improve version parsing # pylint: disable=unused-variable version = int(env.get("PYTHON_VERSION", "3")) first_time = False if not exists(join(virtualenv_path, "bin", "activate")): echo("-----> Creating virtualenv for '{}'".format(app), fg='green') try: makedirs(virtualenv_path) except FileExistsError: echo("-----> Env dir already exists: '{}'".format(app), fg='yellow') call('virtualenv --python=python{version:d} {app:s}'.format(**locals()), cwd=ENV_ROOT, shell=True) 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): echo("-----> Running pip for '{}'".format(app), fg='green') call('pip install -r {}'.format(requirements), cwd=virtualenv_path, shell=True) return spawn_app(app, deltas) def deploy_identity(app, deltas={}): env_path = join(ENV_ROOT, app) if not exists(env_path): makedirs(env_path) return spawn_app(app, deltas) def spawn_app(app, deltas={}): """Create all workers for an app""" # pylint: disable=unused-variable app_path = join(APP_ROOT, app) procfile = join(app_path, 'Procfile') workers = parse_procfile(procfile) workers.pop("preflight", None) workers.pop("release", None) ordinals = defaultdict(lambda: 1) worker_count = {k: 1 for k in workers.keys()} # the Python virtualenv virtualenv_path = join(ENV_ROOT, app) # Settings shipped with the app env_file = join(APP_ROOT, app, 'ENV') # Custom overrides settings = join(ENV_ROOT, app, 'ENV') # Live settings live = join(ENV_ROOT, app, 'LIVE_ENV') # Scaling scaling = join(ENV_ROOT, app, 'SCALING') # Bootstrap environment env = { 'APP': app, 'LOG_ROOT': LOG_ROOT, 'HOME': environ['HOME'], 'USER': environ['USER'], 'PATH': ':'.join([join(virtualenv_path, 'bin'), environ['PATH']]), 'PWD': dirname(env_file), '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 exists(node_path): env["NODE_PATH"] = node_path env["PATH"] = ':'.join([join(node_path, ".bin"), env['PATH']]) # Load environment variables shipped with repo (if any) if exists(env_file): env.update(parse_settings(env_file, env)) # Override with custom settings (if any) if exists(settings): env.update(parse_settings(settings, env)) # lgtm [py/modification-of-default-value] if 'web' in workers or 'wsgi' in workers or 'jwsgi' in workers or 'static' in workers or 'rwsgi' in workers: # Pick a port if none defined if 'PORT' not in env: env['PORT'] = str(get_free_port()) echo("-----> picking free port {PORT}".format(**env)) if get_boolean(env.get('DISABLE_IPV6', 'false')): safe_defaults.pop('NGINX_IPV6_ADDRESS', None) echo("-----> nginx will NOT use IPv6".format(**locals())) # Safe defaults for addressing for k, v in safe_defaults.items(): if k not in env: echo("-----> nginx {k:s} will be set to {v}".format(**locals())) env[k] = v # Set up nginx if we have NGINX_SERVER_NAME set if 'NGINX_SERVER_NAME' in env: # Hack to get around ClickCommand env['NGINX_SERVER_NAME'] = env['NGINX_SERVER_NAME'].split(',') env['NGINX_SERVER_NAME'] = ' '.join(env['NGINX_SERVER_NAME']) nginx = command_output("nginx -V") nginx_ssl = "443 ssl" if "--with-http_v2_module" in nginx: nginx_ssl += " http2" elif "--with-http_spdy_module" in nginx and "nginx/1.6.2" not in nginx: # avoid Raspbian bug nginx_ssl += " spdy" nginx_conf = join(NGINX_ROOT, "{}.conf".format(app)) env.update({ # lgtm [py/modification-of-default-value] 'NGINX_SSL': nginx_ssl, 'NGINX_ROOT': NGINX_ROOT, 'ACME_WWW': ACME_WWW, }) # default to reverse proxying to the TCP port we picked env['PIKU_INTERNAL_NGINX_UWSGI_SETTINGS'] = 'proxy_pass http://{BIND_ADDRESS:s}:{PORT:s};'.format(**env) if 'wsgi' in workers or 'jwsgi' in workers: sock = join(NGINX_ROOT, "{}.sock".format(app)) env['PIKU_INTERNAL_NGINX_UWSGI_SETTINGS'] = expandvars(PIKU_INTERNAL_NGINX_UWSGI_SETTINGS, env) env['NGINX_SOCKET'] = env['BIND_ADDRESS'] = "unix://" + sock if 'PORT' in env: del env['PORT'] else: env['NGINX_SOCKET'] = "{BIND_ADDRESS:s}:{PORT:s}".format(**env) echo("-----> nginx will look for app '{}' on {}".format(app, env['NGINX_SOCKET'])) domains = env['NGINX_SERVER_NAME'].split() domain = domains[0] issuefile = join(ACME_ROOT, domain, "issued-" + "-".join(domains)) 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 root_ca = ACME_ROOT_CA # 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): echo("-----> writing temporary nginx conf") buffer = expandvars(NGINX_ACME_FIRSTRUN_TEMPLATE, env) with open(nginx_conf, "w") as h: h.write(buffer) if not exists(key) or not exists(issuefile): echo("-----> getting letsencrypt certificate") certlist = " ".join(["-d {}".format(d) for d in domains]) call('{acme:s}/acme.sh --issue {certlist:s} -w {www:s} --server {root_ca:s}'.format(**locals()), shell=True) call('{acme:s}/acme.sh --install-cert {certlist: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)): symlink(join(ACME_ROOT, domain), join(ACME_WWW, app)) try: symlink("/dev/null", issuefile) except Exception: pass else: echo("-----> letsencrypt certificate already installed") # fall back to creating self-signed certificate if acme failed if not exists(key) or stat(crt).st_size == 0: echo("-----> generating self-signed certificate") call( 'openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=NY/L=New York/O=Piku/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 get_boolean(env.get('NGINX_CLOUDFLARE_ACL', 'false')): try: cf = 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)) if get_boolean(env.get('DISABLE_IPV6', 'false')): for i in cf['result']['ipv6_cidrs']: acl.append("allow {};".format(i)) # allow access from controlling machine if 'SSH_CLIENT' in environ: remote_ip = environ['SSH_CLIENT'].split()[0] echo("-----> nginx ACL will include your IP ({})".format(remote_ip)) acl.append("allow {};".format(remote_ip)) acl.extend(["allow 127.0.0.1;", "deny all;"]) except Exception: cf = defaultdict() echo("-----> Could not retrieve CloudFlare IP ranges: {}".format(format_exc()), fg="red") env['NGINX_ACL'] = " ".join(acl) env['PIKU_INTERNAL_NGINX_BLOCK_GIT'] = "" if env.get('NGINX_ALLOW_GIT_FOLDERS') else "location ~ /\.git { deny all; }" env['PIKU_INTERNAL_PROXY_CACHE_PATH'] = '' env['PIKU_INTERNAL_NGINX_CACHE_MAPPINGS'] = '' # Get a mapping of /prefix1,/prefix2 default_cache_path = join(CACHE_ROOT, app) if not exists(default_cache_path): makedirs(default_cache_path) try: cache_size = int(env.get('NGINX_CACHE_SIZE', '1')) except Exception: echo("=====> Invalid cache size, defaulting to 1GB") cache_size = 1 cache_size = str(cache_size) + "g" try: cache_time_control = int(env.get('NGINX_CACHE_CONTROL', '3600')) except Exception: echo("=====> Invalid time for cache control, defaulting to 3600s") cache_time_control = 3600 cache_time_control = str(cache_time_control) try: cache_time_content = int(env.get('NGINX_CACHE_TIME', '3600')) except Exception: echo("=====> Invalid cache time for content, defaulting to 3600s") cache_time_content = 3600 cache_time_content = str(cache_time_content) + "s" try: cache_time_redirects = int(env.get('NGINX_CACHE_REDIRECTS', '3600')) except Exception: echo("=====> Invalid cache time for redirects, defaulting to 3600s") cache_time_redirects = 3600 cache_time_redirects = str(cache_time_redirects) + "s" try: cache_time_any = int(env.get('NGINX_CACHE_ANY', '3600')) except Exception: echo("=====> Invalid cache expiry fallback, defaulting to 3600s") cache_time_any = 3600 cache_time_any = str(cache_time_any) + "s" try: cache_time_expiry = int(env.get('NGINX_CACHE_EXPIRY', '86400')) except Exception: echo("=====> Invalid cache expiry, defaulting to 86400s") cache_time_expiry = 86400 cache_time_expiry = str(cache_time_expiry) + "s" cache_prefixes = env.get('NGINX_CACHE_PREFIXES', '') cache_path = env.get('NGINX_CACHE_PATH', default_cache_path) if not exists(cache_path): echo("=====> Cache path {} does not exist, using default {}, be aware of disk usage.".format(cache_path, default_cache_path)) cache_path = env.get(default_cache_path) if len(cache_prefixes): prefixes = [] # this will turn into part of /(path1|path2|path3) try: items = cache_prefixes.split(',') for item in items: if item[0] == '/': prefixes.append(item[1:]) else: prefixes.append(item) cache_prefixes = "|".join(prefixes) echo("-----> nginx will cache /({}) prefixes up to {} or {} of disk space, with the following timings:".format(cache_prefixes, cache_time_expiry, cache_size)) echo("-----> nginx will cache content for {}.".format(cache_time_content)) echo("-----> nginx will cache redirects for {}.".format(cache_time_redirects)) echo("-----> nginx will cache everything else for {}.".format(cache_time_any)) echo("-----> nginx will send caching headers asking for {} seconds of public caching.".format(cache_time_control)) env['PIKU_INTERNAL_PROXY_CACHE_PATH'] = expandvars( PIKU_INTERNAL_PROXY_CACHE_PATH, locals()) env['PIKU_INTERNAL_NGINX_CACHE_MAPPINGS'] = expandvars( PIKU_INTERNAL_NGINX_CACHE_MAPPING, locals()) env['PIKU_INTERNAL_NGINX_CACHE_MAPPINGS'] = expandvars( env['PIKU_INTERNAL_NGINX_CACHE_MAPPINGS'], env) except Exception as e: echo("Error {} in cache path spec: should be /prefix1:[,/prefix2], ignoring.".format(e)) env['PIKU_INTERNAL_NGINX_CACHE_MAPPINGS'] = '' env['PIKU_INTERNAL_NGINX_STATIC_MAPPINGS'] = '' # Get a mapping of /prefix1:path1,/prefix2:path2 static_paths = env.get('NGINX_STATIC_PATHS', '') # prepend static worker path if present if 'static' in workers: stripped = workers['static'].strip("/").rstrip("/") static_paths = ("/" if stripped[0:1] == ":" else "/:") + (stripped if stripped else ".") + "/" + ("," if static_paths else "") + static_paths if len(static_paths): try: items = static_paths.split(',') for item in items: static_url, static_path = item.split(':') if static_path[0] != '/': static_path = join(app_path, static_path).rstrip("/") + "/" echo("-----> nginx will map {} to {}.".format(static_url, static_path)) env['PIKU_INTERNAL_NGINX_STATIC_MAPPINGS'] = env['PIKU_INTERNAL_NGINX_STATIC_MAPPINGS'] + expandvars( PIKU_INTERNAL_NGINX_STATIC_MAPPING, locals()) except Exception as e: echo("Error {} in static path spec: should be /prefix1:path1[,/prefix2:path2], ignoring.".format(e)) env['PIKU_INTERNAL_NGINX_STATIC_MAPPINGS'] = '' env['PIKU_INTERNAL_NGINX_CUSTOM_CLAUSES'] = expandvars(open(join(app_path, env["NGINX_INCLUDE_FILE"])).read(), env) if env.get("NGINX_INCLUDE_FILE") else "" env['PIKU_INTERNAL_NGINX_PORTMAP'] = "" if 'web' in workers or 'wsgi' in workers or 'jwsgi' in workers or 'rwsgi' in workers: env['PIKU_INTERNAL_NGINX_PORTMAP'] = expandvars(NGINX_PORTMAP_FRAGMENT, env) env['PIKU_INTERNAL_NGINX_COMMON'] = expandvars(NGINX_COMMON_FRAGMENT, env) echo("-----> nginx will map app '{}' to hostname(s) '{}'".format(app, env['NGINX_SERVER_NAME'])) if get_boolean(env.get('NGINX_HTTPS_ONLY', 'false')): buffer = expandvars(NGINX_HTTPS_ONLY_TEMPLATE, env) echo("-----> nginx will redirect all requests to hostname(s) '{}' to HTTPS".format(env['NGINX_SERVER_NAME'])) else: buffer = expandvars(NGINX_TEMPLATE, env) # remove all references to IPv6 listeners (for enviroments where it's disabled) if get_boolean(env.get('DISABLE_IPV6', 'false')): buffer = '\n'.join([line for line in buffer.split('\n') if 'NGINX_IPV6' not in line]) # change any unecessary uWSGI specific directives to standard proxy ones if 'wsgi' not in workers and 'jwsgi' not in workers: buffer = buffer.replace("uwsgi_", "proxy_") # map Cloudflare connecting IP to REMOTE_ADDR if get_boolean(env.get('NGINX_CLOUDFLARE_ACL', 'false')): buffer = buffer.replace("REMOTE_ADDR $remote_addr", "REMOTE_ADDR $http_cf_connecting_ip") 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: nginx_config_test = None if nginx_config_test: echo("Error: [nginx config] {}".format(nginx_config_test), fg='red') echo("Warning: removing broken nginx config.", fg='yellow') unlink(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 workers}) 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('PIKU_INTERNAL_'): del env[k] # Save current settings write_config(live, env) write_config(scaling, worker_count, ':') if get_boolean(env.get('PIKU_AUTO_RESTART', 'true')): config = glob(join(UWSGI_ENABLED, '{}*.ini'.format(app))) if len(config): echo("-----> Removing uwsgi configs to trigger auto-restart.") for c in config: remove(c) # Create new workers 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): echo("-----> spawning '{app:s}:{k:s}.{w:d}'".format(**locals()), fg='green') spawn_worker(app, k, workers[k], env, w) # Remove unnecessary workers (leave logfiles) for k, v in to_destroy.items(): for w in v: # lgtm [py/unused-loop-variable] enabled = join(UWSGI_ENABLED, '{app:s}_{k:s}.{w:d}.ini'.format(**locals())) if exists(enabled): echo("-----> terminating '{app:s}:{k:s}.{w:d}'".format(**locals()), fg='yellow') unlink(enabled) return env def spawn_worker(app, kind, command, env, ordinal=1): """Set up and deploy a single worker of a given kind""" # pylint: disable=unused-variable 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', join(APP_ROOT, app)), ('uid', getpwuid(getuid()).pw_name), ('gid', getgrgid(getgid()).gr_name), ('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)), ('logfile-chown', '%s:%s' % (getpwuid(getuid()).pw_name, getgrgid(getgid()).gr_name)), ('logfile-chmod', '640'), ('logto2', '{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 'UWSGI_IDLE' in env: try: idle_timeout = int(env['UWSGI_IDLE']) settings.extend([ ('idle', str(idle_timeout)), ('cheap', 'True'), ('die-on-idle', 'True') ]) echo("-----> uwsgi will start workers on demand and kill them after {}s of inactivity".format(idle_timeout), fg='yellow') except Exception: echo("Error: malformed setting 'UWSGI_IDLE', ignoring it.".format(), fg='red') pass if kind == 'cron': settings.extend([ ['cron', command.replace("*/", "-").replace("*", "-1")], ]) if kind == 'jwsgi': settings.extend([ ('module', command), ('threads', env.get('UWSGI_THREADS', '4')), ('plugin', 'jvm'), ('plugin', 'jwsgi') ]) # could not come up with a better kind for ruby, web would work but that means loading the rack plugin in web. if kind == 'rwsgi': settings.extend([ ('module', command), ('threads', env.get('UWSGI_THREADS', '4')), ('plugin', 'rack'), ('plugin', 'rbrequire'), ('plugin', 'post-buffering') ]) python_version = int(env.get('PYTHON_VERSION', '3')) if kind == 'wsgi': settings.extend([ ('module', command), ('threads', env.get('UWSGI_THREADS', '4')), ]) 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: try: tasks = int(env['UWSGI_ASYNCIO']) settings.extend([ ('plugin', 'asyncio_python'), ('async', tasks), ]) echo("-----> uwsgi will support {} async tasks".format(tasks), fg='yellow') except ValueError: echo("Error: malformed setting 'UWSGI_ASYNCIO', ignoring it.".format(), fg='red') elif python_version == 3: settings.extend([ ('plugin', 'python3'), ]) if 'UWSGI_ASYNCIO' in env: try: tasks = int(env['UWSGI_ASYNCIO']) settings.extend([ ('plugin', 'asyncio_python3'), ('async', tasks), ]) echo("-----> uwsgi will support {} async tasks".format(tasks), fg='yellow') except ValueError: echo("Error: malformed setting 'UWSGI_ASYNCIO', ignoring it.".format(), fg='red') # If running under nginx, don't expose a port at all if 'NGINX_SERVER_NAME' in env: sock = join(NGINX_ROOT, "{}.sock".format(app)) echo("-----> nginx will talk to uWSGI via {}".format(sock), fg='yellow') settings.extend([ ('socket', sock), ('chmod-socket', '664'), ]) else: echo("-----> nginx will talk to uWSGI via {BIND_ADDRESS:s}:{PORT:s}".format(**env), fg='yellow') settings.extend([ ('http', '{BIND_ADDRESS:s}:{PORT:s}'.format(**env)), ('http-use-socket', '{BIND_ADDRESS:s}:{PORT:s}'.format(**env)), ('http-socket', '{BIND_ADDRESS:s}:{PORT:s}'.format(**env)), ]) elif kind == 'web': echo("-----> nginx will talk to the 'web' process via {BIND_ADDRESS:s}:{PORT:s}".format(**env), fg='yellow') settings.append(('attach-daemon', command)) elif kind == 'static': echo("-----> nginx serving static files only".format(**env), fg='yellow') elif kind == 'cron': echo("-----> uwsgi scheduled cron for {command}".format(**locals()), fg='yellow') 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 += parse_settings(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())) copyfile(available, enabled) def do_stop(app): config = glob(join(UWSGI_ENABLED, '{}*.ini'.format(app))) if len(config) > 0: echo("Stopping app '{}'...".format(app), fg='yellow') for c in config: remove(c) else: echo("Error: app '{}' not deployed!".format(app), fg='red') # TODO app could be already stopped. Need to able to tell the difference. def do_restart(app): """Restarts a deployed app""" # This must work even if the app is stopped when called. At the end, the app should be running. echo("restarting app '{}'...".format(app), fg='yellow') do_stop(app) spawn_app(app) 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, "rt", encoding="utf-8", errors="ignore") inodes[f] = 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 deque(open(f, "rt", encoding="utf-8", errors="ignore"), 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 line: updated = True yield "{} | {}".format(prefixes[f].ljust(longest), line) if not updated: sleep(1) # Check if logs rotated for f in filenames: if exists(f): if stat(f).st_ino != inodes[f]: files[f] = open(f) inodes[f] = stat(f).st_ino else: filenames.remove(f) # === CLI commands === CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) @group(context_settings=CONTEXT_SETTINGS) def piku(): """The smallest PaaS you've ever seen""" pass piku.rc = getattr(piku, "result_callback", None) or getattr(piku, "resultcallback", None) @piku.rc() def cleanup(ctx): """Callback from command execution -- add debugging to taste""" pass # --- User commands --- @piku.command("apps") def cmd_apps(): """List apps, e.g.: piku apps""" apps = listdir(APP_ROOT) if not apps: echo("There are no applications deployed.") return for a in apps: running = len(glob(join(UWSGI_ENABLED, '{}*.ini'.format(a)))) != 0 echo(('*' if running else ' ') + a, fg='green') @piku.command("config") @argument('app') def cmd_config(app): """Show config, e.g.: piku config """ app = exit_if_invalid(app) config_file = join(ENV_ROOT, app, 'ENV') if exists(config_file): echo(open(config_file).read().strip(), fg='white') else: echo("Warning: app '{}' not deployed, no config found.".format(app), fg='yellow') @piku.command("config:get") @argument('app') @argument('setting') def cmd_config_get(app, setting): """e.g.: piku config:get FOO""" app = exit_if_invalid(app) config_file = join(ENV_ROOT, app, 'ENV') if exists(config_file): env = parse_settings(config_file) if setting in env: echo("{}".format(env[setting]), fg='white') else: echo("Warning: no active configuration for '{}'".format(app)) @piku.command("config:set") @argument('app') @argument('settings', nargs=-1) def cmd_config_set(app, settings): """e.g.: piku config:set FOO=bar BAZ=quux""" app = exit_if_invalid(app) config_file = join(ENV_ROOT, app, 'ENV') env = parse_settings(config_file) for s in settings: try: k, v = map(lambda x: x.strip(), s.split("=", 1)) env[k] = v echo("Setting {k:s}={v} for '{app:s}'".format(**locals()), fg='white') except Exception: echo("Error: malformed setting '{}'".format(s), fg='red') return write_config(config_file, env) do_deploy(app) @piku.command("config:unset") @argument('app') @argument('settings', nargs=-1) def cmd_config_unset(app, settings): """e.g.: piku config:unset FOO""" app = exit_if_invalid(app) config_file = join(ENV_ROOT, app, 'ENV') env = parse_settings(config_file) for s in settings: if s in env: del env[s] echo("Unsetting {} for '{}'".format(s, app), fg='white') write_config(config_file, env) do_deploy(app) @piku.command("config:live") @argument('app') def cmd_config_live(app): """e.g.: piku config:live """ app = exit_if_invalid(app) live_config = join(ENV_ROOT, app, 'LIVE_ENV') if exists(live_config): echo(open(live_config).read().strip(), fg='white') else: echo("Warning: app '{}' not deployed, no config found.".format(app), fg='yellow') @piku.command("deploy") @argument('app') def cmd_deploy(app): """e.g.: piku deploy """ app = exit_if_invalid(app) do_deploy(app) @piku.command("destroy") @argument('app') def cmd_destroy(app): """e.g.: piku destroy """ app = exit_if_invalid(app) # leave DATA_ROOT, since apps may create hard to reproduce data, # and CACHE_ROOT, since `nginx` will set permissions to protect it for p in [join(x, app) for x in [APP_ROOT, GIT_ROOT, ENV_ROOT, LOG_ROOT]]: if exists(p): echo("--> Removing folder '{}'".format(p), fg='yellow') rmtree(p) for p in [join(x, '{}*.ini'.format(app)) for x in [UWSGI_AVAILABLE, UWSGI_ENABLED]]: g = glob(p) if len(g) > 0: for f in g: echo("--> Removing file '{}'".format(f), fg='yellow') 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): echo("--> Removing file '{}'".format(f), fg='yellow') remove(f) acme_link = join(ACME_WWW, app) acme_certs = realpath(acme_link) if exists(acme_certs): echo("--> Removing folder '{}'".format(acme_certs), fg='yellow') rmtree(acme_certs) echo("--> Removing file '{}'".format(acme_link), fg='yellow') unlink(acme_link) # These come last to make sure they're visible for p in [join(x, app) for x in [DATA_ROOT, CACHE_ROOT]]: if exists(p): echo("==> Preserving folder '{}'".format(p), fg='red') @piku.command("logs") @argument('app') @argument('process', nargs=1, default='*') def cmd_logs(app, process): """Tail running logs, e.g: piku logs []""" app = exit_if_invalid(app) logfiles = glob(join(LOG_ROOT, app, process + '.*.log')) if len(logfiles) > 0: for line in multi_tail(app, logfiles): echo(line.strip(), fg='white') else: echo("No logs found for app '{}'.".format(app), fg='yellow') @piku.command("ps") @argument('app') def cmd_ps(app): """Show process count, e.g: piku ps """ app = exit_if_invalid(app) config_file = join(ENV_ROOT, app, 'SCALING') if exists(config_file): echo(open(config_file).read().strip(), fg='white') else: echo("Error: no workers found for app '{}'.".format(app), fg='red') @piku.command("ps:scale") @argument('app') @argument('settings', nargs=-1) def cmd_ps_scale(app, settings): """e.g.: piku ps:scale =""" app = exit_if_invalid(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: echo("Error: cannot scale type '{}' below 0".format(k), fg='red') return if k not in worker_count: echo("Error: worker type '{}' not present in '{}'".format(k, app), fg='red') return deltas[k] = c - worker_count[k] except Exception: echo("Error: malformed setting '{}'".format(s), fg='red') return do_deploy(app, deltas) @piku.command("run") @argument('app') @argument('cmd', nargs=-1) def cmd_run(app, cmd): """e.g.: piku run ls -- -al""" app = exit_if_invalid(app) config_file = join(ENV_ROOT, app, 'LIVE_ENV') environ.update(parse_settings(config_file)) for f in [stdout, stderr]: fl = fcntl(f, F_GETFL) fcntl(f, F_SETFL, fl | O_NONBLOCK) p = Popen(' '.join(cmd), stdin=stdin, stdout=stdout, stderr=stderr, env=environ, cwd=join(APP_ROOT, app), shell=True) p.communicate() @piku.command("restart") @argument('app') def cmd_restart(app): """Restart an app: piku restart """ app = exit_if_invalid(app) do_restart(app) @piku.command("setup") def cmd_setup(): """Initialize environment""" echo("Running in Python {}".format(".".join(map(str, version_info)))) # Create required paths for p in [APP_ROOT, CACHE_ROOT, DATA_ROOT, GIT_ROOT, ENV_ROOT, UWSGI_ROOT, UWSGI_AVAILABLE, UWSGI_ENABLED, LOG_ROOT, NGINX_ROOT]: if not exists(p): echo("Creating '{}'.".format(p), fg='green') 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', getpwuid(getuid()).pw_name), ('gid', getgrgid(getgid()).gr_name), ('enable-threads', 'true'), ('threads', '{}'.format(cpu_count() * 2)), ] with open(join(UWSGI_ROOT, 'uwsgi.ini'), 'w') as h: h.write('[uwsgi]\n') # pylint: disable=unused-variable for k, v in settings: h.write("{k:s} = {v}\n".format(**locals())) # mark this script as executable (in case we were invoked via interpreter) if not (stat(PIKU_SCRIPT).st_mode & S_IXUSR): echo("Setting '{}' as executable.".format(PIKU_SCRIPT), fg='yellow') chmod(PIKU_SCRIPT, stat(PIKU_SCRIPT).st_mode | S_IXUSR) @piku.command("setup:ssh") @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] key = open(key_file, 'r').read().strip() echo("Adding key '{}'.".format(fingerprint), fg='white') setup_authorized_keys(fingerprint, PIKU_SCRIPT, key) except Exception: echo("Error: invalid public key file '{}': {}".format(key_file, format_exc()), fg='red') elif public_key_file == '-': buffer = "".join(stdin.readlines()) with NamedTemporaryFile(mode="w") as f: f.write(buffer) f.flush() add_helper(f.name) else: echo("Error: public key file '{}' not found.".format(key_file), fg='red') add_helper(public_key_file) @piku.command("stop") @argument('app') def cmd_stop(app): """Stop an app, e.g: piku stop """ app = exit_if_invalid(app) do_stop(app) # --- Internal commands --- @piku.command("git-hook") @argument('app') def cmd_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) data_path = join(DATA_ROOT, app) for line in stdin: # pylint: disable=unused-variable oldrev, newrev, refname = line.strip().split(" ") # Handle pushes if not exists(app_path): echo("-----> Creating app '{}'".format(app), fg='green') makedirs(app_path) # The data directory may already exist, since this may be a full redeployment (we never delete data since it may be expensive to recreate) if not exists(data_path): makedirs(data_path) call("git clone --quiet {} {}".format(repo_path, app), cwd=APP_ROOT, shell=True) do_deploy(app, newrev=newrev) @piku.command("git-receive-pack") @argument('app') def cmd_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): 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 | PIKU_ROOT="{PIKU_ROOT:s}" {PIKU_SCRIPT:s} git-hook {app:s}""".format(**env)) # Make the hook executable by our user chmod(hook_path, stat(hook_path).st_mode | 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) @piku.command("git-upload-pack") @argument('app') def cmd_git_upload_pack(app): """INTERNAL: Handle git upload pack for an app""" app = sanitize_app_name(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) @piku.command("scp", context_settings=dict(ignore_unknown_options=True, allow_extra_args=True)) @pass_context def cmd_scp(ctx): """Simple wrapper to allow scp to work.""" call(" ".join(["scp"] + ctx.args), cwd=GIT_ROOT, shell=True) def _get_plugin_commands(path): sys_path.append(abspath(path)) cli_commands = [] if isdir(path): for item in listdir(path): module_path = join(path, item) if isdir(module_path): try: module = import_module(item) except Exception: module = None if hasattr(module, 'cli_commands'): cli_commands.append(module.cli_commands()) return cli_commands @piku.command("help") @pass_context def cmd_help(ctx): """display help for piku""" echo(ctx.parent.get_help()) @piku.command("update") def cmd_update(): """Update the piku cli""" echo("Updating piku...") with NamedTemporaryFile(mode="w") as f: tempfile = f.name cmd = """curl -sL -w %{{http_code}} {} -o {}""".format(PIKU_RAW_SOURCE_URL, tempfile) response = check_output(cmd.split(' '), stderr=STDOUT) http_code = response.decode('utf8').strip() if http_code == "200": copyfile(tempfile, PIKU_SCRIPT) echo("Update successful.") else: echo("Error updating piku - please check if {} is accessible from this machine.".format(PIKU_RAW_SOURCE_URL)) echo("Done.") if __name__ == '__main__': cli_commands = _get_plugin_commands(path=PIKU_PLUGIN_ROOT) cli_commands.append(piku) cli = CommandCollection(sources=cli_commands) cli()