diff --git a/orchestra/contrib/saas/backends/nextcloud.py b/orchestra/contrib/saas/backends/nextcloud.py new file mode 100644 index 00000000..f4302ef6 --- /dev/null +++ b/orchestra/contrib/saas/backends/nextcloud.py @@ -0,0 +1,175 @@ +import re +import sys +import textwrap +import time +import xml.etree.ElementTree as ET +from urllib.parse import urlparse + +import requests +from django.utils.translation import ugettext_lazy as _ + +from orchestra.contrib.orchestration import ServiceController +from orchestra.contrib.resources import ServiceMonitor + +from . import ApacheTrafficByName +from .. import settings + + +class NextCloudAPIMixin(object): + def validate_response(self, response): + request = response.request + context = (request.method, response.url, request.body, response.status_code) + sys.stderr.write("%s %s '%s' HTTP %s\n" % context) + if response.status_code != requests.codes.ok: + raise RuntimeError("%s %s '%s' HTTP %s" % context) + root = ET.fromstring(response.text) + statuscode = root.find("./meta/statuscode").text + if statuscode != '100': + message = root.find("./meta/status").text + request = response.request + context = (request.method, response.url, request.body, statuscode, message) + raise RuntimeError("%s %s '%s' ERROR %s, %s" % context) + + def api_call(self, action, url_path, *args, **kwargs): + BASE_URL = settings.SAAS_NEXTCLOUD_API_URL.rstrip('/') + url = '/'.join((BASE_URL, url_path)) + response = action(url, headers={'OCS-APIRequest':'true'}, *args, **kwargs) + self.validate_response(response) + return response + + def api_get(self, url_path, *args, **kwargs): + return self.api_call(requests.get, url_path, *args, **kwargs) + + def api_post(self, url_path, *args, **kwargs): + return self.api_call(requests.post, url_path, *args, **kwargs) + + def api_put(self, url_path, *args, **kwargs): + return self.api_call(requests.put, url_path, *args, **kwargs) + + def api_delete(self, url_path, *args, **kwargs): + return self.api_call(requests.delete, url_path, *args, **kwargs) + + def create(self, saas): + data = { + 'userid': saas.name, + 'password': saas.password + } + self.api_post('users', data) + + def update(self, saas): + """ + key: email|quota|display|password + value: el valor a modificar. + Si es un email, tornarà un error si la direcció no te la "@" + Si es una quota, sembla que algo per l'estil "5G", "100M", etc. funciona. Quota 0 = infinit + "display" es el display name, no crec que el fem servir, és cosmetic + """ + data = { + 'key': 'password', + 'value': saas.password, + } + self.api_put('users/%s' % saas.name, data) + + def get_user(self, saas): + """ + { + 'displayname' + 'email' + 'quota' => + { + 'free' (en Bytes) + 'relative' (en tant per cent sense signe %, e.g. 68.17) + 'total' (en Bytes) + 'used' (en Bytes) + } + } + """ + response = self.api_get('users/%s' % saas.name) + root = ET.fromstring(response.text) + ret = {} + for data in root.find('./data'): + ret[data.tag] = data.text + ret['quota'] = {} + for data in root.find('.data/quota'): + ret['quota'][data.tag] = data.text + return ret + + +class NextCloudController(NextCloudAPIMixin, ServiceController): + """ + Creates a wordpress site on a WordPress MultiSite installation. + + You should point it to the database server + """ + verbose_name = _("nextCloud SaaS") + model = 'saas.SaaS' + default_route_match = "saas.service == 'nextcloud'" + doc_settings = (settings, + ('SAAS_NEXTCLOUD_API_URL',) + ) + + def update_or_create(self, saas, server): + try: + self.api_get('users/%s' % saas.name) + except RuntimeError: + if getattr(saas, 'password'): + self.create(saas) + else: + raise + else: + if getattr(saas, 'password'): + self.update(saas) + + def remove(self, saas, server): + self.api_delete('users/%s' % saas.name) + + def save(self, saas): + # TODO disable user https://github.com/owncloud/core/issues/12601 + self.append(self.update_or_create, saas) + + def delete(self, saas): + self.append(self.remove, saas) + + +class NextcloudTraffic(ApacheTrafficByName): + __doc__ = ApacheTrafficByName.__doc__ + verbose_name = _("nextCloud SaaS Traffic") + default_route_match = "saas.service == 'nextcloud'" + doc_settings = (settings, + ('SAAS_TRAFFIC_IGNORE_HOSTS', 'SAAS_NEXTCLOUD_LOG_PATH') + ) + log_path = settings.SAAS_NEXTCLOUD_LOG_PATH + + +class NextCloudDiskQuota(NextCloudAPIMixin, ServiceMonitor): + model = 'saas.SaaS' + verbose_name = _("nextCloud SaaS Disk Quota") + default_route_match = "saas.service == 'nextcloud'" + resource = ServiceMonitor.DISK + delete_old_equal_values = True + + def monitor(self, user): + context = self.get_context(user) + self.append("echo %(object_id)s $(monitor %(base_home)s)" % context) + + def get_context(self, user): + context = { + 'object_id': user.pk, + 'base_home': user.get_base_home(), + } + return replace(context, "'", '"') + + def get_quota(self, saas, server): + try: + user = self.get_user(saas) + except requests.exceptions.ConnectionError: + time.sleep(2) + user = self.get_user(saas) + context = { + 'object_id': saas.pk, + 'used': int(user['quota'].get('used', 0)), + } + sys.stdout.write('%(object_id)i %(used)i\n' % context) + + def monitor(self, saas): + self.append(self.get_quota, saas) diff --git a/orchestra/contrib/saas/services/nextcloud.py b/orchestra/contrib/saas/services/nextcloud.py new file mode 100644 index 00000000..3398f0da --- /dev/null +++ b/orchestra/contrib/saas/services/nextcloud.py @@ -0,0 +1,13 @@ +from django import forms +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +from .. import settings +from .options import SoftwareService + + +class NextCloudService(SoftwareService): + name = 'nextcloud' + verbose_name = "nextCloud" + icon = 'orchestra/icons/apps/nextCloud.png' + site_domain = settings.SAAS_NEXTCLOUD_DOMAIN diff --git a/orchestra/contrib/saas/settings.py b/orchestra/contrib/saas/settings.py index a15a6bd9..af768867 100644 --- a/orchestra/contrib/saas/settings.py +++ b/orchestra/contrib/saas/settings.py @@ -18,6 +18,7 @@ SAAS_ENABLED_SERVICES = Setting('SAAS_ENABLED_SERVICES', 'orchestra.contrib.saas.services.dokuwiki.DokuWikiService', 'orchestra.contrib.saas.services.drupal.DrupalService', 'orchestra.contrib.saas.services.owncloud.OwnCloudService', + 'orchestra.contrib.saas.services.nextcloud.NextCloudService', # 'orchestra.contrib.saas.services.seafile.SeaFileService', ), # lazy loading @@ -235,6 +236,23 @@ SAAS_OWNCLOUD_LOG_PATH = Setting('SAAS_OWNCLOUD_LOG_PATH', ) +# nextCloud +SAAS_NEXTCLOUD_DOMAIN = Setting('SAAS_NEXTCLOUD_DOMAIN', + 'nextcloud.{}'.format(ORCHESTRA_BASE_DOMAIN), + help_text="Uses ORCHESTRA_BASE_DOMAIN by default.", +) + +SAAS_NEXTCLOUD_API_URL = Setting('SAAS_NEXTCLOUD_API_URL', + 'https://admin:secret@nextcloud.{}/ocs/v1.php/cloud'.format(ORCHESTRA_BASE_DOMAIN), +) + +SAAS_NEXTCLOUD_LOG_PATH = Setting('SAAS_NEXTCLOUD_LOG_PATH', + '', + help_text=_('Filesystem path for the webserver access logs.
' + 'LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Host}i\"" host'), +) + + # BSCW SAAS_BSCW_DOMAIN = Setting('SAAS_BSCW_DOMAIN',