diff --git a/.travis.yml b/.travis.yml index 2cf45c9f..c17dcbf9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,7 +14,7 @@ script: - echo -e "admin\ntest\ntest\nyou@example.com\nyou@example.com" | python GeoHealthCheck/models.py create - flake8 - python GeoHealthCheck/models.py load tests/data/fixtures.json y - - python GeoHealthCheck/models.py run + - python GeoHealthCheck/healthcheck.py - python tests/run_tests.py - cd docs && make html diff --git a/Dockerfile b/Dockerfile index eef50879..bd04bef0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,81 +4,82 @@ FROM python:2.7.13-alpine # FROM debian:jessie # Credits to yjacolin for providing first versions -LABEL original_developer "yjacolin " -LABEL maintainer "Just van den Broecke " +LABEL original_developer="yjacolin " \ + maintainer="Just van den Broecke " # These are default values, # Override when running container via docker(-compose) # General ENV settings -ENV LC_ALL "en_US.UTF-8" -ENV LANG "en_US.UTF-8" -ENV LANGUAGE "en_US.UTF-8" - -# GHC ENV settings -ENV ADMIN_NAME admin -ENV ADMIN_PWD admin -ENV ADMIN_EMAIL admin.istrator@mydomain.com -ENV SQLALCHEMY_DATABASE_URI 'sqlite:////GeoHealthCheck/DB/data.db' -ENV SECRET_KEY 'd544ccc37dc3ad214c09b1b7faaa64c60351d5c8bb48b342' -ENV GHC_RETENTION_DAYS 30 -ENV GHC_RUN_FREQUENCY 'hourly' -ENV GHC_PROBE_HTTP_TIMEOUT_SECS 30 -ENV GHC_SELF_REGISTER False -ENV GHC_NOTIFICATIONS False -ENV GHC_NOTIFICATIONS_VERBOSITY True -ENV GHC_WWW_LINK_EXCEPTION_CHECK False -ENV GHC_ADMIN_EMAIL 'you@example.com' -ENV GHC_NOTIFICATIONS_EMAIL 'you2@example.com,them@example.com' -ENV GHC_SITE_TITLE 'GeoHealthCheck' -ENV GHC_SITE_URL 'http://localhost' -ENV GHC_SMTP_SERVER None -ENV GHC_SMTP_PORT None -ENV GHC_SMTP_TLS False -ENV GHC_SMTP_SSL False -ENV GHC_SMTP_USERNAME None -ENV GHC_SMTP_PASSWORD None -ENV GHC_METADATA_CACHE_SECS 900 - -# WSGI server settings, assumed is gunicorn -ENV HOST 0.0.0.0 -ENV PORT 80 -ENV WSGI_WORKERS 4 -ENV WSGI_WORKER_TIMEOUT 6000 -ENV WSGI_WORKER_CLASS 'eventlet' - -# GHC Core Plugins modules and/or classes, seldom needed to set: -# if not specified here or in Container environment -# all GHC built-in Plugins will be active. +ENV LC_ALL="en_US.UTF-8" \ + LANG="en_US.UTF-8" \ + LANGUAGE="en_US.UTF-8" \ + \ + \ + # GHC ENV settings\ + ADMIN_NAME=admin \ + ADMIN_PWD=admin \ + ADMIN_EMAIL=admin.istrator@mydomain.com \ + SQLALCHEMY_DATABASE_URI='sqlite:////GeoHealthCheck/DB/data.db' \ + SECRET_KEY='d544ccc37dc3ad214c09b1b7faaa64c60351d5c8bb48b342' \ + GHC_PROBE_HTTP_TIMEOUT_SECS=30 \ + GHC_MINIMAL_RUN_FREQUENCY_MINS=10 \ + GHC_RETENTION_DAYS=30 \ + GHC_SELF_REGISTER=False \ + GHC_NOTIFICATIONS=False \ + GHC_NOTIFICATIONS_VERBOSITY=True \ + GHC_WWW_LINK_EXCEPTION_CHECK=False \ + GHC_ADMIN_EMAIL='you@example.com' \ + GHC_RUNNER_IN_WEBAPP=False \ + GHC_LOG_LEVEL=30 \ + GHC_LOG_FORMAT='%(asctime)s - %(name)s - %(levelname)s - %(message)s' \ + GHC_NOTIFICATIONS_EMAIL='you2@example.com,them@example.com' \ + GHC_SITE_TITLE='GeoHealthCheck' \ + GHC_SITE_URL='http://localhost' \ + GHC_SMTP_SERVER=None \ + GHC_SMTP_PORT=None \ + GHC_SMTP_TLS=False \ + GHC_SMTP_SSL=False \ + GHC_SMTP_USERNAME=None \ + GHC_SMTP_PASSWORD=None \ + GHC_METADATA_CACHE_SECS=900 \ + \ +# WSGI server settings, assumed is gunicorn \ +HOST=0.0.0.0 \ +PORT=80 \ +WSGI_WORKERS=4 \ +WSGI_WORKER_TIMEOUT=6000 \ +WSGI_WORKER_CLASS='eventlet' \ +\ +# GHC Core Plugins modules and/or classes, seldom needed to set: \ +# if not specified here or in Container environment \ +# all GHC built-in Plugins will be active. \ #ENV GHC_PLUGINS 'GeoHealthCheck.plugins.probe.owsgetcaps,\ # GeoHealthCheck.plugins.probe.wms, ...., ...\ -# GeoHealthCheck.plugins.check.checks' - -# GHC User Plugins, best be overridden via Container environment -ENV GHC_USER_PLUGINS '' +# GeoHealthCheck.plugins.check.checks' \ +\ +# GHC User Plugins, best be overridden via Container environment \ +GHC_USER_PLUGINS='' RUN apk add --no-cache --virtual .build-deps gcc build-base linux-headers postgresql-dev \ - && apk add --no-cache bash vim postgresql-client \ + && apk add --no-cache bash postgresql-client tzdata openntpd \ && pip install virtualenv \ && rm -rf /var/cache/apk/* /tmp/* /var/tmp/* # Add standard files and Add/override Plugins # Alternative Entrypoints to run GHC jobs # Override default Entrypoint with these on Containers -ADD docker/install.sh docker/configure.sh docker/run.sh \ - docker/config_site.py docker/cron-jobs-daily.sh docker/cron-jobs-hourly.sh docker/plugins / -RUN chmod a+x /*.sh +ADD docker/scripts/*.sh docker/config_site.py docker/plugins / # Add Source Code ADD . /GeoHealthCheck # Install and Remove build-related packages for smaller image size -RUN bash install.sh \ - && apk del .build-deps +RUN chmod a+x /*.sh && bash install.sh && apk del .build-deps # For SQLite VOLUME ["/GeoHealthCheck/DB/"] EXPOSE ${PORT} -ENTRYPOINT /configure.sh && /run.sh +ENTRYPOINT /run-web.sh diff --git a/GeoHealthCheck/app.py b/GeoHealthCheck/app.py index 06b9f779..935537ff 100644 --- a/GeoHealthCheck/app.py +++ b/GeoHealthCheck/app.py @@ -32,7 +32,6 @@ import csv import logging import json -from datetime import datetime, timedelta from StringIO import StringIO from itertools import chain @@ -44,7 +43,6 @@ from flask_migrate import Migrate from __init__ import __version__ -from healthcheck import sniff_test_resource, run_test_resource from init import App from enums import RESOURCE_TYPES from models import Resource, Run, ProbeVars, CheckVars, Tag, User, Recipient @@ -74,6 +72,16 @@ ('hr_HR', 'Croatian (Croatia)') ) +# Should GHC Runner be run within GHC webapp? +if CONFIG['GHC_RUNNER_IN_WEBAPP'] is True: + LOGGER.info('Running GHC Scheduler in WebApp') + from scheduler import start_schedule + + # Start scheduler + start_schedule() +else: + LOGGER.info('NOT Running GHC Scheduler in WebApp') + # commit or rollback shorthand def db_commit(): @@ -122,35 +130,6 @@ def unauthorized_callback(): return redirect(url_for('login', lang=g.current_lang, next=url)) -def next_page_refresh(): - """determines when to refresh webapp based on GHC_RUN_FREQUENCY""" - - now = datetime.now() - - frequency = CONFIG['GHC_RUN_FREQUENCY'] - - if frequency == 'hourly': # get next hour - now2 = now.replace(minute=0, second=0, microsecond=0) - refresh = timedelta(hours=1) - elif frequency == 'daily': # get next day - now2 = now.replace(hour=0, minute=0, second=0, microsecond=0) - refresh = timedelta(days=1) - elif frequency == ['weekly']: # get next day - now2 = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - refresh = timedelta(weeks=1) - elif frequency == ['monthly']: - now2 = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - refresh = timedelta(weeks=4) - elif frequency == ['yearly']: # get next day - now2 = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - refresh = timedelta(weeks=52) - - next_frequency = now2 + refresh - differ = next_frequency - now - - return differ.seconds - - @APP.template_filter('cssize_reliability') def cssize_reliability(value, css_type=None): """returns CSS button class snippet based on score""" @@ -162,11 +141,11 @@ def cssize_reliability(value, css_type=None): score = 'danger' panel = 'red' elif (CONFIG['GHC_RELIABILITY_MATRIX']['orange']['min'] <= number <= - CONFIG['GHC_RELIABILITY_MATRIX']['orange']['max']): + CONFIG['GHC_RELIABILITY_MATRIX']['orange']['max']): score = 'warning' panel = 'yellow' elif (CONFIG['GHC_RELIABILITY_MATRIX']['green']['min'] <= number <= - CONFIG['GHC_RELIABILITY_MATRIX']['green']['max']): + CONFIG['GHC_RELIABILITY_MATRIX']['green']['max']): score = 'success' panel = 'green' else: # should never really get here @@ -203,7 +182,6 @@ def context_processors(): tags = views.get_tag_counts() return { 'app_version': __version__, - 'next_page_refresh': next_page_refresh(), 'resource_types': RESOURCE_TYPES, 'resource_types_counts': rtc['counts'], 'resources_total': rtc['total'], @@ -490,6 +468,7 @@ def add(): url = request.form['url'].strip() resources_to_add = [] + from healthcheck import sniff_test_resource, run_test_resource sniffed_resources = sniff_test_resource(CONFIG, resource_type, url) if not sniffed_resources: @@ -557,7 +536,7 @@ def add(): param_vals = {} for param in param_defs: if param_defs[param]['value']: - param_vals[param] =\ + param_vals[param] = \ param_defs[param]['value'] check_vars = CheckVars( probe_to_add, check_class, param_vals) @@ -641,8 +620,8 @@ def update(resource_identifier): # Add ProbeVars anew each with optional CheckVars for probe in value: - print('adding Probe class=%s parms=%s' % - (probe['probe_class'], str(probe))) + LOGGER.info('adding Probe class=%s parms=%s' % + (probe['probe_class'], str(probe))) probe_vars = ProbeVars(resource, probe['probe_class'], probe['parameters']) for check in probe['checks']: @@ -663,6 +642,9 @@ def update(resource_identifier): elif getattr(resource, key) != resource_identifier_dict[key]: # Update other resource attrs, mainly 'name' setattr(resource, key, resource_identifier_dict[key]) + min_run_freq = CONFIG['GHC_MINIMAL_RUN_FREQUENCY_MINS'] + if int(resource.run_frequency) < min_run_freq: + resource.run_frequency = min_run_freq update_counter += 1 # Always update geo-IP: maybe failure on creation or @@ -699,6 +681,7 @@ def test(resource_identifier): flash(gettext('Resource not found'), 'danger') return redirect(request.referrer) + from healthcheck import run_test_resource result = run_test_resource( resource) @@ -754,10 +737,6 @@ def delete(resource_identifier): flash(gettext('Resource not found'), 'danger') return redirect(url_for('home', lang=g.current_lang)) - runs = Run.query.filter_by(resource_identifier=resource_identifier).all() - - for run in runs: - DB.session.delete(run) resource.clear_recipients() DB.session.delete(resource) @@ -908,7 +887,7 @@ def api_probes_avail(resource_type=None, resource_id=None): if __name__ == '__main__': # run locally, for fun import sys - logging.basicConfig() + HOST = '0.0.0.0' PORT = 8000 if len(sys.argv) > 1: diff --git a/GeoHealthCheck/config_main.py b/GeoHealthCheck/config_main.py index c589ef70..37b4b092 100644 --- a/GeoHealthCheck/config_main.py +++ b/GeoHealthCheck/config_main.py @@ -38,8 +38,8 @@ SECRET_KEY = None GHC_RETENTION_DAYS = 30 -GHC_RUN_FREQUENCY = 'hourly' GHC_PROBE_HTTP_TIMEOUT_SECS = 30 +GHC_MINIMAL_RUN_FREQUENCY_MINS = 10 GHC_SELF_REGISTER = False GHC_NOTIFICATIONS = False GHC_NOTIFICATIONS_VERBOSITY = True @@ -48,6 +48,10 @@ GHC_NOTIFICATIONS_EMAIL = ['you2@example.com'] GHC_SITE_TITLE = 'GeoHealthCheck Demonstration' GHC_SITE_URL = 'http://host' +GHC_RUNNER_IN_WEBAPP = True +# 10=DEBUG 20=INFO 30=WARN(ING) 40=ERROR 50=FATAL/CRITICAL +GHC_LOG_LEVEL = 30 +GHC_LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' # Some GetCaps docs are huge. This allows # caching them for N seconds. Set to -1 to diff --git a/GeoHealthCheck/factory.py b/GeoHealthCheck/factory.py index cf31c27e..ff9ff34a 100644 --- a/GeoHealthCheck/factory.py +++ b/GeoHealthCheck/factory.py @@ -1,3 +1,8 @@ +import logging + +LOGGER = logging.getLogger(__name__) + + class Factory: """ Object, Function class Factory (Pattern). @@ -18,9 +23,9 @@ def create_obj(class_string): # class instance from class object with constructor args return class_obj() - except Exception, e: - print("cannot create object instance from class '%s' e=%s" % - (class_string, str(e))) + except Exception as e: + LOGGER.error("cannot create object instance from class '%s' e=%s" % + (class_string, str(e))) raise e @staticmethod @@ -41,8 +46,8 @@ def create_class(class_string): class_obj = getattr( __import__(module_name, globals(), locals(), [class_name], -1), class_name) - except Exception, e: - print("cannot create class '%s'" % class_string) + except Exception as e: + LOGGER.error("cannot create class '%s'" % class_string) raise e return class_obj @@ -61,8 +66,8 @@ def create_module(module_string): try: module_obj = __import__(module_string, globals(), locals(), fromlist=['']) - except Exception, e: - print("cannot create module from '%s'" % module_string) + except Exception as e: + LOGGER.error("cannot create module from '%s'" % module_string) raise e return module_obj diff --git a/GeoHealthCheck/healthcheck.py b/GeoHealthCheck/healthcheck.py index ae0c1df6..285f8158 100644 --- a/GeoHealthCheck/healthcheck.py +++ b/GeoHealthCheck/healthcheck.py @@ -27,13 +27,13 @@ # # ================================================================= -import datetime +from datetime import datetime import logging import json from urllib2 import urlopen from urlparse import urlparse from functools import partial -from pprint import pprint +from flask_babel import gettext from owslib.wms import WebMapService from owslib.wmts import WebMapTileService @@ -44,12 +44,74 @@ from owslib.csw import CatalogueServiceWeb from owslib.sos import SensorObservationService -from flask_babel import gettext +from init import App from enums import RESOURCE_TYPES +from models import Resource, Run from probe import Probe from result import ResourceResult +from notifications import notify LOGGER = logging.getLogger(__name__) +APP = App.get_app() +DB = App.get_db() + + +# commit or rollback shorthand +def db_commit(): + err = None + try: + DB.session.commit() + except Exception as err: + DB.session.rollback() + # finally: + # DB.session.close() + return err + + +def run_resources(): + for resource in Resource.query.all(): # run all tests + LOGGER.info('Testing %s %s' % + (resource.resource_type, resource.url)) + + run_resource(resource.identifier) + + +# complete handle of resource test +def run_resource(resourceid): + resource = Resource.query.filter_by(identifier=resourceid).first() + + if not resource.active: + # Exit test of resource if it's not active + return + + # Get the status of the last run, + # assume success if there is none + last_run_success = True + last_run = resource.last_run + if last_run: + last_run_success = last_run.success + + # Run test + result = run_test_resource(resource) + + run1 = Run(resource, result, datetime.utcnow()) + + DB.session.add(run1) + + # commit or rollback each run to avoid long-lived transactions + # see https://github.com/geopython/GeoHealthCheck/issues/14 + db_commit() + + if APP.config['GHC_NOTIFICATIONS']: + # Attempt notification + try: + notify(APP.config, resource, run1, last_run_success) + except Exception as err: + # Don't bail out on failure in order to commit the Run + msg = str(err) + logging.warn('error notifying: %s' % msg) + if not __name__ == '__main__': + DB.session.remove() def run_test_resource(resource): @@ -81,7 +143,7 @@ def sniff_test_resource(config, resource_type, url): raise RuntimeError(msg2) title = None - start_time = datetime.datetime.utcnow() + start_time = datetime.utcnow() message = None resource_type_map = {'OGC:WMS': [partial(WebMapService, version='1.3.0'), partial(WebMapService, version='1.1.1')], @@ -153,7 +215,7 @@ def sniff_test_resource(config, resource_type, url): title = urlparse(url).hostname elif resource_type == 'OSGeo:GeoNode': endpoints = ows - end_time = datetime.datetime.utcnow() + end_time = datetime.utcnow() delta = end_time - start_time response_time = '%s.%s' % (delta.seconds, delta.microseconds) base_tags = geonode_make_tags(url) @@ -184,7 +246,7 @@ def sniff_test_resource(config, resource_type, url): message = msg success = False - end_time = datetime.datetime.utcnow() + end_time = datetime.utcnow() delta = end_time - start_time response_time = '%s.%s' % (delta.seconds, delta.microseconds) @@ -235,12 +297,15 @@ def geonode_make_tags(base_url): if __name__ == '__main__': - import sys - logging.basicConfig(level=logging.INFO) - from init import App - if len(sys.argv) < 3: - print('Usage: %s ' % sys.argv[0]) - sys.exit(1) - - # TODO: need APP.config here, None for now - pprint(sniff_test_resource(App.get_config(), sys.argv[1], sys.argv[2])) + print('START - Running health check tests on %s' + % datetime.utcnow().isoformat()) + run_resources() + print('END - Running health check tests on %s' + % datetime.utcnow().isoformat()) + # from init import App + # if len(sys.argv) < 3: + # print('Usage: %s ' % sys.argv[0]) + # sys.exit(1) + # + # # TODO: need APP.config here, None for now + # pprint(sniff_test_resource(App.get_config(), sys.argv[1], sys.argv[2])) diff --git a/GeoHealthCheck/init.py b/GeoHealthCheck/init.py index 7be7d8bb..d9f88f89 100644 --- a/GeoHealthCheck/init.py +++ b/GeoHealthCheck/init.py @@ -29,10 +29,13 @@ import os import sys +import logging from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_babel import Babel +LOGGER = logging.getLogger(__name__) + def to_list(obj): obj_type = type(obj) @@ -55,69 +58,71 @@ class App: babel_instance = None plugins_instance = None home_dir = None + count = 0 @staticmethod def init(): # Do init once - if not App.app_instance: - app = Flask(__name__) + app = Flask(__name__) + + # Read and override configs + app.config.from_pyfile('config_main.py') + app.config.from_pyfile('../instance/config_site.py') - # Read and override configs - app.config.from_pyfile('config_main.py') - app.config.from_pyfile('../instance/config_site.py') + # Global Logging config + logging.basicConfig(level=int(app.config['GHC_LOG_LEVEL']), + format=app.config['GHC_LOG_FORMAT']) - app.config['GHC_SITE_URL'] = \ - app.config['GHC_SITE_URL'].rstrip('/') + app.config['GHC_SITE_URL'] = \ + app.config['GHC_SITE_URL'].rstrip('/') - app.secret_key = app.config['SECRET_KEY'] + app.secret_key = app.config['SECRET_KEY'] - App.db_instance = SQLAlchemy(app) - App.babel_instance = Babel(app) + App.db_instance = SQLAlchemy(app) + App.babel_instance = Babel(app) - # Plugins (via Docker ENV) must be list, but may have been - # specified as comma-separated string, or older set notation - app.config['GHC_PLUGINS'] = to_list(app.config['GHC_PLUGINS']) - app.config['GHC_USER_PLUGINS'] = \ - to_list(app.config['GHC_USER_PLUGINS']) + # Plugins (via Docker ENV) must be list, but may have been + # specified as comma-separated string, or older set notation + app.config['GHC_PLUGINS'] = to_list(app.config['GHC_PLUGINS']) + app.config['GHC_USER_PLUGINS'] = \ + to_list(app.config['GHC_USER_PLUGINS']) - # Concatenate core- and user-Plugins - App.plugins_instance = \ - app.config['GHC_PLUGINS'] + app.config['GHC_USER_PLUGINS'] + # Concatenate core- and user-Plugins + App.plugins_instance = \ + app.config['GHC_PLUGINS'] + app.config['GHC_USER_PLUGINS'] - # Needed to find Plugins - home_dir = os.path.dirname(os.path.abspath(__file__)) - App.home_dir = sys.path.append('%s/..' % home_dir) + # Needed to find Plugins + home_dir = os.path.dirname(os.path.abspath(__file__)) + App.home_dir = sys.path.append('%s/..' % home_dir) - # Finally assign app-instance - App.app_instance = app - print("init.py: created GHC App instance") + # Finally assign app-instance + App.app_instance = app + App.count += 1 + LOGGER.info("created GHC App instance #%d" % App.count) @staticmethod def get_app(): - App.init() return App.app_instance @staticmethod def get_babel(): - App.init() return App.babel_instance @staticmethod def get_config(): - App.init() return App.app_instance.config @staticmethod def get_db(): - App.init() return App.db_instance @staticmethod def get_home_dir(): - App.init() return App.home_dir @staticmethod def get_plugins(): - App.init() return App.plugins_instance + + +App.init() diff --git a/GeoHealthCheck/migrations/versions/34531bfd7cab_.py b/GeoHealthCheck/migrations/versions/34531bfd7cab_.py new file mode 100644 index 00000000..f7896ad8 --- /dev/null +++ b/GeoHealthCheck/migrations/versions/34531bfd7cab_.py @@ -0,0 +1,47 @@ +"""empty message + +Revision ID: 34531bfd7cab +Revises: bb91fb332c36 +Create Date: 2018-03-19 16:59:42.235474 + +""" +from alembic import op +import sqlalchemy as sa +from GeoHealthCheck.migrations import alembic_helpers + +# revision identifiers, used by Alembic. +revision = '34531bfd7cab' +down_revision = 'bb91fb332c36' +branch_labels = None +depends_on = None + + +def upgrade(): + if not alembic_helpers.table_has_column('resource', 'run_frequency'): + print('Column run_frequency not present in resource table, will create') + op.add_column(u'resource', sa.Column('run_frequency', sa.Integer(), + nullable=False, default=60, server_default='60')) + else: + print('Column run_frequency already present in resource table') + + if not alembic_helpers.tables_exist(['resource_lock']): + print('Table for Resource locking not present, will create') + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('resource_lock', + sa.Column('identifier', sa.Integer(), nullable=False, unique=True), + sa.Column('resource_identifier', sa.Integer(), nullable=False, unique=True), + sa.Column('owner', sa.Text, nullable=False, default='NOT SET', server_default='NOT SET'), + sa.Column('start_time', sa.DateTime, nullable=False), + sa.Column('end_time', sa.DateTime, nullable=False), + sa.ForeignKeyConstraint(['resource_identifier'], ['resource.identifier'], ), + sa.PrimaryKeyConstraint('identifier') + ) + # ### end Alembic commands ### + else: + print('Table for Resource-locking already present, will not create') + + +def downgrade(): + print('Dropping Column run_frequency from resource table') + op.drop_column(u'resource', 'run_frequency') + op.drop_table('resource_lock') diff --git a/GeoHealthCheck/models.py b/GeoHealthCheck/models.py index 6a548e49..693a45b6 100644 --- a/GeoHealthCheck/models.py +++ b/GeoHealthCheck/models.py @@ -31,7 +31,7 @@ import json import logging from flask_babel import gettext as _ -from datetime import datetime +from datetime import datetime, timedelta from sqlalchemy import func, and_ from sqlalchemy.orm import deferred @@ -41,7 +41,6 @@ from enums import RESOURCE_TYPES from factory import Factory from init import App -from notifications import notify from wtforms.validators import Email, ValidationError from owslib.util import bind_url @@ -49,6 +48,24 @@ LOGGER = logging.getLogger(__name__) +# Complete handle of old runs deletion +def flush_runs(): + APP = App.get_app() + retention_days = int(APP.config['GHC_RETENTION_DAYS']) + LOGGER.info('Flushing runs older than %d days' % retention_days) + all_runs = Run.query.all() + run_count = 0 + for run in all_runs: + days_old = (datetime.utcnow() - run.checked_datetime).days + if days_old > retention_days: + run_count += 1 + DB.session.delete(run) + db_commit() + LOGGER.info('Deleted %d Runs' % run_count) + + DB.session.remove() + + class Run(DB.Model): """measurement of resource state""" @@ -56,7 +73,8 @@ class Run(DB.Model): resource_identifier = DB.Column(DB.Integer, DB.ForeignKey('resource.identifier')) resource = DB.relationship('Resource', - backref=DB.backref('runs', lazy='dynamic')) + backref=DB.backref('runs', lazy='dynamic', + cascade="all,delete")) checked_datetime = DB.Column(DB.DateTime, nullable=False) success = DB.Column(DB.Boolean, nullable=False) response_time = DB.Column(DB.Float, nullable=False) @@ -216,6 +234,7 @@ class dummy_value(object): @staticmethod def gettext(*args, **kwargs): return _(*args, **kwargs) + dummy_form = None v(dummy_form, dummy_value()) @@ -273,11 +292,11 @@ def is_webhook(self): @classmethod def burry_dead(cls): RN = ResourceNotification - q = DB.session.query(cls)\ - .join(RN, - RN.recipient_id == cls.id, - isouter=True)\ - .filter(RN.recipient_id.is_(None)) + q = DB.session.query(cls) \ + .join(RN, + RN.recipient_id == cls.id, + isouter=True) \ + .filter(RN.recipient_id.is_(None)) for item in q: DB.session.delete(item) @@ -289,10 +308,10 @@ def get_or_create(cls, channel, location): raise ValueError("invalid value {}: {}".format(location, err)) try: - r = DB.session.query(cls)\ - .filter(and_(cls.channel == channel, - cls.location == location))\ - .one() + r = DB.session.query(cls) \ + .filter(and_(cls.channel == channel, + cls.location == location)) \ + .one() except (MultipleResultsFound, NoResultFound,): r = cls(channel=channel, location=location) DB.session.add(r) @@ -309,12 +328,12 @@ def get_suggestions(cls, channel, for_user): Res = Resource ResNot = ResourceNotification - q = DB.session.query(Rcp.location)\ - .join(ResNot, ResNot.recipient_id == Rcp.id)\ - .join(Res, Res.identifier == ResNot.resource_id)\ - .group_by(Rcp.location)\ - .filter(and_(Res.owner_identifier == for_user, - Rcp.channel == channel)) + q = DB.session.query(Rcp.location) \ + .join(ResNot, ResNot.recipient_id == Rcp.id) \ + .join(Res, Res.identifier == ResNot.resource_id) \ + .group_by(Rcp.location) \ + .filter(and_(Res.owner_identifier == for_user, + Rcp.channel == channel)) return [item[0] for item in q] @@ -348,6 +367,7 @@ class Resource(DB.Model): owner = DB.relationship('User', backref=DB.backref('username2', lazy='dynamic')) tags = DB.relationship('Tag', secondary=resource_tags, backref='resource') + run_frequency = DB.Column(DB.Integer, default=60) def __init__(self, owner, resource_type, title, url, tags): self.resource_type = resource_type @@ -465,26 +485,26 @@ def clear_recipients(self, channel=None, burry_dead=True): # clear specific channel to_delete = self.get_recipients(channel) if to_delete: - to_del_rcp = DB.session.query(RN)\ - .join(Rcp, - Rcp.id == RN.recipient_id)\ - .filter( - and_(RN.resource_id == - self.identifier, - Rcp.channel == channel, - Rcp.location.in_(to_delete)) - ) + to_del_rcp = DB.session.query(RN) \ + .join(Rcp, + Rcp.id == RN.recipient_id) \ + .filter( + and_(RN.resource_id == + self.identifier, + Rcp.channel == channel, + Rcp.location.in_(to_delete)) + ) else: to_del_rcp = [] else: # remove all m2m connections for Resource<->Recipient - to_del_rcp = DB.session.query(RN)\ - .join(Rcp, - Rcp.id == RN.recipient_id)\ - .filter( - RN.resource_id == - self.identifier, - ) + to_del_rcp = DB.session.query(RN) \ + .join(Rcp, + Rcp.id == RN.recipient_id) \ + .filter( + RN.resource_id == + self.identifier, + ) for rcp_ntf in to_del_rcp: DB.session.delete(rcp_ntf) if burry_dead: @@ -521,6 +541,49 @@ def dump_recipients(self): return out +class ResourceLock(DB.Model): + """lock resource for multiprocessing runs""" + + identifier = DB.Column(DB.Integer, + primary_key=True, autoincrement=False, unique=True) + resource_identifier = DB.Column( + DB.Integer, DB.ForeignKey('resource.identifier'), unique=True) + resource = DB.relationship('Resource', + backref=DB.backref('locks', lazy='dynamic', + cascade="all,delete")) + owner = DB.Column(DB.Text, nullable=False, default='NOT SET') + + start_time = DB.Column(DB.DateTime, nullable=False) + end_time = DB.Column(DB.DateTime, nullable=False) + + def __init__(self, resource, owner, interval_mins): + self.identifier = resource.identifier + self.resource = resource + self.owner = owner + self.init_datetimes(interval_mins) + + def init_datetimes(self, interval_mins): + self.start_time = datetime.utcnow() + # Subtract some space from end-time to allow obtain at scheduled time + minutes = interval_mins - 1 + self.end_time = self.start_time + timedelta(minutes=minutes) + + def has_expired(self): + now = datetime.utcnow() + return now > self.end_time + + def obtain(self, owner, frequency): + if not self.has_expired(): + return False + + self.owner = owner + self.init_datetimes(frequency) + return True + + def __repr__(self): + return '' % self.identifier + + class User(DB.Model): """user accounts""" @@ -697,7 +760,7 @@ def db_commit(): except Exception as err: DB.session.rollback() msg = str(err) - print(msg) + LOGGER.error(msg) if __name__ == '__main__': @@ -757,60 +820,9 @@ def db_commit(): print('Provide path to JSON file, e.g. tests/fixtures.json') elif sys.argv[1] == 'run': - print('START - Running health check tests on %s' - % datetime.utcnow().isoformat()) - from healthcheck import run_test_resource - - for resource in Resource.query.all(): # run all tests - print('Testing %s %s' % - (resource.resource_type, resource.url)) - - if not resource.active: - print('Resource is not active. Skipping') - continue - - # Get the status of the last run, - # assume success if there is none - last_run_success = True - last_run = resource.last_run - if last_run: - last_run_success = last_run.success - - # Run test - result = run_test_resource(resource) - - run1 = Run(resource, result) - - print('Adding Run: success=%s, response_time=%ss\n' - % (str(run1.success), run1.response_time)) - - DB.session.add(run1) - - # commit or rollback each run to avoid long-lived transactions - # see https://github.com/geopython/GeoHealthCheck/issues/14 - db_commit() - - if APP.config['GHC_NOTIFICATIONS']: - # Attempt notification - try: - notify(APP.config, resource, run1, last_run_success) - except Exception as err: - LOGGER.error("Cannot send notifications: %s", - err, - exc_info=err) - - print('END - Running health check tests on %s' - % datetime.utcnow().isoformat()) + print('NOTICE: models.py no longer here.') + print('Use: python healthcheck.py or upcoming cli.py') elif sys.argv[1] == 'flush': - retention_days = int(APP.config['GHC_RETENTION_DAYS']) - print('Flushing runs older than %d days' % - retention_days) - all_runs = Run.query.all() - for run in all_runs: - days_old = (datetime.utcnow() - run.checked_datetime).days - if days_old > retention_days: - print('Run older than %d days. Deleting' % days_old) - DB.session.delete(run) - db_commit() + flush_runs() DB.session.remove() diff --git a/GeoHealthCheck/plugin.py b/GeoHealthCheck/plugin.py index 1f19379e..2b36a7da 100644 --- a/GeoHealthCheck/plugin.py +++ b/GeoHealthCheck/plugin.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- from factory import Factory +import logging import inspect import collections import copy from init import App +LOGGER = logging.getLogger(__name__) + class Plugin(object): """ @@ -176,7 +179,7 @@ def add_result(plugin_name, class_obj): and baseclass != class_obj: add_result(plugin_name, class_obj) except Exception: - print('cannot create obj class=%s' % plugin_name) + LOGGER.warn('cannot create obj class=%s' % plugin_name) return result diff --git a/GeoHealthCheck/scheduler.py b/GeoHealthCheck/scheduler.py new file mode 100644 index 00000000..ebfa6310 --- /dev/null +++ b/GeoHealthCheck/scheduler.py @@ -0,0 +1,281 @@ +# coding=utf-8 +# ================================================================= +# +# Authors: Tom Kralidis +# Just van den Broecke +# +# Copyright (c) 2014 Tom Kralidis +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation +# files (the "Software"), to deal in the Software without +# restriction, including without limitation the rights to use, +# copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following +# conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +# HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +# OTHER DEALINGS IN THE SOFTWARE. +# +# ================================================================= + +import logging +import os +import random +import string +from datetime import datetime, timedelta +from models import Resource, ResourceLock, flush_runs +from healthcheck import run_resource +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.jobstores.base import JobLookupError +from apscheduler.events import \ + EVENT_SCHEDULER_STARTED, EVENT_SCHEDULER_SHUTDOWN, \ + EVENT_JOB_MISSED, EVENT_JOB_ERROR +from init import App + +LOGGER = logging.getLogger(__name__) +DB = App.get_db() + +# Create scheduler +scheduler = BackgroundScheduler() + + +# commit or rollback shorthand +def db_commit(): + err = None + try: + DB.session.commit() + except Exception as err: + DB.session.rollback() + # finally: + # DB.session.close() + return err + + +def run_job(resource_id, frequency): + """ + Runs single job (all Probes) for single Resource. + As multiple instances of the job scheduler may run in different + processes and threads, the database is used to synchronize and assure + only one job will run. This is achieved by having one lock per Resource. + Only the process/thread that acquires its related ResourceLock record + runs the job. As to avoid permanent "lockouts", each ResourceLock has + a lifetime, namely the timespan until the next Run as configured for/per + Resource. This gives all job runners a chance to obtain a lock once + "time's up" for the ResourceLock. + + An extra check for lock obtainment is made via an unique UUID per job + runner. Once the lock is obtained the UUID-field of the lock record + is set and committed to the DB. If we then try to obtain the lock again + (by reading from DB) but the UUID is different this means another job + runner instance did the same but was just before us. The lock timespan + will guard that a particular UUID will keep the lock forever, e.g. if + the application is suddenly shutdown. + + :param resource_id: + :param frequency: + :return: + """ + # Generate unique id + # https://stackoverflow.com/questions/2257441/random-string-generation-with-upper-case-letters-and-digits-in-python + uuid = '%d-%s' % (os.getpid(), ''.join(random.choice( + string.ascii_uppercase + string.digits) for _ in range(8))) + + resource = Resource.query.filter_by(identifier=resource_id).first() + + # Resource may have been deleted, cancel job + if not resource: + stop_job(resource_id) + return + + # Resource exists: try to obtain our Resource Lock record. + lock = ResourceLock.query.filter_by(identifier=resource_id).first() + + if not lock: + # No lock at all on Resource: (hope) we're first + # obtain fresh lock, back-off if failed + LOGGER.info('%d No Lock at all: obtain new' % resource_id) + + lock = ResourceLock(resource, uuid, frequency) + DB.session.add(lock) + lock_err = db_commit() + if lock_err: + # Another process may have been there first! + LOGGER.info('%d Error obtaining Lock %s' % + (resource_id, str(lock_err))) + return + else: + # Lock record found for Resource: check if available for our UUID. + LOGGER.info('%d Lock present: try obtaining..' % resource_id) + if not lock.obtain(uuid, frequency): + LOGGER.info('%d Cannot obtain lock' % resource_id) + return + else: + LOGGER.info('%d Lock obtained, delete and renew' % resource_id) + DB.session.delete(lock) + lock_err = db_commit() + if lock_err: + # Another process may have been there first! + LOGGER.info('%d Lock Delete failed' % resource_id) + return + + LOGGER.info('%d Lock deleted, add new' % resource_id) + # (hope) we're first + # obtain fresh lock, back-off if failed + lock = ResourceLock(resource, uuid, frequency) + DB.session.add(lock) + lock_err = db_commit() + if lock_err: + # Another process may have been there first! + LOGGER.info('%d Lock Add failed' % resource_id) + return + + # Check if we really own the lock + LOGGER.info('%d Lock Add OK' % resource_id) + lock = ResourceLock.query.filter_by( + identifier=resource_id).first() + + if lock.owner != uuid: + LOGGER.info('%d Lock Add OK, not owner: back-off' + % resource_id) + return + + # Run Resource healthchecks only if we have lock. + if lock: + try: + run_resource(resource_id) + LOGGER.info('%d run_resource OK' % resource_id) + finally: + pass + + +def start_schedule(): + LOGGER.info('Starting scheduler') + + # Adapt configuration + scheduler.configure(job_defaults={ + 'coalesce': True, + 'max_instances': 1, + 'misfire_grace_time': 300 + }) + + scheduler.add_listener(lifecycle_listener, + EVENT_SCHEDULER_STARTED | EVENT_SCHEDULER_SHUTDOWN) + scheduler.add_listener(error_listener, + EVENT_JOB_MISSED | EVENT_JOB_ERROR) + + # Start APScheduler + scheduler.start() + import atexit + atexit.register(lambda: stop_schedule()) + + # Add GHC jobs, one for each Resource, plus + # maintenance jobs. + + # Cold start every cron of every Resource + for resource in Resource.query.all(): + add_job(resource) + + # Start maintenance jobs + scheduler.add_job(flush_runs, 'interval', minutes=150) + scheduler.add_job(check_schedule, 'interval', minutes=5) + + +def check_schedule(): + LOGGER.info('Checking Job schedules') + + # Check the schedule for changed jobs + for resource in Resource.query.all(): + job = get_job(resource) + if job is None: + add_job(resource) + continue + + current_freq = job.args[1] + + # Run frequency changed? + if current_freq != resource.run_frequency: + # Reschedule Job + update_job(resource) + + +def lifecycle_listener(event): + event_code = event.code + event_code_str = '' + if event_code == EVENT_SCHEDULER_STARTED: + event_code_str = 'EVENT_SCHEDULER_STARTED' + elif event_code | EVENT_SCHEDULER_SHUTDOWN: + event_code_str = 'EVENT_SCHEDULER_SHUTDOWN' + + LOGGER.info('lifecycle_listener: %s - %s' % (event_code_str, str(event))) + + +def error_listener(event): + event_code = event.code + event_code_str = '' + if event_code | EVENT_JOB_MISSED: + event_code_str = 'EVENT_JOB_MISSED' + elif event_code | EVENT_JOB_ERROR: + event_code_str = 'EVENT_JOB_ERROR' + + LOGGER.error('error_listener: %s - %s' % (event_code_str, str(event))) + + +def get_job(resource): + return scheduler.get_job(str(resource.identifier)) + + +def update_job(resource): + stop_job(resource.identifier) + + # Add job to Scheduler + add_job(resource) + + +def add_job(resource): + LOGGER.info('Starting job for resource=%d' % resource.identifier) + freq = resource.run_frequency + next_run_time = datetime.now() + timedelta(minutes=random.randint(0, freq)) + scheduler.add_job( + run_job, 'interval', args=[resource.identifier, freq], + minutes=freq, next_run_time=next_run_time, max_instances=1, + misfire_grace_time=(freq * 60) / 2, coalesce=True, + id=str(resource.identifier)) + + +def stop_job(resource_id): + LOGGER.info('Stopping job for resource=%d' % resource_id) + + # Try to remove job from scheduler + try: + scheduler.remove_job(str(resource_id)) + except JobLookupError: + pass + + +def stop_schedule(): + LOGGER.info('Stopping Scheduler') + scheduler.shutdown() + scheduler.remove_listener(lifecycle_listener) + scheduler.remove_listener(error_listener) + + +if __name__ == '__main__': + import time + + # Start scheduler + start_schedule() + + while True: + LOGGER.info("This prints once in 5 minutes") + time.sleep(300) # Delay for 5 minute (300 seconds). diff --git a/GeoHealthCheck/templates/edit_resource.html b/GeoHealthCheck/templates/edit_resource.html index 885aaf4e..1569a0fd 100644 --- a/GeoHealthCheck/templates/edit_resource.html +++ b/GeoHealthCheck/templates/edit_resource.html @@ -88,6 +88,12 @@

[{{ _('Edit') }}] {{ resour + + {{ _('Run Every') }} + + + + Probes @@ -258,6 +264,9 @@

[{{ _('Edit') }}] {{ resour // Collect tags var new_tags = $('#resource_tags').val(); + // Collect test_frequency + var new_frequency = $('input[name="resource_frequency_value"]').val(); + // Collect active var new_active = $('#input_resource_active').prop('checked'); @@ -335,6 +344,7 @@

[{{ _('Edit') }}] {{ resour title: new_title, active: new_active, tags: new_tags, + run_frequency: new_frequency, probes: new_probes, notify_emails: new_notify_emails, notify_webhooks: new_notify_webhooks diff --git a/GeoHealthCheck/templates/layout.html b/GeoHealthCheck/templates/layout.html index f751b948..c3bf0b21 100644 --- a/GeoHealthCheck/templates/layout.html +++ b/GeoHealthCheck/templates/layout.html @@ -2,7 +2,6 @@ - @@ -107,8 +106,9 @@ {{ _('Settings') }} diff --git a/GeoHealthCheck/templates/resource.html b/GeoHealthCheck/templates/resource.html index 723f75fb..dc42185c 100644 --- a/GeoHealthCheck/templates/resource.html +++ b/GeoHealthCheck/templates/resource.html @@ -54,6 +54,12 @@