diff --git a/debian/control b/debian/control index c7f83b8..33a0609 100644 --- a/debian/control +++ b/debian/control @@ -16,7 +16,8 @@ Depends: ${misc:Depends}, python-graypy, python-apt, python-memcache, - python-prometheus-client + python-prometheus-client, + python-djangorestframework Recommends: python-django (>= 1.8), python-gadjo, python-django-mellon (>= 1.2.22.26), diff --git a/hobo/environment/models.py b/hobo/environment/models.py index d002d06..01ab76f 100644 --- a/hobo/environment/models.py +++ b/hobo/environment/models.py @@ -1,12 +1,13 @@ import datetime import json - import requests +import socket from django.conf import settings from django.db import models from django.utils.crypto import get_random_string from django.utils.encoding import force_text +from django.utils.six.moves.urllib.parse import urlparse from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from django.core.exceptions import ValidationError @@ -163,6 +164,31 @@ class ServiceBase(models.Model): def get_backoffice_menu_url(self): return None + def is_resolvable(self): + try: + netloc = urlparse(self.base_url).netloc + if netloc and socket.gethostbyname(netloc): + return True + except socket.gaierror: + return False + + def has_valid_certificate(self): + if not self.is_resolvable(): + return False + try: + requests.get(self.base_url, verify=True) + return True + except requests.exceptions.SSLError: + return False + except requests.exceptions.ConnectionError: + return False + + def is_running(self): + if not self.is_resolvable(): + return False + r = requests.get(self.get_admin_zones()[0].href, verify=False) + return bool(r.status_code is 200) + class Authentic(ServiceBase): use_as_idp_for_self = models.BooleanField( diff --git a/hobo/rest/__init__.py b/hobo/rest/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hobo/rest/serializers.py b/hobo/rest/serializers.py new file mode 100644 index 0000000..3675df2 --- /dev/null +++ b/hobo/rest/serializers.py @@ -0,0 +1,26 @@ +# hobo - portal to configure and deploy applications +# Copyright (C) 2015-2018 Entr'ouvert +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +from rest_framework import serializers + + +class HealthBaseSerializer(serializers.Serializer): + title = serializers.CharField() + base_url = serializers.CharField() + is_resolvable = serializers.BooleanField() + has_valid_certificate = serializers.BooleanField() + is_running = serializers.BooleanField() + is_operational = serializers.BooleanField() diff --git a/hobo/rest/urls.py b/hobo/rest/urls.py new file mode 100644 index 0000000..75675b7 --- /dev/null +++ b/hobo/rest/urls.py @@ -0,0 +1,22 @@ +# hobo - portal to configure and deploy applications +# Copyright (C) 2015-2018 Entr'ouvert +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +from django.conf.urls import url +from .views import HealthList + +urlpatterns = [ + url(r'^health/$', HealthList.as_view(), name='api-health-list'), +] diff --git a/hobo/rest/views.py b/hobo/rest/views.py new file mode 100644 index 0000000..a8cf9c6 --- /dev/null +++ b/hobo/rest/views.py @@ -0,0 +1,33 @@ +# hobo - portal to configure and deploy applications +# Copyright (C) 2015-2018 Entr'ouvert +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, see . + +from rest_framework import generics +from rest_framework import permissions + +from hobo.environment.models import AVAILABLE_SERVICES +from hobo.rest.serializers import HealthBaseSerializer + + +class HealthList(generics.ListAPIView): + serializer_class = HealthBaseSerializer + permission_classes = (permissions.IsAuthenticatedOrReadOnly,) + + def get_queryset(self): + qs = [] + for service in AVAILABLE_SERVICES: + for instance in service.objects.all(): + qs.append(instance) + return qs diff --git a/hobo/settings.py b/hobo/settings.py index c8a5a72..071985e 100644 --- a/hobo/settings.py +++ b/hobo/settings.py @@ -36,6 +36,7 @@ INSTALLED_APPS = ( 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', 'mellon', 'gadjo', 'hobo.environment', diff --git a/hobo/urls.py b/hobo/urls.py index c0af2d4..d247818 100644 --- a/hobo/urls.py +++ b/hobo/urls.py @@ -10,6 +10,7 @@ from .environment.urls import urlpatterns as environment_urls from .profile.urls import urlpatterns as profile_urls from .theme.urls import urlpatterns as theme_urls from .emails.urls import urlpatterns as emails_urls +from .rest.urls import urlpatterns as rest_urls urlpatterns = [ url(r'^$', home, name='home'), @@ -20,6 +21,7 @@ urlpatterns = [ url(r'^theme/', decorated_includes(admin_required, include(theme_urls))), url(r'^emails/', decorated_includes(admin_required, include(emails_urls))), + url(r'^api/', include(rest_urls)), url(r'^menu.json$', menu_json, name='menu_json'), url(r'^hobos.json$', hobo), url(r'^admin/', include(admin.site.urls)), diff --git a/setup.py b/setup.py index 8cf8a29..7e5c273 100644 --- a/setup.py +++ b/setup.py @@ -101,6 +101,7 @@ setup( 'django-mellon', 'django-tenant-schemas', 'prometheus_client', + 'djangorestframework>=3.1, <3.7', ], zip_safe=False, cmdclass={ diff --git a/tests/test_health_api.py b/tests/test_health_api.py new file mode 100644 index 0000000..d923bf9 --- /dev/null +++ b/tests/test_health_api.py @@ -0,0 +1,102 @@ +import json +from mock import MagicMock +import pytest +import requests +import socket + +from django.utils import timezone + +from hobo.environment.models import Authentic, Combo + +pytestmark = pytest.mark.django_db + + +def get_entry(ls, title): + """ + Search a list of dictionnaries + """ + entries = [i for i in ls if i['title'] == title] + if len(entries) > 1: + raise(Exception('There is more than 1 %s' % title)) + return entries[0] + + +@pytest.fixture +def services(request): + now = timezone.now() + a = Authentic(title='blues', slug='blues', base_url='https://blues.example.publik', + last_operational_check_timestamp=now, + last_operational_success_timestamp=now) + a.save() + c = Combo(title='jazz', slug='jazz', base_url='https://jazz.example.publik') + c.save() + + +def test_response(app, services, monkeypatch): + monkeypatch.setattr(socket, 'gethostbyname', lambda x: '176.31.123.109') + monkeypatch.setattr(requests, 'get', lambda x, verify: MagicMock(status_code=200)) + response = app.get('/api/health/') + assert response.status_code == 200 + content = json.loads(response.content) + assert len(content) == 2 + assert content[0]['title'] == 'blues' + assert content[1]['title'] == 'jazz' + + +def test_is_resolvable(app, services, monkeypatch): + def gethostname(netloc): + if netloc == "jazz.example.publik": + return '176.31.123.109' + else: + raise socket.gaierror + monkeypatch.setattr(socket, 'gethostbyname', gethostname) + monkeypatch.setattr(requests, 'get', lambda x, verify: MagicMock(status_code=200)) + response = app.get('/api/health/') + content = json.loads(response.content) + blues = get_entry(content, 'blues') + jazz = get_entry(content, 'jazz') + assert not blues['is_resolvable'] + assert jazz['is_resolvable'] + + +def test_is_running(app, services, monkeypatch): + def get(url, verify): + if url == 'https://jazz.example.publik/manage/': + return MagicMock(status_code=200) + else: + return MagicMock(status_code=404) + monkeypatch.setattr(socket, 'gethostbyname', lambda x: '176.31.123.109') + monkeypatch.setattr(requests, 'get', get) + response = app.get('/api/health/') + content = json.loads(response.content) + blues = get_entry(content, 'blues') + jazz = get_entry(content, 'jazz') + assert not blues['is_running'] + assert jazz['is_running'] + + +def test_is_operational(app, services, monkeypatch): + monkeypatch.setattr(socket, 'gethostbyname', lambda x: '176.31.123.109') + monkeypatch.setattr(requests, 'get', lambda x, verify: MagicMock(status_code=200)) + response = app.get('/api/health/') + content = json.loads(response.content) + blues = get_entry(content, 'blues') + jazz = get_entry(content, 'jazz') + assert blues['is_operational'] + assert not jazz['is_operational'] + + +def test_has_valid_certificate(app, services, monkeypatch): + def get(url, verify): + if 'blues.example.publik' in url or not verify: + return MagicMock(status_code=200) + else: + raise requests.exceptions.SSLError + monkeypatch.setattr(socket, 'gethostbyname', lambda x: '176.31.123.109') + monkeypatch.setattr(requests, 'get', get) + response = app.get('/api/health/') + content = json.loads(response.content) + blues = get_entry(content, 'blues') + jazz = get_entry(content, 'jazz') + assert blues['has_valid_certificate'] + assert not jazz['has_valid_certificate'] diff --git a/tox.ini b/tox.ini index a614726..594fbdd 100644 --- a/tox.ini +++ b/tox.ini @@ -38,6 +38,7 @@ deps: cssselect WebTest django-mellon + django-webtest<1.9.3 celery<4 Markdown<3 django18: django-tables2<1.1 @@ -48,6 +49,7 @@ deps: passerelle: python-memcached http://git.entrouvert.org/debian/django-tenant-schemas.git/snapshot/django-tenant-schemas-master.tar.gz httmock + requests commands = ./getlasso.sh hobo: py.test {env:COVERAGE:} {env:NOMIGRATIONS:} {posargs:tests/}