244 lines
7.0 KiB
Python
244 lines
7.0 KiB
Python
"""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
|
|
|
|
|
|
## FLASK CONFIG
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
|
if environ.get('FLASK_CONFIG', None) is None:
|
|
environ['FLASK_CONFIG'] = 'config/local.py'
|
|
app.config.from_envvar('FLASK_CONFIG')
|
|
|
|
|
|
## AUTHENTICATION
|
|
|
|
|
|
from flask_login import LoginManager, UserMixin
|
|
login_manager = LoginManager()
|
|
login_manager.init_app(app)
|
|
|
|
|
|
class User(UserMixin, object):
|
|
pass
|
|
|
|
|
|
@login_manager.user_loader
|
|
def load_user(user_id):
|
|
return User.get(user_id)
|
|
|
|
|
|
## CONFIG
|
|
|
|
|
|
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")
|
|
|
|
|
|
## HELPERS
|
|
|
|
|
|
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
|
|
|
|
|
|
## FORMS
|
|
|
|
|
|
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")
|
|
|
|
|
|
## VIEWS
|
|
|
|
|
|
@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/<app_name>")
|
|
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/<app_name>", 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()
|