From 8abe5d60fa91c06eb979ab57559932643a38ac11 Mon Sep 17 00:00:00 2001 From: Iain Learmonth Date: Sun, 8 May 2022 17:20:04 +0100 Subject: [PATCH] automation: establish an automation framework --- app/cli/__main__.py | 2 + app/cli/automate.py | 117 ++++++++++++++++++ app/models/automation.py | 34 +++++ app/portal/__init__.py | 3 +- app/portal/automation.py | 70 +++++++++++ app/portal/templates/automation.html.j2 | 16 +++ app/portal/templates/base.html.j2 | 6 + app/portal/templates/list.html.j2 | 6 +- app/portal/templates/tables.html.j2 | 63 ++++++++++ app/portal/util.py | 8 ++ app/terraform/__init__.py | 3 +- app/terraform/alarms/__init__.py | 0 app/terraform/alarms/proxy_azure_cdn.py | 36 ++++++ app/terraform/alarms/proxy_cloudfront.py | 60 +++++++++ app/terraform/alarms/proxy_http_status.py | 64 ++++++++++ app/terraform/bridge/__init__.py | 18 +-- app/terraform/bridge/aws.py | 17 +-- app/terraform/bridge/gandi.py | 17 +-- app/terraform/bridge/hcloud.py | 17 +-- app/terraform/bridge/ovh.py | 17 +-- app/terraform/list/__init__.py | 8 +- app/terraform/list/github.py | 10 +- app/terraform/list/gitlab.py | 10 +- app/terraform/list/s3.py | 12 +- app/terraform/proxy/__init__.py | 4 +- app/terraform/proxy/azure_cdn.py | 37 +----- app/terraform/proxy/cloudfront.py | 58 +-------- app/terraform/proxy_check.py | 62 ---------- app/terraform/terraform.py | 27 ++-- docs/admin/external.rst | 4 +- .../versions/0a0a65db7f01_add_automations.py | 54 ++++++++ 31 files changed, 586 insertions(+), 274 deletions(-) create mode 100644 app/cli/automate.py create mode 100644 app/models/automation.py create mode 100644 app/portal/automation.py create mode 100644 app/portal/templates/automation.html.j2 create mode 100644 app/terraform/alarms/__init__.py create mode 100644 app/terraform/alarms/proxy_azure_cdn.py create mode 100644 app/terraform/alarms/proxy_cloudfront.py create mode 100644 app/terraform/alarms/proxy_http_status.py delete mode 100644 app/terraform/proxy_check.py create mode 100644 migrations/versions/0a0a65db7f01_add_automations.py diff --git a/app/cli/__main__.py b/app/cli/__main__.py index 87bfcb3..8c0ec6f 100644 --- a/app/cli/__main__.py +++ b/app/cli/__main__.py @@ -3,6 +3,7 @@ import logging import sys from os.path import basename +from app.cli.automate import AutomateCliHandler from app.cli.db import DbCliHandler from app.cli.list import ListCliHandler @@ -13,6 +14,7 @@ def parse_args(argv): parser = argparse.ArgumentParser() parser.add_argument("-v", "--verbose", help="increase logging verbosity", action="store_true") subparsers = parser.add_subparsers(title="command", help="command to run") + AutomateCliHandler.add_subparser_to(subparsers) DbCliHandler.add_subparser_to(subparsers) ListCliHandler.add_subparser_to(subparsers) args = parser.parse_args(argv[1:]) diff --git a/app/cli/automate.py b/app/cli/automate.py new file mode 100644 index 0000000..ffd7335 --- /dev/null +++ b/app/cli/automate.py @@ -0,0 +1,117 @@ +import argparse +import datetime +import json +import logging + +from app import app +from app.extensions import db +from app.models.automation import Automation, AutomationState, AutomationLogs +from app.terraform import BaseAutomation +from app.terraform.alarms.proxy_azure_cdn import AlarmProxyAzureCdnAutomation +from app.terraform.alarms.proxy_cloudfront import AlarmProxyCloudfrontAutomation +from app.terraform.alarms.proxy_http_status import AlarmProxyHTTPStatusAutomation +from app.terraform.bridge.aws import BridgeAWSAutomation +from app.terraform.bridge.gandi import BridgeGandiAutomation +from app.terraform.bridge.hcloud import BridgeHcloudAutomation +from app.terraform.bridge.ovh import BridgeOvhAutomation +from app.terraform.list.github import ListGithubAutomation +from app.terraform.list.gitlab import ListGitlabAutomation +from app.terraform.list.s3 import ListS3Automation +from app.terraform.proxy.azure_cdn import ProxyAzureCdnAutomation +from app.terraform.proxy.cloudfront import ProxyCloudfrontAutomation + + +jobs = { + x.short_name: x + for x in [ + AlarmProxyAzureCdnAutomation, + AlarmProxyCloudfrontAutomation, + AlarmProxyHTTPStatusAutomation, + BridgeAWSAutomation, + BridgeGandiAutomation, + BridgeHcloudAutomation, + BridgeOvhAutomation, + ListGithubAutomation, + ListGitlabAutomation, + ListS3Automation, + ProxyAzureCdnAutomation, + ProxyCloudfrontAutomation + ] +} + + +def run_all(**kwargs): + for job in jobs.values(): + run_job(job, **kwargs) + + +def run_job(job: BaseAutomation, *, force: bool = False, ignore_schedule: bool = False): + automation = Automation.query.filter(Automation.short_name == job.short_name).first() + if automation is None: + automation = Automation() + automation.short_name = job.short_name + automation.description = job.description + automation.enabled = True + automation.next_is_full = False + automation.added = datetime.datetime.utcnow() + automation.updated = automation.added + db.session.add(automation) + else: + if automation.state == AutomationState.RUNNING and not force: + logging.warning("Not running an already running automation") + return + if not ignore_schedule and not force: + if automation.next_run is not None and automation.next_run > datetime.datetime.utcnow(): + logging.warning("Not time to run this job yet") + return + if not automation.enabled and not force: + db.session.rollback() + logging.warning(f"job {job.short_name} is disabled and --force not specified") + return + automation.state = AutomationState.RUNNING + db.session.commit() + job = job() + try: + success, logs = job.automate() + except Exception as e: + success = False + logs = repr(e) + if success: + automation.state = AutomationState.IDLE + automation.next_run = datetime.datetime.utcnow() + datetime.timedelta(minutes=7) + else: + automation.state = AutomationState.ERROR + automation.enabled = False + automation.next_run = None + log = AutomationLogs() + log.automation_id = automation.id + log.added = datetime.datetime.utcnow() + log.updated = datetime.datetime.utcnow() + log.logs = json.dumps(logs) + db.session.add(log) + automation.last_run = datetime.datetime.utcnow() + db.session.commit() + + +class AutomateCliHandler: + @classmethod + def add_subparser_to(cls, subparsers: argparse._SubParsersAction) -> None: + parser = subparsers.add_parser("automate", help="automation operations") + parser.add_argument("-a", "--all", dest="all", help="run all automation jobs", action="store_true") + parser.add_argument("-j", "--job", dest="job", choices=sorted(jobs.keys()), + help="run a specific automation job") + parser.add_argument("--force", help="run job even if disabled and it's not time yet", action="store_true") + parser.add_argument("--ignore-schedule", help="run job even if it's not time yet", action="store_true") + parser.set_defaults(cls=cls) + + def __init__(self, args): + self.args = args + + def run(self): + with app.app_context(): + if self.args.job: + run_job(jobs[self.args.job], force=self.args.force, ignore_schedule=self.args.ignore_schedule) + elif self.args.all: + run_all(force=self.args.force, ignore_schedule=self.args.ignore_schedule) + else: + logging.error("No action requested") diff --git a/app/models/automation.py b/app/models/automation.py new file mode 100644 index 0000000..bde6542 --- /dev/null +++ b/app/models/automation.py @@ -0,0 +1,34 @@ +import datetime +import enum + +from app.extensions import db +from app.models import AbstractConfiguration, AbstractResource + + +class AutomationState(enum.Enum): + IDLE = 0 + RUNNING = 1 + ERROR = 3 + + +class Automation(AbstractConfiguration): + short_name = db.Column(db.String(25), nullable=False) + state = db.Column(db.Enum(AutomationState), default=AutomationState.IDLE, nullable=False) + enabled = db.Column(db.Boolean, nullable=False) + last_run = db.Column(db.DateTime(), nullable=True) + next_run = db.Column(db.DateTime(), nullable=True) + next_is_full = db.Column(db.Boolean(), nullable=False) + + logs = db.relationship("AutomationLogs", back_populates="automation") + + def kick(self): + self.enabled = True + self.next_run = datetime.datetime.utcnow() + self.updated = datetime.datetime.utcnow() + + +class AutomationLogs(AbstractResource): + automation_id = db.Column(db.Integer, db.ForeignKey(Automation.id), nullable=False) + logs = db.Column(db.Text) + + automation = db.relationship("Automation", back_populates="logs") diff --git a/app/portal/__init__.py b/app/portal/__init__.py index 0154d15..3cbb73f 100644 --- a/app/portal/__init__.py +++ b/app/portal/__init__.py @@ -8,6 +8,7 @@ from app.models.alarms import Alarm from app import Origin, Proxy from app.models.base import Group, MirrorList from app.portal.forms import LifecycleForm, NewMirrorListForm +from app.portal.automation import bp as automation from app.portal.bridgeconf import bp as bridgeconf from app.portal.bridge import bp as bridge from app.portal.group import bp as group @@ -17,7 +18,7 @@ from app.portal.proxy import bp as proxy from app.portal.util import response_404, view_lifecycle portal = Blueprint("portal", __name__, template_folder="templates", static_folder="static") - +portal.register_blueprint(automation, url_prefix="/automation") portal.register_blueprint(bridgeconf, url_prefix="/bridgeconf") portal.register_blueprint(bridge, url_prefix="/bridge") portal.register_blueprint(group, url_prefix="/group") diff --git a/app/portal/automation.py b/app/portal/automation.py new file mode 100644 index 0000000..df39003 --- /dev/null +++ b/app/portal/automation.py @@ -0,0 +1,70 @@ +from datetime import datetime + +from flask import render_template, flash, Response, Blueprint +from flask_wtf import FlaskForm +from sqlalchemy import exc +from wtforms import SubmitField, BooleanField +from wtforms.validators import DataRequired + +from app.extensions import db +from app.models.automation import Automation +from app.portal.util import view_lifecycle, response_404 + +bp = Blueprint("automation", __name__) + + +class EditAutomationForm(FlaskForm): + enabled = BooleanField('Enabled') + submit = SubmitField('Save Changes') + + +@bp.route("/list") +def automation_list(): + automations = Automation.query.filter( + Automation.destroyed == None).order_by(Automation.description).all() + return render_template("list.html.j2", + section="automation", + title="Automation Jobs", + item="automation", + items=automations) + + +@bp.route('/edit/', methods=['GET', 'POST']) +def automation_edit(automation_id): + automation = Automation.query.filter(Automation.id == automation_id).first() + if automation is None: + return Response(render_template("error.html.j2", + section="automation", + header="404 Automation Job Not Found", + message="The requested automation job could not be found."), + status=404) + form = EditAutomationForm(enabled=automation.enabled) + if form.validate_on_submit(): + automation.enabled = form.enabled.data + automation.updated = datetime.utcnow() + try: + db.session.commit() + flash("Saved changes to bridge configuration.", "success") + except exc.SQLAlchemyError: + flash("An error occurred saving the changes to the bridge configuration.", "danger") + return render_template("automation.html.j2", + section="automation", + automation=automation, form=form) + + +@bp.route("/kick/", methods=['GET', 'POST']) +def automation_kick(automation_id: int): + automation = Automation.query.filter( + Automation.id == automation_id, + Automation.destroyed == None).first() + if automation is None: + return response_404("The requested bridge configuration could not be found.") + return view_lifecycle( + header=f"Kick automation timer?", + message=automation.description, + success_view="portal.automation.automation_list", + success_message="This automation job will next run within 1 minute.", + section="automation", + resource=automation, + action="kick" + ) diff --git a/app/portal/templates/automation.html.j2 b/app/portal/templates/automation.html.j2 new file mode 100644 index 0000000..1917520 --- /dev/null +++ b/app/portal/templates/automation.html.j2 @@ -0,0 +1,16 @@ +{% extends "base.html.j2" %} +{% from 'bootstrap5/form.html' import render_form %} +{% from "tables.html.j2" import automation_logs_table %} + +{% block content %} +

Automation Job

+

{{ automation.description }} ({{ automation.short_name }})

+ +
+ {{ render_form(form) }} +
+ +

Logs

+ {{ automation_logs_table(automation.logs) }} + +{% endblock %} diff --git a/app/portal/templates/base.html.j2 b/app/portal/templates/base.html.j2 index 52d9910..1db9162 100644 --- a/app/portal/templates/base.html.j2 +++ b/app/portal/templates/base.html.j2 @@ -128,6 +128,12 @@ Monitoring