diff --git a/debian/changelog b/debian/changelog index ede7e33f..b9322780 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,63 @@ +moulinette (3.5.2) stable; urgency=low + + - Release as stable ! + - [fix] Do not miserably crash if the lock does not exist when attempting to release it + - [i18n] Update translation for Arabic, Italian + + Thanks to all contributors (Aleks, BoF, silkevicious) <3 ! + + -- Alexandre Aubin Wed, 10 Apr 2019 02:14:00 +0000 + +moulinette (3.5.1) testing; urgency=low + + * [fix] Fix case where stdinfo is not provided in call_async_output (0a300e5) + * [i18n] Improve translation for Greek, Hungarian, Polish, Swedish, French, Catalan, Occitan + + Thanks to all contributors (Aleks, ariasuni, Quenti, ppr, Xaloc) <3 ! + + -- Alexandre Aubin Wed, 03 Apr 2019 02:25:00 +0000 + +moulinette (3.5.0) testing; urgency=low + + * [i18n] Improve Russian and Chinese (Mandarin) translations + + Contributors : n3uz, Алексей + + -- Alexandre Aubin Wed, 13 Mar 2019 17:20:00 +0000 + +moulinette (3.4.2) stable; urgency=low + + * [i18n] Improve Basque translation + + Thanks to all contributors (A. Garaialde) <3 ! + + -- Alexandre Aubin Tue, 29 Jan 2019 16:50:00 +0000 + +moulinette (3.4.1) testing; urgency=low + + * [i18n] Improve Chinese(Mandarin) translation + * [i18n] Misc orthotypograhy + + Thanks to all contributors (Jibec, aleiyer) <3 ! + + -- Alexandre Aubin Thu, 17 Jan 2019 21:50:00 +0000 + +moulinette (3.4.0) testing; urgency=low + + * [fix] Code cleaning with autopep8 (#187) + * [fix] Automatically reconnect LDAP authenticator when slapd restarts (#185) + * [enh] Display date as system timezone (#184) + * [mod] Make sure `gpg.encrypt` actually does something (#182) + * [mod] Add possiblity to get attribute name of conflict in LDAP (#181) + * [enh] Simplify moulinette error management (#180) + * [mod] Remove Access-Control-Allow-Origin (#174) + * [enh] Protect against CSRF (#171) + * [i18n] Improve Spanish translation + + Thanks to all contributors (Aleks, randomstuff, irina11y, Josue, gdayon, ljf) <3 ! + + -- Alexandre Aubin Thu, 20 Dec 2018 21:53:00 +0000 + moulinette (3.3.1) stable; urgency=low * [fix] 'force' semantics in 'utils.filesystem.mkdir' (#177) diff --git a/debian/control b/debian/control index 71191b69..12a34020 100644 --- a/debian/control +++ b/debian/control @@ -16,7 +16,8 @@ Depends: ${misc:Depends}, ${python:Depends}, python-gnupg, python-gevent-websocket, python-argcomplete, - python-psutil + python-psutil, + python-tz Replaces: yunohost-cli Breaks: yunohost-cli Description: prototype interfaces with ease in Python diff --git a/locales/ar.json b/locales/ar.json index f0ca8df8..8102b572 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -45,10 +45,11 @@ "error_removing": "خطأ أثناء عملية حذف {path:s}: {error:s}", "error_changing_file_permissions": "خطأ أثناء عملية تعديل التصريحات لـ {path:s}: {error:s}", "invalid_url": "خطأ في عنوان الرابط {url:s} (هل هذا الموقع موجود حقًا ؟)", - "download_ssl_error": "خطأ في الإتصال الآمن عبر الـ SSL أثناء محاولة الإتصال بـ {url:s}", + "download_ssl_error": "خطأ في الاتصال الآمن عبر الـ SSL أثناء محاولة الربط بـ {url:s}", "download_timeout": "{url:s} استغرق مدة طويلة جدا للإستجابة، فتوقّف.", "download_unknown_error": "خطأ أثناء عملية تنزيل البيانات مِن {url:s} : {error:s}", "download_bad_status_code": "{url:s} أعاد رمز الحالة {code:s}", "command_unknown": "الأمر '{command:s}' غير معروف ؟", - "corrupted_yaml": "قراءة مُشوّهة لنسق yaml مِن {ressource:s} (السبب : {error:s})" + "corrupted_yaml": "قراءة مُشوّهة لنسق yaml مِن {ressource:s} (السبب : {error:s})", + "info": "معلومة:" } diff --git a/locales/bn_BD.json b/locales/bn_BD.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/bn_BD.json @@ -0,0 +1 @@ +{} diff --git a/locales/ca.json b/locales/ca.json index 798fafb7..374f7a64 100644 --- a/locales/ca.json +++ b/locales/ca.json @@ -50,5 +50,6 @@ "download_timeout": "{url:s} ha tardat massa en respondre, s'ha deixat d'esperar.", "download_unknown_error": "Error al baixar dades des de {url:s}: {error:s}", "download_bad_status_code": "{url:s} ha retornat el codi d'estat {code:s}", - "command_unknown": "Ordre '{command:s}' desconegut ?" + "command_unknown": "Ordre '{command:s}' desconegut ?", + "info": "Info:" } diff --git a/locales/cmn.json b/locales/cmn.json new file mode 100644 index 00000000..bbad7315 --- /dev/null +++ b/locales/cmn.json @@ -0,0 +1,54 @@ +{ + "argument_required": "{argument}是必须的", + "authentication_profile_required": "必须验证配置文件{profile}", + "authentication_required": "需要验证", + "authentication_required_long": "此操作需要验证", + "colon": "{} ", + "confirm": "确认{prompt}", + "deprecated_command": "{prog}{command}已经放弃使用,将来会删除", + "deprecated_command_alias": "{prog}{old}已经放弃使用,将来会删除,请使用{prog}{new}代替", + "error": "错误:", + "error_see_log": "发生错误。请参看日志文件获取错误详情,日志文件位于 /var/log/yunohost/。", + "file_exists": "文件已存在:{path}", + "file_not_exist": "文件不存在:{path}", + "folder_exists": "目录已存在:{path}", + "folder_not_exist": "目录不存在", + "info": "信息:", + "instance_already_running": "实例已正在运行", + "invalid_argument": "参数错误{argument}:{error}", + "invalid_password": "密码错误", + "invalid_usage": "用法错误,输入 --help 查看帮助信息", + "ldap_attribute_already_exists": "参数{attribute}已赋值{value}", + "ldap_operation_error": "LDAP操作时发生了错误", + "ldap_server_down": "无法连接LDAP服务器", + "logged_in": "登录成功", + "logged_out": "注销成功", + "not_logged_in": "您未登录", + "operation_interrupted": "操作中断", + "password": "密码", + "pattern_not_match": "模式匹配失败", + "root_required": "必须以root身份进行此操作", + "server_already_running": "服务已运行在指定端口", + "success": "成功!", + "unable_authenticate": "认证失败", + "unable_retrieve_session": "获取会话失败", + "unknown_group": "未知组{group}", + "unknown_user": "未知用户{user}", + "values_mismatch": "值不匹配", + "warning": "警告:", + "websocket_request_expected": "期望一个WebSocket请求", + "cannot_open_file": "不能打开文件{file:s}(原因:{error:s})", + "cannot_write_file": "写入文件{file:s}失败(原因:{error:s})", + "unknown_error_reading_file": "尝试读取文件{file:s}时发生错误", + "corrupted_json": "json数据{ressource:s}读取失败(原因:{error:s})", + "corrupted_yaml": "读取yaml文件{ressource:s}失败(原因:{error:s})", + "error_writing_file": "写入文件{file:s}失败:{error:s}", + "error_removing": "删除路径{path:s}失败:{error:s}", + "error_changing_file_permissions": "目录{path:s}权限修改失败:{error:s}", + "invalid_url": "url:{url:s}无效(site是否存在?)", + "download_ssl_error": "连接{url:s}时发生SSL错误", + "download_timeout": "{url:s}响应超时,放弃。", + "download_unknown_error": "下载{url:s}失败:{error:s}", + "download_bad_status_code": "{url:s}返回状态码:{code:s}", + "command_unknown": "未知命令:{command:s}?" +} diff --git a/locales/el.json b/locales/el.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/el.json @@ -0,0 +1 @@ +{} diff --git a/locales/en.json b/locales/en.json index e46a8751..0091b232 100644 --- a/locales/en.json +++ b/locales/en.json @@ -27,7 +27,6 @@ "operation_interrupted": "Operation interrupted", "password": "Password", "pattern_not_match": "Does not match pattern", - "permission_denied": "Permission denied", "root_required": "You must be root to perform this action", "server_already_running": "A server is already running on that port", "success": "Success!", @@ -47,10 +46,10 @@ "error_writing_file": "Error when writing file {file:s}: {error:s}", "error_removing": "Error when removing {path:s}: {error:s}", "error_changing_file_permissions": "Error when changing permissions for {path:s}: {error:s}", - "invalid_url": "Invalid url {url:s} (does this site exists ?)", + "invalid_url": "Invalid url {url:s} (does this site exists?)", "download_ssl_error": "SSL error when connecting to {url:s}", "download_timeout": "{url:s} took too long to answer, gave up.", - "download_unknown_error": "Error when downloading data from {url:s} : {error:s}", + "download_unknown_error": "Error when downloading data from {url:s}: {error:s}", "download_bad_status_code": "{url:s} returned status code {code:s}", - "command_unknown": "Command '{command:s}' unknown ?" + "command_unknown": "Command '{command:s}' unknown?" } diff --git a/locales/es.json b/locales/es.json index 36fd562b..9b00b0db 100644 --- a/locales/es.json +++ b/locales/es.json @@ -49,5 +49,6 @@ "download_timeout": "{url:s} tardó demasiado en responder, me rindo.", "download_unknown_error": "Error al descargar datos desde {url:s} : {error:s}", "download_bad_status_code": "{url:s} devolvió el código de estado {code:s}", - "command_unknown": "Comando '{command:s}' desconocido ?" + "command_unknown": "Comando '{command:s}' desconocido ?", + "corrupted_yaml": "yaml corrupto leido desde {ressource:s} (motivo: {error:s})" } diff --git a/locales/eu.json b/locales/eu.json new file mode 100644 index 00000000..db0ce305 --- /dev/null +++ b/locales/eu.json @@ -0,0 +1,3 @@ +{ + "argument_required": "'{argument}' argumentua beharrezkoa da" +} diff --git a/locales/fr.json b/locales/fr.json index 59bca0f7..cd2c357a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1,23 +1,23 @@ { - "argument_required": "L’argument « {argument} » est requis", - "authentication_profile_required": "L’authentification au profil « {profile} » requise", + "argument_required": "L’argument '{argument}' est requis", + "authentication_profile_required": "L’authentification au profil '{profile}' est requise", "authentication_required": "Authentification requise", "authentication_required_long": "L’authentification est requise pour exécuter cette action", "colon": "{} : ", "confirm": "Confirmez : {prompt}", - "deprecated_command": "« {prog} {command} » est déprécié et sera bientôt supprimé", - "deprecated_command_alias": "« {prog} {old} » est déprécié et sera bientôt supprimé, utilisez « {prog} {new} » à la place", + "deprecated_command": "'{prog} {command}' est déprécié et sera bientôt supprimé", + "deprecated_command_alias": "'{prog} {old}' est déprécié et sera bientôt supprimé, utilisez '{prog} {new}' à la place", "error": "Erreur :", - "error_see_log": "Une erreur est survenue. Veuillez consulter les journaux pour plus de détails, ils sont situés en /var/log/yunohost/.", - "file_exists": "Le fichier existe déjà : « {path} »", - "file_not_exist": "Le fichier « {path} » n’existe pas", - "folder_exists": "Le dossier existe déjà : « {path} »", + "error_see_log": "Une erreur est survenue. Veuillez consulter les journaux pour plus de détails, ils sont situés dans /var/log/yunohost/.", + "file_exists": "Le fichier existe déjà : '{path}'", + "file_not_exist": "Le fichier '{path}' n’existe pas", + "folder_exists": "Le dossier existe déjà : '{path}'", "folder_not_exist": "Le dossier n’existe pas", "instance_already_running": "Une instance est déjà en cours d’exécution", - "invalid_argument": "Argument « {argument} » incorrect : {error}", + "invalid_argument": "Argument '{argument}' incorrect : {error}", "invalid_password": "Mot de passe incorrect", "invalid_usage": "Utilisation erronée, utilisez --help pour accéder à l’aide", - "ldap_attribute_already_exists": "L’attribut « {attribute} » existe déjà avec comme valeur : {value}", + "ldap_attribute_already_exists": "L’attribut '{attribute}' existe déjà avec la valeur suivante : '{value}'", "ldap_operation_error": "Une erreur est survenue lors de l’opération LDAP", "ldap_server_down": "Impossible d’atteindre le serveur LDAP", "logged_in": "Connecté", @@ -32,23 +32,24 @@ "success": "Succès !", "unable_authenticate": "Impossible de vous authentifier", "unable_retrieve_session": "Impossible de récupérer la session", - "unknown_group": "Groupe « {group} » inconnu", - "unknown_user": "Utilisateur « {user} » inconnu", + "unknown_group": "Groupe '{group}' inconnu", + "unknown_user": "L'utilisateur « {user} » est inconnu", "values_mismatch": "Les valeurs ne correspondent pas", "warning": "Attention :", - "websocket_request_expected": "Requête WebSocket attendue", - "cannot_open_file": "Impossible d’ouvrir le fichier {file:s} (cause : {error:s})", - "cannot_write_file": "Ne peut pas écrire le fichier {file:s} (cause : {error:s})", + "websocket_request_expected": "Une requête WebSocket est attendue", + "cannot_open_file": "Impossible d’ouvrir le fichier {file:s} (raison : {error:s})", + "cannot_write_file": "Ne peut pas écrire le fichier {file:s} (raison : {error:s})", "unknown_error_reading_file": "Erreur inconnue en essayant de lire le fichier {file:s}", - "corrupted_json": "Json corrompu lu depuis {ressource:s} (cause : {error:s})", - "error_writing_file": "Erreur en écrivant le fichier {file:s}:{error:s}", - "error_removing": "Erreur lors de la suppression {path:s}:{error:s}", - "error_changing_file_permissions": "Erreur lors de la modification des autorisations pour {path:s}:{error:s}", - "invalid_url": "Url invalide {url:s} (ce site existe-t-il ?)", + "corrupted_json": "Fichier JSON corrompu en lecture depuis {ressource:s} (raison : {error:s})", + "error_writing_file": "Erreur en écrivant le fichier {file:s} : {error:s}", + "error_removing": "Erreur lors de la suppression {path:s} : {error:s}", + "error_changing_file_permissions": "Erreur lors de la modification des autorisations pour {path:s} : {error:s}", + "invalid_url": "URL {url:s} invalide : ce site existe-t-il ?", "download_ssl_error": "Erreur SSL lors de la connexion à {url:s}", - "download_timeout": "{url:s} a pris trop de temps pour répondre, abandon.", - "download_unknown_error": "Erreur lors du téléchargement des données à partir de {url:s}:{error:s}", - "download_bad_status_code": "{url:s} code de statut renvoyé {code:s}", - "command_unknown": "Commande « {command:s} » inconnue ?", - "corrupted_yaml": "YAML corrompu lu {ressource:s} depuis (cause : {error:s})" + "download_timeout": "{url:s} a pris trop de temps pour répondre : abandon.", + "download_unknown_error": "Erreur lors du téléchargement des données à partir de {url:s} : {error:s}", + "download_bad_status_code": "{url:s} renvoie le code d'état {code:s}", + "command_unknown": "Commande '{command:s}' inconnue ?", + "corrupted_yaml": "Fichier YAML corrompu en lecture depuis {ressource:s} (raison : {error:s})", + "info": "Info :" } diff --git a/locales/hu.json b/locales/hu.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/hu.json @@ -0,0 +1 @@ +{} diff --git a/locales/it.json b/locales/it.json index f27c89d3..479a1b96 100644 --- a/locales/it.json +++ b/locales/it.json @@ -50,5 +50,6 @@ "download_timeout": "{url:s} ci ha messo troppo a rispondere, abbandonato.", "download_unknown_error": "Errore durante il download di dati da {url:s} : {error:s}", "download_bad_status_code": "{url:s} ha restituito il codice di stato {code:s}", - "command_unknown": "Comando '{command:s}' sconosciuto ?" + "command_unknown": "Comando '{command:s}' sconosciuto ?", + "info": "Info:" } diff --git a/locales/oc.json b/locales/oc.json index 7ec0cfb6..64ebbf67 100644 --- a/locales/oc.json +++ b/locales/oc.json @@ -50,5 +50,6 @@ "download_bad_status_code": "{url:s} tòrna lo còdi d’estat {code:s}", "command_unknown": "Comanda {command:s} desconeguda ?", "corrupted_json": "Fichièr Json corromput legit de {ressource:s} (rason : {error:s})", - "corrupted_yaml": "Fichièr YAML corromput legit de {ressource:s} (rason : {error:s})" + "corrupted_yaml": "Fichièr YAML corromput legit de {ressource:s} (rason : {error:s})", + "info": "Info :" } diff --git a/locales/pl.json b/locales/pl.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/pl.json @@ -0,0 +1 @@ +{} diff --git a/locales/ru.json b/locales/ru.json index 0967ef42..0ef34009 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1 +1,48 @@ -{} +{ + "argument_required": "Требуется'{argument}' аргумент", + "authentication_profile_required": "Для доступа к '{profile}' требуется аутентификация", + "authentication_required": "Требуется аутентификация", + "authentication_required_long": "Для этого действия требуется аутентификация", + "colon": "{}: ", + "confirm": "Подтвердить {prompt}", + "deprecated_command": "'{prog} {command}' устарела и будет удалена", + "deprecated_command_alias": "'{prog} {old}' устарела и будет удалена, вместо неё используйте '{prog} {new}'", + "error": "Ошибка:", + "error_see_log": "Произошла ошибка. Пожалуйста, смотри подробности в логах, находящихся /var/log/yunohost/.", + "file_exists": "Файл уже существует: '{path}'", + "file_not_exist": "Файл не существует: '{path}'", + "folder_exists": "Каталог уже существует: '{path}'", + "folder_not_exist": "Каталог не существует", + "invalid_argument": "Неправильный аргумент '{argument}': {error}", + "invalid_password": "Неправильный пароль", + "ldap_attribute_already_exists": "Атрибут '{attribute}' уже существует со значением '{value}'", + "logged_in": "Вы вошли", + "logged_out": "Вы вышли из системы", + "not_logged_in": "Вы не залогинились", + "operation_interrupted": "Действие прервано", + "password": "Пароль", + "pattern_not_match": "Не соответствует образцу", + "server_already_running": "Сервер уже запущен на этом порте", + "success": "Отлично!", + "unable_authenticate": "Аутентификация невозможна", + "unknown_group": "Неизвестная '{group}' группа", + "unknown_user": "Неизвестный '{user}' пользователь", + "values_mismatch": "Неверные значения", + "warning": "Внимание :", + "websocket_request_expected": "Ожидается запрос WebSocket", + "cannot_open_file": "Не могу открыть файл {file:s} (причина: {error:s})", + "cannot_write_file": "Не могу записать файл {file:s} (причина: {error:s})", + "unknown_error_reading_file": "Неизвестная ошибка при чтении файла {file:s}", + "corrupted_yaml": "Повреждённой yaml получен от {ressource:s} (причина: {error:s})", + "error_writing_file": "Ошибка при записи файла {file:s}: {error:s}", + "error_removing": "Ошибка при удалении {path:s}: {error:s}", + "invalid_url": "Неправильный url {url:s} (этот сайт существует ?)", + "download_ssl_error": "Ошибка SSL при соединении с {url:s}", + "download_timeout": "Превышено время ожидания ответа от {url:s}.", + "download_unknown_error": "Ошибка при загрузке данных с {url:s} : {error:s}", + "instance_already_running": "Процесс уже запущен", + "ldap_operation_error": "Ошибка в процессе работы LDAP", + "root_required": "Чтобы выполнить это действие, вы должны иметь права root", + "corrupted_json": "Повреждённый json получен от {ressource:s} (причина: {error:s})", + "command_unknown": "Команда '{command:s}' неизвестна ?" +} diff --git a/locales/sv.json b/locales/sv.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/locales/sv.json @@ -0,0 +1 @@ +{} diff --git a/moulinette/__init__.py b/moulinette/__init__.py index 1b1207e6..719c5dd3 100755 --- a/moulinette/__init__.py +++ b/moulinette/__init__.py @@ -137,5 +137,5 @@ def cli(namespaces, args, use_cache=True, output_as=None, except MoulinetteError as e: import logging logging.getLogger(namespaces[0]).error(e.strerror) - return e.errno + return 1 return 0 diff --git a/moulinette/actionsmap.py b/moulinette/actionsmap.py index 4921fb9c..4458fd44 100644 --- a/moulinette/actionsmap.py +++ b/moulinette/actionsmap.py @@ -2,7 +2,6 @@ import os import re -import errno import logging import yaml import cPickle as pickle @@ -26,6 +25,7 @@ logger = logging.getLogger('moulinette.actionsmap') # Extra parameters definition class _ExtraParameter(object): + """ Argument parser for an extra parameter. @@ -105,6 +105,7 @@ class CommentParameter(_ExtraParameter): class AskParameter(_ExtraParameter): + """ Ask for the argument value if possible and needed. @@ -139,6 +140,7 @@ class AskParameter(_ExtraParameter): class PasswordParameter(AskParameter): + """ Ask for the password argument value if possible and needed. @@ -160,6 +162,7 @@ class PasswordParameter(AskParameter): class PatternParameter(_ExtraParameter): + """ Check if the argument value match a pattern. @@ -187,9 +190,8 @@ class PatternParameter(_ExtraParameter): if msg == message: msg = m18n.g(message) - raise MoulinetteError(errno.EINVAL, - m18n.g('invalid_argument', - argument=arg_name, error=msg)) + raise MoulinetteError('invalid_argument', + argument=arg_name, error=msg) return arg_value @staticmethod @@ -206,6 +208,7 @@ class PatternParameter(_ExtraParameter): class RequiredParameter(_ExtraParameter): + """ Check if a required argument is defined or not. @@ -218,9 +221,8 @@ class RequiredParameter(_ExtraParameter): if required and (arg_value is None or arg_value == ''): logger.debug("argument '%s' is required", arg_name) - raise MoulinetteError(errno.EINVAL, - m18n.g('argument_required', - argument=arg_name)) + raise MoulinetteError('argument_required', + argument=arg_name) return arg_value @staticmethod @@ -243,6 +245,7 @@ extraparameters_list = [CommentParameter, AskParameter, PasswordParameter, class ExtraArgumentParser(object): + """ Argument validator and parser for the extra parameters. @@ -285,7 +288,7 @@ class ExtraArgumentParser(object): except Exception as e: logger.error("unable to validate extra parameter '%s' " "for argument '%s': %s", p, arg_name, e) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') return parameters @@ -360,6 +363,7 @@ def ordered_yaml_load(stream): class ActionsMap(object): + """Validate and process actions defined into an actions map The actions map defines the features - and their usage - of an @@ -501,7 +505,7 @@ class ActionsMap(object): except (AttributeError, ImportError): logger.exception("unable to load function %s.%s", namespace, func_name) - raise MoulinetteError(errno.EIO, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') else: log_id = start_action_logging() if logger.isEnabledFor(logging.DEBUG): diff --git a/moulinette/authenticators/__init__.py b/moulinette/authenticators/__init__.py index 578ef490..57d1a785 100644 --- a/moulinette/authenticators/__init__.py +++ b/moulinette/authenticators/__init__.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- -import errno import gnupg import logging -from moulinette import m18n from moulinette.cache import open_cachefile from moulinette.core import MoulinetteError @@ -14,6 +12,7 @@ logger = logging.getLogger('moulinette.authenticator') # Base Class ----------------------------------------------------------- class BaseAuthenticator(object): + """Authenticator base representation Each authenticators must implement an Authenticator class derived @@ -97,7 +96,7 @@ class BaseAuthenticator(object): except TypeError: logger.error("unable to extract token parts from '%s'", token) if password is None: - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') logger.info("session will not be stored") store_session = False @@ -114,7 +113,7 @@ class BaseAuthenticator(object): except: logger.exception("authentication (name: '%s', vendor: '%s') fails", self.name, self.vendor) - raise MoulinetteError(errno.EACCES, m18n.g('unable_authenticate')) + raise MoulinetteError('unable_authenticate') # Store session if store_session: @@ -138,9 +137,13 @@ class BaseAuthenticator(object): """Store a session and its associated password""" gpg = gnupg.GPG() gpg.encoding = 'utf-8' + + # Encrypt the password using the session hash + s = str(gpg.encrypt(password, None, symmetric=True, passphrase=session_hash)) + assert len(s), "For some reason GPG can't perform encryption, maybe check /root/.gnupg/gpg.conf or re-run with gpg = gnupg.GPG(verbose=True) ?" + with self._open_sessionfile(session_id, 'w') as f: - f.write(str(gpg.encrypt(password, None, symmetric=True, - passphrase=session_hash))) + f.write(s) def _retrieve_session(self, session_id, session_hash): """Retrieve a session and return its associated password""" @@ -149,8 +152,7 @@ class BaseAuthenticator(object): enc_pwd = f.read() except IOError: logger.debug("unable to retrieve session", exc_info=1) - raise MoulinetteError(errno.ENOENT, - m18n.g('unable_retrieve_session')) + raise MoulinetteError('unable_retrieve_session') else: gpg = gnupg.GPG() gpg.encoding = 'utf-8' @@ -159,6 +161,5 @@ class BaseAuthenticator(object): if decrypted.ok is not True: logger.error("unable to decrypt password for the session: %s", decrypted.status) - raise MoulinetteError(errno.EINVAL, - m18n.g('unable_retrieve_session')) + raise MoulinetteError('unable_retrieve_session') return decrypted.data diff --git a/moulinette/authenticators/ldap.py b/moulinette/authenticators/ldap.py index 0a84399c..f3625efc 100644 --- a/moulinette/authenticators/ldap.py +++ b/moulinette/authenticators/ldap.py @@ -2,7 +2,6 @@ # TODO: Use Python3 to remove this fix! from __future__ import absolute_import -import errno import logging import random import string @@ -11,7 +10,6 @@ import ldap import ldap.sasl import ldap.modlist as modlist -from moulinette import m18n from moulinette.core import MoulinetteError from moulinette.authenticators import BaseAuthenticator @@ -21,6 +19,7 @@ logger = logging.getLogger('moulinette.authenticator.ldap') # LDAP Class Implementation -------------------------------------------- class Authenticator(BaseAuthenticator): + """LDAP Authenticator Initialize a LDAP connexion for the given arguments. It attempts to @@ -80,7 +79,7 @@ class Authenticator(BaseAuthenticator): def authenticate(self, password): try: - con = ldap.initialize(self.uri) + con = ldap.ldapobject.ReconnectLDAPObject(self.uri, retry_max=10, retry_delay=0.5) if self.userdn: if 'cn=external,cn=auth' in self.userdn: con.sasl_non_interactive_bind_s('EXTERNAL') @@ -89,10 +88,10 @@ class Authenticator(BaseAuthenticator): else: con.simple_bind_s() except ldap.INVALID_CREDENTIALS: - raise MoulinetteError(errno.EACCES, m18n.g('invalid_password')) + raise MoulinetteError('invalid_password') except ldap.SERVER_DOWN: logger.exception('unable to reach the server to authenticate') - raise MoulinetteError(169, m18n.g('ldap_server_down')) + raise MoulinetteError('ldap_server_down') else: self.con = con self._ensure_password_uses_strong_hash(password) @@ -144,7 +143,7 @@ class Authenticator(BaseAuthenticator): except Exception as e: logger.exception("error during LDAP search operation with: base='%s', " "filter='%s', attrs=%s and exception %s", base, filter, attrs, e) - raise MoulinetteError(169, m18n.g('ldap_operation_error')) + raise MoulinetteError('ldap_operation_error') result_list = [] if not attrs or 'dn' not in attrs: @@ -175,7 +174,7 @@ class Authenticator(BaseAuthenticator): except Exception as e: logger.exception("error during LDAP add operation with: rdn='%s', " "attr_dict=%s and exception %s", rdn, attr_dict, e) - raise MoulinetteError(169, m18n.g('ldap_operation_error')) + raise MoulinetteError('ldap_operation_error') else: return True @@ -195,7 +194,7 @@ class Authenticator(BaseAuthenticator): self.con.delete_s(dn) except Exception as e: logger.exception("error during LDAP delete operation with: rdn='%s' and exception %s", rdn, e) - raise MoulinetteError(169, m18n.g('ldap_operation_error')) + raise MoulinetteError('ldap_operation_error') else: return True @@ -226,7 +225,7 @@ class Authenticator(BaseAuthenticator): logger.exception("error during LDAP update operation with: rdn='%s', " "attr_dict=%s, new_rdn=%s and exception: %s", rdn, attr_dict, new_rdn, e) - raise MoulinetteError(169, m18n.g('ldap_operation_error')) + raise MoulinetteError('ldap_operation_error') else: return True @@ -240,14 +239,30 @@ class Authenticator(BaseAuthenticator): Returns: Boolean | MoulinetteError + """ + attr_found = self.get_conflict(value_dict) + if attr_found: + logger.info("attribute '%s' with value '%s' is not unique", + attr_found[0], attr_found[1]) + raise MoulinetteError('ldap_attribute_already_exists', + attribute=attr_found[0], + value=attr_found[1]) + return True + + def get_conflict(self, value_dict, base_dn=None): + """ + Check uniqueness of values + + Keyword arguments: + value_dict -- Dictionnary of attributes/values to check + + Returns: + None | list with Fist conflict attribute name and value + """ for attr, value in value_dict.items(): - if not self.search(filter=attr + '=' + value): + if not self.search(base=base_dn, filter=attr + '=' + value): continue else: - logger.info("attribute '%s' with value '%s' is not unique", - attr, value) - raise MoulinetteError(errno.EEXIST, - m18n.g('ldap_attribute_already_exists', - attribute=attr, value=value)) - return True + return (attr, value) + return None diff --git a/moulinette/core.py b/moulinette/core.py index c6e367f7..c438a6c7 100644 --- a/moulinette/core.py +++ b/moulinette/core.py @@ -3,7 +3,6 @@ import os import time import json -import errno import logging import psutil @@ -20,6 +19,7 @@ logger = logging.getLogger('moulinette.core') # Internationalization ------------------------------------------------- class Translator(object): + """Internationalization class Provide an internationalization mechanism based on JSON files to @@ -138,6 +138,7 @@ class Translator(object): class Moulinette18n(object): + """Internationalization service for the moulinette Manage internationalization and access to the proper keys translation @@ -215,6 +216,7 @@ class Moulinette18n(object): class MoulinetteSignals(object): + """Signals connector for the moulinette Allow to easily connect signals from the moulinette to handlers. A @@ -344,7 +346,7 @@ def init_interface(name, kwargs={}, actionsmap={}): mod = import_module('moulinette.interfaces.%s' % name) except ImportError: logger.exception("unable to load interface '%s'", name) - raise MoulinetteError(errno.EINVAL, moulinette.m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') else: try: # Retrieve interface classes @@ -352,7 +354,7 @@ def init_interface(name, kwargs={}, actionsmap={}): interface = mod.Interface except AttributeError: logger.exception("unable to retrieve classes of interface '%s'", name) - raise MoulinetteError(errno.EIO, moulinette.m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') # Instantiate or retrieve ActionsMap if isinstance(actionsmap, dict): @@ -361,12 +363,12 @@ def init_interface(name, kwargs={}, actionsmap={}): amap = actionsmap else: logger.error("invalid actionsmap value %r", actionsmap) - raise MoulinetteError(errno.EINVAL, moulinette.m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') return interface(amap, **kwargs) -def init_authenticator((vendor, name), kwargs={}): +def init_authenticator(vendor_and_name, kwargs={}): """Return a new authenticator instance Retrieve the given authenticator vendor and return a new instance of @@ -378,11 +380,12 @@ def init_authenticator((vendor, name), kwargs={}): - kwargs -- A dict of arguments for the authenticator profile """ + (vendor, name) = vendor_and_name try: mod = import_module('moulinette.authenticators.%s' % vendor) except ImportError: logger.exception("unable to load authenticator vendor '%s'", vendor) - raise MoulinetteError(errno.EINVAL, moulinette.m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') else: return mod.Authenticator(name, **kwargs) @@ -411,12 +414,21 @@ def clean_session(session_id, profiles=[]): # Moulinette core classes ---------------------------------------------- -class MoulinetteError(OSError): +class MoulinetteError(Exception): + """Moulinette base exception""" - pass + + def __init__(self, key, raw_msg=False, *args, **kwargs): + if raw_msg: + msg = key + else: + msg = moulinette.m18n.g(key, *args, **kwargs) + super(MoulinetteError, self).__init__(msg) + self.strerror = msg class MoulinetteLock(object): + """Locker for a moulinette instance It provides a lock mechanism for a given moulinette instance. It can @@ -471,8 +483,7 @@ class MoulinetteLock(object): break if self.timeout is not None and (time.time() - start_time) > self.timeout: - raise MoulinetteError(errno.EBUSY, - moulinette.m18n.g('instance_already_running')) + raise MoulinetteError('instance_already_running') # Wait before checking again time.sleep(self.interval) @@ -486,7 +497,10 @@ class MoulinetteLock(object): """ if self._locked: - os.unlink(self._lockfile) + if os.path.exists(self._lockfile): + os.unlink(self._lockfile) + else: + logger.warning("Uhoh, somehow the lock %s did not exist ..." % self._lockfile) logger.debug('lock has been released') self._locked = False @@ -495,10 +509,7 @@ class MoulinetteLock(object): with open(self._lockfile, 'w') as f: f.write(str(os.getpid())) except IOError: - raise MoulinetteError( - errno.EPERM, '%s. %s.'.format( - moulinette.m18n.g('permission_denied'), - moulinette.m18n.g('root_required'))) + raise MoulinetteError('root_required') def _lock_PIDs(self): diff --git a/moulinette/interfaces/__init__.py b/moulinette/interfaces/__init__.py index 678c9526..9bcc921a 100644 --- a/moulinette/interfaces/__init__.py +++ b/moulinette/interfaces/__init__.py @@ -1,7 +1,6 @@ # -*- coding: utf-8 -*- import re -import errno import logging import argparse import copy @@ -20,6 +19,7 @@ CALLBACKS_PROP = '_callbacks' # Base Class ----------------------------------------------------------- class BaseActionsMapParser(object): + """Actions map's base Parser Each interfaces must implement an ActionsMapParser class derived @@ -142,7 +142,7 @@ class BaseActionsMapParser(object): # Validate tid and namespace if not isinstance(tid, tuple) and \ (namespace is None or not hasattr(namespace, TO_RETURN_PROP)): - raise MoulinetteError(errno.EINVAL, m18n.g('invalid_usage')) + raise MoulinetteError('invalid_usage') elif not tid: tid = GLOBAL_SECTION @@ -158,8 +158,7 @@ class BaseActionsMapParser(object): # TODO: Catch errors auth = msignals.authenticate(cls(), **auth_conf) if not auth.is_authenticated: - raise MoulinetteError(errno.EACCES, - m18n.g('authentication_required_long')) + raise MoulinetteError('authentication_required_long') if self.get_conf(tid, 'argument_auth') and \ self.get_conf(tid, 'authenticate') == 'all': namespace.auth = auth @@ -263,7 +262,7 @@ class BaseActionsMapParser(object): else: logger.error("expecting 'all', 'False' or a list for " "configuration 'authenticate', got %r", ifaces) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') # -- 'authenticator' try: @@ -278,7 +277,7 @@ class BaseActionsMapParser(object): except KeyError: logger.error("requesting profile '%s' which is undefined in " "global configuration of 'authenticator'", auth) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') elif is_global and isinstance(auth, dict): if len(auth) == 0: logger.warning('no profile defined in global configuration ' @@ -301,7 +300,7 @@ class BaseActionsMapParser(object): else: logger.error("expecting a dict of profile(s) or a profile name " "for configuration 'authenticator', got %r", auth) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') # -- 'argument_auth' try: @@ -314,7 +313,7 @@ class BaseActionsMapParser(object): else: logger.error("expecting a boolean for configuration " "'argument_auth', got %r", arg_auth) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') # -- 'lock' try: @@ -327,7 +326,7 @@ class BaseActionsMapParser(object): else: logger.error("expecting a boolean for configuration 'lock', " "got %r", lock) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') return conf @@ -354,6 +353,7 @@ class BaseActionsMapParser(object): class BaseInterface(object): + """Moulinette's base Interface Each interfaces must implement an Interface class derived from this @@ -426,8 +426,8 @@ class _CallbackAction(argparse.Action): value = self.callback(namespace, values, **self.callback_kwargs) except: logger.exception("cannot get value from callback method " - "'{0}'".format(self.callback_method)) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + "'{0}'".format(self.callback_method)) + raise MoulinetteError('error_see_log') else: if value: if self.callback_return: @@ -437,6 +437,7 @@ class _CallbackAction(argparse.Action): class _ExtendedSubParsersAction(argparse._SubParsersAction): + """Subparsers with extended properties for argparse It provides the following additional properties at initialization, diff --git a/moulinette/interfaces/api.py b/moulinette/interfaces/api.py index 4ce66294..82034a1f 100644 --- a/moulinette/interfaces/api.py +++ b/moulinette/interfaces/api.py @@ -11,6 +11,7 @@ from gevent.queue import Queue from geventwebsocket import WebSocketError from bottle import run, request, response, Bottle, HTTPResponse +from bottle import get, post, install, abort, delete, put from moulinette import msignals, m18n, DATA_DIR from moulinette.core import MoulinetteError, clean_session @@ -26,12 +27,43 @@ logger = log.getLogger('moulinette.interface.api') # API helpers ---------------------------------------------------------- +CSRF_TYPES = set(["text/plain", + "application/x-www-form-urlencoded", + "multipart/form-data"]) + + +def is_csrf(): + """Checks is this is a CSRF request.""" + + if request.method != "POST": + return False + if request.content_type is None: + return True + content_type = request.content_type.lower().split(';')[0] + if content_type not in CSRF_TYPES: + return False + + return request.headers.get("X-Requested-With") is None + + +# Protection against CSRF +def filter_csrf(callback): + def wrapper(*args, **kwargs): + if is_csrf(): + abort(403, "CSRF protection") + else: + return callback(*args, **kwargs) + return wrapper + + class LogQueues(dict): + """Map of session id to queue.""" pass class APIQueueHandler(logging.Handler): + """ A handler class which store logging records into a queue, to be used and retrieved from the API. @@ -57,6 +89,7 @@ class APIQueueHandler(logging.Handler): class _HTTPArgumentParser(object): + """Argument parser for HTTP requests Object for parsing HTTP requests into Python objects. It is based @@ -159,10 +192,11 @@ class _HTTPArgumentParser(object): def _error(self, message): # TODO: Raise a proper exception - raise MoulinetteError(1, message) + raise MoulinetteError(message) class _ActionsMapPlugin(object): + """Actions map Bottle Plugin Process relevant action for the request using the actions map and @@ -317,7 +351,7 @@ class _ActionsMapPlugin(object): self.logout(profile) except: pass - raise error_to_response(e) + raise HTTPUnauthorizedResponse(e.strerror) else: # Update dicts with new values s_hashes[profile] = s_hash @@ -404,7 +438,7 @@ class _ActionsMapPlugin(object): try: ret = self.actionsmap.process(arguments, timeout=30, route=_route) except MoulinetteError as e: - raise error_to_response(e) + raise HTTPBadRequestResponse(e.strerror) except Exception as e: if isinstance(e, HTTPResponse): raise e @@ -488,38 +522,12 @@ class HTTPUnauthorizedResponse(HTTPResponse): super(HTTPUnauthorizedResponse, self).__init__(output, 401) -class HTTPForbiddenResponse(HTTPResponse): - - def __init__(self, output=''): - super(HTTPForbiddenResponse, self).__init__(output, 403) - - class HTTPErrorResponse(HTTPResponse): def __init__(self, output=''): super(HTTPErrorResponse, self).__init__(output, 500) -def error_to_response(error): - """Convert a MoulinetteError to relevant HTTP response.""" - if error.errno == errno.EPERM: - return HTTPForbiddenResponse(error.strerror) - elif error.errno == errno.EACCES: - return HTTPUnauthorizedResponse(error.strerror) - # Client-side error - elif error.errno in [errno.ENOENT, errno.ESRCH, errno.ENXIO, errno.EEXIST, - errno.ENODEV, errno.EINVAL, errno.ENOPKG, errno.EDESTADDRREQ]: - return HTTPBadRequestResponse(error.strerror) - # Server-side error - elif error.errno in [errno.EIO, errno.EBUSY, errno.ENODATA, errno.EINTR, - errno.ENETUNREACH]: - return HTTPErrorResponse(error.strerror) - else: - logger.debug('unknown relevant response for error [%s] %s', - error.errno, error.strerror) - return HTTPErrorResponse(error.strerror) - - def format_for_response(content): """Format the resulted content of a request for the HTTP response.""" if request.method == 'POST': @@ -541,6 +549,7 @@ def format_for_response(content): # API Classes Implementation ------------------------------------------- class ActionsMapParser(BaseActionsMapParser): + """Actions map's Parser for the API Provide actions map parsing methods for a CLI usage. The parser for @@ -630,7 +639,7 @@ class ActionsMapParser(BaseActionsMapParser): tid, parser = self._parsers[route] except KeyError: logger.error("no argument parser found for route '%s'", route) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') ret = argparse.Namespace() # Perform authentication if needed @@ -643,7 +652,7 @@ class ActionsMapParser(BaseActionsMapParser): # TODO: Catch errors auth = msignals.authenticate(klass(), **auth_conf) if not auth.is_authenticated: - raise MoulinetteError(errno.EACCES, m18n.g('authentication_required_long')) + raise MoulinetteError('authentication_required_long') if self.get_conf(tid, 'argument_auth') and \ self.get_conf(tid, 'authenticate') == 'all': ret.auth = auth @@ -677,6 +686,7 @@ class ActionsMapParser(BaseActionsMapParser): class Interface(BaseInterface): + """Application Programming Interface for the moulinette Initialize a HTTP server which serves the API connected to a given @@ -722,6 +732,7 @@ class Interface(BaseInterface): return callback # Install plugins + app.install(filter_csrf) app.install(apiheader) app.install(api18n) app.install(_ActionsMapPlugin(actionsmap, use_websocket, log_queues)) @@ -765,9 +776,8 @@ class Interface(BaseInterface): logger.exception("unable to start the server instance on %s:%d", host, port) if e.args[0] == errno.EADDRINUSE: - raise MoulinetteError(errno.EADDRINUSE, - m18n.g('server_already_running')) - raise MoulinetteError(errno.EIO, m18n.g('error_see_log')) + raise MoulinetteError('server_already_running') + raise MoulinetteError('error_see_log') # Routes handlers diff --git a/moulinette/interfaces/cli.py b/moulinette/interfaces/cli.py index 00737b5e..ce7a2726 100644 --- a/moulinette/interfaces/cli.py +++ b/moulinette/interfaces/cli.py @@ -2,12 +2,14 @@ import os import sys -import errno import getpass import locale import logging from argparse import SUPPRESS from collections import OrderedDict +import time +import pytz +from datetime import date, datetime import argcomplete @@ -94,6 +96,32 @@ def plain_print_dict(d, depth=0): print(d) +def pretty_date(_date): + """Display a date in the current time zone without ms and tzinfo + + Argument: + - date -- The date or datetime to display + """ + # Deduce system timezone + nowutc = datetime.now(tz=pytz.utc) + nowtz = datetime.now() + nowtz = nowtz.replace(tzinfo=pytz.utc) + offsetHour = nowutc - nowtz + offsetHour = int(round(offsetHour.total_seconds() / 3600)) + localtz = 'Etc/GMT%+d' % offsetHour + + # Transform naive date into UTC date + if _date.tzinfo is None: + _date = _date.replace(tzinfo=pytz.utc) + + # Convert UTC date into system locale date + _date = _date.astimezone(pytz.timezone(localtz)) + if isinstance(_date, datetime): + return _date.strftime("%Y-%m-%d %H:%M:%S") + else: + return _date.strftime("%Y-%m-%d") + + def pretty_print_dict(d, depth=0): """Print in a pretty way a dictionary recursively @@ -127,10 +155,14 @@ def pretty_print_dict(d, depth=0): else: if isinstance(value, unicode): value = value.encode('utf-8') + elif isinstance(v, date): + v = pretty_date(v) print("{:s}- {}".format(" " * (depth + 1), value)) else: if isinstance(v, unicode): v = v.encode('utf-8') + elif isinstance(v, date): + v = pretty_date(v) print("{:s}{}: {}".format(" " * depth, k, v)) @@ -145,6 +177,7 @@ def get_locale(): # CLI Classes Implementation ------------------------------------------- class TTYHandler(logging.StreamHandler): + """TTY log handler A handler class which prints logging records for a tty. The record is @@ -210,6 +243,7 @@ class TTYHandler(logging.StreamHandler): class ActionsMapParser(BaseActionsMapParser): + """Actions map's Parser for the CLI Provide actions map parsing methods for a CLI usage. The parser for @@ -329,7 +363,7 @@ class ActionsMapParser(BaseActionsMapParser): raise except: logger.exception("unable to parse arguments '%s'", ' '.join(args)) - raise MoulinetteError(errno.EINVAL, m18n.g('error_see_log')) + raise MoulinetteError('error_see_log') else: self.prepare_action_namespace(getattr(ret, '_tid', None), ret) self._parser.dequeue_callbacks(ret) @@ -337,6 +371,7 @@ class ActionsMapParser(BaseActionsMapParser): class Interface(BaseInterface): + """Command-line Interface for the moulinette Initialize an interface connected to the standard input/output @@ -376,7 +411,7 @@ class Interface(BaseInterface): """ if output_as and output_as not in ['json', 'plain', 'none']: - raise MoulinetteError(errno.EINVAL, m18n.g('invalid_usage')) + raise MoulinetteError('invalid_usage') # auto-complete argcomplete.autocomplete(self.actionsmap.parser._parser) @@ -389,7 +424,7 @@ class Interface(BaseInterface): try: ret = self.actionsmap.process(args, timeout=timeout) except (KeyboardInterrupt, EOFError): - raise MoulinetteError(errno.EINTR, m18n.g('operation_interrupted')) + raise MoulinetteError('operation_interrupted') if ret is None or output_as == 'none': return @@ -439,7 +474,7 @@ class Interface(BaseInterface): if confirm: m = message[0].lower() + message[1:] if prompt(m18n.g('confirm', prompt=m)) != value: - raise MoulinetteError(errno.EINVAL, m18n.g('values_mismatch')) + raise MoulinetteError('values_mismatch') return value diff --git a/moulinette/utils/filesystem.py b/moulinette/utils/filesystem.py index 8d229e29..521f93c5 100644 --- a/moulinette/utils/filesystem.py +++ b/moulinette/utils/filesystem.py @@ -23,21 +23,16 @@ def read_file(file_path): # Check file exists if not os.path.isfile(file_path): - raise MoulinetteError(errno.ENOENT, - m18n.g('file_not_exist', path=file_path)) + raise MoulinetteError('file_not_exist', path=file_path) # Open file and read content try: with open(file_path, "r") as f: file_content = f.read() except IOError as e: - raise MoulinetteError(errno.EACCES, - m18n.g('cannot_open_file', - file=file_path, error=str(e))) + raise MoulinetteError('cannot_open_file', file=file_path, error=str(e)) except Exception as e: - raise MoulinetteError(errno.EIO, - m18n.g('error_reading_file', - file=file_path, error=str(e))) + raise MoulinetteError('error_reading_file', file=file_path, error=str(e)) return file_content @@ -57,9 +52,7 @@ def read_json(file_path): try: loaded_json = json.loads(file_content) except ValueError as e: - raise MoulinetteError(errno.EINVAL, - m18n.g('corrupted_json', - ressource=file_path, error=str(e))) + raise MoulinetteError('corrupted_json', ressource=file_path, error=str(e)) return loaded_json @@ -79,9 +72,7 @@ def read_yaml(file_path): try: loaded_yaml = yaml.safe_load(file_content) except ValueError as e: - raise MoulinetteError(errno.EINVAL, - m18n.g('corrupted_yaml', - ressource=file_path, error=str(e))) + raise MoulinetteError('corrupted_yaml', ressource=file_path, error=str(e)) return loaded_yaml @@ -111,13 +102,9 @@ def write_to_file(file_path, data, file_mode="w"): with open(file_path, file_mode) as f: f.write(data) except IOError as e: - raise MoulinetteError(errno.EACCES, - m18n.g('cannot_write_file', - file=file_path, error=str(e))) + raise MoulinetteError('cannot_write_file', file=file_path, error=str(e)) except Exception as e: - raise MoulinetteError(errno.EIO, - m18n.g('error_writing_file', - file=file_path, error=str(e))) + raise MoulinetteError('error_writing_file', file=file_path, error=str(e)) def append_to_file(file_path, data): @@ -152,16 +139,12 @@ def write_to_json(file_path, data): with open(file_path, "w") as f: json.dump(data, f) except IOError as e: - raise MoulinetteError(errno.EACCES, - m18n.g('cannot_write_file', - file=file_path, error=str(e))) + raise MoulinetteError('cannot_write_file', file=file_path, error=str(e)) except Exception as e: - raise MoulinetteError(errno.EIO, - m18n.g('_error_writing_file', - file=file_path, error=str(e))) + raise MoulinetteError('_error_writing_file', file=file_path, error=str(e)) -def mkdir(path, mode=0777, parents=False, uid=None, gid=None, force=False): +def mkdir(path, mode=0o777, parents=False, uid=None, gid=None, force=False): """Create a directory with optional features Create a directory and optionaly set its permissions to mode and its @@ -223,16 +206,14 @@ def chown(path, uid=None, gid=None, recursive=False): try: uid = getpwnam(uid).pw_uid except KeyError: - raise MoulinetteError(errno.EINVAL, - m18n.g('unknown_user', user=uid)) + raise MoulinetteError('unknown_user', user=uid) elif uid is None: uid = -1 if isinstance(gid, basestring): try: gid = grp.getgrnam(gid).gr_gid except KeyError: - raise MoulinetteError(errno.EINVAL, - m18n.g('unknown_group', group=gid)) + raise MoulinetteError('unknown_group', group=gid) elif gid is None: gid = -1 @@ -245,9 +226,7 @@ def chown(path, uid=None, gid=None, recursive=False): for f in files: os.chown(os.path.join(root, f), uid, gid) except Exception as e: - raise MoulinetteError(errno.EIO, - m18n.g('error_changing_file_permissions', - path=path, error=str(e))) + raise MoulinetteError('error_changing_file_permissions', path=path, error=str(e)) def chmod(path, mode, fmode=None, recursive=False): @@ -271,9 +250,7 @@ def chmod(path, mode, fmode=None, recursive=False): for f in files: os.chmod(os.path.join(root, f), fmode) except Exception as e: - raise MoulinetteError(errno.EIO, - m18n.g('error_changing_file_permissions', - path=path, error=str(e))) + raise MoulinetteError('error_changing_file_permissions', path=path, error=str(e)) def rm(path, recursive=False, force=False): @@ -292,6 +269,4 @@ def rm(path, recursive=False, force=False): os.remove(path) except OSError as e: if not force: - raise MoulinetteError(errno.EIO, - m18n.g('error_removing', - path=path, error=str(e))) + raise MoulinetteError('error_removing', path=path, error=str(e)) diff --git a/moulinette/utils/log.py b/moulinette/utils/log.py index 69eb62cf..d57c0b50 100644 --- a/moulinette/utils/log.py +++ b/moulinette/utils/log.py @@ -70,6 +70,7 @@ def getHandlersByClass(classinfo, limit=0): class MoulinetteLogger(Logger): + """Custom logger class Extend base Logger class to provide the SUCCESS custom log level with @@ -153,6 +154,7 @@ def getActionLogger(name=None, logger=None, action_id=None): class ActionFilter(object): + """Extend log record for an optionnal action Filter a given record and look for an `action_id` key. If it is not found diff --git a/moulinette/utils/network.py b/moulinette/utils/network.py index 27e753e3..6620ba71 100644 --- a/moulinette/utils/network.py +++ b/moulinette/utils/network.py @@ -1,7 +1,5 @@ -import errno import json -from moulinette import m18n from moulinette.core import MoulinetteError @@ -25,27 +23,22 @@ def download_text(url, timeout=30, expected_status_code=200): r = requests.get(url, timeout=timeout) # Invalid URL except requests.exceptions.ConnectionError: - raise MoulinetteError(errno.EBADE, - m18n.g('invalid_url', url=url)) + raise MoulinetteError('invalid_url', url=url) # SSL exceptions except requests.exceptions.SSLError: - raise MoulinetteError(errno.EBADE, - m18n.g('download_ssl_error', url=url)) + raise MoulinetteError('download_ssl_error', url=url) # Timeout exceptions except requests.exceptions.Timeout: - raise MoulinetteError(errno.ETIME, - m18n.g('download_timeout', url=url)) + raise MoulinetteError('download_timeout', url=url) # Unknown stuff except Exception as e: - raise MoulinetteError(errno.ECONNRESET, - m18n.g('download_unknown_error', - url=url, error=str(e))) + raise MoulinetteError('download_unknown_error', + url=url, error=str(e)) # Assume error if status code is not 200 (OK) if expected_status_code is not None \ and r.status_code != expected_status_code: - raise MoulinetteError(errno.EBADE, - m18n.g('download_bad_status_code', - url=url, code=str(r.status_code))) + raise MoulinetteError('download_bad_status_code', + url=url, code=str(r.status_code)) return r.text @@ -66,7 +59,6 @@ def download_json(url, timeout=30, expected_status_code=200): try: loaded_json = json.loads(text) except ValueError: - raise MoulinetteError(errno.EINVAL, - m18n.g('corrupted_json', ressource=url)) + raise MoulinetteError('corrupted_json', ressource=url) return loaded_json diff --git a/moulinette/utils/process.py b/moulinette/utils/process.py index 5f1b0ddb..ccfc4873 100644 --- a/moulinette/utils/process.py +++ b/moulinette/utils/process.py @@ -60,12 +60,13 @@ def call_async_output(args, callback, **kwargs): if "stdinfo" in kwargs and kwargs["stdinfo"] is not None: assert len(callback) == 3 stdinfo = kwargs.pop("stdinfo") - os.mkfifo(stdinfo, 0600) + os.mkfifo(stdinfo, 0o600) # Open stdinfo for reading (in a nonblocking way, i.e. even # if command does not write in the stdinfo pipe...) stdinfo_f = os.open(stdinfo, os.O_RDONLY | os.O_NONBLOCK) else: - kwargs.pop("stdinfo") + if "stdinfo" in kwargs: + kwargs.pop("stdinfo") stdinfo = None # Validate callback argument @@ -98,13 +99,15 @@ def call_async_output(args, callback, **kwargs): # this way is not 100% perfect but should do it stdout_consum.process_next_line() stderr_consum.process_next_line() - stdinfo_consum.process_next_line() + if stdinfo: + stdinfo_consum.process_next_line() time.sleep(.1) stderr_reader.join() # clear the queues stdout_consum.process_current_queue() stderr_consum.process_current_queue() - stdinfo_consum.process_current_queue() + if stdinfo: + stdinfo_consum.process_current_queue() else: while not stdout_reader.eof(): stdout_consum.process_current_queue() diff --git a/moulinette/utils/serialize.py b/moulinette/utils/serialize.py index 800cf1b0..06981d6e 100644 --- a/moulinette/utils/serialize.py +++ b/moulinette/utils/serialize.py @@ -1,6 +1,7 @@ import logging from json.encoder import JSONEncoder import datetime +import pytz logger = logging.getLogger('moulinette.utils.serialize') @@ -8,6 +9,7 @@ logger = logging.getLogger('moulinette.utils.serialize') # JSON utilities ------------------------------------------------------- class JSONExtendedEncoder(JSONEncoder): + """Extended JSON encoder Extend default JSON encoder to recognize more types and classes. It @@ -26,7 +28,9 @@ class JSONExtendedEncoder(JSONEncoder): return list(o) # Display the date in its iso format ISO-8601 Internet Profile (RFC 3339) - if isinstance(o, datetime.datetime) or isinstance(o, datetime.date): + if isinstance(o, datetime.date): + if o.tzinfo is None: + o = o.replace(tzinfo=pytz.utc) return o.isoformat() # Return the repr for object that json can't encode diff --git a/moulinette/utils/stream.py b/moulinette/utils/stream.py index b11f7490..96d6fb5d 100644 --- a/moulinette/utils/stream.py +++ b/moulinette/utils/stream.py @@ -8,6 +8,7 @@ from multiprocessing.queues import SimpleQueue # Read from a stream --------------------------------------------------- class AsynchronousFileReader(Process): + """ Helper class to implement asynchronous reading of a file in a separate thread. Pushes read lines on a queue to @@ -74,6 +75,7 @@ class AsynchronousFileReader(Process): class Consummer(object): + def __init__(self, queue, callback): self.queue = queue self.callback = callback diff --git a/setup.py b/setup.py index b9dddbaa..ea4ded50 100755 --- a/setup.py +++ b/setup.py @@ -30,5 +30,6 @@ setup(name='Moulinette', 'moulinette.interfaces', 'moulinette.utils', ], - data_files=[(LOCALES_DIR, locale_files)] + data_files=[(LOCALES_DIR, locale_files)], + tests_require=["pytest", "webtest"], ) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..955fa577 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + +from webtest import TestApp as WebTestApp +from bottle import Bottle +from moulinette.interfaces.api import filter_csrf + + +URLENCODED = 'application/x-www-form-urlencoded' +FORMDATA = 'multipart/form-data' +TEXT = 'text/plain' + +TYPES = [URLENCODED, FORMDATA, TEXT] +SAFE_METHODS = ["HEAD", "GET", "PUT", "DELETE"] + + +app = Bottle(autojson=True) +app.install(filter_csrf) + + +@app.get('/') +def get_hello(): + return "Hello World!\n" + + +@app.post('/') +def post_hello(): + return "OK\n" + + +@app.put('/') +def put_hello(): + return "OK\n" + + +@app.delete('/') +def delete_hello(): + return "OK\n" + + +webtest = WebTestApp(app) + + +def test_get(): + r = webtest.get("/") + assert r.status_code == 200 + + +def test_csrf_post(): + r = webtest.post("/", "test", expect_errors=True) + assert r.status_code == 403 + + +def test_post_json(): + r = webtest.post("/", "test", + headers=[("Content-Type", "application/json")]) + assert r.status_code == 200 + + +def test_csrf_post_text(): + r = webtest.post("/", "test", + headers=[("Content-Type", "text/plain")], + expect_errors=True) + assert r.status_code == 403 + + +def test_csrf_post_urlencoded(): + r = webtest.post("/", "test", + headers=[("Content-Type", + "application/x-www-form-urlencoded")], + expect_errors=True) + assert r.status_code == 403 + + +def test_csrf_post_form(): + r = webtest.post("/", "test", + headers=[("Content-Type", "multipart/form-data")], + expect_errors=True) + assert r.status_code == 403 + + +def test_ok_post_text(): + r = webtest.post("/", "test", + headers=[("Content-Type", "text/plain"), + ("X-Requested-With", "XMLHttpRequest")]) + assert r.status_code == 200 + + +def test_ok_post_urlencoded(): + r = webtest.post("/", "test", + headers=[("Content-Type", + "application/x-www-form-urlencoded"), + ("X-Requested-With", "XMLHttpRequest")]) + assert r.status_code == 200 + + +def test_ok_post_form(): + r = webtest.post("/", "test", + headers=[("Content-Type", "multipart/form-data"), + ("X-Requested-With", "XMLHttpRequest")]) + assert r.status_code == 200