"""Less flashy version. More hard-coding.""" from hashlib import md5 from json import dumps, loads from os import environ, mkdir from os.path import exists from pathlib import Path from shlex import split from subprocess import run from typing import Dict from flask import Flask, render_template, request from flask_wtf import FlaskForm from ruamel.yaml import YAML from wtforms import PasswordField, StringField from wtforms.validators import URL, DataRequired, Length app = Flask(__name__) app.secret_key = b'_5#y2L"F4Q8z\n\xec]/' yaml = YAML() APPS_SPEC = { "gitea": { "url": "https://git.autonomic.zone/compose-stacks/gitea", "secrets": [ "db_passwd", "db_root_passwd", "internal_token", "jwt_secret", "secret_key", ], "configs": ["app_ini"], } } DATA_DIR = Path("./data") def clone_app_template(app_name: str) -> None: """Git clone an app template repository.""" clone_path = DATA_DIR / app_name clone_url = APPS_SPEC[app_name]["url"] run(split(f"git clone {clone_url} {clone_path}")) def create_docker_secret(key: str, value: str) -> None: """Load a docker secret into swarm.""" command = f"echo {value} | docker secret create {key} -" run(command, shell=True) def dump_db(db: Dict) -> None: """Dump the database.""" with open(DATA_DIR / "db.json", "w") as handle: handle.write(dumps(db)) def load_db() -> Dict: """Load the database.""" db_path = DATA_DIR / "db.json" if exists(db_path): with open(db_path, "r") as handle: return loads(handle.read()) return {} def get_hash(value: str) -> str: """Hash a value for swarm versioning with good 'ol md5.""" hasher = md5() hasher.update(value.encode()) return hasher.hexdigest() def arrange_configs_and_secrets(app_name, form_data, env): """Version secrets and configs for swarm.""" def _create_versions(values, secrets=False): """Step through listing and create an env var key/val. Takes "app_ini" and produces {"APP_INI_VERSION": "app_ini_laksjdklajsdkla"}. Hash is based on the value of the input passed in from the form. """ for value in values: if value in form_data: hashed = get_hash(form_data[value]) env_key = f"{value.upper()}_VERSION" env_val = f"{value}_{hashed}" env[env_key] = env_val if secrets: create_docker_secret(env_val, form_data[value]) _create_versions(APPS_SPEC[app_name]["configs"]) _create_versions(APPS_SPEC[app_name]["secrets"], secrets=True) return env def stack_deploy(app_name, stack_name, env): """Depoy an application to the swarm.""" compose_yml = DATA_DIR / app_name / "compose.yml" command = f"docker stack deploy -c {compose_yml} {stack_name}" # Note(decentral1se): we're not sure if this ever succeeds actually stuff # like https://github.com/issuu/sure-deploy could be used and once we # graduate this to a dockerise app, we could install that all in the # container and run it from here. Of course, if we're using JS or not might # affect this, say, we have a page that is real-time reporting or we're # using a static page? Unsure what the UX could be like, thinking of # cloudron loading bar but never like those... run(split(command), env=env) def get_loaded_env(app_name, request_form): """Load environment from install form for compose.yml injection.""" env = environ.copy() for key in request_form.keys(): # Note(decentral1se): {"app_ini": "foo"} -> {"APP_INI": "foo"} env[key.upper()] = request.form[key] # Note(decentral1se): maybe we don't need this in the end since we will be # quite sure that our configs are working and we are not getting rate # limited env["LETS_ENCRYPT_ENV"] = "staging" return env class GiteaInstallForm(FlaskForm): """Gitea installation form.""" app_name = StringField("Application name", default="Git with a cup of tea") domain = StringField("Domain name", validators=[DataRequired()],) stack_name = StringField("Stack name", default="magic-app-gitea") db_host = StringField("Database host", default="mariadb:3306") db_name = StringField("Database name", default="gitea") db_passwd = PasswordField( "Database password", validators=[DataRequired(), Length(min=32)], ) db_root_passwd = PasswordField( "Database root password", validators=[DataRequired(), Length(min=32)], ) db_type = StringField("Database type", default="mysql") db_user = StringField("Database user", default="mysql") internal_token = PasswordField( "Internal secret token", validators=[DataRequired(), Length(min=105)], ) jwt_secret = PasswordField( "JWT secret", validators=[DataRequired(), Length(min=43)], ) secret_key = PasswordField( "Secret key", validators=[DataRequired(), Length(min=64)], ) ssh_host_port = StringField("SSH host port", default="2225") @app.route("/") def index(): """Home page for app installation possibilities.""" return render_template("second/index.html", apps=[app for app in APPS_SPEC]) @app.route("/install/") def install(app_name): """Installation page for an app.""" if app_name == "gitea": form = GiteaInstallForm() return render_template("second/install.html", app_name=app_name, form=form) @app.route("/deploy/", methods=["POST"]) def deploy(app_name): """Deployment end-point for an app.""" if app_name == "gitea": form = GiteaInstallForm(request.form) if not form.validate(): return render_template("second/install.html", app_name=app_name, form=form) try: mkdir(DATA_DIR) except FileExistsError: # Note(decentral1se): we already cloned it, so keep going pass clone_app_template(app_name) # Note(decentral1se): there are badly named and the functions have too many # concerns but they basically load every value required into the # environment so that it can be injected when docker stack deploy is run, # oh and secrets are also created as part of this step ;) env = get_loaded_env(app_name, form.data) env = arrange_configs_and_secrets(app_name, form.data, env) stack_name = form.data["stack_name"] dump_db({stack_name: form.data}) stack_deploy(app_name, stack_name, env) return render_template( "second/deploy.html", app_name=app_name, stack_name=stack_name ) if __name__ == "__main__": app.run(debug=True, host="0.0.0.0")