#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Main entry-point into the 'GitLab Tools' Flask application. This is a GitLab Tools License: GPL-3.0 Website: https://github.com/Salamek/gitlab-tools Command details: server Run the application using the Flask Development Server. Auto-reloads files when they change. shell Starts a Python interactive shell with the Flask application context. create_all Only create database tables if they don't exist and then exit migrations Migrations list_routes List all available routes post_install Post install script celerybeat Run a Celery Beat periodic task scheduler. celeryworker Run a Celery worker process. celerydev Starts a Celery worker with Celery Beat in the same process. setup Setup application Usage: gitlab-tools server [-p NUM] [-l DIR] [--config_prod] gitlab-tools list_routes gitlab-tools shell [--config_prod] gitlab-tools create_all [--config_prod] gitlab-tools post_install [--config_prod] [--user=USER] gitlab-tools migrations (upgrade|current|migrate|history|heads|show|stamp|downgrade|init|revision|merge|branches|edit) [--config_prod] gitlab-tools migrations stamp <revision> [--config_prod] [-h] [-d DIRECTORY] [--sql] [--tag TAG] gitlab-tools celerydev [-l DIR] [--config_prod] gitlab-tools celerybeat [-s FILE] [--pid=FILE] [-l DIR] [--config_prod] gitlab-tools celeryworker [-n NUM] [-l DIR] [--config_prod] gitlab-tools setup [--config_prod] gitlab-tools (-h | --help) Options: --config_prod Load the production configuration instead of development. -l DIR --log_dir=DIR Log all statements to file in this directory instead of stdout. Only ERROR statements will go to stdout. stderr is not used. -n NUM --name=NUM Celery Worker name integer. [default: 1] --pid=FILE Celery Beat PID file. [default: ./celery_beat.pid] -p NUM --port=NUM Flask will listen on this port number. -s FILE --schedule=FILE Celery Beat schedule database file. [default: ./celery_beat.db] """ from __future__ import print_function import logging import logging.handlers import os import signal import subprocess import sys import urllib.parse from functools import wraps import flask import yaml from celery.app.log import Logging from celery.bin.celery import main as celery_main from docopt import docopt from flask_migrate import MigrateCommand, stamp from flask_script import Shell, Manager from gitlab_tools.application import create_app, get_config from gitlab_tools.config import Config from gitlab_tools.extensions import db from gitlab_tools.tools.crypto import random_password from gitlab_tools.tools.helpers import get_home_dir, get_user_group_id, get_user_id, get_repository_storage, \ get_ssh_storage OPTIONS = docopt(__doc__) class CustomFormatter(logging.Formatter): LEVEL_MAP = {logging.FATAL: 'F', logging.ERROR: 'E', logging.WARN: 'W', logging.INFO: 'I', logging.DEBUG: 'D'} def format(self, record): record.levelletter = self.LEVEL_MAP[record.levelno] return super(CustomFormatter, self).format(record) def setup_logging(name: str=None, level: int=logging.DEBUG): """Setup Google-Style logging for the entire application. At first I hated this but I had to use it for work, and now I prefer it. Who knew? From: https://github.com/twitter/commons/blob/master/src/python/twitter/common/log/formatters/glog.py Always logs DEBUG statements somewhere. Positional arguments: name -- Append this string to the log file filename. """ log_to_disk = False if OPTIONS['--log_dir']: if not os.path.isdir(OPTIONS['--log_dir']): print('ERROR: Directory {} does not exist.'.format(OPTIONS['--log_dir'])) sys.exit(1) if not os.access(OPTIONS['--log_dir'], os.W_OK): print('ERROR: No permissions to write to directory {}.'.format(OPTIONS['--log_dir'])) sys.exit(1) log_to_disk = True fmt = '%(levelletter)s%(asctime)s.%(msecs).03d %(process)d %(filename)s:%(lineno)d] %(message)s' datefmt = '%m%d %H:%M:%S' formatter = CustomFormatter(fmt, datefmt) console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(level) console_handler.setFormatter(formatter) root = logging.getLogger() root.setLevel(level) root.addHandler(console_handler) if log_to_disk: file_name = os.path.join(OPTIONS['--log_dir'], 'gitlab-tools_{}.log'.format(name)) file_handler = logging.handlers.TimedRotatingFileHandler(file_name, when='d', backupCount=7) file_handler.setFormatter(formatter) root.addHandler(file_handler) def log_messages(app): """Log messages common to Tornado and devserver.""" log = logging.getLogger(__name__) log.info('Server is running at http://{}:{}/'.format(app.config['HOST'], app.config['PORT'])) log.info('Flask version: {}'.format(flask.__version__)) log.info('DEBUG: {}'.format(app.config['DEBUG'])) log.info('STATIC_FOLDER: {}'.format(app.static_folder)) def parse_options() -> Config: """Parses command line options for Flask. Returns: Config instance to pass into create_app(). """ # Figure out which class will be imported. if OPTIONS['--config_prod']: config_class_string = 'gitlab_tools.config.Production' else: config_class_string = 'gitlab_tools.config.Config' config_obj = get_config(config_class_string) # Force port from commandline if OPTIONS['--port']: if not OPTIONS['--port'].isdigit(): print('ERROR: Port should be a number.') sys.exit(1) config_obj.PORT = OPTIONS['--port'] return config_obj def command(func): """Decorator that registers the chosen command/function. If a function is decorated with @command but that function name is not a valid "command" according to the docstring, a KeyError will be raised, since that's a bug in this script. If a user doesn't specify a valid command in their command line arguments, the above docopt(__doc__) line will print a short summary and call sys.exit() and stop up there. If a user specifies a valid command, but for some reason the developer did not register it, an AttributeError will raise, since it is a bug in this script. Finally, if a user specifies a valid command and it is registered with @command below, then that command is "chosen" by this decorator function, and set as the attribute `chosen`. It is then executed below in `if __name__ == '__main__':`. Doing this instead of using Flask-Script. Positional arguments: func -- the function to decorate """ @wraps(func) def wrapped(): return func() # Register chosen function. if func.__name__ not in OPTIONS: raise KeyError('Cannot register {}, not mentioned in docstring/docopt.'.format(func.__name__)) if OPTIONS[func.__name__]: command.chosen = func return wrapped @command def server() -> None: options = parse_options() setup_logging('server', logging.DEBUG if options.DEBUG else logging.WARNING) app = create_app(options) log_messages(app) app.run(host=app.config['HOST'], port=int(app.config['PORT']), debug=app.config['DEBUG']) @command def shell() -> None: setup_logging('shell') app = create_app(parse_options()) app.app_context().push() Shell(make_context=lambda: dict(app=app, db=db)).run(no_ipython=False, no_bpython=False) @command def create_all() -> None: setup_logging('create_all') app = create_app(parse_options()) log = logging.getLogger(__name__) with app.app_context(): tables_before = set(db.engine.table_names()) db.create_all() tables_after = set(db.engine.table_names()) created_tables = tables_after - tables_before for table in created_tables: log.info('Created table: {}'.format(table)) @command def list_routes() -> None: output = [] app = create_app(parse_options()) app.config['SERVER_NAME'] = 'example.com' with app.app_context(): for rule in app.url_map.iter_rules(): integer_replaces = {} options = {} integer = 0 for arg in rule.arguments: options[arg] = str(integer) integer_replaces[str(integer)] = "[{0}]".format(arg) integer = +1 methods = ','.join(rule.methods) url = flask.url_for(rule.endpoint, **options) for integer_replace in integer_replaces: url = url.replace(integer_replace, integer_replaces[integer_replace]) line = urllib.parse.unquote("{:50s} {:20s} {}".format(rule.endpoint, methods, url)) output.append(line) for line in sorted(output): print(line) @command def post_install() -> None: if not os.geteuid() == 0: sys.exit('Script must be run as root') app = create_app(parse_options()) config_path = os.path.join('/', 'etc', 'gitlab-tools', 'config.yml') configuration = {} if os.path.isfile(config_path): with open(config_path) as f: loaded_data = yaml.load(f) if isinstance(loaded_data, dict): configuration.update(loaded_data) if not configuration.get('USER') and OPTIONS['--user']: app.config['USER'] = configuration['USER'] = OPTIONS['--user'] # Generate database and config if nothing is specified if 'SQLALCHEMY_DATABASE_URI' not in configuration or not configuration['SQLALCHEMY_DATABASE_URI']: database_path = 'sqlite:///{}/gitlab-tools.db'.format(get_home_dir(app.config['USER'])) configuration['SQLALCHEMY_DATABASE_URI'] = database_path # We need to set DB config to make stamp work app.config['SQLALCHEMY_DATABASE_URI'] = configuration['SQLALCHEMY_DATABASE_URI'] # Create empty database with app.app_context(): db.create_all() with app.app_context(): stamp() # Generate secret key if 'SECRET_KEY' not in configuration or not configuration['SECRET_KEY']: app.config['SECRET_KEY'] = configuration['SECRET_KEY'] = random_password() # Set port and host if 'HOST' not in configuration or not configuration['HOST']: configuration['HOST'] = '0.0.0.0' if 'PORT' not in configuration or not configuration['PORT']: configuration['PORT'] = 80 # Write new configuration with open(config_path, 'w') as f: yaml.dump(configuration, f, default_flow_style=False, allow_unicode=True) @command def setup() -> None: if not os.geteuid() == 0: sys.exit('Script must be run as root') app = create_app(parse_options()) config_path = os.path.join('/', 'etc', 'gitlab-tools', 'config.yml') configuration = {} if os.path.isfile(config_path): with open(config_path) as f: loaded_data = yaml.load(f) if isinstance(loaded_data, dict): configuration.update(loaded_data) def required_input(text): return input(text) or required_input(text) def database_sqlite(): print('SQLite configuration:') home_dir = get_home_dir(configuration['USER']) database_path_default = os.path.join(home_dir, 'gitlab-tools.db') connection_info = urllib.parse.urlparse( configuration.get('SQLALCHEMY_DATABASE_URI', 'sqlite:///{}'.format(database_path_default)) ) if connection_info.scheme == 'sqlite': database_path = os.path.join('/', connection_info.path.lstrip('/')) else: database_path = database_path_default database_location = input('Location [{}]: '.format(database_path)) or database_path app.config['SQLALCHEMY_DATABASE_URI'] = configuration['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///{}'.format(database_location) def database_mysql(): print('MySQL configuration:') database_login('mysql') def database_postgresql(): print('PostgreSQL configuration:') database_login('postgresql') def database_login(database_type): connection_info = urllib.parse.urlparse( configuration.get('SQLALCHEMY_DATABASE_URI', '{}://gitlab-tools:password@127.0.0.1/gitlab-tools'.format(database_type)) ) if connection_info.scheme == database_type: database_name = connection_info.path database_server = connection_info.netloc database_user = connection_info.username database_password = connection_info.password else: database_name = 'gitlab-tools' database_server = '127.0.0.1' database_user = 'gitlab-tools' database_password = None database_server = input('Server [{}]: '.format(database_server)) or database_server database_name = input('Database [{}]: '.format(database_name)) or database_name database_user = input('User [{}]: '.format(database_user)) or database_user if not database_password: database_password = required_input('Password (required):') else: database_password = input('Password [{}]: '.format('*' * len(database_password))) or database_password app.config['SQLALCHEMY_DATABASE_URI'] = configuration['SQLALCHEMY_DATABASE_URI'] = '{}://{}:{}@{}/{}'.format( database_type, database_user, database_password, database_server, database_name ) def ignore(): pass print('Default application user (must exists):') default_user = configuration.get('USER', app.config.get('USER')) app.config['USER'] = configuration['USER'] = input('[{}]: '.format(default_user)) or default_user # Check if repository storage directory exists repository_storage_path = get_repository_storage(app.config['USER']) if not os.path.isdir(repository_storage_path): print('Creating {}'.format(repository_storage_path)) os.mkdir(repository_storage_path) os.chown(repository_storage_path, get_user_id(app.config['USER']), get_user_group_id(app.config['USER'])) # Check if ssh storage directory exists ssh_storage_path = get_ssh_storage(app.config['USER']) if not os.path.isdir(ssh_storage_path): print('Creating {}'.format(ssh_storage_path)) os.mkdir(ssh_storage_path) os.chown(ssh_storage_path, get_user_id(app.config['USER']), get_user_group_id(app.config['USER'])) database_types = { 0: {'name': 'Ignore', 'default': True, 'call': ignore}, 1: {'name': 'SQLite', 'default': False, 'call': database_sqlite}, 2: {'name': 'PostgreSQL [Recommended]', 'default': False, 'call': database_postgresql}, 3: {'name': 'MySQL', 'default': False, 'call': database_mysql}, } print('Choose database type you want to use:') db_type_default = None for db_type in database_types: if database_types[db_type]['default']: db_type_default = db_type print('{}) {}{}'.format( db_type, database_types[db_type]['name'], ' (default)' if database_types[db_type]['default'] else '') ) database_type = int(input('Database type [{}]: '.format(db_type_default)) or db_type_default) if database_type not in database_types: print('Invalid option selected') sys.exit(1) database_types[database_type]['call']() print('Webserver configuration:') webserver_host = configuration.get('HOST', '127.0.0.1') webserver_port = configuration.get('PORT', '80') configuration['HOST'] = input('Host (for integrated web server - when used) [{}]: '.format(webserver_host)) or webserver_host configuration['PORT'] = input('Port (for integrated web server - when used) [{}]: '.format(webserver_port)) or webserver_port server_name = '{}:{}'.format(configuration['HOST'], configuration['PORT']) configuration['SERVER_NAME'] = input('Server name (on what domain or ip+port is this application available) [{}]: '.format(server_name)) or server_name print('Gitlab configuration:') default_gitlab_url = configuration.get('GITLAB_URL') default_gitlab_app_id = configuration.get('GITLAB_APP_ID') default_gitlab_app_secret = configuration.get('GITLAB_APP_SECRET') configuration['GITLAB_URL'] = input('Gitlab URL [{}]:'.format(default_gitlab_url)) or default_gitlab_url configuration['GITLAB_APP_ID'] = input('Gitlab APP ID [{}]:'.format(default_gitlab_app_id)) or default_gitlab_app_id configuration['GITLAB_APP_SECRET'] = input('Gitlab APP SECRET [{}]:'.format(default_gitlab_app_secret)) or default_gitlab_app_secret print('Save new configuration ?') for item in configuration: print('{}: {}'.format(item, configuration[item])) save_configuration = input('Save ? (y/n) [y]: ') or 'y' if save_configuration == 'y': # Write new configuration with open(config_path, 'w') as f: yaml.dump(configuration, f, default_flow_style=False, allow_unicode=True) print('Configuration saved.') recreate_database = input('Recreate database ? (y/n) [n]: ') or 'n' if recreate_database == 'y': # Create empty database with app.app_context(): # Create tables db.create_all() # Since we are running this script as root, # make sure that SQlite database (if used) is writable by application user database_configuration_info = urllib.parse.urlparse( configuration.get('SQLALCHEMY_DATABASE_URI') ) if database_configuration_info.scheme == 'sqlite': os.chown(database_configuration_info.path, get_user_id(app.config['USER']), get_user_group_id(app.config['USER'])) # Stamp database to lates migration stamp() print('Database has been created') restart_services = input('Restart services to load new configuration ? (y/n) [n]: ') or 'n' if restart_services == 'y': subprocess.call(['systemctl', 'restart', 'gitlab-tools_celeryworker']) subprocess.call(['systemctl', 'restart', 'gitlab-tools_celerybeat']) subprocess.call(['systemctl', 'restart', 'gitlab-tools']) @command def celerydev(): setup_logging('celerydev') app = create_app(parse_options(), no_sql=True) Logging._setup = True # Disable Celery from setting up logging, already done in setup_logging(). celery_args = ['celery', 'worker', '-B', '-s', '/tmp/celery.db', '--concurrency=5'] with app.app_context(): return celery_main(celery_args) @command def celerybeat(): options = parse_options() setup_logging('celerybeat', logging.DEBUG if options.DEBUG else logging.WARNING) app = create_app(options) Logging._setup = True celery_args = ['celery', 'beat', '-C', '--pidfile', OPTIONS['--pid'], '-s', OPTIONS['--schedule']] with app.app_context(): return celery_main(celery_args) @command def celeryworker(): options = parse_options() setup_logging('celeryworker{}'.format(OPTIONS['--name']), logging.DEBUG if options.DEBUG else logging.WARNING) app = create_app(options, no_sql=True) Logging._setup = True celery_args = ['celery', 'worker', '-n', OPTIONS['--name'], '-C', '--autoscale=10,1', '--without-gossip'] with app.app_context(): return celery_main(celery_args) @command def migrations() -> None: app = create_app(parse_options()) manager = Manager(app) manager.add_command('migrations', MigrateCommand) manager.run() def main() -> None: signal.signal(signal.SIGINT, lambda *_: sys.exit(0)) # Properly handle Control+C getattr(command, 'chosen')() # Execute the function specified by the user. if __name__ == '__main__': main()